From 3198c6b666fb71a03b650dc9f662cda7a8aa1b77 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 9 Jan 2026 09:13:47 +0100 Subject: [PATCH 01/14] Replace destroy button with actions dropdown Prepare EditConfigurationView to support additional actions beyond delete. The actions dropdown can be extended by subclasses to add custom menu items like duplicate. REDMINE-21205 --- .../stylesheets/pageflow/editor/base.scss | 1 + .../pageflow/editor/drop_down_button.scss | 7 ++ .../editor/edit_configuration_view.scss | 5 + config/locales/de.yml | 1 + config/locales/en.yml | 1 + .../editing_the_entry_outline_spec.rb | 4 +- .../views/EditConfigurationView-spec.js | 107 ++++++++++++++++-- .../src/editor/views/EditConfigurationView.js | 44 ++++++- .../dom/editor/edit_configuration_view.rb | 9 +- 9 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 app/assets/stylesheets/pageflow/editor/edit_configuration_view.scss 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..84e40b266d 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; 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/config/locales/de.yml b/config/locales/de.yml index bae7e9e8b8..ecc916c0aa 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1513,6 +1513,7 @@ de: Dieser Schritt kann nicht rückgängig gemacht werden. edit_configuration: + actions: Aktionen back: "Zurück" confirm_destroy: Wirklich löschen? destroy: Löschen diff --git a/config/locales/en.yml b/config/locales/en.yml index 9d6a46a1bb..707a15b536 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1506,6 +1506,7 @@ en: This operation cannot be undone. edit_configuration: + actions: Actions back: "Back" confirm_destroy: Really delete this record? This action cannot be undone. destroy: Delete 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/views/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index bd8b6cfb96..e0a60b0499 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', () => { @@ -117,7 +118,7 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); window.confirm = () => true; - view.$el.find('.destroy').click(); + DropDownButton.find(view).selectMenuItemByName('destroy'); expect(customDestroyMethod).toHaveBeenCalled(); expect(editor.router.navigate).toHaveBeenCalled(); @@ -140,7 +141,7 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); window.confirm = () => true; - view.$el.find('.destroy').click(); + DropDownButton.find(view).selectMenuItemByName('destroy'); expect(editor.router.navigate).not.toHaveBeenCalled(); }); @@ -306,8 +307,94 @@ describe('EditConfigurationView', () => { }); }); + describe('actions dropdown', () => { + support.useFakeTranslations({ + pageflow: { + editor: { + views: { + edit_configuration: { + actions: 'Actions', + confirm_destroy: 'Really delete?', + destroy: 'Delete' + } + } + } + } + }); + + it('renders a DropDownButtonView', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const View = EditConfigurationView.extend({ + configure(configurationEditor) { + configurationEditor.tab('general', function() { + }); + } + }); + + const view = new View({model: new Model()}).render(); + const dropDownButton = DropDownButton.find(view); + + expect(dropDownButton).toBeDefined(); + }); + + it('renders Delete menu item', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const View = EditConfigurationView.extend({ + configure(configurationEditor) { + configurationEditor.tab('general', function() { + }); + } + }); + + const view = new View({model: new Model()}).render(); + const dropDownButton = DropDownButton.find(view); + + expect(dropDownButton.menuItemLabels()).toContain('Delete'); + }); + + it('uses Actions as button label', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const View = EditConfigurationView.extend({ + configure(configurationEditor) { + configurationEditor.tab('general', function() { + }); + } + }); + + const view = new View({model: new Model()}).render(); + + expect(view.$el.find('.drop_down_button button').text()).toBe('Actions'); + }); + + it('calls destroyModel when Delete menu item is clicked', () => { + const Model = Backbone.Model.extend({ + mixins: [configurationContainer(), failureTracking] + }); + const destroyModel = jest.fn(); + const View = EditConfigurationView.extend({ + configure(configurationEditor) { + configurationEditor.tab('general', function() { + }); + }, + destroyModel + }); + + const view = new View({model: new Model()}).render(); + window.confirm = () => true; + DropDownButton.find(view).selectMenuItemByName('destroy'); + + expect(destroyModel).toHaveBeenCalled(); + }); + }); + describe('hideDestroyButton', () => { - it('shows destroy button by default', () => { + it('shows actions dropdown by default', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); @@ -320,10 +407,10 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); - expect(view.$el.find('.destroy')).toHaveLength(1); + expect(DropDownButton.findAll(view)).toHaveLength(1); }); - it('hides destroy button when hideDestroyButton is true', () => { + it('hides actions dropdown when hideDestroyButton is true', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); @@ -338,7 +425,7 @@ 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', () => { @@ -359,11 +446,11 @@ describe('EditConfigurationView', () => { const viewWithDestroy = new View({model: new Model({preventDestroy: false})}).render(); const viewWithoutDestroy = new View({model: new Model({preventDestroy: true})}).render(); - expect(viewWithDestroy.$el.find('.destroy')).toHaveLength(1); - expect(viewWithoutDestroy.$el.find('.destroy')).toHaveLength(0); + expect(DropDownButton.findAll(viewWithDestroy)).toHaveLength(1); + expect(DropDownButton.findAll(viewWithoutDestroy)).toHaveLength(0); }); - it('does not prevent destroy event handler when button is shown', () => { + it('does not prevent destroy event handler when actions dropdown is shown', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking], destroyWithDelay: jest.fn() @@ -379,7 +466,7 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); window.confirm = () => true; - view.$el.find('.destroy').click(); + DropDownButton.find(view).selectMenuItemByName('destroy'); expect(view.model.destroyWithDelay).toHaveBeenCalled(); }); diff --git a/package/src/editor/views/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index ac3a9b041b..4f3b924b45 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'; @@ -60,7 +62,7 @@ export const EditConfigurationView = Marionette.Layout.extend({ template: ({t, backLabel, hideDestroyButton}) => ` ${backLabel} - ${hideDestroyButton ? '' : `${t('destroy')}`} + ${hideDestroyButton ? '' : '
'}

