diff --git a/app/assets/stylesheets/pageflow/editor/base.scss b/app/assets/stylesheets/pageflow/editor/base.scss index af391ec0f6..35e0e63498 100644 --- a/app/assets/stylesheets/pageflow/editor/base.scss +++ b/app/assets/stylesheets/pageflow/editor/base.scss @@ -59,6 +59,7 @@ @import "./composables"; @import "./list"; @import "./drop_down_button"; + @import "./edit_configuration_view"; @import "./outline"; @import "./sortable"; diff --git a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss index 81ee6b55dc..f5f5e7e77a 100644 --- a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss +++ b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss @@ -12,9 +12,16 @@ > button.ellipsis_icon { @include fa-ellipsis-v-icon; + } + + > button.ellipsis_icon.has_icon_only { width: var(--drop-down-button-width, 31px); } + > button.ellipsis_icon.has_icon_and_text::before { + margin-right: 9px; + } + > button.borderless { --ui-button-border-color: transparent; --ui-button-hover-border-color: transparent; @@ -102,6 +109,11 @@ padding-top: 1px; } + &.is_destructive a:hover { + background-color: var(--ui-error-color); + color: var(--ui-on-error-color); + } + &.has_radio, &.has_check_box { a { diff --git a/app/assets/stylesheets/pageflow/editor/edit_configuration_view.scss b/app/assets/stylesheets/pageflow/editor/edit_configuration_view.scss new file mode 100644 index 0000000000..612240b395 --- /dev/null +++ b/app/assets/stylesheets/pageflow/editor/edit_configuration_view.scss @@ -0,0 +1,5 @@ +.edit_configuration_view { + .actions_drop_down_button { + float: right; + } +} diff --git a/app/assets/stylesheets/pageflow/editor/info_box.scss b/app/assets/stylesheets/pageflow/editor/info_box.scss index daf43e4eda..3bb7896688 100644 --- a/app/assets/stylesheets/pageflow/editor/info_box.scss +++ b/app/assets/stylesheets/pageflow/editor/info_box.scss @@ -18,6 +18,18 @@ background-color: var(--ui-error-surface-color); } + .with_icon { + display: flex; + align-items: center; + gap: space(2); + + img { + flex-shrink: 0; + width: space(5); + height: space(5); + } + } + .shortcuts { dt { display: block; diff --git a/config/locales/de.yml b/config/locales/de.yml index bae7e9e8b8..5ac8dce4f2 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1512,7 +1512,11 @@ de: Kapitel einschließlich ALLER enthaltener Seiten wirklich löschen? Dieser Schritt kann nicht rückgängig gemacht werden. + destroy_menu_item: + confirm_destroy: Wirklich löschen? + destroy: Löschen edit_configuration: + actions: Aktionen back: "Zurück" confirm_destroy: Wirklich löschen? destroy: Löschen @@ -2115,12 +2119,6 @@ de: next: ">>" previous: !!str '<<' truncate: "..." - pageflow_scrolled: - editor: - section_item: - set_cutoff: "Paywall Grenze oberhalb setzen" - reset_cutoff: "Paywall Grenze entfernen" - cutoff: "Paywall Grenze" activemodel: attributes: pageflow/site_root_entry_form: diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d6a46a1bb..85e3d2d473 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1505,7 +1505,11 @@ en: Really delete this chapter including ALL its pages? This operation cannot be undone. + destroy_menu_item: + confirm_destroy: Really delete this record? This action cannot be undone. + destroy: Delete edit_configuration: + actions: Actions back: "Back" confirm_destroy: Really delete this record? This action cannot be undone. destroy: Delete @@ -2106,12 +2110,6 @@ en: next: ">>" previous: !!str '<<' truncate: "..." - pageflow_scrolled: - editor: - section_item: - set_cutoff: "Set paywall cutoff above" - reset_cutoff: "Remove paywall cutoff" - cutoff: "Paywall cutoff" activemodel: attributes: pageflow/site_root_entry_form: diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 10064a6edf..580a534809 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -445,9 +445,9 @@ de: Karten mit einer Flip-Animation drehen und weitere Inhalte auf der Rückseite der Karte anbieten. back: Zurück - destroy: Löschen + confirm_destroy: Soll der Teaser wirklich gelöscht werden? + destroy: Teaser löschen description: Liste von Kacheln mit Bild und Beschriftung - confirm_delete_item: Soll der Teaser wirklich gelöscht werden? items: Einträge name: Teaser-Liste tabs: @@ -591,8 +591,8 @@ de: general: Bildergalerie edit_item: back: Zurück - destroy: Löschen - confirm_delete_link: Soll der Bildergalerie-Eintrag wirklich gelöscht werden? + confirm_destroy: Soll der Bildergalerie-Eintrag wirklich gelöscht werden? + destroy: Eintrag löschen attributes: image: label: Bild @@ -893,8 +893,8 @@ de: hotspots: edit_area: back: Zurück - destroy: Löschen - confirm_delete_link: Soll der Hotspot-Bereich wirklich gelöscht werden? + confirm_destroy: Soll der Hotspot-Bereich wirklich gelöscht werden? + destroy: Hotspot-Bereich löschen tabs: area: Hotspot-Bereich portrait: Hochkant @@ -1133,10 +1133,6 @@ de: inline_help: |- Kapitel kann als Link-Ziel verwendet werden, erscheint allerdings nicht in der Navigationsleiste. - confirm_destroy: |- - Kapitel einschließlich ALLER enthaltener Abschnitte wirklich löschen? - - Dieser Schritt kann nicht rückgängig gemacht werden. save_error: Beim Speichern des Kapitels ist ein Fehler aufgetreten. tabs: chapter: Kapitel @@ -1149,7 +1145,27 @@ de: hint: Ziehe ein Rechteck, um den wichtigsten Bereich des Bildes zu markieren. reset: Zurücksetzen save: Speichern + chapter_menu_items: + move_to_main: In Kapitel umwandeln + move_to_excursions: In Exkurs umwandeln + destroy_chapter_menu_item: + confirm_destroy: |- + Kapitel inklusive ALLER Abschnitte wirklich löschen? + + Diese Aktion kann nicht rückgängig gemacht werden. + destroy: Kapitel löschen + destroy_content_element_menu_item: + confirm_destroy: Element wirklich löschen? + destroy: Element löschen + selection_label: Auswahl löschen + duplicate_content_element_menu_item: + label: Element duplizieren + selection_label: Auswahl duplizieren + destroy_section_menu_item: + confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? + destroy: Abschnitt löschen edit_section: + hidden_info: Dieser Abschnitt ist außerhalb des Editors ausgeblendet. motif_area_info_text: Markiere den wichtigsten Teil des Hintergrunds, der beim Erreichen des Abschnitts sichtbar und nicht von anderen Elementen überdeckt sein soll. attributes: appearance: @@ -1265,11 +1281,9 @@ de: header: Element einfügen no_options: Keine Optionen verfügbar section_item: + cutoff: Paywall Grenze drag_hint: Ziehen, um den Abschnitt zu verschieben - duplicate: Duplizieren - insert_section_above: Abschnitt oberhalb einfügen - insert_section_below: Abschnitt unterhalb einfügen - copy_permalink: Permalink kopieren + hidden: Nur im Editor sichtbar save_error: Beim Speichern des Abschnitts ist ein Fehler aufgetreten. transitions: beforeAfter: Statische Hintergründe @@ -1278,9 +1292,15 @@ de: reveal: Freilegen scroll: Aus-/Einscrollen scrollOver: Überlagern + section_menu_items: + copy_permalink: Permalink kopieren + duplicate: Abschnitt duplizieren hide: Außerhalb des Editors ausblenden + insert_section_above: Abschnitt oberhalb einfügen + insert_section_below: Abschnitt unterhalb einfügen + reset_cutoff: Paywall Grenze entfernen + set_cutoff: Paywall Grenze oberhalb setzen show: Außerhalb des Editors einblenden - hidden: Nur im Editor sichtbar section_padding_visualization: intersecting_auto: "Darstellung des dynamischen Abstands, der sich an die Größe des Motivbereichs anpasst, um Überlappungen von Text und Motiv zu vermeiden" intersecting_manual: "Darstellung des manuell definierten Abstands, der bei Änderung der Fenstergröße konstant bleibt" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index ff5360e890..dc5dba9587 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -436,9 +436,9 @@ en: Turn cards with a flip animation and offer additional content on the backside of the card. back: Back - destroy: Delete + confirm_destroy: Are you sure you want to delete this teaser? + destroy: Delete teaser description: A list of tiles with thumbnail and caption - confirm_delete_item: Are you sure you want to delete this teaser? items: Items name: Teasers tabs: @@ -577,8 +577,8 @@ en: general: Image Gallery edit_item: back: Back - destroy: Delete - confirm_delete_link: Are you sure you want to delete this image gallery item? + confirm_destroy: Are you sure you want to delete this image gallery item? + destroy: Delete item attributes: image: label: Image @@ -878,8 +878,8 @@ en: hotspots: edit_area: back: Back - destroy: Delete - confirm_delete_link: Are you sure you want to delete this area? + confirm_destroy: Are you sure you want to delete this area? + destroy: Delete area tabs: area: Hotspot Area portrait: Portrait @@ -1117,10 +1117,6 @@ en: inline_help: |- Chapter can be used as link destination but does not appear in the navigation bar. - confirm_destroy: |- - Really delete this chapter including ALL its sections? - - This operation cannot be undone. save_error: There was an error while saving this chapter. tabs: chapter: Chapter @@ -1133,7 +1129,27 @@ en: hint: Drag to select the most important part of the image. reset: Reset save: Save + chapter_menu_items: + move_to_main: Turn into chapter + move_to_excursions: Turn into excursion + destroy_chapter_menu_item: + confirm_destroy: |- + Really delete this chapter including ALL its sections? + + This operation cannot be undone. + destroy: Delete chapter + destroy_content_element_menu_item: + confirm_destroy: Really delete this element? + destroy: Delete element + selection_label: Delete selection + duplicate_content_element_menu_item: + label: Duplicate element + selection_label: Duplicate selection + destroy_section_menu_item: + confirm_destroy: Really delete this section including all its elements? + destroy: Delete section edit_section: + hidden_info: This section is hidden outside of the editor. motif_area_info_text: Mark the most important part of the backdrop that should be visible and unobstructed when reaching the section. attributes: appearance: @@ -1249,11 +1265,9 @@ en: header: Insert element no_options: No options available section_item: + cutoff: Paywall cutoff drag_hint: Drag to move section - duplicate: Duplicate - insert_section_above: Insert section above - insert_section_below: Insert section below - copy_permalink: Copy permalink + hidden: Only visible in editor save_error: There was an error while saving this section. transitions: beforeAfter: Static Backgrounds @@ -1262,9 +1276,15 @@ en: reveal: Reveal scroll: Scroll scrollOver: Scroll over + section_menu_items: + copy_permalink: Copy permalink + duplicate: Duplicate section hide: Hide outside of the editor + insert_section_above: Insert section above + insert_section_below: Insert section below + reset_cutoff: Remove paywall cutoff + set_cutoff: Set paywall cutoff above show: Show outside of the editor - hidden: Only visible in editor section_padding_visualization: intersecting_auto: "Visualization of dynamic padding that adjusts to the motif area size to prevent text from overlapping the motif" intersecting_manual: "Visualization of manually defined padding that stays constant as viewport size changes" diff --git a/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/SidebarEditLinkView-spec.js b/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/SidebarEditLinkView-spec.js new file mode 100644 index 0000000000..443b27b9c3 --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/SidebarEditLinkView-spec.js @@ -0,0 +1,80 @@ +import {SidebarEditLinkView} from 'contentElements/externalLinkList/editor/SidebarEditLinkView'; +import {ExternalLinkCollection} from 'contentElements/externalLinkList/editor/models/ExternalLinkCollection'; + +import {editor} from 'pageflow/editor'; +import {DropDownButton} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('SidebarEditLinkView', () => { + useFakeXhr(); + const {createEntry} = useEditorGlobals(); + + beforeEach(() => { + editor.router = {navigate: jest.fn()}; + }); + + describe('destroy action', () => { + it('removes model from collection when confirmed', () => { + const entry = createEntry({ + contentElements: [ + { + id: 1, + typeName: 'externalLinkList', + configuration: { + links: [{id: 1}, {id: 2}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const links = ExternalLinkCollection.forContentElement(contentElement, entry); + const view = new SidebarEditLinkView({ + model: links.get(1), + collection: links, + entry, + contentElement + }); + window.confirm = jest.fn(() => true); + + view.render(); + DropDownButton.find(view).selectMenuItemByName('destroy'); + + expect(links.length).toBe(1); + expect(links.get(1)).toBeUndefined(); + }); + + }); + + describe('goBack', () => { + it('posts command to deselect item', () => { + const entry = createEntry({ + contentElements: [ + { + id: 1, + typeName: 'externalLinkList', + configuration: { + links: [{id: 1}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const links = ExternalLinkCollection.forContentElement(contentElement, entry); + const view = new SidebarEditLinkView({ + model: links.get(1), + collection: links, + entry, + contentElement + }); + contentElement.postCommand = jest.fn(); + + view.render(); + view.goBack(); + + expect(contentElement.postCommand).toHaveBeenCalledWith({ + type: 'SET_SELECTED_ITEM', + index: -1 + }); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js index 3599753422..6f8adfc883 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/SidebarEditAreaView-spec.js @@ -1,7 +1,8 @@ import {SidebarEditAreaView} from 'contentElements/hotspots/editor/SidebarEditAreaView'; import {AreasCollection} from 'contentElements/hotspots/editor/models/AreasCollection'; -import {ConfigurationEditor, Tabs, renderBackboneView as render, useFakeTranslations} from 'pageflow/testHelpers'; +import {editor} from 'pageflow/editor'; +import {ConfigurationEditor, DropDownButton, Tabs, renderBackboneView as render, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals, useFakeXhr} from 'support'; import userEvent from '@testing-library/user-event'; @@ -9,11 +10,83 @@ describe('SidebarEditAreaView', () => { useFakeXhr(); const {createEntry} = useEditorGlobals(); + beforeEach(() => { + editor.router = {navigate: jest.fn()}; + }); + useFakeTranslations({ 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs.area': 'Area', 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs.portrait': 'Portrait' }); + describe('destroy action', () => { + it('removes model from collection when confirmed', () => { + const entry = createEntry({ + imageFiles: [{perma_id: 10}], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + areas: [{id: 1}, {id: 2}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const areas = AreasCollection.forContentElement(contentElement); + const view = new SidebarEditAreaView({ + model: areas.get(1), + collection: areas, + entry, + contentElement + }); + window.confirm = jest.fn(() => true); + + view.render(); + DropDownButton.find(view).selectMenuItemByName('destroy'); + + expect(areas.length).toBe(1); + expect(areas.get(1)).toBeUndefined(); + }); + }); + + describe('goBack', () => { + it('posts command to deselect area', () => { + const entry = createEntry({ + imageFiles: [{perma_id: 10}], + contentElements: [ + { + id: 1, + typeName: 'hotspots', + configuration: { + image: 10, + areas: [{id: 1}] + } + } + ] + }); + const contentElement = entry.contentElements.get(1); + const areas = AreasCollection.forContentElement(contentElement); + const view = new SidebarEditAreaView({ + model: areas.get(1), + collection: areas, + entry, + contentElement + }); + contentElement.postCommand = jest.fn(); + + view.render(); + view.goBack(); + + expect(contentElement.postCommand).toHaveBeenCalledWith({ + type: 'SET_ACTIVE_AREA', + index: -1 + }); + }); + }); + it('renders portrait tab if portrait image is present', () => { const entry = createEntry({ imageFiles: [ 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 c1a338e043..332170cc43 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -301,4 +301,49 @@ describe('Chapter', () => { expect(chapter.isExcursion()).toBe(true); }); }); + + describe('#toggleExcursion', () => { + useFakeXhr(() => testContext); + + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [ + {id: 100, configuration: {main: true}}, + {id: 200} + ], + chapters: [ + {id: 1, storylineId: 100, position: 0}, + {id: 2, storylineId: 200, position: 0} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + it('moves chapter from main to excursions', () => { + const {entry} = testContext; + const chapter = entry.chapters.get(1); + + chapter.toggleExcursion(); + + expect(chapter.get('storylineId')).toEqual(200); + expect(entry.storylines.main().chapters.length).toEqual(0); + expect(entry.storylines.excursions().chapters.length).toEqual(2); + }); + + it('moves chapter from excursions to main', () => { + const {entry} = testContext; + const chapter = entry.chapters.get(2); + + chapter.toggleExcursion(); + + expect(chapter.get('storylineId')).toEqual(100); + expect(entry.storylines.main().chapters.length).toEqual(2); + expect(entry.storylines.excursions().chapters.length).toEqual(0); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js index 4855ce46a0..f669389779 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js @@ -37,7 +37,7 @@ describe('ScrolledEntry', () => { it('sends item with delete flag to batch endpoint', () => { const {entry, requests} = testContext; - entry.deleteContentElement(5); + entry.deleteContentElement(entry.contentElements.get(5)); expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); expect(JSON.parse(requests[0].requestBody)).toMatchObject({ @@ -113,7 +113,7 @@ describe('ScrolledEntry', () => { it('merges the two adjacent content elements ', () => { const {entry, requests} = testContext; - entry.deleteContentElement(5); + entry.deleteContentElement(entry.contentElements.get(5)); expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); expect(JSON.parse(requests[0].requestBody)).toEqual({ @@ -192,7 +192,7 @@ describe('ScrolledEntry', () => { it('leaves adjacent content elements unchanged', () => { const {entry, requests} = testContext; - entry.deleteContentElement(5); + entry.deleteContentElement(entry.contentElements.get(5)); expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); expect(JSON.parse(requests[0].requestBody)).toEqual({ @@ -245,7 +245,7 @@ describe('ScrolledEntry', () => { it('leaves adjacent content element unchanged', () => { const {entry, requests} = testContext; - entry.deleteContentElement(5); + entry.deleteContentElement(entry.contentElements.get(5)); expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); expect(JSON.parse(requests[0].requestBody)).toEqual({ diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/duplicateContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/duplicateContentElement-spec.js new file mode 100644 index 0000000000..2d04a526d4 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/duplicateContentElement-spec.js @@ -0,0 +1,96 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories, setupGlobals} from 'pageflow/testHelpers'; +import {useFakeXhr, normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('#duplicateContentElement', () => { + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {id: 1}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 10}], + sections: [{id: 100, chapterId: 10}], + contentElements: [ + {id: 1000, permaId: 1, sectionId: 100, position: 0, typeName: 'textBlock', + configuration: {value: 'Some text'}}, + {id: 1001, permaId: 2, sectionId: 100, position: 1, typeName: 'inlineImage'} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('creates content element with same type and configuration', () => { + const {entry} = testContext; + const contentElement = entry.contentElements.first(); + + const newContentElement = entry.duplicateContentElement(contentElement); + + expect(newContentElement.get('typeName')).toBe('textBlock'); + expect(newContentElement.configuration.get('value')).toBe('Some text'); + }); + + it('posts to batch endpoint', () => { + const {entry, requests} = testContext; + const contentElement = entry.contentElements.first(); + + entry.duplicateContentElement(contentElement); + + expect(requests[0].url).toBe('/editor/entries/1/scrolled/sections/100/content_elements/batch'); + }); + + it('adds duplicated content element after original on server response', () => { + const {entry, server} = testContext; + const section = entry.sections.first(); + const contentElement = entry.contentElements.first(); + + entry.duplicateContentElement(contentElement); + + server.respondWith( + 'PUT', + /content_elements\/batch/, + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + {id: 1000}, + {id: 1002, permaId: 3}, + {id: 1001} + ])] + ); + server.respond(); + + expect(section.contentElements.pluck('position')).toEqual([0, 1, 2]); + expect(section.contentElements.pluck('id')).toEqual([1000, 1002, 1001]); + }); + + it('selects duplicated content element after sync', () => { + const {entry, server} = testContext; + const contentElement = entry.contentElements.first(); + const listener = jest.fn(); + entry.on('selectContentElement', listener); + + const newContentElement = entry.duplicateContentElement(contentElement); + + server.respondWith( + 'PUT', + /content_elements\/batch/, + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + {id: 1000}, + {id: 1002, permaId: 3}, + {id: 1001} + ])] + ); + server.respond(); + + expect(listener).toHaveBeenCalledWith(newContentElement); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js b/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js index 2ebdb253c2..9a975086ab 100644 --- a/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js @@ -1,7 +1,7 @@ import 'pageflow-scrolled/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories, setupGlobals} from 'pageflow/testHelpers'; -import {normalizeSeed} from 'support'; +import {normalizeSeed, useFakeXhr} from 'support'; describe('Storyline', () => { let testContext; @@ -34,4 +34,54 @@ describe('Storyline', () => { expect(chapter.get('position')).toEqual(6); }); }); + + describe('#appendChapter', () => { + useFakeXhr(() => testContext); + + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [{id: 10}, {id: 20}], + chapters: [ + {id: 1, storylineId: 10, position: 0}, + {id: 2, storylineId: 10, position: 1}, + {id: 3, storylineId: 20, position: 0} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + it('moves chapter to end of storyline', () => { + const {entry} = testContext; + const sourceStoryline = entry.storylines.get(10); + const targetStoryline = entry.storylines.get(20); + const chapter = entry.chapters.get(1); + + targetStoryline.appendChapter(chapter); + + expect(sourceStoryline.chapters.length).toEqual(1); + expect(targetStoryline.chapters.length).toEqual(2); + expect(chapter.get('position')).toEqual(1); + }); + + it('sets position to 0 for empty storyline', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [{id: 10}, {id: 20}], + chapters: [ + {id: 1, storylineId: 10, position: 0} + ] + }) + }); + const chapter = entry.chapters.get(1); + + entry.storylines.excursions().appendChapter(chapter); + + expect(chapter.get('position')).toEqual(0); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js new file mode 100644 index 0000000000..098a95b17c --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -0,0 +1,243 @@ +import {DestroyContentElementMenuItem, DuplicateContentElementMenuItem} from 'editor/models/contentElementMenuItems'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {factories, normalizeSeed} from 'support'; + +describe('ContentElementMenuItems', () => { + describe('DuplicateContentElementMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.duplicate_content_element_menu_item.label': 'Duplicate element', + 'pageflow_scrolled.editor.duplicate_content_element_menu_item.selection_label': 'Duplicate selection' + }); + + it('has Duplicate element label', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Duplicate element'); + }); + + it('has Duplicate selection label when handleDuplicate is defined', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {handleDuplicate() {}}); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Duplicate selection'); + }); + + it('calls duplicateContentElement on entry when selected', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + entry.duplicateContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + expect(entry.duplicateContentElement).toHaveBeenCalledWith(contentElement); + }); + + it('calls handleDuplicate instead of duplicateContentElement if defined', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + const handleDuplicate = jest.fn(); + entry.duplicateContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {handleDuplicate}); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + expect(handleDuplicate).toHaveBeenCalledWith(contentElement); + expect(entry.duplicateContentElement).not.toHaveBeenCalled(); + }); + }); + + describe('DestroyContentElementMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.destroy_content_element_menu_item.destroy': 'Delete element', + 'pageflow_scrolled.editor.destroy_content_element_menu_item.selection_label': 'Delete selection', + 'pageflow_scrolled.editor.destroy_content_element_menu_item.confirm_destroy': 'Really delete this element?' + }); + + it('has Delete element label', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Delete element'); + }); + + it('has Delete selection label when handleDestroy is defined', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {handleDestroy() {}}); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Delete selection'); + }); + + it('calls deleteContentElement on entry when confirmed', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + entry.deleteContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(window.confirm).toHaveBeenCalledWith('Really delete this element?'); + expect(entry.deleteContentElement).toHaveBeenCalledWith(contentElement); + }); + + it('calls handleDestroy if content element type defines it', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + const handleDestroy = jest.fn(); + entry.deleteContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {handleDestroy}); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(handleDestroy).toHaveBeenCalledWith(contentElement); + expect(entry.deleteContentElement).toHaveBeenCalled(); + }); + + it('does not call deleteContentElement if handleDestroy returns false', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + entry.deleteContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', { + handleDestroy() { + return false; + } + }); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(entry.deleteContentElement).not.toHaveBeenCalled(); + }); + + it('does not delete when cancelled', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + entry.deleteContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + window.confirm = jest.fn().mockReturnValue(false); + + menuItem.selected(); + + expect(entry.deleteContentElement).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js new file mode 100644 index 0000000000..b6cc1285b6 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -0,0 +1,262 @@ +import { + HideShowSectionMenuItem, + DuplicateSectionMenuItem, + InsertSectionAboveMenuItem, + InsertSectionBelowMenuItem, + CutoffSectionMenuItem, + CopyPermalinkMenuItem, + DestroySectionMenuItem +} from 'editor/models/sectionMenuItems'; + +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; + +describe('SectionMenuItems', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.section_menu_items.hide': 'Hide', + 'pageflow_scrolled.editor.section_menu_items.show': 'Show', + 'pageflow_scrolled.editor.section_menu_items.duplicate': 'Duplicate', + 'pageflow_scrolled.editor.section_menu_items.insert_section_above': 'Insert above', + 'pageflow_scrolled.editor.section_menu_items.insert_section_below': 'Insert below', + 'pageflow_scrolled.editor.section_menu_items.set_cutoff': 'Set cutoff', + 'pageflow_scrolled.editor.section_menu_items.reset_cutoff': 'Reset cutoff', + 'pageflow_scrolled.editor.section_menu_items.copy_permalink': 'Copy permalink', + 'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section', + 'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?' + }); + + const {createEntry} = useEditorGlobals(); + + describe('HideShowSectionMenuItem', () => { + it('sets hidden configuration to true when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new HideShowSectionMenuItem({}, {section}); + + menuItem.selected(); + + expect(section.configuration.get('hidden')).toBe(true); + }); + + it('unsets hidden configuration when already hidden', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {hidden: true}}] + }); + const section = entry.sections.get(1); + const menuItem = new HideShowSectionMenuItem({}, {section}); + + menuItem.selected(); + + expect(section.configuration.get('hidden')).toBeUndefined(); + }); + + it('has Hide label when section is visible', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new HideShowSectionMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Hide'); + }); + + it('has Show label when section is hidden', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {hidden: true}}] + }); + const section = entry.sections.get(1); + const menuItem = new HideShowSectionMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Show'); + }); + + it('updates label when hidden state changes', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new HideShowSectionMenuItem({}, {section}); + + section.configuration.set('hidden', true); + + expect(menuItem.get('label')).toBe('Show'); + }); + }); + + describe('DuplicateSectionMenuItem', () => { + it('has Duplicate label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new DuplicateSectionMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Duplicate'); + }); + + it('calls duplicateSection on chapter when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + section.chapter.duplicateSection = jest.fn(); + const menuItem = new DuplicateSectionMenuItem({}, {section}); + + menuItem.selected(); + + expect(section.chapter.duplicateSection).toHaveBeenCalledWith(section); + }); + }); + + describe('InsertSectionAboveMenuItem', () => { + it('has Insert above label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new InsertSectionAboveMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Insert above'); + }); + + it('calls insertSection with before option when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + section.chapter.insertSection = jest.fn(); + const menuItem = new InsertSectionAboveMenuItem({}, {section}); + + menuItem.selected(); + + expect(section.chapter.insertSection).toHaveBeenCalledWith({before: section}); + }); + }); + + describe('InsertSectionBelowMenuItem', () => { + it('has Insert below label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new InsertSectionBelowMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Insert below'); + }); + + it('calls insertSection with after option when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + section.chapter.insertSection = jest.fn(); + const menuItem = new InsertSectionBelowMenuItem({}, {section}); + + menuItem.selected(); + + expect(section.chapter.insertSection).toHaveBeenCalledWith({after: section}); + }); + }); + + describe('CutoffSectionMenuItem', () => { + it('has Set cutoff label when not at section', () => { + const entry = createEntry({ + site: {cutoff_mode_name: 'subscription_headers'}, + sections: [{id: 1, permaId: 100}] + }); + const section = entry.sections.get(1); + const menuItem = new CutoffSectionMenuItem({}, { + section, + cutoff: entry.cutoff + }); + + expect(menuItem.get('label')).toBe('Set cutoff'); + }); + + it('has Reset cutoff label when at section', () => { + const entry = createEntry({ + site: {cutoff_mode_name: 'subscription_headers'}, + metadata: {configuration: {cutoff_section_perma_id: 100}}, + sections: [{id: 1, permaId: 100}] + }); + const section = entry.sections.get(1); + const menuItem = new CutoffSectionMenuItem({}, { + section, + cutoff: entry.cutoff + }); + + expect(menuItem.get('label')).toBe('Reset cutoff'); + }); + + it('sets cutoff to section when selected', () => { + const entry = createEntry({ + site: {cutoff_mode_name: 'subscription_headers'}, + sections: [{id: 1, permaId: 100}] + }); + const section = entry.sections.get(1); + const menuItem = new CutoffSectionMenuItem({}, { + section, + cutoff: entry.cutoff + }); + + menuItem.selected(); + + expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toBe(100); + }); + + it('resets cutoff when already at section', () => { + const entry = createEntry({ + site: {cutoff_mode_name: 'subscription_headers'}, + metadata: {configuration: {cutoff_section_perma_id: 100}}, + sections: [{id: 1, permaId: 100}] + }); + const section = entry.sections.get(1); + const menuItem = new CutoffSectionMenuItem({}, { + section, + cutoff: entry.cutoff + }); + + menuItem.selected(); + + expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toBeUndefined(); + }); + }); + + describe('CopyPermalinkMenuItem', () => { + it('has Copy permalink label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new CopyPermalinkMenuItem({}, {entry, section}); + + expect(menuItem.get('label')).toBe('Copy permalink'); + }); + + it('supports separated attribute', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new CopyPermalinkMenuItem({separated: true}, {entry, section}); + + expect(menuItem.get('separated')).toBe(true); + }); + + it('copies permalink to clipboard when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + entry.getSectionPermalink = jest.fn().mockReturnValue('http://example.com/section'); + const section = entry.sections.get(1); + const menuItem = new CopyPermalinkMenuItem({}, {entry, section}); + navigator.clipboard = {writeText: jest.fn()}; + + menuItem.selected(); + + expect(entry.getSectionPermalink).toHaveBeenCalledWith(section); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('http://example.com/section'); + }); + }); + + describe('DestroySectionMenuItem', () => { + it('has Delete section label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new DestroySectionMenuItem({}, {section}); + + expect(menuItem.get('label')).toBe('Delete section'); + }); + + it('calls destroyWithDelay on section when confirmed', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + section.destroyWithDelay = jest.fn(); + const menuItem = new DestroySectionMenuItem({}, {section}); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(window.confirm).toHaveBeenCalledWith('Really delete this section?'); + expect(section.destroyWithDelay).toHaveBeenCalled(); + }); + }); +}); 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 62678c069d..137e9c64c5 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js @@ -65,68 +65,4 @@ describe('EditContentElementView', () => { contentElement: contentElement }); }); - - it('lets content element types override detroy button', () => { - const editor = factories.editorApi(); - const entry = factories.entry(ScrolledEntry, {}, { - entryTypeSeed: normalizeSeed({ - contentElements: [ - {id: 1, typeName: 'textBlock'} - ] - }) - }); - const contentElement = entry.contentElements.get(1); - const view = new EditContentElementView({ - model: contentElement, - editor, - entry - }); - const handleDestroy = jest.fn(); - entry.deleteContentElement = jest.fn(); - - editor.contentElementTypes.register('textBlock', { - configurationEditor() { - this.tab('general', function() {}); - }, - - handleDestroy - }); - view.render(); - - view.destroyModel(); - - expect(handleDestroy).toHaveBeenCalledWith(contentElement); - }); - - it('does not call deleteContentElement if handleDestroy returns false', () => { - const editor = factories.editorApi(); - const entry = factories.entry(ScrolledEntry, {}, { - entryTypeSeed: normalizeSeed({ - contentElements: [ - {id: 1, typeName: 'textBlock'} - ] - }) - }); - const view = new EditContentElementView({ - model: entry.contentElements.get(1), - editor, - entry - }); - entry.deleteContentElement = jest.fn(); - - editor.contentElementTypes.register('textBlock', { - configurationEditor() { - this.tab('general', function() {}); - }, - - handleDestroy() { - return false; - } - }); - view.render(); - - view.destroyModel(); - - expect(entry.deleteContentElement).not.toHaveBeenCalled(); - }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js index d46d5f0f0f..3657860a46 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js @@ -1,6 +1,6 @@ import {EditSectionView} from 'editor/views/EditSectionView'; -import {ConfigurationEditor} from 'pageflow/testHelpers'; +import {ConfigurationEditor, DropDownButton, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; describe('EditSectionView', () => { @@ -254,4 +254,34 @@ describe('EditSectionView', () => { expect(configurationEditor.visibleInputPropertyNames()) .not.toContain('backdropEffectsMobile'); }); + + describe('actions dropdown', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.section_menu_items.duplicate': 'Duplicate', + 'pageflow_scrolled.editor.section_menu_items.insert_section_above': 'Insert above', + 'pageflow_scrolled.editor.section_menu_items.insert_section_below': 'Insert below', + 'pageflow_scrolled.editor.section_menu_items.hide': 'Hide', + 'pageflow_scrolled.editor.section_menu_items.copy_permalink': 'Copy permalink', + 'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section' + }); + + it('includes section-specific menu items', () => { + const entry = createEntry({sections: [{id: 1}]}); + const view = new EditSectionView({ + model: entry.sections.get(1), + entry + }); + + view.render(); + const allDropDowns = DropDownButton.findAll(view); + const actionsDropDown = allDropDowns[0]; + + expect(actionsDropDown.menuItemLabels()).toContain('Duplicate'); + expect(actionsDropDown.menuItemLabels()).toContain('Insert above'); + expect(actionsDropDown.menuItemLabels()).toContain('Insert below'); + expect(actionsDropDown.menuItemLabels()).toContain('Hide'); + expect(actionsDropDown.menuItemLabels()).toContain('Copy permalink'); + expect(actionsDropDown.menuItemLabels()).toContain('Delete section'); + }); + }); }); 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 751d03b20f..62431f73f3 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,6 @@ import {SectionItemView} from 'editor/views/SectionItemView'; import {useEditorGlobals, useFakeXhr} from 'support'; -import userEvent from '@testing-library/user-event'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; import '@testing-library/jest-dom/extend-expect'; @@ -9,38 +8,32 @@ describe('SectionItemView', () => { useFakeXhr(); useFakeTranslations({ - 'pageflow_scrolled.editor.section_item.hide': 'Hide', - 'pageflow_scrolled.editor.section_item.show': 'Show', - 'pageflow_scrolled.editor.section_item.set_cutoff': 'Set cutoff point', - 'pageflow_scrolled.editor.section_item.reset_cutoff': 'Remove cutoff point', + 'pageflow_scrolled.editor.section_menu_items.hide': 'Hide', + 'pageflow_scrolled.editor.section_menu_items.show': 'Show', + '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', }); const {createEntry} = useEditorGlobals(); - it('offer menu item to hide and show section', async () => { + + it('renders menu with section menu items', () => { const entry = createEntry({ sections: [ {id: 1, permaId: 100} ] }); - const section = entry.sections.get(1) const view = new SectionItemView({ entry, - model: section + model: entry.sections.get(1) }); - const user = userEvent.setup(); const {getByRole} = render(view); - await user.click(getByRole('link', {name: 'Hide'})); - - expect(section.configuration.get('hidden')).toEqual(true); - await user.click(getByRole('link', {name: 'Show'})); - - expect(section.configuration.get('hidden')).toBeUndefined(); + expect(getByRole('link', {name: 'Hide'})).not.toBeNull(); }); - it('does not offer menu item to set cutoff section by default', () => { + it('does not render cutoff menu item by default', () => { const entry = createEntry({ sections: [ {id: 1, permaId: 100} @@ -56,7 +49,7 @@ describe('SectionItemView', () => { expect(queryByRole('link', {name: 'Set cutoff point'})).toBeNull(); }); - it('offers menu item to set cutoff section if site has cutoff mode', async () => { + it('renders cutoff menu item if site has cutoff mode', () => { const entry = createEntry({ site: { cutoff_mode_name: 'subscription_headers' @@ -70,55 +63,9 @@ describe('SectionItemView', () => { model: entry.sections.get(1) }); - const user = userEvent.setup(); - const {getByRole} = render(view); - await user.click(getByRole('link', {name: 'Set cutoff point'})); - - expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toEqual(100); - }); - - it('offers menu item to reset cutoff section if site has cutoff mode', async () => { - const entry = createEntry({ - site: { - cutoff_mode_name: 'subscription_headers' - }, - metadata: {configuration: {cutoff_section_perma_id: 101}}, - sections: [ - {id: 1, permaId: 100}, - {id: 2, permaId: 101} - ] - }); - const view = new SectionItemView({ - entry, - model: entry.sections.get(2) - }); - - const user = userEvent.setup(); const {getByRole} = render(view); - await user.click(getByRole('link', {name: 'Remove cutoff point'})); - - expect(entry.metadata.configuration.get('cutoff_section_perma_id')).toBeUndefined(); - }); - - it('updates menu item when cutoff section changes', () => { - const entry = createEntry({ - site: { - cutoff_mode_name: 'subscription_headers' - }, - sections: [ - {id: 1, permaId: 100}, - {id: 2, permaId: 101} - ] - }); - const view = new SectionItemView({ - entry, - model: entry.sections.get(2) - }); - - const {queryByRole} = render(view); - entry.metadata.configuration.set('cutoff_section_perma_id', 101) - expect(queryByRole('link', {name: 'Remove cutoff point'})).not.toBeNull(); + expect(getByRole('link', {name: 'Set cutoff point'})).not.toBeNull(); }); it('renders cutoff indicator', () => { diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/duplicateNodes-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/duplicateNodes-spec.js new file mode 100644 index 0000000000..46d519101e --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/duplicateNodes-spec.js @@ -0,0 +1,285 @@ +/** @jsx jsx */ +import {duplicateNodes} from 'frontend/inlineEditing/EditableText/duplicateNodes'; + +import {createHyperscript} from 'slate-hyperscript'; + +const h = createHyperscript({ + elements: { + paragraph: {type: 'paragraph'}, + heading: {type: 'heading'}, + blockQuote: {type: 'block-quote'}, + bulletedList: {type: 'bulleted-list'}, + listItem: {type: 'list-item'} + }, +}); + +// Strip meta tags to make deep equality checks work +const jsx = (tagName, attributes, ...children) => { + delete attributes.__self; + delete attributes.__source; + return h(tagName, attributes, ...children); +} + +describe('duplicateNodes', () => { + it('duplicates single selected paragraph', () => { + const editor = ( + + + Line 1 + + + + Line 2 + + + ); + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + Line 1 + + + Line 2 + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('duplicates multiple selected paragraphs', () => { + const editor = ( + + + + Line 1 + + + Line 2 + + + + Line 3 + + + ); + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + Line 2 + + + Line 1 + + + Line 2 + + + Line 3 + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('preserves node properties when duplicating', () => { + const editor = ( + + + Line 1 + + + + ); + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + Line 1 + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('duplicates heading', () => { + const editor = ( + + + Title + + + + Text + + + ); + + duplicateNodes(editor); + + const output = ( + + + Title + + + Title + + + Text + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('duplicates list as a whole', () => { + const editor = ( + + + + Item 1 + + + + Item 2 + + + + ); + + duplicateNodes(editor); + + const output = ( + + + + Item 1 + + + Item 2 + + + + + Item 1 + + + Item 2 + + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('does nothing when no selection', () => { + const editor = ( + + + Line 1 + + + ); + editor.selection = null; + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + ); + expect(editor.children).toEqual(output.children); + }); + + it('selects duplicated nodes', () => { + const editor = ( + + + Line 1 + + + + Line 2 + + + ); + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + Line 1 + + + Line 2 + + + ); + expect(editor.selection).toEqual(output.selection); + }); + + it('selects all duplicated nodes when multiple were selected', () => { + const editor = ( + + + + Line 1 + + + Line 2 + + + + Line 3 + + + ); + + duplicateNodes(editor); + + const output = ( + + + Line 1 + + + Line 2 + + + Line 1 + + + Line 2 + + + Line 3 + + + ); + expect(editor.selection).toEqual(output.selection); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/SidebarEditLinkView.js b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/SidebarEditLinkView.js index c4e28b5032..619c89aecb 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/SidebarEditLinkView.js +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/SidebarEditLinkView.js @@ -1,43 +1,45 @@ -import {ConfigurationEditorView, ColorInputView, SeparatorView} from 'pageflow/ui'; -import {editor, FileInputView, InfoBoxView} from 'pageflow/editor'; +import {ColorInputView, SeparatorView} from 'pageflow/ui'; +import {EditConfigurationView, DestroyMenuItem, FileInputView, InfoBoxView} from 'pageflow/editor'; import {InlineFileRightsMenuItem} from 'pageflow-scrolled/editor'; -import Marionette from 'backbone.marionette'; import I18n from 'i18n-js'; +export const SidebarEditLinkView = EditConfigurationView.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.externalLinkList', -export const SidebarEditLinkView = Marionette.Layout.extend({ - template: (data) => ` - ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.back')} - ${I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.destroy')} + destroyEvent: 'remove', -
- `, - className: 'edit_external_link', - regions: { - formContainer: '.form_container', + getConfigurationModel() { + return this.model; }, - events: { - 'click a.back': 'goBack', - 'click a.destroy': 'destroyLink' + + goBackPath() { + return `/scrolled/content_elements/${this.options.contentElement.get('id')}`; + }, + + goBack() { + this.options.contentElement.postCommand({type: 'SET_SELECTED_ITEM', index: -1}); + EditConfigurationView.prototype.goBack.call(this); + }, + + getActionsMenuItems() { + return [new DestroyLinkMenuItem({}, { + collection: this.options.collection, + model: this.model + })]; }, - initialize: function(options) {}, - onRender: function () { - var configurationEditor = new ConfigurationEditorView({ - model: this.model, - attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.content_elements.externalLinkList.attributes'], - tabTranslationKeyPrefix: 'pageflow_scrolled.editor.content_elements.externalLinkList.tabs' - }); - var self = this; - var thumbnailAspectRatio = this.options.contentElement.configuration.get('thumbnailAspectRatio'); - var previewAspectRatio = this.options.entry.getAspectRatio(thumbnailAspectRatio) - var thumbnailFit = this.options.contentElement.configuration.get('thumbnailFit'); - configurationEditor.tab('edit_link', function () { + configure(configurationEditor) { + const contentElement = this.options.contentElement; + const thumbnailAspectRatio = contentElement.configuration.get('thumbnailAspectRatio'); + const previewAspectRatio = this.options.entry.getAspectRatio(thumbnailAspectRatio); + const thumbnailFit = contentElement.configuration.get('thumbnailFit'); + + configurationEditor.tab('edit_link', function() { this.input('thumbnail', FileInputView, { collection: 'image_files', fileSelectionHandler: 'contentElement.externalLinks.link', fileSelectionHandlerOptions: { - contentElementId: self.options.contentElement.get('id') + contentElementId: contentElement.get('id') }, positioning: previewAspectRatio && thumbnailFit !== 'contain', positioningOptions: { @@ -56,18 +58,13 @@ export const SidebarEditLinkView = Marionette.Layout.extend({ ), }); }); - this.formContainer.show(configurationEditor); - }, - goBack: function() { - this.options.contentElement.postCommand({type: 'SET_SELECTED_ITEM', - index: -1}); + } +}); - editor.navigate(`/scrolled/content_elements/${this.options.contentElement.get('id')}`, {trigger: true}); - }, - destroyLink: function () { - if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.externalLinkList.confirm_delete_item'))) { - this.options.collection.remove(this.model); - this.goBack(); - } - }, +const DestroyLinkMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.externalLinkList', + + destroyModel() { + this.options.collection.remove(this.options.model); + } }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js index 25ed9611b5..db2f12f780 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -1,41 +1,44 @@ -import {ConfigurationEditorView, SelectInputView, SliderInputView, SeparatorView} from 'pageflow/ui'; -import {editor, FileInputView} from 'pageflow/editor'; -import Marionette from 'backbone.marionette'; -import I18n from 'i18n-js'; +import {SelectInputView, SliderInputView, SeparatorView} from 'pageflow/ui'; +import {EditConfigurationView, DestroyMenuItem, FileInputView} from 'pageflow/editor'; import {AreaInputView} from './AreaInputView'; import styles from './SidebarEditAreaView.module.css'; -export const SidebarEditAreaView = Marionette.Layout.extend({ - template: (data) => ` - ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.back')} - ${I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.destroy')} +export const SidebarEditAreaView = EditConfigurationView.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.hotspots.edit_area', -
- `, + className: 'edit_configuration_view ' + styles.view, - className: styles.view, + destroyEvent: 'remove', - regions: { - formContainer: '.form_container', + getConfigurationModel() { + return this.model; }, - events: { - 'click a.back': 'goBack', - 'click a.destroy': 'destroyLink' + defaultTab() { + return this.options.tab || + (this.options.entry.get('emulation_mode') === 'phone' ? 'portrait' : 'area'); }, - onRender: function () { - const options = this.options; + goBackPath() { + return `/scrolled/content_elements/${this.options.contentElement.get('id')}`; + }, - const configurationEditor = new ConfigurationEditorView({ - model: this.model, - attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.content_elements.hotspots.edit_area.attributes'], - tabTranslationKeyPrefix: 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.tabs', - tab: options.tab || (options.entry.get('emulation_mode') === 'phone' ? 'portrait' : 'area') - }); + goBack() { + this.options.contentElement.postCommand({type: 'SET_ACTIVE_AREA', index: -1}); + EditConfigurationView.prototype.goBack.call(this); + }, + getActionsMenuItems() { + return [new DestroyAreaMenuItem({}, { + collection: this.options.collection, + model: this.model + })]; + }, + + configure(configurationEditor) { + const options = this.options; const file = options.contentElement.configuration.getImageFile('image'); const portraitFile = options.contentElement.configuration.getImageFile('portraitImage'); const panZoomEnabled = options.contentElement.configuration.get('enablePanZoom') !== 'never'; @@ -147,18 +150,13 @@ export const SidebarEditAreaView = Marionette.Layout.extend({ }); }); } + } +}); - this.formContainer.show(configurationEditor); - }, - - goBack: function() { - editor.navigate(`/scrolled/content_elements/${this.options.contentElement.get('id')}`, {trigger: true}); - }, +const DestroyAreaMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.hotspots.edit_area', - destroyLink: function () { - if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.hotspots.edit_area.confirm_delete_link'))) { - this.options.collection.remove(this.model); - this.goBack(); - } - }, + destroyModel() { + this.options.collection.remove(this.options.model); + } }); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js index 4ee9ce7547..d4494fcdbf 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/SidebarEditItemView.js @@ -1,40 +1,34 @@ -import {ConfigurationEditorView} from 'pageflow/ui'; -import {editor, FileInputView} from 'pageflow/editor'; -import Marionette from 'backbone.marionette'; -import I18n from 'i18n-js'; +import {EditConfigurationView, DestroyMenuItem, FileInputView} from 'pageflow/editor'; -export const SidebarEditItemView = Marionette.Layout.extend({ - template: (data) => ` - ${I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.back')} - ${I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.destroy')} +export const SidebarEditItemView = EditConfigurationView.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item', -
- `, + destroyEvent: 'remove', - regions: { - formContainer: '.form_container', + getConfigurationModel() { + return this.model; }, - events: { - 'click a.back': 'goBack', - 'click a.destroy': 'destroyLink' + goBackPath() { + return `/scrolled/content_elements/${this.options.contentElement.get('id')}`; }, - onRender: function () { - const options = this.options; + getActionsMenuItems() { + return [new DestroyItemMenuItem({}, { + collection: this.options.collection, + model: this.model + })]; + }, - const configurationEditor = new ConfigurationEditorView({ - model: this.model, - attributeTranslationKeyPrefixes: ['pageflow_scrolled.editor.content_elements.imageGallery.edit_item.attributes'], - tabTranslationKeyPrefix: 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item.tabs', - }); + configure(configurationEditor) { + const contentElement = this.options.contentElement; configurationEditor.tab('item', function() { this.input('image', FileInputView, { collection: 'image_files', fileSelectionHandler: 'imageGalleryItem', fileSelectionHandlerOptions: { - contentElementId: options.contentElement.get('id') + contentElementId: contentElement.get('id') }, positioning: false }); @@ -42,23 +36,18 @@ export const SidebarEditItemView = Marionette.Layout.extend({ collection: 'image_files', fileSelectionHandler: 'imageGalleryItem', fileSelectionHandlerOptions: { - contentElementId: options.contentElement.get('id') + contentElementId: contentElement.get('id') }, positioning: false }); }); + } +}); - this.formContainer.show(configurationEditor); - }, - - goBack: function() { - editor.navigate(`/scrolled/content_elements/${this.options.contentElement.get('id')}`, {trigger: true}); - }, +const DestroyItemMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item', - destroyLink: function () { - if (window.confirm(I18n.t('pageflow_scrolled.editor.content_elements.imageGallery.edit_item.confirm_delete_link'))) { - this.options.collection.remove(this.model); - this.goBack(); - } + destroyModel() { + this.options.collection.remove(this.options.model); } }); diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js index 9548aaa7f2..e98450233a 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js @@ -141,6 +141,10 @@ editor.contentElementTypes.register('textBlock', { contentElement.postCommand({type: 'REMOVE'}); return false; } + }, + + handleDuplicate(contentElement) { + contentElement.postCommand({type: 'DUPLICATE'}); } }); diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index ef82fc1751..3ca9f17311 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -48,6 +48,19 @@ export const Chapter = Backbone.Model.extend({ return !this.storyline.isMain(); }, + toggleExcursion() { + const targetStoryline = this.isExcursion() ? + this.entry.storylines.main() : + this.entry.storylines.excursions(); + + targetStoryline.appendChapter(this); + + if (this.sections.length) { + this.entry.trigger('selectSection', this.sections.first()); + this.entry.trigger('scrollToSection', this.sections.first()); + } + }, + addSection(attributes, options = {}) { const defaultConfiguration = options.skipDefaults ? {} : { transition: this.entry.metadata.configuration.get('defaultTransition'), diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/duplicateContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/duplicateContentElement.js new file mode 100644 index 0000000000..f86b37b41b --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/duplicateContentElement.js @@ -0,0 +1,21 @@ +import {Batch} from './Batch'; +import {ContentElement} from '../ContentElement'; + +export function duplicateContentElement(entry, contentElement) { + const batch = new Batch(entry, contentElement.section); + + const newContentElement = new ContentElement({ + typeName: contentElement.get('typeName'), + configuration: JSON.parse(JSON.stringify(contentElement.configuration.attributes)) + }); + + batch.insertAfter(contentElement, newContentElement); + + batch.save({ + success() { + entry.trigger('selectContentElement', newContentElement); + } + }); + + return newContentElement; +} 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 937f7b554c..429f40440f 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -16,6 +16,7 @@ import {Cutoff} from '../Cutoff'; import {insertContentElement} from './insertContentElement'; import {moveContentElement} from './moveContentElement'; import {deleteContentElement} from './deleteContentElement'; +import {duplicateContentElement} from './duplicateContentElement'; import {sortColors} from './sortColors'; import {Scale} from '../../../shared/Scale'; @@ -131,8 +132,12 @@ export const ScrolledEntry = Entry.extend({ }); }, - deleteContentElement(id) { - deleteContentElement(this, this.contentElements.get(id)); + deleteContentElement(contentElement) { + deleteContentElement(this, contentElement); + }, + + duplicateContentElement(contentElement) { + return duplicateContentElement(this, contentElement); }, getTypographyVariants({contentElement, prefix}) { diff --git a/entry_types/scrolled/package/src/editor/models/Storyline.js b/entry_types/scrolled/package/src/editor/models/Storyline.js index da57e7fed2..372e4921b9 100644 --- a/entry_types/scrolled/package/src/editor/models/Storyline.js +++ b/entry_types/scrolled/package/src/editor/models/Storyline.js @@ -34,6 +34,17 @@ export const Storyline = Backbone.Model.extend({ }); }, + appendChapter(chapter) { + const position = this.chapters.length ? + Math.max(...this.chapters.pluck('position')) + 1 : + 0; + + chapter.set('position', position); + this.chapters.add(chapter); + this.chapters.sort(); + this.chapters.saveOrder(); + }, + isMain() { return !!this.configuration.get('main'); } diff --git a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js new file mode 100644 index 0000000000..42dcc65067 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -0,0 +1,36 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; +import {DestroyMenuItem} from 'pageflow/editor'; + +export const ToggleExcursionMenuItem = Backbone.Model.extend({ + initialize(attributes, {chapter}) { + this.chapter = chapter; + + this.listenTo(chapter, 'change:storylineId', this.update); + this.update(); + }, + + selected() { + this.chapter.toggleExcursion(); + }, + + update() { + this.set('label', I18n.t( + this.chapter.isExcursion() ? + 'pageflow_scrolled.editor.chapter_menu_items.move_to_main' : + 'pageflow_scrolled.editor.chapter_menu_items.move_to_excursions' + )); + } +}); + +export const DestroyChapterMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.destroy_chapter_menu_item', + + initialize(attributes, options) { + DestroyMenuItem.prototype.initialize.call( + this, + attributes, + {destroyedModel: options.chapter} + ); + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js new file mode 100644 index 0000000000..dd3b5a54c9 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -0,0 +1,68 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; +import {DestroyMenuItem} from 'pageflow/editor'; + +export const DuplicateContentElementMenuItem = Backbone.Model.extend({ + initialize(attributes, options) { + this.contentElement = options.contentElement; + this.entry = options.entry; + this.editor = options.editor; + + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + this.set('label', I18n.t( + contentElementType.handleDuplicate ? + 'pageflow_scrolled.editor.duplicate_content_element_menu_item.selection_label' : + 'pageflow_scrolled.editor.duplicate_content_element_menu_item.label' + )); + }, + + selected() { + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + if (contentElementType.handleDuplicate) { + contentElementType.handleDuplicate(this.contentElement); + } + else { + this.entry.duplicateContentElement(this.contentElement); + } + } +}); + +export const DestroyContentElementMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.destroy_content_element_menu_item', + + initialize(attributes, options) { + this.contentElement = options.contentElement; + this.entry = options.entry; + this.editor = options.editor; + + DestroyMenuItem.prototype.initialize.call(this, attributes, options); + + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + if (contentElementType.handleDestroy) { + this.set('label', I18n.t( + 'pageflow_scrolled.editor.destroy_content_element_menu_item.selection_label' + )); + } + }, + + destroyModel() { + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + if (contentElementType.handleDestroy) { + const result = contentElementType.handleDestroy(this.contentElement); + + if (result === false) { + return false; + } + } + + this.entry.deleteContentElement(this.contentElement); + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js new file mode 100644 index 0000000000..35935ffe04 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -0,0 +1,129 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; +import {DestroyMenuItem} from 'pageflow/editor'; + +export const HideShowSectionMenuItem = Backbone.Model.extend({ + initialize(attributes, {section}) { + this.section = section; + + this.listenTo(section.configuration, 'change:hidden', this.update); + this.update(); + }, + + selected() { + if (this.section.configuration.get('hidden')) { + this.section.configuration.unset('hidden'); + } + else { + this.section.configuration.set('hidden', true); + } + }, + + update() { + this.set('label', I18n.t( + this.section.configuration.get('hidden') ? + 'pageflow_scrolled.editor.section_menu_items.show' : + 'pageflow_scrolled.editor.section_menu_items.hide' + )); + } +}); + +export const DuplicateSectionMenuItem = Backbone.Model.extend({ + initialize(attributes, {section}) { + this.section = section; + this.set('label', I18n.t('pageflow_scrolled.editor.section_menu_items.duplicate')); + }, + + selected() { + this.section.chapter.duplicateSection(this.section); + } +}); + +export const InsertSectionAboveMenuItem = Backbone.Model.extend({ + initialize(attributes, {section}) { + this.section = section; + this.set('label', I18n.t('pageflow_scrolled.editor.section_menu_items.insert_section_above')); + }, + + selected() { + this.section.chapter.insertSection({before: this.section}); + } +}); + +export const InsertSectionBelowMenuItem = Backbone.Model.extend({ + initialize(attributes, {section}) { + this.section = section; + this.set('label', I18n.t('pageflow_scrolled.editor.section_menu_items.insert_section_below')); + }, + + selected() { + this.section.chapter.insertSection({after: this.section}); + } +}); + +export const CutoffSectionMenuItem = Backbone.Model.extend({ + initialize(attributes, {cutoff, section}) { + this.cutoff = cutoff; + this.section = section; + + this.listenTo(cutoff, 'change', this.update); + this.update(); + }, + + selected() { + if (this.cutoff.isAtSection(this.section)) { + this.cutoff.reset(); + } + else { + this.cutoff.setSection(this.section); + } + }, + + update() { + this.set('label', I18n.t( + this.cutoff.isAtSection(this.section) ? + 'pageflow_scrolled.editor.section_menu_items.reset_cutoff' : + 'pageflow_scrolled.editor.section_menu_items.set_cutoff' + )); + } +}); + +export const CopyPermalinkMenuItem = Backbone.Model.extend({ + initialize(attributes, {entry, section}) { + this.entry = entry; + this.section = section; + this.set('label', I18n.t('pageflow_scrolled.editor.section_menu_items.copy_permalink')); + }, + + selected() { + navigator.clipboard.writeText( + this.entry.getSectionPermalink(this.section) + ); + } +}); + +export const DestroySectionMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.destroy_section_menu_item', + + initialize(attributes, options) { + DestroyMenuItem.prototype.initialize.call( + this, + attributes, + {destroyedModel: options.section} + ); + } +}); + +export function createSectionMenuItems({entry, section}) { + return [ + new DuplicateSectionMenuItem({}, {section}), + new InsertSectionAboveMenuItem({}, {section}), + new InsertSectionBelowMenuItem({}, {section}), + ...(entry.cutoff.isEnabled() ? + [new CutoffSectionMenuItem({}, {cutoff: entry.cutoff, section})] : + []), + new CopyPermalinkMenuItem({separated: true}, {entry, section}), + new HideShowSectionMenuItem({separated: true}, {section}), + new DestroySectionMenuItem({}, {section}) + ]; +} diff --git a/entry_types/scrolled/package/src/editor/views/EditChapterView.js b/entry_types/scrolled/package/src/editor/views/EditChapterView.js index 62effac483..6b87e9f0a5 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,9 +1,18 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; +import {DestroyChapterMenuItem, ToggleExcursionMenuItem} from '../models/chapterMenuItems'; + export const EditChapterView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_chapter', + getActionsMenuItems() { + return [ + new ToggleExcursionMenuItem({}, {chapter: this.model}), + new DestroyChapterMenuItem({separated: true}, {chapter: this.model}) + ]; + }, + configure: function(configurationEditor) { const 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 b96ac028ac..736d8d092a 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -1,5 +1,10 @@ import {EditConfigurationView} from 'pageflow/editor'; +import { + DestroyContentElementMenuItem, + DuplicateContentElementMenuItem +} from '../models/contentElementMenuItems'; + export const EditContentElementView = EditConfigurationView.extend({ translationKeyPrefix() { return `pageflow_scrolled.editor.content_elements.${this.model.get('typeName')}` @@ -13,18 +18,18 @@ export const EditContentElementView = EditConfigurationView.extend({ contentElement: this.model}); }, - destroyModel() { - const contentElementType = - this.options.editor.contentElementTypes.findByTypeName(this.model.get('typeName')); - - if (contentElementType.handleDestroy) { - const result = contentElementType.handleDestroy(this.model); - - if (result === false) { - return false; - } - } - - this.options.entry.deleteContentElement(this.model); + getActionsMenuItems() { + return [ + new DuplicateContentElementMenuItem({}, { + contentElement: this.model, + entry: this.options.entry, + editor: this.options.editor + }), + new DestroyContentElementMenuItem({}, { + contentElement: this.model, + entry: this.options.entry, + editor: this.options.editor + }) + ]; } }); diff --git a/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js b/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js index 422636dbc8..65b5000f7a 100644 --- a/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditDefaultsView.js @@ -10,7 +10,6 @@ import paddingBottomIcon from './images/paddingBottom.svg'; export const EditDefaultsView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_defaults', - hideDestroyButton: true, goBackPath: '/meta_data/widgets', configure: function(configurationEditor) { diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index d4ff7eb3a2..c4a08beced 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -15,7 +15,6 @@ const i18nPrefix = 'pageflow_scrolled.editor.edit_section_paddings'; export const EditSectionPaddingsView = EditConfigurationView.extend({ translationKeyPrefix: i18nPrefix, - hideDestroyButton: true, className: styles.view, diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js index c8df246b73..be9dc8b2ea 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js @@ -5,7 +5,6 @@ import {normalizeSectionConfigurationData} from '../../entryState'; export const EditSectionTransitionView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section_transition', - hideDestroyButton: true, configure: function(configurationEditor) { const entry = this.options.entry; diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 70464267f9..5a72540d00 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -1,4 +1,4 @@ -import {EditConfigurationView, FileInputView, ColorInputView} from 'pageflow/editor'; +import {EditConfigurationView, FileInputView, ColorInputView, InfoBoxView} from 'pageflow/editor'; import { SelectInputView, CheckBoxInputView, @@ -10,14 +10,21 @@ import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; import {EffectListInputView} from './inputs/EffectListInputView'; import {SectionPaddingsInputView} from './inputs/SectionPaddingsInputView'; import {InlineFileRightsMenuItem} from '../models/InlineFileRightsMenuItem' +import {createSectionMenuItems} from '../models/sectionMenuItems'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; import {EditMotifAreaDialogView} from './EditMotifAreaDialogView'; +import hiddenIcon from './images/hidden.svg'; + export const EditSectionView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section', + getActionsMenuItems() { + return createSectionMenuItems({entry: this.options.entry, section: this.model}); + }, + configure: function(configurationEditor) { const entry = this.options.entry; const editor = this.options.editor; @@ -36,6 +43,14 @@ export const EditSectionView = EditConfigurationView.extend({ }; configurationEditor.tab('section', function() { + this.view(InfoBoxView, { + text: I18n.t('pageflow_scrolled.editor.edit_section.hidden_info'), + icon: hiddenIcon, + level: 'info', + visibleBinding: 'hidden', + visible: hidden => !!hidden + }); + this.input('backdropType', SelectInputView, { values: features.isEnabled('backdrop_content_elements') ? ['image', 'video', 'color', 'contentElement'] : diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 9a318cd577..455673f220 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -5,6 +5,7 @@ import {modelLifecycleTrackingView, DropDownButtonView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView' +import {createSectionMenuItems} from '../models/sectionMenuItems'; import arrowsIcon from './images/arrows.svg'; import hiddenIcon from './images/hidden.svg'; @@ -112,49 +113,9 @@ export const SectionItemView = Marionette.ItemView.extend({ entry: this.options.entry })); - const dropDownMenuItems = new Backbone.Collection(); - - dropDownMenuItems.add(new MenuItem({ - label: I18n.t('pageflow_scrolled.editor.section_item.duplicate') - }, { - selected: () => - this.model.chapter.duplicateSection(this.model) - })); - - dropDownMenuItems.add(new MenuItem({ - label: I18n.t('pageflow_scrolled.editor.section_item.insert_section_above') - }, { - selected: () => - this.model.chapter.insertSection({before: this.model}) - })); - - dropDownMenuItems.add(new MenuItem({ - label: I18n.t('pageflow_scrolled.editor.section_item.insert_section_below') - }, { - selected: () => - this.model.chapter.insertSection({after: this.model}) - })); - - dropDownMenuItems.add(new HideShowMenuItem({}, { - section: this.model - })); - - if (this.options.entry.cutoff.isEnabled()) { - dropDownMenuItems.add(new CutoffMenuItem({}, { - cutoff: this.options.entry.cutoff, - section: this.model - })); - } - - dropDownMenuItems.add(new MenuItem({ - label: I18n.t('pageflow_scrolled.editor.section_item.copy_permalink'), - separated: true - }, { - selected: () => - navigator.clipboard.writeText( - this.options.entry.getSectionPermalink(this.model) - ) - })); + const dropDownMenuItems = new Backbone.Collection( + createSectionMenuItems({entry: this.options.entry, section: this.model}) + ); this.appendSubview(new DropDownButtonView({ items: dropDownMenuItems, @@ -194,66 +155,3 @@ export const SectionItemView = Marionette.ItemView.extend({ this.$el.toggleClass(styles.hidden, !!this.model.configuration.get('hidden')); } }); - -const MenuItem = Backbone.Model.extend({ - initialize: function(attributes, options) { - this.options = options; - }, - - selected: function() { - this.options.selected(); - } -}); - -const CutoffMenuItem = Backbone.Model.extend({ - initialize: function(attributes, {cutoff, section}) { - this.cutoff = cutoff; - this.section = section; - - this.listenTo(cutoff, 'change', this.update); - this.update(); - }, - - selected() { - if (this.cutoff.isAtSection(this.section)) { - this.cutoff.reset(); - } - else { - this.cutoff.setSection(this.section); - } - }, - - update() { - this.set('label', I18n.t( - this.cutoff.isAtSection(this.section) ? - 'pageflow_scrolled.editor.section_item.reset_cutoff' : - 'pageflow_scrolled.editor.section_item.set_cutoff' - )); - } -}); - -const HideShowMenuItem = Backbone.Model.extend({ - initialize: function(attributes, {section}) { - this.section = section; - - this.listenTo(section.configuration, 'change:hidden', this.update); - this.update(); - }, - - selected() { - if (this.section.configuration.get('hidden')) { - this.section.configuration.unset('hidden') - } - else { - this.section.configuration.set('hidden', true) - } - }, - - update() { - this.set('label', I18n.t( - this.section.configuration.get('hidden') ? - 'pageflow_scrolled.editor.section_item.show' : - 'pageflow_scrolled.editor.section_item.hide' - )); - } -}); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/duplicateNodes.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/duplicateNodes.js new file mode 100644 index 0000000000..24c2a833ba --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/duplicateNodes.js @@ -0,0 +1,33 @@ +import {Editor, Element, Transforms} from 'slate'; + +export function duplicateNodes(editor) { + if (!editor.selection) { + return; + } + + const selectedEntries = Array.from( + Editor.nodes(editor, { + at: editor.selection, + mode: 'highest', + match: n => Element.isElement(n) + }) + ); + + if (selectedEntries.length === 0) { + return; + } + + const clonedNodes = selectedEntries.map( + ([node]) => JSON.parse(JSON.stringify(node)) + ); + + const lastPath = selectedEntries[selectedEntries.length - 1][1]; + const insertAt = lastPath[0] + 1; + + Transforms.insertNodes(editor, clonedNodes, {at: [insertAt]}); + + Transforms.select(editor, { + anchor: Editor.start(editor, [insertAt]), + focus: Editor.end(editor, [insertAt + clonedNodes.length - 1]) + }); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js index 3979e9f286..b9aa964eb8 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js @@ -36,6 +36,7 @@ import { } from './lineBreaks'; import {useShortcutHandler} from './shortcuts'; +import {duplicateNodes} from './duplicateNodes'; import styles from './index.module.css'; @@ -112,6 +113,10 @@ export const EditableText = React.memo(function EditableText({ if (command.type === 'REMOVE') { Transforms.removeNodes(editor, {mode: 'highest'}); } + else if (command.type === 'DUPLICATE') { + duplicateNodes(editor); + ReactEditor.focus(editor); + } else if (command.type === 'TRANSIENT_STATE_UPDATE') { if ('typographyVariant' in command.payload) { applyTypographyVariant(editor, command.payload.typographyVariant); diff --git a/entry_types/scrolled/spec/features/entry_editor/editing_the_entry_outline_spec.rb b/entry_types/scrolled/spec/features/entry_editor/editing_the_entry_outline_spec.rb index 457b23a1ef..df30e67c96 100644 --- a/entry_types/scrolled/spec/features/entry_editor/editing_the_entry_outline_spec.rb +++ b/entry_types/scrolled/spec/features/entry_editor/editing_the_entry_outline_spec.rb @@ -17,7 +17,7 @@ outline.chapter_items.first.edit_link.click accept_confirm do - Pageflow::Dom::Editor::EditConfigurationView.find!.destroy_button.click + Pageflow::Dom::Editor::EditConfigurationView.find!.select_action('Delete chapter') end outline = Dom::Editor::EntryOutline.find! @@ -39,7 +39,7 @@ chapter_item.section_items.first.thumbnail.double_click accept_confirm do - Pageflow::Dom::Editor::EditConfigurationView.find!.destroy_button.click + Pageflow::Dom::Editor::EditConfigurationView.find!.select_action('Delete section') end outline = Dom::Editor::EntryOutline.find! diff --git a/package/spec/editor/collections/ForeignKeySubsetCollection-spec.js b/package/spec/editor/collections/ForeignKeySubsetCollection-spec.js index cd56830d2f..4f0b63251c 100644 --- a/package/spec/editor/collections/ForeignKeySubsetCollection-spec.js +++ b/package/spec/editor/collections/ForeignKeySubsetCollection-spec.js @@ -73,6 +73,38 @@ describe('ForeignKeySubsetCollection', () => { expect(postComments.first().get('postId')).toBe(5); }); + it('removes model when foreign key changes', () => { + const post = new Backbone.Model({id: 5}); + const comments = new Backbone.Collection([ + {id: 1, postId: 5} + ]); + const postComments = new ForeignKeySubsetCollection({ + parentModel: post, + parent: comments, + foreignKeyAttribute: 'postId' + }); + + comments.get(1).set('postId', 10); + + expect(postComments.length).toBe(0); + }); + + it('adds model when foreign key changes to match', () => { + const post = new Backbone.Model({id: 5}); + const comments = new Backbone.Collection([ + {id: 1, postId: 10} + ], {comparator: 'position'}); + const postComments = new ForeignKeySubsetCollection({ + parentModel: post, + parent: comments, + foreignKeyAttribute: 'postId' + }); + + comments.get(1).set('postId', 5); + + expect(postComments.length).toBe(1); + }); + it('clears when parent model is destroyed', () => { const post = new Backbone.Model({id: 5}, {urlRoot: '/posts'}); const comments = new Backbone.Collection([ @@ -251,4 +283,29 @@ describe('ForeignKeySubsetCollection', () => { expect(postComments.first().get('position')).toBe(1); }); + + it('keeps reference when model is moved to different subset collection', () => { + const post1 = new Backbone.Model({id: 5}); + const post2 = new Backbone.Model({id: 10}); + const comments = new Backbone.Collection([ + {id: 1, postId: 5, position: 0} + ], {comparator: 'position'}); + new ForeignKeySubsetCollection({ + parentModel: post1, + parent: comments, + foreignKeyAttribute: 'postId', + parentReferenceAttribute: 'post' + }); + const post2Comments = new ForeignKeySubsetCollection({ + parentModel: post2, + parent: comments, + foreignKeyAttribute: 'postId', + parentReferenceAttribute: 'post' + }); + const comment = comments.get(1); + + post2Comments.add(comment); + + expect(comment.post).toBe(post2); + }); }); diff --git a/package/spec/editor/models/DestroyMenuItem-spec.js b/package/spec/editor/models/DestroyMenuItem-spec.js new file mode 100644 index 0000000000..255467a86b --- /dev/null +++ b/package/spec/editor/models/DestroyMenuItem-spec.js @@ -0,0 +1,54 @@ +import {DestroyMenuItem} from 'pageflow/editor'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +describe('DestroyMenuItem', () => { + useFakeTranslations({ + 'pageflow.editor.destroy_menu_item.destroy': 'Delete', + 'pageflow.editor.destroy_menu_item.confirm_destroy': 'Really delete?' + }); + + it('has name destroy by default', () => { + const menuItem = new DestroyMenuItem(); + + expect(menuItem.get('name')).toBe('destroy'); + }); + + it('has destructive true by default', () => { + const menuItem = new DestroyMenuItem(); + + expect(menuItem.get('destructive')).toBe(true); + }); + + it('calls destroyWithDelay on model when confirmed', () => { + const destroyedModel = {destroyWithDelay: jest.fn()}; + const menuItem = new DestroyMenuItem({}, {destroyedModel}); + window.confirm = jest.fn().mockReturnValue(true); + + menuItem.selected(); + + expect(window.confirm).toHaveBeenCalledWith('Really delete?'); + expect(destroyedModel.destroyWithDelay).toHaveBeenCalled(); + }); + + it('does not call destroyWithDelay if cancelled', () => { + const destroyedModel = {destroyWithDelay: jest.fn()}; + const menuItem = new DestroyMenuItem({}, {destroyedModel}); + window.confirm = jest.fn().mockReturnValue(false); + + menuItem.selected(); + + expect(destroyedModel.destroyWithDelay).not.toHaveBeenCalled(); + }); + + it('uses translationKeyPrefix for label', () => { + const menuItem = new DestroyMenuItem(); + + expect(menuItem.get('label')).toBe('Delete'); + }); + + it('uses translationKeyPrefix for confirmMessage', () => { + const menuItem = new DestroyMenuItem(); + + expect(menuItem.get('confirmMessage')).toBe('Really delete?'); + }); +}); diff --git a/package/spec/editor/views/DropDownButtonView-spec.js b/package/spec/editor/views/DropDownButtonView-spec.js index 9b10fc2cb8..f6b4c2dcb5 100644 --- a/package/spec/editor/views/DropDownButtonView-spec.js +++ b/package/spec/editor/views/DropDownButtonView-spec.js @@ -183,6 +183,21 @@ describe('DropDownButtonView', () => { expect(items.eq(1)).toHaveClass('separated'); }); + it('supports marking items as destructive', () => { + var dropDownButtonView = new DropDownButtonView({ + items: new Backbone.Collection([ + {label: 'Item 1'}, + {label: 'Item 2', destructive: true} + ]) + }); + + dropDownButtonView.render(); + var items = dropDownButtonView.$el.find('ul li'); + + expect(items.eq(0)).not.toHaveClass('is_destructive'); + expect(items.eq(1)).toHaveClass('is_destructive'); + }); + function mapToText(el) { return el.map(function() { return $(this).text().trim(); diff --git a/package/spec/editor/views/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index bd8b6cfb96..1df3c37d30 100644 --- a/package/spec/editor/views/EditConfigurationView-spec.js +++ b/package/spec/editor/views/EditConfigurationView-spec.js @@ -9,6 +9,7 @@ import { import {TextInputView} from 'pageflow/ui'; import {ConfigurationEditor} from '$support/dominos/ui'; +import {DropDownButton} from '$support/dominos/editor'; import * as support from '$support'; describe('EditConfigurationView', () => { @@ -101,50 +102,6 @@ describe('EditConfigurationView', () => { }); }); - it('allows overriding destroyModel method', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] - }); - const customDestroyMethod = jest.fn(); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - }, - - destroyModel: customDestroyMethod - }); - - const view = new View({model: new Model()}).render(); - window.confirm = () => true; - view.$el.find('.destroy').click(); - - expect(customDestroyMethod).toHaveBeenCalled(); - expect(editor.router.navigate).toHaveBeenCalled(); - }); - - it('does not go back if destroyModel returns false', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] - }); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - }, - - destroyModel() { - return false; - } - }); - - const view = new View({model: new Model()}).render(); - window.confirm = () => true; - view.$el.find('.destroy').click(); - - expect(editor.router.navigate).not.toHaveBeenCalled(); - }); - describe('goBack navigation', () => { it('navigates to / by default', () => { const Model = Backbone.Model.extend({ @@ -306,30 +263,62 @@ describe('EditConfigurationView', () => { }); }); - describe('hideDestroyButton', () => { - it('shows destroy button by default', () => { + describe('getConfigurationModel', () => { + it('uses model.configuration by default', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); const View = EditConfigurationView.extend({ configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); + configurationEditor.tab('general', function() {}); } }); + const model = new Model(); - const view = new View({model: new Model()}).render(); + const view = new View({model}).render(); + + expect(view.configurationEditor.model).toBe(model.configuration); + }); - expect(view.$el.find('.destroy')).toHaveLength(1); + it('can be overridden to use model directly', () => { + const Model = Backbone.Model.extend({}); + const View = EditConfigurationView.extend({ + getConfigurationModel() { + return this.model; + }, + + configure(configurationEditor) { + configurationEditor.tab('general', function() {}); + } + }); + const model = new Model(); + + const view = new View({model}).render(); + + expect(view.configurationEditor.model).toBe(model); + }); + }); + + describe('actions dropdown', () => { + support.useFakeTranslations({ + pageflow: { + editor: { + views: { + edit_configuration: { + actions: 'Actions', + confirm_destroy: 'Really delete?', + destroy: 'Delete' + } + } + } + } }); - it('hides destroy button when hideDestroyButton is true', () => { + it('does not render dropdown by default', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); const View = EditConfigurationView.extend({ - hideDestroyButton: true, - configure(configurationEditor) { configurationEditor.tab('general', function() { }); @@ -338,50 +327,105 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); - expect(view.$el.find('.destroy')).toHaveLength(0); + expect(DropDownButton.findAll(view)).toHaveLength(0); }); - it('supports hideDestroyButton as function', () => { + it('renders dropdown when getActionsMenuItems returns items', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); + const CustomMenuItem = Backbone.Model.extend({ + selected: jest.fn() + }); const View = EditConfigurationView.extend({ - hideDestroyButton() { - return this.model.get('preventDestroy'); + getActionsMenuItems() { + return [new CustomMenuItem({name: 'custom', label: 'Custom Action'})]; }, + configure(configurationEditor) { + configurationEditor.tab('general', function() {}); + } + }); + + const view = new View({model: new Model()}).render(); + const dropDownButton = DropDownButton.find(view); + expect(dropDownButton.menuItemLabels()).toContain('Custom Action'); + }); + + it('uses Actions as button label', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const CustomMenuItem = Backbone.Model.extend({ + selected: jest.fn() + }); + const View = EditConfigurationView.extend({ + getActionsMenuItems() { + return [new CustomMenuItem({name: 'custom', label: 'Custom'})]; + }, configure(configurationEditor) { configurationEditor.tab('general', function() { }); } }); - const viewWithDestroy = new View({model: new Model({preventDestroy: false})}).render(); - const viewWithoutDestroy = new View({model: new Model({preventDestroy: true})}).render(); + const view = new View({model: new Model()}).render(); + + expect(view.$el.find('.drop_down_button button').text()).toBe('Actions'); + }); + }); + + describe('model destroy', () => { + it('navigates back when model is destroyed', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const View = EditConfigurationView.extend({ + configure(configurationEditor) { + configurationEditor.tab('general', function() {}); + } + }); + const model = new Model(); + + new View({model}).render(); + model.trigger('destroy'); - expect(viewWithDestroy.$el.find('.destroy')).toHaveLength(1); - expect(viewWithoutDestroy.$el.find('.destroy')).toHaveLength(0); + expect(editor.router.navigate).toHaveBeenCalledWith('/', {trigger: true}); }); - it('does not prevent destroy event handler when button is shown', () => { + it('does not navigate back by default when model is removed from collection', () => { const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking], - destroyWithDelay: jest.fn() + mixins: [configurationContainer(), failureTracking] }); const View = EditConfigurationView.extend({ - hideDestroyButton: false, + configure(configurationEditor) { + configurationEditor.tab('general', function() {}); + } + }); + const model = new Model(); + + new View({model}).render(); + model.trigger('remove'); + + expect(editor.router.navigate).not.toHaveBeenCalled(); + }); + it('navigates back when model is removed if destroyEvent is set to remove', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const View = EditConfigurationView.extend({ + destroyEvent: 'remove', configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); + configurationEditor.tab('general', function() {}); } }); + const model = new Model(); - const view = new View({model: new Model()}).render(); - window.confirm = () => true; - view.$el.find('.destroy').click(); + new View({model}).render(); + model.trigger('remove'); - expect(view.model.destroyWithDelay).toHaveBeenCalled(); + expect(editor.router.navigate).toHaveBeenCalledWith('/', {trigger: true}); }); }); }); diff --git a/package/spec/editor/views/InfoBoxView-spec.js b/package/spec/editor/views/InfoBoxView-spec.js index f33d41144d..2c54c9ba1d 100644 --- a/package/spec/editor/views/InfoBoxView-spec.js +++ b/package/spec/editor/views/InfoBoxView-spec.js @@ -1,31 +1,56 @@ +import '@testing-library/jest-dom/extend-expect'; import Backbone from 'backbone'; import {InfoBoxView} from 'editor/views/InfoBoxView'; +import {renderBackboneView} from 'testHelpers/renderBackboneView'; describe('InfoBoxView', () => { describe('with visibleBindingValue option', () => { it('hides element when value of attribute does not match', () => { - var view = new InfoBoxView({ + const view = new InfoBoxView({ model: new Backbone.Model({hidden: true}), visibleBinding: 'hidden', visibleBindingValue: false }); - view.render(); + renderBackboneView(view); - expect(view.$el).toHaveClass('hidden_via_binding'); + expect(view.el).toHaveClass('hidden_via_binding'); }); it('does not set hidden class when value of attribute matches', () => { - var view = new InfoBoxView({ + const view = new InfoBoxView({ model: new Backbone.Model({hidden: false}), visibleBinding: 'hidden', visibleBindingValue: false }); - view.render(); + renderBackboneView(view); - expect(view.$el).not.toHaveClass('hidden_via_binding'); + expect(view.el).not.toHaveClass('hidden_via_binding'); + }); + }); + + describe('with icon option', () => { + it('renders img inside with_icon wrapper', () => { + const {getByRole, getByText} = renderBackboneView(new InfoBoxView({ + model: new Backbone.Model(), + text: 'Some text', + icon: 'path/to/icon.svg' + })); + + expect(getByRole('img')).toHaveAttribute('src', 'path/to/icon.svg'); + expect(getByText('Some text')).toBeInTheDocument(); + }); + + it('renders text without wrapper when no icon', () => { + const {getByText, queryByRole} = renderBackboneView(new InfoBoxView({ + model: new Backbone.Model(), + text: 'Some text' + })); + + expect(queryByRole('img')).not.toBeInTheDocument(); + expect(getByText('Some text')).toBeInTheDocument(); }); }); }); diff --git a/package/src/editor/collections/ForeignKeySubsetCollection.js b/package/src/editor/collections/ForeignKeySubsetCollection.js index c32a52597c..54a556d3bf 100644 --- a/package/src/editor/collections/ForeignKeySubsetCollection.js +++ b/package/src/editor/collections/ForeignKeySubsetCollection.js @@ -38,6 +38,7 @@ export const ForeignKeySubsetCollection = SubsetCollection.extend({ SubsetCollection.prototype.constructor.call(this, { parent, parentModel, + watchAttribute: options.foreignKeyAttribute, filter: function(item) { return !parentModel.isNew() && @@ -58,7 +59,9 @@ export const ForeignKeySubsetCollection = SubsetCollection.extend({ this.each(model => model[options.parentReferenceAttribute] = parentModel); this.listenTo(this, 'remove', function(model) { - model[options.parentReferenceAttribute] = null; + if (model[options.parentReferenceAttribute] === parentModel) { + model[options.parentReferenceAttribute] = null; + } }); } } diff --git a/package/src/editor/index.js b/package/src/editor/index.js index 16868e8834..00b9ada7b0 100644 --- a/package/src/editor/index.js +++ b/package/src/editor/index.js @@ -13,6 +13,7 @@ export * from './utils/formDataUtils'; export * from './utils/stylesheet'; export * from './models/OtherEntry'; +export * from './models/DestroyMenuItem'; export * from './models/EditLock'; export * from './models/Page'; export * from './models/StorylineScaffold'; diff --git a/package/src/editor/models/DestroyMenuItem.js b/package/src/editor/models/DestroyMenuItem.js new file mode 100644 index 0000000000..83256c9fea --- /dev/null +++ b/package/src/editor/models/DestroyMenuItem.js @@ -0,0 +1,44 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; + +/** + * A menu item that shows a confirmation dialog before calling a + * destroy callback. + * + * @param {Object} attributes + * @param {boolean} [attributes.separated] - Display separator above item. + * + * @param {Object} options + * @param {Backbone.Model} [options.destroyedModel] - Model to destroy. + * Override `destroyModel` method for custom behavior. + * + * Set `translationKeyPrefix` to provide `destroy` and `confirm_destroy` + * translations. + * + * @since edge + */ +export const DestroyMenuItem = Backbone.Model.extend({ + translationKeyPrefix: 'pageflow.editor.destroy_menu_item', + + defaults: { + name: 'destroy', + destructive: true + }, + + initialize(attributes, options) { + this.options = options || {}; + + this.set('label', I18n.t(`${this.translationKeyPrefix}.destroy`)); + this.set('confirmMessage', I18n.t(`${this.translationKeyPrefix}.confirm_destroy`)); + }, + + selected() { + if (window.confirm(this.get('confirmMessage'))) { + this.destroyModel(); + } + }, + + destroyModel() { + this.options.destroyedModel?.destroyWithDelay(); + } +}); diff --git a/package/src/editor/views/DropDownButtonItemView.js b/package/src/editor/views/DropDownButtonItemView.js index 95c216ba95..25fa61ae81 100644 --- a/package/src/editor/views/DropDownButtonItemView.js +++ b/package/src/editor/views/DropDownButtonItemView.js @@ -52,6 +52,7 @@ export const DropDownButtonItemView = Marionette.ItemView.extend({ this.$el.toggleClass('has_radio', this.model.get('kind') === 'radio'); this.$el.toggleClass('is_checked', !!this.model.get('checked')); this.$el.toggleClass('separated', !!this.model.get('separated')); + this.$el.toggleClass('is_destructive', !!this.model.get('destructive')); this.$el.data('name', this.model.get('name')); } diff --git a/package/src/editor/views/DropDownButtonView.js b/package/src/editor/views/DropDownButtonView.js index 8b168bcefc..93c23c5f4e 100644 --- a/package/src/editor/views/DropDownButtonView.js +++ b/package/src/editor/views/DropDownButtonView.js @@ -38,7 +38,8 @@ import template from '../templates/dropDownButton.jst'; * - `name` - A name for the menu item which is not displayed. * - `label` - Used as menu item label. * - `disabled` - Make the menu item inactive. - * - `checked` - Display a check mark in front of the item + * - `checked` - Display a check mark in front of the item. + * - `destructive` - Display with red hover state. * - `items` - A Backbone collection of nested menu items. * * If the menu item model provdised a `selected` method, it is called diff --git a/package/src/editor/views/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index ac3a9b041b..6546e11e9c 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -1,7 +1,9 @@ +import Backbone from 'backbone'; import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import _ from 'underscore'; +import {DropDownButtonView} from './DropDownButtonView'; import {failureIndicatingView} from './mixins/failureIndicatingView'; import {ConfigurationEditorView} from 'pageflow/ui'; import {editor} from '../base'; @@ -24,43 +26,34 @@ import {editor} from '../base'; * * * `.back` (optional): Back button label. * - * * `.destroy` (optional): Destroy button - * label. - * - * * `.confirm_destroy` (optional): Confirm - * message displayed before destroying. - * * * `.save_error` (optional): Header of the * failure message that is displayed if the model cannot be saved. * * * `.retry` (optional): Label of the retry * button of the failure message. * - * Override the `destroyModel` method to customize destroy behavior. - * Calls `destroyWithDelay` by default. - * * Override the `goBackPath` property or method to customize the path * that the back button navigates to. Defaults to `/`. * * Override the `defaultTab` property or method to set the initially * selected tab. * - * Set the `hideDestroyButton` property to `true` to hide the destroy - * button. + * Override the `getActionsMenuItems` method to add menu items to the + * actions dropdown. * * @param {Object} options * @param {Backbone.Model} options.model - - * Model including the {@link configurationContainer}, - * {@link failureTracking} and {@link delayedDestroying} mixins. + * Model including the {@link configurationContainer} and + * {@link failureTracking} mixins. * * @since 15.1 */ export const EditConfigurationView = Marionette.Layout.extend({ className: 'edit_configuration_view', - template: ({t, backLabel, hideDestroyButton}) => ` + template: ({t, backLabel}) => ` ${backLabel} - ${hideDestroyButton ? '' : `${t('destroy')}`} +

${t('save_error')}

@@ -74,8 +67,7 @@ export const EditConfigurationView = Marionette.Layout.extend({ serializeData() { return { t: key => this.t(key), - backLabel: this.getBackLabel(), - hideDestroyButton: _.result(this, 'hideDestroyButton') + backLabel: this.getBackLabel() }; }, @@ -86,8 +78,13 @@ export const EditConfigurationView = Marionette.Layout.extend({ }, events: { - 'click a.back': 'goBack', - 'click a.destroy': 'destroy' + 'click a.back': 'goBack' + }, + + destroyEvent: 'destroy', + + initialize() { + this.listenTo(this.model, _.result(this, 'destroyEvent'), this.goBack); }, onRender: function() { @@ -96,28 +93,40 @@ export const EditConfigurationView = Marionette.Layout.extend({ this.configurationEditor = new ConfigurationEditorView({ tabTranslationKeyPrefix: `${translationKeyPrefix}.tabs`, attributeTranslationKeyPrefixes: [`${translationKeyPrefix}.attributes`], - model: this.model.configuration, + model: this.getConfigurationModel(), tab: _.result(this, 'defaultTab') }); this.configure(this.configurationEditor); this.configurationContainer.show(this.configurationEditor); - }, - onShow: function() { - this.configurationEditor.refreshScroller(); + this.renderActionsDropDown(); }, - destroy: function() { - if (window.confirm(this.t('confirm_destroy'))) { - if (this.destroyModel() !== false) { - this.goBack(); - } + renderActionsDropDown() { + const items = new Backbone.Collection(this.getActionsMenuItems()); + + if (!items.length) { + return; } + + this.$el.find('.actions_drop_down_button').append( + this.subview(new DropDownButtonView({ + items, + label: this.t('actions'), + ellipsisIcon: true, + openOnClick: true, + alignMenu: 'right' + })).el + ); + }, + + getActionsMenuItems() { + return []; }, - destroyModel() { - this.model.destroyWithDelay(); + onShow: function() { + this.configurationEditor.refreshScroller(); }, goBack: function() { @@ -125,6 +134,10 @@ export const EditConfigurationView = Marionette.Layout.extend({ editor.navigate(path, {trigger: true}); }, + getConfigurationModel() { + return this.model.configuration; + }, + getBackLabel() { return this.t(_.result(this, 'goBackPath') ? 'back' : 'outline'); }, diff --git a/package/src/editor/views/InfoBoxView.js b/package/src/editor/views/InfoBoxView.js index ddaa5f4b9c..0a5eff9910 100644 --- a/package/src/editor/views/InfoBoxView.js +++ b/package/src/editor/views/InfoBoxView.js @@ -2,23 +2,33 @@ import Marionette from 'backbone.marionette'; import {attributeBinding} from 'pageflow/ui'; -export const InfoBoxView = Marionette.View.extend({ +export const InfoBoxView = Marionette.ItemView.extend({ className: 'info_box', mixins: [attributeBinding], + template: (data) => data.icon ? + `
${data.text}
` : + data.text, + + serializeData() { + return { + text: this.options.text, + icon: this.options.icon + }; + }, + initialize() { this.setupBooleanAttributeBinding('visible', this.updateVisible); }, - updateVisible: function() { - this.$el.toggleClass('hidden_via_binding', - this.getBooleanAttributBoundOption('visible') === false); + onRender() { + this.$el.addClass(this.options.level); + this.updateVisible(); }, - render: function() { - this.$el.addClass(this.options.level) - this.$el.html(this.options.text); - return this; + updateVisible() { + this.$el.toggleClass('hidden_via_binding', + this.getBooleanAttributBoundOption('visible') === false); } }); diff --git a/package/src/editor/views/mixins/modelLifecycleTrackingView.js b/package/src/editor/views/mixins/modelLifecycleTrackingView.js index f594a63277..2a11528ca1 100644 --- a/package/src/editor/views/mixins/modelLifecycleTrackingView.js +++ b/package/src/editor/views/mixins/modelLifecycleTrackingView.js @@ -63,11 +63,11 @@ export function modelLifecycleTrackingView({classNames}) { }, updateFailIndicator: function() { - if (classNames.failed) { + if (classNames.failed && this.model.isFailed) { this.$el.toggleClass(classNames.failed, this.model.isFailed()); } - if (classNames.failureMessage) { + if (classNames.failureMessage && this.model.getFailureMessage) { this.$el.find(`.${classNames.failureMessage}`).text(this.model.getFailureMessage()); } } diff --git a/spec/support/pageflow/dom/editor/edit_configuration_view.rb b/spec/support/pageflow/dom/editor/edit_configuration_view.rb index 47d1e816b7..1c7d89c381 100644 --- a/spec/support/pageflow/dom/editor/edit_configuration_view.rb +++ b/spec/support/pageflow/dom/editor/edit_configuration_view.rb @@ -4,8 +4,13 @@ module Editor class EditConfigurationView < Domino selector 'sidebar .edit_configuration_view' - def destroy_button - node.find('.destroy') + def actions_button + node.find('.drop_down_button') + end + + def select_action(label) + actions_button.click + Capybara.current_session.find('#editor_menu_container .drop_down_button_item', text: label).click end def back_button