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({
+