${t('save_error')}

@@ -86,8 +88,7 @@ export const EditConfigurationView = Marionette.Layout.extend({ }, events: { - 'click a.back': 'goBack', - 'click a.destroy': 'destroy' + 'click a.back': 'goBack' }, onRender: function() { @@ -102,6 +103,33 @@ export const EditConfigurationView = Marionette.Layout.extend({ this.configure(this.configurationEditor); this.configurationContainer.show(this.configurationEditor); + + this.renderActionsDropDown(); + }, + + renderActionsDropDown() { + if (_.result(this, 'hideDestroyButton')) { + return; + } + + const items = new Backbone.Collection([ + new DestroyMenuItem({ + name: 'destroy', + label: this.t('destroy') + }, { + view: this + }) + ]); + + this.$el.find('.actions_drop_down_button').append( + this.subview(new DropDownButtonView({ + items, + label: this.t('actions'), + ellipsisIcon: true, + openOnClick: true, + alignMenu: 'right' + })).el + ); }, onShow: function() { @@ -137,3 +165,13 @@ export const EditConfigurationView = Marionette.Layout.extend({ }); } }); + +const DestroyMenuItem = Backbone.Model.extend({ + initialize(attributes, options) { + this.options = options; + }, + + selected() { + this.options.view.destroy(); + } +}); 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 From b09d0b5b23c69decde965220a3eeab8a6a907a79 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 09:04:22 +0100 Subject: [PATCH 02/14] Allow customizing actions dropdown in EditConfigurationView Extract getActionsMenuItems() method to allow subclasses to provide custom menu items. Add getDestroyMenuItem() with separated option for adding visual separators. REDMINE-21205 --- .../views/EditConfigurationView-spec.js | 24 +++++++++++++++ .../src/editor/views/EditConfigurationView.js | 30 ++++++++++++------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/package/spec/editor/views/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index e0a60b0499..7981fd354f 100644 --- a/package/spec/editor/views/EditConfigurationView-spec.js +++ b/package/spec/editor/views/EditConfigurationView-spec.js @@ -393,6 +393,30 @@ describe('EditConfigurationView', () => { }); }); + describe('getActionsMenuItems', () => { + it('allows subclass to customize menu items', () => { + 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 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'); + }); + }); + describe('hideDestroyButton', () => { it('shows actions dropdown by default', () => { const Model = Backbone.Model.extend({ diff --git a/package/src/editor/views/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index 4f3b924b45..a0477796fc 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -108,19 +108,12 @@ export const EditConfigurationView = Marionette.Layout.extend({ }, renderActionsDropDown() { - if (_.result(this, 'hideDestroyButton')) { + const items = new Backbone.Collection(this.getActionsMenuItems()); + + if (!items.length) { return; } - const items = new Backbone.Collection([ - new DestroyMenuItem({ - name: 'destroy', - label: this.t('destroy') - }, { - view: this - }) - ]); - this.$el.find('.actions_drop_down_button').append( this.subview(new DropDownButtonView({ items, @@ -132,6 +125,23 @@ export const EditConfigurationView = Marionette.Layout.extend({ ); }, + getActionsMenuItems() { + if (_.result(this, 'hideDestroyButton')) { + return []; + } + return [this.getDestroyMenuItem()]; + }, + + getDestroyMenuItem({separated} = {}) { + return new DestroyMenuItem({ + name: 'destroy', + label: this.t('destroy'), + separated + }, { + view: this + }); + }, + onShow: function() { this.configurationEditor.refreshScroller(); }, From ddcebbe03ba9f498f1bc70b4868e40679f9c85dc Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 09:04:22 +0100 Subject: [PATCH 03/14] Share section menu items between views Extract menu item models to reusable SectionMenuItems module with factory function. Both EditSectionView and SectionItemView now use the same menu items for duplicate, insert, hide/show, cutoff, and copy permalink actions. Move related translations to section_menu_items namespace and remove misplaced translations from main pageflow locales. REDMINE-21205 --- config/locales/de.yml | 6 - config/locales/en.yml | 6 - entry_types/scrolled/config/locales/de.yml | 14 +- entry_types/scrolled/config/locales/en.yml | 14 +- .../editor/models/SectionMenuItems-spec.js | 236 ++++++++++++++++++ .../spec/editor/views/EditSectionView-spec.js | 32 ++- .../spec/editor/views/SectionItemView-spec.js | 8 +- .../src/editor/models/SectionMenuItems.js | 115 +++++++++ .../src/editor/views/EditSectionView.js | 8 + .../src/editor/views/SectionItemView.js | 110 +------- 10 files changed, 416 insertions(+), 133 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/SectionMenuItems-spec.js create mode 100644 entry_types/scrolled/package/src/editor/models/SectionMenuItems.js diff --git a/config/locales/de.yml b/config/locales/de.yml index ecc916c0aa..861dd5ebf8 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -2116,12 +2116,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 707a15b536..bd51a83ae4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2107,12 +2107,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..52b51d7c81 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1265,11 +1265,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 +1276,15 @@ de: reveal: Freilegen scroll: Aus-/Einscrollen scrollOver: Überlagern + section_menu_items: + copy_permalink: Permalink kopieren + duplicate: 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..f75f6bd665 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1249,11 +1249,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 +1260,15 @@ en: reveal: Reveal scroll: Scroll scrollOver: Scroll over + section_menu_items: + copy_permalink: Copy permalink + duplicate: Duplicate 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/editor/models/SectionMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/SectionMenuItems-spec.js new file mode 100644 index 0000000000..5255db1e39 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/SectionMenuItems-spec.js @@ -0,0 +1,236 @@ +import { + HideShowSectionMenuItem, + DuplicateSectionMenuItem, + InsertSectionAboveMenuItem, + InsertSectionBelowMenuItem, + CutoffSectionMenuItem, + CopyPermalinkMenuItem +} 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' + }); + + 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('has separated attribute', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new CopyPermalinkMenuItem({}, {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'); + }); + }); +}); 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..449faff25c 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.edit_section.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..73c2107845 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -9,10 +9,10 @@ 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', }); 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..4239c45a49 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/SectionMenuItems.js @@ -0,0 +1,115 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; + +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 function createSectionMenuItems({entry, section}) { + return [ + new DuplicateSectionMenuItem({}, {section}), + new InsertSectionAboveMenuItem({}, {section}), + new InsertSectionBelowMenuItem({}, {section}), + new CopyPermalinkMenuItem({separated: true}, {entry, section}), + new HideShowSectionMenuItem({separated: true}, {section}), + ...(entry.cutoff.isEnabled() ? + [new CutoffSectionMenuItem({}, {cutoff: entry.cutoff, section})] : + []) + ]; +} diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 70464267f9..cac81fc11f 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -10,6 +10,7 @@ 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'; @@ -18,6 +19,13 @@ import {EditMotifAreaDialogView} from './EditMotifAreaDialogView'; export const EditSectionView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section', + getActionsMenuItems() { + return [ + ...createSectionMenuItems({entry: this.options.entry, section: this.model}), + this.getDestroyMenuItem() + ]; + }, + configure: function(configurationEditor) { const entry = this.options.entry; const editor = this.options.editor; diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 9a318cd577..f87bbba0dc 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' - )); - } -}); From bc922f98189bccccf324f972d2e7576c58406cf4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 10:24:53 +0100 Subject: [PATCH 04/14] Highlight delete menu items with red hover state Make destructive actions more obvious by showing a red background on hover. The delete menu item in configuration views now uses this style. REDMINE-21205 --- .../pageflow/editor/drop_down_button.scss | 5 +++++ .../spec/editor/views/DropDownButtonView-spec.js | 15 +++++++++++++++ .../src/editor/views/DropDownButtonItemView.js | 1 + package/src/editor/views/DropDownButtonView.js | 3 ++- package/src/editor/views/EditConfigurationView.js | 3 ++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss index 84e40b266d..f5f5e7e77a 100644 --- a/app/assets/stylesheets/pageflow/editor/drop_down_button.scss +++ b/app/assets/stylesheets/pageflow/editor/drop_down_button.scss @@ -109,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/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/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 a0477796fc..f7480e921f 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -136,7 +136,8 @@ export const EditConfigurationView = Marionette.Layout.extend({ return new DestroyMenuItem({ name: 'destroy', label: this.t('destroy'), - separated + separated, + destructive: true }, { view: this }); From c91058608bc896d79037ec57a62dbd889a6020ee Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 09:04:22 +0100 Subject: [PATCH 05/14] Extract reusable DestroyMenuItem for actions dropdown Allow EditConfigurationView subclasses to provide custom delete menu items with type-specific confirmation messages and destroy behavior. Views can override getActionsMenuItems to return custom menu items. EditConfigurationView now listens to model destroy events to navigate back automatically. REDMINE-21205 --- config/locales/de.yml | 3 + config/locales/en.yml | 3 + entry_types/scrolled/config/locales/de.yml | 16 +- entry_types/scrolled/config/locales/en.yml | 16 +- .../models/contentElementMenuItems-spec.js | 131 +++++++++++++ ...Items-spec.js => sectionMenuItems-spec.js} | 36 +++- .../views/EditContentElementView-spec.js | 64 ------- .../spec/editor/views/EditSectionView-spec.js | 2 +- .../spec/editor/views/SectionItemView-spec.js | 67 +------ .../src/editor/models/chapterMenuItems.js | 13 ++ .../editor/models/contentElementMenuItems.js | 28 +++ ...ectionMenuItems.js => sectionMenuItems.js} | 20 +- .../src/editor/views/EditChapterView.js | 8 + .../editor/views/EditContentElementView.js | 23 +-- .../src/editor/views/EditDefaultsView.js | 1 - .../editor/views/EditSectionPaddingsView.js | 1 - .../editor/views/EditSectionTransitionView.js | 1 - .../src/editor/views/EditSectionView.js | 7 +- .../src/editor/views/SectionItemView.js | 2 +- .../editor/models/DestroyMenuItem-spec.js | 54 ++++++ .../views/EditConfigurationView-spec.js | 174 ++---------------- package/src/editor/index.js | 1 + package/src/editor/models/DestroyMenuItem.js | 44 +++++ .../src/editor/views/EditConfigurationView.js | 66 ++----- 24 files changed, 408 insertions(+), 373 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js rename entry_types/scrolled/package/spec/editor/models/{SectionMenuItems-spec.js => sectionMenuItems-spec.js} (86%) create mode 100644 entry_types/scrolled/package/src/editor/models/chapterMenuItems.js create mode 100644 entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js rename entry_types/scrolled/package/src/editor/models/{SectionMenuItems.js => sectionMenuItems.js} (88%) create mode 100644 package/spec/editor/models/DestroyMenuItem-spec.js create mode 100644 package/src/editor/models/DestroyMenuItem.js diff --git a/config/locales/de.yml b/config/locales/de.yml index 861dd5ebf8..5ac8dce4f2 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1512,6 +1512,9 @@ 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" diff --git a/config/locales/en.yml b/config/locales/en.yml index bd51a83ae4..85e3d2d473 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1505,6 +1505,9 @@ 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" diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 52b51d7c81..293b8deeab 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -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,6 +1145,18 @@ de: hint: Ziehe ein Rechteck, um den wichtigsten Bereich des Bildes zu markieren. reset: Zurücksetzen save: Speichern + 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 + destroy_section_menu_item: + confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? + destroy: Abschnitt löschen edit_section: 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: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index f75f6bd665..840cbb28b0 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -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,6 +1129,18 @@ en: hint: Drag to select the most important part of the image. reset: Reset save: Save + 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 + destroy_section_menu_item: + confirm_destroy: Really delete this section including all its elements? + destroy: Delete section edit_section: motif_area_info_text: Mark the most important part of the backdrop that should be visible and unobstructed when reaching the section. attributes: 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..0a6142ef2b --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -0,0 +1,131 @@ +import {DestroyContentElementMenuItem} from 'editor/models/contentElementMenuItems'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {factories, normalizeSeed} from 'support'; + +describe('ContentElementMenuItems', () => { + describe('DestroyContentElementMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.destroy_content_element_menu_item.destroy': 'Delete element', + '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); + + const menuItem = new DestroyContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Delete element'); + }); + + 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 similarity index 86% rename from entry_types/scrolled/package/spec/editor/models/SectionMenuItems-spec.js rename to entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js index 5255db1e39..b6cc1285b6 100644 --- a/entry_types/scrolled/package/spec/editor/models/SectionMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -4,8 +4,9 @@ import { InsertSectionAboveMenuItem, InsertSectionBelowMenuItem, CutoffSectionMenuItem, - CopyPermalinkMenuItem -} from 'editor/models/SectionMenuItems'; + CopyPermalinkMenuItem, + DestroySectionMenuItem +} from 'editor/models/sectionMenuItems'; import {useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -19,7 +20,9 @@ describe('SectionMenuItems', () => { '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.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(); @@ -212,10 +215,10 @@ describe('SectionMenuItems', () => { expect(menuItem.get('label')).toBe('Copy permalink'); }); - it('has separated attribute', () => { + it('supports separated attribute', () => { const entry = createEntry({sections: [{id: 1}]}); const section = entry.sections.get(1); - const menuItem = new CopyPermalinkMenuItem({}, {entry, section}); + const menuItem = new CopyPermalinkMenuItem({separated: true}, {entry, section}); expect(menuItem.get('separated')).toBe(true); }); @@ -233,4 +236,27 @@ describe('SectionMenuItems', () => { 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 449faff25c..3657860a46 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionView-spec.js @@ -262,7 +262,7 @@ describe('EditSectionView', () => { '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.edit_section.destroy': 'Delete section' + 'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section' }); it('includes section-specific menu items', () => { 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 73c2107845..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'; @@ -17,30 +16,24 @@ describe('SectionItemView', () => { }); 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/src/editor/models/chapterMenuItems.js b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js new file mode 100644 index 0000000000..07896779b4 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -0,0 +1,13 @@ +import {DestroyMenuItem} from 'pageflow/editor'; + +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..23a1d63e6f --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -0,0 +1,28 @@ +import {DestroyMenuItem} from 'pageflow/editor'; + +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); + }, + + 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 similarity index 88% rename from entry_types/scrolled/package/src/editor/models/SectionMenuItems.js rename to entry_types/scrolled/package/src/editor/models/sectionMenuItems.js index 4239c45a49..35935ffe04 100644 --- a/entry_types/scrolled/package/src/editor/models/SectionMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -1,5 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; +import {DestroyMenuItem} from 'pageflow/editor'; export const HideShowSectionMenuItem = Backbone.Model.extend({ initialize(attributes, {section}) { @@ -101,15 +102,28 @@ export const CopyPermalinkMenuItem = Backbone.Model.extend({ } }); +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}), - new CopyPermalinkMenuItem({separated: true}, {entry, section}), - new HideShowSectionMenuItem({separated: true}, {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..983a034a47 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,9 +1,17 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; +import {DestroyChapterMenuItem} from '../models/chapterMenuItems'; + export const EditChapterView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_chapter', + getActionsMenuItems() { + return [ + new DestroyChapterMenuItem({}, {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..c60f84ed25 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -1,5 +1,7 @@ import {EditConfigurationView} from 'pageflow/editor'; +import {DestroyContentElementMenuItem} from '../models/contentElementMenuItems'; + export const EditContentElementView = EditConfigurationView.extend({ translationKeyPrefix() { return `pageflow_scrolled.editor.content_elements.${this.model.get('typeName')}` @@ -13,18 +15,13 @@ 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 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 cac81fc11f..4e45c067b9 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -10,7 +10,7 @@ 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 {createSectionMenuItems} from '../models/sectionMenuItems'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; @@ -20,10 +20,7 @@ export const EditSectionView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section', getActionsMenuItems() { - return [ - ...createSectionMenuItems({entry: this.options.entry, section: this.model}), - this.getDestroyMenuItem() - ]; + return createSectionMenuItems({entry: this.options.entry, section: this.model}); }, configure: function(configurationEditor) { diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index f87bbba0dc..455673f220 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -5,7 +5,7 @@ import {modelLifecycleTrackingView, DropDownButtonView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView' -import {createSectionMenuItems} from '../models/SectionMenuItems'; +import {createSectionMenuItems} from '../models/sectionMenuItems'; import arrowsIcon from './images/arrows.svg'; import hiddenIcon from './images/hidden.svg'; 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/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index 7981fd354f..e830ede776 100644 --- a/package/spec/editor/views/EditConfigurationView-spec.js +++ b/package/spec/editor/views/EditConfigurationView-spec.js @@ -102,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; - DropDownButton.find(view).selectMenuItemByName('destroy'); - - 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; - DropDownButton.find(view).selectMenuItemByName('destroy'); - - expect(editor.router.navigate).not.toHaveBeenCalled(); - }); - describe('goBack navigation', () => { it('navigates to / by default', () => { const Model = Backbone.Model.extend({ @@ -322,41 +278,7 @@ describe('EditConfigurationView', () => { } }); - it('renders a DropDownButtonView', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] - }); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - } - }); - - const view = new View({model: new Model()}).render(); - const dropDownButton = DropDownButton.find(view); - - expect(dropDownButton).toBeDefined(); - }); - - it('renders Delete menu item', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] - }); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - } - }); - - const view = new View({model: new Model()}).render(); - const dropDownButton = DropDownButton.find(view); - - expect(dropDownButton.menuItemLabels()).toContain('Delete'); - }); - - it('uses Actions as button label', () => { + it('does not render dropdown by default', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); @@ -369,32 +291,10 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); - expect(view.$el.find('.drop_down_button button').text()).toBe('Actions'); - }); - - it('calls destroyModel when Delete menu item is clicked', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] - }); - const destroyModel = jest.fn(); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - }, - destroyModel - }); - - const view = new View({model: new Model()}).render(); - window.confirm = () => true; - DropDownButton.find(view).selectMenuItemByName('destroy'); - - expect(destroyModel).toHaveBeenCalled(); + expect(DropDownButton.findAll(view)).toHaveLength(0); }); - }); - describe('getActionsMenuItems', () => { - it('allows subclass to customize menu items', () => { + it('renders dropdown when getActionsMenuItems returns items', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); @@ -415,32 +315,18 @@ describe('EditConfigurationView', () => { expect(dropDownButton.menuItemLabels()).toContain('Custom Action'); }); - }); - describe('hideDestroyButton', () => { - it('shows actions dropdown by default', () => { + it('uses Actions as button label', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); - const View = EditConfigurationView.extend({ - configure(configurationEditor) { - configurationEditor.tab('general', function() { - }); - } - }); - - const view = new View({model: new Model()}).render(); - - expect(DropDownButton.findAll(view)).toHaveLength(1); - }); - - it('hides actions dropdown when hideDestroyButton is true', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking] + const CustomMenuItem = Backbone.Model.extend({ + selected: jest.fn() }); const View = EditConfigurationView.extend({ - hideDestroyButton: true, - + getActionsMenuItems() { + return [new CustomMenuItem({name: 'custom', label: 'Custom'})]; + }, configure(configurationEditor) { configurationEditor.tab('general', function() { }); @@ -449,50 +335,26 @@ describe('EditConfigurationView', () => { const view = new View({model: new Model()}).render(); - expect(DropDownButton.findAll(view)).toHaveLength(0); + expect(view.$el.find('.drop_down_button button').text()).toBe('Actions'); }); + }); - it('supports hideDestroyButton as function', () => { + describe('model destroy', () => { + it('navigates back when model is destroyed', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer(), failureTracking] }); const View = EditConfigurationView.extend({ - hideDestroyButton() { - return this.model.get('preventDestroy'); - }, - - 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(); - - expect(DropDownButton.findAll(viewWithDestroy)).toHaveLength(1); - expect(DropDownButton.findAll(viewWithoutDestroy)).toHaveLength(0); - }); - - it('does not prevent destroy event handler when actions dropdown is shown', () => { - const Model = Backbone.Model.extend({ - mixins: [configurationContainer(), failureTracking], - destroyWithDelay: jest.fn() - }); - const View = EditConfigurationView.extend({ - hideDestroyButton: false, - 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; - DropDownButton.find(view).selectMenuItemByName('destroy'); + new View({model}).render(); + model.trigger('destroy'); - expect(view.model.destroyWithDelay).toHaveBeenCalled(); + expect(editor.router.navigate).toHaveBeenCalledWith('/', {trigger: true}); }); }); }); 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/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index f7480e921f..82576e1169 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -26,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('save_error')}

@@ -76,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() }; }, @@ -91,6 +81,10 @@ export const EditConfigurationView = Marionette.Layout.extend({ 'click a.back': 'goBack' }, + initialize() { + this.listenTo(this.model, 'destroy', this.goBack); + }, + onRender: function() { const translationKeyPrefix = _.result(this, 'translationKeyPrefix'); @@ -126,39 +120,13 @@ export const EditConfigurationView = Marionette.Layout.extend({ }, getActionsMenuItems() { - if (_.result(this, 'hideDestroyButton')) { - return []; - } - return [this.getDestroyMenuItem()]; - }, - - getDestroyMenuItem({separated} = {}) { - return new DestroyMenuItem({ - name: 'destroy', - label: this.t('destroy'), - separated, - destructive: true - }, { - view: this - }); + return []; }, onShow: function() { this.configurationEditor.refreshScroller(); }, - destroy: function() { - if (window.confirm(this.t('confirm_destroy'))) { - if (this.destroyModel() !== false) { - this.goBack(); - } - } - }, - - destroyModel() { - this.model.destroyWithDelay(); - }, - goBack: function() { const path = _.result(this, 'goBackPath') || '/'; editor.navigate(path, {trigger: true}); @@ -176,13 +144,3 @@ export const EditConfigurationView = Marionette.Layout.extend({ }); } }); - -const DestroyMenuItem = Backbone.Model.extend({ - initialize(attributes, options) { - this.options = options; - }, - - selected() { - this.options.view.destroy(); - } -}); From 1562630c4ada34fab1a323ab31ae8c8063ff69b9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 12:51:45 +0100 Subject: [PATCH 06/14] Watch foreign key in ForeignKeySubsetCollection Make ForeignKeySubsetCollection automatically update when an item's foreign key attribute changes. This enables moving items between collections by simply changing the foreign key value, without manually removing from source and adding to target. Also fix parent reference handling when moving items between collections - the old collection's remove handler no longer overwrites the reference that was just set by the new collection. REDMINE-41070 --- .../ForeignKeySubsetCollection-spec.js | 57 +++++++++++++++++++ .../collections/ForeignKeySubsetCollection.js | 5 +- 2 files changed, 61 insertions(+), 1 deletion(-) 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/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; + } }); } } From 0483c412f7fc939f258d406604e15232aae3d42e Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 12:51:45 +0100 Subject: [PATCH 07/14] Allow turning chapters into excursions and vice versa Add actions menu item in chapter edit view to toggle between main storyline and excursions. This makes it easy for editors to reorganize content without having to delete and recreate chapters. REDMINE-41070 --- entry_types/scrolled/config/locales/de.yml | 3 ++ entry_types/scrolled/config/locales/en.yml | 3 ++ .../spec/editor/models/Chapter-spec.js | 45 ++++++++++++++++ .../spec/editor/models/Storyline-spec.js | 52 ++++++++++++++++++- .../package/src/editor/models/Chapter.js | 13 +++++ .../package/src/editor/models/Storyline.js | 11 ++++ .../src/editor/models/chapterMenuItems.js | 23 ++++++++ .../src/editor/views/EditChapterView.js | 5 +- 8 files changed, 152 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 293b8deeab..618aab902a 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1145,6 +1145,9 @@ 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? diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 840cbb28b0..991f89c26b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1129,6 +1129,9 @@ 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? 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/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/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/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 index 07896779b4..42dcc65067 100644 --- a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -1,5 +1,28 @@ +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', diff --git a/entry_types/scrolled/package/src/editor/views/EditChapterView.js b/entry_types/scrolled/package/src/editor/views/EditChapterView.js index 983a034a47..6b87e9f0a5 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,14 +1,15 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; -import {DestroyChapterMenuItem} from '../models/chapterMenuItems'; +import {DestroyChapterMenuItem, ToggleExcursionMenuItem} from '../models/chapterMenuItems'; export const EditChapterView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_chapter', getActionsMenuItems() { return [ - new DestroyChapterMenuItem({}, {chapter: this.model}) + new ToggleExcursionMenuItem({}, {chapter: this.model}), + new DestroyChapterMenuItem({separated: true}, {chapter: this.model}) ]; }, From b62037eab01084df415bc9fc14cfcbf663355594 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 14:56:12 +0100 Subject: [PATCH 08/14] Show info box when editing hidden section Indicate to editors that a section is hidden outside of the editor to prevent confusion about why content might not appear in the published entry. Also add icon support to InfoBoxView for visual emphasis. REDMINE-41070 --- .../stylesheets/pageflow/editor/info_box.scss | 12 ++++++ entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../src/editor/views/EditSectionView.js | 12 +++++- package/spec/editor/views/InfoBoxView-spec.js | 37 ++++++++++++++++--- package/src/editor/views/InfoBoxView.js | 26 +++++++++---- 6 files changed, 74 insertions(+), 15 deletions(-) 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/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 618aab902a..9005804b3b 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1161,6 +1161,7 @@ de: 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: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 991f89c26b..08a5808221 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1145,6 +1145,7 @@ en: 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: diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 4e45c067b9..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, @@ -16,6 +16,8 @@ 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', @@ -41,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/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/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); } }); From c2bdef7b383d4286134c9f042aae74cddf4c8e62 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 15:22:53 +0100 Subject: [PATCH 09/14] Add getConfigurationModel to EditConfigurationView Allow subclasses to override which model is passed to ConfigurationEditorView. By default returns model.configuration, but can be overridden to return model directly for views that edit plain Backbone models instead of configuration containers. --- .../views/EditConfigurationView-spec.js | 36 +++++++++++++++++++ .../src/editor/views/EditConfigurationView.js | 6 +++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/package/spec/editor/views/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index e830ede776..42a27e7a83 100644 --- a/package/spec/editor/views/EditConfigurationView-spec.js +++ b/package/spec/editor/views/EditConfigurationView-spec.js @@ -263,6 +263,42 @@ describe('EditConfigurationView', () => { }); }); + 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() {}); + } + }); + const model = new Model(); + + const view = new View({model}).render(); + + expect(view.configurationEditor.model).toBe(model.configuration); + }); + + 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: { diff --git a/package/src/editor/views/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index 82576e1169..58afd549f0 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -91,7 +91,7 @@ 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') }); @@ -132,6 +132,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'); }, From ccc2c5df6d84e8861d078d350c6b8bd0bea1904d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 15:22:53 +0100 Subject: [PATCH 10/14] Refactor sidebar edit views to extend EditConfigurationView Sidebar views for editing collection items (external links, hotspot areas, image gallery items) now extend EditConfigurationView instead of duplicating its boilerplate. Delete actions are moved to the actions dropdown menu and use DestroyMenuItem. Allow views to specify which model event triggers back navigation. Defaults to 'destroy'. Sidebar edit views for collection items (hotspot areas, gallery items, external links) override to 'remove' since they use collection.remove() for deletion. modelLifecycleTrackingView is made defensive about missing failureTracking methods. REDMINE-21205 --- entry_types/scrolled/config/locales/de.yml | 12 +-- entry_types/scrolled/config/locales/en.yml | 12 +-- .../editor/SidebarEditLinkView-spec.js | 80 +++++++++++++++++++ .../editor/SidebarEditAreaView-spec.js | 41 +++++++++- .../editor/SidebarEditLinkView.js | 79 +++++++++--------- .../hotspots/editor/SidebarEditAreaView.js | 65 +++++++-------- .../editor/SidebarEditItemView.js | 59 ++++++-------- .../views/EditConfigurationView-spec.js | 35 ++++++++ .../src/editor/views/EditConfigurationView.js | 4 +- .../mixins/modelLifecycleTrackingView.js | 4 +- 10 files changed, 263 insertions(+), 128 deletions(-) create mode 100644 entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/SidebarEditLinkView-spec.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 9005804b3b..2899b5d0dd 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 diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 08a5808221..37ced5063b 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 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..5b6b690f06 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,49 @@ 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(); + }); + + }); + it('renders portrait tab if portrait image is present', () => { const entry = createEntry({ imageFiles: [ 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..df6cfb42b7 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,39 @@ -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') - }); + 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 +145,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/package/spec/editor/views/EditConfigurationView-spec.js b/package/spec/editor/views/EditConfigurationView-spec.js index 42a27e7a83..1df3c37d30 100644 --- a/package/spec/editor/views/EditConfigurationView-spec.js +++ b/package/spec/editor/views/EditConfigurationView-spec.js @@ -392,5 +392,40 @@ describe('EditConfigurationView', () => { expect(editor.router.navigate).toHaveBeenCalledWith('/', {trigger: true}); }); + + it('does not navigate back by default when model is removed from collection', () => { + 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('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() {}); + } + }); + const model = new Model(); + + new View({model}).render(); + model.trigger('remove'); + + expect(editor.router.navigate).toHaveBeenCalledWith('/', {trigger: true}); + }); }); }); diff --git a/package/src/editor/views/EditConfigurationView.js b/package/src/editor/views/EditConfigurationView.js index 58afd549f0..6546e11e9c 100644 --- a/package/src/editor/views/EditConfigurationView.js +++ b/package/src/editor/views/EditConfigurationView.js @@ -81,8 +81,10 @@ export const EditConfigurationView = Marionette.Layout.extend({ 'click a.back': 'goBack' }, + destroyEvent: 'destroy', + initialize() { - this.listenTo(this.model, 'destroy', this.goBack); + this.listenTo(this.model, _.result(this, 'destroyEvent'), this.goBack); }, onRender: function() { 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()); } } From 83bab9202c7742937d9ee4808c744b6fdab516bf Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 16:05:48 +0100 Subject: [PATCH 11/14] Deselect area when navigating back from edit area view Post SET_ACTIVE_AREA command with index -1 to clear the active area highlight in the preview when leaving the area edit view. --- .../editor/SidebarEditAreaView-spec.js | 34 +++++++++++++++++++ .../hotspots/editor/SidebarEditAreaView.js | 5 +++ 2 files changed, 39 insertions(+) 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 5b6b690f06..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 @@ -50,7 +50,41 @@ describe('SidebarEditAreaView', () => { 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', () => { 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 df6cfb42b7..db2f12f780 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/SidebarEditAreaView.js @@ -25,6 +25,11 @@ export const SidebarEditAreaView = EditConfigurationView.extend({ return `/scrolled/content_elements/${this.options.contentElement.get('id')}`; }, + goBack() { + this.options.contentElement.postCommand({type: 'SET_ACTIVE_AREA', index: -1}); + EditConfigurationView.prototype.goBack.call(this); + }, + getActionsMenuItems() { return [new DestroyAreaMenuItem({}, { collection: this.options.collection, From 6b5eea9a82eca75aebb3c3dc1d56df6f191fc99c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 16:15:17 +0100 Subject: [PATCH 12/14] Add duplicate action to content element menu Allow users to quickly create a copy of a content element with identical type and configuration. The duplicate is inserted directly after the original and automatically selected. REDMINE-21205 --- entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../deleteContentElement-spec.js | 8 +- .../duplicateContentElement-spec.js | 96 +++++++++++++++++++ .../models/contentElementMenuItems-spec.js | 47 ++++++++- .../ScrolledEntry/duplicateContentElement.js | 21 ++++ .../src/editor/models/ScrolledEntry/index.js | 9 +- .../editor/models/contentElementMenuItems.js | 14 +++ .../editor/views/EditContentElementView.js | 9 +- 9 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/duplicateContentElement-spec.js create mode 100644 entry_types/scrolled/package/src/editor/models/ScrolledEntry/duplicateContentElement.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 2899b5d0dd..2e95d6d985 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1157,6 +1157,8 @@ de: destroy_content_element_menu_item: confirm_destroy: Element wirklich löschen? destroy: Element löschen + duplicate_content_element_menu_item: + label: Element duplizieren destroy_section_menu_item: confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? destroy: Abschnitt löschen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 37ced5063b..f82be96ca0 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1141,6 +1141,8 @@ en: destroy_content_element_menu_item: confirm_destroy: Really delete this element? destroy: Delete element + duplicate_content_element_menu_item: + label: Duplicate element destroy_section_menu_item: confirm_destroy: Really delete this section including all its elements? destroy: Delete section 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/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index 0a6142ef2b..a0a8c3165e 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -1,10 +1,55 @@ -import {DestroyContentElementMenuItem} from 'editor/models/contentElementMenuItems'; +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' + }); + + 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); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Duplicate element'); + }); + + 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(); + + const menuItem = new DuplicateContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + expect(entry.duplicateContentElement).toHaveBeenCalledWith(contentElement); + }); + }); + describe('DestroyContentElementMenuItem', () => { useFakeTranslations({ 'pageflow_scrolled.editor.destroy_content_element_menu_item.destroy': 'Delete element', 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/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index 23a1d63e6f..8f12094adb 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -1,5 +1,19 @@ +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.set('label', I18n.t('pageflow_scrolled.editor.duplicate_content_element_menu_item.label')); + }, + + selected() { + this.entry.duplicateContentElement(this.contentElement); + } +}); + export const DestroyContentElementMenuItem = DestroyMenuItem.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.destroy_content_element_menu_item', diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index c60f84ed25..337f59666f 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -1,6 +1,9 @@ import {EditConfigurationView} from 'pageflow/editor'; -import {DestroyContentElementMenuItem} from '../models/contentElementMenuItems'; +import { + DestroyContentElementMenuItem, + DuplicateContentElementMenuItem +} from '../models/contentElementMenuItems'; export const EditContentElementView = EditConfigurationView.extend({ translationKeyPrefix() { @@ -17,6 +20,10 @@ export const EditContentElementView = EditConfigurationView.extend({ getActionsMenuItems() { return [ + new DuplicateContentElementMenuItem({}, { + contentElement: this.model, + entry: this.options.entry + }), new DestroyContentElementMenuItem({}, { contentElement: this.model, entry: this.options.entry, From c6166d8de9d550aec716daa0bb43262d65e2fe19 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 16:33:23 +0100 Subject: [PATCH 13/14] Support text block paragraph duplication Allow duplicating individual paragraphs within text blocks using the content element duplicate action. Text blocks override the default duplication behavior to duplicate only the selected Slate nodes rather than the entire content element. The duplicated nodes are automatically selected afterwards. REDMINE-21205 --- .../models/contentElementMenuItems-spec.js | 25 ++ .../EditableText/duplicateNodes-spec.js | 285 ++++++++++++++++++ .../src/contentElements/textBlock/editor.js | 4 + .../editor/models/contentElementMenuItems.js | 11 +- .../editor/views/EditContentElementView.js | 3 +- .../EditableText/duplicateNodes.js | 33 ++ .../inlineEditing/EditableText/index.js | 5 + 7 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/duplicateNodes-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/duplicateNodes.js diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index a0a8c3165e..69cf54e941 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -37,6 +37,7 @@ describe('ContentElementMenuItems', () => { }); const contentElement = entry.contentElements.get(1); entry.duplicateContentElement = jest.fn(); + editor.contentElementTypes.register('textBlock', {}); const menuItem = new DuplicateContentElementMenuItem({}, { contentElement, @@ -48,6 +49,30 @@ describe('ContentElementMenuItems', () => { 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', () => { 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/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/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index 8f12094adb..c117825fa0 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -6,11 +6,20 @@ export const DuplicateContentElementMenuItem = Backbone.Model.extend({ initialize(attributes, options) { this.contentElement = options.contentElement; this.entry = options.entry; + this.editor = options.editor; this.set('label', I18n.t('pageflow_scrolled.editor.duplicate_content_element_menu_item.label')); }, selected() { - this.entry.duplicateContentElement(this.contentElement); + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + if (contentElementType.handleDuplicate) { + contentElementType.handleDuplicate(this.contentElement); + } + else { + this.entry.duplicateContentElement(this.contentElement); + } } }); diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index 337f59666f..736d8d092a 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -22,7 +22,8 @@ export const EditContentElementView = EditConfigurationView.extend({ return [ new DuplicateContentElementMenuItem({}, { contentElement: this.model, - entry: this.options.entry + entry: this.options.entry, + editor: this.options.editor }), new DestroyContentElementMenuItem({}, { contentElement: this.model, 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); From f84037ec5dccb528afde0df4a26501837c0575d4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 13 Jan 2026 16:58:48 +0100 Subject: [PATCH 14/14] Improve action menu labels for text blocks Show "Delete selection" and "Duplicate selection" labels when text block handles the action itself (operating on selected paragraphs rather than the whole element). Also make section duplicate label more explicit. REDMINE-21205 --- entry_types/scrolled/config/locales/de.yml | 4 +- entry_types/scrolled/config/locales/en.yml | 4 +- .../models/contentElementMenuItems-spec.js | 44 ++++++++++++++++++- .../editor/models/contentElementMenuItems.js | 19 +++++++- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 2e95d6d985..580a534809 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1157,8 +1157,10 @@ de: 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 @@ -1292,7 +1294,7 @@ de: scrollOver: Überlagern section_menu_items: copy_permalink: Permalink kopieren - duplicate: Duplizieren + duplicate: Abschnitt duplizieren hide: Außerhalb des Editors ausblenden insert_section_above: Abschnitt oberhalb einfügen insert_section_below: Abschnitt unterhalb einfügen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index f82be96ca0..dc5dba9587 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1141,8 +1141,10 @@ en: 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 @@ -1276,7 +1278,7 @@ en: scrollOver: Scroll over section_menu_items: copy_permalink: Copy permalink - duplicate: Duplicate + duplicate: Duplicate section hide: Hide outside of the editor insert_section_above: Insert section above insert_section_below: Insert section below diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index 69cf54e941..098a95b17c 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -7,7 +7,8 @@ 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.label': 'Duplicate element', + 'pageflow_scrolled.editor.duplicate_content_element_menu_item.selection_label': 'Duplicate selection' }); it('has Duplicate element label', () => { @@ -18,6 +19,7 @@ describe('ContentElementMenuItems', () => { }) }); const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); const menuItem = new DuplicateContentElementMenuItem({}, { contentElement, @@ -28,6 +30,25 @@ describe('ContentElementMenuItems', () => { 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, {}, { @@ -78,6 +99,7 @@ describe('ContentElementMenuItems', () => { 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?' }); @@ -89,6 +111,7 @@ describe('ContentElementMenuItems', () => { }) }); const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); const menuItem = new DestroyContentElementMenuItem({}, { contentElement, @@ -99,6 +122,25 @@ describe('ContentElementMenuItems', () => { 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, {}, { diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index c117825fa0..dd3b5a54c9 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -7,7 +7,15 @@ export const DuplicateContentElementMenuItem = Backbone.Model.extend({ this.contentElement = options.contentElement; this.entry = options.entry; this.editor = options.editor; - this.set('label', I18n.t('pageflow_scrolled.editor.duplicate_content_element_menu_item.label')); + + 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() { @@ -32,6 +40,15 @@ export const DestroyContentElementMenuItem = DestroyMenuItem.extend({ 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() {