From 77762ad2d3055cd4a615ae65cd5578dfa8cf7609 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 09:07:45 +0200 Subject: [PATCH 01/17] Show entry titles for linked entries in editor The story combobox and link tooltips should show the titles editors use to manage their entries, not the titles of published revisions. Titles are no longer rendered for published entries since they are only needed by editor tooltips, so internal entry titles do not leak into published entries. --- .../_linked_entry.json.jbuilder | 2 +- .../linkable_entries_controller_spec.rb | 6 ++--- .../entry_json_seed_helper_spec.rb | 26 ++++++++++++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder index 73f10a07cb..6429731d75 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder @@ -1,9 +1,9 @@ json.id entry.id json.perma_id entry.id -json.title entry.title json.url url if local_assigns.key?(:preview_url) + json.title entry.entry.title json.preview_url preview_url json.publication_state publication_state json.signature signature diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb index b15331ad7b..e5d9c8dec0 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb @@ -36,22 +36,22 @@ module PageflowScrolled }]) end - it 'uses title of published revision like seed' do + it 'uses title of entry instead of revision' do user = create(:user) account = create(:account, with_previewer: user) entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) other_entry = create(:published_entry, type_name: 'scrolled', account:, + title: 'entry-title', revision_attributes: {title: 'Revision title'}) - other_entry.entry.update!(title: 'entry-title') sign_in_and_lock(entry, user) get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') expect(response.body).to include_json([{ id: other_entry.id, - title: 'Revision title' + title: 'entry-title' }]) 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 7516483e89..f919340d01 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 @@ -1757,7 +1757,7 @@ def json_config(result) expect(result).to include_json(collections: {linkedEntries: []}) end - it 'renders ids, titles and urls of published linked entries' do + it 'renders ids and urls of published linked entries' do account = create(:account) linked_entry = create(:published_entry, type_name: 'scrolled', @@ -1775,14 +1775,13 @@ def json_config(result) { id: linked_entry.id, permaId: linked_entry.id, - title: 'linked-entry', url: 'http://test.host/linked-entry' } ] }) end - it 'does not render preview urls in published entry' do + it 'does not render titles and preview urls in published entry' do account = create(:account) linked_entry = create(:published_entry, type_name: 'scrolled', account:) entry = create(:published_entry, type_name: 'scrolled', account:) @@ -1793,7 +1792,7 @@ def json_config(result) result = render(helper, entry) expect(JSON.parse(result)['collections']['linkedEntries'][0].keys) - .to contain_exactly('id', 'permaId', 'title', 'url') + .to contain_exactly('id', 'permaId', 'url') end it 'renders future url of unpublished linked entry of published entry' do @@ -1943,6 +1942,25 @@ def json_config(result) ) end + it 'renders entry titles instead of revision titles for draft entry' do + account = create(:account) + linked_entry = create(:published_entry, + type_name: 'scrolled', + account:, + title: 'entry-title', + revision_attributes: {title: 'Revision title'}) + entry = create(:draft_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: {value: [link_node(entry_id: linked_entry.id)]}) + + result = render(helper, entry) + + expect(result).to include_json(collections: { + linkedEntries: [{title: 'entry-title'}] + }) + end + it 'renders signatures for draft entry to let editors link entries again' do account = create(:account) linked_entry = create(:published_entry, type_name: 'scrolled', account:) From 96be4d29c85e358a488f5e2912f1d4a1b12bc8ca Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 20 Oct 2026 12:08:02 +0200 Subject: [PATCH 02/17] Keep linkable entries separate from linked entries in editor Linked entries from the seed and from inserted fragments are only needed to render links and no longer carry signatures. Entries that new links point to are added to linked entries. The story combobox only offers entries of the same site the current user can access and explains when an existing link points to an entry the user cannot access. --- .../editor/linkable_entries_controller.rb | 20 +++-- .../linked_entries_helper.rb | 5 +- .../pageflow_scrolled/linkable_entry.rb | 27 +++++++ .../_linkable_entry.json.jbuilder | 6 ++ .../linkable_entries/index.json.jbuilder | 4 +- .../_linked_entry.json.jbuilder | 1 - entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../LinkedEntriesCollection-spec.js | 34 ++++++++ .../spec/editor/models/Chapter-spec.js | 10 +-- .../SelectLinkDestinationDialogView-spec.js | 77 ++++++++++++++----- .../spec/entryState/linkedEntries-spec.js | 6 +- .../collections/LinkedEntriesCollection.js | 8 ++ .../package/src/editor/models/Chapter.js | 4 +- .../src/editor/models/ScrolledEntry/index.js | 4 +- .../src/editor/views/LinkToOtherEntryView.js | 24 +++++- .../views/LinkToOtherEntryView.module.css | 4 + .../views/SelectLinkDestinationDialogView.js | 8 +- .../TypographyVariantSelectInputView.js | 2 +- .../package/src/entryState/collections.js | 4 +- .../linkable_entries_controller_spec.rb | 13 ++-- .../entry_json_seed_helper_spec.rb | 14 ---- .../pageflow_scrolled/linkable_entry_spec.rb | 40 ++++++++++ 23 files changed, 249 insertions(+), 68 deletions(-) create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder create mode 100644 entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js create mode 100644 entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb index 01ad304357..a2a11700af 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb @@ -4,15 +4,21 @@ module Editor class LinkableEntriesController < ActionController::Base include Pageflow::EditorController - helper PageflowScrolled::LinkedEntriesHelper + helper Pageflow::SocialShareHelper def index - @entries = Pageflow::Entry - .accessible_by(current_ability, :read) - .where(account: @entry.account, fragment_library: nil) - .where.not(id: @entry.id) - .preload(:site, :account, :published_revision, :draft, permalink: :directory) - .order(:title) + @linkable_entries = LinkableEntry.all( + entries + .preload(:site, :account, :published_revision, :draft, permalink: :directory) + .order(:title), + except: @entry + ) + end + + private + + def entries + @entry.site.entries.accessible_by(current_ability, :read) end end end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb index 5e31fd7851..90fb567bfc 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb @@ -18,15 +18,14 @@ def scrolled_linked_entries(scrolled_entry, configurables) end end - def scrolled_linked_entry(entry, include_preview: true) + def scrolled_linked_entry(entry, include_preview:) linked_entry = Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) result = {entry: linked_entry, url: social_share_entry_url(linked_entry)} return result unless include_preview result.merge(preview_url: main_app.preview_admin_entry_path(entry), - publication_state: entry.publication_state, - signature: EntryLinkSignature.generate(entry.id)) + publication_state: entry.publication_state) end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb new file mode 100644 index 0000000000..c7833a9772 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb @@ -0,0 +1,27 @@ +module PageflowScrolled + # Entry that editors can link to. + # + # @api private + class LinkableEntry + attr_reader :entry + + delegate :id, :title, :type_name, :publication_state, :draft, to: :entry + + class << self + def all(scope, except:) + scope + .where(fragment_library: nil) + .where.not(id: except.id) + .map { |entry| new(entry) } + end + end + + def initialize(entry) + @entry = entry + end + + def published_entry + Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder new file mode 100644 index 0000000000..1a5523014e --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder @@ -0,0 +1,6 @@ +json.id linkable_entry.id +json.title linkable_entry.title +json.url social_share_entry_url(linkable_entry.published_entry) +json.preview_url main_app.preview_admin_entry_path(linkable_entry.entry) +json.publication_state linkable_entry.publication_state +json.signature PageflowScrolled::EntryLinkSignature.generate(linkable_entry.id) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder index 1ef16a5f11..9a3e19ba50 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder @@ -1,5 +1,5 @@ json.key_format!(camelize: :lower) -json.array!(@entries) do |entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **scrolled_linked_entry(entry)) +json.array!(@linkable_entries) do |linkable_entry| + json.partial!('pageflow_scrolled/editor/linkable_entries/linkable_entry', linkable_entry:) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder index 6429731d75..3110e8b269 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder @@ -6,5 +6,4 @@ if local_assigns.key?(:preview_url) json.title entry.entry.title json.preview_url preview_url json.publication_state publication_state - json.signature signature end diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 02d1dcf60a..c3ba63273d 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1488,6 +1488,7 @@ de: edit_header: Link bearbeiten file: Datei header: Link erstellen + inaccessible_entry: Dieser Link verweist auf den Beitrag „%{title}“, auf den du keinen Zugriff hast. Wähle einen anderen Beitrag aus, um den Link zu ändern. kinds: file: description: Lasse Leser eine Datei herunterladen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index ca56f4bae9..1c75397eb0 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1470,6 +1470,7 @@ en: edit_header: Edit link file: File header: Create link + inaccessible_entry: This link points to the story "%{title}" which you cannot access. Select another story to change the link. kinds: file: description: Let readers download a file diff --git a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js new file mode 100644 index 0000000000..cd00fb3040 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js @@ -0,0 +1,34 @@ +import {LinkedEntriesCollection} from 'editor/collections/LinkedEntriesCollection'; +import Backbone from 'backbone'; + +describe('LinkedEntriesCollection', () => { + describe('#addLinkableEntry', () => { + it('adds attributes needed to render links', () => { + const linkedEntries = new LinkedEntriesCollection(); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, title: 'Other', url: '/other', previewUrl: '/admin/other', + publicationState: 'not_published', signature: 'signed' + })); + + expect(linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'Other', url: '/other', previewUrl: '/admin/other', + publicationState: 'not_published' + }); + }); + + it('updates existing linked entry', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, title: 'Seed', publicationState: 'not_published'} + ]); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, title: 'Other', publicationState: 'published_without_password_protection' + })); + + expect(linkedEntries.get(5).attributes).toMatchObject({ + title: 'Other', publicationState: 'published_without_password_protection' + }); + }); + }); +}); 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 70c887a327..aaf417f238 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -341,7 +341,7 @@ describe('Chapter', () => { expect(chapter.sections.get(101).contentElements.pluck('id')).toEqual([1000]); }); - it('makes entries linked from fragment available as linkable entries', () => { + it('adds entries linked from fragment to linked entries', () => { const {entry, server} = testContext; const chapter = entry.chapters.first(); const fragmentWithLinks = new Backbone.Model({ @@ -358,13 +358,13 @@ describe('Chapter', () => { contentElements: []} ]); - expect(entry.linkableEntries.get(9).get('title')).toEqual('Linked story'); + expect(entry.linkedEntries.get(9).get('title')).toEqual('Linked story'); }); - it('keeps existing linkable entries when adding entries linked from fragment', () => { + it('keeps existing linked entries when adding entries linked from fragment', () => { const {entry, server} = testContext; const chapter = entry.chapters.first(); - entry.linkableEntries.add({id: 5, title: 'Other story'}); + entry.linkedEntries.add({id: 5, title: 'Other story'}); const fragmentWithLinks = new Backbone.Model({ libraryId: 3, chapterPermaId: 700, @@ -377,7 +377,7 @@ describe('Chapter', () => { contentElements: []} ]); - expect(entry.linkableEntries.pluck('id')).toEqual([5, 9]); + expect(entry.linkedEntries.pluck('id')).toEqual([5, 9]); }); it('refreshes files before adding sections', () => { 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 e732253776..6ecd76a185 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -27,6 +27,14 @@ describe('SelectLinkDestinationDialogView', () => { useFakeXhr(() => testContext); + function respondWithLinkableEntries(linkableEntries) { + testContext.server.respondWith( + 'GET', /linkable_entries$/, [200, {'Content-Type': 'application/json'}, + JSON.stringify(linkableEntries)] + ); + testContext.server.respond(); + } + setupGlobals({ entry: () => factories.entry(ScrolledEntry, {id: 1}) }); @@ -36,6 +44,7 @@ describe('SelectLinkDestinationDialogView', () => { 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', 'pageflow_scrolled.editor.select_link_destination.url': 'URL', 'pageflow_scrolled.editor.select_link_destination.story': 'Story', + 'pageflow_scrolled.editor.select_link_destination.inaccessible_entry': 'No access to %{title}', 'pageflow_scrolled.editor.select_link_destination.kinds.this_entry.title': 'Chapter or section of this story', 'pageflow_scrolled.editor.select_link_destination.kinds.other_entry.title': 'Another story', 'pageflow_scrolled.editor.select_link_destination.kinds.file.title': 'File download', @@ -154,14 +163,6 @@ describe('SelectLinkDestinationDialogView', () => { }); describe('with other entry selected', () => { - function respondWithLinkableEntries(linkableEntries) { - testContext.server.respondWith( - 'GET', /linkable_entries$/, [200, {'Content-Type': 'application/json'}, - JSON.stringify(linkableEntries)] - ); - testContext.server.respond(); - } - function renderDialog({seed, onSelect = jest.fn()} = {}) { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed(seed) @@ -194,15 +195,17 @@ describe('SelectLinkDestinationDialogView', () => { expect(testContext.requests).toHaveLength(1); }); - it('keeps linked entries from seed when fetching entries', async () => { + it('only offers entries returned by server', async () => { const user = userEvent.setup(); const result = renderDialog({ seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]} }); await user.click(result.getByRole('radio', {name: /Another story/})); respondWithLinkableEntries([{id: 5, title: 'Other story'}]); + await user.click(result.getByRole('button', {name: /Show stories/})); - expect(result.entry.linkableEntries.pluck('id')).toEqual([9, 5]); + expect(result.getAllByRole('option').map(option => option.textContent)) + .toEqual(['Other story']); }); it('disables create button until story is selected', async () => { @@ -223,19 +226,22 @@ describe('SelectLinkDestinationDialogView', () => { expect(listener).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); }); - it('links to entry from seed with signature', async () => { - const listener = jest.fn(); + it('makes linked entry available to render created link', async () => { const user = userEvent.setup(); - const result = renderDialog({ - seed: {linkedEntries: [{id: 9, title: 'Linked story', signature: 'signed-9'}]}, - onSelect: listener - }); + const result = renderDialog(); await user.click(result.getByRole('radio', {name: /Another story/})); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', url: '/other-story', previewUrl: '/admin/other-story', + publicationState: 'not_published', signature: 'signed-5' + }]); await user.click(result.getByRole('button', {name: /Show stories/})); - await user.click(result.getByRole('option', {name: 'Linked story'})); + await user.click(result.getByRole('option', {name: /Other story/})); await user.click(result.getByRole('button', {name: 'Create link'})); - expect(listener).toHaveBeenCalledWith({href: {entry: 9, signature: 'signed-9'}}); + expect(result.entry.linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'Other story', url: '/other-story', previewUrl: '/admin/other-story', + publicationState: 'not_published' + }); }); it('allows opening other entry in new tab', async () => { @@ -513,6 +519,8 @@ describe('SelectLinkDestinationDialogView', () => { onSelect }); + respondWithLinkableEntries([{id: 9, title: 'Linked story', signature: 'other'}]); + expect(result.getByRole('radio', {name: /Another story/})).toBeChecked(); expect(result.getByRole('combobox', {name: 'Story'})).toHaveValue('Linked story'); @@ -524,6 +532,39 @@ describe('SelectLinkDestinationDialogView', () => { }); }); + it('explains that link to entry that cannot be linked to cannot be changed', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, signature: 'signed-9'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + + expect(result.getByText('No access to Linked story')).toBeInTheDocument(); + expect(result.getByRole('combobox', {name: 'Story'})).toHaveValue(''); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({href: {entry: 9, signature: 'signed-9'}}); + }); + + it('allows replacing link to entry that cannot be linked to', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, signature: 'signed-9'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Other story'})); + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(result.queryByText('No access to Linked story')).not.toBeInTheDocument(); + expect(onSelect).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); + }); + it('fetches entries when other entry is preselected', () => { renderDialog({href: {entry: 9, signature: 'signed-9'}}); diff --git a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js index 8e8fc5fc11..01416a8b64 100644 --- a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js +++ b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js @@ -44,10 +44,10 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toBeUndefined(); }); - describe('with watched linkable entries', () => { + describe('with watched linked entries', () => { it('returns url of added entry', () => { const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); - entry.linkableEntries.add({id: 5, title: 'Other', url: 'http://example.com/other'}); + entry.linkedEntries.add({id: 5, title: 'Other', url: 'http://example.com/other'}); const {result} = renderHookInEntry( () => useLinkedEntryUrl({entry: 5}), { @@ -76,7 +76,7 @@ describe('useLinkedEntryUrl', () => { it('syncs preview url and publication state', () => { const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); - entry.linkableEntries.add({ + entry.linkedEntries.add({ id: 5, title: 'Other', url: 'http://example.com/other', diff --git a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js new file mode 100644 index 0000000000..0f3a437a06 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js @@ -0,0 +1,8 @@ +import Backbone from 'backbone'; + +export const LinkedEntriesCollection = Backbone.Collection.extend({ + addLinkableEntry(linkableEntry) { + this.add(linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState'), + {merge: true}); + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index a9c424b476..daa870b172 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -131,8 +131,8 @@ export const Chapter = Backbone.Model.extend({ } } }).then(sections => { - this.entry.linkableEntries.set(fragment.get('collections')?.linkedEntries || [], - {remove: false}); + this.entry.linkedEntries.set(fragment.get('collections')?.linkedEntries || [], + {remove: false}); return this.entry.refreshFiles().then(() => this.addFragmentSections(sections)); }); 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 dba6f4986e..4e36514e90 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -13,6 +13,7 @@ import { ContentElementsCollection } from '../../collections'; import {LinkableEntriesCollection} from '../../collections/LinkableEntriesCollection'; +import {LinkedEntriesCollection} from '../../collections/LinkedEntriesCollection'; import {CommentDisplayFilter} from '../CommentDisplayFilter'; import {ContentElement} from '../ContentElement'; @@ -76,7 +77,8 @@ export const ScrolledEntry = Entry.extend({ this.sections.sort(); - this.linkableEntries = new LinkableEntriesCollection(seed.collections.linkedEntries); + this.linkedEntries = new LinkedEntriesCollection(seed.collections.linkedEntries); + this.linkableEntries = new LinkableEntriesCollection(); this.cutoff = new Cutoff({entry: this}); diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js index 0b877b0c72..25e266b141 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js @@ -10,11 +10,19 @@ export const LinkToOtherEntryView = Marionette.ItemView.extend({ className: styles.view, template: () => ` +
`, - ui: cssModulesUtils.ui(styles, 'entrySelectContainer', 'openInNewTabContainer'), + ui: cssModulesUtils.ui(styles, + 'inaccessibleEntryNotice', + 'entrySelectContainer', + 'openInNewTabContainer'), + + initialize() { + this.listenTo(this.options.entry.linkableEntries, 'sync', this.updateInaccessibleEntryNotice); + }, onRender() { this.appendSubview(new EntrySelectView({ @@ -36,5 +44,19 @@ export const LinkToOtherEntryView = Marionette.ItemView.extend({ entryId: linkableEntry?.id, signature: linkableEntry?.get('signature') }); + + this.updateInaccessibleEntryNotice(); + }, + + updateInaccessibleEntryNotice() { + const entryId = this.model.get('entryId'); + const linkedEntry = this.options.entry.linkedEntries.get(entryId); + const inaccessible = !!linkedEntry && !this.options.entry.linkableEntries.get(entryId); + + this.ui.inaccessibleEntryNotice.prop('hidden', !inaccessible); + this.ui.inaccessibleEntryNotice.text(inaccessible ? + I18n.t('pageflow_scrolled.editor.select_link_destination.inaccessible_entry', + {title: linkedEntry.get('title')}) : + ''); } }); diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css index f6870a4ac5..1297e170a2 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css @@ -4,6 +4,10 @@ gap: space(3); } +.inaccessibleEntryNotice { + margin: 0; +} + .entrySelectContainer {} .openInNewTabContainer {} diff --git a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js index d8c816c7dd..410e3e7240 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js @@ -178,7 +178,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ fetchLinkableEntries() { if (this.selection.get('kind') === 'otherEntry' && !this.linkableEntriesFetched) { this.linkableEntriesFetched = true; - this.options.entry.linkableEntries.fetch({remove: false}); + this.options.entry.linkableEntries.fetch(); } }, @@ -213,6 +213,12 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ }, apply(link) { + const linkableEntry = link.href.entry && this.options.entry.linkableEntries.get(link.href.entry); + + if (linkableEntry) { + this.options.entry.linkedEntries.addLinkableEntry(linkableEntry); + } + this.options.onSelect(link); this.close(); }, diff --git a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js index 7f9b59a5d1..e97f0499df 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js @@ -98,7 +98,7 @@ function buildEntry(entry, contentElement, item, getPreviewConfiguration) { metadata: entry.metadata, widgets: entry.widgets, files: entry.files, - linkableEntries: entry.linkableEntries, + linkedEntries: entry.linkedEntries, storylines: entry.storylines, chapters: new Backbone.Collection([section.chapter]), diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index e5cb10f473..ef010cf4aa 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -25,7 +25,7 @@ export function watchCollections(entry, {dispatch}) { function collectionSpecs(entry) { const { - storylines, chapters, sections, contentElements, widgets, files, linkableEntries + storylines, chapters, sections, contentElements, widgets, files, linkedEntries } = entry; return [ @@ -73,7 +73,7 @@ function collectionSpecs(entry) { includeConfiguration: true }, { - collection: linkableEntries, + collection: linkedEntries, name: 'linkedEntries', attributes: ['id', {permaId: 'id'}, 'title', 'url', 'previewUrl', 'publicationState'], keyAttribute: 'permaId' diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb index e5d9c8dec0..07c7c7e6cf 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb @@ -17,7 +17,7 @@ module PageflowScrolled expect(response.status).to eq(401) end - it 'lists ids, titles and urls of other entries of account' do + it 'lists ids, titles, urls and publication states of other entries of site' do user = create(:user) account = create(:account, with_previewer: user) entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) @@ -28,7 +28,6 @@ module PageflowScrolled expect(response.body).to include_json([{ id: other_entry.id, - permaId: other_entry.id, title: 'Other story', url: 'http://test.host/other-story', previewUrl: '/admin/entries/other-story/preview', @@ -43,6 +42,7 @@ module PageflowScrolled other_entry = create(:published_entry, type_name: 'scrolled', account:, + site: entry.site, title: 'entry-title', revision_attributes: {title: 'Revision title'}) @@ -79,11 +79,11 @@ module PageflowScrolled expect(JSON.parse(response.body)).to be_empty end - it 'skips entries of other accounts' do + it 'skips entries of other sites' do user = create(:user) account = create(:account, with_previewer: user) entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) - create(:entry, type_name: 'scrolled', account: create(:account, with_previewer: user)) + create(:entry, type_name: 'scrolled', account:, site: create(:site, account:)) sign_in_and_lock(entry, user) get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') @@ -125,12 +125,11 @@ module PageflowScrolled get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') expect(response.body).to include_json([{ - title: 'Paged story', - url: 'http://test.host/paged-story' + title: 'Paged story' }]) end - it 'lists entries of account for admins' do + it 'lists entries of site for admins' do user = create(:user, :admin) account = create(:account) entry = create(:entry, type_name: 'scrolled', account:) 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 f919340d01..c30b80a7ba 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 @@ -1961,20 +1961,6 @@ def json_config(result) }) end - it 'renders signatures for draft entry to let editors link entries again' do - account = create(:account) - linked_entry = create(:published_entry, type_name: 'scrolled', account:) - entry = create(:draft_entry, type_name: 'scrolled', account:) - create(:content_element, - revision: entry.revision, - configuration: {value: [link_node(entry_id: linked_entry.id)]}) - - result = render(helper, entry) - signature = JSON.parse(result)['collections']['linkedEntries'][0]['signature'] - - expect(EntryLinkSignature.valid?(linked_entry.id, signature)).to eq(true) - end - def link_node(entry_id:, signature: PageflowScrolled::EntryLinkSignature.generate(entry_id)) {type: 'link', href: {entry: entry_id, signature:}, children: [{text: 'Link'}]} end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb new file mode 100644 index 0000000000..982a15f4ea --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe LinkableEntry do + describe '.all' do + it 'wraps entries of given scope' do + entry = create(:entry) + other_entry = create(:entry, title: 'Other story') + + result = LinkableEntry.all(Pageflow::Entry.all, except: entry) + + expect(result.map(&:entry)).to eq([other_entry]) + expect(result.first.title).to eq('Other story') + end + + it 'skips fragment libraries' do + entry = create(:entry) + create(:entry, fragment_library: 'shared') + + expect(LinkableEntry.all(Pageflow::Entry.all, except: entry)).to eq([]) + end + end + + describe '#published_entry' do + it 'uses published revision' do + entry = create(:published_entry, revision_attributes: {title: 'Published'}) + entry.entry.draft.update!(title: 'Draft') + + expect(LinkableEntry.new(entry.entry).published_entry.title).to eq('Published') + end + + it 'falls back to draft for entries that are not published' do + entry = create(:entry) + entry.draft.update!(title: 'Draft') + + expect(LinkableEntry.new(entry).published_entry.title).to eq('Draft') + end + end + end +end From a75eb46d6e1cd0edc933ded268c5cbb469712d00 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 09:53:18 +0200 Subject: [PATCH 03/17] Replace slugify package with own implementation using shared char map Prepares computing chapter slugs on the server. The char map is stored as JSON to let Ruby use the same mapping. It matches slugify 1.5.1 with the German locale since host apps resolve the previous dependency to that version. Fixtures generated with the slugify package ensure output does not change. --- .../scrolled/package/config/slugCharMap.json | 531 ++++++++++++++++++ entry_types/scrolled/package/package.json | 1 - .../package/spec/fixtures/slugify.json | 126 +++++ .../package/spec/shared/chapterSlug-spec.js | 2 +- .../package/spec/shared/slugify-spec.js | 11 + .../package/src/shared/chapterSlug.js | 8 +- .../scrolled/package/src/shared/slugify.js | 17 + yarn.lock | 5 - 8 files changed, 688 insertions(+), 13 deletions(-) create mode 100644 entry_types/scrolled/package/config/slugCharMap.json create mode 100644 entry_types/scrolled/package/spec/fixtures/slugify.json create mode 100644 entry_types/scrolled/package/spec/shared/slugify-spec.js create mode 100644 entry_types/scrolled/package/src/shared/slugify.js diff --git a/entry_types/scrolled/package/config/slugCharMap.json b/entry_types/scrolled/package/config/slugCharMap.json new file mode 100644 index 0000000000..4f07160150 --- /dev/null +++ b/entry_types/scrolled/package/config/slugCharMap.json @@ -0,0 +1,531 @@ +{ + "$": "dollar", + "%": "prozent", + "&": "und", + "<": "less", + ">": "greater", + "|": "oder", + "¢": "cent", + "£": "pound", + "¤": "currency", + "¥": "yen", + "©": "(c)", + "ª": "a", + "®": "(r)", + "º": "o", + "À": "A", + "Á": "A", + "Â": "A", + "Ã": "A", + "Ä": "AE", + "Å": "A", + "Æ": "AE", + "Ç": "C", + "È": "E", + "É": "E", + "Ê": "E", + "Ë": "E", + "Ì": "I", + "Í": "I", + "Î": "I", + "Ï": "I", + "Ð": "D", + "Ñ": "N", + "Ò": "O", + "Ó": "O", + "Ô": "O", + "Õ": "O", + "Ö": "OE", + "Ø": "O", + "Ù": "U", + "Ú": "U", + "Û": "U", + "Ü": "UE", + "Ý": "Y", + "Þ": "TH", + "ß": "ss", + "à": "a", + "á": "a", + "â": "a", + "ã": "a", + "ä": "ae", + "å": "a", + "æ": "ae", + "ç": "c", + "è": "e", + "é": "e", + "ê": "e", + "ë": "e", + "ì": "i", + "í": "i", + "î": "i", + "ï": "i", + "ð": "d", + "ñ": "n", + "ò": "o", + "ó": "o", + "ô": "o", + "õ": "o", + "ö": "oe", + "ø": "o", + "ù": "u", + "ú": "u", + "û": "u", + "ü": "ue", + "ý": "y", + "þ": "th", + "ÿ": "y", + "Ā": "A", + "ā": "a", + "Ă": "A", + "ă": "a", + "Ą": "A", + "ą": "a", + "Ć": "C", + "ć": "c", + "Č": "C", + "č": "c", + "Ď": "D", + "ď": "d", + "Đ": "DJ", + "đ": "dj", + "Ē": "E", + "ē": "e", + "Ė": "E", + "ė": "e", + "Ę": "e", + "ę": "e", + "Ě": "E", + "ě": "e", + "Ğ": "G", + "ğ": "g", + "Ģ": "G", + "ģ": "g", + "Ĩ": "I", + "ĩ": "i", + "Ī": "i", + "ī": "i", + "Į": "I", + "į": "i", + "İ": "I", + "ı": "i", + "Ķ": "k", + "ķ": "k", + "Ļ": "L", + "ļ": "l", + "Ľ": "L", + "ľ": "l", + "Ł": "L", + "ł": "l", + "Ń": "N", + "ń": "n", + "Ņ": "N", + "ņ": "n", + "Ň": "N", + "ň": "n", + "Ō": "O", + "ō": "o", + "Ő": "O", + "ő": "o", + "Œ": "OE", + "œ": "oe", + "Ŕ": "R", + "ŕ": "r", + "Ř": "R", + "ř": "r", + "Ś": "S", + "ś": "s", + "Ş": "S", + "ş": "s", + "Š": "S", + "š": "s", + "Ţ": "T", + "ţ": "t", + "Ť": "T", + "ť": "t", + "Ũ": "U", + "ũ": "u", + "Ū": "u", + "ū": "u", + "Ů": "U", + "ů": "u", + "Ű": "U", + "ű": "u", + "Ų": "U", + "ų": "u", + "Ŵ": "W", + "ŵ": "w", + "Ŷ": "Y", + "ŷ": "y", + "Ÿ": "Y", + "Ź": "Z", + "ź": "z", + "Ż": "Z", + "ż": "z", + "Ž": "Z", + "ž": "z", + "Ə": "E", + "ƒ": "f", + "Ơ": "O", + "ơ": "o", + "Ư": "U", + "ư": "u", + "Lj": "LJ", + "lj": "lj", + "Nj": "NJ", + "nj": "nj", + "Ș": "S", + "ș": "s", + "Ț": "T", + "ț": "t", + "ə": "e", + "˚": "o", + "Ά": "A", + "Έ": "E", + "Ή": "H", + "Ί": "I", + "Ό": "O", + "Ύ": "Y", + "Ώ": "W", + "ΐ": "i", + "Α": "A", + "Β": "B", + "Γ": "G", + "Δ": "D", + "Ε": "E", + "Ζ": "Z", + "Η": "H", + "Θ": "8", + "Ι": "I", + "Κ": "K", + "Λ": "L", + "Μ": "M", + "Ν": "N", + "Ξ": "3", + "Ο": "O", + "Π": "P", + "Ρ": "R", + "Σ": "S", + "Τ": "T", + "Υ": "Y", + "Φ": "F", + "Χ": "X", + "Ψ": "PS", + "Ω": "W", + "Ϊ": "I", + "Ϋ": "Y", + "ά": "a", + "έ": "e", + "ή": "h", + "ί": "i", + "ΰ": "y", + "α": "a", + "β": "b", + "γ": "g", + "δ": "d", + "ε": "e", + "ζ": "z", + "η": "h", + "θ": "8", + "ι": "i", + "κ": "k", + "λ": "l", + "μ": "m", + "ν": "n", + "ξ": "3", + "ο": "o", + "π": "p", + "ρ": "r", + "ς": "s", + "σ": "s", + "τ": "t", + "υ": "y", + "φ": "f", + "χ": "x", + "ψ": "ps", + "ω": "w", + "ϊ": "i", + "ϋ": "y", + "ό": "o", + "ύ": "y", + "ώ": "w", + "Ё": "Yo", + "Ђ": "DJ", + "Є": "Ye", + "І": "I", + "Ї": "Yi", + "Ј": "J", + "Љ": "LJ", + "Њ": "NJ", + "Ћ": "C", + "Џ": "DZ", + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ж": "Zh", + "З": "Z", + "И": "I", + "Й": "J", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "C", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sh", + "Ъ": "U", + "Ы": "Y", + "Ь": "", + "Э": "E", + "Ю": "Yu", + "Я": "Ya", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "j", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "ch", + "ш": "sh", + "щ": "sh", + "ъ": "u", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + "ё": "yo", + "ђ": "dj", + "є": "ye", + "і": "i", + "ї": "yi", + "ј": "j", + "љ": "lj", + "њ": "nj", + "ћ": "c", + "ѝ": "u", + "џ": "dz", + "Ґ": "G", + "ґ": "g", + "Ғ": "GH", + "ғ": "gh", + "Қ": "KH", + "қ": "kh", + "Ң": "NG", + "ң": "ng", + "Ү": "UE", + "ү": "ue", + "Ұ": "U", + "ұ": "u", + "Һ": "H", + "һ": "h", + "Ә": "AE", + "ә": "ae", + "Ө": "OE", + "ө": "oe", + "฿": "baht", + "ა": "a", + "ბ": "b", + "გ": "g", + "დ": "d", + "ე": "e", + "ვ": "v", + "ზ": "z", + "თ": "t", + "ი": "i", + "კ": "k", + "ლ": "l", + "მ": "m", + "ნ": "n", + "ო": "o", + "პ": "p", + "ჟ": "zh", + "რ": "r", + "ს": "s", + "ტ": "t", + "უ": "u", + "ფ": "f", + "ქ": "k", + "ღ": "gh", + "ყ": "q", + "შ": "sh", + "ჩ": "ch", + "ც": "ts", + "ძ": "dz", + "წ": "ts", + "ჭ": "ch", + "ხ": "kh", + "ჯ": "j", + "ჰ": "h", + "Ẁ": "W", + "ẁ": "w", + "Ẃ": "W", + "ẃ": "w", + "Ẅ": "W", + "ẅ": "w", + "ẞ": "SS", + "Ạ": "A", + "ạ": "a", + "Ả": "A", + "ả": "a", + "Ấ": "A", + "ấ": "a", + "Ầ": "A", + "ầ": "a", + "Ẩ": "A", + "ẩ": "a", + "Ẫ": "A", + "ẫ": "a", + "Ậ": "A", + "ậ": "a", + "Ắ": "A", + "ắ": "a", + "Ằ": "A", + "ằ": "a", + "Ẳ": "A", + "ẳ": "a", + "Ẵ": "A", + "ẵ": "a", + "Ặ": "A", + "ặ": "a", + "Ẹ": "E", + "ẹ": "e", + "Ẻ": "E", + "ẻ": "e", + "Ẽ": "E", + "ẽ": "e", + "Ế": "E", + "ế": "e", + "Ề": "E", + "ề": "e", + "Ể": "E", + "ể": "e", + "Ễ": "E", + "ễ": "e", + "Ệ": "E", + "ệ": "e", + "Ỉ": "I", + "ỉ": "i", + "Ị": "I", + "ị": "i", + "Ọ": "O", + "ọ": "o", + "Ỏ": "O", + "ỏ": "o", + "Ố": "O", + "ố": "o", + "Ồ": "O", + "ồ": "o", + "Ổ": "O", + "ổ": "o", + "Ỗ": "O", + "ỗ": "o", + "Ộ": "O", + "ộ": "o", + "Ớ": "O", + "ớ": "o", + "Ờ": "O", + "ờ": "o", + "Ở": "O", + "ở": "o", + "Ỡ": "O", + "ỡ": "o", + "Ợ": "O", + "ợ": "o", + "Ụ": "U", + "ụ": "u", + "Ủ": "U", + "ủ": "u", + "Ứ": "U", + "ứ": "u", + "Ừ": "U", + "ừ": "u", + "Ử": "U", + "ử": "u", + "Ữ": "U", + "ữ": "u", + "Ự": "U", + "ự": "u", + "Ỳ": "Y", + "ỳ": "y", + "Ỵ": "Y", + "ỵ": "y", + "Ỷ": "Y", + "ỷ": "y", + "Ỹ": "Y", + "ỹ": "y", + "–": "-", + "‘": "'", + "’": "'", + "“": "\"", + "”": "\"", + "„": "\"", + "†": "+", + "•": "*", + "…": "...", + "₠": "ecu", + "₢": "cruzeiro", + "₣": "french franc", + "₤": "lira", + "₥": "mill", + "₦": "naira", + "₧": "peseta", + "₨": "rupee", + "₩": "won", + "₪": "new shequel", + "₫": "dong", + "€": "euro", + "₭": "kip", + "₮": "tugrik", + "₯": "drachma", + "₰": "penny", + "₱": "peso", + "₲": "guarani", + "₳": "austral", + "₴": "hryvnia", + "₵": "cedi", + "₸": "kazakhstani tenge", + "₹": "indian rupee", + "₺": "turkish lira", + "₽": "russian ruble", + "₿": "bitcoin", + "℠": "sm", + "™": "tm", + "∂": "d", + "∆": "delta", + "∑": "summe", + "∞": "unendlich", + "♥": "liebe", + "元": "yuan", + "円": "yen", + "﷼": "rial" +} diff --git a/entry_types/scrolled/package/package.json b/entry_types/scrolled/package/package.json index 1b648e6ad9..e06977846b 100644 --- a/entry_types/scrolled/package/package.json +++ b/entry_types/scrolled/package/package.json @@ -35,7 +35,6 @@ "slate": "^0.57.3", "slate-history": "^0.57.3", "slate-react": "^0.57.3", - "slugify": "^1.4.6", "striptags": "^3.2.0", "use-context-selector": "^1.2.11", "video.js": "https://github.com/tf/video.js#pageflow-scrolled-8", diff --git a/entry_types/scrolled/package/spec/fixtures/slugify.json b/entry_types/scrolled/package/spec/fixtures/slugify.json new file mode 100644 index 0000000000..264ff68acf --- /dev/null +++ b/entry_types/scrolled/package/spec/fixtures/slugify.json @@ -0,0 +1,126 @@ +[ + { + "title": "My Chapter", + "slug": "my-chapter" + }, + { + "title": "Über uns & mehr!", + "slug": "ueber-uns-und-mehr" + }, + { + "title": "Straße & Co.", + "slug": "strasse-und-co" + }, + { + "title": "Ölförderung", + "slug": "oelfoerderung" + }, + { + "title": "Привет мир", + "slug": "privet-mir" + }, + { + "title": "Ελληνικά", + "slug": "ellhnika" + }, + { + "title": "中文标题", + "slug": "" + }, + { + "title": "😀 Emoji", + "slug": "emoji" + }, + { + "title": " Leading and trailing ", + "slug": "leading-and-trailing" + }, + { + "title": "Non breaking space", + "slug": "non-breaking-space" + }, + { + "title": "Mixed—dash – and - hyphen", + "slug": "mixeddash-and-hyphen" + }, + { + "title": "It's \"quoted\"", + "slug": "its-quoted" + }, + { + "title": "$100 % off", + "slug": "dollar100-prozent-off" + }, + { + "title": "Ь", + "slug": "" + }, + { + "title": "À la carte", + "slug": "a-la-carte" + }, + { + "title": "Đà Nẵng", + "slug": "dja-nang" + }, + { + "title": "Ünïcödé", + "slug": "uenicoede" + }, + { + "title": "tab\tseparated", + "slug": "tab-separated" + }, + { + "title": "a.b,c;d", + "slug": "abcd" + }, + { + "title": "___", + "slug": "" + }, + { + "title": "Kapitel 1: Einleitung", + "slug": "kapitel-1-einleitung" + }, + { + "title": "100€ für 5£", + "slug": "100euro-fuer-5pound" + }, + { + "title": "♥ love ♥", + "slug": "liebe-love-liebe" + }, + { + "title": "LJubljana", + "slug": "ubljana" + }, + { + "title": "ﷺ test", + "slug": "test" + }, + { + "title": "Fragen & Antworten", + "slug": "fragen-und-antworten" + }, + { + "title": "50 % Rabatt", + "slug": "50-prozent-rabatt" + }, + { + "title": "Ja | Nein", + "slug": "ja-oder-nein" + }, + { + "title": "Müller", + "slug": "mueller" + }, + { + "title": "Vorher – nachher", + "slug": "vorher-nachher" + }, + { + "title": "Zitat „Hallo“", + "slug": "zitat-hallo" + } +] diff --git a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js index 41810154a2..cd63cd3e50 100644 --- a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js +++ b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js @@ -42,6 +42,6 @@ describe('getChapterSlugs', () => { {configuration: {title: 'Über uns & mehr!'}, permaId: 100} ]; - expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-and-mehr'); + expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-und-mehr'); }); }); diff --git a/entry_types/scrolled/package/spec/shared/slugify-spec.js b/entry_types/scrolled/package/spec/shared/slugify-spec.js new file mode 100644 index 0000000000..e02a52efb6 --- /dev/null +++ b/entry_types/scrolled/package/spec/shared/slugify-spec.js @@ -0,0 +1,11 @@ +import {slugify} from 'shared/slugify'; + +import cases from '../fixtures/slugify.json'; + +describe('slugify', () => { + cases.forEach(({title, slug}) => { + it(`turns ${JSON.stringify(title)} into ${JSON.stringify(slug)}`, () => { + expect(slugify(title)).toBe(slug); + }); + }); +}); diff --git a/entry_types/scrolled/package/src/shared/chapterSlug.js b/entry_types/scrolled/package/src/shared/chapterSlug.js index 41238e6b96..8f2071380d 100644 --- a/entry_types/scrolled/package/src/shared/chapterSlug.js +++ b/entry_types/scrolled/package/src/shared/chapterSlug.js @@ -1,4 +1,4 @@ -import slugify from 'slugify'; +import {slugify} from './slugify'; export function getChapterSlugs(chapters) { const result = {}; @@ -8,11 +8,7 @@ export function getChapterSlugs(chapters) { let chapterSlug = chapter.configuration.title; if (chapterSlug) { - chapterSlug = slugify(chapterSlug, { - lower: true, - locale: 'de', - strict: true - }); + chapterSlug = slugify(chapterSlug); if (usedSlugs[chapterSlug]) { chapterSlug = chapterSlug + '-' + chapter.permaId; diff --git a/entry_types/scrolled/package/src/shared/slugify.js b/entry_types/scrolled/package/src/shared/slugify.js new file mode 100644 index 0000000000..44fe8561e8 --- /dev/null +++ b/entry_types/scrolled/package/src/shared/slugify.js @@ -0,0 +1,17 @@ +import charMap from 'pageflow-scrolled/config/slugCharMap.json'; + +export function slugify(string) { + return string + .normalize() + .split('') + .map(character => + (charMap[character] || character) + .replace(/[^\w\s$*_+~.()'"!\-:@]+/g, '') + ) + .join('') + .trim() + .replace(/[\s-]+/g, '-') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-'); +} diff --git a/yarn.lock b/yarn.lock index c154c95335..989195105f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11895,11 +11895,6 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" -slugify@^1.4.6: - version "1.4.6" - resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.6.tgz#ef288d920a47fb01c2be56b3487b6722f5e34ace" - integrity sha512-ZdJIgv9gdrYwhXqxsH9pv7nXxjUEyQ6nqhngRxoAAOlmMGA28FDq5O4/5US4G2/Nod7d1ovNcgURQJ7kHq50KQ== - smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" From c71fb92228bf040b07e2236431422fe92fa62c40 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 09:54:31 +0200 Subject: [PATCH 04/17] Port chapter slugs to Ruby Shares the char map and test fixtures with the frontend implementation to ensure slugs match. --- .../models/pageflow_scrolled/chapter_slugs.rb | 31 +++++++++++++ .../scrolled/lib/pageflow_scrolled/slugify.rb | 38 ++++++++++++++++ .../pageflow_scrolled/chapter_slugs_spec.rb | 43 +++++++++++++++++++ .../spec/pageflow_scrolled/slugify_spec.rb | 13 ++++++ 4 files changed, 125 insertions(+) create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb create mode 100644 entry_types/scrolled/lib/pageflow_scrolled/slugify.rb create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/slugify_spec.rb diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb new file mode 100644 index 0000000000..c933139217 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb @@ -0,0 +1,31 @@ +module PageflowScrolled + # Compute chapter slugs the same way as the frontend. + # + # @api private + class ChapterSlugs + def initialize(chapters) + @chapters = chapters + end + + def to_h + used_slugs = Set.new + + @chapters.to_h do |chapter| + [chapter.perma_id, slug(chapter, used_slugs)] + end + end + + private + + def slug(chapter, used_slugs) + title = chapter.configuration['title'] + return "chapter-#{chapter.perma_id}" if title.blank? + + slug = Slugify.call(title) + slug = "#{slug}-#{chapter.perma_id}" if used_slugs.include?(slug) + used_slugs << slug + + slug + end + end +end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb b/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb new file mode 100644 index 0000000000..fdc791955f --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb @@ -0,0 +1,38 @@ +module PageflowScrolled + # Port of the slugify function of the scrolled package. Both use + # the same char map. Slugs need to match, otherwise links to + # chapters computed on the server miss the anchors the frontend + # renders. + # + # @api private + module Slugify + WHITESPACE = "\t\n\v\f\r    - 

   ".freeze + + class << self + def call(string) + string + .unicode_normalize(:nfc) + .each_char + .map { |character| map(character).gsub(/[^a-zA-Z0-9_#{WHITESPACE}$*+~.()'"!\-:@]+/o, '') } + .join + .gsub(/\A[#{WHITESPACE}]+|[#{WHITESPACE}]+\z/o, '') + .gsub(/[#{WHITESPACE}-]+/o, '-') + .downcase + .gsub(/[^a-z0-9-]/, '') + .gsub(/-+/, '-') + end + + private + + def map(character) + char_map[character].presence || character + end + + def char_map + @char_map ||= JSON.parse( + File.read(Engine.root.join('package', 'config', 'slugCharMap.json')) + ) + end + end + end +end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb new file mode 100644 index 0000000000..df3e3c184c --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe ChapterSlugs do + it 'returns slugified title' do + chapters = [build(:scrolled_chapter, perma_id: 100, configuration: {title: 'My Chapter'})] + + expect(ChapterSlugs.new(chapters).to_h[100]).to eq('my-chapter') + end + + it 'returns chapter-permaId for empty title' do + chapters = [build(:scrolled_chapter, perma_id: 100, configuration: {title: ''})] + + expect(ChapterSlugs.new(chapters).to_h[100]).to eq('chapter-100') + end + + it 'returns chapter-permaId for missing title' do + chapters = [build(:scrolled_chapter, perma_id: 100, configuration: {})] + + expect(ChapterSlugs.new(chapters).to_h[100]).to eq('chapter-100') + end + + it 'appends permaId if slug would not be unique' do + chapters = [ + build(:scrolled_chapter, perma_id: 100, configuration: {title: 'Same Title'}), + build(:scrolled_chapter, perma_id: 200, configuration: {title: 'Same Title'}) + ] + + slugs = ChapterSlugs.new(chapters).to_h + + expect(slugs[100]).to eq('same-title') + expect(slugs[200]).to eq('same-title-200') + end + + it 'handles special characters' do + chapters = [build(:scrolled_chapter, + perma_id: 100, + configuration: {title: 'Über uns & mehr!'})] + + expect(ChapterSlugs.new(chapters).to_h[100]).to eq('ueber-uns-und-mehr') + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/slugify_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/slugify_spec.rb new file mode 100644 index 0000000000..83eb78b64f --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/slugify_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe Slugify do + cases = JSON.parse(File.read(Engine.root.join('package', 'spec', 'fixtures', 'slugify.json'))) + + cases.each do |test_case| + it "turns #{test_case['title'].inspect} into #{test_case['slug'].inspect}" do + expect(Slugify.call(test_case['title'])).to eq(test_case['slug']) + end + end + end +end From e3e0fecba519852df228ee121ee2fd1e24c5b594 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 09:55:07 +0200 Subject: [PATCH 05/17] Fall back to perma id for chapter titles without slug characters Titles in scripts like Chinese or Arabic used to produce empty slugs, which turned chapter anchors into plain # links. --- .../app/models/pageflow_scrolled/chapter_slugs.rb | 5 ++--- .../scrolled/package/spec/shared/chapterSlug-spec.js | 8 ++++++++ entry_types/scrolled/package/src/shared/chapterSlug.js | 4 +--- .../spec/models/pageflow_scrolled/chapter_slugs_spec.rb | 6 ++++++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb index c933139217..db5472f669 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb @@ -18,10 +18,9 @@ def to_h private def slug(chapter, used_slugs) - title = chapter.configuration['title'] - return "chapter-#{chapter.perma_id}" if title.blank? + slug = Slugify.call(chapter.configuration['title'] || '') + return "chapter-#{chapter.perma_id}" if slug.empty? - slug = Slugify.call(title) slug = "#{slug}-#{chapter.perma_id}" if used_slugs.include?(slug) used_slugs << slug diff --git a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js index cd63cd3e50..0ccb186619 100644 --- a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js +++ b/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js @@ -25,6 +25,14 @@ describe('getChapterSlugs', () => { expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); }); + it('returns chapter-permaId for title without characters allowed in slugs', () => { + const chapters = [ + {configuration: {title: '中文标题'}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); + }); + it('appends permaId if slug would not be unique', () => { const chapters = [ {configuration: {title: 'Same Title'}, permaId: 100}, diff --git a/entry_types/scrolled/package/src/shared/chapterSlug.js b/entry_types/scrolled/package/src/shared/chapterSlug.js index 8f2071380d..c26205946c 100644 --- a/entry_types/scrolled/package/src/shared/chapterSlug.js +++ b/entry_types/scrolled/package/src/shared/chapterSlug.js @@ -5,11 +5,9 @@ export function getChapterSlugs(chapters) { const usedSlugs = {}; chapters.forEach(chapter => { - let chapterSlug = chapter.configuration.title; + let chapterSlug = chapter.configuration.title && slugify(chapter.configuration.title); if (chapterSlug) { - chapterSlug = slugify(chapterSlug); - if (usedSlugs[chapterSlug]) { chapterSlug = chapterSlug + '-' + chapter.permaId; } diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb index df3e3c184c..f5bcce5c04 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_slugs_spec.rb @@ -20,6 +20,12 @@ module PageflowScrolled expect(ChapterSlugs.new(chapters).to_h[100]).to eq('chapter-100') end + it 'returns chapter-permaId for title without characters allowed in slugs' do + chapters = [build(:scrolled_chapter, perma_id: 100, configuration: {title: '中文标题'})] + + expect(ChapterSlugs.new(chapters).to_h[100]).to eq('chapter-100') + end + it 'appends permaId if slug would not be unique' do chapters = [ build(:scrolled_chapter, perma_id: 100, configuration: {title: 'Same Title'}), From b558c579a41729f48fb842e415721a246543315c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 13:43:50 +0200 Subject: [PATCH 06/17] Extract model for content of revision Determines which storylines, chapters, sections and content elements to render in one place. Lets the seed of entries and the fragment library index share a partial for rendering content collections. --- .../entry_json_seed_helper.rb | 67 ++------------- .../pageflow_scrolled/fragment_library.rb | 25 ++---- .../pageflow_scrolled/revision_content.rb | 53 ++++++++++++ .../fragment_libraries/index.json.jbuilder | 28 +------ .../_content_collections.json.jbuilder | 23 +++++ .../entry_json_seed/_entry.json.jbuilder | 25 +----- .../fragment_library_spec.rb | 7 +- .../revision_content_spec.rb | 84 +++++++++++++++++++ 8 files changed, 183 insertions(+), 129 deletions(-) create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb index ee3421e72f..be02cb66b1 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb @@ -26,80 +26,25 @@ def scrolled_entry_json_seed_script_tag(scrolled_entry, options = {}) end def scrolled_entry_json_seed(json, scrolled_entry, options = {}) - revision = scrolled_entry.revision - sections = scrolled_entry_json_seed_sections(scrolled_entry, options) - content_elements = scrolled_entry_json_seed_content_elements(revision, sections) + content = RevisionContent.new(scrolled_entry.revision, + include_hidden: options[:include_hidden_sections], + cutoff: scrolled_entry.cutoff_mode_enabled_for?(request)) widgets = scrolled_entry.resolve_widgets(insert_point: :react) entry_config = Pageflow.config_for(scrolled_entry) json.partial!('pageflow_scrolled/entry_json_seed/entry', entry: scrolled_entry, entry_config:, - storylines: scrolled_entry_json_seed_storylines(revision), - chapters: scrolled_entry_json_seed_chapters(revision, options), - sections:, - content_elements:, + content:, file_reference_locations: EntryFileReferenceLocations.new( entry_config, - content_elements, + content.content_elements, include_unused: options[:include_unused_file_reference_locations] ), linked_entries: scrolled_linked_entries(scrolled_entry, - content_elements + widgets), + content.content_elements + widgets), widgets:, options:) end - - private - - def scrolled_entry_json_seed_storylines(revision) - Storyline.all_for_revision(revision) - end - - def scrolled_entry_json_seed_sections(scrolled_entry, options) - main_storyline = Storyline.all_for_revision(scrolled_entry.revision).first || Storyline.new - - sections = - if scrolled_entry.cutoff_mode_enabled_for?(request) - main_storyline.sections_before_cutoff_section - else - Section.all_for_revision(scrolled_entry.revision) - end - - return sections if options[:include_hidden_sections] - - sections.reject { |section| section.configuration['hidden'] } - end - - def scrolled_entry_json_seed_chapters(revision, options) - chapters = Chapter.all_for_revision(revision) - return chapters if options[:include_hidden_sections] - - has_visible_sections, has_hidden_sections = - scrolled_entry_json_seed_chapter_section_visibilites(revision) - - chapters.reject do |chapter| - has_hidden_sections[chapter.id] && !has_visible_sections[chapter.id] - end - end - - def scrolled_entry_json_seed_chapter_section_visibilites(revision) - has_visible_sections = {} - has_hidden_sections = {} - - Section.all_for_revision(revision).each do |section| - if section.configuration['hidden'] - has_hidden_sections[section.chapter_id] = true - else - has_visible_sections[section.chapter_id] = true - end - end - - [has_visible_sections, has_hidden_sections] - end - - def scrolled_entry_json_seed_content_elements(revision, sections) - ContentElement.all_for_revision(revision).where(section: sections) - end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 5e3f172cce..339b063de6 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -35,26 +35,14 @@ def title entry.entry_title end - def storylines - Storyline.all_for_revision(entry.draft) - end - - def chapters - Chapter.all_for_revision(entry.draft) - end - - def sections - Section.all_for_revision(entry.draft) - end - - def content_elements - ContentElement.all_for_revision(entry.draft) + def content + @content ||= RevisionContent.new(entry.draft, include_hidden: true) end def copy_fragment_to(fragment_perma_id:, entry:, chapter:) SectionsCopy .new(source_entry: self.entry, destination_entry: entry) - .perform(chapters.find_by!(perma_id: fragment_perma_id).sections, chapter:) + .perform(fragment_chapter(fragment_perma_id).sections, chapter:) end def extract_fragment_from(entry:, chapter:, title: nil) @@ -69,6 +57,11 @@ def extract_fragment_from(entry:, chapter:, title: nil) private + def fragment_chapter(perma_id) + content.chapters.find { |chapter| chapter.perma_id == perma_id.to_i } || + raise(ActiveRecord::RecordNotFound) + end + def create_entry(locale:) Pageflow::DraftEntry.new( Pageflow::Entry.create!( @@ -84,7 +77,7 @@ def create_entry(locale:) end def create_fragment_chapter(chapter, title) - storyline = storylines.detect { |candidate| candidate.configuration['main'] } + storyline = content.storylines.detect { |candidate| candidate.configuration['main'] } storyline.chapters.create!(position: (storyline.chapters.maximum(:position) || -1) + 1, configuration: fragment_configuration(chapter, title)) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb new file mode 100644 index 0000000000..a376a18c9d --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -0,0 +1,53 @@ +module PageflowScrolled + # Storylines, chapters, sections and content elements of a revision. + # + # @api private + class RevisionContent + def initialize(revision, include_hidden: false, cutoff: false) + @revision = revision + @include_hidden = include_hidden + @cutoff = cutoff + end + + def storylines + @storylines ||= Storyline.all_for_revision(@revision).to_a + end + + def chapters + @chapters ||= Chapter.all_for_revision(@revision).reject do |chapter| + !@include_hidden && only_hidden_sections?(chapter) + end + end + + def sections + @sections ||= (@cutoff ? sections_before_cutoff : all_sections).reject do |section| + !@include_hidden && section.configuration['hidden'] + end + end + + def content_elements + @content_elements ||= ContentElement.all_for_revision(@revision).where(section: sections).to_a + end + + private + + def only_hidden_sections?(chapter) + chapter_sections = sections_by_chapter_id.fetch(chapter.id, []) + + chapter_sections.any? && + chapter_sections.all? { |section| section.configuration['hidden'] } + end + + def sections_by_chapter_id + @sections_by_chapter_id ||= all_sections.group_by(&:chapter_id) + end + + def sections_before_cutoff + (storylines.first || Storyline.new).sections_before_cutoff_section + end + + def all_sections + @all_sections ||= Section.all_for_revision(@revision).to_a + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder index ecb946e699..46e8de3d9c 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder @@ -5,33 +5,11 @@ json.array!(@libraries) do |library| json.title library.title json.collections do - json.storylines do - json.array!(library.storylines) do |storyline| - json.partial! 'pageflow_scrolled/storylines/storyline', storyline: - end - end - - json.chapters do - json.array!(library.chapters) do |chapter| - json.partial! 'pageflow_scrolled/chapters/chapter', chapter: - end - end - - json.sections do - json.array!(library.sections) do |section| - json.partial! 'pageflow_scrolled/sections/section', section: - end - end - - json.content_elements do - json.array!(library.content_elements) do |content_element| - json.partial! 'pageflow_scrolled/content_elements/content_element', - content_element: - end - end + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', + content: library.content) json.linked_entries(scrolled_linked_entries(library.entry, - library.content_elements)) do |linked_entry| + library.content.content_elements)) do |linked_entry| json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder new file mode 100644 index 0000000000..85e898c1aa --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder @@ -0,0 +1,23 @@ +json.storylines do + json.array!(content.storylines) do |storyline| + json.partial!('pageflow_scrolled/storylines/storyline', storyline:) + end +end + +json.chapters do + json.array!(content.chapters) do |chapter| + json.partial!('pageflow_scrolled/chapters/chapter', chapter:) + end +end + +json.sections do + json.array!(content.sections) do |section| + json.partial!('pageflow_scrolled/sections/section', section:) + end +end + +json.content_elements do + json.array!(content.content_elements) do |content_element| + json.partial!('pageflow_scrolled/content_elements/content_element', content_element:) + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder index 8dd3d1964c..e9830d30b1 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder @@ -90,30 +90,7 @@ unless options[:skip_collections] end end - json.storylines do - json.array!(storylines) do |storyline| - json.partial! 'pageflow_scrolled/storylines/storyline', storyline: - end - end - - json.chapters do - json.array!(chapters) do |chapter| - json.partial! 'pageflow_scrolled/chapters/chapter', chapter: - end - end - - json.sections do - json.array!(sections) do |section| - json.partial! 'pageflow_scrolled/sections/section', section: - end - end - - json.content_elements do - json.array!(content_elements) do |content_element| - json.partial! 'pageflow_scrolled/content_elements/content_element', - content_element: - end - end + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', content:) json.linked_entries(linked_entries) do |linked_entry| json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb index 71cfe1a5a9..a81bc998fa 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb @@ -75,7 +75,7 @@ module PageflowScrolled chapter = create(:scrolled_chapter, revision: entry.draft) library = FragmentLibrary.new(Pageflow::DraftEntry.new(entry)) - expect(library.chapters).to eq([chapter]) + expect(library.content.chapters).to eq([chapter]) end end @@ -160,8 +160,9 @@ module PageflowScrolled library.extract_fragment_from(entry: Pageflow::DraftEntry.new(entry), chapter:) - expect(library.chapters.map(&:configuration)).to match([include('title' => 'Intro')]) - expect(library.sections.count).to eq(1) + expect(library.content.chapters.map(&:configuration)) + .to match([include('title' => 'Intro')]) + expect(library.content.sections.size).to eq(1) end end end 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 new file mode 100644 index 0000000000..7aaa9a998f --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb @@ -0,0 +1,84 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe RevisionContent do + describe '#chapters' do + it 'skips chapters that only contain hidden sections' do + revision = create(:revision) + chapter1 = create(:scrolled_chapter, revision:, position: 1) + create(:section, chapter: chapter1, configuration: {hidden: true}) + chapter2 = create(:scrolled_chapter, revision:, position: 2) + create(:section, chapter: chapter2, configuration: {hidden: true}) + create(:section, chapter: chapter2) + chapter3 = create(:scrolled_chapter, revision:, position: 3) + + result = RevisionContent.new(revision).chapters + + expect(result).to eq([chapter2, chapter3]) + end + + it 'includes chapters that only contain hidden sections if requested' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + create(:section, chapter:, configuration: {hidden: true}) + + result = RevisionContent.new(revision, include_hidden: true).chapters + + expect(result).to eq([chapter]) + end + end + + describe '#sections' do + it 'skips hidden sections' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + section = create(:section, chapter:, position: 1) + create(:section, chapter:, position: 2, configuration: {hidden: true}) + + expect(RevisionContent.new(revision).sections).to eq([section]) + end + + it 'includes hidden sections if requested' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + sections = [ + create(:section, chapter:, position: 1), + create(:section, chapter:, position: 2, configuration: {hidden: true}) + ] + + expect(RevisionContent.new(revision, include_hidden: true).sections).to eq(sections) + end + + it 'only includes sections before cutoff section if requested' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + section = create(:section, chapter:, position: 1) + cutoff_section = create(:section, chapter:, position: 2) + revision.update!(configuration: {'cutoff_section_perma_id' => cutoff_section.perma_id}) + + expect(RevisionContent.new(revision, cutoff: true).sections).to eq([section]) + end + end + + describe '#content_elements' do + it 'returns content elements of sections' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + content_element = create(:content_element, section: create(:section, chapter:)) + create(:content_element, + section: create(:section, chapter:, configuration: {hidden: true})) + + expect(RevisionContent.new(revision).content_elements).to eq([content_element]) + end + end + + describe '#storylines' do + it 'returns storylines of revision' do + revision = create(:revision) + storyline = create(:scrolled_storyline, revision:) + + expect(RevisionContent.new(revision).storylines).to include(storyline) + end + end + end +end From bc66e7f8cdd282b9236b03921c8025261799ccdc Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:00:17 +0200 Subject: [PATCH 07/17] Compute chapter slugs on the server Published entries no longer need to ship the slugify char map. The editor still computes slugs to update them as chapter titles change. Slugs take chapters with only hidden sections into account like the editor does. Lets links to chapters of other entries use the same slugs by only loading chapters of linked entries. --- .../pageflow_scrolled/revision_content.rb | 14 +++- .../_content_collections.json.jbuilder | 1 + .../models/ScrolledEntry/chapterSlugs-spec.js | 74 +++++++++++++++++++ .../utils}/chapterSlug-spec.js | 2 +- .../{shared => editor/utils}/slugify-spec.js | 4 +- .../package/spec/entryState/structure-spec.js | 55 +++++--------- .../editor/collections/ChaptersCollection.js | 9 ++- .../src/editor/models/ScrolledEntry/index.js | 25 +++++-- .../{shared => editor/utils}/chapterSlug.js | 0 .../src/{shared => editor/utils}/slugify.js | 0 .../package/src/entryState/collections.js | 2 +- .../package/src/entryState/structure.js | 5 +- .../package/src/testHelpers/normalizeSeed.js | 12 +++ .../fragment_libraries_controller_spec.rb | 1 + .../entry_json_seed_helper_spec.rb | 24 ++++++ .../revision_content_spec.rb | 28 +++++++ 16 files changed, 201 insertions(+), 55 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js rename entry_types/scrolled/package/spec/{shared => editor/utils}/chapterSlug-spec.js (96%) rename entry_types/scrolled/package/spec/{shared => editor/utils}/slugify-spec.js (68%) rename entry_types/scrolled/package/src/{shared => editor/utils}/chapterSlug.js (100%) rename entry_types/scrolled/package/src/{shared => editor/utils}/slugify.js (100%) 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 a376a18c9d..c552c64587 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -14,11 +14,15 @@ def storylines end def chapters - @chapters ||= Chapter.all_for_revision(@revision).reject do |chapter| + @chapters ||= all_chapters.reject do |chapter| !@include_hidden && only_hidden_sections?(chapter) end end + def chapter_slug_for(chapter) + chapter_slugs[chapter.perma_id] + end + def sections @sections ||= (@cutoff ? sections_before_cutoff : all_sections).reject do |section| !@include_hidden && section.configuration['hidden'] @@ -49,5 +53,13 @@ def sections_before_cutoff def all_sections @all_sections ||= Section.all_for_revision(@revision).to_a end + + def all_chapters + @all_chapters ||= Chapter.all_for_revision(@revision).to_a + end + + def chapter_slugs + @chapter_slugs ||= ChapterSlugs.new(all_chapters).to_h + end end end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder index 85e898c1aa..8b248d58c8 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder @@ -7,6 +7,7 @@ end json.chapters do json.array!(content.chapters) do |chapter| json.partial!('pageflow_scrolled/chapters/chapter', chapter:) + json.chapter_slug content.chapter_slug_for(chapter) end end 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 new file mode 100644 index 0000000000..00456d69e9 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js @@ -0,0 +1,74 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('chapter slugs', () => { + function createEntry(chapters, storylines) { + return factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({chapters, storylines}) + }); + } + + it('sets chapter slugs on chapters', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('intro'); + }); + + it('orders chapters by storyline position like the server', () => { + const entry = createEntry([ + {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: 2, position: 1, configuration: {}} + ]); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('intro-100'); + }); + + it('updates chapter slug when title changes', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + entry.chapters.get(1).configuration.set('title', 'Outro', {autoSave: false}); + + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('outro'); + }); + + it('updates slugs of other chapters with same title', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 200, position: 1, configuration: {title: 'Intro'}} + ]); + + entry.chapters.get(1).configuration.set('title', 'Other', {autoSave: false}); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + }); + + it('updates slugs when chapters are removed', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 200, position: 1, configuration: {title: 'Intro'}} + ]); + + entry.chapters.remove(entry.chapters.get(1)); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + }); + + it('does not include chapter slugs when saving chapters', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + expect(entry.chapters.get(1).toJSON()).not.toHaveProperty('chapterSlug'); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js b/entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js similarity index 96% rename from entry_types/scrolled/package/spec/shared/chapterSlug-spec.js rename to entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js index 0ccb186619..33249503ea 100644 --- a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js +++ b/entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js @@ -1,4 +1,4 @@ -import {getChapterSlugs} from 'shared/chapterSlug'; +import {getChapterSlugs} from 'editor/utils/chapterSlug'; describe('getChapterSlugs', () => { it('returns slugified title', () => { diff --git a/entry_types/scrolled/package/spec/shared/slugify-spec.js b/entry_types/scrolled/package/spec/editor/utils/slugify-spec.js similarity index 68% rename from entry_types/scrolled/package/spec/shared/slugify-spec.js rename to entry_types/scrolled/package/spec/editor/utils/slugify-spec.js index e02a52efb6..b87927b888 100644 --- a/entry_types/scrolled/package/spec/shared/slugify-spec.js +++ b/entry_types/scrolled/package/spec/editor/utils/slugify-spec.js @@ -1,6 +1,6 @@ -import {slugify} from 'shared/slugify'; +import {slugify} from 'editor/utils/slugify'; -import cases from '../fixtures/slugify.json'; +import cases from '../../fixtures/slugify.json'; describe('slugify', () => { cases.forEach(({title, slug}) => { diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index 21d1bbea1f..aa9a06d301 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -16,6 +16,7 @@ import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories} from 'pageflow/testHelpers'; import {renderHookInEntry, normalizeSeed} from 'support'; +import {act} from '@testing-library/react'; const storylinesSeed = [ @@ -585,54 +586,34 @@ describe('useChapters', () => { ]); }); - it('sanitizes chapter titles', () => { + it('reads chapter slugs from seed', () => { const {result} = renderHookInEntry( () => useChapters(), { seed: { chapters: [ - { - configuration: { - title: 'SmallCase', - } - }, - { - configuration: { - title: 'RemöveUmläütß', - } - }, - { - configuration: { - title: 'remove space', - } - }, - { - configuration: { - title: 'remove#special$character', - } - }, - + {permaId: 10, chapterSlug: 'computed-on-server', configuration: {title: 'Intro'}} ] } } ); - const chapters = result.current; + expect(result.current).toMatchObject([{chapterSlug: 'computed-on-server'}]); + }); - expect(chapters).toMatchObject([ - { - chapterSlug: "smallcase", - }, - { - chapterSlug: 'remoeveumlaeuetss', - }, - { - chapterSlug: 'remove-space', - }, - { - chapterSlug: 'removespecialdollarcharacter', - } - ]); + it('updates chapter slugs when titles change in editor', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 1, permaId: 10, configuration: {title: 'Intro'}}] + }) + }); + const {result} = renderHookInEntry(() => useChapters(), { + setup: dispatch => watchCollections(entry, {dispatch}) + }); + + act(() => entry.chapters.get(1).configuration.set('title', 'Outro', {autoSave: false})); + + expect(result.current).toMatchObject([{chapterSlug: 'outro'}]); }); }); diff --git a/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js b/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js index 3349c7c534..87420117e1 100644 --- a/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js @@ -10,7 +10,12 @@ export const ChaptersCollection = Backbone.Collection.extend({ entryTypeEditorControllerUrls.forCollection({resources: 'chapters'}) ], - comparator: function(chapter) { - return chapter.get('position'); + comparator: function(chapterA, chapterB) { + return storylinePosition(chapterA) - storylinePosition(chapterB) || + chapterA.get('position') - chapterB.get('position'); } }); + +function storylinePosition(chapter) { + return chapter.storyline?.get('position') || 0; +} 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 4e36514e90..505fd95ba5 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -30,7 +30,7 @@ import {updateContentElement} from './updateContentElement'; import {sortColors} from './sortColors'; import {Scale} from '../../../shared/Scale'; -import {getChapterSlugs} from '../../../shared/chapterSlug'; +import {getChapterSlugs} from '../../utils/chapterSlug'; const typographySizeSuffixes = ['xxxl', 'xxl', 'xl', 'lg', 'md', 'sm', 'xs', 'xxs', 'xxxs']; @@ -74,6 +74,12 @@ export const ScrolledEntry = Entry.extend({ this.storylines = new StorylinesCollection(seed.collections.storylines, {chapters: this.chapters, entry: this}); + this.chapters.sort(); + + this.listenTo(this.chapters, + 'add remove sort change:permaId change:configuration:title', + this.updateChapterSlugs); + this.updateChapterSlugs(); this.sections.sort(); @@ -335,13 +341,18 @@ export const ScrolledEntry = Entry.extend({ }, getChapterPermalink(chapter) { - const allChapters = this.chapters.map(c => ({ - permaId: c.get('permaId'), - configuration: c.configuration.attributes - })); - const chapterSlugs = getChapterSlugs(allChapters); + return `${this.get('pretty_url')}#${chapter.get('chapterSlug')}`; + }, - return `${this.get('pretty_url')}#${chapterSlugs[chapter.get('permaId')]}`; + updateChapterSlugs() { + const chapterSlugs = getChapterSlugs(this.chapters.map(chapter => ({ + permaId: chapter.get('permaId'), + configuration: chapter.configuration.attributes + }))); + + this.chapters.each(chapter => + chapter.set('chapterSlug', chapterSlugs[chapter.get('permaId')]) + ); }, getPaletteColors({name} = {}) { diff --git a/entry_types/scrolled/package/src/shared/chapterSlug.js b/entry_types/scrolled/package/src/editor/utils/chapterSlug.js similarity index 100% rename from entry_types/scrolled/package/src/shared/chapterSlug.js rename to entry_types/scrolled/package/src/editor/utils/chapterSlug.js diff --git a/entry_types/scrolled/package/src/shared/slugify.js b/entry_types/scrolled/package/src/editor/utils/slugify.js similarity index 100% rename from entry_types/scrolled/package/src/shared/slugify.js rename to entry_types/scrolled/package/src/editor/utils/slugify.js diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index ef010cf4aa..65b7865697 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -54,7 +54,7 @@ function collectionSpecs(entry) { { collection: chapters, name: 'chapters', - attributes: ['id', 'permaId', 'storylineId'], + attributes: ['id', 'permaId', 'storylineId', 'chapterSlug'], 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 5bae9db829..78261ca34a 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -1,6 +1,5 @@ import {useMemo, useCallback} from 'react'; import {useEntryStateCollectionItems, useEntryStateCollectionItem} from './EntryStateProvider'; -import {getChapterSlugs} from '../shared/chapterSlug'; /** * Returns a nested data structure representing the chapters and sections @@ -349,13 +348,11 @@ export function useChapters() { const chapters = useEntryStateCollectionItems('chapters'); return useMemo(() => { - const chapterSlugs = getChapterSlugs(chapters); - return chapters.map((chapter, index) => ({ id: chapter.id, permaId: chapter.permaId, storylineId: chapter.storylineId, - chapterSlug: chapterSlugs[chapter.permaId], + chapterSlug: chapter.chapterSlug, index, ...chapter.configuration })); diff --git a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js index 56eccf9408..7d2781ad02 100644 --- a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js +++ b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js @@ -1,3 +1,5 @@ +import {getChapterSlugs} from '../editor/utils/chapterSlug'; + /** * Construct data structure that resembles seed generated by server * side JBuilder templates. @@ -216,6 +218,16 @@ function normalizeSections(sections = [], contentElements) { } function normalizeChapters(chapters = [], sections) { + const normalizedChapters = normalizeChaptersWithoutSlugs(chapters, sections); + const chapterSlugs = getChapterSlugs(normalizedChapters); + + return normalizedChapters.map(chapter => ({ + chapterSlug: chapterSlugs[chapter.permaId], + ...chapter + })); +} + +function normalizeChaptersWithoutSlugs(chapters, sections) { const chapterDefaults = { configuration: {} }; diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_libraries_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_libraries_controller_spec.rb index 8b0d285003..f8c75f138e 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_libraries_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_libraries_controller_spec.rb @@ -102,6 +102,7 @@ module PageflowScrolled expect(response.body).to include_json([{ collections: { chapters: [{permaId: chapter.perma_id, + chapterSlug: 'intro', configuration: {title: 'Intro', kind: 'intro'}}], sections: [{permaId: section.perma_id}], 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 c30b80a7ba..6bf2329787 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 @@ -111,6 +111,30 @@ def render(helper, entry, options = {}) }) end + it 'renders chapter slugs' do + entry = create(:published_entry, type_name: 'scrolled') + chapters = [ + create(:scrolled_chapter, + revision: entry.revision, + position: 1, + configuration: {title: 'Intro'}), + create(:scrolled_chapter, + revision: entry.revision, + position: 2, + configuration: {title: 'Intro'}) + ] + + result = render(helper, entry) + + expect(result) + .to include_json(collections: { + chapters: [ + {chapterSlug: 'intro'}, + {chapterSlug: "intro-#{chapters[1].perma_id}"} + ] + }) + end + it 'orders chapters according to position attribute' do entry = create(:published_entry, type_name: 'scrolled') chapter1 = create(:scrolled_chapter, revision: entry.revision, position: 4) 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 7aaa9a998f..4bdf6a47e5 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 @@ -28,6 +28,34 @@ module PageflowScrolled end end + describe '#chapter_slug_for' do + it 'computes slugs of chapters' do + revision = create(:revision) + chapters = [ + create(:scrolled_chapter, revision:, position: 1, configuration: {title: 'Intro'}), + create(:scrolled_chapter, revision:, position: 2, configuration: {title: 'Intro'}) + ] + + content = RevisionContent.new(revision) + + expect(content.chapter_slug_for(chapters[0])).to eq('intro') + expect(content.chapter_slug_for(chapters[1])).to eq("intro-#{chapters[1].perma_id}") + end + + it 'takes chapters with only hidden sections into account' do + revision = create(:revision) + hidden_chapter = create(:scrolled_chapter, + revision:, position: 1, configuration: {title: 'Intro'}) + create(:section, chapter: hidden_chapter, configuration: {hidden: true}) + chapter = create(:scrolled_chapter, + revision:, position: 2, configuration: {title: 'Intro'}) + + content = RevisionContent.new(revision) + + expect(content.chapter_slug_for(chapter)).to eq("intro-#{chapter.perma_id}") + end + end + describe '#sections' do it 'skips hidden sections' do revision = create(:revision) From 95107c5d39f7fc20691a4b795f59add957b17997 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:00:46 +0200 Subject: [PATCH 08/17] Find main storyline of revision content Matches the frontend, which also determines the main storyline by its configuration. --- .../pageflow_scrolled/fragment_library.rb | 2 +- .../pageflow_scrolled/revision_content.rb | 4 ++++ .../revision_content_spec.rb | 20 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 339b063de6..4381a2a495 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -77,7 +77,7 @@ def create_entry(locale:) end def create_fragment_chapter(chapter, title) - storyline = content.storylines.detect { |candidate| candidate.configuration['main'] } + storyline = content.main_storyline storyline.chapters.create!(position: (storyline.chapters.maximum(:position) || -1) + 1, configuration: fragment_configuration(chapter, title)) 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 c552c64587..e6cb530b8b 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -13,6 +13,10 @@ def storylines @storylines ||= Storyline.all_for_revision(@revision).to_a end + def main_storyline + storylines.detect { |storyline| storyline.configuration['main'] } + end + def chapters @chapters ||= all_chapters.reject do |chapter| !@include_hidden && only_hidden_sections?(chapter) 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 4bdf6a47e5..db319a8b99 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 @@ -2,6 +2,26 @@ module PageflowScrolled RSpec.describe RevisionContent do + 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}) + + 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: {}) + + expect(RevisionContent.new(revision).main_storyline).to be_nil + end + end + describe '#chapters' do it 'skips chapters that only contain hidden sections' do revision = create(:revision) From 83e2fb90e0d43e170ad9fd1e22e4485db4752f58 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:02:34 +0200 Subject: [PATCH 09/17] Sign links to chapters and sections of other entries Signatures cover the linked chapter or section. Otherwise links could reveal titles of chapters that nobody with access to the entry chose to link to. Signatures of links to entries as a whole keep their payload to stay valid. --- .../entry_link_references.rb | 44 ++++++++++--- .../pageflow_scrolled/entry_link_signature.rb | 19 ++++-- .../entry_link_references_spec.rb | 64 +++++++++++++++++++ .../entry_link_signature_spec.rb | 30 +++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb index ae83c04b04..c80c99fa8c 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb @@ -1,5 +1,6 @@ module PageflowScrolled - # Find signed links to other entries in configurations. + # Find signed links to other entries and their chapters and + # sections in configurations. # # @api private class EntryLinkReferences @@ -8,20 +9,47 @@ def initialize(configurations) end def entry_ids - signed_entry_ids - .uniq - .select { |entry_id, signature| EntryLinkSignature.valid?(entry_id, signature) } - .map(&:first) - .uniq + valid_links.map(&:entry_id).uniq + end + + def chapter_perma_ids(entry_id) + perma_ids(entry_id, :chapter) + end + + def section_perma_ids(entry_id) + perma_ids(entry_id, :section) end private - def signed_entry_ids + Link = Struct.new(:entry_id, :chapter, :section, :signature) + + def perma_ids(entry_id, attribute) + valid_links + .select { |link| link.entry_id == entry_id } + .filter_map(&attribute) + .uniq + end + + def valid_links + @valid_links ||= links.uniq.select do |link| + EntryLinkSignature.valid?(link.entry_id, + link.signature, + chapter: link.chapter, + section: link.section) + end + end + + def links @configurations.each_with_object([]) do |configuration, result| each_href(configuration) do |href| entry_id = to_id(href['entry']) - result << [entry_id, href['signature']] if entry_id + next unless entry_id + + result << Link.new(entry_id, + to_id(href['chapter']), + to_id(href['section']), + href['signature']) end end end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb index cfc4104492..3d5de6e7ef 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb @@ -10,17 +10,28 @@ module EntryLinkSignature private_constant :PURPOSE class << self - def generate(entry_id) - verifier.generate(entry_id, purpose: PURPOSE) + def generate(entry_id, chapter: nil, section: nil) + verifier.generate(payload(entry_id, chapter:, section:), purpose: PURPOSE) end - def valid?(entry_id, signature) + def valid?(entry_id, signature, chapter: nil, section: nil) signature.is_a?(String) && - verifier.verified(signature, purpose: PURPOSE) == entry_id + verifier.verified(signature, purpose: PURPOSE) == + payload(entry_id, chapter:, section:) end private + def payload(entry_id, chapter:, section:) + if chapter + [entry_id, 'chapter', chapter] + elsif section + [entry_id, 'section', section] + else + entry_id + end + end + def verifier Rails.application.message_verifier('pageflow_scrolled_entry_link') end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb index 4f3bc54438..2d5162f5da 100644 --- a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb +++ b/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb @@ -113,5 +113,69 @@ module PageflowScrolled expect(references.entry_ids).to eq([]) end + + describe '#chapter_perma_ids' do + it 'collects perma ids of signed chapter links per entry' do + configurations = [ + {'href' => chapter_href(7, 10)}, + {'href' => chapter_href(7, 11)}, + {'href' => chapter_href(7, 10)}, + {'href' => chapter_href(8, 12)} + ] + + references = EntryLinkReferences.new(configurations) + + expect(references.chapter_perma_ids(7)).to eq([10, 11]) + expect(references.entry_ids).to eq([7, 8]) + end + + it 'ignores chapter links signed for entry only' do + configurations = [ + {'href' => {'entry' => 7, 'chapter' => 10, + 'signature' => EntryLinkSignature.generate(7)}} + ] + + references = EntryLinkReferences.new(configurations) + + expect(references.chapter_perma_ids(7)).to eq([]) + expect(references.entry_ids).to eq([]) + end + + it 'returns empty array for entry without chapter links' do + references = EntryLinkReferences.new([]) + + expect(references.chapter_perma_ids(7)).to eq([]) + end + end + + describe '#section_perma_ids' do + it 'collects perma ids of signed section links per entry' do + configurations = [ + {'href' => {'entry' => 7, 'section' => 20, + 'signature' => EntryLinkSignature.generate(7, section: 20)}} + ] + + references = EntryLinkReferences.new(configurations) + + expect(references.section_perma_ids(7)).to eq([20]) + end + + it 'ignores section links with signature of chapter' do + configurations = [ + {'href' => {'entry' => 7, 'section' => 20, + 'signature' => EntryLinkSignature.generate(7, chapter: 20)}} + ] + + references = EntryLinkReferences.new(configurations) + + expect(references.section_perma_ids(7)).to eq([]) + end + end + + def chapter_href(entry_id, chapter_perma_id) + {'entry' => entry_id, + 'chapter' => chapter_perma_id, + 'signature' => EntryLinkSignature.generate(entry_id, chapter: chapter_perma_id)} + end end end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_signature_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/entry_link_signature_spec.rb index c3bf9b2962..8232661e92 100644 --- a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_signature_spec.rb +++ b/entry_types/scrolled/spec/pageflow_scrolled/entry_link_signature_spec.rb @@ -25,5 +25,35 @@ module PageflowScrolled it 'rejects non-string signature' do expect(EntryLinkSignature.valid?(7, {'some' => 'hash'})).to eq(false) end + + it 'accepts signature generated for chapter of entry' do + signature = EntryLinkSignature.generate(7, chapter: 10) + + expect(EntryLinkSignature.valid?(7, signature, chapter: 10)).to eq(true) + end + + it 'rejects signature generated for other chapter' do + signature = EntryLinkSignature.generate(7, chapter: 11) + + expect(EntryLinkSignature.valid?(7, signature, chapter: 10)).to eq(false) + end + + it 'rejects signature generated for entry when used for chapter' do + signature = EntryLinkSignature.generate(7) + + expect(EntryLinkSignature.valid?(7, signature, chapter: 10)).to eq(false) + end + + it 'accepts signature generated for section of entry' do + signature = EntryLinkSignature.generate(7, section: 20) + + expect(EntryLinkSignature.valid?(7, signature, section: 20)).to eq(true) + end + + it 'rejects signature generated for chapter with same perma id when used for section' do + signature = EntryLinkSignature.generate(7, chapter: 20) + + expect(EntryLinkSignature.valid?(7, signature, section: 20)).to eq(false) + end end end From aa1f9a0d754d9692584c2e153a64258c94d0edcd Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:10:50 +0200 Subject: [PATCH 10/17] Render linked chapters of other entries in seed Entry link references move to models and only determine ids of linked entries and chapters. Linked entries load chapters of the published revisions and drafts of all linked entries with a single query. Published entries only receive slugs of linked chapters. Slugs of chapters that are not published yet are taken from the draft to make links work once the linked entry is published. Previews and the editor also receive chapter titles and slugs of the draft to let preview links point to chapters. Links to sections only need the perma id of the section. --- .../entry_json_seed_helper.rb | 2 - .../linked_entries_helper.rb | 36 ++-- .../entry_link_references.rb | 21 +- .../models/pageflow_scrolled/linked_entry.rb | 37 ++++ .../pageflow_scrolled/linked_entry/chapter.rb | 8 + .../linked_entry/chapter_loader.rb | 66 +++++++ .../fragment_libraries/index.json.jbuilder | 5 +- .../entry_json_seed/_entry.json.jbuilder | 4 +- .../_linked_entry.json.jbuilder | 9 - .../linked_entries/_attributes.json.jbuilder | 3 + .../linked_entries/_chapter.json.jbuilder | 2 + .../_linked_entry.json.jbuilder | 7 + .../_preview_linked_entry.json.jbuilder | 13 ++ .../entry_json_seed_helper_spec.rb | 172 +++++++++++++++++ .../entry_link_references_spec.rb | 119 ++++++++++++ .../pageflow_scrolled/linked_entry_spec.rb | 128 +++++++++++++ .../entry_link_references_spec.rb | 181 ------------------ 17 files changed, 579 insertions(+), 234 deletions(-) rename entry_types/scrolled/{lib => app/models}/pageflow_scrolled/entry_link_references.rb (80%) create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb create mode 100644 entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb delete mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/entry_link_references_spec.rb create mode 100644 entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb delete mode 100644 entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb index be02cb66b1..65cb48e100 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb @@ -41,8 +41,6 @@ def scrolled_entry_json_seed(json, scrolled_entry, options = {}) content.content_elements, include_unused: options[:include_unused_file_reference_locations] ), - linked_entries: scrolled_linked_entries(scrolled_entry, - content.content_elements + widgets), widgets:, options:) end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb index 90fb567bfc..9456ff2e85 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb @@ -1,31 +1,31 @@ module PageflowScrolled - # Determine urls of entries that links point to. + # Render entries and chapters that links point to. # # @api private module LinkedEntriesHelper include Pageflow::SocialShareHelper - def scrolled_linked_entries(scrolled_entry, configurables) - entry_ids = EntryLinkReferences.new(configurables.map(&:configuration)).entry_ids - return [] if entry_ids.empty? - - Pageflow::Entry - .where(id: entry_ids, account_id: scrolled_entry.account.id) - .preload(:site, :account, :published_revision, :draft, permalink: :directory) - .order(:id) - .map do |entry| - scrolled_linked_entry(entry, include_preview: !scrolled_entry.published_revision?) - end + def scrolled_linked_entries_json_seed(json, scrolled_entry, configurables) + json.linked_entries(scrolled_linked_entries(scrolled_entry, configurables)) do |linked_entry| + json.partial!(scrolled_linked_entry_partial(scrolled_entry), linked_entry:) + end end - def scrolled_linked_entry(entry, include_preview:) - linked_entry = Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) - result = {entry: linked_entry, url: social_share_entry_url(linked_entry)} + private - return result unless include_preview + def scrolled_linked_entries(scrolled_entry, configurables) + LinkedEntry.all( + scrolled_entry.account.entries.preload(:site, :account, permalink: :directory), + EntryLinkReferences.new(configurables.map(&:configuration)).chapter_perma_ids_by_entry_id + ) + end - result.merge(preview_url: main_app.preview_admin_entry_path(entry), - publication_state: entry.publication_state) + def scrolled_linked_entry_partial(scrolled_entry) + if scrolled_entry.published_revision? + 'pageflow_scrolled/linked_entries/linked_entry' + else + 'pageflow_scrolled/linked_entries/preview_linked_entry' + end end end end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb b/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb similarity index 80% rename from entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb rename to entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb index c80c99fa8c..b1f6d22342 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb @@ -8,29 +8,16 @@ def initialize(configurations) @configurations = configurations end - def entry_ids - valid_links.map(&:entry_id).uniq - end - - def chapter_perma_ids(entry_id) - perma_ids(entry_id, :chapter) - end - - def section_perma_ids(entry_id) - perma_ids(entry_id, :section) + def chapter_perma_ids_by_entry_id + valid_links + .group_by(&:entry_id) + .transform_values { |links| links.filter_map(&:chapter).uniq } end private Link = Struct.new(:entry_id, :chapter, :section, :signature) - def perma_ids(entry_id, attribute) - valid_links - .select { |link| link.entry_id == entry_id } - .filter_map(&attribute) - .uniq - end - def valid_links @valid_links ||= links.uniq.select do |link| EntryLinkSignature.valid?(link.entry_id, diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb new file mode 100644 index 0000000000..3159fc9121 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb @@ -0,0 +1,37 @@ +module PageflowScrolled + # Entry that links point to along with the chapters links point to. + # + # @api private + class LinkedEntry + attr_reader :entry, :chapters + + delegate :id, :title, :publication_state, to: :entry + + class << self + def all(scope, chapter_perma_ids_by_entry_id) + return [] if chapter_perma_ids_by_entry_id.empty? + + entries = scope + .where(id: chapter_perma_ids_by_entry_id.keys) + .preload(:published_revision, :draft) + .order(:id) + .to_a + chapter_loader = ChapterLoader.new(entries) + + entries.map do |entry| + new(entry, + chapters: chapter_loader.for(entry, chapter_perma_ids_by_entry_id[entry.id])) + end + end + end + + def initialize(entry, chapters: []) + @entry = entry + @chapters = chapters + end + + def published_entry + Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb new file mode 100644 index 0000000000..9e29678931 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb @@ -0,0 +1,8 @@ +module PageflowScrolled + class LinkedEntry + # Chapter of a linked entry that links point to. + # + # @api private + Chapter = Struct.new(:perma_id, :slug, :preview_slug, :title, keyword_init: true) + end +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 new file mode 100644 index 0000000000..2450a1a610 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb @@ -0,0 +1,66 @@ +module PageflowScrolled + class LinkedEntry + # Load chapters of linked entries. Chapters of the published + # revisions and drafts of all entries are loaded at once. + # + # @api private + class ChapterLoader + def initialize(entries) + @entries = entries + @chapters_by_revision = {} + end + + def for(entry, perma_ids) + return [] if perma_ids.empty? + + published = revision_chapters(entry.published_revision) + draft = revision_chapters(entry.draft) + + perma_ids.filter_map do |perma_id| + linked_chapter(perma_id, published[perma_id], draft[perma_id]) + end + end + + private + + RevisionChapter = Struct.new(:slug, :title) + + def linked_chapter(perma_id, published, draft) + return unless published || draft + + Chapter.new(perma_id:, + slug: (published || draft).slug, + preview_slug: (draft || published).slug, + title: (draft || published).title) + end + + def revision_chapters(revision) + return {} unless revision + + @chapters_by_revision[revision.id] ||= begin + chapters = chapters_by_revision_id.fetch(revision.id, []) + slugs = ChapterSlugs.new(chapters).to_h + + chapters.to_h do |chapter| + [chapter.perma_id, + RevisionChapter.new(slugs[chapter.perma_id], chapter.configuration['title'])] + end + end + end + + def chapters_by_revision_id + @chapters_by_revision_id ||= + PageflowScrolled::Chapter + .joins(:storyline) + .where(pageflow_scrolled_storylines: {revision_id: revision_ids}) + .order(PageflowScrolled::Chapter::POSITION_ORDER) + .select('pageflow_scrolled_chapters.*', 'pageflow_scrolled_storylines.revision_id') + .group_by(&:revision_id) + end + + def revision_ids + @entries.flat_map { |entry| [entry.published_revision&.id, entry.draft&.id] }.compact + end + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder index 46e8de3d9c..7e04cf3278 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder @@ -8,10 +8,7 @@ json.array!(@libraries) do |library| json.partial!('pageflow_scrolled/entry_json_seed/content_collections', content: library.content) - json.linked_entries(scrolled_linked_entries(library.entry, - library.content.content_elements)) do |linked_entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) - end + scrolled_linked_entries_json_seed(json, library.entry, library.content.content_elements) files_json_seed(json, library.entry) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder index e9830d30b1..308e7d59dc 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder @@ -92,9 +92,7 @@ unless options[:skip_collections] json.partial!('pageflow_scrolled/entry_json_seed/content_collections', content:) - json.linked_entries(linked_entries) do |linked_entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) - end + scrolled_linked_entries_json_seed(json, entry, content.content_elements + widgets) json.widgets do json.array!(widgets) do |widget| diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder deleted file mode 100644 index 3110e8b269..0000000000 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder +++ /dev/null @@ -1,9 +0,0 @@ -json.id entry.id -json.perma_id entry.id -json.url url - -if local_assigns.key?(:preview_url) - json.title entry.entry.title - json.preview_url preview_url - json.publication_state publication_state -end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder new file mode 100644 index 0000000000..341dc99f16 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder @@ -0,0 +1,3 @@ +json.id linked_entry.id +json.perma_id linked_entry.id +json.url social_share_entry_url(linked_entry.published_entry) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder new file mode 100644 index 0000000000..7fe47e417b --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder @@ -0,0 +1,2 @@ +json.perma_id chapter.perma_id +json.slug chapter.slug diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder new file mode 100644 index 0000000000..5d9275046b --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder @@ -0,0 +1,7 @@ +json.partial!('pageflow_scrolled/linked_entries/attributes', linked_entry:) + +if linked_entry.chapters.any? + json.chapters(linked_entry.chapters) do |chapter| + json.partial!('pageflow_scrolled/linked_entries/chapter', chapter:) + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder new file mode 100644 index 0000000000..9f4a43ce2d --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder @@ -0,0 +1,13 @@ +json.partial!('pageflow_scrolled/linked_entries/attributes', linked_entry:) + +json.title linked_entry.title +json.preview_url main_app.preview_admin_entry_path(linked_entry.entry) +json.publication_state linked_entry.publication_state + +if linked_entry.chapters.any? + json.chapters(linked_entry.chapters) do |chapter| + json.partial!('pageflow_scrolled/linked_entries/chapter', chapter:) + json.preview_slug chapter.preview_slug + json.title chapter.title + 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 6bf2329787..5e1c91a6b8 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 @@ -1985,6 +1985,178 @@ def json_config(result) }) end + context 'with links to chapters and sections' do + it 'renders only slug of linked chapter in published entry' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + create(:scrolled_chapter, + revision: linked_entry.revision, + position: 1, + configuration: {title: 'Intro'}) + chapter = create(:scrolled_chapter, + revision: linked_entry.revision, + position: 2, + configuration: {title: 'Intro'}) + entry = create(:published_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: {value: [chapter_link_node(linked_entry, chapter)]}) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries'][0]['chapters']) + .to eq([{'permaId' => chapter.perma_id, 'slug' => "intro-#{chapter.perma_id}"}]) + end + + it 'renders slug of draft for chapters not published yet' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, + revision: linked_entry.entry.draft, + configuration: {title: 'New chapter'}) + entry = create(:published_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: {value: [chapter_link_node(linked_entry, chapter)]}) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries'][0]['chapters']) + .to eq([{'permaId' => chapter.perma_id, 'slug' => 'new-chapter'}]) + end + + it 'skips chapters that do not exist anymore' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + entry = create(:published_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: { + value: [chapter_link_node(linked_entry, double(perma_id: 1234))] + }) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries'][0]) + .not_to have_key('chapters') + end + + it 'skips chapters with invalid signature' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, revision: linked_entry.revision) + entry = create(:published_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: { + value: [{ + type: 'link', + href: {entry: linked_entry.id, + chapter: chapter.perma_id, + signature: EntryLinkSignature.generate(linked_entry.id)}, + children: [{text: 'Link'}] + }] + }) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries']).to eq([]) + end + + it 'renders titles and preview slugs of chapters for draft entry' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + published_chapter = create(:scrolled_chapter, + revision: linked_entry.revision, + configuration: {title: 'Old title'}) + create(:scrolled_chapter, + revision: linked_entry.entry.draft, + perma_id: published_chapter.perma_id, + configuration: {title: 'New title'}) + entry = create(:draft_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: {value: [chapter_link_node(linked_entry, published_chapter)]}) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries'][0]['chapters']) + .to eq([{ + 'permaId' => published_chapter.perma_id, + 'slug' => 'old-title', + 'previewSlug' => 'new-title', + 'title' => 'New title' + }]) + end + + it 'renders preview data for published entry with custom revision' do + account = create(:account) + linked_entry = create(:published_entry, + type_name: 'scrolled', + account:, + title: 'linked-entry') + chapter = create(:scrolled_chapter, + revision: linked_entry.entry.draft, + configuration: {title: 'New chapter'}) + entry = create(:entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.draft, + configuration: {value: [chapter_link_node(linked_entry, chapter)]}) + + result = render(helper, Pageflow::PublishedEntry.new(entry, entry.draft)) + + expect(result).to include_json( + collections: { + linkedEntries: [{ + previewUrl: '/admin/entries/linked-entry/preview', + publicationState: 'published_without_password_protection', + chapters: [{ + permaId: chapter.perma_id, + title: 'New chapter', + previewSlug: 'new-chapter' + }] + }] + } + ) + end + + it 'renders entries of section links without sections' do + account = create(:account) + linked_entry = create(:published_entry, type_name: 'scrolled', account:) + section = create(:section, + chapter: create(:scrolled_chapter, revision: linked_entry.revision)) + entry = create(:draft_entry, type_name: 'scrolled', account:) + create(:content_element, + revision: entry.revision, + configuration: {value: [section_link_node(linked_entry, section)]}) + + result = render(helper, entry) + + expect(JSON.parse(result)['collections']['linkedEntries']) + .to match([include('id' => linked_entry.id)]) + expect(JSON.parse(result)['collections']['linkedEntries'][0]) + .not_to have_key('sections') + end + + def chapter_link_node(linked_entry, chapter) + {type: 'link', + href: {entry: linked_entry.id, + chapter: chapter.perma_id, + signature: EntryLinkSignature.generate(linked_entry.id, + chapter: chapter.perma_id)}, + children: [{text: 'Link'}]} + end + + def section_link_node(linked_entry, section) + {type: 'link', + href: {entry: linked_entry.id, + section: section.perma_id, + signature: EntryLinkSignature.generate(linked_entry.id, + section: section.perma_id)}, + children: [{text: 'Link'}]} + end + end + def link_node(entry_id:, signature: PageflowScrolled::EntryLinkSignature.generate(entry_id)) {type: 'link', href: {entry: entry_id, signature:}, children: [{text: 'Link'}]} end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/entry_link_references_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/entry_link_references_spec.rb new file mode 100644 index 0000000000..92666837f9 --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/entry_link_references_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe EntryLinkReferences do + describe '#chapter_perma_ids_by_entry_id' do + it 'returns ids of entries of signed links in slate trees' do + configurations = [ + { + 'value' => [ + { + 'type' => 'paragraph', + 'children' => [ + {'type' => 'link', 'href' => entry_href(5), 'children' => [{'text' => 'L'}]} + ] + } + ] + } + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq(5 => []) + end + + it 'collects links in keyed link objects' do + configurations = [ + { + 'itemLinks' => { + 'item1' => {'href' => entry_href(5), 'openInNewTab' => true}, + 'item2' => {'href' => entry_href(6)} + } + } + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq(5 => [], 6 => []) + end + + it 'ignores links with invalid signature' do + configurations = [ + {'href' => {'entry' => 5}}, + {'href' => {'entry' => 6, 'signature' => 'forged'}}, + {'href' => {'entry' => 7, 'signature' => EntryLinkSignature.generate(5)}} + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq({}) + end + + it 'ignores links without entry and invalid entry values' do + configurations = [ + { + 'value' => [ + {'type' => 'link', 'href' => {'section' => 10}}, + {'type' => 'link', 'href' => {'file' => {'permaId' => 5}}}, + {'type' => 'link', 'href' => 'https://example.com'}, + {'type' => 'link', 'href' => {'entry' => 'other'}}, + {'type' => 'link', 'href' => {'entry' => 0}} + ] + }, + nil + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq({}) + end + + it 'returns perma ids of signed chapter links once' do + configurations = [ + {'href' => chapter_href(5, 20)}, + {'href' => chapter_href(5, 20)}, + {'href' => {'entry' => 5, + 'section' => 30, + 'signature' => EntryLinkSignature.generate(5, section: 30)}} + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq(5 => [20]) + end + + it 'includes entries of section links' do + configurations = [ + {'href' => {'entry' => 5, + 'section' => 30, + 'signature' => EntryLinkSignature.generate(5, section: 30)}} + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq(5 => []) + end + + it 'ignores chapter links signed for entry only' do + configurations = [ + {'href' => {'entry' => 5, 'chapter' => 10, + 'signature' => EntryLinkSignature.generate(5)}} + ] + + result = EntryLinkReferences.new(configurations).chapter_perma_ids_by_entry_id + + expect(result).to eq({}) + end + end + + def entry_href(entry_id) + {'entry' => entry_id, 'signature' => EntryLinkSignature.generate(entry_id)} + end + + def chapter_href(entry_id, chapter_perma_id) + {'entry' => entry_id, + 'chapter' => chapter_perma_id, + 'signature' => EntryLinkSignature.generate(entry_id, chapter: chapter_perma_id)} + 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 new file mode 100644 index 0000000000..524fcf9fa0 --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe LinkedEntry do + describe '.all' do + it 'wraps entries of given scope ordered by id' do + entries = create_list(:entry, 2) + + result = LinkedEntry.all(Pageflow::Entry.all, + entries[1].id => [], + entries[0].id => []) + + expect(result.map(&:entry)).to eq(entries) + end + + it 'ignores entries outside of given scope' do + entry = create(:entry) + + expect(LinkedEntry.all(Pageflow::Entry.none, entry.id => [])).to eq([]) + end + + it 'returns chapters for given perma ids' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + create(:scrolled_chapter, revision: entry.draft) + + linked_entry = LinkedEntry.all(Pageflow::Entry.all, entry.id => [chapter.perma_id]).first + + expect(linked_entry.chapters.map(&:perma_id)).to eq([chapter.perma_id]) + end + + it 'skips chapters that can not be found' do + entry = create(:published_entry, type_name: 'scrolled') + + linked_entry = LinkedEntry.all(Pageflow::Entry.all, entry.id => [1234]).first + + expect(linked_entry.chapters).to eq([]) + end + + it 'uses slug of published revision' do + entry = create(:published_entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, + revision: entry.revision, + configuration: {title: 'Old title'}) + create(:scrolled_chapter, + revision: entry.entry.draft, + perma_id: chapter.perma_id, + configuration: {title: 'New title'}) + + expect(linked_chapter(entry.entry, chapter).slug).to eq('old-title') + end + + it 'falls back to slug of draft for chapters that are not published yet' do + entry = create(:published_entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, + revision: entry.entry.draft, + configuration: {title: 'New chapter'}) + + expect(linked_chapter(entry.entry, chapter).slug).to eq('new-chapter') + end + + it 'uses slug and title of draft for previews' do + entry = create(:published_entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, + revision: entry.revision, + configuration: {title: 'Old title'}) + create(:scrolled_chapter, + revision: entry.entry.draft, + perma_id: chapter.perma_id, + configuration: {title: 'New title'}) + + linked_chapter = linked_chapter(entry.entry, chapter) + + expect(linked_chapter.preview_slug).to eq('new-title') + expect(linked_chapter.title).to eq('New title') + end + + it 'falls back to slug and title of published revision for previews' do + entry = create(:published_entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, + revision: entry.revision, + configuration: {title: 'Deleted in draft'}) + + linked_chapter = linked_chapter(entry.entry, chapter) + + expect(linked_chapter.preview_slug).to eq('deleted-in-draft') + expect(linked_chapter.title).to eq('Deleted in draft') + end + + it 'takes preceding chapters into account for slugs' do + entry = create(:entry, type_name: 'scrolled') + create(:scrolled_chapter, revision: entry.draft, position: 1, + configuration: {title: 'Intro'}) + chapter = create(:scrolled_chapter, revision: entry.draft, position: 2, + configuration: {title: 'Intro'}) + + expect(linked_chapter(entry, chapter).slug).to eq("intro-#{chapter.perma_id}") + end + + it 'loads chapters of all entries with a single query' do + entries = create_list(:entry, 2, type_name: 'scrolled') + chapters = entries.map { |entry| create(:scrolled_chapter, revision: entry.draft) } + allow(Chapter).to receive(:joins).and_call_original + + result = LinkedEntry.all(Pageflow::Entry.all, + entries[0].id => [chapters[0].perma_id], + entries[1].id => [chapters[1].perma_id]) + + expect(result.flat_map(&:chapters).map(&:perma_id)).to eq(chapters.map(&:perma_id)) + expect(Chapter).to have_received(:joins).once + end + + it 'does not load chapters of entries linked as a whole' do + entry = create(:entry, type_name: 'scrolled') + create(:scrolled_chapter, revision: entry.draft) + allow(Chapter).to receive(:joins).and_call_original + + LinkedEntry.all(Pageflow::Entry.all, entry.id => []) + + expect(Chapter).not_to have_received(:joins) + end + end + + def linked_chapter(entry, chapter) + LinkedEntry.all(Pageflow::Entry.all, entry.id => [chapter.perma_id]).first.chapters.first + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb deleted file mode 100644 index 2d5162f5da..0000000000 --- a/entry_types/scrolled/spec/pageflow_scrolled/entry_link_references_spec.rb +++ /dev/null @@ -1,181 +0,0 @@ -require 'spec_helper' - -module PageflowScrolled - RSpec.describe EntryLinkReferences do - it 'collects entry ids of signed links in slate trees' do - configurations = [ - { - 'value' => [ - { - 'type' => 'paragraph', - 'children' => [ - { - 'type' => 'link', - 'href' => {'entry' => 7, 'signature' => EntryLinkSignature.generate(7)}, - 'children' => [{'text' => 'Link'}] - } - ] - } - ] - } - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([7]) - end - - it 'collects links in keyed link objects' do - configurations = [ - { - 'itemLinks' => { - 'item1' => { - 'href' => {'entry' => 7, 'signature' => EntryLinkSignature.generate(7)}, - 'openInNewTab' => true - }, - 'item2' => { - 'href' => {'entry' => 8, 'signature' => EntryLinkSignature.generate(8)} - } - } - } - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([7, 8]) - end - - it 'collects each entry id once' do - configurations = [ - {'href' => {'entry' => 7, 'signature' => EntryLinkSignature.generate(7)}}, - {'href' => {'entry' => 7, 'signature' => EntryLinkSignature.generate(7)}} - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([7]) - end - - it 'ignores links with invalid signature' do - configurations = [ - { - 'value' => [ - {'type' => 'link', 'href' => {'entry' => 7}}, - {'type' => 'link', 'href' => {'entry' => 8, 'signature' => 'forged'}}, - { - 'type' => 'link', - 'href' => {'entry' => 9, 'signature' => EntryLinkSignature.generate(10)} - } - ] - } - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([]) - end - - it 'ignores links without entry' do - configurations = [ - { - 'value' => [ - {'type' => 'link', 'href' => {'section' => 10}}, - {'type' => 'link', 'href' => {'chapter' => 3}}, - {'type' => 'link', 'href' => {'file' => {'permaId' => 5}}}, - {'type' => 'link', 'href' => 'https://example.com'} - ] - } - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([]) - end - - it 'ignores entry values that are not positive integers' do - configurations = [ - { - 'value' => [ - {'type' => 'link', 'href' => {'entry' => 'other'}}, - {'type' => 'link', 'href' => {'entry' => 0}}, - {'type' => 'link', 'href' => {'entry' => nil}} - ] - } - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.entry_ids).to eq([]) - end - - it 'supports nil configurations' do - references = EntryLinkReferences.new([nil]) - - expect(references.entry_ids).to eq([]) - end - - describe '#chapter_perma_ids' do - it 'collects perma ids of signed chapter links per entry' do - configurations = [ - {'href' => chapter_href(7, 10)}, - {'href' => chapter_href(7, 11)}, - {'href' => chapter_href(7, 10)}, - {'href' => chapter_href(8, 12)} - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.chapter_perma_ids(7)).to eq([10, 11]) - expect(references.entry_ids).to eq([7, 8]) - end - - it 'ignores chapter links signed for entry only' do - configurations = [ - {'href' => {'entry' => 7, 'chapter' => 10, - 'signature' => EntryLinkSignature.generate(7)}} - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.chapter_perma_ids(7)).to eq([]) - expect(references.entry_ids).to eq([]) - end - - it 'returns empty array for entry without chapter links' do - references = EntryLinkReferences.new([]) - - expect(references.chapter_perma_ids(7)).to eq([]) - end - end - - describe '#section_perma_ids' do - it 'collects perma ids of signed section links per entry' do - configurations = [ - {'href' => {'entry' => 7, 'section' => 20, - 'signature' => EntryLinkSignature.generate(7, section: 20)}} - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.section_perma_ids(7)).to eq([20]) - end - - it 'ignores section links with signature of chapter' do - configurations = [ - {'href' => {'entry' => 7, 'section' => 20, - 'signature' => EntryLinkSignature.generate(7, chapter: 20)}} - ] - - references = EntryLinkReferences.new(configurations) - - expect(references.section_perma_ids(7)).to eq([]) - end - end - - def chapter_href(entry_id, chapter_perma_id) - {'entry' => entry_id, - 'chapter' => chapter_perma_id, - 'signature' => EntryLinkSignature.generate(entry_id, chapter: chapter_perma_id)} - end - end -end From bd00b1f334f1c028cae924a488769287f7b94125 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:11:34 +0200 Subject: [PATCH 11/17] Resolve links to chapters and sections of other entries --- .../LinkedEntriesCollection-spec.js | 38 ++++++++++++ .../spec/editor/models/Chapter-spec.js | 24 +++++++ .../spec/entryState/linkedEntries-spec.js | 62 +++++++++++++++++++ .../package/spec/frontend/Link-spec.js | 30 +++++++++ .../collections/LinkedEntriesCollection.js | 30 ++++++++- .../package/src/editor/models/Chapter.js | 5 +- .../package/src/entryState/collections.js | 3 +- .../package/src/entryState/linkedEntries.js | 29 ++++++++- 8 files changed, 214 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js index cd00fb3040..75c5ac5904 100644 --- a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js @@ -31,4 +31,42 @@ describe('LinkedEntriesCollection', () => { }); }); }); + + describe('#mergeLinkedEntries', () => { + it('adds new linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([{id: 5}]); + + linkedEntries.mergeLinkedEntries([{id: 9, title: 'Other'}]); + + expect(linkedEntries.pluck('id')).toEqual([5, 9]); + }); + + it('adds chapters to existing linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, chapters: [{permaId: 20, slug: 'intro'}]} + ]); + + linkedEntries.mergeLinkedEntries([{ + id: 5, + chapters: [{permaId: 20, slug: 'other'}, {permaId: 30, slug: 'outro'}] + }]); + + expect(linkedEntries.get(5).get('chapters')).toEqual([ + {permaId: 20, slug: 'intro'}, + {permaId: 30, slug: 'outro'} + ]); + }); + + it('updates attributes of existing linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, title: 'Old', chapters: [{permaId: 20, slug: 'intro'}]} + ]); + + linkedEntries.mergeLinkedEntries([{id: 5, title: 'New'}]); + + expect(linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'New', chapters: [{permaId: 20, slug: 'intro'}] + }); + }); + }); }); 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 aaf417f238..323874860e 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -380,6 +380,30 @@ describe('Chapter', () => { expect(entry.linkedEntries.pluck('id')).toEqual([5, 9]); }); + it('merges chapters of entries linked from fragment', () => { + const {entry, server} = testContext; + const chapter = entry.chapters.first(); + entry.linkedEntries.add({id: 9, chapters: [{permaId: 20, slug: 'intro'}]}); + const fragmentWithLinks = new Backbone.Model({ + libraryId: 3, + chapterPermaId: 700, + collections: { + linkedEntries: [{id: 9, chapters: [{permaId: 30, slug: 'outro'}]}] + } + }); + + chapter.insertFragment(fragmentWithLinks); + respond(server, [ + {id: 101, permaId: 501, chapterId: 10, position: 1, configuration: {}, + contentElements: []} + ]); + + expect(entry.linkedEntries.get(9).get('chapters')).toEqual([ + {permaId: 20, slug: 'intro'}, + {permaId: 30, slug: 'outro'} + ]); + }); + it('refreshes files before adding sections', () => { const {entry, server} = testContext; const chapter = entry.chapters.first(); diff --git a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js index 01416a8b64..f1652511d2 100644 --- a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js +++ b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js @@ -32,6 +32,46 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toEqual('https://example.com/other'); }); + it('returns url with slug of linked chapter', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, chapter: 20}), { + seed: { + linkedEntries: [{ + id: 5, + url: 'https://example.com/other', + chapters: [{permaId: 20, slug: 'the-outro'}] + }] + } + } + ); + + expect(result.current).toEqual('https://example.com/other#the-outro'); + }); + + it('returns url without anchor for unknown chapter', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, chapter: 30}), { + seed: { + linkedEntries: [{id: 5, url: 'https://example.com/other'}] + } + } + ); + + expect(result.current).toEqual('https://example.com/other'); + }); + + it('returns url with section anchor', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, section: 10}), { + seed: { + linkedEntries: [{id: 5, url: 'https://example.com/other'}] + } + } + ); + + expect(result.current).toEqual('https://example.com/other#section-10'); + }); + it('returns undefined for unknown entry', () => { const {result} = renderHookInEntry( () => useLinkedEntryUrl({entry: 6}), { @@ -74,6 +114,28 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toEqual('http://example.com/other'); }); + it('syncs linked chapters', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + linkedEntries: [{ + id: 5, + url: 'http://example.com/other', + chapters: [{permaId: 20, slug: 'intro'}] + }] + }) + }); + + const {result} = renderHookInEntry( + () => useLinkedEntry(5), { + setup: dispatch => watchCollections(entry, {dispatch}) + } + ); + + expect(result.current).toMatchObject({ + chapters: [{permaId: 20, slug: 'intro'}] + }); + }); + it('syncs preview url and publication state', () => { const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); entry.linkedEntries.add({ diff --git a/entry_types/scrolled/package/spec/frontend/Link-spec.js b/entry_types/scrolled/package/spec/frontend/Link-spec.js index 6db884ff4c..1a92aced14 100644 --- a/entry_types/scrolled/package/spec/frontend/Link-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Link-spec.js @@ -248,6 +248,36 @@ describe('Link', () => { expect(getByRole('link')).not.toHaveAttribute('target'); }); + it('renders link to chapter of linked entry', () => { + const {getByRole} = renderInEntry( + Click, + { + seed: { + linkedEntries: [{ + id: 5, + url: 'http://example.com/other', + chapters: [{permaId: 20, slug: 'the-intro'}] + }] + } + } + ); + + expect(getByRole('link')).toHaveAttribute('href', 'http://example.com/other#the-intro'); + }); + + it('renders link to section of linked entry', () => { + const {getByRole} = renderInEntry( + Click, + { + seed: { + linkedEntries: [{id: 5, url: 'http://example.com/other'}] + } + } + ); + + expect(getByRole('link')).toHaveAttribute('href', 'http://example.com/other#section-10'); + }); + it('renders link without href for unknown entry', () => { const {getByText} = renderInEntry( Click diff --git a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js index 0f3a437a06..5ef12426d8 100644 --- a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js @@ -2,7 +2,33 @@ import Backbone from 'backbone'; export const LinkedEntriesCollection = Backbone.Collection.extend({ addLinkableEntry(linkableEntry) { - this.add(linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState'), - {merge: true}); + this.mergeLinkedEntries([ + linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState') + ]); + }, + + mergeLinkedEntries(linkedEntries) { + linkedEntries.forEach(attributes => { + const linkedEntry = this.get(attributes.id); + + if (linkedEntry) { + linkedEntry.set({ + ...attributes, + chapters: mergeChapters(linkedEntry.get('chapters'), attributes.chapters) + }); + } + else { + this.add(attributes); + } + }); } }); + +function mergeChapters(chapters = [], newChapters = []) { + return [ + ...chapters, + ...newChapters.filter(chapter => + !chapters.some(existingChapter => existingChapter.permaId === chapter.permaId) + ) + ]; +} diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index daa870b172..9a9c654594 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -131,8 +131,9 @@ export const Chapter = Backbone.Model.extend({ } } }).then(sections => { - this.entry.linkedEntries.set(fragment.get('collections')?.linkedEntries || [], - {remove: false}); + this.entry.linkedEntries.mergeLinkedEntries( + fragment.get('collections')?.linkedEntries || [] + ); return this.entry.refreshFiles().then(() => this.addFragmentSections(sections)); }); diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index 65b7865697..242cdc7dab 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -75,7 +75,8 @@ function collectionSpecs(entry) { { collection: linkedEntries, name: 'linkedEntries', - attributes: ['id', {permaId: 'id'}, 'title', 'url', 'previewUrl', 'publicationState'], + attributes: ['id', {permaId: 'id'}, 'title', 'url', 'previewUrl', 'publicationState', + 'chapters'], keyAttribute: 'permaId' }, { diff --git a/entry_types/scrolled/package/src/entryState/linkedEntries.js b/entry_types/scrolled/package/src/entryState/linkedEntries.js index 835d5797c3..9e90ca8933 100644 --- a/entry_types/scrolled/package/src/entryState/linkedEntries.js +++ b/entry_types/scrolled/package/src/entryState/linkedEntries.js @@ -4,6 +4,31 @@ export function useLinkedEntry(entryId) { return useEntryStateCollectionItem('linkedEntries', entryId); } -export function useLinkedEntryUrl({entry: entryId}) { - return useLinkedEntry(entryId)?.url; +export function useLinkedEntryUrl({entry: entryId, chapter, section}) { + const linkedEntry = useLinkedEntry(entryId); + + if (!linkedEntry) { + return undefined; + } + + return withAnchor(linkedEntry.url, { + chapterSlug: findLinkedChapter(linkedEntry, chapter)?.slug, + section + }); +} + +function findLinkedChapter(linkedEntry, chapterPermaId) { + return chapterPermaId && + (linkedEntry.chapters || []).find(chapter => chapter.permaId === chapterPermaId); +} + +function withAnchor(url, {chapterSlug, section}) { + if (section) { + return `${url}#section-${section}`; + } + else if (chapterSlug) { + return `${url}#${chapterSlug}`; + } + + return url; } From 798a3ad5b8a0d7c2940b7a87e515c27ce71c9860 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:14:15 +0200 Subject: [PATCH 12/17] Add editor endpoint rendering outline of entry that can be linked to Renders the structure of the draft of the entry including hidden chapters and sections along with signatures that let links to them resolve. Chapters also carry titles and slugs to let new links render in the editor. Entries linked from the entry are not rendered since the editor might not have access to them. Type names tell which entries have an outline. --- .../editor/linkable_entries_controller.rb | 6 + .../pageflow_scrolled/linkable_entry.rb | 20 +- .../_linkable_entry.json.jbuilder | 1 + .../linkable_entries/show.json.jbuilder | 25 +++ entry_types/scrolled/config/routes.rb | 2 +- .../linkable_entries_controller_spec.rb | 177 ++++++++++++++++++ .../pageflow_scrolled/linkable_entry_spec.rb | 41 ++++ 7 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb index a2a11700af..22b204bcce 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb @@ -4,6 +4,7 @@ module Editor class LinkableEntriesController < ActionController::Base include Pageflow::EditorController + helper Pageflow::FilesHelper helper Pageflow::SocialShareHelper def index @@ -15,6 +16,11 @@ def index ) end + def show + @linkable_entry = LinkableEntry.find(entries, params[:id], except: @entry) + head :not_found unless @linkable_entry + end + private def entries diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb index c7833a9772..5362225fa3 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb @@ -9,10 +9,18 @@ class LinkableEntry class << self def all(scope, except:) - scope - .where(fragment_library: nil) - .where.not(id: except.id) - .map { |entry| new(entry) } + linkable(scope, except).map { |entry| new(entry) } + end + + def find(scope, id, except:) + entry = linkable(scope, except).find_by(id:, type_name: 'scrolled') + entry && new(entry) + end + + private + + def linkable(scope, except) + scope.where(fragment_library: nil).where.not(id: except.id) end end @@ -23,5 +31,9 @@ def initialize(entry) def published_entry Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) end + + def content + @content ||= RevisionContent.new(draft, include_hidden: true) + end end end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder index 1a5523014e..865caaf0ba 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder @@ -1,5 +1,6 @@ json.id linkable_entry.id json.title linkable_entry.title +json.type_name linkable_entry.type_name json.url social_share_entry_url(linkable_entry.published_entry) json.preview_url main_app.preview_admin_entry_path(linkable_entry.entry) json.publication_state linkable_entry.publication_state diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder new file mode 100644 index 0000000000..d1893b1fe7 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder @@ -0,0 +1,25 @@ +json.key_format!(camelize: :lower) + +json.partial!('pageflow_scrolled/editor/linkable_entries/linkable_entry', + linkable_entry: @linkable_entry) + +json.chapters(@linkable_entry.content.chapters) do |chapter| + json.perma_id chapter.perma_id + json.title chapter.configuration['title'] + json.slug @linkable_entry.content.chapter_slug_for(chapter) + json.signature PageflowScrolled::EntryLinkSignature.generate(@linkable_entry.id, + chapter: chapter.perma_id) +end + +json.sections(@linkable_entry.content.sections) do |section| + json.perma_id section.perma_id + json.signature PageflowScrolled::EntryLinkSignature.generate(@linkable_entry.id, + section: section.perma_id) +end + +json.collections do + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', + content: @linkable_entry.content) + + files_json_seed(json, @linkable_entry.draft) +end diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 4fecd43a8e..8cb3812120 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -1,7 +1,7 @@ PageflowScrolled::Engine.routes.draw do scope module: 'editor' do resources :fragment_libraries, only: [:index] - resources :linkable_entries, only: [:index] + resources :linkable_entries, only: [:index, :show] shallow do # Legacy path to support editor sessions that span the deploy that diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb index 07c7c7e6cf..c4bea1b8a7 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/linkable_entries_controller_spec.rb @@ -35,6 +35,18 @@ module PageflowScrolled }]) end + it 'renders type names of entries' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + create(:entry, type_name: 'paged', account:) + + sign_in_and_lock(entry, user) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.body).to include_json([{typeName: 'paged'}]) + end + it 'uses title of entry instead of revision' do user = create(:user) account = create(:account, with_previewer: user) @@ -156,6 +168,171 @@ module PageflowScrolled end end + describe '#show' do + it 'renders draft structure of linked entry' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, + revision: linked_entry.draft, + configuration: {title: 'Intro'}) + section = create(:section, chapter:) + create(:content_element, :heading, section:) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.body).to include_json( + collections: { + chapters: [{permaId: chapter.perma_id, chapterSlug: 'intro'}], + sections: [{permaId: section.perma_id}], + contentElements: [{typeName: 'heading'}] + } + ) + end + + it 'renders chapters and sections of linked entry' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, + revision: linked_entry.draft, + configuration: {title: 'Intro'}) + section = create(:section, chapter:) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.body).to include_json( + id: linked_entry.id, + typeName: 'scrolled', + chapters: [{permaId: chapter.perma_id, title: 'Intro', slug: 'intro'}], + sections: [{permaId: section.perma_id}] + ) + end + + it 'renders signatures for chapters and sections' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, revision: linked_entry.draft) + section = create(:section, chapter:) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + result = JSON.parse(response.body) + + expect(EntryLinkSignature.valid?(linked_entry.id, + result['chapters'][0]['signature'], + chapter: chapter.perma_id)).to eq(true) + expect(EntryLinkSignature.valid?(linked_entry.id, + result['sections'][0]['signature'], + section: section.perma_id)).to eq(true) + end + + it 'renders hidden chapters and sections' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, revision: linked_entry.draft) + section = create(:section, chapter:, configuration: {hidden: true}) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.body).to include_json( + chapters: [{permaId: chapter.perma_id}], + sections: [{permaId: section.perma_id}], + collections: { + chapters: [{permaId: chapter.perma_id}], + sections: [{permaId: section.perma_id}] + } + ) + end + + it 'does not render entries linked from linked entry' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + other_entry = create(:entry, type_name: 'scrolled', account:) + create(:content_element, + revision: linked_entry.draft, + configuration: { + value: [{ + type: 'link', + href: {entry: other_entry.id, + signature: EntryLinkSignature.generate(other_entry.id)}, + children: [{text: 'Link'}] + }] + }) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(JSON.parse(response.body)['collections']).not_to have_key('linkedEntries') + end + + it 'responds with not found for entries the user may not preview' do + user = create(:user) + account = create(:account) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'scrolled', account:) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.status).to eq(404) + end + + it 'responds with not found for entries of other sites' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, + type_name: 'scrolled', + account:, + site: create(:site, account:)) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.status).to eq(404) + end + + it 'responds with not found for entries of other entry types' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user) + linked_entry = create(:entry, type_name: 'paged', account:) + + sign_in_and_lock(entry, user) + get(:show, + params: {entry_type: 'scrolled', entry_id: entry, id: linked_entry.id}, + format: 'json') + + expect(response.status).to eq(404) + end + end + def sign_in_and_lock(entry, user) sign_in(user, scope: :user) acquire_edit_lock(user, entry) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb index 982a15f4ea..9a88bfdf6a 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/linkable_entry_spec.rb @@ -21,6 +21,47 @@ module PageflowScrolled end end + describe '.find' do + it 'returns linkable scrolled entry' do + entry = create(:entry) + other_entry = create(:entry, type_name: 'scrolled') + + result = LinkableEntry.find(Pageflow::Entry.all, other_entry.id, except: entry) + + expect(result.entry).to eq(other_entry) + end + + it 'returns nil for entries of other types' do + entry = create(:entry) + other_entry = create(:entry, type_name: 'paged') + + expect(LinkableEntry.find(Pageflow::Entry.all, other_entry.id, except: entry)).to be_nil + end + + it 'returns nil for given entry' do + entry = create(:entry, type_name: 'scrolled') + + expect(LinkableEntry.find(Pageflow::Entry.all, entry.id, except: entry)).to be_nil + end + + it 'returns nil for fragment libraries' do + entry = create(:entry) + library = create(:entry, type_name: 'scrolled', fragment_library: 'shared') + + expect(LinkableEntry.find(Pageflow::Entry.all, library.id, except: entry)).to be_nil + end + end + + describe '#content' do + it 'includes hidden sections of draft' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:, configuration: {hidden: true}) + + expect(LinkableEntry.new(entry).content.sections).to eq([section]) + end + end + describe '#published_entry' do it 'uses published revision' do entry = create(:published_entry, revision_attributes: {title: 'Published'}) From d1f9fa33d1bfad2fee6578b16c2994229301bc17 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:14:28 +0200 Subject: [PATCH 13/17] Build read only outline of entry that can be linked to --- .../spec/editor/models/LinkableEntry-spec.js | 176 ++++++++++++++++++ .../collections/LinkableEntriesCollection.js | 4 + .../src/editor/models/LinkableEntry.js | 64 +++++++ .../src/editor/views/SectionThumbnailView.js | 10 +- 4 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js create mode 100644 entry_types/scrolled/package/src/editor/models/LinkableEntry.js diff --git a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js new file mode 100644 index 0000000000..b2fa8423b8 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js @@ -0,0 +1,176 @@ +import {LinkableEntriesCollection} from 'editor/collections/LinkableEntriesCollection'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView'; + +import {renderBackboneView, setupGlobals} from 'pageflow/testHelpers'; +import {act} from '@testing-library/react'; +import 'contentElements/heading/frontend'; +import {factories, normalizeSeed, useFakeXhr} from 'support'; + +describe('LinkableEntry', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + useFakeXhr(() => testContext); + + setupGlobals({ + entry: () => factories.entry(ScrolledEntry, {id: 1}) + }); + + function fetchOutline(response, {entry} = {}) { + entry = entry || factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Other'}]); + + const promise = linkableEntries.get(5).fetchOutline({entry}); + + testContext.server.respondWith( + 'GET', /linkable_entries\/5/, [200, {'Content-Type': 'application/json'}, + JSON.stringify(response)] + ); + testContext.server.respond(); + + return promise; + } + + function structure(collections) { + return { + id: 5, + title: 'Other', + url: '/other', + chapters: [], + sections: [], + collections: { + storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + chapters: [], + sections: [], + contentElements: [], + imageFiles: [], + ...collections + } + }; + } + + it('updates title, urls, chapters and sections', async () => { + const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Old'}]); + const linkableEntry = linkableEntries.get(5); + + const promise = linkableEntry.fetchOutline({entry}); + testContext.server.respondWith( + 'GET', /linkable_entries\/5/, [200, {'Content-Type': 'application/json'}, + JSON.stringify({ + ...structure(), + title: 'Other', + url: '/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'not_published', + chapters: [{permaId: 20, slug: 'intro'}], + sections: [{permaId: 30, state: 'not_published'}] + })] + ); + testContext.server.respond(); + await promise; + + expect(linkableEntry.pick('title', 'url', 'previewUrl', 'publicationState', + 'chapters', 'sections')).toEqual({ + title: 'Other', + url: '/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'not_published', + chapters: [{permaId: 20, slug: 'intro'}], + sections: [{permaId: 30, state: 'not_published'}] + }); + }); + + it('exposes signatures of chapters and sections', () => { + const linkableEntries = new LinkableEntriesCollection([{ + id: 5, + chapters: [{permaId: 20, signature: 'signed-20'}], + sections: [{permaId: 30, signature: 'signed-30'}] + }]); + + expect(linkableEntries.get(5).chapterLinkSignature(20)).toEqual('signed-20'); + expect(linkableEntries.get(5).sectionLinkSignature(30)).toEqual('signed-30'); + }); + + it('rejects when outline can not be fetched', async () => { + const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Other'}]); + + const promise = linkableEntries.get(5).fetchOutline({entry}); + testContext.server.respondWith('GET', /linkable_entries\/5/, [404, {}, '']); + testContext.server.respond(); + + await expect(promise).rejects.toBeDefined(); + }); + + it('builds chapters and sections of storylines', async () => { + const outline = await fetchOutline(structure({ + chapters: [{id: 1, permaId: 20, storylineId: 1, configuration: {title: 'Intro'}}], + sections: [{id: 2, permaId: 30, chapterId: 1}] + })); + + const chapter = outline.storylines.main().chapters.first(); + + expect(chapter.configuration.get('title')).toEqual('Intro'); + expect(chapter.sections.first().get('permaId')).toEqual(30); + }); + + it('merges collections into seed of current entry', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + themeOptions: {colors: {accent: '#f00'}}, + sections: [{id: 100, permaId: 1000}] + }) + }); + + const outline = await fetchOutline(structure({ + sections: [{id: 2, permaId: 30, chapterId: 1}] + }), {entry}); + + expect(outline.scrolledSeed.config.theme.options.colors.accent).toEqual('#f00'); + expect(outline.scrolledSeed.collections.sections).toEqual([ + {id: 2, permaId: 30, chapterId: 1} + ]); + }); + + it('does not render widgets of current entry in thumbnails', async () => { + const outline = await fetchOutline(structure()); + + expect(outline.scrolledSeed.collections.widgets).toEqual([]); + }); + + it('is read only', async () => { + const outline = await fetchOutline(structure()); + + expect(outline.readOnly).toEqual(true); + expect(outline.isCurrentSectionInExcursion()).toEqual(false); + }); + + it('can be rendered with section thumbnails in outline view', async () => { + const outline = await fetchOutline(structure({ + chapters: [{id: 1, permaId: 20, storylineId: 1, configuration: {}}], + sections: [{id: 2, permaId: 30, chapterId: 1, configuration: {}}], + contentElements: [{ + id: 3, permaId: 40, sectionId: 2, typeName: 'heading', + configuration: {children: 'Other heading'} + }] + })); + jest.useFakeTimers(); + + const view = new SelectableEntryOutlineView({ + entry: outline, + onSelectChapter: jest.fn(), + onSelectSection: jest.fn() + }); + const {findByText} = renderBackboneView(view); + act(() => jest.runAllTimers()); + jest.useRealTimers(); + + expect(await findByText('Other heading')).not.toBeNull(); + view.close(); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js index 1ba2ca404b..f438e85cf3 100644 --- a/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js @@ -2,6 +2,10 @@ import Backbone from 'backbone'; import {entryTypeEditorControllerUrls} from 'pageflow/editor'; +import {LinkableEntry} from '../models/LinkableEntry'; + export const LinkableEntriesCollection = Backbone.Collection.extend({ + model: LinkableEntry, + mixins: [entryTypeEditorControllerUrls.forCollection({resources: 'linkable_entries'})] }); diff --git a/entry_types/scrolled/package/src/editor/models/LinkableEntry.js b/entry_types/scrolled/package/src/editor/models/LinkableEntry.js new file mode 100644 index 0000000000..26f1003b66 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/LinkableEntry.js @@ -0,0 +1,64 @@ +import Backbone from 'backbone'; + +import { + StorylinesCollection, + ChaptersCollection, + SectionsCollection, + ContentElementsCollection +} from '../collections'; + +export const LinkableEntry = Backbone.Model.extend({ + chapterLinkSignature(permaId) { + return findSignature(this.get('chapters'), permaId); + }, + + sectionLinkSignature(permaId) { + return findSignature(this.get('sections'), permaId); + }, + + fetchOutline({entry}) { + return Promise.resolve(Backbone.$.getJSON(this.url())).then(response => { + const {collections, ...attributes} = response; + this.set(attributes); + + return new LinkableEntryOutline({id: this.id}, { + collections, + seed: entry.scrolledSeed + }); + }); + } +}); + +const LinkableEntryOutline = Backbone.Model.extend({ + readOnly: true, + + initialize(attributes, {collections, seed}) { + this.contentElements = new ContentElementsCollection(collections.contentElements); + this.sections = new SectionsCollection(collections.sections, + {contentElements: this.contentElements}); + this.chapters = new ChaptersCollection(collections.chapters, + {sections: this.sections, + entry: this}); + this.storylines = new StorylinesCollection(collections.storylines, + {chapters: this.chapters, + entry: this}); + this.sections.sort(); + + this.scrolledSeed = { + ...seed, + collections: { + ...seed.collections, + ...collections, + widgets: [] + } + }; + }, + + isCurrentSectionInExcursion() { + return false; + } +}); + +function findSignature(destinations, permaId) { + return (destinations || []).find(destination => destination.permaId === permaId)?.signature; +} diff --git a/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js b/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js index ce4c265d68..31ec2a0316 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js @@ -32,12 +32,16 @@ export const SectionThumbnailView = Marionette.ItemView.extend({ renderThumbnail() { if (!this.model.isNew()) { + const entry = this.options.entry; + ReactDOM.render(React.createElement(StandaloneSectionThumbnail, { sectionPermaId: this.model.get('permaId'), - seed: this.options.entry.scrolledSeed, - subscribe: dispatch => - watchCollections(this.options.entry, {dispatch}) + seed: entry.scrolledSeed, + subscribe: entry.readOnly ? + undefined : + dispatch => + watchCollections(entry, {dispatch}) }), this.ui.thumbnail[0]); } From 8f5934b9582ebc7ca5599ae327786c7b28e59b66 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:15:32 +0200 Subject: [PATCH 14/17] Highlight selected chapter or section in selectable outline Lets editors see the current destination when editing links to chapters or sections. --- .../SelectLinkDestinationDialogView-spec.js | 3 + .../views/SelectableEntryOutlineView-spec.js | 101 +++++++++++++++++- .../views/SelectLinkDestinationDialogView.js | 1 + .../editor/views/SelectableChapterItemView.js | 21 ++++ .../SelectableChapterItemView.module.css | 6 ++ .../views/SelectableEntryOutlineView.js | 22 ++++ .../SelectableEntryOutlineView.module.css | 1 + .../editor/views/SelectableSectionItemView.js | 20 ++++ .../views/SelectableStorylineItemView.js | 1 + .../src/editor/views/StorylinesTabsView.js | 3 +- 10 files changed, 177 insertions(+), 2 deletions(-) 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 6ecd76a185..651f08e3dd 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -476,6 +476,7 @@ describe('SelectLinkDestinationDialogView', () => { }); it('preselects chapter of this entry', async () => { + Element.prototype.scrollIntoView = jest.fn(); const onSelect = jest.fn(); const user = userEvent.setup(); const result = renderDialog({href: {chapter: 10}}, { @@ -484,10 +485,12 @@ describe('SelectLinkDestinationDialogView', () => { }); expect(result.getByRole('radio', {name: /Chapter or section of this story/})).toBeChecked(); + expect(result.getByText('Intro').closest('li')).toHaveAttribute('aria-current', 'true'); await user.click(result.getByRole('button', {name: 'Save'})); expect(onSelect).toHaveBeenCalledWith({href: {chapter: 10}}); + delete Element.prototype.scrollIntoView; }); it('preselects file', async () => { 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 b279b1cb17..022a9726ea 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -1,3 +1,5 @@ +import Backbone from 'backbone'; + import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; @@ -5,6 +7,7 @@ import {factories, normalizeSeed} from 'support'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; describe('SelectableEntryOutlineView', () => { useFakeTranslations({ @@ -15,7 +18,9 @@ describe('SelectableEntryOutlineView', () => { 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here', 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning', - 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end' + 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end', + 'pageflow_scrolled.editor.storylines_tabs.main': 'Outline', + 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions' }); describe('in default mode', () => { @@ -256,4 +261,98 @@ describe('SelectableEntryOutlineView', () => { expect(getByText('No chapters')).toBeTruthy(); }); }); + + describe('with selection', () => { + beforeEach(() => { + jest.useFakeTimers(); + Element.prototype.scrollIntoView = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + delete Element.prototype.scrollIntoView; + }); + + function createEntry() { + return factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 1, storylineId: 1, permaId: 10, configuration: {title: 'Intro'}}, + {id: 2, storylineId: 1, permaId: 20, configuration: {title: 'Outro'}}, + {id: 3, storylineId: 2, permaId: 30, configuration: {title: 'Excursion'}} + ], + sections: [ + {id: 1, chapterId: 1, permaId: 100}, + {id: 2, chapterId: 2, permaId: 200}, + {id: 3, chapterId: 3, permaId: 300} + ] + }) + }); + } + + function renderOutline(selection) { + const view = new SelectableEntryOutlineView({ + entry: createEntry(), + selection, + onSelectChapter: jest.fn(), + onSelectSection: jest.fn() + }); + + return render(view); + } + + it('marks selected chapter as current', () => { + const {getByText} = renderOutline(new Backbone.Model({chapter: 20})); + + expect(getByText('Outro').closest('li')).toHaveAttribute('aria-current', 'true'); + expect(getByText('Intro').closest('li')).not.toHaveAttribute('aria-current'); + }); + + it('marks selected section as current', () => { + const {getAllByTitle} = renderOutline(new Backbone.Model({section: 200})); + + expect(getAllByTitle('Select section').map(link => + link.closest('li').getAttribute('aria-current') + )).toEqual([null, 'true']); + }); + + it('updates when selection changes', () => { + const selection = new Backbone.Model({chapter: 20}); + const {getByText} = renderOutline(selection); + + selection.set({chapter: 10}); + + expect(getByText('Intro').closest('li')).toHaveAttribute('aria-current', 'true'); + expect(getByText('Outro').closest('li')).not.toHaveAttribute('aria-current'); + }); + + it('displays excursions tab if selected chapter is an excursion', () => { + const {getByRole} = renderOutline(new Backbone.Model({chapter: 30})); + + expect(getByRole('tab', {name: 'Excursions'})).toHaveAttribute('aria-selected', 'true'); + }); + + it('scrolls top of selected chapter into view', () => { + const {getByText} = renderOutline(new Backbone.Model({chapter: 20})); + jest.runAllTimers(); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({block: 'start'}); + expect(Element.prototype.scrollIntoView.mock.instances[0]) + .toBe(getByText('Outro').closest('li')); + }); + + it('scrolls selected section into view', () => { + const {getAllByTitle} = renderOutline(new Backbone.Model({section: 200})); + jest.runAllTimers(); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + expect(Element.prototype.scrollIntoView.mock.instances[0]) + .toBe(getAllByTitle('Select section')[1].closest('li')); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js index 410e3e7240..c269282060 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js @@ -116,6 +116,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ this.view(OutlineContainerView, { outlineView: new SelectableEntryOutlineView({ entry: dialog.options.entry, + selection: dialog.outlineSelection, onSelectChapter: chapter => { dialog.outlineSelection.clear({silent: true}); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js index 3568965c8a..6097a986b5 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -84,6 +84,12 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ change: 'update' }, + initialize() { + if (this.options.selection) { + this.listenTo(this.options.selection, 'change', this.updateSelected); + } + }, + onRender() { this.subview(new CollectionView({ el: this.ui.sections, @@ -92,6 +98,7 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelect: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, onSelectSectionPart: this.options.onSelectSectionPart @@ -99,6 +106,20 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ })); this.update(); + + if (this.updateSelected()) { + setTimeout(() => this.el.scrollIntoView({block: 'start'}), 10); + } + }, + + updateSelected() { + const selected = !!this.options.selection && + this.options.selection.get('chapter') === this.model.get('permaId'); + + this.$el.toggleClass(styles.selected, selected); + this.$el.attr('aria-current', selected ? 'true' : null); + + return selected; }, update() { diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css index fd5c037b30..1aae4e7b6d 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css @@ -1,3 +1,4 @@ +@value selectionColor from './colors.module.css'; @value insertLineWidth, insertLineColor from './insertIndicator.module.css'; .root { @@ -6,6 +7,11 @@ position: relative; } +.selected { + box-shadow: 0 0 0 2px selectionColor; + background-color: var(--ui-selection-color-lighter); +} + .empty { cursor: pointer; } diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js index e6c2a74d20..b1cac042e4 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js @@ -17,10 +17,12 @@ export const SelectableEntryOutlineView = Marionette.Layout.extend({ this.appendSubview( new StorylinesTabsView({ entry: this.options.entry, + defaultTab: this.isSelectionInExcursion() ? 'excursions' : undefined, itemViewContstuctor: SelectableStorylineItemView, itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelectChapter: this.options.onSelectChapter, onSelectSection: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, @@ -29,5 +31,25 @@ export const SelectableEntryOutlineView = Marionette.Layout.extend({ }), {to: this.ui.tabs} ); + }, + + isSelectionInExcursion() { + const chapter = this.selectedChapter(); + return !!chapter && chapter.isExcursion(); + }, + + selectedChapter() { + const {entry, selection} = this.options; + + if (!selection) { + return null; + } + + if (selection.get('section')) { + const section = entry.sections.findWhere({permaId: selection.get('section')}); + return section && section.chapter; + } + + return entry.chapters.findWhere({permaId: selection.get('chapter')}); } }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css index bd62b6927b..4de824cf1f 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css @@ -8,6 +8,7 @@ min-height: 0; flex: 1; overflow: auto; + scroll-padding-block: space(4); scrollbar-gutter: stable; padding-right: space(2); border-bottom: solid 1px var(--ui-on-surface-color-lightest); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js index ff88274616..97cc0b9efd 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js @@ -96,11 +96,31 @@ export const SelectableSectionItemView = Marionette.ItemView.extend({ } }), + initialize() { + if (this.options.selection) { + this.listenTo(this.options.selection, 'change', this.updateSelected); + } + }, + onRender() { this.subview(new SectionThumbnailView({ el: this.ui.thumbnail, model: this.model, entry: this.options.entry })); + + if (this.updateSelected()) { + setTimeout(() => this.el.scrollIntoView({block: 'center'}), 10); + } + }, + + updateSelected() { + const selected = !!this.options.selection && + this.options.selection.get('section') === this.model.get('permaId'); + + this.$el.toggleClass(baseStyles.active, selected); + this.$el.attr('aria-current', selected ? 'true' : null); + + return selected; } }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js index 6df75df3ab..1771574685 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js @@ -38,6 +38,7 @@ export const SelectableStorylineItemView = Marionette.ItemView.extend({ itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelectChapter: this.options.onSelectChapter, onSelectSection: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, diff --git a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js index eaed36cad4..0ddfda42ad 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js @@ -9,7 +9,8 @@ export const StorylinesTabsView = Marionette.View.extend({ render() { this.tabsView = new TabsView({ i18n: 'pageflow_scrolled.editor.storylines_tabs', - defaultTab: this.options.entry.isCurrentSectionInExcursion() ? 'excursions' : 'main' + defaultTab: this.options.defaultTab || + (this.options.entry.isCurrentSectionInExcursion() ? 'excursions' : 'main') }); ['main', 'excursions'].forEach(name => { From a02fc5e8ab887d9c8292dd97e729566cdd981e47 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:17:56 +0200 Subject: [PATCH 15/17] Let editors link to chapters and sections of other entries The link dialog offers expanding the outline of the selected story once linkable entries have been loaded. Links store the signature rendered for the selected chapter or section. Linked chapters are added to linked entries to render new links right away. Links to chapters and sections of entries the editor cannot access are kept as they are. --- entry_types/scrolled/config/locales/de.yml | 3 + entry_types/scrolled/config/locales/en.yml | 3 + .../LinkedEntriesCollection-spec.js | 13 + .../SelectLinkDestinationDialogView-spec.js | 259 +++++++++++++++++- .../collections/LinkedEntriesCollection.js | 23 +- .../src/editor/views/LinkToOtherEntryView.js | 188 ++++++++++++- .../views/LinkToOtherEntryView.module.css | 42 +++ .../views/SelectLinkDestinationDialogView.js | 41 ++- package/spec/ui/views/AccordionView-spec.js | 19 ++ package/src/ui/views/AccordionView.js | 17 ++ 10 files changed, 589 insertions(+), 19 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index c3ba63273d..d2461499b1 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1502,8 +1502,11 @@ de: url: description: Verlinke eine beliebige Seite im Internet title: Andere Website + link_to_beginning_of_entry: Stattdessen mit Beginn des Beitrags verlinken + link_to_chapter_or_section: Mit Kapitel oder Abschnitt des Beitrags verlinken no_file_selected: Keine Datei ausgewählt open_in_new_tab: In neuem Tab öffnen + outline_failed: Die Gliederung des Beitrags konnte nicht geladen werden. remove: Link entfernen save: Speichern select_in_sidebar: Datei auswählen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 1c75397eb0..90cafe104a 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1484,8 +1484,11 @@ en: url: description: Link to any page on the internet title: Other website + link_to_beginning_of_entry: Link to beginning of story instead + link_to_chapter_or_section: Link to chapter or section of story no_file_selected: No file selected open_in_new_tab: Open in new tab + outline_failed: The outline of the story could not be loaded. remove: Remove link save: Save select_in_sidebar: Select file diff --git a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js index 75c5ac5904..a246053c90 100644 --- a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js @@ -17,6 +17,19 @@ describe('LinkedEntriesCollection', () => { }); }); + it('adds chapter of linkable entry', () => { + const linkedEntries = new LinkedEntriesCollection([{id: 5, chapters: []}]); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', signature: 'signed'}] + }), {chapter: 20}); + + expect(linkedEntries.get(5).get('chapters')).toEqual([ + {permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'intro'} + ]); + }); + it('updates existing linked entry', () => { const linkedEntries = new LinkedEntriesCollection([ {id: 5, title: 'Seed', publicationState: 'not_published'} 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 651f08e3dd..6dd91aa732 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -40,6 +40,12 @@ describe('SelectLinkDestinationDialogView', () => { }); useFakeTranslations({ + 'pageflow_scrolled.editor.select_link_destination.link_to_chapter_or_section': + 'Link to chapter or section of story', + 'pageflow_scrolled.editor.select_link_destination.link_to_beginning_of_entry': + 'Link to beginning of story instead', + 'pageflow_scrolled.editor.select_link_destination.outline_failed': + 'Outline could not be loaded', 'pageflow_scrolled.editor.entry_select.show_entries': 'Show stories', 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', 'pageflow_scrolled.editor.select_link_destination.url': 'URL', @@ -162,6 +168,38 @@ describe('SelectLinkDestinationDialogView', () => { expect(getByRole('radio', {name: /Chapter or section of this story/})).toBeChecked(); }); + function respondWithOutline(id, { + chapters = [], sections = [], signatures = {}, chapterDestinations + } = {}) { + testContext.server.respondWith( + 'GET', new RegExp(`linkable_entries/${id}$`), [ + 200, {'Content-Type': 'application/json'}, + JSON.stringify({ + id, + permaId: id, + title: 'Other story', + url: '/other-story', + chapters: chapterDestinations || destinations(signatures.chapters), + sections: destinations(signatures.sections), + collections: { + storylines: [{id: 1, permaId: 1, configuration: {main: true}}], + chapters, + sections, + contentElements: [] + } + }) + ] + ); + testContext.server.respond(); + } + + function destinations(signatures = {}) { + return Object.entries(signatures).map(([permaId, signature]) => ({ + permaId: Number(permaId), + signature + })); + } + describe('with other entry selected', () => { function renderDialog({seed, onSelect = jest.fn()} = {}) { const entry = factories.entry(ScrolledEntry, {}, { @@ -172,9 +210,11 @@ describe('SelectLinkDestinationDialogView', () => { return {entry, ...render(view)}; } - async function selectOtherEntry(user, {getByRole}) { + async function selectOtherEntry(user, {getByRole}, {moreEntries = []} = {}) { await user.click(getByRole('radio', {name: /Another story/})); - respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', typeName: 'scrolled', signature: 'signed-5' + }, ...moreEntries]); await user.click(getByRole('button', {name: /Show stories/})); await user.click(getByRole('option', {name: 'Other story'})); } @@ -244,6 +284,170 @@ describe('SelectLinkDestinationDialogView', () => { }); }); + async function expandOutline(user, {getByRole}, outline) { + await user.click(getByRole('button', {name: 'Link to chapter or section of story'})); + respondWithOutline(5, outline); + } + + it('allows selecting chapter of other entry', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + + it('collapses outline when selecting entry without outline', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result, { + moreEntries: [{id: 6, title: 'Paged story', typeName: 'paged', signature: 'signed-6'}] + }); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}] + }); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Paged story'})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({href: {entry: 6, signature: 'signed-6'}}); + expect(testContext.requests.map(request => request.url)) + .not.toContainEqual(expect.stringMatching(/linkable_entries\/6$/)); + }); + + it('displays outline only once when expanding again while loading', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await user.click(result.getByRole('button', {name: 'Link to chapter or section of story'})); + await user.click(result.getByRole('button', {name: 'Link to beginning of story instead'})); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}] + }); + + expect(await result.findAllByText('Other chapter')).toHaveLength(1); + }); + + it('keeps selected chapter when selecting same entry again', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Other story'})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + + it('makes linked chapter available to render created link', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + chapterDestinations: [{permaId: 70, title: 'Other chapter', slug: 'other-chapter', + signature: 'signed-chapter-70'}] + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(result.entry.linkedEntries.get(5).get('chapters')).toEqual([{ + permaId: 70, title: 'Other chapter', slug: 'other-chapter', previewSlug: 'other-chapter' + }]); + }); + + it('allows selecting section of other entry', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, configuration: {}}], + sections: [{id: 8, permaId: 80, chapterId: 7, configuration: {}}], + signatures: {sections: {80: 'signed-section-80'}} + }); + await user.click(await result.findByTitle('Select section')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, section: 80, signature: 'signed-section-80'} + }); + }); + + it('disables create button while outline is expanded without selection', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await expandOutline(user, result); + + expect(result.getByRole('button', {name: 'Create link'})).toBeDisabled(); + }); + + it('allows switching back to linking to beginning of story', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Link to beginning of story instead'})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); + }); + + it('does not offer linking to chapters of entries of other types', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await user.click(result.getByRole('radio', {name: /Another story/})); + respondWithLinkableEntries([{ + id: 6, title: 'Paged story', typeName: 'paged', signature: 'signed-6' + }]); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Paged story'})); + + expect(result.queryByRole('button', {name: 'Link to chapter or section of story'})) + .toBeNull(); + }); + + it('displays error if outline can not be loaded', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await user.click(result.getByRole('button', {name: 'Link to chapter or section of story'})); + testContext.server.respondWith('GET', /linkable_entries\/5$/, [404, {}, '']); + testContext.server.respond(); + + expect(await result.findByText('Outline could not be loaded')).toBeInTheDocument(); + }); + it('allows opening other entry in new tab', async () => { const listener = jest.fn(); const user = userEvent.setup(); @@ -568,6 +772,57 @@ describe('SelectLinkDestinationDialogView', () => { expect(onSelect).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); }); + it('expands outline of other entry for chapter links', async () => { + Element.prototype.scrollIntoView = jest.fn(); + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{ + id: 9, title: 'Linked story', typeName: 'scrolled', signature: 'signed-9' + }]); + respondWithOutline(9, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + + expect((await result.findByText('Other chapter')).closest('li')) + .toHaveAttribute('aria-current', 'true'); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({ + href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'} + }); + delete Element.prototype.scrollIntoView; + }); + + it('does not offer outline for chapter link to entry that cannot be linked to', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', typeName: 'scrolled', signature: 'signed-5' + }]); + + expect(result.getByText('No access to Linked story')).toBeInTheDocument(); + expect(result.queryByRole('button', {name: 'Link to beginning of story instead'})) + .toBeNull(); + expect(testContext.requests).toHaveLength(1); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({ + href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + it('fetches entries when other entry is preselected', () => { renderDialog({href: {entry: 9, signature: 'signed-9'}}); diff --git a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js index 5ef12426d8..bbf2dbd1ee 100644 --- a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js @@ -1,10 +1,13 @@ import Backbone from 'backbone'; export const LinkedEntriesCollection = Backbone.Collection.extend({ - addLinkableEntry(linkableEntry) { - this.mergeLinkedEntries([ - linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState') - ]); + addLinkableEntry(linkableEntry, {chapter} = {}) { + const linkedChapter = findLinkedChapter(linkableEntry, chapter); + + this.mergeLinkedEntries([{ + ...linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState'), + ...(linkedChapter && {chapters: [linkedChapter]}) + }]); }, mergeLinkedEntries(linkedEntries) { @@ -32,3 +35,15 @@ function mergeChapters(chapters = [], newChapters = []) { ) ]; } + +function findLinkedChapter(linkableEntry, permaId) { + const chapter = permaId && + (linkableEntry.get('chapters') || []).find(item => item.permaId === permaId); + + return chapter && { + permaId, + title: chapter.title, + slug: chapter.slug, + previewSlug: chapter.slug + }; +} diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js index 25e266b141..9f0b74126f 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js @@ -4,6 +4,7 @@ import Marionette from 'backbone.marionette'; import {cssModulesUtils, CheckBoxInputView} from 'pageflow/ui'; import {EntrySelectView} from './EntrySelectView'; +import {SelectableEntryOutlineView} from './SelectableEntryOutlineView'; import styles from './LinkToOtherEntryView.module.css'; export const LinkToOtherEntryView = Marionette.ItemView.extend({ @@ -13,20 +14,45 @@ export const LinkToOtherEntryView = Marionette.ItemView.extend({
