diff --git a/app/assets/stylesheets/pageflow/editor/base.scss b/app/assets/stylesheets/pageflow/editor/base.scss index cd217cca92..86fbab4e36 100644 --- a/app/assets/stylesheets/pageflow/editor/base.scss +++ b/app/assets/stylesheets/pageflow/editor/base.scss @@ -71,6 +71,7 @@ @import "./files"; @import "./confirm_encoding"; @import "./files_gallery"; + @import "./mark_button"; @import "./filtered_files"; @import "./file_type_pills"; @import "./folder_breadcrumb"; diff --git a/app/assets/stylesheets/pageflow/editor/composables.scss b/app/assets/stylesheets/pageflow/editor/composables.scss index fe623b7fcb..248166f4dc 100644 --- a/app/assets/stylesheets/pageflow/editor/composables.scss +++ b/app/assets/stylesheets/pageflow/editor/composables.scss @@ -12,6 +12,16 @@ @include icon-button; } +.secondary_button { + @include simple-button; +} + .destructive_icon_button { @include icon-button("destructive"); } + +// Defined last to override the padding of the buttons above. +.compact_button { + padding-top: space(1); + padding-bottom: space(1); +} diff --git a/app/assets/stylesheets/pageflow/editor/files_gallery.scss b/app/assets/stylesheets/pageflow/editor/files_gallery.scss index 96cafafb70..3c3cfd500b 100644 --- a/app/assets/stylesheets/pageflow/editor/files_gallery.scss +++ b/app/assets/stylesheets/pageflow/editor/files_gallery.scss @@ -40,13 +40,7 @@ ul.files_gallery { background-color: var(--ui-selection-color-lighter); } - > li:hover .explorer_file_item-mark, - .explorer_file_item-mark:focus-visible { - opacity: 0.8; - } - - > li.marking .explorer_file_item-mark { - @include fa-circle-icon; + > li:hover .mark_button:not(.marked) { opacity: 0.8; } @@ -54,35 +48,11 @@ ul.files_gallery { background-color: transparent; } - > li.marking .explorer_file_item-mark:hover { - @include fa-check-circle-icon; - } - - > li.marked .explorer_file_item-mark { - @include fa-check-circle-icon; - - opacity: 1; - } - // Top right corner of the 100px wide thumbnail centered in the item. - .explorer_file_item-mark { - @include fa-check-circle-icon; - + .mark_button { position: absolute; top: space(3); left: calc(50% + 50px - #{space(7)}); - width: space(6); - height: space(6); - padding: 0; - border: none; - background-color: transparent; - color: var(--ui-on-primary-color); - filter: drop-shadow(0 0 2px #00000077); - font-size: 22px; - font-weight: bold; - line-height: space(6); - cursor: pointer; - opacity: 0; } > li.blank_slate, diff --git a/app/assets/stylesheets/pageflow/editor/mark_button.scss b/app/assets/stylesheets/pageflow/editor/mark_button.scss new file mode 100644 index 0000000000..bd24dc0db9 --- /dev/null +++ b/app/assets/stylesheets/pageflow/editor/mark_button.scss @@ -0,0 +1,36 @@ +.mark_button { + @include fa-check-circle-icon; + + width: space(6); + height: space(6); + padding: 0; + border: none; + background-color: transparent; + color: var(--ui-on-primary-color); + filter: drop-shadow(0 0 2px #00000077); + font-size: 22px; + font-weight: bold; + line-height: space(6); + cursor: pointer; + opacity: 0; + + &:focus-visible { + opacity: 0.8; + } + + &.marking { + @include fa-circle-icon; + + opacity: 0.8; + } + + &.marking:hover { + @include fa-check-circle-icon; + } + + &.marked { + @include fa-check-circle-icon; + + opacity: 1; + } +} diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb deleted file mode 100644 index 186a7adf75..0000000000 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -module PageflowScrolled - module Editor - # @api private - class FragmentExtractionsController < ActionController::Base - include Pageflow::EditorController - - before_action do - head :not_found unless @entry.feature_state('fragments') - end - - def create - chapter = Chapter.all_for_revision(@entry.draft).find(params[:chapter_id]) - library = FragmentLibrary.shared_for_account(@entry.account) - - authorize!(:update, library) - library.extract_fragment_from(entry: @entry, chapter:, title: params[:title]) - - head :created - rescue ActiveRecord::RecordNotFound - head :not_found - end - end - end -end diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb new file mode 100644 index 0000000000..0bc9dfa4f0 --- /dev/null +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb @@ -0,0 +1,36 @@ +module PageflowScrolled + module Editor + # @api private + class SectionFragmentExtractionsController < ActionController::Base + include Pageflow::EditorController + + before_action do + head :not_found unless @entry.feature_state('fragments') + end + + def create + library = FragmentLibrary.shared_for_account(@entry.account) + + authorize!(:update, library) + library.extract_sections_from(entry: @entry, + sections: find_sections, + title: params[:title]) + + head :created + rescue ActiveRecord::RecordNotFound + head :not_found + end + + private + + def find_sections + ids = Array(params[:section_ids]).map(&:to_i).uniq + sections = Section.all_for_revision(@entry.draft).where(id: ids).to_a + + raise ActiveRecord::RecordNotFound if ids.empty? || sections.size < ids.size + + sections + end + end + end +end diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb similarity index 85% rename from entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb rename to entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb index d20c88f0f4..c3d5f8b8af 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb @@ -1,7 +1,7 @@ module PageflowScrolled module Editor # @api private - class FragmentInsertionsController < ActionController::Base + class SectionFragmentInsertionsController < ActionController::Base include Pageflow::EditorController before_action do @@ -13,7 +13,7 @@ def create @sections = authorized_scope(:read, FragmentLibrary) .find(fragment_params[:entry_id]) - .copy_fragment_to(fragment_perma_id: fragment_params[:chapter_perma_id], + .copy_sections_to(fragment_perma_id: fragment_params[:chapter_perma_id], entry: @entry, chapter:) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 871aabfa32..d95c3e21ef 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -39,7 +39,7 @@ def content @content ||= RevisionContent.new(entry.draft, include_hidden: true) end - def copy_fragment_to(fragment_perma_id:, entry:, chapter:) + def copy_sections_to(fragment_perma_id:, entry:, chapter:) SectionsCopy .new(source_entry: self.entry, destination_entry: entry) .perform(fragment_chapter(fragment_perma_id).sections, chapter:) @@ -57,13 +57,13 @@ def prepare_content_elements_for(fragment_perma_id:, entry:) end end - def extract_fragment_from(entry:, chapter:, title: nil) + def extract_sections_from(entry:, sections:, title: nil) ActiveRecord::Base.transaction do @entry ||= create_entry(locale: entry.locale) SectionsCopy .new(source_entry: entry, destination_entry: self.entry) - .perform(chapter.sections, chapter: create_fragment_chapter(chapter, title)) + .perform(sections, chapter: create_chapter({'title' => title.presence&.strip}.compact)) end end @@ -107,10 +107,6 @@ def create_entry(locale:) ) end - def create_fragment_chapter(chapter, title) - create_chapter(fragment_configuration(chapter, title)) - end - def create_content_elements_fragment_section(title) chapter = create_chapter({ 'title' => title.presence&.strip, @@ -127,11 +123,6 @@ def create_chapter(configuration) configuration:) end - def fragment_configuration(chapter, title) - title = title.presence&.strip - title ? chapter.configuration.merge('title' => title) : chapter.configuration - end - # @api private class Query include Enumerable diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_insertions/create.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/section_fragment_insertions/create.json.jbuilder similarity index 100% rename from entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_insertions/create.json.jbuilder rename to entry_types/scrolled/app/views/pageflow_scrolled/editor/section_fragment_insertions/create.json.jbuilder diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 3ea840235c..da748bd031 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1287,11 +1287,6 @@ de: save: Speichern chapter_menu_items: copy_permalink: Permalink kopieren - extract_fragment: Zu geteilten Fragmenten hinzufügen... - extract_fragment_failed: Das Kapitel konnte nicht zu den geteilten Fragmenten hinzugefügt werden. - extract_fragment_forbidden: Sie dürfen den geteilten Fragmenten dieses Kontos keine Kapitel hinzufügen. - extracted_fragment: Zu geteilten Fragmenten hinzugefügt - extracting_fragment: Wird zu geteilten Fragmenten hinzugefügt... move_to_main: In Kapitel umwandeln move_to_excursions: In Exkurs umwandeln destroy_chapter_menu_item: @@ -1445,9 +1440,14 @@ de: remove: Entfernen entry_outline: add_chapter: Neues Kapitel + end_selection: Auswahl beenden header: Gliederung finish_reorder_chapters: Umsortieren beenden reorder_chapters: "Kapitel umsortieren..." + select_sections: "Abschnitte auswählen..." + selected_sections: + one: 1 Abschnitt ausgewählt + other: "%{count} Abschnitte ausgewählt" trash: Gelöschte Inhalte entry_select: no_matches: Keine passenden Beiträge @@ -1467,6 +1467,12 @@ de: name: Name des Fragments shared_hint: Fragmente werden mit allen Beiträgen des Kontos und allen Mitgliedern mit Bearbeitungsrechten geteilt. submit: Hinzufügen + extract_sections_fragment_menu_item: + extracted: Zu geteilten Fragmenten hinzugefügt + extracting: Wird zu geteilten Fragmenten hinzugefügt... + failed: Die Abschnitte konnten nicht zu den geteilten Fragmenten hinzugefügt werden. + forbidden: Sie dürfen den geteilten Fragmenten dieses Kontos keine Abschnitte hinzufügen. + label: Zu geteilten Fragmenten hinzufügen... fragment_library: blank_slate: Noch keine Fragmente vorhanden. cancel: Abbrechen @@ -1493,6 +1499,7 @@ de: cutoff: Paywall Grenze drag_hint: Ziehen, um den Abschnitt zu verschieben hidden: Nur im Editor sichtbar + mark: Abschnitt markieren save_error: Beim Speichern des Abschnitts ist ein Fehler aufgetreten. transitions: beforeAfter: Statische Hintergründe @@ -1555,6 +1562,8 @@ de: header_insertPosition: Abschnitt verschieben header_sectionPart: Element verschieben hint: Wähle die Position aus, an die verschoben werden soll. + select_multiple_content_elements_menu_item: + label: "Mehrere auswählen..." selectable_storyline_item: blank_slate: Keine Kapitel blank_slate_excursions: Keine Exkurse @@ -1805,6 +1814,8 @@ de: tabs: activity: Letzte Aktivität multi_selection_view: + end_selection_mode: Fertig + selection_mode_hint: Elemente in der Vorschau anklicken, um sie zur Auswahl hinzuzufügen oder daraus zu entfernen. tab: one: 1 Element other: "%{count} Elemente" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index d88e419a4a..43ed4baf57 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1269,11 +1269,6 @@ en: save: Save chapter_menu_items: copy_permalink: Copy permalink - extract_fragment: Add to shared fragments... - extract_fragment_failed: The chapter could not be added to the shared fragments. - extract_fragment_forbidden: You are not allowed to add chapters to the shared fragments of this account. - extracted_fragment: Added to shared fragments - extracting_fragment: Adding to shared fragments... move_to_main: Turn into chapter move_to_excursions: Turn into excursion destroy_chapter_menu_item: @@ -1427,9 +1422,14 @@ en: remove: Remove entry_outline: add_chapter: New chapter + end_selection: End selection header: Outline finish_reorder_chapters: Finish reordering reorder_chapters: "Reorder chapters..." + select_sections: "Select sections..." + selected_sections: + one: 1 section selected + other: "%{count} sections selected" trash: Deleted content entry_select: no_matches: No matching stories @@ -1449,6 +1449,12 @@ en: name: Name of the fragment shared_hint: Fragments are shared across all entries of the account and all its members with editor access. submit: Add + extract_sections_fragment_menu_item: + extracted: Added to shared fragments + extracting: Adding to shared fragments... + failed: The sections could not be added to the shared fragments. + forbidden: You are not allowed to add sections to the shared fragments of this account. + label: Add to shared fragments... fragment_library: blank_slate: No fragments yet. cancel: Cancel @@ -1475,6 +1481,7 @@ en: cutoff: Paywall cutoff drag_hint: Drag to move section hidden: Only visible in editor + mark: Mark section save_error: There was an error while saving this section. transitions: beforeAfter: Static Backgrounds @@ -1537,6 +1544,8 @@ en: header_insertPosition: Move section header_sectionPart: Move element hint: Select the position to move to. + select_multiple_content_elements_menu_item: + label: "Select multiple..." selectable_storyline_item: blank_slate: No chapters blank_slate_excursions: No excursions @@ -1785,6 +1794,8 @@ en: tabs: activity: Latest activity multi_selection_view: + end_selection_mode: Done + selection_mode_hint: Click elements in the preview to add them to or remove them from the selection. tab: one: 1 Element other: "%{count} Elements" diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 4f2b954db0..ef73c0d040 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -3,6 +3,7 @@ resources :fragment_libraries, only: [:index] resources :content_element_fragment_extractions, only: [:create] resources :content_element_fragment_preparations, only: [:create] + resources :section_fragment_extractions, only: [:create] resources :linkable_entries, only: [:index, :show] resources :trash_items, only: [:index, :destroy] do member do @@ -29,8 +30,7 @@ put :order end - resources :fragment_insertions, only: [:create] - resources :fragment_extractions, only: [:create] + resources :section_fragment_insertions, only: [:create] resources :sections, only: [:create, :update, :destroy] do collection do diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index 8d00251a92..0bd26d1721 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -625,6 +625,254 @@ describe('PreviewMessageController', () => { ); }); + it('sends SELECT message for selection mode on startContentElementSelection event', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SELECT') { + resolve(event.data); + } + }); + entry.trigger('startContentElementSelection', entry.contentElements.first()); + })).resolves.toMatchObject({ + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + } + }); + }); + + it('sends END_SELECTION_MODE message on endSelectionMode event', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({}) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'END_SELECTION_MODE') { + resolve(event.data); + } + }); + entry.trigger('endSelectionMode'); + })).resolves.toMatchObject({type: 'END_SELECTION_MODE'}); + }); + + it('passes selection mode to multi selection route', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + } + }, '*'); + })).resolves.toBe( + '/scrolled/multi_selection?payload=' + + encodeURIComponent(JSON.stringify({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + })) + ); + }); + + describe('marked sections', () => { + function setup() { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}, {id: 2}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return {entry, editor, iframeWindow}; + } + + function nextSelectMessage(iframeWindow) { + return new Promise(resolve => { + function handler(event) { + if (event.data.type === 'SELECT') { + iframeWindow.removeEventListener('message', handler); + resolve(event.data.payload); + } + } + + iframeWindow.addEventListener('message', handler); + }); + } + + it('sends SELECT message with marked sections', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + const message = nextSelectMessage(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + + expect(await message).toEqual({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + }); + + it('sends SELECT message with empty selection when marking ends', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + await tick(); + + const message = nextSelectMessage(iframeWindow); + entry.markedSections.stop(); + + expect(await message).toBeNull(); + }); + + it('marks sections of SELECTED multi selection of section settings', async () => { + const {entry, editor} = setup(); + + await new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + } + }, '*'); + }); + + expect(entry.markedSections.pluck('id')).toEqual([1, 2]); + }); + + it('navigates to outline on SELECTED multi selection of section settings', () => { + const {editor} = setup(); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + } + }, '*'); + })).resolves.toBe('/'); + }); + + it('keeps marks on SELECTED multi selection of sections', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({ + type: 'SELECTED', + payload: {type: 'multiSelection', itemType: 'section', items: [{id: 1}]} + }, '*'); + await tick(); + + expect(entry.markedSections.pluck('id')).toEqual([1]); + }); + + it('keeps marks on SELECTED message for section', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({type: 'SELECTED', payload: {type: 'section', id: 2}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(true); + }); + + it('ends marking on SELECTED message for section settings', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('does not reset selection of preview when marking ends because of it', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + await tick(); + const payloads = []; + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SELECT') { + payloads.push(event.data.payload); + } + }); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + await tick(); + + expect(payloads).toEqual([]); + }); + + it('ends marking on SELECTED message for empty selection', async () => { + const {entry} = setup(); + entry.markedSections.start(); + + window.postMessage({type: 'SELECTED', payload: {}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('remembers section selected in preview', async () => { + const {entry} = setup(); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + + expect(entry.get('selectedSectionId')).toBe(2); + }); + + it('forgets selected section when other item is selected in preview', async () => { + const {entry} = setup(); + + window.postMessage({type: 'SELECTED', payload: {type: 'section', id: 2}}, '*'); + window.postMessage({type: 'SELECTED', payload: {type: 'widget', id: 'some'}}, '*'); + await tick(); + + expect(entry.get('selectedSectionId')).toBeNull(); + }); + }); + it('navigates to edit section route on SELECTED message for section settings', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { diff --git a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js index 09cbc2004f..8e26c66091 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js @@ -3,6 +3,7 @@ import 'editor/config'; import {SideBarController} from 'editor/controllers/SideBarController'; import {CommentsView} from 'editor/views/CommentsView'; import {CommentActivityView} from 'editor/views/CommentActivityView'; +import {MultiSelectionView} from 'editor/views/MultiSelectionView'; import {factories} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -38,6 +39,29 @@ describe('SideBarController', () => { }); }); + describe('#multiSelection', () => { + it('passes items and selection mode to MultiSelectionView', () => { + const entry = createEntry({}); + + const region = {show: jest.fn()}; + const controller = new SideBarController({region, entry}); + + controller.multiSelection(encodeURIComponent(JSON.stringify({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + }))); + + const shown = region.show.mock.calls[0][0]; + expect(shown).toBeInstanceOf(MultiSelectionView); + expect(shown.options).toMatchObject({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + }); + }); + }); + describe('#commentActivity', () => { it('shows a CommentActivityView in the region', () => { const entry = createEntry({}); diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js index 4f01008f3a..6f99504915 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -290,7 +290,7 @@ describe('Chapter', () => { function respond(server, sections) { server.respond( - 'POST', '/editor/entries/1/scrolled/chapters/10/fragment_insertions', + 'POST', '/editor/entries/1/scrolled/chapters/10/section_fragment_insertions', [201, {'Content-Type': 'application/json'}, JSON.stringify(sections)] ); server.respond( @@ -306,7 +306,7 @@ describe('Chapter', () => { expect(requests[0].method).toBe('POST'); expect(requests[0].url) - .toBe('/editor/entries/1/scrolled/chapters/10/fragment_insertions'); + .toBe('/editor/entries/1/scrolled/chapters/10/section_fragment_insertions'); expect(requests[0].requestBody) .toContain('fragment%5Bchapter_perma_id%5D=700'); }); @@ -440,52 +440,6 @@ describe('Chapter', () => { }); }); - describe('#extractToFragmentLibrary', () => { - beforeEach(() => { - testContext.entry = factories.entry(ScrolledEntry, {id: 1}, { - entryTypeSeed: normalizeSeed({ - chapters: [{id: 10}] - }) - }); - }); - - setupGlobals({ - entry: () => testContext.entry - }); - - useFakeXhr(() => testContext); - - it('posts to fragment extractions of chapter', () => { - const {entry, requests} = testContext; - - entry.chapters.first().extractToFragmentLibrary(); - - expect(requests[0].method).toBe('POST'); - expect(requests[0].url) - .toBe('/editor/entries/1/scrolled/chapters/10/fragment_extractions'); - }); - - it('posts given title', () => { - const {entry, requests} = testContext; - - entry.chapters.first().extractToFragmentLibrary({title: 'Opening'}); - - expect(requests[0].requestBody).toContain('title=Opening'); - }); - - it('resolves once library responds without body', async () => { - const {entry, server} = testContext; - - const extraction = entry.chapters.first().extractToFragmentLibrary(); - server.respond( - 'POST', '/editor/entries/1/scrolled/chapters/10/fragment_extractions', - [201, {}, ''] - ); - - await expect(extraction).resolves.toBeUndefined(); - }); - }); - describe('#moveSection', () => { describe('within same chapter', () => { beforeEach(() => { diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js new file mode 100644 index 0000000000..a73caa0efd --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js @@ -0,0 +1,56 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {setupGlobals} from 'pageflow/testHelpers'; +import {factories, normalizeSeed, useFakeXhr} from 'support'; + +describe('ScrolledEntry#extractSectionsToFragmentLibrary', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + testContext.entry = factories.entry(ScrolledEntry, {id: 1}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 10}, {id: 11}] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('posts to section fragment extractions of entry', () => { + const {entry, requests} = testContext; + + entry.extractSectionsToFragmentLibrary([entry.sections.get(10)]); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url) + .toBe('/editor/entries/1/scrolled/section_fragment_extractions'); + }); + + it('posts ids of sections and title', () => { + const {entry, requests} = testContext; + + entry.extractSectionsToFragmentLibrary([entry.sections.get(10), entry.sections.get(11)], + {title: 'Intro'}); + + expect(new URLSearchParams(requests[0].requestBody).getAll('section_ids[]')) + .toEqual(['10', '11']); + expect(new URLSearchParams(requests[0].requestBody).get('title')).toEqual('Intro'); + }); + + it('resolves once library responds without body', async () => { + const {entry, server} = testContext; + + const extraction = entry.extractSectionsToFragmentLibrary([entry.sections.get(10)]); + server.respond( + 'POST', '/editor/entries/1/scrolled/section_fragment_extractions', + [201, {}, ''] + ); + + await expect(extraction).resolves.toBeUndefined(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js new file mode 100644 index 0000000000..61e7a47b63 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js @@ -0,0 +1,28 @@ +import {useEditorGlobals} from 'support'; + +describe('ScrolledEntry#markedSections', () => { + const {createEntry} = useEditorGlobals(); + + it('is inactive by default', () => { + const entry = createEntry({sections: [{id: 10}]}); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('can mark sections', () => { + const entry = createEntry({sections: [{id: 10}]}); + + entry.markedSections.toggle(entry.sections.get(10)); + + expect(entry.markedSections.includes(entry.sections.get(10))).toBe(true); + }); + + it('drops removed sections', () => { + const entry = createEntry({sections: [{id: 10}, {id: 11}]}); + entry.markedSections.add([entry.sections.get(10), entry.sections.get(11)]); + + entry.sections.remove(entry.sections.get(11)); + + expect(entry.markedSections.models).toEqual([entry.sections.get(10)]); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js index 11cbb5ea72..966a36e727 100644 --- a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js @@ -1,13 +1,9 @@ import { CopyPermalinkMenuItem, - ExtractFragmentMenuItem, ToggleExcursionMenuItem, DestroyChapterMenuItem } from 'editor/models/chapterMenuItems'; -import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; - -import {app} from 'pageflow/editor'; import {useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -16,11 +12,6 @@ describe('ChapterMenuItems', () => { 'pageflow_scrolled.editor.chapter_menu_items.copy_permalink': 'Copy permalink', 'pageflow_scrolled.editor.chapter_menu_items.move_to_main': 'Move to main', 'pageflow_scrolled.editor.chapter_menu_items.move_to_excursions': 'Move to excursions', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment': 'Add to shared fragments', - 'pageflow_scrolled.editor.chapter_menu_items.extracting_fragment': 'Adding to shared fragments...', - 'pageflow_scrolled.editor.chapter_menu_items.extracted_fragment': 'Added to shared fragments', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment_failed': 'Could not add chapter.', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment_forbidden': 'Not allowed.', 'pageflow_scrolled.editor.destroy_chapter_menu_item.destroy': 'Delete chapter', 'pageflow_scrolled.editor.destroy_chapter_menu_item.confirm_destroy': 'Really delete this chapter?' }); @@ -90,109 +81,6 @@ describe('ChapterMenuItems', () => { }); }); - describe('ExtractFragmentMenuItem', () => { - beforeEach(() => { - jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); - }); - - afterEach(() => jest.restoreAllMocks()); - - function createMenuItem(extraction, configuration = {}) { - const entry = createEntry({chapters: [{id: 1, configuration}]}); - const chapter = entry.chapters.get(1); - chapter.extractToFragmentLibrary = jest.fn().mockReturnValue(extraction); - - return {chapter, menuItem: new ExtractFragmentMenuItem({}, {chapter})}; - } - - function pendingExtraction() { - return new Promise(() => {}); - } - - function submitName(name) { - return ExtractFragmentDialogView.show.mock.calls[0][0].onSubmit(name); - } - - it('has Add to shared fragments label', () => { - const {menuItem} = createMenuItem(pendingExtraction()); - - expect(menuItem.get('label')).toBe('Add to shared fragments'); - }); - - it('opens name dialog prefilled with chapter title when selected', () => { - const {menuItem} = createMenuItem(pendingExtraction(), {title: 'Intro'}); - - menuItem.selected(); - - expect(ExtractFragmentDialogView.show) - .toHaveBeenCalledWith(expect.objectContaining({title: 'Intro'})); - }); - - it('extracts chapter with submitted name', () => { - const {chapter, menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - submitName('Opening with video'); - - expect(chapter.extractToFragmentLibrary) - .toHaveBeenCalledWith({title: 'Opening with video'}); - }); - - it('does not extract chapter before name is submitted', () => { - const {chapter, menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - - expect(chapter.extractToFragmentLibrary).not.toHaveBeenCalled(); - }); - - it('is disabled while extraction is pending', () => { - const {menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - submitName('Intro'); - - expect(menuItem.get('label')).toBe('Adding to shared fragments...'); - expect(menuItem.get('disabled')).toBe(true); - }); - - it('stays disabled once chapter has been added', async () => { - const {menuItem} = createMenuItem(Promise.resolve()); - - menuItem.selected(); - await submitName('Intro'); - - expect(menuItem.get('label')).toBe('Added to shared fragments'); - expect(menuItem.get('disabled')).toBe(true); - }); - - it('is enabled again and reports error when extraction fails', async () => { - const {menuItem} = createMenuItem(Promise.reject({status: 500})); - const errors = []; - app.on('error', error => errors.push(error.message)); - - menuItem.selected(); - await submitName('Intro'); - - expect(menuItem.get('label')).toBe('Add to shared fragments'); - expect(menuItem.get('disabled')).toBe(false); - expect(errors).toEqual(['Could not add chapter.']); - app.off('error'); - }); - - it('reports missing permission', async () => { - const {menuItem} = createMenuItem(Promise.reject({status: 403})); - const errors = []; - app.on('error', error => errors.push(error.message)); - - menuItem.selected(); - await submitName('Intro'); - - expect(errors).toEqual(['Not allowed.']); - app.off('error'); - }); - }); - describe('DestroyChapterMenuItem', () => { it('has Delete chapter label', () => { const entry = createEntry({chapters: [{id: 1}]}); diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index ef5f1f6b94..0d0c63f4c3 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -3,7 +3,8 @@ import { DestroyContentElementsMenuItem, DuplicateContentElementMenuItem, ExtractContentElementsFragmentMenuItem, - MoveContentElementMenuItem + MoveContentElementMenuItem, + SelectMultipleContentElementsMenuItem } from 'editor/models/contentElementMenuItems'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; @@ -614,4 +615,35 @@ describe('ContentElementMenuItems', () => { app.off('error'); }); }); + + describe('SelectMultipleContentElementsMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label': + 'Select multiple' + }); + + it('has Select multiple label', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const contentElement = entry.contentElements.get(1); + + const menuItem = new SelectMultipleContentElementsMenuItem({}, {entry, contentElement}); + + expect(menuItem.get('label')).toBe('Select multiple'); + }); + + it('starts content element selection when selected', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const contentElement = entry.contentElements.get(1); + const listener = jest.fn(); + entry.on('startContentElementSelection', listener); + + new SelectMultipleContentElementsMenuItem({}, {entry, contentElement}).selected(); + + expect(listener).toHaveBeenCalledWith(contentElement); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js index e46b8861f5..9ff82877db 100644 --- a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -6,11 +6,15 @@ import { CutoffSectionMenuItem, CopyPermalinkMenuItem, DestroySectionMenuItem, - MoveSectionMenuItem + MoveSectionMenuItem, + ExtractSectionsFragmentMenuItem, + createSectionMenuItems } from 'editor/models/sectionMenuItems'; import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; +import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; -import {useFakeTranslations} from 'pageflow/testHelpers'; +import {app} from 'pageflow/editor'; +import {useFakeFeatures, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({ @@ -31,7 +35,17 @@ describe('SectionMenuItems', () => { 'pageflow_scrolled.editor.section_menu_items.copy_permalink': 'Copy permalink', 'pageflow_scrolled.editor.section_menu_items.move': 'Move...', 'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section', - 'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?' + 'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.label': + 'Add to shared fragments', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.extracting': + 'Adding to shared fragments...', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.extracted': + 'Added to shared fragments', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.failed': + 'Could not add sections.', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.forbidden': + 'Not allowed.' }); const {createEntry} = useEditorGlobals(); @@ -358,4 +372,133 @@ describe('SectionMenuItems', () => { expect(targetChapter.moveSection).toHaveBeenCalledWith(section); }); }); + + describe('ExtractSectionsFragmentMenuItem', () => { + beforeEach(() => { + jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + function createMenuItem(extraction) { + const entry = createEntry({sections: [{id: 1}, {id: 2}]}); + entry.extractSectionsToFragmentLibrary = jest.fn().mockReturnValue(extraction); + const sections = [entry.sections.get(1), entry.sections.get(2)]; + + return {entry, sections, menuItem: new ExtractSectionsFragmentMenuItem({}, {entry, sections})}; + } + + function pendingExtraction() { + return new Promise(() => {}); + } + + function submitName(name) { + return ExtractFragmentDialogView.show.mock.calls[0][0].onSubmit(name); + } + + it('has Add to shared fragments label', () => { + const {menuItem} = createMenuItem(pendingExtraction()); + + expect(menuItem.get('label')).toBe('Add to shared fragments'); + }); + + it('extracts sections with submitted name', () => { + const {entry, sections, menuItem} = createMenuItem(pendingExtraction()); + + menuItem.selected(); + submitName('Intro'); + + expect(entry.extractSectionsToFragmentLibrary) + .toHaveBeenCalledWith(sections, {title: 'Intro'}); + }); + + it('is disabled while extraction is pending', () => { + const {menuItem} = createMenuItem(pendingExtraction()); + + menuItem.selected(); + submitName('Intro'); + + expect(menuItem.get('label')).toBe('Adding to shared fragments...'); + expect(menuItem.get('disabled')).toBe(true); + }); + + it('stays disabled once sections have been added', async () => { + const {menuItem} = createMenuItem(Promise.resolve()); + + menuItem.selected(); + await submitName('Intro'); + + expect(menuItem.get('label')).toBe('Added to shared fragments'); + expect(menuItem.get('disabled')).toBe(true); + }); + + it('is enabled again and reports error when extraction fails', async () => { + const {menuItem} = createMenuItem(Promise.reject({status: 500})); + const errors = []; + app.on('error', error => errors.push(error.message)); + + menuItem.selected(); + await submitName('Intro'); + + expect(menuItem.get('label')).toBe('Add to shared fragments'); + expect(menuItem.get('disabled')).toBe(false); + expect(errors).toEqual(['Could not add sections.']); + app.off('error'); + }); + + it('reports missing permission', async () => { + const {menuItem} = createMenuItem(Promise.reject({status: 403})); + const errors = []; + app.on('error', error => errors.push(error.message)); + + menuItem.selected(); + await submitName('Intro'); + + expect(errors).toEqual(['Not allowed.']); + app.off('error'); + }); + }); + + describe('createSectionMenuItems', () => { + it('does not offer adding section to shared fragments by default', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.map(item => item.get('label'))).not.toContain('Add to shared fragments'); + }); + + it('separates copy permalink item by default', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.find(item => item.get('label') === 'Copy permalink').get('separated')) + .toBe(true); + }); + + describe('with fragments feature', () => { + useFakeFeatures('editor', ['fragments']); + + it('offers adding section to shared fragments', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + const item = items.find(item => item.get('label') === 'Add to shared fragments'); + + expect(item.sections).toEqual([entry.sections.get(1)]); + }); + + it('separates adding to shared fragments instead of copy permalink item', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.find(item => item.get('label') === 'Add to shared fragments') + .get('separated')).toBe(true); + expect(items.find(item => item.get('label') === 'Copy permalink').get('separated')) + .toBeFalsy(); + }); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js index 4ec1931276..0ca21dfff1 100644 --- a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js @@ -1,4 +1,6 @@ import {ChapterItemView} from 'editor/views/ChapterItemView'; +import {ListSelection} from 'pageflow/editor'; +import {SortableCollectionView} from 'pageflow/ui'; import {FragmentLibraryDialogView} from 'editor/views/FragmentLibraryDialogView'; import {useEditorGlobals, useFakeXhr} from 'support'; @@ -129,6 +131,39 @@ describe('ChapterItemView', () => { expect(screen.queryByRole('button', {name: 'Insert fragment'})).toBeNull(); }); + describe('with section selection', () => { + function renderChapterItem() { + const entry = createEntry({chapters: [{id: 1}], sections: [{id: 10, chapterId: 1}]}); + const markedSections = new ListSelection(); + const view = new ChapterItemView({entry, model: entry.chapters.get(1), markedSections}); + + render(view); + + return {entry, markedSections}; + } + + afterEach(() => jest.restoreAllMocks()); + + it('disables sorting of sections while selection is active', () => { + const disableSorting = jest.spyOn(SortableCollectionView.prototype, 'disableSorting'); + const {markedSections} = renderChapterItem(); + + markedSections.start(); + + expect(disableSorting).toHaveBeenCalled(); + }); + + it('enables sorting of sections again when selection ends', () => { + const {markedSections} = renderChapterItem(); + markedSections.start(); + const enableSorting = jest.spyOn(SortableCollectionView.prototype, 'enableSorting'); + + markedSections.stop(); + + expect(enableSorting).toHaveBeenCalled(); + }); + }); + describe('with fragments feature', () => { useFakeFeatures('editor', ['fragments']); diff --git a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js index c16fe9439e..4a202949b5 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js @@ -1,6 +1,6 @@ import {EditChapterView} from 'editor/views/EditChapterView'; -import {ConfigurationEditor, useFakeFeatures, useFakeTranslations} from 'pageflow/testHelpers'; +import {ConfigurationEditor, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; describe('EditChapterView', () => { @@ -8,42 +8,7 @@ describe('EditChapterView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.edit_chapter.tabs.chapter': 'Chapter', - 'pageflow_scrolled.editor.edit_chapter.tabs.excursion': 'Excursion', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment': 'Add to shared fragments' - }); - - it('does not offer adding chapter to shared fragments without fragments feature', () => { - const entry = createEntry({ - storylines: [{id: 100, role: 'main'}], - chapters: [{id: 1, storylineId: 100}] - }); - - const view = new EditChapterView({ - model: entry.chapters.get(1), - entry - }); - - expect(view.getActionsMenuItems().map(item => item.get('label'))) - .not.toContain('Add to shared fragments'); - }); - - describe('with fragments feature', () => { - useFakeFeatures('editor', ['fragments']); - - it('offers adding chapter to shared fragments', () => { - const entry = createEntry({ - storylines: [{id: 100, role: 'main'}], - chapters: [{id: 1, storylineId: 100}] - }); - - const view = new EditChapterView({ - model: entry.chapters.get(1), - entry - }); - - expect(view.getActionsMenuItems().map(item => item.get('label'))) - .toContain('Add to shared fragments'); - }); + 'pageflow_scrolled.editor.edit_chapter.tabs.excursion': 'Excursion' }); it('uses chapter tab for main storyline chapters', () => { diff --git a/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js index 06a8cc2206..c62cff5e8a 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js @@ -74,7 +74,21 @@ describe('EditContentElementView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.label': - 'Add to shared fragments' + 'Add to shared fragments', + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label': + 'Select multiple' + }); + + it('offers selecting multiple content elements', () => { + const view = createView(); + const listener = jest.fn(); + view.options.entry.on('startContentElementSelection', listener); + + view.getActionsMenuItems() + .find(item => item.get('label') === 'Select multiple') + .selected(); + + expect(listener).toHaveBeenCalledWith(view.model); }); function createView() { diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index b85b6c3d28..2683045cdc 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -1,9 +1,12 @@ import {EntryOutlineView} from 'editor/views/EntryOutlineView'; +import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; import {useEditorGlobals} from 'support'; import userEvent from '@testing-library/user-event'; import {screen, within} from '@testing-library/dom'; -import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import { + useFakeFeatures, useFakeTranslations, renderBackboneView as render +} from 'pageflow/testHelpers'; import '@testing-library/jest-dom/extend-expect'; describe('EntryOutlineView', () => { @@ -11,7 +14,17 @@ describe('EntryOutlineView', () => { 'pageflow_scrolled.editor.storylines_tabs.main': 'Main', 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions', 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.', - 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content', + 'pageflow_scrolled.editor.entry_outline.reorder_chapters': 'Reorder chapters', + 'pageflow_scrolled.editor.entry_outline.finish_reorder_chapters': 'Finish reordering', + 'pageflow_scrolled.editor.section_item.mark': 'Mark section', + 'pageflow_scrolled.editor.entry_outline.select_sections': 'Select sections', + 'pageflow_scrolled.editor.entry_outline.end_selection': 'End selection', + 'pageflow_scrolled.editor.entry_outline.selected_sections.one': '1 section selected', + 'pageflow_scrolled.editor.entry_outline.selected_sections.other': + '%{count} sections selected', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.label': + 'Add to shared fragments' }); const {createEntry} = useEditorGlobals(); @@ -389,4 +402,255 @@ describe('EntryOutlineView', () => { expect(screen.getByRole('button', {name: 'Deleted content'})).toBeInTheDocument(); }); + + it('ends chapter reordering when switching storyline tabs', async () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [ + {id: 1, permaId: 100, position: 0, storylineId: 10}, + {id: 2, permaId: 101, position: 0, storylineId: 11} + ] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + await user.click(screen.getByRole('tab', {name: 'Excursions'})); + + expect(screen.getByRole('link', {name: 'Reorder chapters'})).toBeInTheDocument(); + }); + + it('does not let sections be marked by default', () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + + render(new EntryOutlineView({entry})); + + expect(screen.queryByRole('button', {name: 'Mark section'})).toBeNull(); + }); + + it('does not offer selecting sections by default', () => { + render(new EntryOutlineView({entry: createEntry({})})); + + expect(screen.queryByRole('link', {name: 'Select sections'})).toBeNull(); + }); + + describe('with fragments feature', () => { + useFakeFeatures('editor', ['fragments']); + + it('lets sections be marked', async () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('button', {name: 'Mark section'})); + + expect(screen.getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'true'); + }); + + function createEntryWithSections() { + return createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [ + {id: 10, permaId: 1000, chapterId: 1, position: 0}, + {id: 11, permaId: 1100, chapterId: 1, position: 1} + ] + }); + } + + it('does not show selection bar while no section is marked', () => { + render(new EntryOutlineView({entry: createEntryWithSections()})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('shows number of marked sections in selection bar', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(first); + await user.click(second); + + expect(screen.getByText('2 sections selected')).toBeInTheDocument(); + }); + + it('shows selection bar when selecting sections from menu', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + + expect(screen.getByText('0 sections selected')).toBeInTheDocument(); + }); + + it('offers ending selection in menu while selecting sections', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + await user.click(screen.getByRole('link', {name: 'End selection'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getByRole('link', {name: 'Select sections'})).toBeInTheDocument(); + }); + + it('renders selection bar between tab bar and chapters', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + const bar = screen.getByText('0 sections selected'); + + expect(screen.getByRole('tablist').compareDocumentPosition(bar) & + Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('tabpanel').compareDocumentPosition(bar) & + Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + }); + + it('ends section selection when switching storyline tabs', async () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [{id: 1, permaId: 100, position: 0, storylineId: 10}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('button', {name: 'Mark section'})); + await user.click(screen.getByRole('tab', {name: 'Excursions'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('ends chapter reordering when selecting sections', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + + expect(screen.getByRole('link', {name: 'Reorder chapters'})).toBeInTheDocument(); + }); + + it('ends section selection when reordering chapters', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('unmarks sections when selection is ended', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'End selection'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('shows sections marked before outline was rendered', () => { + const entry = createEntryWithSections(); + entry.markedSections.add(entry.sections.get(10)); + + render(new EntryOutlineView({entry})); + + expect(screen.getByText('1 section selected')).toBeInTheDocument(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('ends marking when outline is closed', () => { + const entry = createEntryWithSections(); + const view = new EntryOutlineView({entry}); + render(view); + entry.markedSections.add(entry.sections.get(10)); + + view.close(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('drops removed sections from selection', async () => { + const entry = createEntryWithSections(); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(first); + await user.click(second); + entry.sections.remove(entry.sections.get(11)); + + expect(screen.getByText('1 section selected')).toBeInTheDocument(); + }); + + it('adds marked sections to shared fragments', async () => { + const entry = createEntryWithSections(); + entry.extractSectionsToFragmentLibrary = jest.fn(() => new Promise(() => {})); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(second); + await user.click(first); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + show.mock.calls[0][0].onSubmit('Intro'); + + expect(entry.extractSectionsToFragmentLibrary).toHaveBeenCalledWith( + [entry.sections.get(11), entry.sections.get(10)], + {title: 'Intro'} + ); + show.mockRestore(); + }); + + it('keeps selection while name dialog has not been submitted', async () => { + const entry = createEntryWithSections(); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'true'); + show.mockRestore(); + }); + + it('ends selection when name dialog is submitted', async () => { + const entry = createEntryWithSections(); + entry.extractSectionsToFragmentLibrary = jest.fn(() => new Promise(() => {})); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + show.mock.calls[0][0].onSubmit('Intro'); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'false'); + show.mockRestore(); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js b/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js index 105a9bae84..fff4428b4d 100644 --- a/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js @@ -27,14 +27,18 @@ describe('MultiSelectionView', () => { 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.label': 'Add to shared fragments', 'pageflow.editor.templates.back_button_decorator.outline': 'Outline', - 'pageflow.editor.views.edit_configuration.actions': 'Actions' + 'pageflow.editor.views.edit_configuration.actions': 'Actions', + 'pageflow_scrolled.editor.multi_selection_view.selection_mode_hint': + 'Click elements in the preview to add them.', + 'pageflow_scrolled.editor.multi_selection_view.end_selection_mode': 'Done' }); - function buildView(entry, items) { + function buildView(entry, items, options) { return new MultiSelectionView({ entry, itemType: 'contentElement', - items + items, + ...options }); } @@ -175,4 +179,50 @@ describe('MultiSelectionView', () => { .toHaveBeenCalledWith(items, {title: 'Quote'}); }); }); + + it('does not render selection mode hint by default', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}, {id: 2, typeName: 'heading'}] + }); + + const {queryByRole} = renderBackboneView(buildView(entry, [{id: 1}, {id: 2}])); + + expect(queryByRole('button', {name: 'Done'})).toBeNull(); + }); + + it('renders selection mode hint in selection mode', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + + const {getByText} = renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + + expect(getByText('Click elements in the preview to add them.')).toBeInTheDocument(); + }); + + it('renders selection mode hint below tab bar', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + + const {getByRole, getByText} = + renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + + expect(getByRole('tablist').compareDocumentPosition( + getByText('Click elements in the preview to add them.') + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('ends selection mode when done button is clicked', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + const listener = jest.fn(); + entry.on('endSelectionMode', listener); + + const {getByRole} = renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + fireEvent.click(getByRole('button', {name: 'Done'})); + + expect(listener).toHaveBeenCalled(); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js index 7d4c0ae825..7ae56079ef 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -1,7 +1,9 @@ import {SectionItemView} from 'editor/views/SectionItemView'; +import {ListSelection} from 'pageflow/editor'; import {useEditorGlobals, useFakeXhr} from 'support'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import {fireEvent} from '@testing-library/dom'; import '@testing-library/jest-dom/extend-expect'; describe('SectionItemView', () => { @@ -13,6 +15,8 @@ describe('SectionItemView', () => { 'pageflow_scrolled.editor.section_menu_items.set_cutoff': 'Set cutoff point', 'pageflow_scrolled.editor.section_menu_items.reset_cutoff': 'Remove cutoff point', 'pageflow_scrolled.editor.section_item.cutoff': 'Cutoff point', + 'pageflow_scrolled.editor.section_item.drag_hint': 'Drag to move', + 'pageflow_scrolled.editor.section_item.mark': 'Mark section', }); const {createEntry} = useEditorGlobals(); @@ -227,4 +231,130 @@ describe('SectionItemView', () => { expect(view.el).not.toHaveAttribute('aria-current', 'true'); }); }); + + describe('marking', () => { + function renderSectionItem({markedSections} = {}) { + const entry = createEntry({sections: [{id: 1, permaId: 100}, {id: 2, permaId: 200}]}); + const section = entry.sections.get(1); + const view = new SectionItemView({entry, model: section, markedSections}); + + return {entry, section, ...render(view)}; + } + + it('does not render mark button without section selection', () => { + const {queryByRole} = renderSectionItem(); + + expect(queryByRole('button', {name: 'Mark section'})).toBeNull(); + }); + + it('renders unpressed mark button', () => { + const {getByRole} = renderSectionItem({markedSections: new ListSelection()}); + + expect(getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('marks section when mark button is clicked', () => { + const markedSections = new ListSelection(); + const {section, getByRole} = renderSectionItem({markedSections}); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(markedSections.includes(section)).toBe(true); + expect(getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('unmarks marked section when mark button is clicked', () => { + const markedSections = new ListSelection(); + const {section, getByRole} = renderSectionItem({markedSections}); + markedSections.add(section); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(markedSections.includes(section)).toBe(false); + }); + + it('marks section on modifier click', () => { + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(markedSections.includes(section)).toBe(true); + }); + + it('marks section on click with meta key', () => { + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); + + fireEvent.click(getByTitle('Drag to move'), {metaKey: true}); + + expect(markedSections.includes(section)).toBe(true); + }); + + it('does not mark section on plain click', () => { + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); + + fireEvent.click(getByTitle('Drag to move')); + + expect(markedSections.includes(section)).toBe(false); + }); + + it('also marks section selected in preview on first modifier click', () => { + const markedSections = new ListSelection(); + const {entry, section, getByTitle} = renderSectionItem({markedSections}); + entry.set('selectedSectionId', 2); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(markedSections.pluck('id')).toEqual([2, 1]); + expect(markedSections.includes(section)).toBe(true); + }); + + it('does not select section in preview on modifier click', () => { + const markedSections = new ListSelection(); + const {entry, getByTitle} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('selectSection', listener); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('scrolls to section on modifier click', () => { + const markedSections = new ListSelection(); + const {entry, section, getByTitle} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(listener).toHaveBeenCalledWith(section); + }); + + it('does not select section in preview when mark button is clicked', () => { + const markedSections = new ListSelection(); + const {entry, getByRole} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('selectSection', listener); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('scrolls to section when mark button is clicked', () => { + const markedSections = new ListSelection(); + const {entry, section, getByRole} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(listener).toHaveBeenCalledWith(section); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js index 650817a3b9..486806de91 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js @@ -5,12 +5,26 @@ import {frontend} from 'pageflow-scrolled/frontend'; import {editableTextValue} from 'support'; import {useInlineEditingPageObjects, renderEntry} from 'support/pageObjects/inlineEditing'; -import {fireEvent} from '@testing-library/react'; +import {act, fireEvent} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect' describe('inline editing content element multi selection', () => { useInlineEditingPageObjects(); + it('flags document while selection modifier is held', () => { + renderEntry({ + seed: { + contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}] + } + }); + + fireEvent.keyDown(window, {key: 'Control', ctrlKey: true}); + + expect(document.documentElement).toHaveAttribute('data-selection-modifier'); + + fireEvent.keyUp(window, {key: 'Control'}); + }); + it('marks both content elements as selected', () => { const {getContentElementByTestId} = renderEntry({ seed: { @@ -195,4 +209,161 @@ describe('inline editing content element multi selection', () => { expect(queryAllByRole('status')).toHaveLength(0); }); + + describe('selection mode', () => { + function startSelectionMode(items) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items, + selectionMode: true + } + }, + origin: window.location.origin + })); + }); + } + + function renderEntryWithTwoContentElements() { + return renderEntry({ + seed: { + sections: [{id: 1, permaId: 10}], + contentElements: [ + {id: 1, sectionId: 1, typeName: 'withTestId', configuration: {testId: 5}}, + {id: 2, sectionId: 1, typeName: 'withTestId', configuration: {testId: 6}} + ] + } + }); + } + + it('adds content element to selection on plain click', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('removes content element from selection on plain click', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(false); + }); + + it('stays in selection mode when only one content element is left', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + getContentElementByTestId(6).select(); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('keeps last content element selected when it is clicked again', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + getContentElementByTestId(5).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + }); + + it('ends selection mode on escape', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + fireEvent.keyDown(window, {key: 'Escape'}); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('keeps selection when selection mode ends', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + fireEvent.keyDown(window, {key: 'Escape'}); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('ends selection mode when editor requests it', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: {type: 'END_SELECTION_MODE'}, + origin: window.location.origin + })); + }); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('ends selection mode when section is clicked', () => { + const {getContentElementByTestId, getSectionByPermaId} = + renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + fireEvent.mouseDown(getSectionByPermaId(10).el); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('flags document while in selection mode', () => { + renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + + expect(document.documentElement).toHaveAttribute('data-selection-mode'); + + fireEvent.keyDown(window, {key: 'Escape'}); + + expect(document.documentElement).not.toHaveAttribute('data-selection-mode'); + }); + + it('does not focus editable text when clicked in selection mode', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [ + {id: 1, typeName: 'withTestId', configuration: {testId: 5}}, + { + id: 2, + typeName: 'withEditableInlineText', + configuration: { + testId: 6, + value: editableTextValue('Some heading', {type: 'heading'}) + } + } + ] + } + }); + const editableText = getContentElementByTestId(6).editableText(); + + startSelectionMode([{id: 1}]); + editableText.click('Some heading'); + + expect(editableText.isFocused()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js new file mode 100644 index 0000000000..5e56f851d2 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js @@ -0,0 +1,144 @@ +import {useInlineEditingPageObjects, renderEntry} from 'support/pageObjects/inlineEditing'; +import {useFakeFeatures} from 'pageflow/testHelpers'; +import '@testing-library/jest-dom/extend-expect' + +describe('inline editing section multi selection', () => { + useInlineEditingPageObjects(); + useFakeFeatures('frontend', ['fragments']); + + function renderEntryWithThreeSections() { + return renderEntry({ + seed: { + sections: [ + {id: 1, permaId: 10}, + {id: 2, permaId: 20}, + {id: 3, permaId: 30} + ] + } + }); + } + + function lastSelectedPayload() { + return window.parent.postMessage.mock.calls + .map(([message]) => message) + .filter(message => message.type === 'SELECTED') + .pop() + .payload; + } + + it('selects multiple sections when section is clicked with modifier', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + }); + }); + + it('marks multi selected sections as selected', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(true); + expect(getSectionByPermaId(30).isSelected()).toBe(false); + }); + + it('reverts to selection of section settings when reduced to one section', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({type: 'sectionSettings', id: 1}); + }); + + it('carries over section selected by editor', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({type: 'section', id: 1}); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + }); + }); + + it('carries over multiple sections selected by editor', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}, {id: 2}] + }); + getSectionByPermaId(30).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}, {id: 3}] + }); + }); + + it('marks sections of multi selection by editor as selected', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(false); + }); + + it('ends multi selection when section is clicked without modifier', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + getSectionByPermaId(30).select(); + + expect(lastSelectedPayload()).toEqual({type: 'sectionSettings', id: 3}); + }); + + it('resets selection when only section of multi selection by editor is clicked with modifier', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + getSectionByPermaId(10).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({}); + }); +}); + +describe('inline editing section multi selection without fragments feature', () => { + useInlineEditingPageObjects(); + + it('ignores sections clicked with modifier', () => { + const {getSectionByPermaId} = renderEntry({ + seed: {sections: [{id: 1, permaId: 10}, {id: 2, permaId: 20}]} + }); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js index 287a1e8a7f..d02e0f64c7 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js @@ -1,6 +1,9 @@ import { extendMultiSelection, - isInMultiSelection + isInMultiSelection, + isInSelectionMode, + watchSelectionModifier, + withoutSelectionMode } from 'frontend/inlineEditing/multiSelection'; describe('extendMultiSelection', () => { @@ -96,6 +99,58 @@ describe('extendMultiSelection', () => { expect(extendMultiSelection(selection, {type: 'contentElement', id: 5})) .toEqual({type: 'contentElement', id: 5}); }); + + it('keeps multi selection with single item in selection mode', () => { + const selection = { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}], + selectionMode: true + }; + + expect(extendMultiSelection(selection, {type: 'contentElement', id: 6})) + .toEqual({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + }); + }); + + it('keeps last item in selection mode', () => { + const selection = { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + }; + + expect(extendMultiSelection(selection, {type: 'contentElement', id: 5})) + .toBe(selection); + }); +}); + +describe('isInSelectionMode', () => { + it('returns true for multi selection in selection mode', () => { + expect(isInSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + })).toBe(true); + }); + + it('returns false for multi selection', () => { + expect(isInSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}] + })).toBe(false); + }); + + it('returns false without selection', () => { + expect(isInSelectionMode(null)).toBe(false); + }); }); describe('isInMultiSelection', () => { @@ -128,3 +183,80 @@ describe('isInMultiSelection', () => { expect(isInMultiSelection(null, {type: 'contentElement', id: 6})).toBe(false); }); }); + +describe('watchSelectionModifier', () => { + let unwatch; + + afterEach(() => unwatch()); + + function isFlagged() { + return document.documentElement.hasAttribute('data-selection-modifier'); + } + + it('flags document while modifier key is held', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Meta', metaKey: true})); + + expect(isFlagged()).toBe(true); + }); + + it('removes flag when modifier key is released', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Control', ctrlKey: true})); + window.dispatchEvent(new KeyboardEvent('keyup', {key: 'Control'})); + + expect(isFlagged()).toBe(false); + }); + + it('reads modifier state from mouse moves', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new MouseEvent('mousemove', {ctrlKey: true})); + + expect(isFlagged()).toBe(true); + }); + + it('removes flag when window loses focus', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new MouseEvent('mousemove', {metaKey: true})); + window.dispatchEvent(new Event('blur')); + + expect(isFlagged()).toBe(false); + }); + + it('removes flag and stops watching when unwatched', () => { + watchSelectionModifier(window)(); + unwatch = () => {}; + + window.dispatchEvent(new MouseEvent('mousemove', {metaKey: true})); + + expect(isFlagged()).toBe(false); + }); +}); + +describe('withoutSelectionMode', () => { + it('keeps multi selection of several items', () => { + expect(withoutSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}], + selectionMode: true + })).toEqual({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}] + }); + }); + + it('turns multi selection of single item into selection of item', () => { + expect(withoutSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + })).toEqual({type: 'contentElement', id: 5}); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js index bbf6f0a59e..882fdd37b1 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js +++ b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js @@ -57,8 +57,12 @@ function createInlineEditingSectionPageObject(el) { return { ...baseCreateSectionPageObject(el), - select() { - fireEvent.mouseDown(selectionRect); + select({ctrlKey = false} = {}) { + fireEvent.mouseDown(selectionRect, {ctrlKey}); + }, + + isSelected() { + return selectionRect.getAttribute('aria-selected') === 'true'; }, clickAddContentElement() { diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index e16e97f6d6..48c9d09167 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -177,6 +177,24 @@ export const PreviewMessageController = Object.extend({ }) ); + this.listenTo(this.entry, 'startContentElementSelection', contentElement => + postMessage({ + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: contentElement.id}], + selectionMode: true + } + }) + ); + + this.listenTo(this.entry, 'endSelectionMode', () => + postMessage({type: 'END_SELECTION_MODE'}) + ); + + this.watchMarkedSections(postMessage); + this.listenTo(this.entry.commentDisplayFilter, 'change:resolution change:alwaysShowComments', filter => @@ -222,9 +240,16 @@ export const PreviewMessageController = Object.extend({ message.data.payload.highlightedThreadId : undefined, selectedCommentsSubject: - selectedCommentsSubjectFor(this.entry, message.data.payload) + selectedCommentsSubjectFor(this.entry, message.data.payload), + selectedSectionId: type === 'section' || type === 'sectionSettings' ? id : null }); + if (!isSelectionOfSections(message.data.payload) && + this.entry.markedSections?.isActive()) { + this.previewShowsMarkedSections = false; + this.entry.markedSections.stop(); + } + if (type === 'contentElementComments' || type === 'sectionComments') { if (!onCommentsView() && message.data.payload.source !== 'editor') { this.editor.navigate('/scrolled/comments?tab=selection', {trigger: true}) @@ -246,9 +271,16 @@ export const PreviewMessageController = Object.extend({ } } else if (type === 'multiSelection') { - const {itemType, items} = message.data.payload; - const payload = encodeURIComponent(JSON.stringify({itemType, items})); - this.editor.navigate(`/scrolled/multi_selection?payload=${payload}`, {trigger: true}) + const {itemType, items, selectionMode} = message.data.payload; + + if (itemType === 'sectionSettings') { + this.markSections(items); + this.editor.navigate('/', {trigger: true}); + } + else if (itemType !== 'section') { + const payload = encodeURIComponent(JSON.stringify({itemType, items, selectionMode})); + this.editor.navigate(`/scrolled/multi_selection?payload=${payload}`, {trigger: true}) + } } else if (type === 'sectionSettings') { this.editor.navigate(`/scrolled/sections/${id}`, {trigger: true}) @@ -339,6 +371,37 @@ export const PreviewMessageController = Object.extend({ } }, + watchMarkedSections(postMessage) { + const markedSections = this.entry.markedSections; + this.previewShowsMarkedSections = markedSections.isActive(); + + this.listenTo(markedSections, 'add remove reset change:selecting', () => { + const active = markedSections.isActive(); + + if (active || this.previewShowsMarkedSections) { + postMessage({ + type: 'SELECT', + payload: active ? + { + type: 'multiSelection', + itemType: 'section', + items: markedSections.map(section => ({id: section.id})) + } : + null + }); + } + + this.previewShowsMarkedSections = active; + }); + }, + + markSections(items) { + this.entry.markedSections.reset( + items.map(({id}) => this.entry.sections.get(id)).filter(Boolean) + ); + this.entry.markedSections.start(); + }, + preserveScrollPoint(callback) { if (this.preservedScrollTarget) { const {sectionId, align} = this.preservedScrollTarget; @@ -394,3 +457,8 @@ function onCommentsView() { return fragment === 'scrolled/comments' || fragment.startsWith('scrolled/comments?'); } + +function isSelectionOfSections({type, itemType}) { + return type === 'section' || + (type === 'multiSelection' && (itemType === 'section' || itemType === 'sectionSettings')); +} diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index e663da0369..7524c5d5e3 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -82,12 +82,13 @@ export const SideBarController = Marionette.Controller.extend({ }, multiSelection: function(payload) { - const {itemType, items} = JSON.parse(decodeURIComponent(payload)); + const {itemType, items, selectionMode} = JSON.parse(decodeURIComponent(payload)); this.region.show(new MultiSelectionView({ entry: this.entry, itemType, - items + items, + selectionMode })); }, diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index ae4b679724..8250009662 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -122,7 +122,7 @@ export const Chapter = Backbone.Model.extend({ insertFragment(fragment) { return $.ajax({ - url: `${this.url()}/fragment_insertions`, + url: `${this.url()}/section_fragment_insertions`, type: 'POST', dataType: 'json', data: { @@ -140,14 +140,6 @@ export const Chapter = Backbone.Model.extend({ }); }, - extractToFragmentLibrary({title} = {}) { - return Promise.resolve($.ajax({ - url: `${this.url()}/fragment_extractions`, - type: 'POST', - data: {title} - })).then(() => undefined); - }, - addFragmentSections(sectionsAttributes) { const sections = sectionsAttributes.map(({contentElements, ...attributes}) => { this.sections.add(attributes, {contentElements: this.entry.contentElements}); diff --git a/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js b/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js new file mode 100644 index 0000000000..fbff97055c --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js @@ -0,0 +1,38 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; +import {app} from 'pageflow/editor'; + +import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; + +export const ExtractFragmentMenuItem = Backbone.Model.extend({ + initialize() { + this.update('label', {disabled: false}); + }, + + selected() { + ExtractFragmentDialogView.show({ + onSubmit: title => this.extract(title) + }); + }, + + extract(title) { + this.update('extracting', {disabled: true}); + + return this.extractToFragmentLibrary({title}).then( + () => this.update('extracted', {disabled: true}), + ({status}) => { + this.update('label', {disabled: false}); + app.trigger('error', { + message: I18n.t(`${this.translationKeyPrefix}.${status === 403 ? 'forbidden' : 'failed'}`) + }); + } + ); + }, + + update(labelKey, {disabled}) { + this.set({ + label: I18n.t(`${this.translationKeyPrefix}.${labelKey}`), + disabled + }); + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 0887c4daab..9cf14c9b37 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import {Entry, editor} from 'pageflow/editor'; +import {Entry, ListSelection, editor} from 'pageflow/editor'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; import {createReviewSession} from 'pageflow/review'; @@ -92,6 +92,9 @@ export const ScrolledEntry = Entry.extend({ this.cutoff = new Cutoff({entry: this}); + this.markedSections = new ListSelection(); + this.listenTo(this.sections, 'remove', section => this.markedSections.remove(section)); + editor.failures.watch(this.contentElements); editor.failures.watch(this.sections); editor.failures.watch(this.chapters); @@ -238,6 +241,17 @@ export const ScrolledEntry = Entry.extend({ })).then(() => undefined); }, + extractSectionsToFragmentLibrary(sections, {title} = {}) { + return Promise.resolve($.ajax({ + url: `${this.url()}/scrolled/section_fragment_extractions`, + type: 'POST', + data: { + section_ids: sections.map(section => section.id), + title + } + })).then(() => undefined); + }, + fileReferences() { return this.fileReferencesIndex; }, diff --git a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js index 100febdc6d..5d41892786 100644 --- a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -1,8 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {app, DestroyMenuItem} from 'pageflow/editor'; - -import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; +import {DestroyMenuItem} from 'pageflow/editor'; export const CopyPermalinkMenuItem = Backbone.Model.extend({ initialize(attributes, {entry, chapter}) { @@ -18,40 +16,6 @@ export const CopyPermalinkMenuItem = Backbone.Model.extend({ } }); -export const ExtractFragmentMenuItem = Backbone.Model.extend({ - initialize(attributes, {chapter}) { - this.chapter = chapter; - this.update('extract_fragment', {disabled: false}); - }, - - selected() { - ExtractFragmentDialogView.show({ - title: this.chapter.configuration.get('title'), - onSubmit: title => this.extract(title) - }); - }, - - extract(title) { - this.update('extracting_fragment', {disabled: true}); - - return this.chapter.extractToFragmentLibrary({title}).then( - () => this.update('extracted_fragment', {disabled: true}), - ({status}) => { - this.update('extract_fragment', {disabled: false}); - app.trigger('error', { - message: t(status === 403 ? - 'extract_fragment_forbidden' : - 'extract_fragment_failed') - }); - } - ); - }, - - update(labelKey, {disabled}) { - this.set({label: t(labelKey), disabled}); - } -}); - export const ToggleExcursionMenuItem = Backbone.Model.extend({ initialize(attributes, {chapter}) { this.chapter = chapter; @@ -84,7 +48,3 @@ export const DestroyChapterMenuItem = DestroyMenuItem.extend({ ); } }); - -function t(key) { - return I18n.t(`pageflow_scrolled.editor.chapter_menu_items.${key}`); -} diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index 4ca89e0988..be55c4135a 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -1,8 +1,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {app, DestroyMenuItem} from 'pageflow/editor'; +import {DestroyMenuItem} from 'pageflow/editor'; -import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; +import {ExtractFragmentMenuItem} from './ExtractFragmentMenuItem'; import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; export const DuplicateContentElementMenuItem = Backbone.Model.extend({ @@ -86,43 +86,31 @@ export const DestroyContentElementsMenuItem = DestroyMenuItem.extend({ } }); -export const ExtractContentElementsFragmentMenuItem = Backbone.Model.extend({ +export const ExtractContentElementsFragmentMenuItem = ExtractFragmentMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item', + initialize(attributes, {entry, items}) { this.entry = entry; this.items = items; - this.update('label', {disabled: false}); + ExtractFragmentMenuItem.prototype.initialize.call(this); }, - selected() { - ExtractFragmentDialogView.show({ - onSubmit: title => this.extract(title) - }); - }, + extractToFragmentLibrary(options) { + return this.entry.extractContentElementsToFragmentLibrary(this.items, options); + } +}); - extract(title) { - this.update('extracting', {disabled: true}); - - return this.entry.extractContentElementsToFragmentLibrary(this.items, {title}).then( - () => this.update('extracted', {disabled: true}), - ({status}) => { - this.update('label', {disabled: false}); - app.trigger('error', { - message: I18n.t( - 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.' + - (status === 403 ? 'forbidden' : 'failed') - ) - }); - } - ); +export const SelectMultipleContentElementsMenuItem = Backbone.Model.extend({ + initialize(attributes, {entry, contentElement}) { + this.entry = entry; + this.contentElement = contentElement; + this.set('label', I18n.t( + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label' + )); }, - update(labelKey, {disabled}) { - this.set({ - label: I18n.t( - `pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.${labelKey}` - ), - disabled - }); + selected() { + this.entry.trigger('startContentElementSelection', this.contentElement); } }); diff --git a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js index 358586f7d5..cc3b2b5245 100644 --- a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -1,7 +1,9 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {DestroyMenuItem} from 'pageflow/editor'; +import {features} from 'pageflow/frontend'; +import {ExtractFragmentMenuItem} from './ExtractFragmentMenuItem'; import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; export const HideShowSectionMenuItem = Backbone.Model.extend({ @@ -132,6 +134,20 @@ export const MoveSectionMenuItem = Backbone.Model.extend({ } }); +export const ExtractSectionsFragmentMenuItem = ExtractFragmentMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.extract_sections_fragment_menu_item', + + initialize(attributes, {entry, sections}) { + this.entry = entry; + this.sections = sections; + ExtractFragmentMenuItem.prototype.initialize.call(this); + }, + + extractToFragmentLibrary(options) { + return this.entry.extractSectionsToFragmentLibrary(this.sections, options); + } +}); + export const DestroySectionMenuItem = DestroyMenuItem.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.destroy_section_menu_item', @@ -145,6 +161,8 @@ export const DestroySectionMenuItem = DestroyMenuItem.extend({ }); export function createSectionMenuItems({entry, section}) { + const fragments = features.isEnabled('fragments'); + return [ new DuplicateSectionMenuItem({}, {section}), new MoveSectionMenuItem({}, {entry, section}), @@ -153,7 +171,10 @@ export function createSectionMenuItems({entry, section}) { ...(entry.cutoff.isEnabled() ? [new CutoffSectionMenuItem({}, {cutoff: entry.cutoff, section})] : []), - new CopyPermalinkMenuItem({separated: true}, {entry, section}), + ...(fragments ? + [new ExtractSectionsFragmentMenuItem({separated: true}, {entry, sections: [section]})] : + []), + new CopyPermalinkMenuItem({separated: !fragments}, {entry, section}), new HideShowSectionMenuItem({separated: true}, {section}), new DestroySectionMenuItem({}, {section}) ]; diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index 14e0123368..fbd0edd946 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -70,20 +70,37 @@ export const ChapterItemView = Marionette.Layout.extend({ }, onRender() { - this.subview(new SortableCollectionView({ + this.sectionsView = this.subview(new SortableCollectionView({ el: this.ui.sections, collection: this.model.sections, itemViewConstructor: SectionItemView, itemViewOptions: { - entry: this.options.entry + entry: this.options.entry, + markedSections: this.options.markedSections }, connectWith: cssModulesUtils.selector(styles, 'sections'), forceDraggableFallback: browser.agent.matchesDesktopSafari() })); + if (this.options.markedSections) { + this.listenTo(this.options.markedSections, + 'add remove reset change:selecting', + this.updateSorting); + this.updateSorting(); + } + this.update(); }, + updateSorting() { + if (this.options.markedSections.isActive()) { + this.sectionsView.disableSorting(); + } + else { + this.sectionsView.enableSorting(); + } + }, + update() { this.ui.title.toggleClass(styles.blank, !this.model.configuration.get('title') && diff --git a/entry_types/scrolled/package/src/editor/views/EditChapterView.js b/entry_types/scrolled/package/src/editor/views/EditChapterView.js index 745e819ee8..4a2bc7c801 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,11 +1,9 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; -import {features} from 'pageflow/frontend'; import { CopyPermalinkMenuItem, DestroyChapterMenuItem, - ExtractFragmentMenuItem, ToggleExcursionMenuItem } from '../models/chapterMenuItems'; @@ -16,9 +14,6 @@ export const EditChapterView = EditConfigurationView.extend({ return [ new ToggleExcursionMenuItem({}, {chapter: this.model}), new CopyPermalinkMenuItem({}, {entry: this.options.entry, chapter: this.model}), - ...(features.isEnabled('fragments') ? - [new ExtractFragmentMenuItem({}, {chapter: this.model})] : - []), new DestroyChapterMenuItem({separated: true}, {chapter: this.model}) ]; }, diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index 53257cbc7a..f6dc15afdf 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -5,7 +5,8 @@ import { DestroyContentElementMenuItem, DuplicateContentElementMenuItem, ExtractContentElementsFragmentMenuItem, - MoveContentElementMenuItem + MoveContentElementMenuItem, + SelectMultipleContentElementsMenuItem } from '../models/contentElementMenuItems'; export const EditContentElementView = EditConfigurationView.extend({ @@ -37,6 +38,10 @@ export const EditContentElementView = EditConfigurationView.extend({ entry: this.options.entry, editor: this.options.editor }), + new SelectMultipleContentElementsMenuItem({separated: true}, { + contentElement: this.model, + entry: this.options.entry + }), ...(features.isEnabled('fragments') ? [new ExtractContentElementsFragmentMenuItem({}, { entry: this.options.entry, diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 79af1c5f0d..996bab3466 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -3,10 +3,13 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {cssModulesUtils} from 'pageflow/ui'; import {DropDownButtonView} from 'pageflow/editor'; +import {features} from 'pageflow/frontend'; import {StorylinesTabsView} from './StorylinesTabsView'; import {StorylineItemView} from './StorylineItemView'; import {TrashButtonView} from './TrashButtonView'; +import {ExtractFragmentDialogView} from './ExtractFragmentDialogView'; +import {ExtractSectionsFragmentMenuItem} from '../models/sectionMenuItems'; import styles from './EntryOutlineView.module.css'; @@ -19,9 +22,54 @@ export const EntryOutlineView = Marionette.Layout.extend({
+ `, - ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), + ui: cssModulesUtils.ui(styles, + 'tabs', 'toolbar', 'dropDownButton', + 'selectionBar', 'selectionBarText', 'selectionBarAction'), + + events: cssModulesUtils.events(styles, { + 'click selectionBarDismiss': function() { + this.markedSections.stop(); + }, + + 'click selectionBarAction': function() { + const menuItem = new ExtractSectionsFragmentMenuItem({}, { + entry: this.options.entry, + sections: this.markedSections.models.slice() + }); + + ExtractFragmentDialogView.show({ + onSubmit: title => { + this.markedSections.stop(); + menuItem.extract(title); + } + }); + } + }), + + initialize() { + if (features.isEnabled('fragments')) { + this.markedSections = this.options.entry.markedSections; + } + }, onRender() { this.ui.toolbar.prepend(this.subview(new TrashButtonView({ @@ -39,6 +87,18 @@ export const EntryOutlineView = Marionette.Layout.extend({ dropDownMenuItems.add(this.reorderChaptersMenutItem); + if (this.markedSections) { + dropDownMenuItems.add(new SelectSectionsMenuItem({}, { + markedSections: this.markedSections + })); + + this.setupSelectionBar(); + this.makeSelectionAndReorderingExclusive(viewModel); + } + else { + this.ui.selectionBar.remove(); + } + this.appendSubview(new DropDownButtonView({ items: dropDownMenuItems, alignMenu: 'right', @@ -47,19 +107,92 @@ export const EntryOutlineView = Marionette.Layout.extend({ openOnClick: true }), {to: this.ui.dropDownButton}); - this.appendSubview( - new StorylinesTabsView({ + const storylinesTabsView = new StorylinesTabsView({ + entry: this.options.entry, + itemViewContstuctor: StorylineItemView, + itemViewOptions: { entry: this.options.entry, - itemViewContstuctor: StorylineItemView, - itemViewOptions: { - entry: this.options.entry, - viewModel - } - }), - {to: this.ui.tabs} - ); + viewModel, + markedSections: this.markedSections + } + }); + + this.listenTo(storylinesTabsView, 'change', () => { + viewModel.set('collapsed', false); + this.markedSections?.stop(); + }); + + this.appendSubview(storylinesTabsView, {to: this.ui.tabs}); this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); + + if (this.markedSections) { + this.ui.tabs.find('.tabs_view-container').first().before(this.ui.selectionBar); + } + }, + + onClose() { + this.markedSections?.stop(); + }, + + setupSelectionBar() { + this.listenTo(this.markedSections, + 'add remove reset change:selecting', + this.updateSelectionBar); + + this.updateSelectionBar(); + }, + + makeSelectionAndReorderingExclusive(viewModel) { + this.listenTo(viewModel, 'change:collapsed', (model, collapsed) => { + if (collapsed) { + this.markedSections.stop(); + } + }); + + this.listenTo(this.markedSections, 'change:selecting add', () => { + if (this.markedSections.isActive()) { + viewModel.set('collapsed', false); + } + }); + }, + + updateSelectionBar() { + const selection = this.markedSections; + + this.ui.selectionBar.prop('hidden', !selection.isActive()); + this.ui.selectionBarText.text( + I18n.t('pageflow_scrolled.editor.entry_outline.selected_sections', + {count: selection.length}) + ); + + this.ui.selectionBarAction.prop('disabled', !selection.length); + } +}); + +const SelectSectionsMenuItem = Backbone.Model.extend({ + initialize(attributes, {markedSections}) { + this.markedSections = markedSections; + + this.listenTo(markedSections, 'add remove reset change:selecting', this.updateLabel); + this.updateLabel(); + }, + + selected() { + if (this.markedSections.isActive()) { + this.markedSections.stop(); + } + else { + this.markedSections.start(); + } + }, + + updateLabel() { + this.set('label', I18n.t( + this.markedSections.isActive() ? + 'pageflow_scrolled.editor.entry_outline.end_selection' : + 'pageflow_scrolled.editor.entry_outline.select_sections' + )); } }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css index edc805967c..48ad0eedba 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css @@ -23,3 +23,69 @@ --ui-on-button-color: var(--ui-primary-color); } } + +.selectionBar { + position: sticky; + /* Padding of .scrolling in pageflow/editor/base.scss */ + top: -10px; + z-index: 2; + display: flow-root; + background-color: var(--ui-surface-color); +} + +.selectionBarBox { + composes: banner from './banner.module.css'; + margin: space(3) 0 space(2); +} + +.selectionBar[hidden] { + display: none; +} + +.selectionBar:not([hidden]) + :global(.tabs_view-container) { + /* Cancels margin-top of .chapters in StorylineItemView.module.css */ + margin-top: space(-3); +} + +.selectionBarStatus { + display: flex; + align-items: center; + gap: space(2); +} + +.selectionBarText { + flex: 1; +} + +.selectionBarActions { + display: flex; + align-self: flex-end; + gap: space(1); +} + +.selectionBarDismiss { + composes: cancel from './icons.module.css'; + flex: none; + width: space(6); + padding: space(1) 0; + border: none; + border-radius: rounded(); + background-color: transparent; + color: var(--ui-primary-color-light); + line-height: 16px; + cursor: pointer; +} + +.selectionBarDismiss::before { + font-size: 16px; + vertical-align: top; +} + +.selectionBarDismiss:hover { + background-color: var(--ui-on-surface-color-lightest); + color: var(--ui-primary-color); +} + +.selectionBarAction { + composes: secondaryButton compactButton from './buttons.module.css'; +} diff --git a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js index 194bfa9ceb..12bb176dc1 100644 --- a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js +++ b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js @@ -22,16 +22,29 @@ export const MultiSelectionView = Marionette.ItemView.extend({ ${I18n.t('pageflow.editor.templates.back_button_decorator.outline')}
+
+ ${I18n.t('pageflow_scrolled.editor.multi_selection_view.selection_mode_hint')} + +
`, - ui: cssModulesUtils.ui(styles, 'actions', 'tabs'), + ui: cssModulesUtils.ui(styles, 'actions', 'tabs', 'selectionModeHint'), events: { - 'click a.back': 'goBack' + 'click a.back': 'goBack', + + ...cssModulesUtils.events(styles, { + 'click endSelectionMode': function() { + this.options.entry.trigger('endSelectionMode'); + } + }) }, onRender() { const {entry, items} = this.options; + const contentElements = sortByDocumentOrder( entry, items @@ -62,6 +75,13 @@ export const MultiSelectionView = Marionette.ItemView.extend({ }); this.appendSubview(tabsView, {to: this.ui.tabs}); + + if (this.options.selectionMode) { + this.ui.tabs.find('.tabs_view-container').first().before(this.ui.selectionModeHint); + } + else { + this.ui.selectionModeHint.remove(); + } }, // Resetting the selection in the preview makes it navigate back to diff --git a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css index 3fd82021f0..e900d1b302 100644 --- a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css +++ b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css @@ -2,6 +2,17 @@ padding-bottom: space(2); } +.selectionModeHint { + composes: banner from './banner.module.css'; + margin: space(3) 0; +} + +.endSelectionMode { + composes: primaryIconButton compactButton from './buttons.module.css'; + composes: check from './icons.module.css'; + align-self: flex-end; +} + .actions { float: right; } diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 3e69e6d794..e0e5c9f991 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -1,7 +1,7 @@ import I18n from 'i18n-js'; import Backbone from 'backbone'; import Marionette from 'backbone.marionette'; -import {modelLifecycleTrackingView, DropDownButtonView} from 'pageflow/editor'; +import {modelLifecycleTrackingView, DropDownButtonView, MarkButtonView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView' @@ -36,6 +36,7 @@ export const SectionItemView = Marionette.ItemView.extend({ title="${I18n.t('pageflow_scrolled.editor.section_item.drag_hint')}"> +
@@ -50,11 +51,17 @@ export const SectionItemView = Marionette.ItemView.extend({ ui: cssModulesUtils.ui(styles, 'thumbnail', 'dropDownButton', 'editTransition', - 'transition', 'cutoffIndicator'), + 'transition', 'cutoffIndicator', 'markButton'), events: cssModulesUtils.events(styles, { - 'click clickMask': function() { - this.options.entry.trigger('selectSection', this.model); + 'click clickMask': function(event) { + if (this.options.markedSections && (event.ctrlKey || event.metaKey)) { + this.startMarking(); + } + else { + this.options.entry.trigger('selectSection', this.model); + } + this.options.entry.trigger('scrollToSection', this.model); }, @@ -93,12 +100,19 @@ export const SectionItemView = Marionette.ItemView.extend({ this.listenTo(this.model.configuration, 'change:hidden', () => { this.updateHidden(); }); + + if (this.options.markedSections) { + this.listenTo(this.options.markedSections, + 'add remove reset change:selecting', + this.updateMarked); + } }, onRender() { this.updateTransition(); this.updateCutoffIndicator(); this.updateHidden(); + this.updateMarked(); if (this.updateActive()) { setTimeout(() => this.$el[0].scrollIntoView({block: 'nearest'}), 10) @@ -123,6 +137,34 @@ export const SectionItemView = Marionette.ItemView.extend({ borderless: true, openOnClick: true }), {to: this.ui.dropDownButton}); + + if (this.options.markedSections) { + this.appendMarkButton(); + } + }, + + appendMarkButton() { + const markButton = new MarkButtonView({ + model: this.model, + selection: this.options.markedSections, + label: I18n.t('pageflow_scrolled.editor.section_item.mark') + }); + + this.appendSubview(markButton, {to: this.ui.markButton}); + this.listenTo(markButton, 'toggle', () => { + this.options.entry.trigger('scrollToSection', this.model); + }); + }, + + startMarking() { + const {entry, markedSections} = this.options; + const selectedSection = entry.sections.get(entry.get('selectedSectionId')); + + if (selectedSection && selectedSection !== this.model) { + markedSections.add(selectedSection); + } + + markedSections.add(this.model); }, updateTransition() { @@ -150,6 +192,11 @@ export const SectionItemView = Marionette.ItemView.extend({ return active; }, + updateMarked() { + this.$el.toggleClass(styles.marking, !!this.options.markedSections?.isActive()); + this.$el.toggleClass(styles.marked, !!this.options.markedSections?.includes(this.model)); + }, + updateHidden() { this.$el.toggleClass(styles.hidden, !!this.model.configuration.get('hidden')); } diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css index 338fb3115a..72d2df5dc6 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css @@ -54,6 +54,11 @@ .thumbnailContainer { position: relative; background-color: var(--ui-surface-color); + transition: transform 0.15s ease; +} + +.marked .thumbnailContainer { + transform: scale(0.92); } .hidden .thumbnail > * > * { @@ -117,6 +122,41 @@ display: none; } +.markButton { + display: none; + position: absolute; + inset: 0; + z-index: 1; +} + +.marking .markButton { + display: block; +} + +.markButton :global(.mark_button) { + position: absolute; + inset: 0; + width: auto; + height: auto; +} + +.markButton :global(.mark_button)::before { + position: absolute; + top: 4px; + left: 4px; + width: space(6); + text-align: center; +} + +.marking .dragHandle, +.marking .dropDownButton { + display: none; +} + +.marked .outline { + background-color: var(--ui-selection-color-lighter); +} + .editTransition { composes: transition from './outline.module.css'; border: 0; diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js index 3a97fd3eb9..b1ca087bfa 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js @@ -12,7 +12,7 @@ export const StorylineItemView = Marionette.Layout.extend({ className: styles.root, template: () => ` -
+

${I18n.t('pageflow_scrolled.editor.storyline_item.reorder_chapters')}

@@ -61,7 +61,8 @@ export const StorylineItemView = Marionette.Layout.extend({ collection: this.model.chapters, itemViewConstructor: ChapterItemView, itemViewOptions: { - entry: this.options.entry + entry: this.options.entry, + markedSections: this.options.markedSections }, blankSlateViewConstructor: this.model.isMain() ? null : ExcursionBlankSlateView }); diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css b/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css index 56c6a44269..945af29e18 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css @@ -12,17 +12,21 @@ } .expandChapters { + composes: banner from './banner.module.css'; margin-top: space(2); } +.expandChapters p { + margin: 0; +} + .expandChaptersButton { - composes: saveButton from './buttons.module.css'; - padding-top: 3px !important; - padding-bottom: 3px !important; + composes: saveButton compactButton from './buttons.module.css'; + align-self: flex-end; } .collapsed .expandChapters { - display: block !important; + display: flex !important; } .chapters { diff --git a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js index 0ddfda42ad..1be658b409 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js @@ -26,6 +26,7 @@ export const StorylinesTabsView = Marionette.View.extend({ } }) + this.listenTo(this.tabsView, 'change', name => this.trigger('change', name)); this.appendSubview(this.tabsView); return this; }, diff --git a/entry_types/scrolled/package/src/editor/views/banner.module.css b/entry_types/scrolled/package/src/editor/views/banner.module.css new file mode 100644 index 0000000000..4baefdedb6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/banner.module.css @@ -0,0 +1,8 @@ +.banner { + display: flex; + flex-direction: column; + gap: space(2); + padding: space(2); + border-radius: rounded(); + background-color: var(--ui-selection-color-lighter); +} diff --git a/entry_types/scrolled/package/src/editor/views/buttons.module.css b/entry_types/scrolled/package/src/editor/views/buttons.module.css index f9563c0bfa..c1be5139a3 100644 --- a/entry_types/scrolled/package/src/editor/views/buttons.module.css +++ b/entry_types/scrolled/package/src/editor/views/buttons.module.css @@ -8,6 +8,14 @@ composes: secondary_icon_button from global; } +.secondaryButton { + composes: secondary_button from global; +} + +.compactButton { + composes: compact_button from global; +} + .destructiveIconButton { composes: destructive_icon_button from global; } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js index 6c7d4fe3d3..5f5e249e7e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js @@ -9,7 +9,7 @@ import {useI18n} from '../i18n'; import {api} from '../api'; import {widths} from '../layouts'; import {SelectionRect} from './SelectionRect'; -import {hasSelectionModifier} from './multiSelection'; +import {useSelectionMode} from './EditorState'; import {DropTargets} from './DropTargets'; import {MarginIndicator} from './MarginIndicator'; import { @@ -57,6 +57,7 @@ function DefaultSelectionRect(props) { const commentsSelected = type === 'contentElementComments' || type === 'newThread'; const {resolution, alwaysShowComments} = useCommentDisplayFilter(); const {t} = useI18n({locale: 'ui'}); + const selectionMode = useSelectionMode(); const selectionRectRef = useRef(); @@ -101,9 +102,9 @@ function DefaultSelectionRect(props) { commentBadgeInset={!isSelected} ariaLabel={t('pageflow_scrolled.inline_editing.select_content_element')} insertButtonTitles={t('pageflow_scrolled.inline_editing.insert_content_element')} - onMouseDown={event => hasSelectionModifier(event) && + onMouseDown={event => selectionMode.extendsSelection(event) && event.preventDefault()} - onClick={event => hasSelectionModifier(event) ? + onClick={event => selectionMode.extendsSelection(event) ? extendSelection() : select()} onInsertButtonClick={at => diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css index d5b080d286..db95c4f448 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css @@ -1,3 +1,10 @@ .wrapper { position: relative; } + +:global([data-selection-modifier]) .wrapper, +:global([data-selection-modifier]) .wrapper *, +:global([data-selection-mode]) .wrapper, +:global([data-selection-mode]) .wrapper * { + cursor: copy !important; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js index c84ea70f50..114f6a9f00 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js @@ -1,13 +1,14 @@ import {useCallback} from 'react'; -import {hasSelectionModifier} from '../multiSelection'; import {useContentElementEditorState} from '../../useContentElementEditorState'; +import {useSelectionMode} from '../EditorState'; export function useMultiSelectHandler() { const {extendSelection} = useContentElementEditorState(); + const selectionMode = useSelectionMode(); return useCallback(event => { - if (!hasSelectionModifier(event)) { + if (!selectionMode.extendsSelection(event)) { return; } @@ -15,5 +16,5 @@ export function useMultiSelectHandler() { // would edit a content element that is part of a multi selection. event.preventDefault(); extendSelection(); - }, [extendSelection]); + }, [extendSelection, selectionMode]); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js index 0a8434cbb2..8c33bfb4a8 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js @@ -1,6 +1,12 @@ import React, {useState, useContext, useMemo, useCallback, useRef} from 'react'; -import {extendMultiSelection, isInMultiSelection} from './multiSelection'; +import { + extendMultiSelection, + hasSelectionModifier, + isInMultiSelection, + isInSelectionMode, + withoutSelectionMode +} from './multiSelection'; const Context = React.createContext({}); @@ -27,9 +33,15 @@ export function EditorStateProvider(props) { setSelection(extendMultiSelection(selectionRef.current, options)); }, [setSelection]); + const endSelectionMode = useCallback(() => { + if (isInSelectionMode(selectionRef.current)) { + setSelection(withoutSelectionMode(selectionRef.current)); + } + }, [setSelection]); + const value = useMemo(() => ({ - selection, setSelection, extendSelection - }), [setSelection, extendSelection, selection]) + selection, setSelection, extendSelection, endSelectionMode + }), [setSelection, extendSelection, endSelectionMode, selection]) return ( @@ -73,3 +85,14 @@ export function useEditorSelection(options) { }; }, [options, selection, setSelection, select, extendSelection, resetSelection]); } + +export function useSelectionMode() { + const {selection, endSelectionMode} = useContext(Context); + const active = isInSelectionMode(selection); + + return useMemo(() => ({ + active, + end: endSelectionMode, + extendsSelection: event => active || hasSelectionModifier(event) + }), [active, endSelectionMode]); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js index 475043419b..5a4bca338e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js @@ -7,7 +7,8 @@ import { } from 'pageflow-scrolled/review'; import {useEntryStateDispatch} from 'pageflow-scrolled/entryState'; import {usePostMessageListener} from '../../shared/usePostMessageListener'; -import {EditorStateProvider, useEditorSelection} from './EditorState'; +import {EditorStateProvider, useEditorSelection, useSelectionMode} from './EditorState'; +import {watchSelectionModifier} from './multiSelection'; import { useContentElementEditorCommandEmitter, ContentElementEditorCommandSubscriptionProvider @@ -16,10 +17,13 @@ import { export function EntryDecorator({commentingInitialState, children}) { const contentElementEditorCommandEmitter = useContentElementEditorCommandEmitter(); + useEffect(() => watchSelectionModifier(window), []); + return ( + @@ -53,8 +57,37 @@ function CommentDisplayFilterFromEditor({children}) { ); } +function SelectionModeHandler() { + const {active, end} = useSelectionMode(); + + useEffect(() => { + if (!active) { + return; + } + + const root = document.documentElement; + + function handleKeyDown(event) { + if (event.key === 'Escape') { + end(); + } + } + + root.setAttribute('data-selection-mode', ''); + window.addEventListener('keydown', handleKeyDown); + + return () => { + root.removeAttribute('data-selection-mode'); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [active, end]); + + return null; +} + function MessageHandler({contentElementEditorCommandEmitter}) { const {select} = useEditorSelection() + const {end: endSelectionMode} = useSelectionMode(); const dispatch = useEntryStateDispatch(); const receiveMessage = useCallback(data => { @@ -64,11 +97,14 @@ function MessageHandler({contentElementEditorCommandEmitter}) { else if (data.type === 'SELECT') { select(data.payload); } + else if (data.type === 'END_SELECTION_MODE') { + endSelectionMode(); + } else if (data.type === 'CONTENT_ELEMENT_EDITOR_COMMAND') { contentElementEditorCommandEmitter.trigger(`command:${data.payload.contentElementId}`, data.payload.command); } - }, [dispatch, select, contentElementEditorCommandEmitter]); + }, [dispatch, select, endSelectionMode, contentElementEditorCommandEmitter]); usePostMessageListener(receiveMessage); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js index fa8ef477f4..ac45cac5fb 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js @@ -25,7 +25,13 @@ export function SectionDecorator({backdrop, section, contentElements, transition const {t} = useI18n({locale: 'ui'}); const commentingEnabled = features.isEnabled('commenting'); - const {isSelected: isSectionSelected, select, resetSelection} = useEditorSelection({ + const { + isSelected: isSectionSelected, + isMultiSelected: isSectionMultiSelected, + select, + extendSelection, + resetSelection + } = useEditorSelection({ id: section.id, type: 'sectionSettings' }); @@ -78,11 +84,17 @@ export function SectionDecorator({backdrop, section, contentElements, transition type: 'contentElement' }); - const {isSelected: isHighlighted} = useEditorSelection({ + const { + isSelected: isSectionHighlighted, + isMultiSelected: isSectionMarked + } = useEditorSelection({ id: section.id, type: 'section' }); + const isInMultiSelection = isSectionMarked || isSectionMultiSelected; + const isHighlighted = isSectionHighlighted || isInMultiSelection; + const transitionSelection = useEditorSelection({ id: section.id, type: 'sectionTransition' @@ -101,19 +113,22 @@ export function SectionDecorator({backdrop, section, contentElements, transition }); function selectIfOutsideContentItem(event) { - // Do not dissolve a multi selection when a modifier click lands - // next to the elements it contains. - if (hasSelectionModifier(event)) { + if (event.target.closest(`.${contentElementStyles.wrapper}`) || + event.target.closest(`.${backdropStyles.wrapper}`) || + event.target.closest(`.${widgetSelectionRectStyles.wrapper}`) || + event.target.closest(`.${paddingIndicatorStyles.indicator}`) || + event.target.closest(`.${styles.commentBadge}`) || + event.target.closest('#fullscreenRoot') || + event.target.closest('[data-floating-ui-portal]')) { return; } - if (!event.target.closest(`.${contentElementStyles.wrapper}`) && - !event.target.closest(`.${backdropStyles.wrapper}`) && - !event.target.closest(`.${widgetSelectionRectStyles.wrapper}`) && - !event.target.closest(`.${paddingIndicatorStyles.indicator}`) && - !event.target.closest(`.${styles.commentBadge}`) && - !event.target.closest('#fullscreenRoot') && - !event.target.closest('[data-floating-ui-portal]')) { + if (hasSelectionModifier(event)) { + if (features.isEnabled('fragments')) { + extendSelection(); + } + } + else { isSectionSelected ? resetSelection() : select(); } } @@ -121,7 +136,7 @@ export function SectionDecorator({backdrop, section, contentElements, transition return (
diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js index 884d33b5ae..ec2a658df5 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js @@ -5,12 +5,54 @@ // itemType: 'contentElement', // items: [{id: 5}, {id: 6}] // } +// +// In selection mode, started from the editor, plain clicks extend the +// selection as well and it stays a multi selection down to one item: +// +// { +// type: 'multiSelection', +// itemType: 'contentElement', +// items: [{id: 5}], +// selectionMode: true +// } + +const sectionSelectionTypes = ['section', 'sectionSettings']; export function hasSelectionModifier(event) { return event.ctrlKey || event.metaKey; } +export function watchSelectionModifier(window) { + const root = window.document.documentElement; + const events = ['keydown', 'keyup', 'mousemove']; + + function update(event) { + root.toggleAttribute('data-selection-modifier', hasSelectionModifier(event)); + } + + function reset() { + root.removeAttribute('data-selection-modifier'); + } + + events.forEach(name => window.addEventListener(name, update)); + window.addEventListener('blur', reset); + + return () => { + events.forEach(name => window.removeEventListener(name, update)); + window.removeEventListener('blur', reset); + reset(); + }; +} + export function extendMultiSelection(selection, {type, id}) { + // Do not dissolve a multi selection of content elements when a + // modifier click lands on the section next to the elements. + if (sectionSelectionTypes.includes(type) && isMultiSelectionOf(selection, 'contentElement')) { + return selection; + } + + selection = withSectionSelectionType(selection, type); + if (isMultiSelectionOf(selection, type)) { return isInMultiSelection(selection, {type, id}) ? withoutItem(selection, id) : @@ -30,11 +72,43 @@ export function extendMultiSelection(selection, {type, id}) { return {type, id}; } +export function isInSelectionMode(selection) { + return !!selection && !!selection.selectionMode; +} + +export function withoutSelectionMode({selectionMode, ...selection}) { + if (selection.items.length > 1) { + return selection; + } + + return {type: selection.itemType, id: selection.items[0].id}; +} + export function isInMultiSelection(selection, {type, id}) { return isMultiSelectionOf(selection, type) && selection.items.some(item => item.id === id); } +// The outline selects sections with type `section`, the preview with +// type `sectionSettings`. Without this, extending a selection started +// in one place from the other would drop the sections selected so far. +function withSectionSelectionType(selection, type) { + if (!selection || !sectionSelectionTypes.includes(type)) { + return selection; + } + + if (selection.type === 'multiSelection' && + sectionSelectionTypes.includes(selection.itemType)) { + return {...selection, itemType: type}; + } + + if (sectionSelectionTypes.includes(selection.type)) { + return {...selection, type}; + } + + return selection; +} + function isMultiSelectionOf(selection, type) { return !!selection && selection.type === 'multiSelection' && @@ -44,9 +118,13 @@ function isMultiSelectionOf(selection, type) { function withoutItem(selection, id) { const items = selection.items.filter(item => item.id !== id); + if (selection.selectionMode) { + return items.length ? {...selection, items} : selection; + } + if (items.length > 1) { return {...selection, items}; } - return {type: selection.itemType, id: items[0].id}; + return items.length ? {type: selection.itemType, id: items[0].id} : null; } diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb deleted file mode 100644 index 0d8c73ee31..0000000000 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb +++ /dev/null @@ -1,298 +0,0 @@ -require 'spec_helper' -require 'pageflow/editor_controller_test_helper' -require 'pageflow/shared_contexts/fake_translations' - -module PageflowScrolled - RSpec.describe Editor::FragmentExtractionsController, type: :controller do - include Pageflow::EditorControllerTestHelper - - routes { PageflowScrolled::Engine.routes } - - describe '#create' do - it 'requires authentication' do - entry = create(:entry, type_name: 'scrolled') - chapter = create(:scrolled_chapter, revision: entry.draft) - - post_extraction(entry:, chapter:) - - expect(response.status).to eq(401) - end - - it 'creates fragment library for account of entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - - expect { post_extraction(entry:, chapter:) } - .to change { Pageflow::Entry.where(account:, fragment_library: 'shared').count }.by(1) - expect(response.status).to eq(201) - end - - it 'creates scrolled library entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).type_name).to eq('scrolled') - end - - describe 'library title' do - include_context 'fake translations' - - it 'includes account name in locale of entry' do - translation(:de, 'pageflow_scrolled.fragment_library.title', - 'Bausteine von %{account_name}') - translation(:en, 'pageflow_scrolled.fragment_library.title', - 'Shared fragments of %{account_name}') - user = create(:user) - account = create(:account, name: 'Acme', with_editor: user) - entry = create(:entry, - type_name: 'scrolled', - account:, - with_feature: 'fragments', - draft_attributes: {locale: 'de'}) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).title).to eq('Bausteine von Acme') - end - - it 'falls back to English for other locales' do - translation(:de, 'pageflow_scrolled.fragment_library.title', - 'Bausteine von %{account_name}') - translation(:en, 'pageflow_scrolled.fragment_library.title', - 'Shared fragments of %{account_name}') - user = create(:user) - account = create(:account, name: 'Acme', with_editor: user) - entry = create(:entry, - type_name: 'scrolled', - account:, - with_feature: 'fragments', - draft_attributes: {locale: 'fr'}) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).title).to eq('Shared fragments of Acme') - end - end - - it 'copies chapter with its sections into library' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro', 'kind' => 'intro'}) - create(:section, chapter:, position: 0) - create(:section, chapter:, position: 1) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Intro', 'kind' => 'intro') - expect(fragment.sections.count).to eq(2) - end - - it 'uses given title for fragment' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro', 'kind' => 'intro'}) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:, title: 'Opening with video') - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Opening with video', - 'kind' => 'intro') - end - - it 'keeps chapter title if blank title is given' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro'}) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:, title: ' ') - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Intro') - end - - it 'adds fragment to main storyline of library' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.storyline.role).to eq('main') - end - - it 'keeps sections of chapter in entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - create(:section, chapter:) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(chapter.sections.count).to eq(1) - end - - it 'appends fragment to existing library of account' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Outro'}) - library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') - create(:scrolled_chapter, - revision: library.draft, - position: 0, - configuration: {'title' => 'Intro'}) - - sign_in_and_lock(entry, user) - - expect { post_extraction(entry:, chapter:) } - .not_to(change { Pageflow::Entry.where(fragment_library: 'shared').count }) - expect(Chapter.all_for_revision(library.draft).map { |c| c.configuration['title'] }) - .to eq(%w[Intro Outro]) - end - - it 'does not add fragment to library of other account' do - user = create(:user) - account = create(:account, with_editor: user) - other_account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - other_library = create(:entry, - type_name: 'scrolled', - account: other_account, - fragment_library: 'shared') - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(Chapter.all_for_revision(other_library.draft)).to be_empty - expect(find_library(account)).to be_present - end - - it 'does not let user without account editor role create library' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(403) - expect(find_library(account)).to be_nil - end - - it 'lets user with editor role on existing library add fragment' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - library = create(:entry, - type_name: 'scrolled', - account:, - fragment_library: 'shared', - with_editor: user) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(201) - expect(Chapter.all_for_revision(library.draft).count).to eq(1) - end - - it 'does not let user who may not edit existing library add fragment' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(403) - expect(Chapter.all_for_revision(library.draft)).to be_empty - end - - it 'responds with not found for chapter of other entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - other_entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: other_entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(404) - end - - it 'responds with not found if fragments feature is disabled' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(404) - expect(find_library(account)).to be_nil - end - end - - def find_library(account) - Pageflow::Entry.find_by(account:, fragment_library: 'shared') - end - - def post_extraction(entry:, chapter:, title: nil) - post(:create, - params: { - entry_type: 'scrolled', - entry_id: entry, - chapter_id: chapter, - title: - }.compact, format: 'json') - end - - def sign_in_and_lock(entry, user) - sign_in(user, scope: :user) - acquire_edit_lock(user, entry) - end - end -end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb new file mode 100644 index 0000000000..42dff6f83b --- /dev/null +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb @@ -0,0 +1,162 @@ +require 'spec_helper' +require 'pageflow/editor_controller_test_helper' + +module PageflowScrolled + RSpec.describe Editor::SectionFragmentExtractionsController, type: :controller do + include Pageflow::EditorControllerTestHelper + + routes { PageflowScrolled::Engine.routes } + + describe '#create' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(401) + end + + it 'copies sections into fragment of library' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + section = create(:section, revision: entry.draft, configuration: {'layout' => 'left'}) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section], title: 'Intro') + + fragment = Chapter.all_for_revision(find_library(account).draft).first + expect(response.status).to eq(201) + expect(fragment.configuration).to eq('title' => 'Intro') + expect(fragment.sections.map(&:configuration)).to match([include('layout' => 'left')]) + end + + it 'copies sections in document order' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + first_chapter = create(:scrolled_chapter, revision: entry.draft, position: 0) + second_chapter = create(:scrolled_chapter, revision: entry.draft, position: 1) + second_section = create(:section, chapter: second_chapter, + configuration: {'layout' => 'right'}) + first_section = create(:section, chapter: first_chapter, + configuration: {'layout' => 'left'}) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [second_section, first_section]) + + expect(Section.all_for_revision(find_library(account).draft) + .map { |section| section.configuration['layout'] }) + .to eq(%w[left right]) + end + + it 'does not let user who may not edit library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(403) + expect(find_library(account)).to be_nil + end + + it 'lets user with editor role on existing library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + library = create(:entry, + type_name: 'scrolled', + account:, + fragment_library: 'shared', + with_editor: user) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(201) + expect(Chapter.all_for_revision(library.draft).count).to eq(1) + end + + it 'does not let user who may not edit existing library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(403) + expect(Chapter.all_for_revision(library.draft)).to be_empty + end + + it 'responds with not found for section of other entry' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + other_entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + other_section = create(:section, revision: other_entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section, other_section]) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + + it 'responds with not found without sections' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: []) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + + it 'responds with not found if fragments feature is disabled' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + end + + def find_library(account) + Pageflow::Entry.find_by(account:, fragment_library: 'shared') + end + + def post_extraction(entry:, sections:, title: nil) + post(:create, + params: { + entry_type: 'scrolled', + entry_id: entry, + section_ids: sections.map(&:id), + title: + }.compact, format: 'json') + end + + def sign_in_and_lock(entry, user) + sign_in(user, scope: :user) + acquire_edit_lock(user, entry) + end + end +end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb similarity index 98% rename from entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb rename to entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb index 41b179479c..2311054b16 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb @@ -2,7 +2,7 @@ require 'pageflow/editor_controller_test_helper' module PageflowScrolled - RSpec.describe Editor::FragmentInsertionsController, type: :controller do + RSpec.describe Editor::SectionFragmentInsertionsController, type: :controller do render_views include Pageflow::EditorControllerTestHelper diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb index d9fd708486..cc572dc411 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'pageflow/shared_contexts/fake_translations' module PageflowScrolled RSpec.describe FragmentLibrary do @@ -79,7 +80,7 @@ module PageflowScrolled end end - describe '#copy_fragment_to' do + describe '#copy_sections_to' do it 'copies sections of fragment chapter to end of chapter' do library_entry = create(:entry, type_name: 'scrolled', fragment_library: 'shared') fragment = create(:scrolled_chapter, revision: library_entry.draft) @@ -89,7 +90,7 @@ module PageflowScrolled create(:section, chapter:, position: 0) library = FragmentLibrary.new(Pageflow::DraftEntry.new(library_entry)) - sections = library.copy_fragment_to(fragment_perma_id: fragment.perma_id, + sections = library.copy_sections_to(fragment_perma_id: fragment.perma_id, entry: Pageflow::DraftEntry.new(entry), chapter:) @@ -105,7 +106,7 @@ module PageflowScrolled library = FragmentLibrary.new(Pageflow::DraftEntry.new(library_entry)) expect { - library.copy_fragment_to(fragment_perma_id: 404, + library.copy_sections_to(fragment_perma_id: 404, entry: Pageflow::DraftEntry.new(entry), chapter:) }.to raise_error(ActiveRecord::RecordNotFound) @@ -207,14 +208,14 @@ module PageflowScrolled end end - describe '#extract_fragment_from' do + describe '#extract_sections_from' do it 'creates library entry on first extraction' do - account = create(:account, name: 'Acme') + account = create(:account) entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, revision: entry.draft) library = FragmentLibrary.shared_for_account(account) - library.extract_fragment_from(entry: Pageflow::DraftEntry.new(entry), chapter:) + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) expect(library).to be_persisted expect(library.entry.to_model).to have_attributes(account:, @@ -222,20 +223,71 @@ module PageflowScrolled fragment_library: 'shared') end - it 'copies chapter with its sections into library' do + it 'copies sections into chapter with title' do account = create(:account) entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro'}) - create(:section, chapter:) + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:, position: 0, + configuration: {'layout' => 'left'}) + second_section = create(:section, chapter:, position: 1, + configuration: {'layout' => 'right'}) + create(:content_element, :heading, section: second_section) library = FragmentLibrary.shared_for_account(account) - library.extract_fragment_from(entry: Pageflow::DraftEntry.new(entry), chapter:) + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), + sections: [first_section, second_section], + title: ' Intro ') - expect(library.content.chapters.map(&:configuration)) - .to match([include('title' => 'Intro')]) - expect(library.content.sections.size).to eq(1) + expect(library.content.chapters.map(&:configuration)).to eq([{'title' => 'Intro'}]) + expect(library.content.sections.map(&:configuration)) + .to match([include('layout' => 'left'), include('layout' => 'right')]) + expect(library.content.content_elements.map(&:type_name)).to eq(%w[heading]) + end + + it 'omits title if blank title is given' do + account = create(:account) + entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), + sections: [section], + title: ' ') + + expect(library.content.chapters.first.configuration).to eq({}) + end + + describe 'library title' do + include_context 'fake translations' + + before do + translation(:de, 'pageflow_scrolled.fragment_library.title', + 'Bausteine von %{account_name}') + translation(:en, 'pageflow_scrolled.fragment_library.title', + 'Shared fragments of %{account_name}') + end + + it 'includes account name in locale of entry' do + account = create(:account, name: 'Acme') + entry = create(:entry, type_name: 'scrolled', account:, draft_attributes: {locale: 'de'}) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) + + expect(library.entry.to_model.title).to eq('Bausteine von Acme') + end + + it 'falls back to English for other locales' do + account = create(:account, name: 'Acme') + entry = create(:entry, type_name: 'scrolled', account:, draft_attributes: {locale: 'fr'}) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) + + expect(library.entry.to_model.title).to eq('Shared fragments of Acme') + end end end diff --git a/package/documentation.yml b/package/documentation.yml index d859d55e2b..43dbc2d19e 100644 --- a/package/documentation.yml +++ b/package/documentation.yml @@ -60,6 +60,7 @@ toc: - DropDownButtonView - DestroyMenuItem - ListView + - MarkButtonView - ModelThumbnailView - name: UI - Input Views diff --git a/package/spec/editor/collections/ListSelection-spec.js b/package/spec/editor/collections/ListSelection-spec.js index 0d3bd5f450..b7adb09546 100644 --- a/package/spec/editor/collections/ListSelection-spec.js +++ b/package/spec/editor/collections/ListSelection-spec.js @@ -47,6 +47,62 @@ describe('ListSelection', () => { expect(selection.models).toEqual([file]); }); + describe('#isActive', () => { + it('is false by default', () => { + const selection = new ListSelection(); + + expect(selection.isActive()).toBe(false); + }); + + it('is true while items are selected', () => { + const selection = new ListSelection([f.file({id: 1})]); + + expect(selection.isActive()).toBe(true); + }); + + it('is true after start while no items are selected', () => { + const selection = new ListSelection(); + + selection.start(); + + expect(selection.isActive()).toBe(true); + }); + + it('is false after stop', () => { + const selection = new ListSelection([f.file({id: 1})]); + + selection.start(); + selection.stop(); + + expect(selection.isActive()).toBe(false); + }); + }); + + describe('#start', () => { + it('does not trigger event when already started', () => { + const selection = new ListSelection(); + const listener = jest.fn(); + selection.start(); + selection.on('change:selecting', listener); + + selection.start(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('#stop', () => { + it('does not trigger events when not active', () => { + const selection = new ListSelection(); + const listener = jest.fn(); + selection.on('all', listener); + + selection.stop(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('#destroyAll', () => { it('destroys every selected item', () => { const file = f.file({id: 1}); diff --git a/package/spec/editor/views/MarkButtonView-spec.js b/package/spec/editor/views/MarkButtonView-spec.js new file mode 100644 index 0000000000..4a61bdffd4 --- /dev/null +++ b/package/spec/editor/views/MarkButtonView-spec.js @@ -0,0 +1,110 @@ +import {MarkButtonView, ListSelection} from 'pageflow/editor'; +import Backbone from 'backbone'; +import {renderBackboneView as render} from 'pageflow/testHelpers'; +import {within} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; + +describe('MarkButtonView', () => { + function setup({selection = new ListSelection(), ...options} = {}) { + const model = new Backbone.Model(); + const view = new MarkButtonView({model, selection, label: 'Mark', ...options}); + + render(view); + + return {view, model, selection, ...within(view.el.parentNode)}; + } + + it('renders unpressed button', () => { + const {getByRole} = setup(); + + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('aria-pressed', 'false'); + }); + + it('marks model on click', async () => { + const user = userEvent.setup(); + const {model, selection, getByRole} = setup(); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(selection.models).toEqual([model]); + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('aria-pressed', 'true'); + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marked'); + }); + + it('unmarks marked model on click', async () => { + const user = userEvent.setup(); + const {model, selection, getByRole} = setup(); + selection.add(model); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(selection.length).toBe(0); + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marked'); + }); + + it('triggers toggle event after selection changed', async () => { + const user = userEvent.setup(); + const {view, model, selection, getByRole} = setup(); + const listener = jest.fn(() => expect(selection.includes(model)).toBe(true)); + view.on('toggle', listener); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(listener).toHaveBeenCalled(); + }); + + it('does not let click reach parent elements', async () => { + const user = userEvent.setup(); + const {view, getByRole} = setup(); + const listener = jest.fn(); + view.el.parentNode.addEventListener('click', listener); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('indicates marking while other models are marked', () => { + const {getByRole} = setup({selection: new ListSelection([new Backbone.Model()])}); + + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marking'); + }); + + it('indicates marking while selection is active', () => { + const {selection, getByRole} = setup(); + + selection.start(); + + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marking'); + }); + + it('does not indicate marking by default', () => { + const {getByRole} = setup(); + + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marking'); + }); + + it('updates marked state when selection is reset', () => { + const {model, selection, getByRole} = setup(); + selection.add(model); + + selection.reset(); + + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marked'); + }); + + it('uses start marking label while selection is not active', () => { + const {getByRole} = setup({startMarkingLabel: 'Start'}); + + expect(getByRole('button', {name: 'Start'})).toHaveAttribute('title', 'Start'); + }); + + it('uses label while selection is active', () => { + const {selection, getByRole} = setup({startMarkingLabel: 'Start'}); + + selection.start(); + + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('title', 'Mark'); + }); +}); diff --git a/package/spec/ui/views/TabsView-spec.js b/package/spec/ui/views/TabsView-spec.js index a4c89d35ee..19c23c78e0 100644 --- a/package/spec/ui/views/TabsView-spec.js +++ b/package/spec/ui/views/TabsView-spec.js @@ -117,6 +117,55 @@ describe('TabsView', () => { }); }); + describe('change event', () => { + useFakeTranslations({ + 'tabs.one': 'Tab one', + 'tabs.two': 'Tab two' + }); + + function renderTabs() { + var tabsView = new TabsView({i18n: 'tabs'}); + + tabsView.tab('one', function() { return new Backbone.View(); }); + tabsView.tab('two', function() { return new Backbone.View(); }); + + var listener = jest.fn(); + tabsView.on('change', listener); + + return {tabsView, listener, ...renderBackboneView(tabsView)}; + } + + it('is triggered with name of tab when other tab is selected', () => { + var {listener, getByRole} = renderTabs(); + + fireEvent.click(getByRole('tab', {name: 'Tab two'})); + + expect(listener).toHaveBeenCalledWith('two'); + }); + + it('is not triggered for default tab', () => { + var {listener} = renderTabs(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('is not triggered when current tab is selected again', () => { + var {listener, getByRole} = renderTabs(); + + fireEvent.click(getByRole('tab', {name: 'Tab one'})); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('is not triggered when tab is refreshed', () => { + var {tabsView, listener} = renderTabs(); + + tabsView.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('accessibility', () => { useFakeTranslations({ 'tabs.one': 'Tab one', diff --git a/package/src/editor/collections/ListSelection.js b/package/src/editor/collections/ListSelection.js index 6434e6cf09..854b0ec703 100644 --- a/package/src/editor/collections/ListSelection.js +++ b/package/src/editor/collections/ListSelection.js @@ -9,11 +9,19 @@ export const ListSelection = Backbone.Collection.extend({ // even while nothing is checked. Unchecking the last item would // otherwise pull the check boxes away from under the pointer. start: function() { + if (this.selecting) { + return; + } + this.selecting = true; this.trigger('change:selecting'); }, stop: function() { + if (!this.isActive()) { + return; + } + this.selecting = false; this.reset(); this.trigger('change:selecting'); @@ -23,6 +31,10 @@ export const ListSelection = Backbone.Collection.extend({ return !!this.selecting; }, + isActive: function() { + return this.isSelecting() || !!this.length; + }, + toggle: function(item) { if (this.includes(item)) { this.remove(item); diff --git a/package/src/editor/index.js b/package/src/editor/index.js index 5c0f2b56c7..d6b8cf04cf 100644 --- a/package/src/editor/index.js +++ b/package/src/editor/index.js @@ -101,6 +101,7 @@ export * from './views/UploaderView'; export * from './views/BackgroundPositioningView'; export * from './views/BackgroundPositioningImageView'; export * from './views/ExplorerFileItemView'; +export * from './views/MarkButtonView'; export * from './views/ExplorerFileDetailsView'; export * from './views/ExplorerFilesView'; export * from './views/ExplorerFolderItemView'; diff --git a/package/src/editor/templates/explorerFileItem.jst b/package/src/editor/templates/explorerFileItem.jst index 160b6f6ec3..fd5daa5d59 100644 --- a/package/src/editor/templates/explorerFileItem.jst +++ b/package/src/editor/templates/explorerFileItem.jst @@ -1,5 +1,3 @@ - - diff --git a/package/src/editor/views/ExplorerFileItemView.js b/package/src/editor/views/ExplorerFileItemView.js index de2ab78c98..ec612405de 100644 --- a/package/src/editor/views/ExplorerFileItemView.js +++ b/package/src/editor/views/ExplorerFileItemView.js @@ -2,6 +2,7 @@ import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import {FileThumbnailView} from './FileThumbnailView'; +import {MarkButtonView} from './MarkButtonView'; import {loadable} from './mixins/loadable'; import {selectableView} from './mixins/selectableView'; @@ -17,21 +18,10 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ ui: { fileName: '.file_name', - thumbnail: '.file_thumbnail', - markButton: '.explorer_file_item-mark' + thumbnail: '.file_thumbnail' }, events: { - 'click .explorer_file_item-mark': function() { - this.options.markedFiles.toggle(this.model); - - if (this.options.markedFiles.includes(this.model)) { - this.select(); - } - - return false; - }, - 'click': function() { if (!this.isDisabled()) { this.select(); @@ -53,14 +43,31 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ this.update(); this.updateMarked(); - if (this.isDisabled() || !this.options.markedFiles) { - this.ui.markButton.remove(); - } - this.subview(new FileThumbnailView({ el: this.ui.thumbnail, model: this.model })); + + if (!this.isDisabled() && this.options.markedFiles) { + this.appendMarkButton(); + } + }, + + appendMarkButton: function() { + var markButton = new MarkButtonView({ + model: this.model, + selection: this.options.markedFiles, + label: I18n.t('pageflow.editor.views.explorer_file_item_view.mark'), + startMarkingLabel: I18n.t('pageflow.editor.views.explorer_file_item_view.start_marking') + }); + + this.appendSubview(markButton); + + this.listenTo(markButton, 'toggle', function() { + if (this.options.markedFiles.includes(this.model)) { + this.select(); + } + }); }, update: function() { @@ -77,19 +84,8 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ }, updateMarked: function() { - var marked = !!this.options.markedFiles?.includes(this.model); - this.$el.toggleClass('marking', this.isMarking()); - this.$el.toggleClass('marked', marked); - var label = I18n.t(this.isMarking() ? - 'pageflow.editor.views.explorer_file_item_view.mark' : - 'pageflow.editor.views.explorer_file_item_view.start_marking'); - - this.ui.markButton.attr({ - 'aria-pressed': marked ? 'true' : 'false', - 'aria-label': label, - title: label - }); + this.$el.toggleClass('marked', !!this.options.markedFiles?.includes(this.model)); }, isDisabled: function() { diff --git a/package/src/editor/views/MarkButtonView.js b/package/src/editor/views/MarkButtonView.js new file mode 100644 index 0000000000..738307a195 --- /dev/null +++ b/package/src/editor/views/MarkButtonView.js @@ -0,0 +1,68 @@ +import Marionette from 'backbone.marionette'; + +/** + * Round check button that adds a model to or removes it from a + * {@link ListSelection}. + * + * Has class `marking` while the selection is active and + * class `marked` while the model is included. Triggers a `toggle` + * event after the selection changed. + * + * @param {Object} options + * + * @param {Backbone.Model} options.model + * + * @param {ListSelection} options.selection + * + * @param {String} options.label + * Accessible label and title. + * + * @param {String} [options.startMarkingLabel] + * Label to use instead while the selection is not active. + * + * @class + */ +export const MarkButtonView = Marionette.ItemView.extend({ + tagName: 'button', + className: 'mark_button', + template: () => '', + + attributes: { + type: 'button' + }, + + events: { + 'click': function() { + this.options.selection.toggle(this.model); + this.trigger('toggle'); + + return false; + } + }, + + initialize: function() { + this.listenTo(this.options.selection, 'add remove reset change:selecting', this.update); + }, + + onRender: function() { + this.update(); + }, + + update: function() { + var selection = this.options.selection; + var marking = selection.isActive(); + var marked = selection.includes(this.model); + var label = marking ? + this.options.label : + this.options.startMarkingLabel || this.options.label; + + this.$el.toggleClass('marking', marking); + this.$el.toggleClass('marked', marked); + + this.$el.attr({ + 'aria-pressed': marked ? 'true' : 'false', + 'aria-label': label, + title: label + }); + } +}); diff --git a/package/src/ui/views/TabsView.js b/package/src/ui/views/TabsView.js index 62ea093c9f..bf77e5c0a0 100644 --- a/package/src/ui/views/TabsView.js +++ b/package/src/ui/views/TabsView.js @@ -27,6 +27,9 @@ import template from '../templates/tabsView.jst'; * @param {string} [options.i18n] * Legacy alias for `fallbackTranslationKeyPrefix`. * + * Triggers a `change` event with the name of the new tab when the + * user or a call to `changeTab` switches to a different tab. + * * @class */ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ @@ -106,7 +109,9 @@ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ return; } - if (this.currentTabName !== name || options.refresh) { + var previousTabName = this.currentTabName; + + if (previousTabName !== name || options.refresh) { this.container.show(this.tabFactoryFns[name]()); } @@ -114,6 +119,10 @@ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ this._updateActiveTab(name, options.focusTab); this._updateActivePanel(name); + + if (previousTabName && previousTabName !== name) { + this.trigger('change', name); + } }, defaultTab: function() {