From 4d1b6af8183fcb7f84c24067f52f0ff0bcda6363 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 17:34:31 +0200 Subject: [PATCH 01/29] Size sections relative to thumbnail viewport Section thumbnails set --vh from their own size, but full height foregrounds and the default section paddings used raw viewport units. Thumbnails of full height sections thus grew to the height of the browser window and paddings changed when resizing it. Full height foregrounds now use var(--vh). The default section paddings and the padding scale use an --svh custom property, which thumbnails set and which falls back to 1svh elsewhere. Custom properties referencing other custom properties resolve where they are declared. Theme root properties using viewport units are therefore also declared on the thumbnail element, marked by a data-section-thumbnail attribute, so that they pick up the thumbnail's --svh. Other root properties are left alone so overrides from theme stylesheets keep working inside thumbnails. These rules are only rendered for the editor and its preview iframe, where thumbnails are displayed. --- .../pageflow_scrolled/themes_helper.rb | 33 +++++++--- .../editor/entries/_head.html.erb | 2 +- .../pageflow_scrolled/entries/show.html.erb | 5 +- .../scrolled/lib/pageflow_scrolled/plugin.rb | 18 +++--- .../spec/frontend/SectionThumbnail-spec.js | 28 ++++++++- .../src/frontend/Foreground.module.css | 11 ++-- .../package/src/frontend/SectionThumbnail.js | 6 +- .../pageflow_scrolled/themes_helper_spec.rb | 63 +++++++++++++++++++ 8 files changed, 140 insertions(+), 26 deletions(-) diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb index f6ba9897b0..2e1d6eb7e0 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb @@ -25,7 +25,7 @@ def scrolled_theme_stylesheet_pack_tags(theme) end) end - def scrolled_theme_properties_style_tag(theme) + def scrolled_theme_properties_style_tag(theme, include_section_thumbnail_rules: false) declarations = [ scrolled_theme_deep_declarations(theme.options.fetch(:font_family, {}), 'font_family'), scrolled_theme_deep_declarations(theme.options.fetch(:colors, {}), 'color') @@ -38,7 +38,7 @@ def scrolled_theme_properties_style_tag(theme) } #{scrolled_theme_typography_rules(theme)} - #{scrolled_theme_properties_rules(theme)} + #{scrolled_theme_properties_rules(theme, include_section_thumbnail_rules:)} CSS end @@ -63,9 +63,27 @@ def scrolled_theme_typography_rules(theme) RuleSet.new(prefix: 'typography').generate(theme.options.fetch(:typography, {})) end - def scrolled_theme_properties_rules(theme) - RuleSet.new(prefix: 'scope', - custom_properties: true).generate(theme.options.fetch(:properties, {})) + def scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: false) + properties = theme.options.fetch(:properties, {}) + rules = RuleSet.new(prefix: 'scope', custom_properties: true).generate(properties) + return rules unless include_section_thumbnail_rules + + # Section thumbnails set viewport unit custom properties on an + # element with this attribute. Root properties using them only + # resolve against the thumbnail size if declared there again. + viewport_dependent_root_properties = properties.fetch(:root, {}).select do |_, value| + value.is_a?(String) && value.match?(/var\(--s?vh\b/) + end + + return rules if viewport_dependent_root_properties.empty? + + [ + rules, + RuleSet.new(prefix: 'scope', + custom_properties: true, + root_selector: '[data-section-thumbnail]') + .generate(root: viewport_dependent_root_properties) + ].join("\n") end private @@ -232,9 +250,10 @@ def descriptors # @api private class RuleSet - def initialize(prefix:, custom_properties: false) + def initialize(prefix:, custom_properties: false, root_selector: ':root') @prefix = prefix @property_prefix = custom_properties ? '--theme-' : '' + @root_selector = root_selector end def generate(rules) @@ -280,7 +299,7 @@ def generate_rule(rule_name, declarations) end def selector(rule_name) - return ':root' if rule_name == :root + return @root_selector if rule_name == :root ".#{@prefix}-#{rule_name.to_s.camelize(:lower)}" end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb index 2c64561341..1bc9f2ca9b 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb @@ -1,7 +1,7 @@ <%= stylesheet_link_tag 'pageflow_paged/editor', media: 'all' %> <%= stylesheet_pack_tag 'pageflow-scrolled-frontend' %> -<%= scrolled_theme_properties_style_tag(entry.theme) %> +<%= scrolled_theme_properties_style_tag(entry.theme, include_section_thumbnail_rules: true) %> <%= scrolled_theme_stylesheet_pack_tags(entry.theme) %> <%= javascript_include_tag 'pageflow/vendor' %> diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb index 95803242eb..2149961859 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb @@ -22,7 +22,10 @@ <%= scrolled_frontend_stylesheet_packs_tag(entry, entry_mode: entry_mode, seed_options: seed_options) %> <%= scrolled_theme_font_preload_link_tags(entry.theme) %> - <%= scrolled_theme_properties_style_tag(entry.theme) %> + <%= scrolled_theme_properties_style_tag( + entry.theme, + include_section_thumbnail_rules: entry_mode == :editor + ) %> <%= scrolled_theme_stylesheet_pack_tags(entry.theme) %> <%= render_widget_head_fragments(entry, scope: entry_mode) %> diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 72ce70c699..0ad58ba461 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -20,11 +20,11 @@ def configure(config) 'xxs' => '3em', 'xs' => '4.375em', 'sm' => '6em', - 'md' => 'max(7em, 10svh)', - 'lg' => 'max(8em, 10svh)', - 'xl' => 'max(9em, 15svh)', - 'xxl' => 'max(10em, 20svh)', - 'xxxl' => 'max(11em, 30svh)' + 'md' => 'max(7em, calc(10 * var(--svh, 1svh)))', + 'lg' => 'max(8em, calc(10 * var(--svh, 1svh)))', + 'xl' => 'max(9em, calc(15 * var(--svh, 1svh)))', + 'xxl' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'xxxl' => 'max(11em, calc(30 * var(--svh, 1svh)))' } margin_scale = { @@ -101,8 +101,8 @@ def configure(config) c.themes.register_default_options( properties: { root: { - 'section_default_padding_top' => 'max(10em, 20svh)', - 'section_default_padding_bottom' => 'max(10em, 20svh)', + 'section_default_padding_top' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'section_default_padding_bottom' => 'max(10em, calc(20 * var(--svh, 1svh)))', 'content_element_margin_style_default' => '2em', 'content_element_box_shadow_style_default' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', @@ -112,8 +112,8 @@ def configure(config) 'outline_color' => '#a0a0a080' }, cards_appearance_section: { - 'section_default_padding_top' => 'max(10em, 20svh)', - 'section_default_padding_bottom' => 'max(10em, 20svh)' + 'section_default_padding_top' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'section_default_padding_bottom' => 'max(10em, calc(20 * var(--svh, 1svh)))' } } ) diff --git a/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js b/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js index 2db107297d..6560aefaa5 100644 --- a/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js +++ b/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js @@ -1,7 +1,10 @@ import React from 'react'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; -import {StandaloneSectionThumbnail} from 'frontend/SectionThumbnail'; +import { + StandaloneSectionThumbnail, + viewportUnitCustomProperties +} from 'frontend/SectionThumbnail'; import {watchCollections} from 'entryState'; import 'contentElements/heading/frontend'; @@ -102,4 +105,27 @@ describe('SectionThumbnail', () => { expect(consoleError).not.toHaveBeenCalled(); }); + + it('marks element with viewport custom properties for theme root properties', () => { + const seed = normalizeSeed({ + sections: [ + {id: 3, permaId: 1}, + ] + }); + const {container} = render( + + ); + + expect(container.querySelector('[data-section-thumbnail]')).not.toBeNull(); + }); + + describe('viewportUnitCustomProperties', () => { + it('derives viewport units from thumbnail dimensions', () => { + expect(viewportUnitCustomProperties({width: 800, height: 400})).toEqual({ + '--vw': '8px', + '--vh': '4px', + '--svh': '4px' + }); + }); + }); }); diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index 30cdb528a2..53a981acde 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -15,15 +15,16 @@ } .fullFadeHeight { - min-height: 51vh; + min-height: calc(51 * var(--vh)); } .fullHeight { /* First sections set --foreground-widget-padding-top to make room - for the widget, so the terms cancel out to 100vh. Other sections - leave it undefined, causing the fallback to --widget-margin-top-min - to reduce the height to fit below the navigation widget. */ - min-height: calc(100vh + + for the widget, so the terms cancel out to the viewport height. + Other sections leave it undefined, causing the fallback to + --widget-margin-top-min to reduce the height to fit below the + navigation widget. */ + min-height: calc(100 * var(--vh) + var(--foreground-widget-padding-top, 0px) - var(--foreground-widget-padding-top, var(--widget-margin-top-min, 0px))); } diff --git a/entry_types/scrolled/package/src/frontend/SectionThumbnail.js b/entry_types/scrolled/package/src/frontend/SectionThumbnail.js index 0322eea646..ac54db0705 100644 --- a/entry_types/scrolled/package/src/frontend/SectionThumbnail.js +++ b/entry_types/scrolled/package/src/frontend/SectionThumbnail.js @@ -40,6 +40,7 @@ export function SectionThumbnail({sectionPermaId, subscribe, scale}) {
@@ -77,9 +78,10 @@ function clientDimensions(contentRect, scaleFactor) { Math.ceil(contentRect.client.height * scaleFactor) } } -function viewportUnitCustomProperties({width, height}) { +export function viewportUnitCustomProperties({width, height}) { return { '--vw': width && `${width / 100}px`, - '--vh': height && `${height / 100}px` + '--vh': height && `${height / 100}px`, + '--svh': height && `${height / 100}px` } } diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb index 74301e6bee..d8a520cfa8 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb @@ -71,6 +71,23 @@ module PageflowScrolled end describe '#scrolled_theme_properties_style_tag' do + it 'supports including section thumbnail rules' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + html = helper.scrolled_theme_properties_style_tag(theme, + include_section_thumbnail_rules: true) + + expect(html).to have_css('style', + text: '[data-section-thumbnail]', + visible: false) + end + it 'renders style tag with custom color properties for theme' do theme = Pageflow::Theme.new(:test, colors: { @@ -672,6 +689,52 @@ def upload_font(entry, file_name) CSS end + it 'declares viewport dependent root properties again for section thumbnails' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + widget_surface_color: '#fff', + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + css = helper.scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: true) + + expect(css).to include(<<~CSS) + [data-section-thumbnail] { + --theme-section-default-padding-top: max(10em, calc(20 * var(--svh, 1svh))); + } + CSS + end + + it 'does not render section thumbnail rule without viewport dependent root properties' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + widget_surface_color: '#fff' + } + }) + + css = helper.scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: true) + + expect(css).not_to include('data-section-thumbnail') + end + + it 'does not render section thumbnail rule unless requested' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + css = helper.scrolled_theme_properties_rules(theme) + + expect(css).not_to include('data-section-thumbnail') + end + it 'supports media queries' do theme = Pageflow::Theme.new(:test, properties: { From 8afed867024502cb6dd8bb9fa1aedfea25f61ac4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 16:23:04 +0200 Subject: [PATCH 02/29] Number chapters in outline by index Chapter positions keep gaps when chapters are deleted since storylines do not consolidate positions. Deriving the displayed number from the chapter's index in its storyline renumbers the remaining chapters. Outline items update their number when chapters of their storyline are added, removed or reordered. --- .../spec/editor/models/Chapter-spec.js | 14 ++++++++ .../spec/editor/views/ChapterItemView-spec.js | 32 +++++++++++++++++++ .../package/src/editor/models/Chapter.js | 3 +- .../src/editor/views/ChapterItemView.js | 8 +++++ 4 files changed, 56 insertions(+), 1 deletion(-) 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 323874860e..0b84a75ccc 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -715,6 +715,20 @@ describe('Chapter', () => { it('returns excursion label for untitled excursion', () => { expect(createEntry().chapters.get(3).getDisplayName()).toEqual('Excursion'); }); + + it('numbers chapters by their index ignoring gaps in positions', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [{id: 100, configuration: {main: true}}], + chapters: [ + {id: 1, storylineId: 100, position: 0}, + {id: 2, storylineId: 100, position: 3} + ] + }) + }); + + expect(entry.chapters.get(2).getDisplayName()).toEqual('Chapter 2'); + }); }); describe('#isExcursion', () => { diff --git a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js index 15b65cec6f..4ec1931276 100644 --- a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js @@ -47,6 +47,38 @@ describe('ChapterItemView', () => { expect(screen.getByRole('link', {name: /Chapter 1 Some title/})).toBeInTheDocument(); }); + it('updates number when preceding chapter is removed', () => { + const entry = createEntry({ + chapters: [ + {id: 1, permaId: 100, position: 0, configuration: {title: 'First'}}, + {id: 2, permaId: 101, position: 1, configuration: {title: 'Second'}} + ] + }); + const view = new ChapterItemView({ + entry, + model: entry.chapters.get(2) + }); + + render(view); + entry.chapters.remove(entry.chapters.get(1)); + + expect(screen.getByRole('link', {name: /Chapter 1 Second/})).toBeInTheDocument(); + }); + + it('does not fail when own chapter is removed', () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}] + }); + const view = new ChapterItemView({ + entry, + model: entry.chapters.get(1) + }); + + render(view); + + expect(() => entry.chapters.remove(entry.chapters.get(1))).not.toThrow(); + }); + it('marks chapter as hidden in navigation', async () => { const entry = createEntry({ chapters: [ diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 9a9c654594..527d617c7d 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -48,7 +48,8 @@ export const Chapter = Backbone.Model.extend({ getDisplayNumber() { if (this.storyline.isMain()) { - return I18n.t('pageflow_scrolled.editor.chapter_item.chapter') + ' ' + (this.get('position') + 1); + return I18n.t('pageflow_scrolled.editor.chapter_item.chapter') + ' ' + + (this.storyline.chapters.indexOf(this) + 1); } }, diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index faf4a4011a..57181ac9ab 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -62,6 +62,14 @@ export const ChapterItemView = Marionette.Layout.extend({ change: 'update' }, + initialize() { + this.listenTo(this.model.storyline.chapters, 'add remove sort', changed => { + if (changed !== this.model) { + this.update(); + } + }); + }, + onRender() { this.subview(new SortableCollectionView({ el: this.ui.sections, From f7796bac51077a2a1d48e96b8f563a4a6ddcfab9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:03:35 +0200 Subject: [PATCH 03/29] Dim outline items while they are being deleted Replaces the blinking trash glyph so the trash icon of the content trash entrance in the outline toolbar does not read as a deletion in progress. --- .../scrolled/package/src/editor/views/ChapterItemView.js | 1 - .../package/src/editor/views/ChapterItemView.module.css | 3 +-- .../scrolled/package/src/editor/views/SectionItemView.js | 1 - .../package/src/editor/views/SectionItemView.module.css | 3 +-- .../scrolled/package/src/editor/views/outline.module.css | 7 +++---- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index 57181ac9ab..14e0123368 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -23,7 +23,6 @@ export const ChapterItemView = Marionette.Layout.extend({ - diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css index 2f92430052..0ef4fe1913 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css @@ -69,12 +69,11 @@ } .creating .creatingIndicator { display: block; } -.destroying .destroyingIndicator { display: block; } .failed .failedIndicator { display: block; } .creatingIndicator { composes: creatingIndicator from './outline.module.css'; } -.destroyingIndicator { composes: destroyingIndicator from './outline.module.css'; } +.destroying { composes: destroying from './outline.module.css'; } .failedIndicator { composes: failedIndicator from './outline.module.css'; } .addButtons { diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 455673f220..3e69e6d794 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -37,7 +37,6 @@ export const SectionItemView = Marionette.ItemView.extend({
- Date: Fri, 16 Oct 2026 12:06:32 +0200 Subject: [PATCH 04/29] Add role column to scrolled storylines Distinguishes storylines by column instead of configuration so a trash storyline can be filtered inside SQL joins. The column is nullable and only written for new storylines. Existing rows are not backfilled: queries only ever look for the trash role, which old rows never have. --- ...1016000000_add_role_to_pageflow_scrolled_storylines.rb | 5 +++++ .../scrolled/app/models/pageflow_scrolled/storyline.rb | 4 ++-- entry_types/scrolled/spec/factories/storylines.rb | 1 + .../spec/models/pageflow_scrolled/storyline_spec.rb | 8 ++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb diff --git a/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb b/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb new file mode 100644 index 0000000000..cdb74e1348 --- /dev/null +++ b/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb @@ -0,0 +1,5 @@ +class AddRoleToPageflowScrolledStorylines < ActiveRecord::Migration[7.2] + def change + add_column :pageflow_scrolled_storylines, :role, :string + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 7b5f8b9262..bd8eba5155 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -33,8 +33,8 @@ def sections_before_cutoff_section end def self.create_defaults(draft) - create!(revision: draft, configuration: {main: true}) - create!(revision: draft, position: 1) + create!(revision: draft, role: 'main', configuration: {main: true}) + create!(revision: draft, role: 'excursions', position: 1) end private diff --git a/entry_types/scrolled/spec/factories/storylines.rb b/entry_types/scrolled/spec/factories/storylines.rb index 5ea006ef43..d52074e196 100644 --- a/entry_types/scrolled/spec/factories/storylines.rb +++ b/entry_types/scrolled/spec/factories/storylines.rb @@ -2,6 +2,7 @@ module PageflowScrolled FactoryBot.define do factory :scrolled_storyline, class: Storyline do revision + role { configuration.to_h.with_indifferent_access[:main] ? 'main' : 'excursions' } configuration do { main: true diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb index 825fb32ae4..c11a68dadf 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb @@ -12,6 +12,14 @@ module PageflowScrolled expect(storylines.map(&:position)).to eq([0, 1]) end + it 'assigns main and excursions roles to default storylines' do + entry = create(:entry, type_name: 'scrolled') + + storylines = Storyline.all_for_revision(entry.draft) + + expect(storylines.map(&:role)).to eq(%w[main excursions]) + end + it 'copies a duplicate of itself to new revision' do Pageflow.config.revision_components.register(Storyline) revision = create(:revision) From c13b5663e1b2de00ea462a73d3a3b047dd14b634 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 23 Oct 2026 10:52:08 +0200 Subject: [PATCH 05/29] Detect main storyline by role Storylines expose their role in the seed and the editor and frontend identify the main storyline by role instead of configuration.main. New storylines no longer write the main flag. Storylines created before the role column, or by old app instances during a rolling update, fall back to their main flag, so no backfill is needed. --- .../pageflow_scrolled/revision_content.rb | 2 +- .../app/models/pageflow_scrolled/storyline.rb | 6 ++- .../storylines/_storyline.json.jbuilder | 1 + .../FragmentLibrariesCollection-spec.js | 2 +- .../collections/SectionsCollection-spec.js | 6 +-- .../spec/editor/models/Chapter-spec.js | 10 ++--- .../spec/editor/models/LinkableEntry-spec.js | 2 +- .../models/ScrolledEntry/chapterSlugs-spec.js | 2 +- .../spec/editor/models/Section-spec.js | 8 ++-- .../spec/editor/models/Storyline-spec.js | 6 +-- .../editor/models/chapterMenuItems-spec.js | 2 +- .../spec/editor/views/EditChapterView-spec.js | 8 ++-- .../editor/views/EntryCommentsView-spec.js | 2 +- .../editor/views/EntryOutlineView-spec.js | 12 +++--- .../views/FragmentLibraryDialogView-spec.js | 4 +- .../spec/editor/views/SectionItemView-spec.js | 4 +- .../SelectLinkDestinationDialogView-spec.js | 2 +- .../views/SelectableEntryOutlineView-spec.js | 2 +- .../package/spec/entryState/structure-spec.js | 4 +- .../package/spec/frontend/Link-spec.js | 2 +- .../commentNavigationExcursions-spec.js | 2 +- .../features/changeSectionEvent-spec.js | 4 +- .../features/changeSectionMessage-spec.js | 4 +- .../features/changeURLFragment-spec.js | 4 +- .../features/mainStorylineWidget-spec.js | 8 ++-- .../features/renderingExcursions-spec.js | 6 +-- .../features/scrollToSectionMessage-spec.js | 2 +- .../features/sectionFirstClass-spec.js | 2 +- .../frontend/features/transitions-spec.js | 2 +- .../inlineEditing/LinkTooltip-spec.js | 4 +- .../features/contentElementSelection-spec.js | 2 +- .../package/spec/frontend/transitions-spec.js | 2 +- .../spec/frontend/useActiveExcursion-spec.js | 40 +++++++++---------- .../useContentElementLifecycle-spec.js | 18 ++++----- .../spec/frontend/useSectionLifecycle-spec.js | 2 +- .../review/useLocatedCommentThreads-spec.js | 2 +- ...useLocatedCommentThreadsForSubject-spec.js | 2 +- .../spec/testHelpers/normalizeSeed-spec.js | 8 +--- .../collections/StorylinesCollection.js | 4 +- .../package/src/editor/models/Storyline.js | 2 +- .../package/src/entryState/collections.js | 2 +- .../package/src/entryState/structure.js | 2 +- .../package/src/testHelpers/normalizeSeed.js | 5 +-- .../src/widgets/excursionSheet/stories.js | 2 +- .../fragment_extractions_controller_spec.rb | 2 +- .../scrolled/spec/factories/chapters.rb | 4 +- .../scrolled/spec/factories/storylines.rb | 7 +--- .../entry_json_seed_helper_spec.rb | 24 ++++++++--- .../revision_content_spec.rb | 9 ++--- .../pageflow_scrolled/storyline_spec.rb | 20 ++++++++++ 50 files changed, 152 insertions(+), 132 deletions(-) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb index e6cb530b8b..0ff9f0543d 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -14,7 +14,7 @@ def storylines end def main_storyline - storylines.detect { |storyline| storyline.configuration['main'] } + storylines.detect { |storyline| storyline.role == 'main' } end def chapters diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index bd8eba5155..7496eaff18 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -28,12 +28,16 @@ class Storyline < Pageflow::ApplicationRecord # rubocop:todo Style/Documentation nested_revision_components :chapters + def role + super || (configuration['main'] ? 'main' : 'excursions') + end + def sections_before_cutoff_section sections_before(cutoff_section) end def self.create_defaults(draft) - create!(revision: draft, role: 'main', configuration: {main: true}) + create!(revision: draft, role: 'main') create!(revision: draft, role: 'excursions', position: 1) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder index 522632b4f3..ff10ad9088 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder @@ -3,5 +3,6 @@ json.key_format!(camelize: :lower) json.call(storyline, :id, :perma_id, + :role, :position, :configuration) diff --git a/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js index 11153c437f..91b3ed1111 100644 --- a/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js @@ -7,7 +7,7 @@ describe('FragmentLibrariesCollection', () => { return { ...attributes, collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters, sections, contentElements, diff --git a/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js index 5767de67f7..8bb548f938 100644 --- a/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js @@ -8,7 +8,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -32,7 +32,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -54,7 +54,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, position: 0, storylineId: 100}, 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 0b84a75ccc..4f01008f3a 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -692,7 +692,7 @@ describe('Chapter', () => { return factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -719,7 +719,7 @@ describe('Chapter', () => { it('numbers chapters by their index ignoring gaps in positions', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 100, configuration: {main: true}}], + storylines: [{id: 100, role: 'main'}], chapters: [ {id: 1, storylineId: 100, position: 0}, {id: 2, storylineId: 100, position: 3} @@ -736,7 +736,7 @@ describe('Chapter', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -753,7 +753,7 @@ describe('Chapter', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -776,7 +776,7 @@ describe('Chapter', () => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js index b2fa8423b8..7acf230e8a 100644 --- a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js @@ -43,7 +43,7 @@ describe('LinkableEntry', () => { chapters: [], sections: [], collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters: [], sections: [], contentElements: [], diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js index 00456d69e9..7a4466eb52 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js @@ -23,7 +23,7 @@ describe('ScrolledEntry', () => { {id: 1, permaId: 100, storylineId: 2, position: 0, configuration: {title: 'Intro'}}, {id: 2, permaId: 200, storylineId: 1, position: 1, configuration: {title: 'Intro'}} ], [ - {id: 1, position: 0, configuration: {main: true}}, + {id: 1, position: 0, role: 'main'}, {id: 2, position: 1, configuration: {}} ]); diff --git a/entry_types/scrolled/package/spec/editor/models/Section-spec.js b/entry_types/scrolled/package/spec/editor/models/Section-spec.js index c018c9caaa..254dc3b87d 100644 --- a/entry_types/scrolled/package/spec/editor/models/Section-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Section-spec.js @@ -52,7 +52,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -92,7 +92,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -118,7 +118,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -144,7 +144,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ 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 9a975086ab..09e1f02027 100644 --- a/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js @@ -14,7 +14,7 @@ describe('Storyline', () => { beforeEach(() => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}], + storylines: [{id: 10, role: 'main'}], chapters: [{storylineId: 10, position: 5}] }) }); @@ -41,7 +41,7 @@ describe('Storyline', () => { beforeEach(() => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}, {id: 20}], + storylines: [{id: 10, role: 'main'}, {id: 20}], chapters: [ {id: 1, storylineId: 10, position: 0}, {id: 2, storylineId: 10, position: 1}, @@ -71,7 +71,7 @@ describe('Storyline', () => { it('sets position to 0 for empty storyline', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}, {id: 20}], + storylines: [{id: 10, role: 'main'}, {id: 20}], chapters: [ {id: 1, storylineId: 10, position: 0} ] diff --git a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js index d2680510cf..bb8f77fa00 100644 --- a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js @@ -69,7 +69,7 @@ describe('ChapterMenuItems', () => { it('has Move to main label when chapter is an excursion', () => { const entry = createEntry({ - storylines: [{id: 1, configuration: {main: true}}, {id: 2}], + storylines: [{id: 1, role: 'main'}, {id: 2}], chapters: [{id: 1, storylineId: 2}] }); const chapter = entry.chapters.get(1); diff --git a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js index 6d9f1a10d1..c16fe9439e 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js @@ -14,7 +14,7 @@ describe('EditChapterView', () => { it('does not offer adding chapter to shared fragments without fragments feature', () => { const entry = createEntry({ - storylines: [{id: 100, configuration: {main: true}}], + storylines: [{id: 100, role: 'main'}], chapters: [{id: 1, storylineId: 100}] }); @@ -32,7 +32,7 @@ describe('EditChapterView', () => { it('offers adding chapter to shared fragments', () => { const entry = createEntry({ - storylines: [{id: 100, configuration: {main: true}}], + storylines: [{id: 100, role: 'main'}], chapters: [{id: 1, storylineId: 100}] }); @@ -49,7 +49,7 @@ describe('EditChapterView', () => { it('uses chapter tab for main storyline chapters', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -71,7 +71,7 @@ describe('EditChapterView', () => { it('uses excursion tab for excursion chapters', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js index 878e1cf98c..1e8ba1dd09 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js @@ -150,7 +150,7 @@ describe('EntryCommentsView', () => { it('groups excursion chapters below main chapters', () => { const entry = createEntry({ storylines: [ - {id: 1000, permaId: 100, position: 0, configuration: {main: true}}, + {id: 1000, permaId: 100, position: 0, role: 'main'}, {id: 2000, permaId: 200, position: 1, configuration: {}} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index b111bf4f09..492bf87b87 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -73,7 +73,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -123,7 +123,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -170,7 +170,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -219,7 +219,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -268,7 +268,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -319,7 +319,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, diff --git a/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js index eac5d8217a..4388a275f4 100644 --- a/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js @@ -37,7 +37,7 @@ describe('FragmentLibraryDialogView', () => { function library(attributes, chapters, sections) { return { collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters, sections, contentElements: [], @@ -182,7 +182,7 @@ describe('FragmentLibraryDialogView preview pane', () => { id: 3, title: 'Templates', collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters: [{id: 1, permaId: 100, configuration: {title: 'Intro'}}, {id: 2, permaId: 200, configuration: {title: 'Outro'}}], sections: [{id: 10, permaId: 1000, chapterId: 1, configuration: {}}, 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 62431f73f3..7d4c0ae825 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -173,7 +173,7 @@ describe('SectionItemView', () => { it('marks section as active when in excursion and section is current in that excursion', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -202,7 +202,7 @@ describe('SectionItemView', () => { it('does not mark section as active when in excursion but belongs to different chapter', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js index 6dd91aa732..d842ac7e04 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -182,7 +182,7 @@ describe('SelectLinkDestinationDialogView', () => { chapters: chapterDestinations || destinations(signatures.chapters), sections: destinations(signatures.sections), collections: { - storylines: [{id: 1, permaId: 1, configuration: {main: true}}], + storylines: [{id: 1, permaId: 1, role: 'main'}], chapters, sections, contentElements: [] diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js index 1053859b7c..369575ef73 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -299,7 +299,7 @@ describe('SelectableEntryOutlineView', () => { return factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index aa9a06d301..070c59190e 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -24,9 +24,7 @@ const storylinesSeed = [ id: 1, permaId: 10, position: 1, - configuration: { - main: true - } + role: 'main' }, { id: 2, diff --git a/entry_types/scrolled/package/spec/frontend/Link-spec.js b/entry_types/scrolled/package/spec/frontend/Link-spec.js index 1a92aced14..c441abbb7f 100644 --- a/entry_types/scrolled/package/spec/frontend/Link-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Link-spec.js @@ -341,7 +341,7 @@ describe('Link', () => { const storylinesSeed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js index f19d94be5e..dd4192aed0 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js @@ -19,7 +19,7 @@ describe('comment navigation across excursions', () => { const seed = { widgets: [{typeName: 'excursionOverlay', role: 'excursion'}], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js index 3a7bf61121..6c476dace2 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js @@ -89,7 +89,7 @@ describe('change section event', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -150,7 +150,7 @@ describe('change section event', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js index 1a156e5ac7..010e98e982 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js @@ -45,7 +45,7 @@ describe('CHANGE_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -93,7 +93,7 @@ describe('CHANGE_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js index 642c2c533c..f70debbac6 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js @@ -115,7 +115,7 @@ describe('change url fragment on section update', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -164,7 +164,7 @@ describe('change url fragment on section update', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js b/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js index 7775bac95f..e306f0657b 100644 --- a/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js @@ -26,7 +26,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -79,7 +79,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -118,7 +118,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -175,7 +175,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js b/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js index 02fc36b907..39724e2c96 100644 --- a/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js @@ -27,7 +27,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -83,7 +83,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -148,7 +148,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js index 032cc194c0..9594a19b88 100644 --- a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js @@ -273,7 +273,7 @@ describe('SCROLL_TO_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js index cec22b4809..16b9db3e50 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js @@ -47,7 +47,7 @@ describe('section first class', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js b/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js index 8c9917a9b3..1adf80fe3f 100644 --- a/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js @@ -58,7 +58,7 @@ describe('transitions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js index 78eeb820ed..697971ff30 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -396,7 +396,7 @@ describe('LinkTooltip', () => { it('displays tooltip for excursion chapter link with title', async () => { const seed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -423,7 +423,7 @@ describe('LinkTooltip', () => { it('displays tooltip for excursion chapter link without title', async () => { const seed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js index 99e8897919..36f1b3abf1 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js @@ -59,7 +59,7 @@ describe('inline editing content element selection', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/transitions-spec.js b/entry_types/scrolled/package/spec/frontend/transitions-spec.js index 63f5d1154e..db35e5856b 100644 --- a/entry_types/scrolled/package/spec/frontend/transitions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/transitions-spec.js @@ -255,7 +255,7 @@ describe('getTransitionStylesName', () => { const {result} = renderHookInEntry(() => useEntryStructure(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2, configuration: {}} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js index a35e0112d4..97334e8f42 100644 --- a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js @@ -31,7 +31,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -53,7 +53,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -75,7 +75,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -102,7 +102,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -132,7 +132,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -167,7 +167,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -196,7 +196,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -222,7 +222,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -250,7 +250,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -270,7 +270,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -296,7 +296,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -325,7 +325,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -353,7 +353,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -382,7 +382,7 @@ describe('ActiveExcursionProvider', () => { renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -420,7 +420,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -449,7 +449,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -476,7 +476,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -502,7 +502,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -540,7 +540,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -566,7 +566,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js index fb330d6e6b..15f9f81ef8 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js @@ -94,7 +94,7 @@ describe('useContentElementLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -462,7 +462,7 @@ describe('useContentElementLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -507,7 +507,7 @@ describe('useContentElementLifecycle', () => { renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -551,7 +551,7 @@ describe('useContentElementLifecycle', () => { renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -617,7 +617,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -665,7 +665,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -710,7 +710,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -761,7 +761,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -808,7 +808,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js b/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js index 7b4d8bdc1e..f4fa70c41e 100644 --- a/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js @@ -68,7 +68,7 @@ describe('useSectionLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js index c5ff697e76..fb214ffce7 100644 --- a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js +++ b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js @@ -5,7 +5,7 @@ import {renderHookInEntry} from 'pageflow-scrolled/testHelpers'; import {renderHookWithReviewState} from 'support/renderWithReviewState'; const storylinesSeed = [ - {id: 1, permaId: 10, position: 1, configuration: {main: true}}, + {id: 1, permaId: 10, position: 1, role: 'main'}, {id: 2, permaId: 11, position: 2, configuration: {}} ]; const chaptersSeed = [ diff --git a/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js b/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js index 5b85a620a5..36d0f04bc9 100644 --- a/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js +++ b/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js @@ -3,7 +3,7 @@ import {useLocatedCommentThreadsForSubject} from 'review/useLocatedCommentThread import {renderHookWithReviewState} from 'support/renderWithReviewState'; const seed = { - storylines: [{id: 1, permaId: 10, position: 1, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, position: 1, role: 'main'}], chapters: [{id: 1, permaId: 100, storylineId: 1, position: 1}], sections: [{id: 1, permaId: 1000, chapterId: 1, position: 1}], contentElements: [{id: 1, permaId: 10001, sectionId: 1, typeName: 'textBlock'}] diff --git a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js index 7a701eeb2b..445da041f2 100644 --- a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js +++ b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js @@ -251,9 +251,7 @@ describe('normalizeSeed', () => { { id: expect.any(Number), permaId: expect.any(Number), - configuration: { - main: true - } + role: 'main' } ] } @@ -270,9 +268,7 @@ describe('normalizeSeed', () => { { id: expect.any(Number), permaId: expect.any(Number), - configuration: { - main: true - } + role: 'main' } ] } diff --git a/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js b/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js index 7c05bab073..695bc36113 100644 --- a/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js @@ -16,10 +16,10 @@ export const StorylinesCollection = Backbone.Collection.extend({ }, main() { - return this.at(0); + return this.find(storyline => storyline.isMain()); }, excursions() { - return this.at(1); + return this.find(storyline => !storyline.isMain()); } }); diff --git a/entry_types/scrolled/package/src/editor/models/Storyline.js b/entry_types/scrolled/package/src/editor/models/Storyline.js index 372e4921b9..45970b1634 100644 --- a/entry_types/scrolled/package/src/editor/models/Storyline.js +++ b/entry_types/scrolled/package/src/editor/models/Storyline.js @@ -46,6 +46,6 @@ export const Storyline = Backbone.Model.extend({ }, isMain() { - return !!this.configuration.get('main'); + return this.get('role') === 'main'; } }); diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index 60ee2caa24..11fbbd3465 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -34,7 +34,7 @@ function collectionSpecs(entry) { { collection: storylines, name: 'storylines', - attributes: ['id', 'permaId'], + attributes: ['id', 'permaId', 'role'], keyAttribute: 'permaId', includeConfiguration: true }, diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index 78261ca34a..736b5d1e0a 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -373,7 +373,7 @@ export function useMainStoryline() { const storylines = useEntryStateCollectionItems('storylines'); return useMemo( - () => storylines.find(storyline => storyline.configuration.main), + () => storylines.find(storyline => storyline.role === 'main'), [storylines] ); } diff --git a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js index 7d2781ad02..66c3f28090 100644 --- a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js +++ b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js @@ -253,9 +253,8 @@ function normalizeStorylines(storylines = [], chapters) { { id: 1000, permaId: 100, - configuration: { - main: true - } + role: 'main', + configuration: {} } ] } diff --git a/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js b/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js index 686e0efbce..4a229c61dc 100644 --- a/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js +++ b/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js @@ -23,7 +23,7 @@ function getSeed({ { id: 1000, permaId: 100, - configuration: {main: true} + role: 'main' }, { id: 2000, diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb index a8155fc29b..0d8c73ee31 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb @@ -146,7 +146,7 @@ module PageflowScrolled post_extraction(entry:, chapter:) fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.storyline.configuration).to include('main' => true) + expect(fragment.storyline.role).to eq('main') end it 'keeps sections of chapter in entry' do diff --git a/entry_types/scrolled/spec/factories/chapters.rb b/entry_types/scrolled/spec/factories/chapters.rb index 346329a2b6..e57c0a8e9b 100644 --- a/entry_types/scrolled/spec/factories/chapters.rb +++ b/entry_types/scrolled/spec/factories/chapters.rb @@ -9,9 +9,7 @@ module PageflowScrolled if chapter.storyline.nil? revision = evaluator.revision || build(:revision) chapter.storyline = Storyline.all_for_revision(revision).first || - create(:scrolled_storyline, - revision:, - configuration: {main: true}) + create(:scrolled_storyline, revision:) end end diff --git a/entry_types/scrolled/spec/factories/storylines.rb b/entry_types/scrolled/spec/factories/storylines.rb index d52074e196..c99d868dd3 100644 --- a/entry_types/scrolled/spec/factories/storylines.rb +++ b/entry_types/scrolled/spec/factories/storylines.rb @@ -2,12 +2,7 @@ module PageflowScrolled FactoryBot.define do factory :scrolled_storyline, class: Storyline do revision - role { configuration.to_h.with_indifferent_access[:main] ? 'main' : 'excursions' } - configuration do - { - main: true - } - end + role { 'main' } end end end diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb index 0882947828..e3f0320390 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb @@ -16,13 +16,24 @@ def render(helper, entry, options = {}) end context 'storylines' do - it 'renders storylines with id, perma_id, position and configuration' do + it 'renders role of storylines' do + entry = create(:published_entry, type_name: 'scrolled') + create(:scrolled_storyline, revision: entry.revision, role: nil, + configuration: {main: true}) + + result = render(helper, entry) + + expect(result).to include_json(collections: {storylines: [{role: 'main'}]}) + end + + it 'renders storylines with id, perma_id, role, position and configuration' do entry = create(:published_entry, type_name: 'scrolled') main_storyline = create( :scrolled_storyline, revision: entry.revision, + role: 'main', position: 0, - configuration: {main: true} + configuration: {some: 'value'} ) excursion_storyline = create( :scrolled_storyline, @@ -38,9 +49,10 @@ def render(helper, entry, options = {}) { id: main_storyline.id, permaId: main_storyline.perma_id, + role: 'main', position: 0, configuration: { - main: true + some: 'value' } }, { @@ -157,7 +169,7 @@ def render(helper, entry, options = {}) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, @@ -313,7 +325,7 @@ def render(helper, entry, options = {}) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, @@ -806,7 +818,7 @@ def json_config(result) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb index db319a8b99..e0caa19b94 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb @@ -5,18 +5,15 @@ module PageflowScrolled describe '#main_storyline' do it 'returns storyline marked as main' do revision = create(:revision) - create(:scrolled_storyline, revision:, position: 0, configuration: {}) - main_storyline = create(:scrolled_storyline, - revision:, - position: 1, - configuration: {main: true}) + create(:scrolled_storyline, revision:, position: 0, role: 'excursions') + main_storyline = create(:scrolled_storyline, revision:, position: 1, role: 'main') expect(RevisionContent.new(revision).main_storyline).to eq(main_storyline) end it 'returns nil if no storyline is marked as main' do revision = create(:revision) - create(:scrolled_storyline, revision:, configuration: {}) + create(:scrolled_storyline, revision:, role: 'excursions') expect(RevisionContent.new(revision).main_storyline).to be_nil end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb index c11a68dadf..8578af62fd 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb @@ -34,5 +34,25 @@ module PageflowScrolled expect(copied_storylines.first.configuration['some']).to eq('value') end end + + describe '#role' do + it 'returns stored role' do + storyline = build(:scrolled_storyline, role: 'trash', configuration: {'main' => true}) + + expect(storyline.role).to eq('trash') + end + + it 'falls back to main for storylines with main flag' do + storyline = build(:scrolled_storyline, role: nil, configuration: {'main' => true}) + + expect(storyline.role).to eq('main') + end + + it 'falls back to excursions for other storylines' do + storyline = build(:scrolled_storyline, role: nil, configuration: {}) + + expect(storyline.role).to eq('excursions') + end + end end end From 9d185cb4c0ebaf6fd91e854e75f28e35b149f5e5 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:08:23 +0200 Subject: [PATCH 06/29] Hide trash storyline from revision queries all_for_revision of storylines, chapters, sections and content elements excludes the trash storyline, so trashed content disappears from seeds and editor endpoints without a call-site audit. Linked entry chapters, which are loaded with a query of their own, skip it as well. The trash storyline is created lazily after the existing storylines while holding a lock on the revision, so parallel deletions cannot create two of them. --- .../app/models/pageflow_scrolled/chapter.rb | 1 + .../pageflow_scrolled/content_element.rb | 1 + .../linked_entry/chapter_loader.rb | 1 + .../app/models/pageflow_scrolled/section.rb | 1 + .../app/models/pageflow_scrolled/storyline.rb | 15 +++++++ .../entry_json_seed_helper_spec.rb | 37 ++++++++++++++++++ .../models/pageflow_scrolled/chapter_spec.rb | 11 ++++++ .../pageflow_scrolled/content_element_spec.rb | 12 ++++++ .../pageflow_scrolled/linked_entry_spec.rb | 10 +++++ .../models/pageflow_scrolled/section_spec.rb | 11 ++++++ .../pageflow_scrolled/storyline_spec.rb | 39 +++++++++++++++++++ 11 files changed, 139 insertions(+) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb index 2e577a9318..4e5b2aadb3 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb @@ -44,6 +44,7 @@ def duplicate_section(section) def self.all_for_revision(revision) joins(storyline: :revision) .where(pageflow_scrolled_storylines: {revision_id: revision}) + .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb index 3561831624..2cbb2482fd 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb @@ -21,6 +21,7 @@ def entry_for_auto_generated_perma_id def self.all_for_revision(revision) joins(section: {chapter: {storyline: :revision}}) .where(pageflow_scrolled_storylines: {revision_id: revision}) + .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb index 2450a1a610..28dd659342 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb @@ -53,6 +53,7 @@ def chapters_by_revision_id PageflowScrolled::Chapter .joins(:storyline) .where(pageflow_scrolled_storylines: {revision_id: revision_ids}) + .merge(PageflowScrolled::Storyline.excluding_trash) .order(PageflowScrolled::Chapter::POSITION_ORDER) .select('pageflow_scrolled_chapters.*', 'pageflow_scrolled_storylines.revision_id') .group_by(&:revision_id) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb index a4ecb68780..144a2b8a84 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb @@ -25,6 +25,7 @@ def entry_for_auto_generated_perma_id def self.all_for_revision(revision) joins(chapter: {storyline: :revision}) .where(pageflow_scrolled_storylines: {revision_id: revision}) + .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 7496eaff18..61af91293f 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -28,6 +28,8 @@ class Storyline < Pageflow::ApplicationRecord # rubocop:todo Style/Documentation nested_revision_components :chapters + scope :excluding_trash, -> { where.not(role: 'trash').or(where(role: nil)) } + def role super || (configuration['main'] ? 'main' : 'excursions') end @@ -41,6 +43,19 @@ def self.create_defaults(draft) create!(revision: draft, role: 'excursions', position: 1) end + def self.all_for_revision(revision) + super.excluding_trash + end + + def self.find_or_create_trash_for(revision) + revision.with_lock do + where(revision:, role: 'trash').first || + create!(revision:, + role: 'trash', + position: where(revision:).maximum(:position).to_i + 1) + end + end + private def sections_before(section) diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb index e3f0320390..a9f4475cc9 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb @@ -65,6 +65,43 @@ def render(helper, entry, options = {}) end end + context 'trash' do + it 'omits trashed content from published entries' do + entry = create(:published_entry, type_name: 'scrolled') + create(:content_element, revision: entry.revision) + trashed_content_element = create_trashed_content_element(entry.revision) + + result = JSON.parse(render(helper, entry)) + + expect(result['collections']['storylines'].pluck('id')) + .not_to include(trashed_content_element.section.chapter.storyline_id) + expect(result['collections']['chapters'].pluck('id')) + .not_to include(trashed_content_element.section.chapter_id) + expect(result['collections']['sections'].pluck('id')) + .not_to include(trashed_content_element.section_id) + expect(result['collections']['contentElements'].pluck('id')) + .not_to include(trashed_content_element.id) + end + + it 'omits trashed content from draft entries' do + entry = create(:draft_entry, type_name: 'scrolled') + trashed_content_element = create_trashed_content_element(entry.revision) + + result = JSON.parse(render(helper, entry, include_hidden_sections: true)) + + expect(result['collections']['sections'].pluck('id')) + .not_to include(trashed_content_element.section_id) + expect(result['collections']['contentElements'].pluck('id')) + .not_to include(trashed_content_element.id) + end + + def create_trashed_content_element(revision) + trash_storyline = Storyline.find_or_create_trash_for(revision) + chapter = create(:scrolled_chapter, storyline: trash_storyline) + create(:content_element, section: create(:section, chapter:)) + end + end + context 'entries' do it 'renders single-element entry array' do entry = create(:published_entry, diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb index 8803f6df0b..88a3067e77 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb @@ -26,6 +26,17 @@ module PageflowScrolled expect(result).to eq([chapter1, chapter2]) end + + it 'excludes chapters in trash storyline' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + create(:scrolled_chapter, storyline: trash_storyline) + + result = Chapter.all_for_revision(revision) + + expect(result).to eq([chapter]) + end end describe '.create_section' do diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb index d44b22e5e4..1b2be32fa3 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb @@ -15,6 +15,18 @@ module PageflowScrolled expect(result).to eq([content_element1, content_element2, content_element3]) end + + it 'excludes content elements in trash storyline' do + revision = create(:revision) + content_element = create(:content_element, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + trash_chapter = create(:scrolled_chapter, storyline: trash_storyline) + create(:content_element, section: create(:section, chapter: trash_chapter)) + + result = ContentElement.all_for_revision(revision) + + expect(result).to eq([content_element]) + end end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb index 524fcf9fa0..b6764e20c2 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb @@ -29,6 +29,16 @@ module PageflowScrolled expect(linked_entry.chapters.map(&:perma_id)).to eq([chapter.perma_id]) end + it 'skips chapters in trash storyline' do + entry = create(:entry, type_name: 'scrolled') + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + chapter = create(:scrolled_chapter, storyline: trash_storyline) + + linked_entry = LinkedEntry.all(Pageflow::Entry.all, entry.id => [chapter.perma_id]).first + + expect(linked_entry.chapters).to eq([]) + end + it 'skips chapters that can not be found' do entry = create(:published_entry, type_name: 'scrolled') diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb index 9e1bc08cfb..fa1ef6cb3c 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb @@ -14,6 +14,17 @@ module PageflowScrolled expect(result).to eq([section1, section2]) end + + it 'excludes sections in trash storyline' do + revision = create(:revision) + section = create(:section, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + create(:section, chapter: create(:scrolled_chapter, storyline: trash_storyline)) + + result = Section.all_for_revision(revision) + + expect(result).to eq([section]) + end end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb index 8578af62fd..cfaa2037cd 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb @@ -54,5 +54,44 @@ module PageflowScrolled expect(storyline.role).to eq('excursions') end end + + describe '.all_for_revision' do + it 'excludes trash storyline' do + entry = create(:entry, type_name: 'scrolled') + Storyline.find_or_create_trash_for(entry.draft) + + storylines = Storyline.all_for_revision(entry.draft) + + expect(storylines.map(&:role)).to eq(%w[main excursions]) + end + + it 'includes storylines without role' do + revision = create(:revision) + storyline = create(:scrolled_storyline, revision:, role: nil) + + storylines = Storyline.all_for_revision(revision) + + expect(storylines).to eq([storyline]) + end + end + + describe '.find_or_create_trash_for' do + it 'creates trash storyline after existing storylines' do + entry = create(:entry, type_name: 'scrolled') + + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + + expect(trash_storyline).to have_attributes(role: 'trash', position: 2) + end + + it 'returns existing trash storyline' do + entry = create(:entry, type_name: 'scrolled') + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + + result = Storyline.find_or_create_trash_for(entry.draft) + + expect(result).to eq(trash_storyline) + end + end end end From 40b8254caad65306326f37026a2fac432accefb6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:08:23 +0200 Subject: [PATCH 07/29] Keep content trash out of publication and duplication Revision#copy takes a purpose and passes it to after_copy hooks of revision components which opt in. Entries pass :publish, :snapshot and :restore. Copies without explicit purpose, like duplicated entries or entries created from templates, default to :duplicate. Scrolled storylines use the hook to drop the trash storyline unless the copy continues the draft's history, so restoring a snapshot brings back its bin. --- app/models/pageflow/entry.rb | 6 +- app/models/pageflow/revision.rb | 3 +- .../app/models/pageflow_scrolled/storyline.rb | 6 ++ .../scrolled/lib/pageflow_scrolled/plugin.rb | 2 +- .../pageflow_scrolled/storyline_spec.rb | 86 ++++++++++++++++--- lib/pageflow/revision_components.rb | 17 +++- spec/models/pageflow/entry_spec.rb | 81 +++++++++++++++++ spec/models/pageflow/revision_spec.rb | 61 +++++++++++++ 8 files changed, 243 insertions(+), 19 deletions(-) diff --git a/app/models/pageflow/entry.rb b/app/models/pageflow/entry.rb index e7c950f9ba..9f8e71c238 100644 --- a/app/models/pageflow/entry.rb +++ b/app/models/pageflow/entry.rb @@ -89,7 +89,7 @@ def publish(options = {}) # rubocop:todo Metrics/AbcSize revisions.depublish_all association(:published_revision).reset - draft.copy do |revision| + draft.copy(purpose: :publish) do |revision| revision.creator = options[:creator] revision.frozen_at = Time.now revision.published_at = Time.now @@ -102,7 +102,7 @@ def publish(options = {}) # rubocop:todo Metrics/AbcSize def snapshot(options) transaction do - draft.copy do |revision| + draft.copy(purpose: :snapshot) do |revision| revision.creator = options[:creator] revision.frozen_at = Time.now revision.snapshot_type = options.fetch(:type, 'auto') @@ -116,7 +116,7 @@ def restore(options) draft.update!(frozen_at: Time.now, creator: options[:creator], snapshot_type: 'before_restore') - restored_revision.copy do |revision| + restored_revision.copy(purpose: :restore) do |revision| revision.restored_from = restored_revision revision.frozen_at = nil revision.snapshot_type = nil diff --git a/app/models/pageflow/revision.rb b/app/models/pageflow/revision.rb index c39b4aad6b..560a76143f 100644 --- a/app/models/pageflow/revision.rb +++ b/app/models/pageflow/revision.rb @@ -171,7 +171,7 @@ def created_with # Public interface for copying a revision # # @since 15.1 - def copy + def copy(purpose: :duplicate) revision = dup yield(revision) if block_given? @@ -190,6 +190,7 @@ def copy [FileFolder, FileUsage] ) + Pageflow.config_for(revision.entry).revision_components.after_copy(revision, purpose:) revision end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 61af91293f..4bcedf3746 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -56,6 +56,12 @@ def self.find_or_create_trash_for(revision) end end + def self.after_copy(revision, purpose:) + return if %i[snapshot restore].include?(purpose) + + where(revision:, role: 'trash').destroy_all + end + private def sections_before(section) diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 0ad58ba461..e6d9550c54 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -194,7 +194,7 @@ def configure(config) c.file_types.register(Pageflow::BuiltInFileType.audio) c.file_types.register(Pageflow::BuiltInFileType.other) - c.revision_components.register(Storyline, create_defaults: true) + c.revision_components.register(Storyline, create_defaults: true, after_copy: true) ['tikTokEmbed', 'twitterEmbed', 'hotspots', 'socialEmbed', 'videoEmbed', 'inlineBeforeAfter'].each do |name| diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb index cfaa2037cd..bcdbbc7067 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb @@ -33,26 +33,70 @@ module PageflowScrolled expect(copied_storylines.first).not_to eq(storyline) expect(copied_storylines.first.configuration['some']).to eq('value') end - end - describe '#role' do - it 'returns stored role' do - storyline = build(:scrolled_storyline, role: 'trash', configuration: {'main' => true}) + it 'removes trash storyline with its content from published revisions' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) - expect(storyline.role).to eq('trash') + entry.publish(creator: create(:user)) + + published_revision = entry.reload.published_revision + expect(Storyline.where(revision: published_revision, role: 'trash')).to be_empty + expect(Chapter.joins(:storyline) + .where(pageflow_scrolled_storylines: {revision_id: published_revision})) + .to have(0).items end - it 'falls back to main for storylines with main flag' do - storyline = build(:scrolled_storyline, role: nil, configuration: {'main' => true}) + it 'removes trash storyline from duplicated entries' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) - expect(storyline.role).to eq('main') + duplicate = entry.duplicate + + expect(Storyline.where(revision: duplicate.draft, role: 'trash')).to be_empty end - it 'falls back to excursions for other storylines' do - storyline = build(:scrolled_storyline, role: nil, configuration: {}) + it 'removes trash storyline from revisions copied without purpose' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) - expect(storyline.role).to eq('excursions') + copied_revision = entry.draft.copy + + expect(Storyline.where(revision: copied_revision, role: 'trash')).to be_empty + end + + it 'keeps trash storyline in snapshots' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) + + snapshot = entry.snapshot(creator: create(:user)) + + expect(Storyline.where(revision: snapshot, role: 'trash')).to have(1).item end + + it 'restores trash of snapshot with configuration of trashed chapters' do + entry = create(:entry, type_name: 'scrolled') + section = create_trashed_section( + entry.draft, + chapter_configuration: {'trashedAt' => '2026-10-01T12:00:00Z'} + ) + snapshot = entry.snapshot(creator: create(:user)) + section.chapter.destroy + + entry.restore(revision: snapshot, creator: create(:user)) + + trash_storyline = Storyline.find_by(revision: entry.reload.draft, role: 'trash') + expect(trash_storyline.chapters.first.configuration) + .to include('trashedAt' => '2026-10-01T12:00:00Z') + end + end + + def create_trashed_section(revision, chapter_configuration: {}) + trash_storyline = Storyline.find_or_create_trash_for(revision) + chapter = create(:scrolled_chapter, + storyline: trash_storyline, + configuration: chapter_configuration) + create(:section, chapter:) end describe '.all_for_revision' do @@ -93,5 +137,25 @@ module PageflowScrolled expect(result).to eq(trash_storyline) end end + + describe '#role' do + it 'returns stored role' do + storyline = build(:scrolled_storyline, role: 'trash', configuration: {'main' => true}) + + expect(storyline.role).to eq('trash') + end + + it 'falls back to main for storylines with main flag' do + storyline = build(:scrolled_storyline, role: nil, configuration: {'main' => true}) + + expect(storyline.role).to eq('main') + end + + it 'falls back to excursions for other storylines' do + storyline = build(:scrolled_storyline, role: nil, configuration: {}) + + expect(storyline.role).to eq('excursions') + end + end end end diff --git a/lib/pageflow/revision_components.rb b/lib/pageflow/revision_components.rb index ff656f5f36..2c5791c36f 100644 --- a/lib/pageflow/revision_components.rb +++ b/lib/pageflow/revision_components.rb @@ -5,16 +5,23 @@ class RevisionComponents # rubocop:todo Style/Documentation def initialize @revision_components = [] @create_defaults_hooks = [] + @after_copy_hooks = [] end - def register(revision_component, create_defaults: false) + def register(revision_component, create_defaults: false, after_copy: false) return if @revision_components.include?(revision_component) @revision_components << revision_component - return unless create_defaults + if create_defaults + @create_defaults_hooks << ->(revision) { revision_component.create_defaults(revision) } + end - @create_defaults_hooks << ->(revision) { revision_component.create_defaults(revision) } + return unless after_copy + + @after_copy_hooks << lambda do |revision, purpose| + revision_component.after_copy(revision, purpose:) + end end def each(&) @@ -24,5 +31,9 @@ def each(&) def create_defaults(revision) @create_defaults_hooks.each { |hook| hook.call(revision) } end + + def after_copy(revision, purpose:) + @after_copy_hooks.each { |hook| hook.call(revision, purpose) } + end end end diff --git a/spec/models/pageflow/entry_spec.rb b/spec/models/pageflow/entry_spec.rb index 752e7a632e..2841daaaca 100644 --- a/spec/models/pageflow/entry_spec.rb +++ b/spec/models/pageflow/entry_spec.rb @@ -182,6 +182,27 @@ def self.create_defaults(draft) end describe '#publish' do + it 'passes publish purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + entry.publish(creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(entry.reload.published_revision)) + .to contain_exactly(have_attributes(text: 'publish')) + expect(TestRevisionComponent.all_for_revision(entry.draft)) + .to contain_exactly(have_attributes(text: 'draft')) + end + it 'creates a revision' do creator = create(:user) entry = create(:entry) @@ -415,6 +436,26 @@ def self.create_defaults(draft) expect(draft_before_restore.reload).to be_frozen end + it 'passes restore purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + earlier_revision = create(:revision, :published, entry:) + TestRevisionComponent.create!(revision: earlier_revision, text: 'earlier') + + entry.restore(revision: earlier_revision, creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(entry.reload.draft)) + .to contain_exactly(have_attributes(text: 'restore')) + end + it 'turns previous draft into snapshot of type before_restore' do creator = create(:user) entry = create(:entry) @@ -566,6 +607,46 @@ def self.create_defaults(draft) expect { entry.duplicate }.to(change { Entry.count }) end + + it 'passes duplicate purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + duplicate = entry.duplicate + + expect(TestRevisionComponent.all_for_revision(duplicate.draft)) + .to contain_exactly(have_attributes(text: 'duplicate')) + end + end + + describe '#snapshot' do + it 'passes snapshot purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + revision = entry.snapshot(creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(revision)) + .to contain_exactly(have_attributes(text: 'snapshot')) + end end describe '.include_account_name' do diff --git a/spec/models/pageflow/revision_spec.rb b/spec/models/pageflow/revision_spec.rb index cf318133da..6ed6dd5198 100644 --- a/spec/models/pageflow/revision_spec.rb +++ b/spec/models/pageflow/revision_spec.rb @@ -79,6 +79,67 @@ module Pageflow end describe '#copy' do + it 'passes duplicate purpose to after copy hooks by default' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + revision = create(:revision) + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'duplicate')) + end + + it 'does not call after copy hooks registered for other entry types' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.revision_components.register(revision_component, after_copy: true) + end + end + revision = create(:entry).draft + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'original')) + end + + it 'passes given purpose to after copy hooks' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + revision = create(:revision) + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy(purpose: :snapshot) + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'snapshot')) + end + it 'creates a new revision' do revision = create(:revision) From 9a67dd049a986fee5bd489c222a0b751466047d2 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:13:54 +0200 Subject: [PATCH 08/29] Move deleted sections to the content trash Deleting a section re-parents it into a container chapter in the trash storyline instead of destroying it. The container records kind, origin chapter perma_id, origin position and trash time so the section can be restored later. Perma_ids of the section and its content elements stay untouched. --- .../editor/sections_controller.rb | 2 +- .../app/models/pageflow_scrolled/trash.rb | 42 +++++++++++ .../editor/sections_controller_spec.rb | 16 +++++ .../models/pageflow_scrolled/trash_spec.rb | 70 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/trash.rb create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb index 0eb77e346d..605ab4eaa9 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb @@ -32,7 +32,7 @@ def update def destroy section = Section.all_for_revision(@entry.draft).find(params[:id]) - section.destroy + Trash.new(@entry.draft).add_section(section) head :no_content rescue ActiveRecord::RecordNotFound diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb new file mode 100644 index 0000000000..727b1c6241 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -0,0 +1,42 @@ +module PageflowScrolled + # @api private + class Trash + def initialize(revision) + @revision = revision + end + + def add_section(section) + Storyline.transaction do + container = create_container(kind: 'section', + origin_perma_id: section.chapter.perma_id, + origin_position: section.position) + + section.update!(chapter: container, position: 0) + end + end + + private + + def create_container(kind:, origin_perma_id:, origin_position:) + storyline.chapters.create!( + position: next_position, + configuration: { + trash: { + kind:, + originPermaId: origin_perma_id, + originPosition: origin_position, + trashedAt: Time.current.utc.iso8601 + } + } + ) + end + + def next_position + (storyline.chapters.maximum(:position) || -1) + 1 + end + + def storyline + @storyline ||= Storyline.find_or_create_trash_for(@revision) + end + end +end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb index 83d29553b4..bddeebbe85 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb @@ -330,6 +330,22 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'moves the section to the trash' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: section + }, format: 'json') + + expect(section.reload.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + it 'does not allow deleting a section from a different entry' do entry = create(:entry, type_name: 'scrolled') create(:section, revision: entry.draft) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb new file mode 100644 index 0000000000..14083afb3f --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe Trash do + include ActiveSupport::Testing::TimeHelpers + + describe '#add_section' do + it 'removes section from revision' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + Trash.new(entry.draft).add_section(section) + + expect(Section.all_for_revision(entry.draft)).to be_empty + end + + it 'moves section into container chapter in trash storyline' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + Trash.new(entry.draft).add_section(section) + + expect(section.reload.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + + it 'records kind, origin and trash time in container chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + create(:section, chapter:, position: 0) + section = create(:section, chapter:, position: 1) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_section(section) + end + + expect(section.reload.chapter.configuration['trash']).to eq( + 'kind' => 'section', + 'originPermaId' => chapter.perma_id, + 'originPosition' => 1, + 'trashedAt' => '2026-10-01T12:00:00Z' + ) + end + + it 'appends container chapters after existing items' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + + trash.add_section(first_section) + trash.add_section(second_section) + + expect(second_section.reload.chapter.position) + .to be > first_section.reload.chapter.position + end + + it 'keeps perma_ids of section and its content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + expect { + Trash.new(entry.draft).add_section(section) + }.not_to(change { [section.reload.perma_id, content_element.reload.perma_id] }) + end + end + end +end From 9adba676deb45d26d6b8d99db501c99e277b1476 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:14:52 +0200 Subject: [PATCH 09/29] Add editor endpoint listing content trash items Trash items wrap the chapters of the trash storyline and expose kind, origin and trash time recorded in their configuration, so callers do not depend on how the trash is stored. The endpoint lists items newest first including sections and content elements so the editor can render previews. --- .../editor/trash_items_controller.rb | 12 +++ .../app/models/pageflow_scrolled/trash.rb | 13 +++ .../models/pageflow_scrolled/trash/item.rb | 35 ++++++++ .../editor/trash_items/index.json.jbuilder | 12 +++ entry_types/scrolled/config/routes.rb | 1 + .../editor/trash_items_controller_spec.rb | 79 +++++++++++++++++++ .../models/pageflow_scrolled/trash_spec.rb | 26 ++++++ 7 files changed, 178 insertions(+) create mode 100644 entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder create mode 100644 entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb new file mode 100644 index 0000000000..df1e43148f --- /dev/null +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -0,0 +1,12 @@ +module PageflowScrolled + module Editor + # @api private + class TrashItemsController < ActionController::Base + include Pageflow::EditorController + + def index + @items = Trash.new(@entry.draft).items + end + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb index 727b1c6241..5c6aee0251 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -15,8 +15,21 @@ def add_section(section) end end + def items + chapters.includes(sections: :content_elements).map do |chapter| + Item.new(chapter, @revision) + end + end + private + def chapters + existing_storyline = Storyline.find_by(revision: @revision, role: 'trash') + return Chapter.none unless existing_storyline + + existing_storyline.chapters.reorder(position: :desc) + end + def create_container(kind:, origin_perma_id:, origin_position:) storyline.chapters.create!( position: next_position, diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb new file mode 100644 index 0000000000..91a00f4394 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -0,0 +1,35 @@ +module PageflowScrolled + class Trash + # @api private + class Item + delegate :id, :sections, to: :@chapter + + def initialize(chapter, revision) + @chapter = chapter + @revision = revision + end + + def kind + metadata['kind'] + end + + def origin_perma_id + metadata['originPermaId'] + end + + def origin_position + metadata['originPosition'] + end + + def trashed_at + metadata['trashedAt'] + end + + private + + def metadata + @chapter.configuration['trash'] + end + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder new file mode 100644 index 0000000000..d0a22f1993 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder @@ -0,0 +1,12 @@ +json.key_format!(camelize: :lower) + +json.array!(@items) do |item| + json.call(item, :id, :kind, :origin_perma_id, :origin_position, :trashed_at) + + json.sections do + json.array!(item.sections) do |section| + json.partial!('pageflow_scrolled/editor/sections/section_with_content_elements', + section:) + end + end +end diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 8cb3812120..f64bf0f2f0 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -2,6 +2,7 @@ scope module: 'editor' do resources :fragment_libraries, only: [:index] resources :linkable_entries, only: [:index, :show] + resources :trash_items, only: [:index] shallow do # Legacy path to support editor sessions that span the deploy that diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb new file mode 100644 index 0000000000..dd9b713af8 --- /dev/null +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' +require 'pageflow/editor_controller_test_helper' + +module PageflowScrolled + RSpec.describe Editor::TrashItemsController, type: :controller do + render_views + include Pageflow::EditorControllerTestHelper + include ActiveSupport::Testing::TimeHelpers + + routes { PageflowScrolled::Engine.routes } + + describe '#index' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(401) + end + + it 'lists trashed sections with origin and trash time' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:, position: 3) + content_element = create(:content_element, section:) + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_section(section) + end + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.body).to include_json( + [ + { + id: section.reload.chapter_id, + kind: 'section', + originPermaId: chapter.perma_id, + originPosition: 3, + trashedAt: '2026-10-01T12:00:00Z', + sections: [ + { + id: section.id, + permaId: section.perma_id, + contentElements: [{id: content_element.id}] + } + ] + } + ] + ) + end + + it 'lists newest items first' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_section(first_section) + trash.add_section(second_section) + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(JSON.parse(response.body).map { |e| e['sections'].first['id'] }) + .to eq([second_section.id, first_section.id]) + end + + it 'renders empty list if nothing has been trashed' do + entry = create(:entry, type_name: 'scrolled') + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(JSON.parse(response.body)).to eq([]) + end + end + end +end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 14083afb3f..18219e3a5b 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -66,5 +66,31 @@ module PageflowScrolled }.not_to(change { [section.reload.perma_id, content_element.reload.perma_id] }) end end + + describe '#items' do + it 'returns items newest first' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_section(first_section) + trash.add_section(second_section) + + result = Trash.new(entry.draft).items + + expect(result.map(&:id)) + .to eq([second_section.reload.chapter_id, first_section.reload.chapter_id]) + end + + it 'does not create trash storyline' do + entry = create(:entry, type_name: 'scrolled') + + result = Trash.new(entry.draft).items + + expect(result).to be_empty + expect(Storyline.where(revision: entry.draft, role: 'trash')).to be_empty + end + end end end From d0fc8ff68b13ba1df867bc2a5f8f221185d74338 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:16:12 +0200 Subject: [PATCH 10/29] Restore sections from the content trash Re-parents the section into its origin chapter at the recorded position, shifting later sections, and destroys the now empty container chapter. If the origin chapter no longer exists, the section is appended to the first chapter instead. Responds with the section and its content elements so the editor can repopulate its collections in one round trip. --- .../editor/trash_items_controller.rb | 11 +++ .../app/models/pageflow_scrolled/chapter.rb | 7 ++ .../app/models/pageflow_scrolled/trash.rb | 6 ++ .../models/pageflow_scrolled/trash/item.rb | 26 +++++ entry_types/scrolled/config/routes.rb | 6 +- .../editor/trash_items_controller_spec.rb | 64 +++++++++++++ .../models/pageflow_scrolled/trash_spec.rb | 96 +++++++++++++++++++ 7 files changed, 215 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb index df1e43148f..2b680d353c 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -7,6 +7,17 @@ class TrashItemsController < ActionController::Base def index @items = Trash.new(@entry.draft).items end + + def restore + restored = Trash.new(@entry.draft).find_item(params[:id]).restore + + render partial: 'pageflow_scrolled/editor/sections/section_with_content_elements', + locals: {section: restored} + rescue ActiveRecord::RecordNotFound + head :not_found + rescue Trash::DestinationMissing + head :unprocessable_entity + end end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb index 4e5b2aadb3..2fe16c964e 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb @@ -41,6 +41,13 @@ def duplicate_section(section) end end + def insert_section(section, position:) + transaction do + shift_section_positions(from: position) + section.update!(chapter: self, position:) + end + end + def self.all_for_revision(revision) joins(storyline: :revision) .where(pageflow_scrolled_storylines: {revision_id: revision}) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb index 5c6aee0251..074ec9136b 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -1,6 +1,8 @@ module PageflowScrolled # @api private class Trash + class DestinationMissing < StandardError; end + def initialize(revision) @revision = revision end @@ -21,6 +23,10 @@ def items end end + def find_item(id) + Item.new(chapters.find(id), @revision) + end + private def chapters diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb index 91a00f4394..0b4518a838 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -25,11 +25,37 @@ def trashed_at metadata['trashedAt'] end + def restore + Storyline.transaction do + restore_section + end + end + private def metadata @chapter.configuration['trash'] end + + def restore_section + section = sections.first + + insert_into_origin_chapter(section) + @chapter.reload.destroy + + section + end + + def insert_into_origin_chapter(section) + chapters = Chapter.all_for_revision(@revision) + + if (chapter = chapters.find_by(perma_id: origin_perma_id)) + chapter.insert_section(section, position: origin_position) + else + chapter = chapters.first || raise(DestinationMissing) + chapter.insert_section(section, position: (chapter.sections.maximum(:position) || -1) + 1) + end + end end end end diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index f64bf0f2f0..4856fe4df1 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -2,7 +2,11 @@ scope module: 'editor' do resources :fragment_libraries, only: [:index] resources :linkable_entries, only: [:index, :show] - resources :trash_items, only: [:index] + resources :trash_items, only: [:index] do + member do + post :restore + end + end shallow do # Legacy path to support editor sessions that span the deploy that diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb index dd9b713af8..a3b038e17c 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -75,5 +75,69 @@ module PageflowScrolled expect(JSON.parse(response.body)).to eq([]) end end + + describe '#restore' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + Trash.new(entry.draft).add_section(section) + + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(401) + end + + it 'restores section and renders it with content elements' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + id: section.id, + permaId: section.perma_id, + chapterId: chapter.id, + contentElements: [{id: content_element.id, permaId: content_element.perma_id}] + ) + end + + it 'responds with unprocessable entity if no chapter is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + origin_chapter = section.chapter + Trash.new(entry.draft).add_section(section) + origin_chapter.destroy + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(422) + end + + it 'does not allow restoring trash items of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(404) + end + end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 18219e3a5b..7b42d25c22 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -67,6 +67,102 @@ module PageflowScrolled end end + describe '#restore' do + it 'moves section back to origin chapter at origin position' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:, position: 0) + section = create(:section, chapter:, position: 1) + last_section = create(:section, chapter:, position: 2) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(chapter.reload.sections).to eq([first_section, section, last_section]) + end + + it 'shifts positions of sections at or after origin position' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:, position: 0) + trash = Trash.new(entry.draft) + trash.add_section(section) + new_section = create(:section, chapter:, position: 0) + + trash.find_item(section.reload.chapter_id).restore + + expect(chapter.reload.sections.map(&:position)).to eq([0, 1]) + expect(chapter.sections).to eq([section, new_section]) + end + + it 'destroys container chapter' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(trash.items).to be_empty + end + + it 'returns restored section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + + result = trash.find_item(section.reload.chapter_id).restore + + expect(result).to eq(section) + end + + it 'keeps perma_ids of section and its content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(ContentElement.all_for_revision(entry.draft).map(&:perma_id)) + .to eq([content_element.perma_id]) + expect(Section.all_for_revision(entry.draft).map(&:perma_id)) + .to eq([section.perma_id]) + end + + it 'moves section to end of first chapter if origin chapter no longer exists' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + first_chapter = create(:scrolled_chapter, storyline:, position: 0) + existing_section = create(:section, chapter: first_chapter, position: 0) + origin_chapter = create(:scrolled_chapter, storyline:, position: 1) + section = create(:section, chapter: origin_chapter, position: 0) + trash = Trash.new(entry.draft) + trash.add_section(section) + origin_chapter.destroy + + trash.find_item(section.reload.chapter_id).restore + + expect(first_chapter.reload.sections).to eq([existing_section, section]) + end + + it 'raises if no chapter is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + origin_chapter = section.chapter + trash = Trash.new(entry.draft) + trash.add_section(section) + origin_chapter.destroy + + expect { + trash.find_item(section.reload.chapter_id).restore + }.to raise_error(Trash::DestinationMissing) + end + end + describe '#items' do it 'returns items newest first' do entry = create(:entry, type_name: 'scrolled') From 424ed8754aca0a28793b27bb090e6ef10e88f3df Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:17:45 +0200 Subject: [PATCH 11/29] Add content trash items collection to editor entry Trash items can be fetched and restored. Restoring inserts the section into its origin chapter like section duplication does, posting to the restore endpoint and waiting for the response before adding the section with its content elements. If the origin chapter is gone, the section is appended to the first chapter, matching the server. The trash item is removed once the section is restored. --- .../collections/TrashItemsCollection-spec.js | 152 ++++++++++++++++++ .../collections/TrashItemsCollection.js | 15 ++ .../package/src/editor/models/Chapter.js | 19 +++ .../src/editor/models/ScrolledEntry/index.js | 2 + .../package/src/editor/models/TrashItem.js | 37 +++++ 5 files changed, 225 insertions(+) create mode 100644 entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js create mode 100644 entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js create mode 100644 entry_types/scrolled/package/src/editor/models/TrashItem.js diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js new file mode 100644 index 0000000000..919a1885d9 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -0,0 +1,152 @@ +import {useFakeXhr, useEditorGlobals} from 'support'; + +describe('TrashItemsCollection', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + function respondWithJson(server, method, url, status, body) { + server.respond(method, url, [status, {'Content-Type': 'application/json'}, + JSON.stringify(body)]); + } + + it('fetches trash items of entry', () => { + testContext.entry = createEntry({}); + const {entry, server} = testContext; + + entry.trashItems.fetch(); + respondWithJson(server, 'GET', '/editor/entries/1/scrolled/trash_items', 200, [ + {id: 5, kind: 'section', sections: []} + ]); + + expect(entry.trashItems.pluck('id')).toEqual([5]); + }); + + describe('#restore', () => { + beforeEach(() => { + testContext.entry = createEntry({ + chapters: [{id: 10, permaId: 1000}], + sections: [ + {id: 100, chapterId: 10, position: 0}, + {id: 102, chapterId: 10, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 5, kind: 'section', originPermaId: 1000, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredSection(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 200, { + id: 101, + permaId: 501, + chapterId: 10, + position: 1, + configuration: {transition: 'fade'}, + contentElements: [{id: 1000, permaId: 601, sectionId: 101, typeName: 'heading'}] + }); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(5).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/5/restore'); + }); + + it('inserts restored section into origin chapter', async () => { + const {entry, server} = testContext; + const chapter = entry.chapters.get(10); + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(chapter.sections.pluck('id')).toEqual([100, 101, 102]); + expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]); + expect(chapter.sections.get(101).configuration.get('transition')).toEqual('fade'); + }); + + it('adds content elements of restored section', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(entry.sections.get(101).contentElements.pluck('id')).toEqual([1000]); + }); + + it('does not add section before restore succeeds', () => { + const {entry} = testContext; + + entry.trashItems.get(5).restore(); + + expect(entry.chapters.get(10).sections.pluck('id')).toEqual([100, 102]); + }); + + it('appends section to first chapter if origin chapter is missing', async () => { + const {entry, server} = testContext; + entry.trashItems.add({id: 6, kind: 'section', originPermaId: 999, originPosition: 0, + sections: []}); + + const promise = entry.trashItems.get(6).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/6/restore', 200, { + id: 103, permaId: 503, chapterId: 10, position: 2, configuration: {}, + contentElements: [] + }); + await promise; + + expect(entry.chapters.get(10).sections.pluck('id')).toEqual([100, 102, 103]); + expect(entry.chapters.get(10).sections.pluck('position')).toEqual([0, 1, 2]); + }); + + it('rejects without request if no chapter exists', async () => { + const {requests} = testContext; + const entry = createEntry({}); + entry.trashItems.add({id: 6, kind: 'section', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(6).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(entry.trashItems.length).toEqual(0); + }); + + it('keeps trash item if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.trashItems.pluck('id')).toEqual([5]); + }); + + it('keeps positions of sections if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.chapters.get(10).sections.pluck('position')).toEqual([0, 1]); + }); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js new file mode 100644 index 0000000000..543c7fb4c2 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js @@ -0,0 +1,15 @@ +import Backbone from 'backbone'; + +import {entryTypeEditorControllerUrls} from 'pageflow/editor'; + +import {TrashItem} from '../models/TrashItem'; + +export const TrashItemsCollection = Backbone.Collection.extend({ + model: TrashItem, + + mixins: [entryTypeEditorControllerUrls.forCollection({resources: 'trash_items'})], + + initialize(models, {entry} = {}) { + this.entry = entry; + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 527d617c7d..ae4b679724 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -176,6 +176,25 @@ export const Chapter = Backbone.Model.extend({ return newSection; }, + restoreSection(trashItem, {position}) { + const section = this.addSection({position}, { + url: `${trashItem.url()}/restore`, + skipDefaults: true, + wait: true, + success: () => { + this.sections.each(sibling => { + if (sibling !== section && sibling.get('position') >= position) { + sibling.set('position', sibling.get('position') + 1); + } + }); + + this.sections.sort(); + } + }); + + return section; + }, + moveSection(section, {after, before} = {}) { const targetSection = after || before; const sourceChapter = section.chapter; 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 056248339b..726627e8f6 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -14,6 +14,7 @@ import { } from '../../collections'; import {LinkableEntriesCollection} from '../../collections/LinkableEntriesCollection'; import {LinkedEntriesCollection} from '../../collections/LinkedEntriesCollection'; +import {TrashItemsCollection} from '../../collections/TrashItemsCollection'; import {CommentDisplayFilter} from '../CommentDisplayFilter'; import {ContentElement} from '../ContentElement'; @@ -85,6 +86,7 @@ export const ScrolledEntry = Entry.extend({ this.linkedEntries = new LinkedEntriesCollection(seed.collections.linkedEntries); this.linkableEntries = new LinkableEntriesCollection(); + this.trashItems = new TrashItemsCollection([], {entry: this}); this.cutoff = new Cutoff({entry: this}); diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js new file mode 100644 index 0000000000..e38bc10050 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -0,0 +1,37 @@ +import Backbone from 'backbone'; + +export const TrashItem = Backbone.Model.extend({ + restore() { + const {parent, position} = this.restoreTarget(this.collection.entry.chapters, + chapter => chapter.sections); + + if (!parent) { + return Promise.reject(new Error('No chapter to restore into.')); + } + + const section = parent.restoreSection(this, {position}); + + return new Promise((resolve, reject) => { + section.once('sync', () => { + this.collection.remove(this); + resolve(section); + }); + section.once('error', reject); + }); + }, + + restoreTarget(parents, getChildren) { + const origin = parents.findWhere({permaId: this.get('originPermaId')}); + + if (origin) { + return {parent: origin, position: this.get('originPosition')}; + } + + const parent = parents.first(); + + return { + parent, + position: parent && Math.max(-1, ...getChildren(parent).pluck('position')) + 1 + }; + } +}); From 6f050f5bb9be27f38ef6a451c94f4bfccffbc492 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:20:25 +0200 Subject: [PATCH 12/29] Add content trash view to editor sidebar The /scrolled/trash route lists deleted sections with a thumbnail, origin chapter and deletion time and lets editors restore them. Failing restores show an error in place. If loading the trash fails, an error replaces the loading indicator. Thumbnails render from the editor seed, which does not contain files. They subscribe to the editor's file collections only, since watching all collections would replace the trashed sections with the live ones. --- entry_types/scrolled/config/locales/de.yml | 10 + entry_types/scrolled/config/locales/en.yml | 10 + .../collections/TrashItemsCollection-spec.js | 19 ++ .../spec/editor/views/TrashView-spec.js | 177 ++++++++++++++++++ .../spec/entryState/collections-spec.js | 28 ++- .../editor/controllers/SideBarController.js | 7 + .../package/src/editor/models/TrashItem.js | 17 ++ .../src/editor/routers/SideBarRouter.js | 1 + .../package/src/editor/views/TrashView.js | 139 ++++++++++++++ .../src/editor/views/TrashView.module.css | 70 +++++++ .../package/src/entryState/collections.js | 57 +++--- .../scrolled/package/src/entryState/index.js | 2 +- 12 files changed, 513 insertions(+), 24 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/TrashView-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/TrashView.js create mode 100644 entry_types/scrolled/package/src/editor/views/TrashView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 5c7ecb150a..79a0395cd0 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1781,6 +1781,16 @@ de: back: Kommentare tabs: newComment: Neues Thema + trash_view: + back: Gliederung + blank_slate: Aus diesem Beitrag wurde noch nichts gelöscht. + hint: Aus diesem Beitrag gelöschte Abschnitte können hier wiederhergestellt werden. + load_failed: Gelöschte Inhalte konnten nicht geladen werden. + origin: "Abschnitt aus %{chapter}" + origin_missing: Abschnitt aus einem gelöschten Kapitel + restore: Wiederherstellen + restore_failed: Der Abschnitt konnte nicht wiederhergestellt werden. + trashed_at: "Gelöscht %{time}" configuration_places: entry: "Einstellungen" label: "%{chapter} - %{subject}" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 17a510eebb..c6c725f72d 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1761,6 +1761,16 @@ en: back: Comments tabs: newComment: New topic + trash_view: + back: Outline + blank_slate: Nothing has been deleted from this story yet. + hint: Sections deleted from this story can be restored here. + load_failed: Deleted content could not be loaded. + origin: "Section from %{chapter}" + origin_missing: Section from a deleted chapter + restore: Restore + restore_failed: Could not restore the section. + trashed_at: "Deleted %{time}" configuration_places: entry: "Settings" label: "%{chapter} - %{subject}" diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js index 919a1885d9..c1e869d69c 100644 --- a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -28,6 +28,25 @@ describe('TrashItemsCollection', () => { expect(entry.trashItems.pluck('id')).toEqual([5]); }); + it('provides seed with sections and content elements of trash item', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + sections: [ + {id: 50, permaId: 500, contentElements: [{id: 60, permaId: 600, sectionId: 50}]} + ] + }); + + const seed = entry.trashItems.get(5).seed({config: {}, collections: {chapters: []}}); + + expect(seed.config).toEqual({}); + expect(seed.collections.chapters).toEqual([]); + expect(seed.collections.sections).toEqual([{id: 50, permaId: 500}]); + expect(seed.collections.contentElements) + .toEqual([{id: 60, permaId: 600, sectionId: 50}]); + }); + describe('#restore', () => { beforeEach(() => { testContext.entry = createEntry({ diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js new file mode 100644 index 0000000000..05c3bc0401 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -0,0 +1,177 @@ +import '@testing-library/jest-dom/extend-expect'; +import {fireEvent, waitFor, within} from '@testing-library/dom'; + +import {editor} from 'pageflow-scrolled/editor'; + +import {TrashView} from 'editor/views/TrashView'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('TrashView', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + useFakeTranslations({ + 'pageflow_scrolled.editor.chapter_item.chapter': 'Chapter', + 'pageflow_scrolled.editor.trash_view.back': 'Outline', + 'pageflow_scrolled.editor.trash_view.blank_slate': 'Nothing deleted', + 'pageflow_scrolled.editor.trash_view.load_failed': 'Could not load', + 'pageflow_scrolled.editor.trash_view.hint': 'Deleted sections can be restored here.', + 'pageflow_scrolled.editor.trash_view.origin': 'Section from %{chapter}', + 'pageflow_scrolled.editor.trash_view.origin_missing': 'Section from deleted chapter', + 'pageflow_scrolled.editor.trash_view.restore': 'Restore', + 'pageflow_scrolled.editor.trash_view.restore_failed': 'Could not restore', + 'pageflow_scrolled.editor.trash_view.trashed_at': 'Deleted %{time}' + }); + + function trashItem({id, originPermaId, sectionId = id * 10}) { + return { + id, + kind: 'section', + originPermaId, + originPosition: 0, + trashedAt: '2026-10-01T12:00:00Z', + sections: [ + {id: sectionId, permaId: sectionId, chapterId: id, configuration: {}, + contentElements: []} + ] + }; + } + + function render(trashItems) { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}] + }); + testContext.entry = entry; + + const result = renderBackboneView(new TrashView({entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify(trashItems)] + ); + + return result; + } + + it('navigates to the outline when the back link is clicked', () => { + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + const {getByRole} = render([]); + + fireEvent.click(getByRole('link', {name: 'Outline'})); + + expect(navigate).toHaveBeenCalledWith('/', {trigger: true}); + navigate.mockRestore(); + }); + + it('lists trash items with origin chapter', () => { + const {getAllByRole} = render([ + trashItem({id: 5, originPermaId: 100}), + trashItem({id: 6, originPermaId: 999}) + ]); + + const items = getAllByRole('listitem'); + + expect(items[0]).toHaveTextContent('Section from Intro'); + expect(items[1]).toHaveTextContent('Section from deleted chapter'); + }); + + it('prefixes deletion time', () => { + const {getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + expect(getAllByRole('listitem')[0].querySelector('time')) + .toHaveTextContent(/^Deleted .*2026/); + }); + + it('lists most recently deleted items first when shown again', () => { + render([trashItem({id: 5, originPermaId: 100})]); + const view = renderBackboneView(new TrashView({entry: testContext.entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + trashItem({id: 6, originPermaId: 999}), + trashItem({id: 5, originPermaId: 100}) + ])] + ); + + expect(view.getAllByRole('listitem').map(item => item.textContent)) + .toEqual([ + expect.stringContaining('Section from deleted chapter'), + expect.stringContaining('Section from Intro') + ]); + }); + + it('shows loading indicator until trash items have been fetched', async () => { + const entry = createEntry({}); + const view = new TrashView({entry}); + renderBackboneView(view); + + expect(view.el.querySelector('.loading')).not.toBeNull(); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, '[]'] + ); + + await waitFor(() => expect(view.el.querySelector('.loading')).toBeNull()); + }); + + it('displays error instead of loading indicator if loading fails', async () => { + const entry = createEntry({}); + const view = new TrashView({entry}); + const {findByText} = renderBackboneView(view); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [500, {'Content-Type': 'application/json'}, '{}'] + ); + + expect(await findByText('Could not load')).toBeInTheDocument(); + expect(view.el.querySelector('.loading')).toBeNull(); + }); + + it('renders blank slate when nothing has been deleted', () => { + const {getByText} = render([]); + + expect(getByText('Nothing deleted')).toBeInTheDocument(); + }); + + it('explains what can be restored', () => { + const {getByText} = render([]); + + expect(getByText('Deleted sections can be restored here.')).toBeInTheDocument(); + }); + + it('restores entry when restore button is clicked', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + + expect(testContext.requests.at(-1).url) + .toBe('/editor/entries/1/scrolled/trash_items/5/restore'); + }); + + it('displays error when restore fails', async () => { + const {getByRole, getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + testContext.server.respond( + 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', + [422, {'Content-Type': 'application/json'}, '{}'] + ); + + await waitFor(() => + expect(within(getAllByRole('listitem')[0]).getByText('Could not restore')) + .toBeInTheDocument() + ); + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/collections-spec.js b/entry_types/scrolled/package/spec/entryState/collections-spec.js index e0c12d4602..afa32c76b1 100644 --- a/entry_types/scrolled/package/spec/entryState/collections-spec.js +++ b/entry_types/scrolled/package/spec/entryState/collections-spec.js @@ -1,4 +1,4 @@ -import {watchCollections} from 'entryState'; +import {watchCollections, watchFileCollections} from 'entryState'; import {useEditorGlobals} from 'support'; @@ -54,3 +54,29 @@ describe('watchCollections', () => { teardown(); }); }); + +describe('watchFileCollections', () => { + const {createEntry} = useEditorGlobals(); + + it('dispatches file collections only', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 5}], + sections: [{id: 1, permaId: 10}] + }); + const collectionNames = []; + + const teardown = watchFileCollections(entry, { + dispatch(action) { + if (action.payload.items) { + collectionNames.push(action.payload.collectionName); + } + } + }); + + expect(collectionNames).toContain('imageFiles'); + expect(collectionNames).not.toContain('sections'); + expect(collectionNames).not.toContain('contentElements'); + + teardown(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index de911442ce..12cbeedf29 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -10,6 +10,7 @@ import {EditContentElementView} from '../views/EditContentElementView'; import {CommentsView} from '../views/CommentsView'; import {CommentActivityView} from '../views/CommentActivityView'; import {NewThreadView} from '../views/NewThreadView'; +import {TrashView} from '../views/TrashView'; export const SideBarController = Marionette.Controller.extend({ initialize: function(options) { @@ -65,6 +66,12 @@ export const SideBarController = Marionette.Controller.extend({ })); }, + trash: function() { + this.region.show(new TrashView({ + entry: this.entry + })); + }, + contentElement: function(id, tab) { this.region.show(new EditContentElementView({ entry: this.entry, diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js index e38bc10050..ba75ce53b0 100644 --- a/entry_types/scrolled/package/src/editor/models/TrashItem.js +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -1,6 +1,23 @@ import Backbone from 'backbone'; export const TrashItem = Backbone.Model.extend({ + firstSectionPermaId() { + return this.get('sections')[0]?.permaId; + }, + + seed(entrySeed) { + const sections = this.get('sections'); + + return { + ...entrySeed, + collections: { + ...entrySeed.collections, + sections: sections.map(({contentElements, ...section}) => section), + contentElements: sections.flatMap(section => section.contentElements) + } + }; + }, + restore() { const {parent, position} = this.restoreTarget(this.collection.entry.chapters, chapter => chapter.sections); diff --git a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js index d71d779e6d..45d97f20c5 100644 --- a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js +++ b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js @@ -5,6 +5,7 @@ export const SideBarRouter = Marionette.AppRouter.extend({ 'scrolled/comments?tab=:tab': 'comments', 'scrolled/comments': 'comments', 'scrolled/comments/activity': 'commentActivity', + 'scrolled/trash': 'trash', 'scrolled/chapters/:id': 'chapter', 'scrolled/sections/:id/transition': 'sectionTransition', 'scrolled/sections/:id/paddings?position=:position': 'sectionPaddings', diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js new file mode 100644 index 0000000000..61daca5971 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -0,0 +1,139 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import {editor, CollectionView, LoadingView} from 'pageflow/editor'; +import {cssModulesUtils} from 'pageflow/ui'; +import {StandaloneSectionThumbnail} from 'pageflow-scrolled/frontend'; +import {watchFileCollections} from 'pageflow-scrolled/entryState'; + +import styles from './TrashView.module.css'; + +const t = (key, options) => I18n.t(`pageflow_scrolled.editor.trash_view.${key}`, options); + +export const TrashView = Marionette.ItemView.extend({ + template: () => ` + ${t('back')} +

${t('hint')}

+
    + `, + + ui: cssModulesUtils.ui(styles, 'items'), + + events: { + 'click a.back': function() { + editor.navigate('/', {trigger: true}); + return false; + } + }, + + onRender() { + const {entry} = this.options; + const loadingView = this.subview(new LoadingView()); + + this.ui.items.append(loadingView.render().el); + + entry.trashItems.fetch({reset: true}).then(() => { + if (this.isClosed) { + return; + } + + loadingView.close(); + + this.subview(new CollectionView({ + el: this.ui.items, + collection: entry.trashItems, + itemViewConstructor: TrashItemView, + itemViewOptions: {entry}, + blankSlateViewConstructor: BlankSlateView + })); + }, () => { + if (this.isClosed) { + return; + } + + loadingView.close(); + this.ui.items.append(``); + }); + } +}); + +const BlankSlateView = Marionette.ItemView.extend({ + tagName: 'li', + className: styles.blankSlate, + template: () => t('blank_slate') +}); + +const TrashItemView = Marionette.ItemView.extend({ + tagName: 'li', + className: styles.item, + + template: () => ` +
    +
    + + + +
    + + `, + + ui: cssModulesUtils.ui(styles, 'thumbnail', 'origin', 'trashedAt', 'error', 'restore'), + + events: cssModulesUtils.events(styles, { + 'click restore': function() { + this.ui.restore.prop('disabled', true); + this.ui.error.text(''); + + this.model.restore().catch(() => { + this.ui.restore.prop('disabled', false); + this.ui.error.text(t('restore_failed')); + }); + } + }), + + onRender() { + this.ui.origin.text(this.originLabel()); + this.ui.trashedAt + .attr('datetime', this.model.get('trashedAt')) + .text(t('trashed_at', { + time: new Date(this.model.get('trashedAt')).toLocaleString(I18n.locale, { + dateStyle: 'medium', + timeStyle: 'short' + }) + })); + + ReactDOM.render( + React.createElement(StandaloneSectionThumbnail, { + sectionPermaId: this.model.firstSectionPermaId(), + seed: this.model.seed(this.options.entry.scrolledSeed), + subscribe: dispatch => watchFileCollections(this.options.entry, {dispatch}) + }), + this.ui.thumbnail[0] + ); + }, + + onClose() { + ReactDOM.unmountComponentAtNode(this.ui.thumbnail[0]); + }, + + originLabel() { + const chapter = this.options.entry.chapters.findWhere({ + permaId: this.model.get('originPermaId') + }); + + return chapter ? + t('origin', {chapter: chapter.getDisplayName()}) : + t('origin_missing'); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.module.css b/entry_types/scrolled/package/src/editor/views/TrashView.module.css new file mode 100644 index 0000000000..27071c76c6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashView.module.css @@ -0,0 +1,70 @@ +.hint { + composes: info_box info from global; + margin: space(4) 0 0; +} + +.items { + list-style: none; + margin: space(4) 0 0; + padding: 0; +} + +.loadFailed { + color: var(--ui-error-color); +} + +.blankSlate { + color: var(--ui-on-surface-color-light); +} + +.item { + display: flex; + flex-direction: column; + gap: space(2); + margin-bottom: 10px; + padding: 10px; + background-color: var(--ui-surface-color); + border: solid 1px var(--ui-on-surface-color-lighter); + border-radius: rounded(lg); +} + +.thumbnail { + position: relative; + padding-top: 50%; + overflow: hidden; + border-radius: rounded(sm); + border: solid 1px var(--ui-on-surface-color-lighter); +} + +.details { + display: flex; + flex-direction: column; + gap: space(1); + min-width: 0; +} + +.origin { + font-weight: 500; +} + +.trashedAt { + color: var(--ui-on-surface-color-light); +} + +.error { + color: var(--ui-error-color); +} + +.error:empty { + display: none; +} + +.restore { + composes: secondaryIconButton from './buttons.module.css'; + align-self: start; +} + +.restoreIcon { + margin-right: 6px; + vertical-align: top; +} diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index 11fbbd3465..465a41d139 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -1,7 +1,15 @@ import {watchCollection} from '../collections'; export function watchCollections(entry, {dispatch}) { - const teardownFns = collectionSpecs(entry).map( + return watchSpecs([...collectionSpecs(entry), ...fileCollectionSpecs(entry)], dispatch); +} + +export function watchFileCollections(entry, {dispatch}) { + return watchSpecs(fileCollectionSpecs(entry), dispatch); +} + +function watchSpecs(specs, dispatch) { + const teardownFns = specs.map( ({collection, ...spec}) => watchCollection(collection, {...spec, dispatch}) ); @@ -12,7 +20,7 @@ export function watchCollections(entry, {dispatch}) { function collectionSpecs(entry) { const { - storylines, chapters, sections, contentElements, widgets, files, linkedEntries + storylines, chapters, sections, contentElements, widgets, linkedEntries } = entry; return [ @@ -75,29 +83,34 @@ function collectionSpecs(entry) { attributes: [{typeName: 'type_name'}, 'role', {permaId: 'role'}], keyAttribute: 'permaId', includeConfiguration: true - }, - ...Object.keys(files).map(collectionName => ({ - collection: files[collectionName], - name: camelize(collectionName), - attributes: ['id', {permaId: 'perma_id'}, 'width', 'height', - 'basename', 'extension', 'rights', - {displayName: [ - 'display_name', 'file_name', - (displayName, fileName) => displayName || fileName - ]}, - {processedExtension: 'processed_extension'}, - {isReady: 'is_ready'}, - {variants: variants => variants && variants.map(variant => camelize(variant))}, - {durationInMs: 'duration_in_ms'}, - {parentFileId: 'parent_file_id'}, - {parentFileModelType: 'parent_file_model_type'}, - {isReferenced: 'is_referenced'}], - keyAttribute: 'permaId', - includeConfiguration: true - })) + } ]; } +function fileCollectionSpecs(entry) { + const {files} = entry; + + return Object.keys(files).map(collectionName => ({ + collection: files[collectionName], + name: camelize(collectionName), + attributes: ['id', {permaId: 'perma_id'}, 'width', 'height', + 'basename', 'extension', 'rights', + {displayName: [ + 'display_name', 'file_name', + (displayName, fileName) => displayName || fileName + ]}, + {processedExtension: 'processed_extension'}, + {isReady: 'is_ready'}, + {variants: variants => variants && variants.map(variant => camelize(variant))}, + {durationInMs: 'duration_in_ms'}, + {parentFileId: 'parent_file_id'}, + {parentFileModelType: 'parent_file_model_type'}, + {isReferenced: 'is_referenced'}], + keyAttribute: 'permaId', + includeConfiguration: true + })); +} + function camelize(snakeCase) { return snakeCase.replace(/_[a-z]/g, function(match) { return match[1].toUpperCase(); diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index bce317aea9..3241390089 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -35,7 +35,7 @@ export {useWidget, useActiveWidgets} from './widgets'; export {EntryStateProvider, useEntryStateDispatch} from './EntryStateProvider'; -export {watchCollections} from './collections'; +export {watchCollections, watchFileCollections} from './collections'; export {updateContentElementConfiguration} from './updateContentElementConfiguration'; export {updateWidgetConfiguration} from './updateWidgetConfiguration'; From 54a6c71f00810ba2e80d4da8162fcdfdf623bb03 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 16 Oct 2026 12:23:08 +0200 Subject: [PATCH 13/29] Add content trash button to outline toolbar A trash button view sits on the tab line left of the ellipsis menu, shows a trash can with a restore arrow and carries its name as tooltip and accessible name. --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../editor/views/EntryOutlineView-spec.js | 29 ++++++++++++++- .../spec/editor/views/TrashButtonView-spec.js | 33 +++++++++++++++++ .../src/editor/views/EntryOutlineView.js | 7 +++- .../editor/views/EntryOutlineView.module.css | 8 ++-- .../src/editor/views/TrashButtonView.js | 37 +++++++++++++++++++ .../editor/views/TrashButtonView.module.css | 20 ++++++++++ 8 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/TrashButtonView.js create mode 100644 entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 79a0395cd0..a21bfbe5aa 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1436,6 +1436,7 @@ de: header: Gliederung finish_reorder_chapters: Umsortieren beenden reorder_chapters: "Kapitel umsortieren..." + trash: Gelöschte Inhalte entry_select: no_matches: Keine passenden Beiträge not_published: Nicht veröffentlicht diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index c6c725f72d..cd00fdb668 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1418,6 +1418,7 @@ en: header: Outline finish_reorder_chapters: Finish reordering reorder_chapters: "Reorder chapters..." + trash: Deleted content entry_select: no_matches: No matching stories not_published: Not published diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index 492bf87b87..b85b6c3d28 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -10,7 +10,8 @@ describe('EntryOutlineView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.storylines_tabs.main': 'Main', 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions', - 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.' + 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.', + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' }); const {createEntry} = useEditorGlobals(); @@ -362,4 +363,30 @@ describe('EntryOutlineView', () => { expect(screen.queryByText(/Excursions provide additional content outside the main path/)).toBeNull(); }); + + it('renders trash button with tooltip', () => { + const entry = createEntry({}); + + render(new EntryOutlineView({entry})); + + expect(screen.getByRole('button', {name: 'Deleted content'})) + .toHaveAttribute('title', 'Deleted content'); + }); + + it('renders trash button for entries with excursions', () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [ + {id: 1, permaId: 100, position: 0, storylineId: 10}, + {id: 2, permaId: 101, position: 0, storylineId: 11} + ] + }); + + render(new EntryOutlineView({entry})); + + expect(screen.getByRole('button', {name: 'Deleted content'})).toBeInTheDocument(); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js new file mode 100644 index 0000000000..bcf9d8816c --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js @@ -0,0 +1,33 @@ +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import {screen} from '@testing-library/dom'; + +import {editor} from 'pageflow-scrolled/editor'; + +import {TrashButtonView} from 'editor/views/TrashButtonView'; + +import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; + +describe('TrashButtonView', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' + }); + + it('renders button with tooltip', () => { + render(new TrashButtonView()); + + expect(screen.getByRole('button', {name: 'Deleted content'})) + .toHaveAttribute('title', 'Deleted content'); + }); + + it('navigates to trash when clicked', async () => { + const user = userEvent.setup(); + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + render(new TrashButtonView()); + + await user.click(screen.getByRole('button', {name: 'Deleted content'})); + + expect(navigate).toHaveBeenCalledWith('/scrolled/trash', {trigger: true}); + navigate.mockRestore(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 2146cbeb0c..770d0af628 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -6,6 +6,7 @@ import {DropDownButtonView} from 'pageflow/editor'; import {StorylinesTabsView} from './StorylinesTabsView'; import {StorylineItemView} from './StorylineItemView'; +import {TrashButtonView} from './TrashButtonView'; import styles from './EntryOutlineView.module.css'; @@ -20,9 +21,11 @@ export const EntryOutlineView = Marionette.Layout.extend({
    `, - ui: cssModulesUtils.ui(styles, 'tabs', 'dropDownButton'), + ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), onRender() { + this.ui.toolbar.prepend(this.subview(new TrashButtonView()).render().el); + const viewModel = new Backbone.Model({collapsed: false}); const dropDownMenuItems = new Backbone.Collection(); @@ -52,6 +55,8 @@ export const EntryOutlineView = Marionette.Layout.extend({ }), {to: this.ui.tabs} ); + + this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); } }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css index a8a962b1bf..edc805967c 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css @@ -8,12 +8,14 @@ .toolbar { position: absolute; - top: 3px; - right: -1px; + top: 0; + right: 0; + display: flex; + align-items: center; + height: 100%; } .dropDownButton { - --drop-down-button-width: 26px; --ui-on-button-color: var(--ui-primary-color-light); button.hover, diff --git a/entry_types/scrolled/package/src/editor/views/TrashButtonView.js b/entry_types/scrolled/package/src/editor/views/TrashButtonView.js new file mode 100644 index 0000000000..2f648a29ff --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashButtonView.js @@ -0,0 +1,37 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; +import {editor} from 'pageflow-scrolled/editor'; + +import styles from './TrashButtonView.module.css'; + +export const TrashButtonView = Marionette.ItemView.extend({ + tagName: 'button', + className: styles.button, + + attributes: () => ({ + title: I18n.t('pageflow_scrolled.editor.entry_outline.trash'), + 'aria-label': I18n.t('pageflow_scrolled.editor.entry_outline.trash') + }), + + template: () => ` + + `, + + events: { + click() { + editor.navigate('/scrolled/trash', {trigger: true}); + } + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css new file mode 100644 index 0000000000..7509be377b --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css @@ -0,0 +1,20 @@ +.button { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: none; + border: 0; + padding: 0 space(2); + color: var(--ui-primary-color-light); + cursor: pointer; +} + +.icon { + display: block; +} + +.button:hover, +.button:focus-visible { + color: var(--ui-primary-color); +} From 9ff3f03b05a9cb54a672dd24677c785837d434e7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 10:59:26 +0200 Subject: [PATCH 14/29] Move deleted chapters to the content trash Deleting a chapter re-parents it with its sections into the trash storyline instead of destroying it. No container is needed since the chapter itself is the trash item. Kind, origin storyline perma_id, origin position and trash time are recorded next to its existing configuration. The trash listing includes chapter titles. --- .../editor/chapters_controller.rb | 2 +- .../app/models/pageflow_scrolled/trash.rb | 28 ++++++-- .../models/pageflow_scrolled/trash/item.rb | 4 ++ .../editor/trash_items/index.json.jbuilder | 1 + .../editor/chapters_controller_spec.rb | 16 +++++ .../editor/trash_items_controller_spec.rb | 22 ++++++ .../models/pageflow_scrolled/trash_spec.rb | 69 +++++++++++++++++++ 7 files changed, 135 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb index 10e56901a3..2189e245c7 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb @@ -25,7 +25,7 @@ def update def destroy chapter = Chapter.all_for_revision(@entry.draft).find(params[:id]) - chapter.destroy + Trash.new(@entry.draft).add_chapter(chapter) head :no_content rescue ActiveRecord::RecordNotFound diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb index 074ec9136b..4359d422cd 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -17,6 +17,18 @@ def add_section(section) end end + def add_chapter(chapter) + chapter.update!( + storyline:, + position: next_position, + configuration: chapter.configuration.merge( + 'trash' => metadata(kind: 'chapter', + origin_perma_id: chapter.storyline.perma_id, + origin_position: chapter.position) + ) + ) + end + def items chapters.includes(sections: :content_elements).map do |chapter| Item.new(chapter, @revision) @@ -40,16 +52,20 @@ def create_container(kind:, origin_perma_id:, origin_position:) storyline.chapters.create!( position: next_position, configuration: { - trash: { - kind:, - originPermaId: origin_perma_id, - originPosition: origin_position, - trashedAt: Time.current.utc.iso8601 - } + trash: metadata(kind:, origin_perma_id:, origin_position:) } ) end + def metadata(kind:, origin_perma_id:, origin_position:) + { + 'kind' => kind, + 'originPermaId' => origin_perma_id, + 'originPosition' => origin_position, + 'trashedAt' => Time.current.utc.iso8601 + } + end + def next_position (storyline.chapters.maximum(:position) || -1) + 1 end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb index 0b4518a838..65ea91a3de 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -25,6 +25,10 @@ def trashed_at metadata['trashedAt'] end + def title + @chapter.configuration['title'] + end + def restore Storyline.transaction do restore_section diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder index d0a22f1993..5a8018c38d 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder @@ -2,6 +2,7 @@ json.key_format!(camelize: :lower) json.array!(@items) do |item| json.call(item, :id, :kind, :origin_perma_id, :origin_position, :trashed_at) + json.title item.title json.sections do json.array!(item.sections) do |section| diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb index 5e1f7f9a60..1f4e8819d1 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb @@ -244,6 +244,22 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'moves the chapter to the trash' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: chapter + }, format: 'json') + + expect(chapter.reload.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + it 'does not allow deleting a chapter from a different entry' do entry = create(:entry, type_name: 'scrolled') create(:scrolled_chapter, revision: entry.draft) diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb index a3b038e17c..3e4a33a955 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -50,6 +50,28 @@ module PageflowScrolled ) end + it 'lists trashed chapters with title' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft, + configuration: {'title' => 'Intro'}) + section = create(:section, chapter:) + Trash.new(entry.draft).add_chapter(chapter) + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.body).to include_json( + [ + { + id: chapter.id, + kind: 'chapter', + title: 'Intro', + sections: [{id: section.id}] + } + ] + ) + end + it 'lists newest items first' do entry = create(:entry, type_name: 'scrolled') chapter = create(:scrolled_chapter, revision: entry.draft) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 7b42d25c22..9df63f8cfa 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -67,6 +67,75 @@ module PageflowScrolled end end + describe '#add_chapter' do + it 'removes chapter and its sections from revision' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + create(:section, chapter:) + + Trash.new(entry.draft).add_chapter(chapter) + + expect(Chapter.all_for_revision(entry.draft)).to be_empty + expect(Section.all_for_revision(entry.draft)).to be_empty + end + + it 'moves chapter into trash storyline without container' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + + Trash.new(entry.draft).add_chapter(chapter) + + expect(chapter.reload.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + expect(section.reload.chapter).to eq(chapter) + end + + it 'records kind, origin and trash time next to chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + create(:scrolled_chapter, storyline:, position: 0) + chapter = create(:scrolled_chapter, storyline:, position: 1, + configuration: {'title' => 'Intro'}) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_chapter(chapter) + end + + expect(chapter.reload.configuration).to eq( + 'title' => 'Intro', + 'trash' => { + 'kind' => 'chapter', + 'originPermaId' => storyline.perma_id, + 'originPosition' => 1, + 'trashedAt' => '2026-10-01T12:00:00Z' + } + ) + end + + it 'appends chapter after existing items' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + chapter = create(:scrolled_chapter, revision: entry.draft) + trash = Trash.new(entry.draft) + + trash.add_section(section) + trash.add_chapter(chapter) + + expect(trash.items.map(&:id)).to eq([chapter.id, section.reload.chapter_id]) + end + + it 'keeps perma_ids of chapter and its sections' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + + expect { + Trash.new(entry.draft).add_chapter(chapter) + }.not_to(change { [chapter.reload.perma_id, section.reload.perma_id] }) + end + end + describe '#restore' do it 'moves section back to origin chapter at origin position' do entry = create(:entry, type_name: 'scrolled') From 957c4ad816f90f65d4af2bc73dbb84c68010151a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:00:21 +0200 Subject: [PATCH 15/29] Restore chapters from the content trash Re-parents the chapter into its origin storyline at the recorded position, shifting later chapters, and removes the trash metadata from its configuration. Excursion chapters return to the excursions storyline. Responds with the chapter, its sections and their content elements. --- .../editor/trash_items_controller.rb | 9 ++- .../app/models/pageflow_scrolled/storyline.rb | 7 ++ .../models/pageflow_scrolled/trash/item.rb | 17 ++++- .../_chapter_with_sections.json.jbuilder | 9 +++ .../editor/trash_items_controller_spec.rb | 26 +++++++ .../models/pageflow_scrolled/trash_spec.rb | 69 ++++++++++++++++++- 6 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb index 2b680d353c..9223d51ee4 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -11,8 +11,13 @@ def index def restore restored = Trash.new(@entry.draft).find_item(params[:id]).restore - render partial: 'pageflow_scrolled/editor/sections/section_with_content_elements', - locals: {section: restored} + if restored.is_a?(Chapter) + render partial: 'pageflow_scrolled/editor/chapters/chapter_with_sections', + locals: {chapter: restored} + else + render partial: 'pageflow_scrolled/editor/sections/section_with_content_elements', + locals: {section: restored} + end rescue ActiveRecord::RecordNotFound head :not_found rescue Trash::DestinationMissing diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 4bcedf3746..2ccc84b87c 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -34,6 +34,13 @@ def role super || (configuration['main'] ? 'main' : 'excursions') end + def insert_chapter(chapter, position:) + transaction do + chapters.where('position >= ?', position).update_all('position = position + 1') + chapter.update!(storyline: self, position:) + end + end + def sections_before_cutoff_section sections_before(cutoff_section) end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb index 65ea91a3de..8b90e66d13 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -31,7 +31,10 @@ def title def restore Storyline.transaction do - restore_section + case kind + when 'chapter' then restore_chapter + else restore_section + end end end @@ -41,6 +44,13 @@ def metadata @chapter.configuration['trash'] end + def restore_chapter + origin_storyline.insert_chapter(@chapter, position: origin_position) + @chapter.update!(configuration: @chapter.configuration.except('trash')) + + @chapter + end + def restore_section section = sections.first @@ -60,6 +70,11 @@ def insert_into_origin_chapter(section) chapter.insert_section(section, position: (chapter.sections.maximum(:position) || -1) + 1) end end + + def origin_storyline + Storyline.all_for_revision(@revision).find_by(perma_id: origin_perma_id) || + raise(DestinationMissing) + end end end end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder new file mode 100644 index 0000000000..799e18956e --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder @@ -0,0 +1,9 @@ +json.key_format!(camelize: :lower) + +json.partial!('pageflow_scrolled/chapters/chapter', chapter:) + +json.sections do + json.array!(chapter.sections) do |section| + json.partial!('pageflow_scrolled/editor/sections/section_with_content_elements', section:) + end +end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb index 3e4a33a955..1faabbff13 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -132,6 +132,32 @@ module PageflowScrolled ) end + it 'restores chapter and renders it with sections and content elements' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + chapter = create(:scrolled_chapter, storyline:, configuration: {'title' => 'Intro'}) + section = create(:section, chapter:) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_chapter(chapter) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: chapter.id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + id: chapter.id, + permaId: chapter.perma_id, + storylineId: storyline.id, + configuration: {title: 'Intro'}, + sections: [ + {id: section.id, contentElements: [{id: content_element.id}]} + ] + ) + expect(JSON.parse(response.body)['configuration']).not_to have_key('trash') + end + it 'responds with unprocessable entity if no chapter is left' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 9df63f8cfa..355b41fcde 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -136,7 +136,7 @@ module PageflowScrolled end end - describe '#restore' do + describe '#restore with trashed section' do it 'moves section back to origin chapter at origin position' do entry = create(:entry, type_name: 'scrolled') chapter = create(:scrolled_chapter, revision: entry.draft) @@ -232,6 +232,73 @@ module PageflowScrolled end end + describe '#restore with trashed chapter' do + it 'moves chapter back to origin storyline at origin position' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + first_chapter = create(:scrolled_chapter, storyline:, position: 0) + chapter = create(:scrolled_chapter, storyline:, position: 1) + last_chapter = create(:scrolled_chapter, storyline:, position: 2) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(storyline.reload.chapters).to eq([first_chapter, chapter, last_chapter]) + end + + it 'shifts positions of chapters at or after origin position' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + chapter = create(:scrolled_chapter, storyline:, position: 0) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + new_chapter = create(:scrolled_chapter, storyline:, position: 0) + + trash.find_item(chapter.id).restore + + expect(storyline.reload.chapters.map(&:position)).to eq([0, 1]) + expect(storyline.chapters).to eq([chapter, new_chapter]) + end + + it 'restores excursion chapters to excursions storyline' do + entry = create(:entry, type_name: 'scrolled') + excursions = Storyline.all_for_revision(entry.draft).find_by(role: 'excursions') + chapter = create(:scrolled_chapter, storyline: excursions) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(chapter.reload.storyline).to eq(excursions) + end + + it 'clears trash metadata and keeps chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft, + configuration: {'title' => 'Intro'}) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(chapter.reload.configuration).to eq('title' => 'Intro') + end + + it 'keeps sections and returns restored chapter' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + result = trash.find_item(chapter.id).restore + + expect(result).to eq(chapter) + expect(Section.all_for_revision(entry.draft)).to eq([section]) + end + end + describe '#items' do it 'returns items newest first' do entry = create(:entry, type_name: 'scrolled') From c6f8f76e09a81d349538e407481e816b56ac6353 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:01:22 +0200 Subject: [PATCH 16/29] Restore chapters from the content trash in the editor Restoring a trashed chapter inserts it into its origin storyline, waiting for the restore response before adding the chapter with its sections and their content elements. The first section of the restored chapter is selected and scrolled into view. --- .../collections/TrashItemsCollection-spec.js | 123 ++++++++++++++++++ .../package/src/editor/models/Storyline.js | 42 ++++++ .../package/src/editor/models/TrashItem.js | 32 +++-- 3 files changed, 188 insertions(+), 9 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js index c1e869d69c..b34409aaaa 100644 --- a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -168,4 +168,127 @@ describe('TrashItemsCollection', () => { expect(entry.chapters.get(10).sections.pluck('position')).toEqual([0, 1]); }); }); + + describe('#restore of chapter', () => { + beforeEach(() => { + testContext.entry = createEntry({ + storylines: [ + {id: 1, permaId: 10, role: 'main'}, + {id: 2, permaId: 20, configuration: {}} + ], + chapters: [ + {id: 10, storylineId: 1, position: 0}, + {id: 12, storylineId: 1, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 11, kind: 'chapter', originPermaId: 10, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredChapter(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/11/restore', 200, { + id: 11, + permaId: 110, + storylineId: 1, + position: 1, + configuration: {title: 'Intro'}, + sections: [ + { + id: 101, + permaId: 501, + chapterId: 11, + position: 0, + configuration: {}, + contentElements: [{id: 1000, permaId: 601, sectionId: 101, typeName: 'heading'}] + } + ] + }); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(11).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/11/restore'); + }); + + it('inserts restored chapter into origin storyline', async () => { + const {entry, server} = testContext; + const storyline = entry.storylines.get(1); + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(storyline.chapters.pluck('id')).toEqual([10, 11, 12]); + expect(storyline.chapters.pluck('position')).toEqual([0, 1, 2]); + expect(entry.chapters.get(11).configuration.get('title')).toEqual('Intro'); + }); + + it('adds sections and content elements of restored chapter', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.chapters.get(11).sections.pluck('id')).toEqual([101]); + expect(entry.sections.get(101).contentElements.pluck('id')).toEqual([1000]); + }); + + it('selects and scrolls to first section of restored chapter', async () => { + const {entry, server} = testContext; + const events = []; + entry.on('selectSection scrollToSection', section => events.push(section.id)); + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(events).toEqual([101, 101]); + }); + + it('keeps resource url of restored chapter', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.chapters.get(11).url()) + .toEqual('/editor/entries/1/scrolled/chapters/11'); + }); + + it('rejects without request if origin storyline is missing', async () => { + const {entry, requests} = testContext; + entry.trashItems.add({id: 13, kind: 'chapter', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(13).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('keeps positions of chapters if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/11/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.storylines.get(1).chapters.pluck('position')).toEqual([0, 1]); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.trashItems.get(11)).toBeUndefined(); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/models/Storyline.js b/entry_types/scrolled/package/src/editor/models/Storyline.js index 45970b1634..06a500ce0c 100644 --- a/entry_types/scrolled/package/src/editor/models/Storyline.js +++ b/entry_types/scrolled/package/src/editor/models/Storyline.js @@ -5,6 +5,8 @@ import { ForeignKeySubsetCollection } from 'pageflow/editor'; +import {Chapter} from './Chapter'; + export const Storyline = Backbone.Model.extend({ mixins: [ configurationContainer({ @@ -34,6 +36,46 @@ export const Storyline = Backbone.Model.extend({ }); }, + restoreChapter(trashItem) { + const position = trashItem.get('originPosition'); + + const chapter = this.chapters.create( + new Chapter({position, storylineId: this.id}, { + entry: this.entry, + sections: this.entry.sections + }), + { + url: `${trashItem.url()}/restore`, + wait: true, + success: () => { + this.chapters.each(sibling => { + if (sibling !== chapter && sibling.get('position') >= position) { + sibling.set('position', sibling.get('position') + 1); + } + }); + + this.chapters.sort(); + } + } + ); + + chapter.once('sync', (model, response) => { + chapter.configuration.set(response.configuration, {autoSave: false}); + + response.sections.forEach(({contentElements, ...attributes}) => { + chapter.sections.add(attributes, {contentElements: this.entry.contentElements}); + this.entry.contentElements.add(contentElements); + }); + + if (chapter.sections.length) { + this.entry.trigger('selectSection', chapter.sections.first()); + this.entry.trigger('scrollToSection', chapter.sections.first()); + } + }); + + return chapter; + }, + appendChapter(chapter) { const position = this.chapters.length ? Math.max(...this.chapters.pluck('position')) + 1 : diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js index ba75ce53b0..abbb907f2a 100644 --- a/entry_types/scrolled/package/src/editor/models/TrashItem.js +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -19,22 +19,36 @@ export const TrashItem = Backbone.Model.extend({ }, restore() { - const {parent, position} = this.restoreTarget(this.collection.entry.chapters, - chapter => chapter.sections); + const restored = this.get('kind') === 'chapter' ? + this.restoreIntoStoryline() : + this.restoreIntoChapter(); - if (!parent) { - return Promise.reject(new Error('No chapter to restore into.')); + if (!restored) { + return Promise.reject(new Error('Nothing to restore into.')); } - const section = parent.restoreSection(this, {position}); - return new Promise((resolve, reject) => { - section.once('sync', () => { + restored.once('sync', () => { this.collection.remove(this); - resolve(section); + resolve(restored); }); - section.once('error', reject); + restored.once('error', reject); + }); + }, + + restoreIntoStoryline() { + const storyline = this.collection.entry.storylines.findWhere({ + permaId: this.get('originPermaId') }); + + return storyline && storyline.restoreChapter(this); + }, + + restoreIntoChapter() { + const {parent, position} = this.restoreTarget(this.collection.entry.chapters, + chapter => chapter.sections); + + return parent && parent.restoreSection(this, {position}); }, restoreTarget(parents, getChildren) { From 699b13c7b6c89f8cdc3dc140f701a8e69b345070 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:02:04 +0200 Subject: [PATCH 17/29] Label trashed chapters with title and section count Chapter trash items read as one thing rather than a number of loose sections. Failing chapter restores show a generic error since the origin chapter message does not apply. The trash hint now mentions chapters as well. --- entry_types/scrolled/config/locales/de.yml | 6 ++- entry_types/scrolled/config/locales/en.yml | 6 ++- .../spec/editor/views/TrashView-spec.js | 40 ++++++++++++++++++- .../package/src/editor/views/TrashView.js | 12 +++++- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index a21bfbe5aa..50762a63ff 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1785,11 +1785,15 @@ de: trash_view: back: Gliederung blank_slate: Aus diesem Beitrag wurde noch nichts gelöscht. - hint: Aus diesem Beitrag gelöschte Abschnitte können hier wiederhergestellt werden. + chapter: + one: "Kapitel „%{title}“ mit 1 Abschnitt" + other: "Kapitel „%{title}“ mit %{count} Abschnitten" + hint: Aus diesem Beitrag gelöschte Kapitel und Abschnitte können hier wiederhergestellt werden. load_failed: Gelöschte Inhalte konnten nicht geladen werden. origin: "Abschnitt aus %{chapter}" origin_missing: Abschnitt aus einem gelöschten Kapitel restore: Wiederherstellen + restore_chapter_failed: Das Kapitel konnte nicht wiederhergestellt werden. restore_failed: Der Abschnitt konnte nicht wiederhergestellt werden. trashed_at: "Gelöscht %{time}" configuration_places: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index cd00fdb668..48dc950883 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1765,11 +1765,15 @@ en: trash_view: back: Outline blank_slate: Nothing has been deleted from this story yet. - hint: Sections deleted from this story can be restored here. + chapter: + one: "Chapter “%{title}” with 1 section" + other: "Chapter “%{title}” with %{count} sections" + hint: Chapters and sections deleted from this story can be restored here. load_failed: Deleted content could not be loaded. origin: "Section from %{chapter}" origin_missing: Section from a deleted chapter restore: Restore + restore_chapter_failed: Could not restore the chapter. restore_failed: Could not restore the section. trashed_at: "Deleted %{time}" configuration_places: diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js index 05c3bc0401..676e6e50cf 100644 --- a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -29,7 +29,12 @@ describe('TrashView', () => { 'pageflow_scrolled.editor.trash_view.origin_missing': 'Section from deleted chapter', 'pageflow_scrolled.editor.trash_view.restore': 'Restore', 'pageflow_scrolled.editor.trash_view.restore_failed': 'Could not restore', - 'pageflow_scrolled.editor.trash_view.trashed_at': 'Deleted %{time}' + 'pageflow_scrolled.editor.trash_view.restore_chapter_failed': 'Could not restore chapter', + 'pageflow_scrolled.editor.trash_view.trashed_at': 'Deleted %{time}', + 'pageflow_scrolled.editor.trash_view.chapter.one': 'Chapter %{title} with 1 section', + 'pageflow_scrolled.editor.trash_view.chapter.other': + 'Chapter %{title} with %{count} sections', + 'pageflow_scrolled.editor.chapter_item.unnamed': 'Untitled' }); function trashItem({id, originPermaId, sectionId = id * 10}) { @@ -139,6 +144,26 @@ describe('TrashView', () => { expect(view.el.querySelector('.loading')).toBeNull(); }); + it('labels trashed chapters with title and section count', () => { + const {getAllByRole} = render([ + { + ...trashItem({id: 5}), + kind: 'chapter', + title: 'Outro', + sections: [ + {id: 50, permaId: 50, chapterId: 5, configuration: {}, contentElements: []}, + {id: 51, permaId: 51, chapterId: 5, configuration: {}, contentElements: []} + ] + }, + {...trashItem({id: 6}), kind: 'chapter'} + ]); + + const items = getAllByRole('listitem'); + + expect(items[0]).toHaveTextContent('Chapter Outro with 2 sections'); + expect(items[1]).toHaveTextContent('Chapter Untitled with 1 section'); + }); + it('renders blank slate when nothing has been deleted', () => { const {getByText} = render([]); @@ -174,4 +199,17 @@ describe('TrashView', () => { .toBeInTheDocument() ); }); + + it('displays generic error when restoring chapter fails', async () => { + const {getByRole, getAllByRole} = render([ + {...trashItem({id: 5}), kind: 'chapter', originPermaId: 999} + ]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + + await waitFor(() => + expect(within(getAllByRole('listitem')[0]).getByText('Could not restore chapter')) + .toBeInTheDocument() + ); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js index 61daca5971..de68f39b22 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.js +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -97,7 +97,9 @@ const TrashItemView = Marionette.ItemView.extend({ this.model.restore().catch(() => { this.ui.restore.prop('disabled', false); - this.ui.error.text(t('restore_failed')); + this.ui.error.text(t(this.model.get('kind') === 'chapter' ? + 'restore_chapter_failed' : + 'restore_failed')); }); } }), @@ -128,6 +130,14 @@ const TrashItemView = Marionette.ItemView.extend({ }, originLabel() { + if (this.model.get('kind') === 'chapter') { + return t('chapter', { + title: this.model.get('title') || + I18n.t('pageflow_scrolled.editor.chapter_item.unnamed'), + count: this.model.get('sections').length + }); + } + const chapter = this.options.entry.chapters.findWhere({ permaId: this.model.get('originPermaId') }); From e732f25a5ccc937a26ead0567d1d8fb5b9314684 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:26:08 +0200 Subject: [PATCH 18/29] Move content elements deleted via batch to the content trash Batch items flagged with _trash in addition to _delete are moved into a container chapter and section in the trash storyline instead of being destroyed. The container section copies invert and layout of the origin section so previews can match how the elements looked. Elements removed by merging adjacent text blocks are only flagged _delete and stay destroyed, since their content lives on in the merged block. The origin position counts the elements that remain in front of the trashed ones. --- .../editor/content_elements_controller.rb | 2 +- .../pageflow_scrolled/content_element.rb | 31 +++++- .../app/models/pageflow_scrolled/trash.rb | 16 +++ .../content_elements_controller_spec.rb | 98 +++++++++++++++++++ .../models/pageflow_scrolled/trash_spec.rb | 66 +++++++++++++ 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb index 57e592f237..b3ccf3f153 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb @@ -17,7 +17,7 @@ def batch section = Section.all_for_revision(@entry.draft).find(params[:section_id]) items = params.require(:content_elements).map do |item| - item.transform_keys(&:underscore).permit(:id, :type_name, :_delete, + item.transform_keys(&:underscore).permit(:id, :type_name, :_delete, :_trash, configuration: {}, migrate_comment_threads: []) end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb index 2cbb2482fd..a9a0fd8228 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb @@ -43,9 +43,11 @@ def initialize(section, items, comment_thread_subject_ranges: nil) def save! ContentElement.transaction do + trash_content_elements + result = @items.map.with_index { |item, index| if item[:_delete] - @section.content_elements.delete(item[:id]) + @section.content_elements.delete(item[:id]) unless item[:_trash] nil else create_or_update(item, index) @@ -60,6 +62,33 @@ def save! private + def trash_content_elements + trashed_ids = item_ids { |item| item[:_trash] } + return if trashed_ids.empty? + + content_elements = ContentElement.where(section: @section).order(:position).to_a + trashed = content_elements.select { |ce| trashed_ids.include?(ce.id) } + raise ActiveRecord::RecordNotFound if trashed.size < trashed_ids.size + + Trash.new(@storyline.revision).add_content_elements( + trashed, + origin_section: @section, + origin_position: count_remaining_before(content_elements, trashed_ids) + ) + end + + def count_remaining_before(content_elements, trashed_ids) + deleted_ids = item_ids { |item| item[:_delete] } + + content_elements + .take_while { |ce| trashed_ids.exclude?(ce.id) } + .count { |ce| deleted_ids.exclude?(ce.id) } + end + + def item_ids(&) + @items.select(&).map { |item| item[:id].to_i } + end + def create_or_update(item, index) attributes = { section_id: @section.id, diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb index 4359d422cd..ac2cd5b3e6 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -17,6 +17,22 @@ def add_section(section) end end + def add_content_elements(content_elements, origin_section:, origin_position:) + Storyline.transaction do + container = create_container(kind: 'contentElements', + origin_perma_id: origin_section.perma_id, + origin_position:) + container_section = container.sections.create!( + position: 0, + configuration: origin_section.configuration.slice('invert', 'layout') + ) + + content_elements.each_with_index do |content_element, index| + content_element.update!(section: container_section, position: index) + end + end + end + def add_chapter(chapter) chapter.update!( storyline:, diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb index 379057b72f..95ed8c3148 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb @@ -166,6 +166,77 @@ module PageflowScrolled expect(section.content_elements.map(&:id)).to eq([content_elements.last.id]) end + it 'moves content elements marked by _trash flag to the trash' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + kept, trashed, merged = create_list(:content_element, 3, section:) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: kept.id}, + {id: trashed.id, _delete: true, _trash: true}, + {id: merged.id, _delete: true} + ] + }, format: 'json', as: :json) + + expect(section.reload.content_elements).to eq([kept]) + expect(trashed.reload.section.chapter.configuration['trash']) + .to include('kind' => 'contentElements', + 'originPermaId' => section.perma_id, + 'originPosition' => 1) + expect(ContentElement.exists?(merged.id)).to eq(false) + end + + it 'does not trash content elements of other sections' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + other_section = create(:section, revision: entry.draft) + content_element = create(:content_element, section: other_section) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: content_element.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(response.status).to eq(404) + expect(content_element.reload.section).to eq(other_section) + end + + it 'records number of remaining preceding elements as origin position' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + merged = create(:content_element, section:, position: 0) + kept = create(:content_element, section:, position: 1) + trashed = create(:content_element, section:, position: 5) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: kept.id}, + {id: merged.id, _delete: true}, + {id: trashed.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(trashed.reload.section.chapter.configuration['trash']) + .to include('originPosition' => 1) + end + it 'responds with array of objects containging content element ids and perma ids' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) @@ -390,6 +461,33 @@ module PageflowScrolled expect(thread.reload.section_perma_id).to eq(target_section.perma_id) end + it 'updates section_perma_id of threads on moved in elements when trashing in same batch' do + entry = create(:entry, type_name: 'scrolled') + source_section = create(:section, revision: entry.draft) + target_section = create(:section, revision: entry.draft) + trashed = create(:content_element, section: target_section) + element = create(:content_element, :text_block, section: source_section) + thread = create(:comment_thread, + revision: entry.draft, + subject_type: 'ContentElement', + subject_id: element.perma_id, + section_perma_id: source_section.perma_id) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: target_section.id, + content_elements: [ + {id: element.id}, + {id: trashed.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(thread.reload.section_perma_id).to eq(target_section.perma_id) + end + it 'leaves section_perma_id untouched for threads that stay in their section' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 355b41fcde..227b738083 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -136,6 +136,72 @@ module PageflowScrolled end end + describe '#add_content_elements' do + it 'moves content elements into container section keeping their order' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + first = create(:content_element, section:, position: 0) + second = create(:content_element, section:, position: 1) + + Trash.new(entry.draft).add_content_elements([first, second], + origin_section: section, + origin_position: 0) + + container_section = first.reload.section + expect(container_section.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + expect(container_section.content_elements).to eq([first, second.reload]) + expect(section.reload.content_elements).to be_empty + end + + it 'records kind, origin section and position in container chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 2) + end + + expect(content_element.reload.section.chapter.configuration['trash']).to eq( + 'kind' => 'contentElements', + 'originPermaId' => section.perma_id, + 'originPosition' => 2, + 'trashedAt' => '2026-10-01T12:00:00Z' + ) + end + + it 'copies invert and layout of origin section to container section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft, + configuration: {'invert' => true, + 'layout' => 'right', + 'backdrop' => {'image' => 5}}) + content_element = create(:content_element, section:) + + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + expect(content_element.reload.section.configuration) + .to eq('invert' => true, 'layout' => 'right') + end + + it 'keeps perma_ids of content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + expect { + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + }.not_to(change { content_element.reload.perma_id }) + end + end + describe '#restore with trashed section' do it 'moves section back to origin chapter at origin position' do entry = create(:entry, type_name: 'scrolled') From fece7e2c8f6a26495a091d0297022e2eee723f98 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:27:29 +0200 Subject: [PATCH 19/29] Accept late updates of trashed content elements Inline text edits are debounced in the preview and flushed when the content element unmounts, which happens only after the batch moved it to the trash. Letting the update endpoint find trashed content elements of the draft writes those last edits into the trashed copy instead of dropping them. --- .../editor/content_elements_controller.rb | 3 ++- .../pageflow_scrolled/content_element.rb | 5 +++- .../content_elements_controller_spec.rb | 23 +++++++++++++++++++ .../pageflow_scrolled/content_element_spec.rb | 14 +++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb index b3ccf3f153..da7ea53ae9 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb @@ -31,7 +31,8 @@ def batch end def update - content_element = ContentElement.all_for_revision(@entry.draft).find(params[:id]) + content_element = + ContentElement.all_for_revision_including_trash(@entry.draft).find(params[:id]) ContentElement.transaction do content_element.update(content_element_params) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb index a9a0fd8228..48c933e6e9 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb @@ -19,9 +19,12 @@ def entry_for_auto_generated_perma_id end def self.all_for_revision(revision) + all_for_revision_including_trash(revision).merge(Storyline.excluding_trash) + end + + def self.all_for_revision_including_trash(revision) joins(section: {chapter: {storyline: :revision}}) .where(pageflow_scrolled_storylines: {revision_id: revision}) - .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb index 95ed8c3148..97bcffc273 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb @@ -634,6 +634,29 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'allows updating trashed content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, :text_block, section:) + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + authorize_for_editor_controller(entry) + patch(:update, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: content_element, + content_element: { + configuration: {children: 'late edit'} + } + }, format: 'json') + + expect(content_element.reload.configuration).to eq('children' => 'late edit') + expect(response.status).to eq(204) + end + it 'does not allow updating a content element from a different entry' do entry = create(:entry, type_name: 'scrolled') other_entry = create(:entry, type_name: 'scrolled') diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb index 1b2be32fa3..406841f0c3 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb @@ -28,5 +28,19 @@ module PageflowScrolled expect(result).to eq([content_element]) end end + + describe '.all_for_revision_including_trash' do + it 'includes content elements in trash storyline' do + revision = create(:revision) + content_element = create(:content_element, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + trash_chapter = create(:scrolled_chapter, storyline: trash_storyline) + trashed = create(:content_element, section: create(:section, chapter: trash_chapter)) + + result = ContentElement.all_for_revision_including_trash(revision) + + expect(result).to eq([content_element, trashed]) + end + end end end From cd7f1e15b079e8ea743430b80a342158d7c92a90 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:28:35 +0200 Subject: [PATCH 20/29] Restore content elements from the content trash Inserts the trashed content elements into their origin section at the recorded position, shifting later elements, and destroys the container chapter and section. If the origin section is gone or itself in the trash, the elements are appended to the first section instead. Responds with the restored content elements. --- .../editor/trash_items_controller.rb | 8 +- .../app/models/pageflow_scrolled/section.rb | 12 +++ .../models/pageflow_scrolled/trash/item.rb | 24 ++++++ .../_content_elements.json.jbuilder | 3 + .../editor/trash_items_controller_spec.rb | 21 +++++ .../models/pageflow_scrolled/trash_spec.rb | 76 +++++++++++++++++++ 6 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb index 9223d51ee4..894034a9c4 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -11,12 +11,16 @@ def index def restore restored = Trash.new(@entry.draft).find_item(params[:id]).restore - if restored.is_a?(Chapter) + case restored + when Chapter render partial: 'pageflow_scrolled/editor/chapters/chapter_with_sections', locals: {chapter: restored} - else + when Section render partial: 'pageflow_scrolled/editor/sections/section_with_content_elements', locals: {section: restored} + else + render partial: 'pageflow_scrolled/editor/trash_items/content_elements', + locals: {content_elements: restored} end rescue ActiveRecord::RecordNotFound head :not_found diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb index 144a2b8a84..8f1e951ed2 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb @@ -18,6 +18,18 @@ class Section < Pageflow::ApplicationRecord # rubocop:todo Style/Documentation nested_revision_components :content_elements + def insert_content_elements(inserted, position:) + transaction do + content_elements + .where('position >= ?', position) + .update_all(['position = position + ?', inserted.size]) + + inserted.each_with_index do |content_element, index| + content_element.update!(section: self, position: position + index) + end + end + end + def entry_for_auto_generated_perma_id chapter.storyline.revision.entry end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb index 8b90e66d13..ecd71c5d99 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -33,6 +33,7 @@ def restore Storyline.transaction do case kind when 'chapter' then restore_chapter + when 'contentElements' then restore_content_elements else restore_section end end @@ -60,6 +61,15 @@ def restore_section section end + def restore_content_elements + content_elements = sections.first.content_elements.to_a + + insert_into_origin_section(content_elements) + @chapter.reload.destroy + + content_elements + end + def insert_into_origin_chapter(section) chapters = Chapter.all_for_revision(@revision) @@ -71,6 +81,20 @@ def insert_into_origin_chapter(section) end end + def insert_into_origin_section(content_elements) + sections = Section.all_for_revision(@revision) + + if (section = sections.find_by(perma_id: origin_perma_id)) + section.insert_content_elements(content_elements, position: origin_position) + else + section = sections.first || raise(DestinationMissing) + section.insert_content_elements( + content_elements, + position: (section.content_elements.maximum(:position) || -1) + 1 + ) + end + end + def origin_storyline Storyline.all_for_revision(@revision).find_by(perma_id: origin_perma_id) || raise(DestinationMissing) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder new file mode 100644 index 0000000000..96cbb89c35 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder @@ -0,0 +1,3 @@ +json.array!(content_elements) do |content_element| + json.partial!('pageflow_scrolled/content_elements/content_element', content_element:) +end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb index 1faabbff13..05149b8f41 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -158,6 +158,27 @@ module PageflowScrolled expect(JSON.parse(response.body)['configuration']).not_to have_key('trash') end + it 'restores content elements and renders them' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, + id: content_element.reload.section.chapter_id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + [{id: content_element.id, permaId: content_element.perma_id, + sectionId: section.id, position: 0}] + ) + end + it 'responds with unprocessable entity if no chapter is left' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb index 227b738083..74eda4976e 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -365,6 +365,82 @@ module PageflowScrolled end end + describe '#restore with trashed content elements' do + it 'moves content elements back to origin section at origin position' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + first = create(:content_element, section:, position: 0) + trashed = create_list(:content_element, 2, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements(trashed, origin_section: section, origin_position: 1) + last = create(:content_element, section:, position: 1) + + trash.find_item(trashed.first.reload.section.chapter_id).restore + + expect(section.reload.content_elements).to eq([first, *trashed, last]) + expect(section.content_elements.map(&:position)).to eq([0, 1, 2, 3]) + end + + it 'destroys container chapter and section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + container_section = content_element.reload.section + + trash.find_item(container_section.chapter_id).restore + + expect(trash.items).to be_empty + expect(Section.exists?(container_section.id)).to eq(false) + end + + it 'returns restored content elements keeping their perma_ids' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + + result = trash.find_item(content_element.reload.section.chapter_id).restore + + expect(result).to eq([content_element]) + expect(result.first.perma_id).to eq(content_element.perma_id) + end + + it 'appends content elements to first section if origin section has been trashed' do + entry = create(:entry, type_name: 'scrolled') + first_section = create(:section, revision: entry.draft) + existing = create(:content_element, section: first_section, position: 0) + section = create(:section, chapter: first_section.chapter, position: 1) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + trash.add_section(section) + + trash.find_item(content_element.reload.section.chapter_id).restore + + expect(first_section.reload.content_elements).to eq([existing, content_element]) + end + + it 'raises if no section is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + trash.add_section(section) + + expect { + trash.find_item(content_element.reload.section.chapter_id).restore + }.to raise_error(Trash::DestinationMissing) + end + end + describe '#items' do it 'returns items newest first' do entry = create(:entry, type_name: 'scrolled') From dedba64f489879c1ab4de07d351a1af6fc2e4bfb Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:29:26 +0200 Subject: [PATCH 21/29] Send content elements deleted by the user to the content trash Deleting a content element flags its batch item with _trash. Adjacent content elements removed by merging stay plain deletions. --- .../models/ScrolledEntry/deleteContentElement-spec.js | 10 +++++----- .../package/src/editor/models/ScrolledEntry/Batch.js | 10 ++++++++-- .../models/ScrolledEntry/deleteContentElement.js | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) 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 c07f8086d0..37932e98cc 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 @@ -34,7 +34,7 @@ describe('ScrolledEntry', () => { entry: () => testContext.entry }); - it('sends item with delete flag to batch endpoint', () => { + it('sends item with delete and trash flags to batch endpoint', () => { const {entry, requests} = testContext; entry.deleteContentElement(entry.contentElements.get(5)); @@ -43,7 +43,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toMatchObject({ content_elements: [ {id: 6}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); @@ -119,7 +119,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toEqual({ content_elements: [ {id: 4, configuration: {items: ['a', 'b', 'c']}}, - {id: 5, _delete: true}, + {id: 5, _delete: true, _trash: true}, {id: 6, _delete: true} ] }); @@ -199,7 +199,7 @@ describe('ScrolledEntry', () => { content_elements: [ {id: 4}, {id: 6}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); }); @@ -251,7 +251,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toEqual({ content_elements: [ {id: 4}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); }); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js index 94a64dc69b..4e19b61a8e 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js @@ -16,6 +16,7 @@ export function Batch(entry, section, {reviewSession} = {}) { // Content elements that have been removed from contentElements // and shall be deleted on the server. const markedForDeletion = []; + const markedForTrash = new Set(); // New ranges for comment threads, keyed by threadId. Includes the // post-shift range of threads being migrated. @@ -201,9 +202,13 @@ export function Batch(entry, section, {reviewSession} = {}) { } } - function markForDeletion(contentElement) { + function markForDeletion(contentElement, {trash} = {}) { isDirty = true; markedForDeletion.push(contentElement); + + if (trash) { + markedForTrash.add(contentElement); + } } function remove(contentElement) { @@ -340,7 +345,8 @@ export function Batch(entry, section, {reviewSession} = {}) { }), ...markedForDeletion.map(contentElement => ({ id: contentElement.id, - _delete: true + _delete: true, + ...(markedForTrash.has(contentElement) && {_trash: true}) })) ]; } diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js index 6007ecd295..feb77b41c5 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js @@ -8,7 +8,7 @@ export function deleteContentElement(entry, contentElement) { const [before, after] = batch.getAdjacent(contentElement); batch.remove(contentElement); - batch.markForDeletion(contentElement); + batch.markForDeletion(contentElement, {trash: true}); batch.maybeMerge(before, after); batch.save(); From df2f2d898fa41c4d67d6e86a0b7626a82b797262 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:30:34 +0200 Subject: [PATCH 22/29] Restore content elements from the content trash in the editor Restoring trashed content elements inserts the returned elements into their origin section at the recorded position, or appends them to the first section if the origin section is gone, matching the server. The first restored element is selected and scrolled into view. Trash items now resolve their restore target by kind. --- .../collections/TrashItemsCollection-spec.js | 101 ++++++++++++++++++ .../package/src/editor/models/Section.js | 28 +++++ .../package/src/editor/models/TrashItem.js | 59 +++++----- 3 files changed, 163 insertions(+), 25 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js index b34409aaaa..e1215dc1ff 100644 --- a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -291,4 +291,105 @@ describe('TrashItemsCollection', () => { expect(entry.trashItems.get(11)).toBeUndefined(); }); }); + + describe('#restore of content elements', () => { + beforeEach(() => { + testContext.entry = createEntry({ + sections: [{id: 10, permaId: 100}], + contentElements: [ + {id: 1, sectionId: 10, position: 0}, + {id: 3, sectionId: 10, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 20, kind: 'contentElements', originPermaId: 100, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredContentElements(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/20/restore', 200, [ + {id: 2, permaId: 22, sectionId: 10, position: 1, typeName: 'heading', + configuration: {}} + ]); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(20).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/20/restore'); + }); + + it('inserts restored content elements into origin section', async () => { + const {entry, server} = testContext; + const section = entry.sections.get(10); + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(section.contentElements.pluck('id')).toEqual([1, 2, 3]); + expect(section.contentElements.pluck('position')).toEqual([0, 1, 2]); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(entry.trashItems.get(20)).toBeUndefined(); + }); + + it('selects and scrolls to first restored content element', async () => { + const {entry, server} = testContext; + const events = []; + entry.on('selectContentElement scrollToContentElement', + contentElement => events.push(contentElement.id)); + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(events).toEqual([2, 2]); + }); + + it('appends content elements to first section if origin section is missing', async () => { + const {entry, server} = testContext; + entry.trashItems.add({id: 21, kind: 'contentElements', originPermaId: 999, + originPosition: 0, sections: []}); + + const promise = entry.trashItems.get(21).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/21/restore', 200, [ + {id: 4, permaId: 44, sectionId: 10, position: 2, typeName: 'heading', configuration: {}} + ]); + await promise; + + expect(entry.sections.get(10).contentElements.pluck('id')).toEqual([1, 3, 4]); + expect(entry.sections.get(10).contentElements.pluck('position')).toEqual([0, 1, 2]); + }); + + it('rejects without request if no section exists', async () => { + const {requests} = testContext; + const entry = createEntry({}); + entry.trashItems.add({id: 21, kind: 'contentElements', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(21).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('keeps trash item if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(20).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/20/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.trashItems.pluck('id')).toEqual([20]); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/models/Section.js b/entry_types/scrolled/package/src/editor/models/Section.js index 2bde77a28e..2d27555206 100644 --- a/entry_types/scrolled/package/src/editor/models/Section.js +++ b/entry_types/scrolled/package/src/editor/models/Section.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Backbone from 'backbone'; import I18n from 'i18n-js'; import {getAvailableTransitionNames} from 'pageflow-scrolled/frontend'; @@ -36,6 +37,33 @@ export const Section = Backbone.Model.extend({ }); }, + restoreContentElements(trashItem, {position}) { + return Promise.resolve($.ajax({ + url: `${trashItem.url()}/restore`, + type: 'POST', + dataType: 'json' + })).then(contentElements => { + this.contentElements.each(contentElement => { + if (contentElement.get('position') >= position) { + contentElement.set('position', + contentElement.get('position') + contentElements.length, + {autoSave: false}); + } + }); + + const {entry} = this.chapter; + entry.contentElements.add(contentElements); + this.contentElements.sort(); + + const first = contentElements.length && entry.contentElements.get(contentElements[0].id); + + if (first) { + entry.trigger('selectContentElement', first); + entry.trigger('scrollToContentElement', first); + } + }); + }, + chapterPosition: function() { return this.chapter && this.chapter.has('position') ? this.chapter.get('position') : -1; }, diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js index abbb907f2a..89cf779d67 100644 --- a/entry_types/scrolled/package/src/editor/models/TrashItem.js +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -19,38 +19,19 @@ export const TrashItem = Backbone.Model.extend({ }, restore() { - const restored = this.get('kind') === 'chapter' ? - this.restoreIntoStoryline() : - this.restoreIntoChapter(); + const {target, restoreInto} = kinds[this.get('kind')]; + const {parent, position} = target(this.collection.entry, this); - if (!restored) { + if (!parent) { return Promise.reject(new Error('Nothing to restore into.')); } - return new Promise((resolve, reject) => { - restored.once('sync', () => { - this.collection.remove(this); - resolve(restored); - }); - restored.once('error', reject); + return restoreInto(parent, this, position).then(restored => { + this.collection.remove(this); + return restored; }); }, - restoreIntoStoryline() { - const storyline = this.collection.entry.storylines.findWhere({ - permaId: this.get('originPermaId') - }); - - return storyline && storyline.restoreChapter(this); - }, - - restoreIntoChapter() { - const {parent, position} = this.restoreTarget(this.collection.entry.chapters, - chapter => chapter.sections); - - return parent && parent.restoreSection(this, {position}); - }, - restoreTarget(parents, getChildren) { const origin = parents.findWhere({permaId: this.get('originPermaId')}); @@ -66,3 +47,31 @@ export const TrashItem = Backbone.Model.extend({ }; } }); + +const kinds = { + chapter: { + target: (entry, trashItem) => ({ + parent: entry.storylines.findWhere({permaId: trashItem.get('originPermaId')}) + }), + restoreInto: (storyline, trashItem) => whenSynced(storyline.restoreChapter(trashItem)) + }, + section: { + target: (entry, trashItem) => + trashItem.restoreTarget(entry.chapters, chapter => chapter.sections), + restoreInto: (chapter, trashItem, position) => + whenSynced(chapter.restoreSection(trashItem, {position})) + }, + contentElements: { + target: (entry, trashItem) => + trashItem.restoreTarget(entry.sections, section => section.contentElements), + restoreInto: (section, trashItem, position) => + section.restoreContentElements(trashItem, {position}) + } +}; + +function whenSynced(model) { + return new Promise((resolve, reject) => { + model.once('sync', () => resolve(model)); + model.once('error', reject); + }); +} From e55cb358f3e964d80c27e5ee51807b43d67db5bb Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 11:31:42 +0200 Subject: [PATCH 23/29] Label trashed content elements with types and origin section Content element trash items name the types of the deleted elements and the section they were deleted from. Their previews render the container section full height with the theme's smallest non-zero paddings so that less of the elements is clipped. The stored container section is left unchanged. Failing restores show a content element specific error. The trash hint now mentions content elements as well. --- entry_types/scrolled/config/locales/de.yml | 5 +- entry_types/scrolled/config/locales/en.yml | 5 +- .../collections/TrashItemsCollection-spec.js | 57 ++++++++++++++ .../spec/editor/views/TrashView-spec.js | 77 ++++++++++++++++++- .../package/src/editor/models/TrashItem.js | 28 ++++++- .../package/src/editor/views/TrashView.js | 44 +++++++++-- 6 files changed, 206 insertions(+), 10 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 50762a63ff..1ddc98cdff 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1788,12 +1788,15 @@ de: chapter: one: "Kapitel „%{title}“ mit 1 Abschnitt" other: "Kapitel „%{title}“ mit %{count} Abschnitten" - hint: Aus diesem Beitrag gelöschte Kapitel und Abschnitte können hier wiederhergestellt werden. + content_elements: "%{types} aus %{section}" + content_elements_origin_missing: "%{types} aus einem gelöschten Abschnitt" + hint: Aus diesem Beitrag gelöschte Kapitel, Abschnitte und Inhaltselemente können hier wiederhergestellt werden. load_failed: Gelöschte Inhalte konnten nicht geladen werden. origin: "Abschnitt aus %{chapter}" origin_missing: Abschnitt aus einem gelöschten Kapitel restore: Wiederherstellen restore_chapter_failed: Das Kapitel konnte nicht wiederhergestellt werden. + restore_content_elements_failed: Die Inhaltselemente konnten nicht wiederhergestellt werden. restore_failed: Der Abschnitt konnte nicht wiederhergestellt werden. trashed_at: "Gelöscht %{time}" configuration_places: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 48dc950883..a4f4aa0f19 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1768,12 +1768,15 @@ en: chapter: one: "Chapter “%{title}” with 1 section" other: "Chapter “%{title}” with %{count} sections" - hint: Chapters and sections deleted from this story can be restored here. + content_elements: "%{types} from %{section}" + content_elements_origin_missing: "%{types} from a deleted section" + hint: Chapters, sections and content elements deleted from this story can be restored here. load_failed: Deleted content could not be loaded. origin: "Section from %{chapter}" origin_missing: Section from a deleted chapter restore: Restore restore_chapter_failed: Could not restore the chapter. + restore_content_elements_failed: Could not restore the content elements. restore_failed: Could not restore the section. trashed_at: "Deleted %{time}" configuration_places: diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js index e1215dc1ff..04d9438261 100644 --- a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -47,6 +47,63 @@ describe('TrashItemsCollection', () => { .toEqual([{id: 60, permaId: 600, sectionId: 50}]); }); + it('renders content elements in full height section with small paddings in preview seed', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + kind: 'contentElements', + sections: [ + {id: 50, permaId: 500, configuration: {layout: 'right', invert: true}, + contentElements: []} + ] + }); + const entrySeed = { + config: { + theme: { + options: { + properties: { + root: { + 'sectionPaddingTop-none': '0', + 'sectionPaddingTop-sm': '1em', + 'sectionPaddingTop-lg': '4em', + 'sectionPaddingBottom-none': '0', + 'sectionPaddingBottom-xs': '0.5em' + } + } + } + } + }, + collections: {} + }; + + const seed = entry.trashItems.get(5).seed(entrySeed); + + expect(seed.collections.sections[0].configuration).toEqual({ + layout: 'right', + invert: true, + fullHeight: true, + paddingTop: 'sm', + paddingBottom: 'xs' + }); + }); + + it('keeps section configuration of section items in preview seed', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + kind: 'section', + sections: [ + {id: 50, permaId: 500, configuration: {layout: 'left'}, contentElements: []} + ] + }); + + const seed = entry.trashItems.get(5).seed({config: {}, collections: {}}); + + expect(seed.collections.sections[0].configuration).toEqual({layout: 'left'}); + }); + describe('#restore', () => { beforeEach(() => { testContext.entry = createEntry({ diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js index 676e6e50cf..152620e490 100644 --- a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -34,7 +34,16 @@ describe('TrashView', () => { 'pageflow_scrolled.editor.trash_view.chapter.one': 'Chapter %{title} with 1 section', 'pageflow_scrolled.editor.trash_view.chapter.other': 'Chapter %{title} with %{count} sections', - 'pageflow_scrolled.editor.chapter_item.unnamed': 'Untitled' + 'pageflow_scrolled.editor.chapter_item.unnamed': 'Untitled', + 'pageflow_scrolled.editor.trash_view.content_elements': '%{types} from %{section}', + 'pageflow_scrolled.editor.trash_view.content_elements_origin_missing': + '%{types} from deleted section', + 'pageflow_scrolled.editor.trash_view.restore_content_elements_failed': + 'Could not restore elements', + 'pageflow_scrolled.editor.configuration_places.label': '%{chapter} - %{subject}', + 'pageflow_scrolled.editor.configuration_places.section': 'Section %{number}', + 'pageflow_scrolled.editor.content_elements.heading.name': 'Heading', + 'pageflow_scrolled.editor.content_elements.inlineImage.name': 'Image' }); function trashItem({id, originPermaId, sectionId = id * 10}) { @@ -212,4 +221,70 @@ describe('TrashView', () => { .toBeInTheDocument() ); }); + + function contentElementsTrashItem({id, originPermaId}) { + return { + ...trashItem({id}), + kind: 'contentElements', + originPermaId, + sections: [ + { + id: id * 10, permaId: id * 10, chapterId: id, configuration: {}, + contentElements: [ + {id: 1, permaId: 1, typeName: 'heading', configuration: {}}, + {id: 2, permaId: 2, typeName: 'inlineImage', configuration: {}}, + {id: 3, permaId: 3, typeName: 'heading', configuration: {}} + ] + } + ] + }; + } + + function renderWithSections(trashItems) { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}], + sections: [ + {id: 10, permaId: 1000, chapterId: 1, position: 0}, + {id: 11, permaId: 1100, chapterId: 1, position: 1} + ] + }); + + const result = renderBackboneView(new TrashView({entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify(trashItems)] + ); + + return result; + } + + it('labels trashed content elements with type names and origin section', () => { + const {getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}), + contentElementsTrashItem({id: 6, originPermaId: 999}) + ]); + + const items = getAllByRole('listitem'); + + expect(items[0]).toHaveTextContent('Heading, Image from Intro - Section 2'); + expect(items[1]).toHaveTextContent('Heading, Image from deleted section'); + }); + + it('displays content element specific error when restore fails', async () => { + const {getByRole, getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}) + ]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + testContext.server.respond( + 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', + [422, {'Content-Type': 'application/json'}, '{}'] + ); + + await waitFor(() => + expect(within(getAllByRole('listitem')[0]).getByText('Could not restore elements')) + .toBeInTheDocument() + ); + }); }); diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js index 89cf779d67..75f5836d6b 100644 --- a/entry_types/scrolled/package/src/editor/models/TrashItem.js +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -1,5 +1,7 @@ import Backbone from 'backbone'; +import {Scale} from '../../shared/Scale'; + export const TrashItem = Backbone.Model.extend({ firstSectionPermaId() { return this.get('sections')[0]?.permaId; @@ -7,12 +9,15 @@ export const TrashItem = Backbone.Model.extend({ seed(entrySeed) { const sections = this.get('sections'); + const previewSection = this.get('kind') === 'contentElements' ? + withContentElementsPreviewConfiguration(entrySeed) : + section => section; return { ...entrySeed, collections: { ...entrySeed.collections, - sections: sections.map(({contentElements, ...section}) => section), + sections: sections.map(({contentElements, ...section}) => previewSection(section)), contentElements: sections.flatMap(section => section.contentElements) } }; @@ -75,3 +80,24 @@ function whenSynced(model) { model.once('error', reject); }); } + +function withContentElementsPreviewConfiguration(entrySeed) { + const themeProperties = entrySeed.config.theme?.options?.properties || {}; + const scale = scaleName => Scale({scaleName, themeProperties, scaleTranslations: {}}); + + const previewConfiguration = { + fullHeight: true, + paddingTop: smallestNonZeroValue(scale('sectionPaddingTop')), + paddingBottom: smallestNonZeroValue(scale('sectionPaddingBottom')) + }; + + return section => ({ + ...section, + configuration: {...section.configuration, ...previewConfiguration} + }); +} + +function smallestNonZeroValue({values, cssValues}) { + const index = cssValues.findIndex(cssValue => parseFloat(cssValue) !== 0); + return values[index >= 0 ? index : 0]; +} diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js index de68f39b22..c415f795bb 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.js +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -97,9 +97,7 @@ const TrashItemView = Marionette.ItemView.extend({ this.model.restore().catch(() => { this.ui.restore.prop('disabled', false); - this.ui.error.text(t(this.model.get('kind') === 'chapter' ? - 'restore_chapter_failed' : - 'restore_failed')); + this.ui.error.text(t(restoreFailedKeys[this.model.get('kind')])); }); } }), @@ -130,6 +128,9 @@ const TrashItemView = Marionette.ItemView.extend({ }, originLabel() { + const {entry} = this.options; + const originPermaId = this.model.get('originPermaId'); + if (this.model.get('kind') === 'chapter') { return t('chapter', { title: this.model.get('title') || @@ -137,13 +138,44 @@ const TrashItemView = Marionette.ItemView.extend({ count: this.model.get('sections').length }); } + else if (this.model.get('kind') === 'contentElements') { + const types = contentElementTypeNames(this.model); + const section = entry.sections.findWhere({permaId: originPermaId}); - const chapter = this.options.entry.chapters.findWhere({ - permaId: this.model.get('originPermaId') - }); + return section ? + t('content_elements', {types, section: sectionName(section)}) : + t('content_elements_origin_missing', {types}); + } + + const chapter = entry.chapters.findWhere({permaId: originPermaId}); return chapter ? t('origin', {chapter: chapter.getDisplayName()}) : t('origin_missing'); } }); + +const restoreFailedKeys = { + chapter: 'restore_chapter_failed', + section: 'restore_failed', + contentElements: 'restore_content_elements_failed' +}; + +function contentElementTypeNames(trashItem) { + const typeNames = trashItem.get('sections').flatMap(section => + section.contentElements.map(contentElement => contentElement.typeName) + ); + + return [...new Set(typeNames)] + .map(typeName => I18n.t(`pageflow_scrolled.editor.content_elements.${typeName}.name`)) + .join(', '); +} + +function sectionName(section) { + return I18n.t('pageflow_scrolled.editor.configuration_places.label', { + chapter: section.chapter.getDisplayName(), + subject: I18n.t('pageflow_scrolled.editor.configuration_places.section', { + number: section.chapter.sections.indexOf(section) + 1 + }) + }); +} From 5eda5b0ac5cc478664d7b901618fd16fedc9ebb8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 13:56:04 +0200 Subject: [PATCH 24/29] Mention the content trash in delete confirmations Deleting chapters, sections and content elements is no longer final, so the confirmations point to the trash instead of warning that the operation cannot be undone. --- entry_types/scrolled/config/locales/de.yml | 12 +++++++++--- entry_types/scrolled/config/locales/en.yml | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 1ddc98cdff..fa20ad4a91 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1298,10 +1298,13 @@ de: confirm_destroy: |- Kapitel inklusive ALLER Abschnitte wirklich löschen? - Diese Aktion kann nicht rückgängig gemacht werden. + Es kann aus dem Papierkorb wiederhergestellt werden. destroy: Kapitel löschen destroy_content_element_menu_item: - confirm_destroy: Element wirklich löschen? + confirm_destroy: |- + Element wirklich löschen? + + Es kann aus dem Papierkorb wiederhergestellt werden. destroy: Element löschen selection_label: Auswahl löschen duplicate_content_element_menu_item: @@ -1311,7 +1314,10 @@ de: move: Element verschieben... move_selection: Auswahl verschieben... destroy_section_menu_item: - confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? + confirm_destroy: |- + Abschnitt inklusive aller Elemente wirklich löschen? + + Er kann aus dem Papierkorb wiederhergestellt werden. destroy: Abschnitt löschen edit_section: hidden_info: Dieser Abschnitt ist außerhalb des Editors ausgeblendet. diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index a4f4aa0f19..17dec29941 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1280,10 +1280,13 @@ en: confirm_destroy: |- Really delete this chapter including ALL its sections? - This operation cannot be undone. + It can be restored from the trash. destroy: Delete chapter destroy_content_element_menu_item: - confirm_destroy: Really delete this element? + confirm_destroy: |- + Really delete this element? + + It can be restored from the trash. destroy: Delete element selection_label: Delete selection duplicate_content_element_menu_item: @@ -1293,7 +1296,10 @@ en: move: Move element... move_selection: Move selection... destroy_section_menu_item: - confirm_destroy: Really delete this section including all its elements? + confirm_destroy: |- + Really delete this section including all its elements? + + It can be restored from the trash. destroy: Delete section edit_section: hidden_info: This section is hidden outside of the editor. From ade05fd76ca9481a53f2e09ebf476e3f767ba2a4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 16:32:34 +0200 Subject: [PATCH 25/29] Permanently delete content trash items Single trash items can be deleted and the whole trash can be emptied. Both destroy the trash item chapters with their sections and content elements and keep the trash storyline in place. There is no time based pruning: revisions already keep copies of everything. --- .../editor/trash_items_controller.rb | 14 +++ .../app/models/pageflow_scrolled/trash.rb | 4 + .../models/pageflow_scrolled/trash/item.rb | 4 + entry_types/scrolled/config/routes.rb | 6 +- .../editor/trash_items_controller_spec.rb | 88 +++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb index 894034a9c4..d034275daa 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -27,6 +27,20 @@ def restore rescue Trash::DestinationMissing head :unprocessable_entity end + + def destroy + Trash.new(@entry.draft).find_item(params[:id]).destroy + + head :no_content + rescue ActiveRecord::RecordNotFound + head :not_found + end + + def empty + Trash.new(@entry.draft).empty + + head :no_content + end end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb index ac2cd5b3e6..548078b2b1 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -55,6 +55,10 @@ def find_item(id) Item.new(chapters.find(id), @revision) end + def empty + chapters.destroy_all + end + private def chapters diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb index ecd71c5d99..491e638357 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -39,6 +39,10 @@ def restore end end + def destroy + @chapter.destroy + end + private def metadata diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 4856fe4df1..13d10efb8d 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -2,10 +2,14 @@ scope module: 'editor' do resources :fragment_libraries, only: [:index] resources :linkable_entries, only: [:index, :show] - resources :trash_items, only: [:index] do + resources :trash_items, only: [:index, :destroy] do member do post :restore end + + collection do + delete :empty + end end shallow do diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb index 05149b8f41..8cdb10148e 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -208,5 +208,93 @@ module PageflowScrolled expect(response.status).to eq(404) end end + + describe '#destroy' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + Trash.new(entry.draft).add_section(section) + + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(401) + end + + it 'permanently deletes trash item with its content' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + other_section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + trash.add_section(other_section) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(204) + expect(trash.items.map(&:id)).to eq([other_section.reload.chapter_id]) + expect(Section.exists?(section.id)).to eq(false) + expect(ContentElement.exists?(content_element.id)).to eq(false) + end + + it 'does not allow deleting trash items of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(404) + expect(Section.exists?(section.id)).to eq(true) + end + end + + describe '#empty' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(401) + end + + it 'permanently deletes all trash items' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + chapter = create(:scrolled_chapter, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + trash.add_chapter(chapter) + + authorize_for_editor_controller(entry) + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(204) + expect(trash.items).to be_empty + expect(Chapter.exists?(chapter.id)).to eq(false) + expect(Section.exists?(section.id)).to eq(false) + end + + it 'keeps content of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(Section.exists?(section.id)).to eq(true) + end + end end end From faf0e8aaffe91924aacfc62a83d5a868091953d1 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 16:33:17 +0200 Subject: [PATCH 26/29] Permanently delete content trash items in the editor Trash items can be deleted individually and the trash collection can be emptied. Items are only removed from the collection once the server confirmed the deletion. --- .../collections/TrashItemsCollection-spec.js | 54 +++++++++++++++++++ .../collections/TrashItemsCollection.js | 8 +++ .../package/src/editor/models/TrashItem.js | 4 ++ 3 files changed, 66 insertions(+) diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js index 04d9438261..2029b116f1 100644 --- a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -449,4 +449,58 @@ describe('TrashItemsCollection', () => { expect(entry.trashItems.pluck('id')).toEqual([20]); }); }); + + describe('#permanentlyDelete', () => { + beforeEach(() => { + testContext.entry = createEntry({}); + testContext.entry.trashItems.add([{id: 5, sections: []}, {id: 6, sections: []}]); + }); + + it('sends delete request for trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(5).permanentlyDelete(); + + expect(requests[0].method).toBe('DELETE'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/5'); + }); + + it('removes trash item once deleted', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).permanentlyDelete(); + expect(entry.trashItems.pluck('id')).toEqual([5, 6]); + server.respond('DELETE', '/editor/entries/1/scrolled/trash_items/5', [204, {}, '']); + await promise; + + expect(entry.trashItems.pluck('id')).toEqual([6]); + }); + }); + + describe('#empty', () => { + beforeEach(() => { + testContext.entry = createEntry({}); + testContext.entry.trashItems.add([{id: 5, sections: []}, {id: 6, sections: []}]); + }); + + it('sends delete request to empty endpoint', () => { + const {entry, requests} = testContext; + + entry.trashItems.empty(); + + expect(requests[0].method).toBe('DELETE'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/empty'); + }); + + it('removes all trash items once emptied', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.empty(); + expect(entry.trashItems.length).toEqual(2); + server.respond('DELETE', '/editor/entries/1/scrolled/trash_items/empty', [204, {}, '']); + await promise; + + expect(entry.trashItems.length).toEqual(0); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js index 543c7fb4c2..637f97a81e 100644 --- a/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Backbone from 'backbone'; import {entryTypeEditorControllerUrls} from 'pageflow/editor'; @@ -11,5 +12,12 @@ export const TrashItemsCollection = Backbone.Collection.extend({ initialize(models, {entry} = {}) { this.entry = entry; + }, + + empty() { + return Promise.resolve($.ajax({ + url: `${this.url()}/empty`, + type: 'DELETE' + })).then(() => this.remove(this.models.slice())); } }); diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js index 75f5836d6b..2f7b31bc51 100644 --- a/entry_types/scrolled/package/src/editor/models/TrashItem.js +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -37,6 +37,10 @@ export const TrashItem = Backbone.Model.extend({ }); }, + permanentlyDelete() { + return Promise.resolve(this.destroy({wait: true})); + }, + restoreTarget(parents, getChildren) { const origin = parents.findWhere({permaId: this.get('originPermaId')}); From 8a22e1e61a0ca307d8a538f10daa3fe05fe0aa5f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 16:35:24 +0200 Subject: [PATCH 27/29] Offer emptying the trash and deleting single trash items An Actions menu at the top of the trash view offers emptying the trash and each trash item gets a menu to delete it permanently. Both ask for confirmation and display an error if the request fails. --- entry_types/scrolled/config/locales/de.yml | 15 +++ entry_types/scrolled/config/locales/en.yml | 15 +++ .../spec/editor/views/TrashView-spec.js | 105 ++++++++++++++++-- .../package/src/editor/views/TrashView.js | 82 +++++++++++--- .../src/editor/views/TrashView.module.css | 28 ++++- 5 files changed, 218 insertions(+), 27 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index fa20ad4a91..e86c948022 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1789,6 +1789,7 @@ de: tabs: newComment: Neues Thema trash_view: + actions: Aktionen back: Gliederung blank_slate: Aus diesem Beitrag wurde noch nichts gelöscht. chapter: @@ -1796,6 +1797,20 @@ de: other: "Kapitel „%{title}“ mit %{count} Abschnitten" content_elements: "%{types} aus %{section}" content_elements_origin_missing: "%{types} aus einem gelöschten Abschnitt" + delete_item: + confirm_destroy: |- + Diesen Inhalt endgültig löschen? + + Diese Aktion kann nicht rückgängig gemacht werden. + destroy: Endgültig löschen + delete_failed: Der Inhalt konnte nicht gelöscht werden. + empty_failed: Der Papierkorb konnte nicht geleert werden. + empty_trash: + confirm_destroy: |- + Alle Inhalte im Papierkorb endgültig löschen? + + Diese Aktion kann nicht rückgängig gemacht werden. + destroy: Papierkorb leeren hint: Aus diesem Beitrag gelöschte Kapitel, Abschnitte und Inhaltselemente können hier wiederhergestellt werden. load_failed: Gelöschte Inhalte konnten nicht geladen werden. origin: "Abschnitt aus %{chapter}" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 17dec29941..6246f5faa3 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1769,6 +1769,7 @@ en: tabs: newComment: New topic trash_view: + actions: Actions back: Outline blank_slate: Nothing has been deleted from this story yet. chapter: @@ -1776,6 +1777,20 @@ en: other: "Chapter “%{title}” with %{count} sections" content_elements: "%{types} from %{section}" content_elements_origin_missing: "%{types} from a deleted section" + delete_item: + confirm_destroy: |- + Permanently delete this item? + + This cannot be undone. + destroy: Delete permanently + delete_failed: Could not delete the item. + empty_failed: Could not empty the trash. + empty_trash: + confirm_destroy: |- + Permanently delete all items in the trash? + + This cannot be undone. + destroy: Empty trash hint: Chapters, sections and content elements deleted from this story can be restored here. load_failed: Deleted content could not be loaded. origin: "Section from %{chapter}" diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js index 152620e490..e4ebc54918 100644 --- a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -4,6 +4,7 @@ import {fireEvent, waitFor, within} from '@testing-library/dom'; import {editor} from 'pageflow-scrolled/editor'; import {TrashView} from 'editor/views/TrashView'; +import styles from 'editor/views/TrashView.module.css'; import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; import {useEditorGlobals, useFakeXhr} from 'support'; @@ -24,6 +25,13 @@ describe('TrashView', () => { 'pageflow_scrolled.editor.trash_view.back': 'Outline', 'pageflow_scrolled.editor.trash_view.blank_slate': 'Nothing deleted', 'pageflow_scrolled.editor.trash_view.load_failed': 'Could not load', + 'pageflow_scrolled.editor.trash_view.actions': 'Actions', + 'pageflow_scrolled.editor.trash_view.empty_trash.destroy': 'Empty trash', + 'pageflow_scrolled.editor.trash_view.empty_trash.confirm_destroy': 'Empty?', + 'pageflow_scrolled.editor.trash_view.delete_item.destroy': 'Delete permanently', + 'pageflow_scrolled.editor.trash_view.delete_item.confirm_destroy': 'Delete?', + 'pageflow_scrolled.editor.trash_view.empty_failed': 'Could not empty', + 'pageflow_scrolled.editor.trash_view.delete_failed': 'Could not delete', 'pageflow_scrolled.editor.trash_view.hint': 'Deleted sections can be restored here.', 'pageflow_scrolled.editor.trash_view.origin': 'Section from %{chapter}', 'pageflow_scrolled.editor.trash_view.origin_missing': 'Section from deleted chapter', @@ -60,7 +68,11 @@ describe('TrashView', () => { }; } - function render(trashItems) { + function trashItems(getAllByRole) { + return getAllByRole('listitem').filter(item => item.classList.contains(styles.item)); + } + + function render(items) { const entry = createEntry({ chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}] }); @@ -70,7 +82,7 @@ describe('TrashView', () => { testContext.server.respond( 'GET', '/editor/entries/1/scrolled/trash_items', - [200, {'Content-Type': 'application/json'}, JSON.stringify(trashItems)] + [200, {'Content-Type': 'application/json'}, JSON.stringify(items)] ); return result; @@ -92,7 +104,7 @@ describe('TrashView', () => { trashItem({id: 6, originPermaId: 999}) ]); - const items = getAllByRole('listitem'); + const items = trashItems(getAllByRole); expect(items[0]).toHaveTextContent('Section from Intro'); expect(items[1]).toHaveTextContent('Section from deleted chapter'); @@ -101,7 +113,7 @@ describe('TrashView', () => { it('prefixes deletion time', () => { const {getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); - expect(getAllByRole('listitem')[0].querySelector('time')) + expect(trashItems(getAllByRole)[0].querySelector('time')) .toHaveTextContent(/^Deleted .*2026/); }); @@ -117,7 +129,7 @@ describe('TrashView', () => { ])] ); - expect(view.getAllByRole('listitem').map(item => item.textContent)) + expect(trashItems(view.getAllByRole).map(item => item.textContent)) .toEqual([ expect.stringContaining('Section from deleted chapter'), expect.stringContaining('Section from Intro') @@ -167,7 +179,7 @@ describe('TrashView', () => { {...trashItem({id: 6}), kind: 'chapter'} ]); - const items = getAllByRole('listitem'); + const items = trashItems(getAllByRole); expect(items[0]).toHaveTextContent('Chapter Outro with 2 sections'); expect(items[1]).toHaveTextContent('Chapter Untitled with 1 section'); @@ -204,7 +216,7 @@ describe('TrashView', () => { ); await waitFor(() => - expect(within(getAllByRole('listitem')[0]).getByText('Could not restore')) + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore')) .toBeInTheDocument() ); }); @@ -217,7 +229,7 @@ describe('TrashView', () => { fireEvent.click(getByRole('button', {name: 'Restore'})); await waitFor(() => - expect(within(getAllByRole('listitem')[0]).getByText('Could not restore chapter')) + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore chapter')) .toBeInTheDocument() ); }); @@ -240,7 +252,7 @@ describe('TrashView', () => { }; } - function renderWithSections(trashItems) { + function renderWithSections(items) { const entry = createEntry({ chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}], sections: [ @@ -253,7 +265,7 @@ describe('TrashView', () => { testContext.server.respond( 'GET', '/editor/entries/1/scrolled/trash_items', - [200, {'Content-Type': 'application/json'}, JSON.stringify(trashItems)] + [200, {'Content-Type': 'application/json'}, JSON.stringify(items)] ); return result; @@ -265,7 +277,7 @@ describe('TrashView', () => { contentElementsTrashItem({id: 6, originPermaId: 999}) ]); - const items = getAllByRole('listitem'); + const items = trashItems(getAllByRole); expect(items[0]).toHaveTextContent('Heading, Image from Intro - Section 2'); expect(items[1]).toHaveTextContent('Heading, Image from deleted section'); @@ -283,8 +295,77 @@ describe('TrashView', () => { ); await waitFor(() => - expect(within(getAllByRole('listitem')[0]).getByText('Could not restore elements')) + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore elements')) .toBeInTheDocument() ); }); + + describe('permanent deletion', () => { + beforeEach(() => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + window.confirm.mockRestore(); + }); + + it('empties trash via actions menu', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Empty trash'})); + + expect(window.confirm).toHaveBeenCalledWith('Empty?'); + expect(testContext.requests.at(-1)).toMatchObject({ + method: 'DELETE', + url: '/editor/entries/1/scrolled/trash_items/empty' + }); + }); + + it('deletes single item via item menu', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + + expect(window.confirm).toHaveBeenCalledWith('Delete?'); + expect(testContext.requests.at(-1)).toMatchObject({ + method: 'DELETE', + url: '/editor/entries/1/scrolled/trash_items/5' + }); + }); + + it('displays error when emptying trash fails', async () => { + const {getByRole, findByText} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Empty trash'})); + testContext.server.respond( + 'DELETE', '/editor/entries/1/scrolled/trash_items/empty', [500, {}, ''] + ); + + expect(await findByText('Could not empty')).toBeInTheDocument(); + }); + + it('displays error in item when deleting it fails', async () => { + const {getByRole, getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + testContext.server.respond( + 'DELETE', '/editor/entries/1/scrolled/trash_items/5', [500, {}, ''] + ); + + await waitFor(() => + expect(within(trashItems(getAllByRole)[0]).getByText('Could not delete')) + .toBeInTheDocument() + ); + }); + + it('does not delete if confirmation is canceled', () => { + window.confirm.mockReturnValue(false); + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + const requestCount = testContext.requests.length; + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + + expect(testContext.requests).toHaveLength(requestCount); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js index c415f795bb..1708a7bb39 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.js +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -1,9 +1,12 @@ import I18n from 'i18n-js'; +import Backbone from 'backbone'; import Marionette from 'backbone.marionette'; import React from 'react'; import ReactDOM from 'react-dom'; -import {editor, CollectionView, LoadingView} from 'pageflow/editor'; +import { + editor, CollectionView, DestroyMenuItem, DropDownButtonView, LoadingView +} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {StandaloneSectionThumbnail} from 'pageflow-scrolled/frontend'; import {watchFileCollections} from 'pageflow-scrolled/entryState'; @@ -14,12 +17,16 @@ const t = (key, options) => I18n.t(`pageflow_scrolled.editor.trash_view.${key}`, export const TrashView = Marionette.ItemView.extend({ template: () => ` - ${t('back')} +
    + ${t('back')} +
    +
    +

    ${t('hint')}

      `, - ui: cssModulesUtils.ui(styles, 'items'), + ui: cssModulesUtils.ui(styles, 'actions', 'actionError', 'items'), events: { 'click a.back': function() { @@ -32,6 +39,19 @@ export const TrashView = Marionette.ItemView.extend({ const {entry} = this.options; const loadingView = this.subview(new LoadingView()); + this.appendSubview(new DropDownButtonView({ + label: t('actions'), + alignMenu: 'right', + ellipsisIcon: true, + openOnClick: true, + items: new Backbone.Collection([ + new EmptyTrashMenuItem({}, { + trashItems: entry.trashItems, + onFailure: () => this.ui.actionError.text(t('empty_failed')) + }) + ]) + }), {to: this.ui.actions}); + this.ui.items.append(loadingView.render().el); entry.trashItems.fetch({reset: true}).then(() => { @@ -76,19 +96,23 @@ const TrashItemView = Marionette.ItemView.extend({ - +
      + +
      +
      `, - ui: cssModulesUtils.ui(styles, 'thumbnail', 'origin', 'trashedAt', 'error', 'restore'), + ui: cssModulesUtils.ui(styles, + 'thumbnail', 'origin', 'trashedAt', 'error', 'restore', 'itemMenu'), events: cssModulesUtils.events(styles, { 'click restore': function() { @@ -103,6 +127,20 @@ const TrashItemView = Marionette.ItemView.extend({ }), onRender() { + this.appendSubview(new DropDownButtonView({ + title: t('actions'), + alignMenu: 'right', + ellipsisIcon: true, + borderless: true, + openOnClick: true, + items: new Backbone.Collection([ + new DeleteTrashItemMenuItem({}, { + trashItem: this.model, + onFailure: () => this.ui.error.text(t('delete_failed')) + }) + ]) + }), {to: this.ui.itemMenu}); + this.ui.origin.text(this.originLabel()); this.ui.trashedAt .attr('datetime', this.model.get('trashedAt')) @@ -155,6 +193,22 @@ const TrashItemView = Marionette.ItemView.extend({ } }); +const EmptyTrashMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.trash_view.empty_trash', + + destroyModel() { + this.options.trashItems.empty().catch(this.options.onFailure); + } +}); + +const DeleteTrashItemMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.trash_view.delete_item', + + destroyModel() { + this.options.trashItem.permanentlyDelete().catch(this.options.onFailure); + } +}); + const restoreFailedKeys = { chapter: 'restore_chapter_failed', section: 'restore_failed', diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.module.css b/entry_types/scrolled/package/src/editor/views/TrashView.module.css index 27071c76c6..cac2987848 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.module.css +++ b/entry_types/scrolled/package/src/editor/views/TrashView.module.css @@ -1,3 +1,20 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.actions {} + +.actionError { + margin: space(4) 0 0; + color: var(--ui-error-color); +} + +.actionError:empty { + display: none; +} + .hint { composes: info_box info from global; margin: space(4) 0 0; @@ -59,9 +76,18 @@ display: none; } +.footer { + display: flex; + align-items: center; + justify-content: space-between; +} + .restore { composes: secondaryIconButton from './buttons.module.css'; - align-self: start; +} + +.itemMenu { + --ui-on-button-color: var(--ui-primary-color-light); } .restoreIcon { From 1c52822007b01519f06bf1efc1d2fbef4d8651dc Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 16:51:47 +0200 Subject: [PATCH 28/29] Nudge content trash button after deletions Successful deletions of chapters, sections and content elements mark the entry as having unseen trash items. When the trash button is rendered with the mark set, or right away while it is visible, a circle contracts onto it and the icon briefly takes the primary color. The mark is cleared. The circle lives in a container passed by the outline since the tab bar clips its content, and is positioned once the outline is attached. Reduced motion only tints the icon. --- .../ScrolledEntry/unseenTrashItems-spec.js | 62 +++++++++++++ .../spec/editor/views/TrashButtonView-spec.js | 87 ++++++++++++++++++- .../ScrolledEntry/deleteContentElement.js | 6 +- .../src/editor/models/ScrolledEntry/index.js | 7 ++ .../src/editor/views/EntryOutlineView.js | 5 +- .../src/editor/views/TrashButtonView.js | 51 +++++++++++ .../editor/views/TrashButtonView.module.css | 49 +++++++++++ 7 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js new file mode 100644 index 0000000000..678d2bfb3f --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js @@ -0,0 +1,62 @@ +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('ScrolledEntry hasUnseenTrashItems', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + beforeEach(() => { + testContext.entry = createEntry({ + chapters: [{id: 1, permaId: 10}], + sections: [{id: 10, permaId: 100, chapterId: 1}], + contentElements: [{id: 5, permaId: 50, sectionId: 10, typeName: 'heading'}] + }); + }); + + it('is set once a chapter has been deleted', () => { + const {entry, server} = testContext; + + entry.chapters.get(1).destroyWithDelay(); + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + server.respond('DELETE', '/editor/entries/1/scrolled/chapters/1', [204, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is set once a section has been deleted', () => { + const {entry, server} = testContext; + + entry.sections.get(10).destroyWithDelay(); + server.respond('DELETE', '/editor/entries/1/scrolled/sections/10', [204, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is set once a content element has been deleted', () => { + const {entry, server} = testContext; + + entry.deleteContentElement(entry.contentElements.get(5)); + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + server.respond( + 'PUT', '/editor/entries/1/scrolled/sections/10/content_elements/batch', + [200, {'Content-Type': 'application/json'}, '[]'] + ); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is not set if deletion fails', () => { + const {entry, server} = testContext; + + entry.sections.get(10).destroyWithDelay(); + server.respond('DELETE', '/editor/entries/1/scrolled/sections/10', [500, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js index bcf9d8816c..b902a8d1b9 100644 --- a/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js @@ -5,16 +5,31 @@ import {screen} from '@testing-library/dom'; import {editor} from 'pageflow-scrolled/editor'; import {TrashButtonView} from 'editor/views/TrashButtonView'; +import styles from 'editor/views/TrashButtonView.module.css'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; describe('TrashButtonView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' }); + const {createEntry} = useEditorGlobals(); + + function createView(entry = createEntry({})) { + const nudgeContainer = document.createElement('div'); + document.body.appendChild(nudgeContainer); + + return new TrashButtonView({entry, nudgeContainer}); + } + + function nextFrame() { + return new Promise(resolve => window.requestAnimationFrame(resolve)); + } + it('renders button with tooltip', () => { - render(new TrashButtonView()); + render(createView()); expect(screen.getByRole('button', {name: 'Deleted content'})) .toHaveAttribute('title', 'Deleted content'); @@ -23,11 +38,79 @@ describe('TrashButtonView', () => { it('navigates to trash when clicked', async () => { const user = userEvent.setup(); const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); - render(new TrashButtonView()); + render(createView()); await user.click(screen.getByRole('button', {name: 'Deleted content'})); expect(navigate).toHaveBeenCalledWith('/scrolled/trash', {trigger: true}); navigate.mockRestore(); }); + + describe('nudge', () => { + it('nudges when rendered with unseen trash items', async () => { + const entry = createEntry({}); + entry.set('hasUnseenTrashItems', true); + const view = createView(entry); + + render(view); + await nextFrame(); + + expect(view.el).toHaveClass(styles.nudging); + expect(entry.get('hasUnseenTrashItems')).toBe(false); + }); + + it('nudges when trash items become unseen while shown', async () => { + const entry = createEntry({}); + const view = createView(entry); + + render(view); + entry.set('hasUnseenTrashItems', true); + await nextFrame(); + + expect(view.el).toHaveClass(styles.nudging); + expect(entry.get('hasUnseenTrashItems')).toBe(false); + }); + + it('does not nudge without unseen trash items', async () => { + const view = createView(); + + render(view); + await nextFrame(); + + expect(view.el).not.toHaveClass(styles.nudging); + }); + + it('centers contracting circle on button inside nudge container', async () => { + const entry = createEntry({}); + const view = createView(entry); + render(view); + jest.spyOn(view.options.nudgeContainer, 'getBoundingClientRect') + .mockReturnValue({left: 100, top: 50, width: 300, height: 500}); + jest.spyOn(view.el, 'getBoundingClientRect') + .mockReturnValue({left: 350, top: 60, width: 32, height: 36}); + + entry.set('hasUnseenTrashItems', true); + await nextFrame(); + + const circle = view.options.nudgeContainer.querySelector(`.${styles.circle}`); + expect(circle).toHaveClass(styles.nudging); + expect(circle.style.left).toBe('266px'); + expect(circle.style.top).toBe('28px'); + }); + + it('stops nudging once icon animation ended', async () => { + const entry = createEntry({}); + entry.set('hasUnseenTrashItems', true); + const view = createView(entry); + + render(view); + await nextFrame(); + view.el.querySelector(`.${styles.icon}`) + .dispatchEvent(new Event('animationend', {bubbles: true})); + + expect(view.el).not.toHaveClass(styles.nudging); + expect(view.options.nudgeContainer.querySelector(`.${styles.circle}`)) + .not.toHaveClass(styles.nudging); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js index feb77b41c5..a05d688eb0 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js @@ -11,5 +11,9 @@ export function deleteContentElement(entry, contentElement) { batch.markForDeletion(contentElement, {trash: true}); batch.maybeMerge(before, after); - batch.save(); + batch.save({ + success() { + entry.markUnseenTrashItems(); + } + }); } 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 726627e8f6..ded7a10fad 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -94,6 +94,9 @@ export const ScrolledEntry = Entry.extend({ editor.failures.watch(this.sections); editor.failures.watch(this.chapters); + this.listenTo(this.chapters, 'destroy', this.markUnseenTrashItems); + this.listenTo(this.sections, 'destroy', this.markUnseenTrashItems); + editor.savingRecords.watch(this.contentElements); editor.savingRecords.watch(this.sections); editor.savingRecords.watch(this.chapters); @@ -173,6 +176,10 @@ export const ScrolledEntry = Entry.extend({ updateContentElement(this, contentElement, configuration, options); }, + markUnseenTrashItems() { + this.set('hasUnseenTrashItems', true); + }, + deleteContentElement(contentElement) { deleteContentElement(this, contentElement); }, diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 770d0af628..79af1c5f0d 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -24,7 +24,10 @@ export const EntryOutlineView = Marionette.Layout.extend({ ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), onRender() { - this.ui.toolbar.prepend(this.subview(new TrashButtonView()).render().el); + this.ui.toolbar.prepend(this.subview(new TrashButtonView({ + entry: this.options.entry, + nudgeContainer: this.el + })).render().el); const viewModel = new Backbone.Model({collapsed: false}); const dropDownMenuItems = new Backbone.Collection(); diff --git a/entry_types/scrolled/package/src/editor/views/TrashButtonView.js b/entry_types/scrolled/package/src/editor/views/TrashButtonView.js index 2f648a29ff..15624b4b55 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashButtonView.js +++ b/entry_types/scrolled/package/src/editor/views/TrashButtonView.js @@ -1,5 +1,7 @@ +import $ from 'jquery'; import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; +import {cssModulesUtils} from 'pageflow/ui'; import {editor} from 'pageflow-scrolled/editor'; import styles from './TrashButtonView.module.css'; @@ -32,6 +34,55 @@ export const TrashButtonView = Marionette.ItemView.extend({ events: { click() { editor.navigate('/scrolled/trash', {trigger: true}); + }, + + ...cssModulesUtils.events(styles, { + 'animationend icon': function() { + this.$el.removeClass(styles.nudging); + this.circle?.removeClass(styles.nudging); + } + }) + }, + + initialize() { + this.listenTo(this.options.entry, 'change:hasUnseenTrashItems', this.nudge); + }, + + onRender() { + this.nudge(); + }, + + onClose() { + this.circle?.remove(); + }, + + nudge() { + const {entry, nudgeContainer} = this.options; + + if (!entry.get('hasUnseenTrashItems')) { + return; } + + entry.set('hasUnseenTrashItems', false); + + window.requestAnimationFrame(() => { + if (this.isClosed) { + return; + } + + const containerRect = nudgeContainer.getBoundingClientRect(); + const buttonRect = this.el.getBoundingClientRect(); + + this.circle = this.circle || $(`
      `) + .appendTo(nudgeContainer); + + this.circle.css({ + left: buttonRect.left - containerRect.left + buttonRect.width / 2, + top: buttonRect.top - containerRect.top + buttonRect.height / 2 + }); + + this.circle.addClass(styles.nudging); + this.$el.addClass(styles.nudging); + }); } }); diff --git a/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css index 7509be377b..83c5d89d1d 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css +++ b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css @@ -18,3 +18,52 @@ .button:focus-visible { color: var(--ui-primary-color); } + +.circle { + position: absolute; + z-index: 1; + width: 10px; + height: 10px; + margin: -5px; + border-radius: 50%; + background-color: var(--ui-primary-color); + opacity: 0; + pointer-events: none; +} + +.circle.nudging { + animation: contract 0.35s ease-in 0.5s; +} + +.nudging .icon { + animation: tint 0.5s ease-out 0.5s; +} + +@keyframes contract { + from { + transform: scale(40); + opacity: 0; + } + + 50% { + opacity: 0.1; + } + + to { + transform: scale(1); + opacity: 0.5; + } +} + +@keyframes tint { + 60%, + 80% { + color: var(--ui-primary-color); + } +} + +@media (prefers-reduced-motion: reduce) { + .circle.nudging { + animation: none; + } +} From 14e54f552ae1bb152287d56b6f832e287309dab5 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 22 Oct 2026 18:02:53 +0200 Subject: [PATCH 29/29] Render trashed content elements on checkerboard backdrop The default backdrop background can be overridden via the --backdrop-default-background custom property. Previews of trashed content elements use it to show a checkerboard, which makes clear that the elements are rendered out of their original section. --- .../package/spec/editor/views/TrashView-spec.js | 12 ++++++++++++ .../scrolled/package/src/editor/views/TrashView.js | 3 +++ .../package/src/editor/views/TrashView.module.css | 9 +++++++++ .../package/src/frontend/Backdrop.module.css | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js index e4ebc54918..c4644af677 100644 --- a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -283,6 +283,18 @@ describe('TrashView', () => { expect(items[1]).toHaveTextContent('Heading, Image from deleted section'); }); + it('marks content element items to render their preview out of context', () => { + const {getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}), + {...trashItem({id: 6}), originPermaId: 100} + ]); + + const items = trashItems(getAllByRole); + + expect(items[0]).toHaveClass(styles.contentElementsItem); + expect(items[1]).not.toHaveClass(styles.contentElementsItem); + }); + it('displays content element specific error when restore fails', async () => { const {getByRole, getAllByRole} = renderWithSections([ contentElementsTrashItem({id: 5, originPermaId: 1100}) diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js index 1708a7bb39..8674c5d2c2 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.js +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -127,6 +127,9 @@ const TrashItemView = Marionette.ItemView.extend({ }), onRender() { + this.$el.toggleClass(styles.contentElementsItem, + this.model.get('kind') === 'contentElements'); + this.appendSubview(new DropDownButtonView({ title: t('actions'), alignMenu: 'right', diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.module.css b/entry_types/scrolled/package/src/editor/views/TrashView.module.css index cac2987848..f9ac5d43c3 100644 --- a/entry_types/scrolled/package/src/editor/views/TrashView.module.css +++ b/entry_types/scrolled/package/src/editor/views/TrashView.module.css @@ -53,6 +53,15 @@ border: solid 1px var(--ui-on-surface-color-lighter); } +.contentElementsItem .thumbnail { + --backdrop-default-background: + linear-gradient(45deg, #e5e5e5 25%, transparent 25%) 0 0 / 96px 96px, + linear-gradient(135deg, #e5e5e5 25%, transparent 25%) 48px 0 / 96px 96px, + linear-gradient(45deg, transparent 75%, #e5e5e5 75%) 48px -48px / 96px 96px, + linear-gradient(135deg, transparent 75%, #e5e5e5 75%) 0 48px / 96px 96px, + #fff; +} + .details { display: flex; flex-direction: column; diff --git a/entry_types/scrolled/package/src/frontend/Backdrop.module.css b/entry_types/scrolled/package/src/frontend/Backdrop.module.css index 4d6d24ac48..d001ef4e27 100644 --- a/entry_types/scrolled/package/src/frontend/Backdrop.module.css +++ b/entry_types/scrolled/package/src/frontend/Backdrop.module.css @@ -13,7 +13,7 @@ } .defaultBackground { - background-color: #333; + background: var(--backdrop-default-background, #333); } @media print {