+ + + `, ui: cssModulesUtils.ui(styles, 'inaccessibleEntryNotice', 'entrySelectContainer', - 'openInNewTabContainer'), + 'openInNewTabContainer', + 'expandButton', + 'outlineContainer', + 'collapseButton'), + + events: cssModulesUtils.events(styles, { + 'click expandButton': function() { + this.expandOutline(); + }, + + 'click collapseButton': function() { + this.collapseOutline(); + } + }), initialize() { - this.listenTo(this.options.entry.linkableEntries, 'sync', this.updateInaccessibleEntryNotice); + this.listenTo(this.options.entry.linkableEntries, 'sync', this.handleLinkableEntriesSync); }, onRender() { + const linkableEntries = this.options.entry.linkableEntries; + this.appendSubview(new EntrySelectView({ - collection: this.options.entry.linkableEntries, + collection: linkableEntries, selectedEntryId: this.model.get('entryId'), label: I18n.t('pageflow_scrolled.editor.select_link_destination.story'), onSelect: linkableEntry => this.selectEntry(linkableEntry) @@ -37,26 +63,176 @@ export const LinkToOtherEntryView = Marionette.ItemView.extend({ propertyName: 'openInNewTab', label: I18n.t('pageflow_scrolled.editor.select_link_destination.open_in_new_tab') }), {to: this.ui.openInNewTabContainer}); + + if (this.model.get('entryId') && this.model.get('expanded')) { + this.ui.outlineContainer.prop('hidden', false); + this.options.onToggleOutline(true); + this.loadOutline(); + } + + this.updateButtons(); }, selectEntry(linkableEntry) { + if (linkableEntry && linkableEntry.id === this.model.get('entryId')) { + return; + } + + this.model.unset('chapter'); + this.model.unset('section'); + this.model.unset('targetSignature'); this.model.set({ entryId: linkableEntry?.id, signature: linkableEntry?.get('signature') }); + if (!this.hasOutline()) { + this.collapseOutline(); + } + else if (this.model.get('expanded')) { + this.loadOutline(); + } + this.updateInaccessibleEntryNotice(); + this.updateButtons(); + }, + + handleLinkableEntriesSync() { + const linkableEntry = this.linkableEntry(); + + if (linkableEntry) { + if (!this.model.get('signature')) { + this.model.set('signature', linkableEntry.get('signature')); + } + + if (this.model.get('expanded') && this.outlineEntryId !== linkableEntry.id) { + this.loadOutline(); + } + } + else if (this.model.get('expanded')) { + this.ui.outlineContainer.prop('hidden', true); + this.options.onToggleOutline(false); + } + + this.updateInaccessibleEntryNotice(); + this.updateButtons(); + }, + + expandOutline() { + this.model.set('expanded', true); + this.ui.outlineContainer.prop('hidden', false); + this.options.onToggleOutline(true); + this.updateButtons(); + this.loadOutline(); + }, + + collapseOutline() { + this.model.set('expanded', false); + this.model.unset('chapter'); + this.model.unset('section'); + this.model.unset('targetSignature'); + this.ui.outlineContainer.prop('hidden', true); + this.closeOutline(); + this.options.onToggleOutline(false); + this.updateButtons(); + }, + + updateButtons() { + const expanded = !!this.model.get('expanded'); + + this.ui.expandButton.prop('hidden', expanded || !this.hasOutline()); + this.ui.expandButton.attr('aria-expanded', String(expanded)); + this.ui.collapseButton.prop('hidden', !expanded || !this.linkableEntry()); }, updateInaccessibleEntryNotice() { - const entryId = this.model.get('entryId'); - const linkedEntry = this.options.entry.linkedEntries.get(entryId); - const inaccessible = !!linkedEntry && !this.options.entry.linkableEntries.get(entryId); + const linkedEntry = this.options.entry.linkedEntries.get(this.model.get('entryId')); + const inaccessible = !!linkedEntry && !this.linkableEntry(); this.ui.inaccessibleEntryNotice.prop('hidden', !inaccessible); this.ui.inaccessibleEntryNotice.text(inaccessible ? I18n.t('pageflow_scrolled.editor.select_link_destination.inaccessible_entry', {title: linkedEntry.get('title')}) : ''); + }, + + hasOutline() { + return this.linkableEntry()?.get('typeName') === 'scrolled'; + }, + + linkableEntry() { + return this.options.entry.linkableEntries.get(this.model.get('entryId')); + }, + + loadOutline() { + const linkableEntry = this.linkableEntry(); + this.closeOutline(); + this.outlineEntryId = linkableEntry?.id; + + if (!linkableEntry) { + return; + } + + const request = this.outlineRequest = {}; + + linkableEntry.fetchOutline({entry: this.options.entry}).then( + outline => { + if (this.isCurrentOutlineRequest(request)) { + this.showOutline(outline, linkableEntry); + } + }, + () => { + if (this.isCurrentOutlineRequest(request)) { + this.showOutlineError(); + } + } + ); + }, + + isCurrentOutlineRequest(request) { + return this.outlineRequest === request && !this.isClosed; + }, + + showOutline(outline, linkableEntry) { + this.outlineView = this.subview(new SelectableEntryOutlineView({ + entry: outline, + selection: this.model, + + onSelectChapter: chapter => { + this.model.unset('section'); + this.model.set({ + chapter: chapter.get('permaId'), + targetSignature: linkableEntry.chapterLinkSignature(chapter.get('permaId')) + }); + }, + onSelectSection: section => { + this.model.unset('chapter'); + this.model.set({ + section: section.get('permaId'), + targetSignature: linkableEntry.sectionLinkSignature(section.get('permaId')) + }); + } + })); + + this.ui.outlineContainer.append(this.outlineView.el); + }, + + showOutlineError() { + this.ui.outlineContainer.append( + `

+ ${I18n.t('pageflow_scrolled.editor.select_link_destination.outline_failed')} +

` + ); + }, + + closeOutline() { + this.outlineRequest = null; + + if (this.outlineView) { + this.outlineView.close(); + this.outlineView = null; + } + + this.ui.outlineContainer.empty(); } }); diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css index 1297e170a2..f6af049ed4 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css @@ -2,6 +2,8 @@ display: flex; flex-direction: column; gap: space(3); + flex: 1; + min-height: 0; } .inaccessibleEntryNotice { @@ -11,3 +13,43 @@ .entrySelectContainer {} .openInNewTabContainer {} + +.textButton { + composes: unstyledButton from './buttons.module.css'; + align-self: flex-start; + padding: 0; + background: transparent; + color: var(--ui-primary-color); + cursor: pointer; +} + +.textButton::before { + margin-right: space(2); +} + +.textButton[hidden] { + display: none; +} + +.expandButton { + composes: textButton; + composes: rightOpen icon from './icons.module.css'; +} + +.collapseButton { + composes: textButton; + composes: cancel icon from './icons.module.css'; +} + +.outlineContainer { + flex: 1; + container-type: size; +} + +.outlineContainer[hidden] { + display: none; +} + +.outlineError { + color: var(--ui-error-color); +} diff --git a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js index c269282060..385fe9fb97 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js @@ -87,9 +87,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ href && kindOf(href) === 'thisEntry' ? _.pick(href, 'chapter', 'section') : {} ); this.otherEntryLink = new Backbone.Model( - href && href.entry ? - {entryId: href.entry, signature: href.signature, openInNewTab} : - {} + href && href.entry ? otherEntryLinkAttributes(href, {openInNewTab}) : {} ); this.otherEntryLink.modelName = 'otherEntryLink'; this.externalLink = new Backbone.Model( @@ -133,7 +131,8 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ accordion.section(sectionTitle('other_entry'), sectionOptions('otherEntry'), function() { this.view(LinkToOtherEntryView, { entry: dialog.options.entry, - model: dialog.otherEntryLink + model: dialog.otherEntryLink, + onToggleOutline: expanded => accordion.setSectionFill('otherEntry', expanded) }); }); @@ -217,7 +216,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ const linkableEntry = link.href.entry && this.options.entry.linkableEntries.get(link.href.entry); if (linkableEntry) { - this.options.entry.linkedEntries.addLinkableEntry(linkableEntry); + this.options.entry.linkedEntries.addLinkableEntry(linkableEntry, {chapter: link.href.chapter}); } this.options.onSelect(link); @@ -271,8 +270,36 @@ function kindOf(href) { return 'thisEntry'; } -function otherEntryLink({entryId, signature, openInNewTab}) { - if (!entryId || !signature) { +function otherEntryLinkAttributes(href, {openInNewTab}) { + if (href.chapter || href.section) { + return { + entryId: href.entry, + ..._.pick(href, 'chapter', 'section'), + targetSignature: href.signature, + expanded: true, + openInNewTab + }; + } + + return {entryId: href.entry, signature: href.signature, openInNewTab}; +} + +function otherEntryLink({ + entryId, signature, chapter, section, targetSignature, expanded, openInNewTab +}) { + if (!entryId) { + return null; + } + else if (expanded) { + if (!targetSignature || !(chapter || section)) { + return null; + } + + return withTarget({ + href: {entry: entryId, ...(chapter ? {chapter} : {section}), signature: targetSignature} + }, openInNewTab); + } + else if (!signature) { return null; } diff --git a/package/spec/ui/views/AccordionView-spec.js b/package/spec/ui/views/AccordionView-spec.js index dfca88affc..f7ed46f38f 100644 --- a/package/spec/ui/views/AccordionView-spec.js +++ b/package/spec/ui/views/AccordionView-spec.js @@ -153,4 +153,23 @@ describe('AccordionView', () => { expect(getByText('First content').closest('.accordion_view-section')) .toHaveClass('accordion_view-section-fill'); }); + + describe('#setSectionFill', () => { + it('lets section fill remaining height', () => { + const view = new AccordionView({ + selectionModel: new Backbone.Model({kind: 'url'}), + selectionPropertyName: 'kind' + }); + + view.section('Website', {value: 'url'}, function() { + this.view(TextView, {text: 'Website content'}); + }); + + const {getByText} = renderBackboneView(view); + view.setSectionFill('url', true); + + expect(getByText('Website content').closest('.accordion_view-section')) + .toHaveClass('accordion_view-section-fill'); + }); + }); }); diff --git a/package/src/ui/views/AccordionView.js b/package/src/ui/views/AccordionView.js index dbaf509366..e4e00bb880 100644 --- a/package/src/ui/views/AccordionView.js +++ b/package/src/ui/views/AccordionView.js @@ -85,6 +85,23 @@ export const AccordionView = Marionette.View.extend({ }, options || {}))); }, + /** + * Toggle whether the section fills the remaining height while + * expanded. + * + * @param {string} key - Value or name of the section. + * @param {boolean} fill + */ + setSectionFill: function(key, fill) { + const section = this.sections.find(section => section.key === key); + + section.fill = fill; + + if (section.element) { + section.element.classList.toggle('accordion_view-section-fill', fill); + } + }, + render: function() { const groupName = `accordion-${this.cid}`; From ea7899c108e470e974250c42a5349d70e28c5a14 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:20:10 +0200 Subject: [PATCH 16/17] Display linked chapters and sections in editor link tooltip Shows the title of the linked chapter or hints that the link points to a section. Preview links point to the chapter or section in the draft. --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../spec/entryState/linkedEntries-spec.js | 67 ++++++++++++++++++- .../inlineEditing/LinkTooltip-spec.js | 43 +++++++++++- .../scrolled/package/src/entryState/index.js | 2 +- .../package/src/entryState/linkedEntries.js | 25 +++++-- .../src/frontend/inlineEditing/LinkTooltip.js | 37 ++++++---- 7 files changed, 156 insertions(+), 20 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index d2461499b1..c390bd85d3 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -2083,6 +2083,7 @@ de: publication_states: not_published: Noch nicht veröffentlicht published_with_password_protection: Passwortgeschützt + section_in: Abschnitt in unavailable_entry: Nicht verfügbarer Beitrag untitled_excursion: Unbenannter Exkurs visit_chapter: Gehe zu Kapitel diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 90cafe104a..d7bee32a7a 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1909,6 +1909,7 @@ en: publication_states: not_published: Not published yet published_with_password_protection: Password protected + section_in: Section in unavailable_entry: Unavailable story untitled_excursion: Untitled excursion visit_chapter: Go to chapter diff --git a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js index f1652511d2..02d713672f 100644 --- a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js +++ b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js @@ -1,4 +1,9 @@ -import {useLinkedEntry, useLinkedEntryUrl, watchCollections} from 'entryState'; +import { + useLinkedEntry, + useLinkedEntryDestination, + useLinkedEntryUrl, + watchCollections +} from 'entryState'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; @@ -19,6 +24,66 @@ describe('useLinkedEntry', () => { }); }); +describe('useLinkedEntryDestination', () => { + function renderDestination(href, linkedEntry) { + return renderHookInEntry( + () => useLinkedEntryDestination(href), { + seed: { + linkedEntries: [{ + id: 5, + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + ...linkedEntry + }] + } + } + ).result.current; + } + + it('returns undefined for unknown entry', () => { + expect(renderDestination({entry: 6})).toBeUndefined(); + }); + + it('returns urls of entry', () => { + expect(renderDestination({entry: 5})).toMatchObject({ + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview' + }); + }); + + it('returns chapter with published and preview anchors', () => { + const destination = renderDestination({entry: 5, chapter: 20}, { + chapters: [{permaId: 20, title: 'Intro', slug: 'old', previewSlug: 'new'}] + }); + + expect(destination).toMatchObject({ + url: 'https://example.com/other#old', + previewUrl: '/admin/entries/other/preview#new', + chapter: {title: 'Intro'} + }); + }); + + it('returns urls without anchor for unknown chapters', () => { + const destination = renderDestination({entry: 5, chapter: 20}); + + expect(destination).toMatchObject({ + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + chapter: undefined + }); + }); + + it('returns section anchors', () => { + const destination = renderDestination({entry: 5, section: 30}); + + expect(destination).toMatchObject({ + url: 'https://example.com/other#section-30', + previewUrl: '/admin/entries/other/preview#section-30', + section: true + }); + }); +}); + describe('useLinkedEntryUrl', () => { it('returns url of linked entry', () => { const {result} = renderHookInEntry( 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 2137e27f9c..78eeb820ed 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -21,7 +21,8 @@ describe('LinkTooltip', () => { 'pageflow_scrolled.inline_editing.link_tooltip.publication_states.published_with_password_protection': 'Password protected', 'pageflow_scrolled.inline_editing.link_tooltip.open_preview': 'Open preview', - 'pageflow_scrolled.inline_editing.link_tooltip.open_draft_preview': 'Open draft preview' + 'pageflow_scrolled.inline_editing.link_tooltip.open_draft_preview': 'Open draft preview', + 'pageflow_scrolled.inline_editing.link_tooltip.section_in': 'Section in' }); beforeEach(() => { @@ -324,6 +325,46 @@ describe('LinkTooltip', () => { expect(getByText('Unavailable story')).toBeInTheDocument(); }); + describe('with chapter or section', () => { + const linkedEntry = { + title: 'Other story', + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'published_without_password_protection' + }; + + it('displays chapter title linking to chapter', async () => { + const {getByText, getByRole} = renderLinkPreview({entry: 7, chapter: 20}, { + linkedEntry: { + ...linkedEntry, + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'new-intro'}] + } + }); + + const user = userEvent.setup(); + await user.hover(getByText('A link')); + + expect(getByRole('link', {name: 'Other story › Intro'})) + .toHaveAttribute('href', 'https://example.com/other#intro'); + expect(getByRole('link', {name: 'Open draft preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#new-intro'); + }); + + it('displays section hint', async () => { + const {getByText, getByRole} = renderLinkPreview({entry: 7, section: 30}, { + linkedEntry + }); + + const user = userEvent.setup(); + await user.hover(getByText('A link')); + + expect(getByText('Section in', {exact: false})).toBeInTheDocument(); + expect(getByRole('link', {name: 'Other story'})) + .toHaveAttribute('href', 'https://example.com/other#section-30'); + expect(getByRole('link', {name: 'Open draft preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#section-30'); + }); + }); }); it('displays tooltip for file link', async () => { diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index 1a0b5c4e0c..5a2b7dc67a 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -4,7 +4,7 @@ export {useCutOff} from './cutoff'; export {useEmbedOriginUrl} from './embedOriginUrl'; export {useShareProviders, useShareUrl} from './sharing'; export {useEntryTranslations} from './entryTranslations'; -export {useLinkedEntry, useLinkedEntryUrl} from './linkedEntries'; +export {useLinkedEntry, useLinkedEntryDestination, useLinkedEntryUrl} from './linkedEntries'; export { useDarkWidgets, useEntryMetadata diff --git a/entry_types/scrolled/package/src/entryState/linkedEntries.js b/entry_types/scrolled/package/src/entryState/linkedEntries.js index 9e90ca8933..2a9ce5fae6 100644 --- a/entry_types/scrolled/package/src/entryState/linkedEntries.js +++ b/entry_types/scrolled/package/src/entryState/linkedEntries.js @@ -4,17 +4,32 @@ export function useLinkedEntry(entryId) { return useEntryStateCollectionItem('linkedEntries', entryId); } -export function useLinkedEntryUrl({entry: entryId, chapter, section}) { +export function useLinkedEntryUrl(href) { + return useLinkedEntryDestination(href)?.url; +} + +export function useLinkedEntryDestination({entry: entryId, chapter: chapterPermaId, section}) { const linkedEntry = useLinkedEntry(entryId); if (!linkedEntry) { return undefined; } - return withAnchor(linkedEntry.url, { - chapterSlug: findLinkedChapter(linkedEntry, chapter)?.slug, - section - }); + const chapter = findLinkedChapter(linkedEntry, chapterPermaId); + + return { + linkedEntry, + chapter, + section: !!section, + url: withAnchor(linkedEntry.url, { + chapterSlug: chapter?.slug, + section + }), + previewUrl: linkedEntry.previewUrl && withAnchor(linkedEntry.previewUrl, { + chapterSlug: chapter?.previewSlug, + section + }) + }; } function findLinkedChapter(linkedEntry, chapterPermaId) { diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js index b06c23cde6..38843534fb 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/LinkTooltip.js @@ -6,7 +6,7 @@ import {useI18n} from '../i18n'; import { useChapter, useDownloadableFile, - useLinkedEntry, + useLinkedEntryDestination, useMainStoryline } from 'pageflow-scrolled/entryState'; import {SectionThumbnail} from '../SectionThumbnail'; @@ -59,7 +59,7 @@ export function LinkTooltip({disabled, setFloating, floatingStyles, floatingCont function LinkDestination({href, openInNewTab}) { if (href?.entry) { return ( - + ); } else if (href?.chapter) { @@ -133,35 +133,48 @@ function SectionLinkDestination({permaId}) { ); } -function LinkedEntryLinkDestination({entryId, openInNewTab}) { - const linkedEntry = useLinkedEntry(entryId); +function LinkedEntryLinkDestination({href, openInNewTab}) { + const destination = useLinkedEntryDestination(href); const {t} = useI18n({locale: 'ui'}); - if (!linkedEntry) { + if (!destination) { return ( {t('pageflow_scrolled.inline_editing.link_tooltip.unavailable_entry')} ); } + const {linkedEntry} = destination; + return ( <>
+ {destination.section && + {t('pageflow_scrolled.inline_editing.link_tooltip.section_in')}} {linkedEntry.publicationState === 'not_published' ? - {linkedEntry.title} : - : + - {linkedEntry.title} + } {linkedEntry.previewUrl && }
- {linkedEntry.previewUrl && } + {linkedEntry.previewUrl && } ); } +function LinkedEntryDestinationTitle({destination}) { + return ( + <> + {destination.linkedEntry.title} + {destination.chapter?.title && <> › {destination.chapter.title}} + + ); +} + function PublicationStatePill({linkedEntry}) { const {t} = useI18n({locale: 'ui'}); @@ -178,15 +191,15 @@ function PublicationStatePill({linkedEntry}) { const pillPublicationStates = ['not_published', 'published_with_password_protection']; -function PreviewLink({linkedEntry}) { +function PreviewLink({destination}) { const {t} = useI18n({locale: 'ui'}); return (
- - {linkedEntry.publicationState === 'not_published' ? + {destination.linkedEntry.publicationState === 'not_published' ? t('pageflow_scrolled.inline_editing.link_tooltip.open_preview') : t('pageflow_scrolled.inline_editing.link_tooltip.open_draft_preview')} From f31e7918669f0bb001c7f4a050019ae7394aa4a9 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 10:21:48 +0200 Subject: [PATCH 17/17] Display linked chapters and sections in preview link tooltip The tooltip for links to entries that are not published yet or password protected names the linked chapter or section. Its preview link points to the chapter or section in the draft. --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../commenting/features/linkTooltip-spec.js | 45 ++++++++++++++++++- .../package/src/frontend/commenting/Link.js | 6 +-- .../src/frontend/commenting/LinkTooltip.js | 23 ++++++---- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index c390bd85d3..08bebd63e4 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -2190,3 +2190,4 @@ de: open_draft_preview: Vorschau des Entwurfs öffnen open_preview: Vorschau öffnen password_protected: Passwortgeschützt + section_in: Abschnitt in diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index d7bee32a7a..1bf0083bf8 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -2016,3 +2016,4 @@ en: open_draft_preview: Open draft preview open_preview: Open preview password_protected: Password protected + section_in: Section in diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js index 93db32a7e9..3e4f586709 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js @@ -14,10 +14,11 @@ describe('link tooltip in commenting mode', () => { 'pageflow_scrolled.review.link_tooltip.password_protected': 'Password protected', 'pageflow_scrolled.review.link_tooltip.open_preview': 'Open preview', 'pageflow_scrolled.review.link_tooltip.open_draft_preview': 'Open draft preview', + 'pageflow_scrolled.review.link_tooltip.section_in': 'Section in', 'pageflow_scrolled.inline_editing.link_tooltip.opens_in_same_tab': 'Opens in same tab' }); - function renderLink(href, {publicationState = 'not_published'} = {}) { + function renderLink(href, {publicationState = 'not_published', chapters} = {}) { return renderEntry({ seed: { linkedEntries: [{ @@ -25,7 +26,8 @@ describe('link tooltip in commenting mode', () => { title: 'Other story', url: 'https://example.com/other', previewUrl: '/admin/entries/other/preview', - publicationState + publicationState, + chapters }] }, contentElement: {ui: A link} @@ -92,4 +94,43 @@ describe('link tooltip in commenting mode', () => { expect(entry.getAllByRole('link')).toHaveLength(1); }); + + describe('with chapter or section', () => { + it('offers preview of chapter', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, chapter: 20, signature: 's'}, { + chapters: [{permaId: 20, title: 'New chapter', slug: 'new-chapter', + previewSlug: 'new-chapter'}] + }); + + await user.hover(entry.getByText('A link')); + + expect(entry.getByText('Other story › New chapter')).toBeInTheDocument(); + expect(entry.getByRole('link', {name: 'Open preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#new-chapter'); + }); + + it('offers preview of section', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, section: 30, signature: 's'}); + + await user.hover(entry.getByText('A link')); + + expect(entry.getByText('Section in Other story')).toBeInTheDocument(); + expect(entry.getByRole('link', {name: 'Open preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#section-30'); + }); + + it('does not display tooltip for chapters of published entries', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, chapter: 20, signature: 's'}, { + publicationState: 'published_without_password_protection', + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'intro'}] + }); + + await user.hover(entry.getByText('A link')); + + expect(entry.getAllByRole('link')).toHaveLength(1); + }); + }); }); diff --git a/entry_types/scrolled/package/src/frontend/commenting/Link.js b/entry_types/scrolled/package/src/frontend/commenting/Link.js index d12d2448f8..461a5c2219 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/Link.js +++ b/entry_types/scrolled/package/src/frontend/commenting/Link.js @@ -2,13 +2,13 @@ import React from 'react'; import {PlainLink} from '../Link'; import {useLinkTooltip} from '../../shared/linkTooltip'; -import {usePreviewableLinkedEntry} from './LinkTooltip'; +import {usePreviewableLinkDestination} from './LinkTooltip'; export function Link(props) { const linkTooltip = useLinkTooltip(); - const linkedEntry = usePreviewableLinkedEntry(props.href); + const destination = usePreviewableLinkDestination(props.href); - if (!linkedEntry || !linkTooltip) { + if (!destination || !linkTooltip) { return ; } diff --git a/entry_types/scrolled/package/src/frontend/commenting/LinkTooltip.js b/entry_types/scrolled/package/src/frontend/commenting/LinkTooltip.js index 5bf55d6171..36b41b4c53 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/LinkTooltip.js +++ b/entry_types/scrolled/package/src/frontend/commenting/LinkTooltip.js @@ -2,7 +2,7 @@ import React from 'react'; import {FloatingArrow} from '@floating-ui/react'; import {useI18n} from '../i18n'; -import {useLinkedEntry} from 'pageflow-scrolled/entryState'; +import {useLinkedEntryDestination} from 'pageflow-scrolled/entryState'; import { LinkTooltipProvider as SharedLinkTooltipProvider, useLinkTooltip @@ -20,21 +20,24 @@ export function LinkTooltipProvider({children}) { ); } -export function usePreviewableLinkedEntry(href) { - const linkedEntry = useLinkedEntry(href?.entry); +export function usePreviewableLinkDestination(href) { + const destination = useLinkedEntryDestination(href?.entry ? href : {}); - return linkedEntry && previewLabels[linkedEntry.publicationState] ? linkedEntry : null; + return destination && previewLabels[destination.linkedEntry.publicationState] ? + destination : + null; } function LinkTooltip({setFloating, floatingStyles, floatingContext, arrowRef, state}) { const {keep, deactivate} = useLinkTooltip(); - const linkedEntry = usePreviewableLinkedEntry(state?.href); + const destination = usePreviewableLinkDestination(state?.href); const {t} = useI18n({locale: 'ui'}); - if (!linkedEntry) { + if (!destination) { return null; } + const {linkedEntry} = destination; const labels = previewLabels[linkedEntry.publicationState]; return ( @@ -47,13 +50,17 @@ function LinkTooltip({setFloating, floatingStyles, floatingContext, arrowRef, st
- {linkedEntry.title} + + {destination.section ? + `${t('pageflow_scrolled.review.link_tooltip.section_in')} ${linkedEntry.title}` : + [linkedEntry.title, destination.chapter?.title].filter(Boolean).join(' › ')} + {t(`pageflow_scrolled.review.link_tooltip.${labels.state}`)}