From 11c02ee7e45186b15f97f0667dd834c363fb324c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 07:17:46 +0000 Subject: [PATCH 01/10] Count download links as file references Treat files linked from text and link buttons as referenced, so the usage counts shown when selecting files for bulk deletion include downloads the entry still needs. Include these links in reference-based file rights as well. --- .../entry_link_references.rb | 12 +-- .../doc/internal/configuration_schemas.md | 4 +- .../pageflow_scrolled/configuration_hrefs.rb | 25 ++++++ .../lib/pageflow_scrolled/file_references.rb | 46 ++++++++--- .../ScrolledEntry/fileReferences-spec.js | 44 ++++++++++- .../src/shared/collectFileReferences.js | 55 +++++++++++++- .../spec/fixtures/file_references.json | 76 +++++++++++++++++++ .../configuration_hrefs_spec.rb | 43 +++++++++++ .../pageflow_scrolled/sections_copy_spec.rb | 28 +++++++ 9 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 entry_types/scrolled/lib/pageflow_scrolled/configuration_hrefs.rb create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/configuration_hrefs_spec.rb diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb b/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb index b1f6d22342..a38fead684 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb @@ -29,7 +29,7 @@ def valid_links def links @configurations.each_with_object([]) do |configuration, result| - each_href(configuration) do |href| + ConfigurationHrefs.each(configuration) do |href| entry_id = to_id(href['entry']) next unless entry_id @@ -41,16 +41,6 @@ def links end end - def each_href(value, &block) - case value - when Hash - yield value['href'] if value['href'].is_a?(Hash) - value.each_value { |item| each_href(item, &block) } - when Array - value.each { |item| each_href(item, &block) } - end - end - def to_id(value) id = Integer(value, exception: false) id if id&.positive? diff --git a/entry_types/scrolled/doc/internal/configuration_schemas.md b/entry_types/scrolled/doc/internal/configuration_schemas.md index 14c32827fb..5b1c4f72e2 100644 --- a/entry_types/scrolled/doc/internal/configuration_schemas.md +++ b/entry_types/scrolled/doc/internal/configuration_schemas.md @@ -23,7 +23,9 @@ schema file and follow the terms below through both languages. - **File reference** — a location resolved against actual configuration data, as `{subject, path, active}`. Produced by `collectFileReferences`, indexed by file by - `collectEntryFileReferences`. + `collectEntryFileReferences`. Links to files (`href: {file: {permaId, + collectionName}}`) are found anywhere in a configuration, in rich + text and link properties alike, and need no location. - **Referenced file** — a file some configuration points at. Not the same as a *used* file, see below. - **Configuration place** — the editor's presentation of a reference, diff --git a/entry_types/scrolled/lib/pageflow_scrolled/configuration_hrefs.rb b/entry_types/scrolled/lib/pageflow_scrolled/configuration_hrefs.rb new file mode 100644 index 0000000000..6913c4651e --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/configuration_hrefs.rb @@ -0,0 +1,25 @@ +module PageflowScrolled + # Find link targets in configurations, both in slate link nodes and + # in link objects of content elements. + # + # @api private + module ConfigurationHrefs + class << self + def each(value, path = [], &block) + case value + when Hash + each_in_hash(value, path, &block) + when Array + value.each_with_index { |item, index| each(item, path + [index], &block) } + end + end + + private + + def each_in_hash(hash, path, &block) + yield hash['href'], path if hash['href'].is_a?(Hash) + hash.each { |key, item| each(item, path + [key], &block) } + end + end + end +end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/file_references.rb b/entry_types/scrolled/lib/pageflow_scrolled/file_references.rb index b5a2c58533..17261161e7 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/file_references.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/file_references.rb @@ -1,6 +1,6 @@ module PageflowScrolled - # Resolve file reference locations against a configuration to find - # the files it points at. + # Resolve file reference locations against a configuration and find + # links to files in it to find the files it points at. # # @api private class FileReferences @@ -9,22 +9,44 @@ def initialize(locations) end def for(configuration) + location_references(configuration) + link_references(configuration) + end + + private + + def location_references(configuration) @locations.flat_map do |location| values_at(configuration, location['path']).filter_map do |path, value| - perma_id = to_perma_id(value) - next unless perma_id - - { - collection_name: location['collection'], - perma_id:, - path:, - active: active?(location['activeIf'], configuration) - } + reference(location['collection'], value, path, + active?(location['activeIf'], configuration)) end end end - private + def link_references(configuration) + result = [] + + ConfigurationHrefs.each(configuration) do |href, path| + file = href['file'] + next unless file.is_a?(Hash) && collection_name?(file['collectionName']) + + result << reference(file['collectionName'], file['permaId'], + path + %w[href file permaId], true) + end + + result.compact + end + + def collection_name?(value) + value.is_a?(String) && value.present? + end + + def reference(collection_name, value, path, active) + perma_id = to_perma_id(value) + return unless perma_id + + {collection_name:, perma_id:, path:, active:} + end def to_perma_id(value) perma_id = Integer(value, exception: false) diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js index d426822a61..6f0b6f7dd2 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js @@ -1,6 +1,7 @@ import {editor} from 'pageflow-scrolled/editor'; -import {useFakeTranslations} from 'pageflow/testHelpers'; +import {FilteredFilesView} from 'pageflow/editor'; +import {renderBackboneView, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; describe('ScrolledEntry', () => { @@ -12,9 +13,14 @@ describe('ScrolledEntry', () => { 'pageflow_scrolled.editor.edit_section.attributes.backdropImage.label': 'Background image', 'pageflow_scrolled.editor.edit_section.attributes.atmoAudioFileId.label': 'Atmo audio', 'pageflow_scrolled.editor.content_elements.inlineImage.name': 'Image', + 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text', 'pageflow_scrolled.editor.content_elements.inlineImage.attributes.id.label': 'Image file', 'pageflow_scrolled.editor.content_elements.customElement.name': 'Custom element', 'pageflow_scrolled.editor.configuration_places.entry': 'Settings', + 'pageflow.editor.templates.file_item.reference_count': { + one: '1 usage', + other: '%{count} usages' + }, 'activerecord.attributes.pageflow/entry.share_image_id': 'Social Sharing Image' }); @@ -108,6 +114,42 @@ describe('ScrolledEntry', () => { .toEqual(['Intro - Image']); }); + it('counts download links as usages in multi-select without confusing file collections', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5, display_name: 'linked.png'}, + {id: 2, perma_id: 6, display_name: 'unused.png'}], + videoFiles: [{perma_id: 5}], + sections: [{id: 1, permaId: 10, chapterId: 1}], + contentElements: [{ + id: 2, permaId: 20, sectionId: 1, typeName: 'textBlock', + configuration: {value: [{type: 'paragraph', children: [ + {text: 'Download '}, + {type: 'link', href: {file: {permaId: 5, collectionName: 'imageFiles'}}, + children: [{text: 'image'}]} + ]}]} + }] + }); + const references = entry.fileReferences(); + const videoFile = entry.getFileCollection('video_files').findWhere({perma_id: 5}); + + expect(references.placesFor(imageFile(entry, 5)).map(({label}) => label)) + .toEqual(['Intro - Text']); + expect(references.placesFor(imageFile(entry, 6))).toEqual([]); + expect(references.placesFor(videoFile)).toEqual([]); + + const view = new FilteredFilesView({ + entry, + fileTypes: [imageFile(entry, 5).fileType()] + }); + renderBackboneView(view); + view.startSelecting(); + + const badges = Array.from(view.el.querySelectorAll('.file_item-reference_count')); + expect(badges.map(badge => badge.textContent)).toEqual(['1 usage', '0 usages']); + expect(badges.map(badge => badge.classList.contains('is_unreferenced'))) + .toEqual([false, true]); + }); + it('uses pictogram of content element type', () => { editor.contentElementTypes.register('inlineImage', {pictogram: 'inlineImage.svg'}); diff --git a/entry_types/scrolled/package/src/shared/collectFileReferences.js b/entry_types/scrolled/package/src/shared/collectFileReferences.js index 01a02206a8..7a6c77ef57 100644 --- a/entry_types/scrolled/package/src/shared/collectFileReferences.js +++ b/entry_types/scrolled/package/src/shared/collectFileReferences.js @@ -8,19 +8,66 @@ * @private */ export function collectFileReferences({locations, configuration}) { + return [ + ...locationReferences(locations, configuration), + ...linkReferences(configuration) + ]; +} + +function locationReferences(locations, configuration) { return locations.flatMap(location => valuesAt(configuration, location.path) - .map(({path, value}) => ({path, permaId: toPermaId(value)})) - .filter(({permaId}) => permaId) - .map(({path, permaId}) => ({ + .map(({path, value}) => reference({ collectionName: location.collection, - permaId, + value, path, active: isActive(location.activeIf, configuration) })) + .filter(Boolean) ); } +function linkReferences(configuration) { + return fileLinks(configuration) + .map(({path, file}) => isCollectionName(file.collectionName) && reference({ + collectionName: file.collectionName, + value: file.permaId, + path, + active: true + })) + .filter(Boolean); +} + +function reference({collectionName, value, path, active}) { + const permaId = toPermaId(value); + + return permaId && {collectionName, permaId, path, active}; +} + +function fileLinks(value, path = []) { + if (!value || typeof value !== 'object') { + return []; + } + + const file = isObject(value.href) ? value.href.file : null; + const links = isObject(file) ? + [{path: [...path, 'href', 'file', 'permaId'], file}] : + []; + + return [ + ...links, + ...Object.entries(value).flatMap(([key, item]) => fileLinks(item, [...path, key])) + ]; +} + +function isObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isCollectionName(value) { + return typeof value === 'string' && value.length > 0; +} + // Backdrops of legacy sections store a color in the property that // otherwise holds an image perma id. function toPermaId(value) { diff --git a/entry_types/scrolled/spec/fixtures/file_references.json b/entry_types/scrolled/spec/fixtures/file_references.json index 6e63f63c81..dc0c21cfac 100644 --- a/entry_types/scrolled/spec/fixtures/file_references.json +++ b/entry_types/scrolled/spec/fixtures/file_references.json @@ -7,6 +7,82 @@ "compared as strings." ], "cases": [ + { + "name": "finds download links in nested rich text and preserves their collections", + "locations": [], + "configuration": { + "value": [{"type": "bulleted-list", "children": [ + {"type": "list-item", "children": [ + {"text": "Download "}, + {"type": "link", "href": {"file": {"permaId": 5, "collectionName": "imageFiles"}}, "children": [{"text": "image"}]}, + {"text": " or "}, + {"type": "link", "href": {"file": {"permaId": 5, "collectionName": "videoFiles"}}, "children": [{"text": "video"}]}, + {"type": "link", "href": "https://example.com", "children": [{"text": "website"}]}, + {"type": "link", "href": {"chapter": 5}, "children": [{"text": "chapter"}]} + ]} + ]}] + }, + "references": [ + {"collectionName": "imageFiles", "permaId": 5, "path": ["value", "0", "children", "0", "children", "1", "href", "file", "permaId"], "active": true}, + {"collectionName": "videoFiles", "permaId": 5, "path": ["value", "0", "children", "0", "children", "3", "href", "file", "permaId"], "active": true} + ] + }, + { + "name": "finds download links outside rich text", + "locations": [], + "configuration": { + "tooltipLinks": { + "area1": {"href": {"file": {"permaId": 6, "collectionName": "otherFiles"}}, "linkText": "PDF"} + } + }, + "references": [ + {"collectionName": "otherFiles", "permaId": 6, "path": ["tooltipLinks", "area1", "href", "file", "permaId"], "active": true} + ] + }, + { + "name": "finds download links next to location references", + "locations": [{"path": ["image"], "collection": "imageFiles"}], + "configuration": { + "image": 5, + "caption": [{"type": "paragraph", "children": [ + {"type": "link", "href": {"file": {"permaId": 6, "collectionName": "otherFiles"}}, "children": [{"text": "PDF"}]} + ]}] + }, + "references": [ + {"collectionName": "imageFiles", "permaId": 5, "path": ["image"], "active": true}, + {"collectionName": "otherFiles", "permaId": 6, "path": ["caption", "0", "children", "0", "href", "file", "permaId"], "active": true} + ] + }, + { + "name": "finds download links nested in objects that link to files themselves", + "locations": [], + "configuration": { + "href": {"file": {"permaId": 5, "collectionName": "imageFiles"}}, + "items": [{"href": {"file": {"permaId": 6, "collectionName": "otherFiles"}}}] + }, + "references": [ + {"collectionName": "imageFiles", "permaId": 5, "path": ["href", "file", "permaId"], "active": true}, + {"collectionName": "otherFiles", "permaId": 6, "path": ["items", "0", "href", "file", "permaId"], "active": true} + ] + }, + { + "name": "ignores incomplete and malformed download targets", + "locations": [], + "configuration": { + "value": [null, {"type": "paragraph", "children": [ + null, + {"type": "link", "href": {"file": {"collectionName": "imageFiles"}}, "children": [{"text": "missing id"}]}, + {"type": "link", "href": {"file": {"permaId": 0, "collectionName": "imageFiles"}}, "children": [{"text": "invalid id"}]}, + {"type": "link", "href": {"file": {"permaId": 5}}, "children": [{"text": "missing collection"}]}, + {"type": "link", "href": {"file": {"permaId": 5, "collectionName": ""}}, "children": [{"text": "blank collection"}]}, + {"type": "link", "href": {"file": {"permaId": 5, "collectionName": 5}}, "children": [{"text": "collection not a string"}]}, + {"type": "link", "href": [{"file": {"permaId": 5, "collectionName": "imageFiles"}}], "children": [{"text": "href not an object"}]}, + {"type": "link", "href": {"file": 5}, "children": [{"text": "file not an object"}]}, + {"type": "link", "href": null, "children": [{"text": "no href"}]} + ]}] + }, + "references": [] + }, { "name": "finds reference in top level property", "locations": [{"path": ["image"], "collection": "imageFiles"}], diff --git a/entry_types/scrolled/spec/pageflow_scrolled/configuration_hrefs_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/configuration_hrefs_spec.rb new file mode 100644 index 0000000000..68f4256d97 --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/configuration_hrefs_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe ConfigurationHrefs do + it 'yields hrefs of slate link nodes with paths of their nodes' do + configuration = { + 'value' => [ + {'type' => 'paragraph', 'children' => [ + {'type' => 'link', 'href' => {'entry' => 7}, 'children' => [{'text' => 'Link'}]} + ]} + ] + } + + expect { |block| ConfigurationHrefs.each(configuration, &block) } + .to yield_with_args({'entry' => 7}, ['value', 0, 'children', 0]) + end + + it 'yields hrefs of keyed link objects' do + configuration = { + 'itemLinks' => {'item1' => {'href' => {'file' => {'permaId' => 5}}}} + } + + expect { |block| ConfigurationHrefs.each(configuration, &block) } + .to yield_with_args({'file' => {'permaId' => 5}}, %w[itemLinks item1]) + end + + it 'yields hrefs nested inside objects that have an href themselves' do + configuration = { + 'href' => {'entry' => 7}, + 'items' => [{'href' => {'entry' => 8}}] + } + + expect { |block| ConfigurationHrefs.each(configuration, &block) } + .to yield_successive_args([{'entry' => 7}, []], [{'entry' => 8}, ['items', 0]]) + end + + it 'ignores hrefs that are not objects' do + configuration = {'value' => [nil, {'href' => 'https://example.com'}, {'href' => nil}]} + + expect { |block| ConfigurationHrefs.each(configuration, &block) }.not_to yield_control + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/sections_copy_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/sections_copy_spec.rb index 9e1bac8922..a2f2e74b62 100644 --- a/entry_types/scrolled/spec/pageflow_scrolled/sections_copy_spec.rb +++ b/entry_types/scrolled/spec/pageflow_scrolled/sections_copy_spec.rb @@ -125,6 +125,34 @@ module PageflowScrolled .to eq(destination_entry.draft.find_file(Pageflow::ImageFile, image_file.id).perma_id) end + it 'rewrites perma id of file linked from content element' do + source_entry = create(:draft_entry, type_name: 'scrolled') + destination_entry = create(:draft_entry, type_name: 'scrolled') + create_list(:image_file, 2, used_in: destination_entry.draft) + image_file = create_used_file(:image_file, entry: source_entry) + source_section = create(:section, revision: source_entry.draft, configuration: {}) + create(:content_element, + section: source_section, + type_name: 'textBlock', + configuration: { + 'value' => [{'type' => 'paragraph', 'children' => [ + {'type' => 'link', + 'href' => {'file' => {'permaId' => image_file.perma_id, + 'collectionName' => 'imageFiles'}}, + 'children' => [{'text' => 'Download'}]} + ]}] + }) + destination_chapter = create(:scrolled_chapter, revision: destination_entry.draft) + + sections = SectionsCopy + .new(source_entry:, destination_entry:) + .perform([source_section], chapter: destination_chapter) + + expect(sections.first.content_elements.first.configuration + .dig('value', 0, 'children', 0, 'href', 'file', 'permaId')) + .to eq(destination_entry.draft.find_file(Pageflow::ImageFile, image_file.id).perma_id) + end + it 'reuses file usage the destination entry already has' do source_entry = create(:draft_entry, type_name: 'scrolled') destination_entry = create(:draft_entry, type_name: 'scrolled') From b2764f938e78c6b8fd7701065de22858b856caae Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 11:45:53 +0200 Subject: [PATCH 02/10] Find referenced files of entries on the server Determine which files active references in the configurations of an entry point at, including nested files like text tracks, so the published seed can mark referenced files without shipping reference locations and the walking algorithm. --- .../entry_file_reference_locations.rb | 19 ++- .../lib/pageflow_scrolled/referenced_files.rb | 79 +++++++++ .../referenced_files_spec.rb | 159 ++++++++++++++++++ 3 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb index 46ef74320c..fa5bca0e22 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb @@ -11,23 +11,24 @@ def initialize(entry_config, content_elements, include_unused: false) end def content_element_locations - content_element_type_names.each_with_object({}) do |type_name, result| - schema = schemas.find(model: 'contentElement', type_name:) + @content_element_locations ||= + content_element_type_names.each_with_object({}) do |type_name, result| + schema = schemas.find(model: 'contentElement', type_name:) - if schema - result[type_name] = locations.for(schema) - else - log_missing_schema(type_name) + if schema + result[type_name] = locations.for(schema) + else + log_missing_schema(type_name) + end end - end end def section_locations - locations_for(model: 'section') + @section_locations ||= locations_for(model: 'section') end def entry_locations - locations_for(model: 'entry') + @entry_locations ||= locations_for(model: 'entry') end private diff --git a/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb new file mode 100644 index 0000000000..d1f321d330 --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb @@ -0,0 +1,79 @@ +module PageflowScrolled + # Find the files that active references in the configurations of an + # entry point at, including files nested in those. + # + # @api private + class ReferencedFiles + def initialize(entry, sections:, content_elements:, locations:) + @entry = entry + @sections = sections + @content_elements = content_elements + @locations = locations + end + + def include?(collection_name, perma_id) + keys.include?(key(collection_name, perma_id)) + end + + private + + def keys + @keys ||= begin + keys = Set.new(referenced_keys) + keys.merge(nested_keys(keys)) + end + end + + def referenced_keys + @referenced_keys ||= subjects.flat_map do |configuration, subject_locations| + FileReferences.new(subject_locations.to_a) + .for(configuration) + .select { |reference| reference[:active] } + .map { |reference| key(reference[:collection_name], reference[:perma_id]) } + end + end + + def subjects + content_element_locations = @locations.content_element_locations + + [[{'shareImageId' => @entry.share_image_id}, @locations.entry_locations]] + + @sections.map { |section| [section.configuration, @locations.section_locations] } + + @content_elements.map do |content_element| + [content_element.configuration, content_element_locations[content_element.type_name]] + end + end + + def nested_keys(parent_keys) + file_types.reject { |file_type| file_type.nested_file_types.empty? }.flat_map do |file_type| + parent_perma_ids = files_of(file_type).to_h { |file| [file.id, file.perma_id] } + + file_type.nested_file_types.flat_map do |nested_file_type| + nested_files_of_referenced(file_type, nested_file_type, parent_perma_ids, parent_keys) + end + end + end + + def nested_files_of_referenced(file_type, nested_file_type, parent_perma_ids, parent_keys) + files_of(nested_file_type).filter_map do |nested_file| + next unless nested_file.parent_file_model_type == file_type.model.name + next unless parent_keys.include?(key(file_type.collection_name, + parent_perma_ids[nested_file.parent_file_id])) + + key(nested_file_type.collection_name, nested_file.perma_id) + end + end + + def files_of(file_type) + @files ||= {} + @files[file_type.model] ||= @entry.find_files(file_type.model) + end + + def file_types + @file_types ||= Pageflow.config_for(@entry).file_types + end + + def key(collection_name, perma_id) + [collection_name.to_s.camelize(:lower), perma_id] + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb new file mode 100644 index 0000000000..06b291b8b3 --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb @@ -0,0 +1,159 @@ +require 'spec_helper' +require 'pageflow/used_file_test_helper' + +module PageflowScrolled + RSpec.describe ReferencedFiles do + include UsedFileTestHelper + + it 'includes files referenced by content elements' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'inlineImage', + configuration: {'id' => image_file.perma_id}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('image_files', image_file.perma_id)).to eq(true) + end + + it 'includes files linked from content elements' do + entry = create(:draft_entry, type_name: 'scrolled') + other_file = create_used_file(:image_file, entry:) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'textBlock', + configuration: { + 'value' => [{'type' => 'paragraph', 'children' => [ + {'type' => 'link', + 'href' => {'file' => {'permaId' => other_file.perma_id, + 'collectionName' => 'imageFiles'}}, + 'children' => [{'text' => 'Download'}]} + ]}] + }) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('image_files', other_file.perma_id)).to eq(true) + end + + it 'includes files referenced by sections' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + create(:section, + revision: entry.draft, + configuration: {'backdrop' => {'image' => image_file.perma_id}}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('image_files', image_file.perma_id)).to eq(true) + end + + it 'includes share image of entry' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + entry.draft.update!(share_image_id: image_file.perma_id) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('image_files', image_file.perma_id)).to eq(true) + end + + it 'does not include files of other collections with same perma id' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'inlineImage', + configuration: {'id' => image_file.perma_id}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('video_files', image_file.perma_id)).to eq(false) + end + + it 'does not include files of inactive references' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'inlineVideo', + configuration: {'portraitPosterId' => image_file.perma_id}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('image_files', image_file.perma_id)).to eq(false) + end + + it 'does not include files only referenced by sections it was not given' do + entry = create(:draft_entry, type_name: 'scrolled') + image_file = create_used_file(:image_file, entry:) + section = create(:section, + revision: entry.draft, + configuration: {'backdrop' => {'image' => image_file.perma_id}}) + create(:content_element, + section:, + type_name: 'inlineImage', + configuration: {'id' => image_file.perma_id}) + + referenced_files = referenced_files_for(entry, sections: [], content_elements: []) + + expect(referenced_files.include?('image_files', image_file.perma_id)).to eq(false) + end + + it 'includes nested files of referenced files' do + entry = create(:draft_entry, type_name: 'scrolled') + video_file = create_used_file(:video_file, entry:) + text_track_file = create(:text_track_file, used_in: entry.draft, parent_file: video_file) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'inlineVideo', + configuration: {'id' => video_file.perma_id}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('text_track_files', perma_id_of(entry, text_track_file))) + .to eq(true) + end + + it 'does not include nested files of other files' do + entry = create(:draft_entry, type_name: 'scrolled') + video_file = create_used_file(:video_file, entry:) + other_video_file = create_used_file(:video_file, entry:) + text_track_file = create(:text_track_file, + used_in: entry.draft, + parent_file: other_video_file) + section = create(:section, revision: entry.draft) + create(:content_element, + section:, + type_name: 'inlineVideo', + configuration: {'id' => video_file.perma_id}) + + referenced_files = referenced_files_for(entry) + + expect(referenced_files.include?('text_track_files', perma_id_of(entry, text_track_file))) + .to eq(false) + end + + def referenced_files_for(entry, + sections: Section.all_for_revision(entry.draft), + content_elements: ContentElement.all_for_revision(entry.draft)) + ReferencedFiles.new( + entry, + sections:, + content_elements:, + locations: EntryFileReferenceLocations.new(Pageflow.config_for(entry), content_elements) + ) + end + + def perma_id_of(entry, file) + entry.draft.find_file(file.class, file.id).perma_id + end + end +end From b7b3cdc91db9cd0edff997a83b335e39204a15fb Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 11:49:40 +0200 Subject: [PATCH 03/10] Mark referenced files in published seed Render an isReferenced flag for files in the seed of published entries when file rights are derived from references. Only take sections and content elements into account that the seed contains, so hidden and cut off sections do not contribute. --- app/helpers/pageflow/files_helper.rb | 10 +++- .../entry_json_seed_helper.rb | 24 ++++++-- .../entry_json_seed/_entry.json.jbuilder | 2 +- .../entry_json_seed_helper_spec.rb | 59 +++++++++++++++++++ spec/helpers/pageflow/files_helper_spec.rb | 31 +++++++++- 5 files changed, 118 insertions(+), 8 deletions(-) diff --git a/app/helpers/pageflow/files_helper.rb b/app/helpers/pageflow/files_helper.rb index 76e111e27a..c7fe41cf3d 100644 --- a/app/helpers/pageflow/files_helper.rb +++ b/app/helpers/pageflow/files_helper.rb @@ -33,12 +33,20 @@ def file_duration(file) # # @param [JBuilder] json # @param [PublishedEntry] entry + # @param [#include?] referenced_files + # Render `is_referenced` flags for files by calling + # `include?(collection_name, perma_id)`. # @since 15.1 - def files_json_seed(json, entry) + def files_json_seed(json, entry, referenced_files: nil) Pageflow.config.file_types.each do |file_type| json.set!(file_type.collection_name) do json.array!(entry.find_files(file_type.model)) do |file| json.partial!('pageflow/files/file', file:, file_type:) + + if referenced_files + json.is_referenced(referenced_files.include?(file_type.collection_name, + file.perma_id)) + end end end end 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 65cb48e100..7fc89b16f5 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 @@ -31,18 +31,34 @@ def scrolled_entry_json_seed(json, scrolled_entry, options = {}) cutoff: scrolled_entry.cutoff_mode_enabled_for?(request)) widgets = scrolled_entry.resolve_widgets(insert_point: :react) entry_config = Pageflow.config_for(scrolled_entry) + file_reference_locations = EntryFileReferenceLocations.new( + entry_config, + content.content_elements, + include_unused: options[:include_unused_file_reference_locations] + ) json.partial!('pageflow_scrolled/entry_json_seed/entry', entry: scrolled_entry, entry_config:, content:, - file_reference_locations: EntryFileReferenceLocations.new( - entry_config, - content.content_elements, - include_unused: options[:include_unused_file_reference_locations] + file_reference_locations:, + referenced_files: scrolled_entry_json_seed_referenced_files( + scrolled_entry, entry_config, content, file_reference_locations ), widgets:, options:) end + + private + + def scrolled_entry_json_seed_referenced_files(scrolled_entry, entry_config, + content, locations) + return unless entry_config.features.enabled?('file_rights_from_references') + + ReferencedFiles.new(scrolled_entry, + sections: content.sections, + content_elements: content.content_elements, + locations:) + end 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 308e7d59dc..56cdb07c4a 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 @@ -101,6 +101,6 @@ unless options[:skip_collections] end end - files_json_seed(json, entry) unless options[:skip_files] + files_json_seed(json, entry, referenced_files:) unless options[:skip_files] 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 5e1c91a6b8..0248623030 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 @@ -664,6 +664,65 @@ def json_config(result) end end + context 'referenced files' do + it 'marks files referenced by content elements' do + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'file_rights_from_references') + image_file = create_used_file(:image_file, entry:) + other_image_file = create_used_file(:image_file, entry:) + chapter = create(:scrolled_chapter, revision: entry.revision) + section = create(:section, chapter:) + create(:content_element, + section:, + type_name: 'inlineImage', + configuration: {'id' => image_file.perma_id}) + + result = render(helper, entry) + + expect(result).to include_json( + collections: { + imageFiles: [ + {permaId: image_file.perma_id, isReferenced: true}, + {permaId: other_image_file.perma_id, isReferenced: false} + ] + } + ) + end + + it 'does not mark files only referenced by hidden sections' do + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'file_rights_from_references') + image_file = create_used_file(:image_file, entry:) + chapter = create(:scrolled_chapter, revision: entry.revision) + section = create(:section, chapter:, configuration: {'hidden' => true}) + create(:content_element, + section:, + type_name: 'inlineImage', + configuration: {'id' => image_file.perma_id}) + + result = render(helper, entry) + + expect(result).to include_json( + collections: { + imageFiles: [{permaId: image_file.perma_id, isReferenced: false}] + } + ) + end + + it 'does not mark files when feature is disabled' do + entry = create(:published_entry, + type_name: 'scrolled', + without_feature: 'file_rights_from_references') + create_used_file(:image_file, entry:) + + result = JSON.parse(render(helper, entry)) + + expect(result['collections']['imageFiles'].first).not_to have_key('isReferenced') + end + end + context 'entry metadata' do it 'renders share image id' do image_file = create_used_file(:image_file) diff --git a/spec/helpers/pageflow/files_helper_spec.rb b/spec/helpers/pageflow/files_helper_spec.rb index e64920d1eb..d067aed5bb 100644 --- a/spec/helpers/pageflow/files_helper_spec.rb +++ b/spec/helpers/pageflow/files_helper_spec.rb @@ -45,10 +45,10 @@ module Pageflow end describe '#files_json_seed' do - def render(helper, entry) + def render(helper, entry, **options) helper.render_json do |json| yield json if block_given? - helper.files_json_seed(json, entry) + helper.files_json_seed(json, entry, **options) end end @@ -98,6 +98,33 @@ def render(helper, entry) ) end + it 'renders whether files are referenced if referenced files are passed' do + entry = create(:published_entry) + video_file = create(:video_file, used_in: entry.revision) + other_video_file = create(:video_file, used_in: entry.revision) + perma_id = entry.revision.find_file(Pageflow::VideoFile, video_file.id).perma_id + referenced_files = double(include?: false) + allow(referenced_files).to receive(:include?).with('video_files', perma_id).and_return(true) + + result = render(helper, entry, referenced_files:) + + expect(result).to include_json( + video_files: [ + {id: video_file.id, is_referenced: true}, + {id: other_video_file.id, is_referenced: false} + ] + ) + end + + it 'does not render whether files are referenced by default' do + entry = create(:published_entry) + create(:video_file, used_in: entry.revision) + + result = JSON.parse(render(helper, entry)) + + expect(result['video_files'].first).not_to have_key('is_referenced') + end + it 'renders basenames of files' do entry = create(:published_entry) create(:video_file, used_in: entry.revision, file_name: 'some-video.mp4') From a92e134a989130bb144b978eafa9a524100d6163 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 11:53:46 +0200 Subject: [PATCH 04/10] Keep file references up to date in the editor Maintain an index of file references that only rescans the section or content element whose configuration changed. Mark files with an is_referenced attribute and sync it to the preview's entry state, so the preview can tell referenced files apart without walking all configurations on every change. Serve configuration places from the same index. --- .../models/ScrolledEntry/isReferenced-spec.js | 238 ++++++++++++++++++ .../spec/entryState/collections-spec.js | 66 ++--- .../scrolled/package/src/collections.js | 7 - .../models/ScrolledEntry/fileReferences.js | 160 ++++++++++-- .../src/editor/models/ScrolledEntry/index.js | 5 +- .../package/src/entryState/collections.js | 18 +- .../scrolled/package/src/entryState/index.js | 2 +- 7 files changed, 422 insertions(+), 74 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js new file mode 100644 index 0000000000..f4039e1cd4 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js @@ -0,0 +1,238 @@ +import {useEditorGlobals} from 'support'; + +describe('ScrolledEntry', () => { + describe('is_referenced attribute of files', () => { + const {createEntry} = useEditorGlobals(); + + const inlineImageLocations = { + contentElements: {inlineImage: [{path: ['id'], collection: 'imageFiles'}]} + }; + + function create({contentElements, ...options}) { + return createEntry({ + chapters: [{id: 1, permaId: 100}], + sections: [{id: 1, permaId: 10, chapterId: 1}], + contentElements: contentElements.map((contentElement, index) => ({ + id: index + 1, permaId: index + 20, sectionId: 1, ...contentElement + })), + ...options + }); + } + + function file(entry, collectionName, permaId) { + return entry.getFileCollection(collectionName).findWhere({perma_id: permaId}); + } + + it('is set for referenced and unreferenced files', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}, {id: 2, perma_id: 6}], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + expect(file(entry, 'image_files', 6).get('is_referenced')).toBe(false); + }); + + it('is set for files linked from content elements', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [{ + typeName: 'textBlock', + configuration: {value: [{type: 'paragraph', children: [ + {type: 'link', href: {file: {permaId: 5, collectionName: 'imageFiles'}}, + children: [{text: 'Download'}]} + ]}]} + }] + }); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + }); + + it('is false for files of inactive references', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [{typeName: 'inlineVideo', configuration: {portraitPosterId: 5}}], + fileReferenceLocations: { + contentElements: { + inlineVideo: [{path: ['portraitPosterId'], collection: 'imageFiles', + activeIf: {path: ['portraitId'], present: true}}] + } + } + }); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(false); + }); + + it('is set for nested files of referenced files', () => { + const entry = create({ + videoFiles: [{id: 1, perma_id: 5}, {id: 2, perma_id: 6}], + textTrackFiles: [ + {id: 10, perma_id: 50, parent_file_id: 1, parent_file_model_type: 'Pageflow::VideoFile'}, + {id: 11, perma_id: 51, parent_file_id: 2, parent_file_model_type: 'Pageflow::VideoFile'} + ], + contentElements: [{typeName: 'inlineVideo', configuration: {id: 5}}], + fileReferenceLocations: { + contentElements: {inlineVideo: [{path: ['id'], collection: 'videoFiles'}]} + }, + fileModelTypes: { + videoFiles: 'Pageflow::VideoFile', + textTrackFiles: 'Pageflow::TextTrackFile' + } + }); + + expect(file(entry, 'text_track_files', 50).get('is_referenced')).toBe(true); + expect(file(entry, 'text_track_files', 51).get('is_referenced')).toBe(false); + }); + + it('is updated when parent of nested file is assigned', async () => { + const entry = create({ + videoFiles: [{id: 1, perma_id: 5}], + textTrackFiles: [{id: 10, perma_id: 50}], + contentElements: [{typeName: 'inlineVideo', configuration: {id: 5}}], + fileReferenceLocations: { + contentElements: {inlineVideo: [{path: ['id'], collection: 'videoFiles'}]} + } + }); + const textTrackFile = file(entry, 'text_track_files', 50); + + textTrackFile.set('parent_file_id', 1); + await Promise.resolve(); + textTrackFile.set('parent_file_model_type', 'Pageflow::VideoFile'); + await Promise.resolve(); + + expect(textTrackFile.get('is_referenced')).toBe(true); + }); + + it('is updated when configuration of content element changes', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}, {id: 2, perma_id: 6}], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + + entry.contentElements.first().configuration.set('id', 6); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(false); + expect(file(entry, 'image_files', 6).get('is_referenced')).toBe(true); + }); + + it('is updated when content element is removed', async () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + + entry.contentElements.remove(entry.contentElements.first()); + await Promise.resolve(); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(false); + }); + + it('is set for files referenced by added content elements', async () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [], + fileReferenceLocations: inlineImageLocations + }); + + entry.contentElements.add({id: 1, permaId: 20, sectionId: 1, typeName: 'inlineImage', + configuration: {id: 5}}); + await Promise.resolve(); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + }); + + it('is updated once when several content elements are added', async () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [], + fileReferenceLocations: inlineImageLocations + }); + const imageFile = file(entry, 'image_files', 5); + jest.spyOn(imageFile, 'set'); + + entry.contentElements.add([ + {id: 1, permaId: 20, sectionId: 1, typeName: 'inlineImage', configuration: {id: 5}}, + {id: 2, permaId: 21, sectionId: 1, typeName: 'inlineImage', configuration: {id: 5}} + ]); + await Promise.resolve(); + + expect(imageFile.set.mock.calls.filter(([name]) => name === 'is_referenced').length) + .toBe(1); + }); + + it('is updated when section configuration changes', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [], + fileReferenceLocations: { + sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}] + } + }); + + entry.sections.first().configuration.set('backdrop', {image: 5}); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + }); + + it('is updated when share image of entry changes', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [], + fileReferenceLocations: { + entry: [{path: ['shareImageId'], collection: 'imageFiles'}] + } + }); + + entry.metadata.set('share_image_id', 5); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + }); + + it('is set for files added to the entry', async () => { + const entry = create({ + imageFiles: [], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + + entry.getFileCollection('image_files').add({id: 1, perma_id: 5}); + await Promise.resolve(); + + expect(file(entry, 'image_files', 5).get('is_referenced')).toBe(true); + }); + + it('is updated once when several files are added', async () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + const imageFile = file(entry, 'image_files', 5); + jest.spyOn(imageFile, 'set'); + + entry.getFileCollection('image_files').add([{id: 2, perma_id: 6}, {id: 3, perma_id: 7}]); + entry.getFileCollection('image_files').add({id: 4, perma_id: 8}); + await Promise.resolve(); + + expect(imageFile.set.mock.calls.filter(([name]) => name === 'is_referenced').length) + .toBe(1); + }); + + it('does not save files when it changes', () => { + const entry = create({ + imageFiles: [{id: 1, perma_id: 5}], + contentElements: [{typeName: 'inlineImage', configuration: {id: 5}}], + fileReferenceLocations: inlineImageLocations + }); + const imageFile = file(entry, 'image_files', 5); + jest.spyOn(imageFile, 'save'); + + entry.contentElements.first().configuration.set('id', 6); + + expect(imageFile.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/collections-spec.js b/entry_types/scrolled/package/spec/entryState/collections-spec.js index 8a6efa2abd..e0c12d4602 100644 --- a/entry_types/scrolled/package/spec/entryState/collections-spec.js +++ b/entry_types/scrolled/package/spec/entryState/collections-spec.js @@ -1,38 +1,18 @@ -import {collectionsSnapshot, watchCollections} from 'entryState'; +import {watchCollections} from 'entryState'; import {useEditorGlobals} from 'support'; -describe('collectionsSnapshot', () => { +describe('watchCollections', () => { const {createEntry} = useEditorGlobals(); - function entryWithFiles() { - return createEntry({ - videoFiles: [{id: 100, perma_id: 5}], - textTrackFiles: [{id: 200, perma_id: 6, - parent_file_id: 100, - parent_file_model_type: 'Pageflow::VideoFile'}], - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5}}}] + it('includes referenced flags of files', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 5}, {id: 101, perma_id: 6}], + sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5}}}], + fileReferenceLocations: { + sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}] + } }); - } - - it('maps file attributes to entry state items', () => { - const collections = collectionsSnapshot(entryWithFiles()); - - expect(collections.textTrackFiles).toMatchObject([ - {id: 200, permaId: 6, parentFileId: 100, parentFileModelType: 'Pageflow::VideoFile'} - ]); - }); - - it('includes configurations of sections', () => { - const collections = collectionsSnapshot(entryWithFiles()); - - expect(collections.sections).toMatchObject([ - {id: 1, permaId: 10, configuration: {backdrop: {image: 5}}} - ]); - }); - - it('matches the items watchCollections dispatches', () => { - const entry = entryWithFiles(); const items = {}; const teardown = watchCollections(entry, { @@ -43,7 +23,33 @@ describe('collectionsSnapshot', () => { } }); - expect(collectionsSnapshot(entry)).toEqual(items); + expect(items.imageFiles).toMatchObject([ + {permaId: 5, isReferenced: true}, + {permaId: 6, isReferenced: false} + ]); + + teardown(); + }); + + it('dispatches change when referenced flag of file changes', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 5}], + sections: [{id: 1, permaId: 10}], + fileReferenceLocations: { + sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}] + } + }); + const actions = []; + + const teardown = watchCollections(entry, {dispatch: action => actions.push(action)}); + entry.sections.first().configuration.set('backdrop', {image: 5}); + + expect(actions).toContainEqual(expect.objectContaining({ + payload: expect.objectContaining({ + collectionName: 'imageFiles', + attributes: expect.objectContaining({permaId: 5, isReferenced: true}) + }) + })); teardown(); }); diff --git a/entry_types/scrolled/package/src/collections.js b/entry_types/scrolled/package/src/collections.js index 4c10bc8e7d..3982d872b4 100644 --- a/entry_types/scrolled/package/src/collections.js +++ b/entry_types/scrolled/package/src/collections.js @@ -129,13 +129,6 @@ export function updateConfiguration({dispatch, name, key, configuration}) { }) } -export function collectionSnapshot(collection, {attributes, includeConfiguration}) { - return collection.map(model => getAttributes(model, { - attributeNames: attributes, - includeConfiguration - })); -} - export function watchCollection(collection, {name, dispatch, attributes, includeConfiguration, keyAttribute = 'id'}) { const handle = {}; diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js index 1f1641a27c..afc4ab2036 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js @@ -1,36 +1,158 @@ -import {collectionsSnapshot} from 'pageflow-scrolled/entryState'; +import {collectFileReferences} from '../../../shared/collectFileReferences'; -import {collectEntryFileReferences} from '../../../shared/collectEntryFileReferences'; - -export function fileReferences(entry) { +export function watchFileReferences(entry) { const {config} = entry.scrolledSeed; + const locations = config.fileReferenceLocations || {}; + const referencesBySubject = new Map(); + let referencesByFile = {}; + let updateScheduled = false; + + subjectSources(entry, locations).forEach(source => { + source.models().forEach(model => scan(source, model)); + + entry.listenTo(source.emitter, source.changeEvents, model => { + scan(source, model); + update(); + }); + + if (source.collection) { + entry.listenTo(source.collection, 'add', model => { + scan(source, model); + scheduleUpdate(); + }); - const references = collectEntryFileReferences({ - collections: collectionsSnapshot(entry), - locations: config.fileReferenceLocations, - fileModelTypes: config.fileModelTypes + entry.listenTo(source.collection, 'remove', model => { + referencesBySubject.delete(model); + scheduleUpdate(); + }); + } }); + Object.values(entry.files).forEach(collection => + entry.listenTo(collection, + 'add remove change:perma_id change:parent_file_id change:parent_file_model_type', + scheduleUpdate) + ); + + update(); + return { placesFor(file) { - return references.of(camelize(file.fileType().collectionName), file.get('perma_id')) - .filter(({active}) => active) - .map(({subject, path}) => - subjectModel(entry, subject).getConfigurationPlace(path)); + return (referencesByFile[fileKey(camelize(file.fileType().collectionName), + file.get('perma_id'))] || []) + .filter(({active}) => active) + .map(({placeModel, path}) => placeModel.getConfigurationPlace(path)); } }; -} -function subjectModel(entry, {model, permaId}) { - if (model === 'entry') { - return entry; + function scan(source, model) { + referencesBySubject.set(model, collectFileReferences({ + locations: source.locations(model), + configuration: source.configuration(model) + }).map(reference => ({...reference, placeModel: source.placeModel(model)}))); } - if (model === 'section') { - return entry.sections.findWhere({permaId}); + function scheduleUpdate() { + if (updateScheduled) { + return; + } + + updateScheduled = true; + + Promise.resolve().then(() => { + updateScheduled = false; + update(); + }); + } + + function update() { + referencesByFile = {}; + + referencesBySubject.forEach(references => + references.forEach(reference => add(reference)) + ); + + addNestedFileReferences(); + markReferencedFiles(); } - return entry.contentElements.findWhere({permaId}); + function add({collectionName, permaId, ...reference}) { + const key = fileKey(collectionName, permaId); + (referencesByFile[key] = referencesByFile[key] || []).push(reference); + } + + function addNestedFileReferences() { + const modelTypes = config.fileModelTypes || {}; + const parentKeysByModelTypeAndId = {}; + + eachFile((file, collectionName) => { + parentKeysByModelTypeAndId[`${modelTypes[collectionName]}/${file.id}`] = + fileKey(collectionName, file.get('perma_id')); + }); + + eachFile((file, collectionName) => { + if (!file.get('parent_file_id')) { + return; + } + + const parentKey = parentKeysByModelTypeAndId[ + `${file.get('parent_file_model_type')}/${file.get('parent_file_id')}` + ]; + + (referencesByFile[parentKey] || []).forEach(reference => + add({collectionName, permaId: file.get('perma_id'), ...reference}) + ); + }); + } + + function markReferencedFiles() { + eachFile((file, collectionName) => { + const references = referencesByFile[fileKey(collectionName, file.get('perma_id'))] || []; + file.set('is_referenced', references.some(({active}) => active)); + }); + } + + function eachFile(fn) { + Object.entries(entry.files).forEach(([collectionName, collection]) => + collection.each(file => fn(file, camelize(collectionName))) + ); + } +} + +function subjectSources(entry, locations) { + return [ + { + emitter: entry.metadata, + models: () => [entry.metadata], + changeEvents: 'change:share_image_id', + locations: () => locations.entry || [], + configuration: metadata => ({shareImageId: metadata.get('share_image_id')}), + placeModel: () => entry + }, + { + emitter: entry.sections, + collection: entry.sections, + models: () => entry.sections.models, + changeEvents: 'change:configuration', + locations: () => locations.sections || [], + configuration: section => section.configuration.attributes, + placeModel: section => section + }, + { + emitter: entry.contentElements, + collection: entry.contentElements, + models: () => entry.contentElements.models, + changeEvents: 'change:configuration', + locations: contentElement => + (locations.contentElements || {})[contentElement.get('typeName')] || [], + configuration: contentElement => contentElement.configuration.attributes, + placeModel: contentElement => contentElement + } + ]; +} + +function fileKey(collectionName, permaId) { + return `${collectionName}/${permaId}`; } // File collections are named in snake case in the editor and in camel 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 505fd95ba5..056248339b 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -25,7 +25,7 @@ import {insertContentElement} from './insertContentElement'; import {moveContentElement} from './moveContentElement'; import {deleteContentElement} from './deleteContentElement'; import {duplicateContentElement} from './duplicateContentElement'; -import {fileReferences} from './fileReferences'; +import {watchFileReferences} from './fileReferences'; import {updateContentElement} from './updateContentElement'; import {sortColors} from './sortColors'; @@ -97,6 +97,7 @@ export const ScrolledEntry = Entry.extend({ editor.savingRecords.watch(this.chapters); this.scrolledSeed = seed; + this.fileReferencesIndex = watchFileReferences(this); this.commentDisplayFilter = new CommentDisplayFilter(); @@ -179,7 +180,7 @@ export const ScrolledEntry = Entry.extend({ }, fileReferences() { - return fileReferences(this); + return this.fileReferencesIndex; }, getConfigurationPlace() { diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index 242cdc7dab..60ee2caa24 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -1,17 +1,4 @@ -import {collectionSnapshot, watchCollection} from '../collections'; - -/** - * Entry state collections of an entry, taken from its Backbone models - * once. Use `watchCollections` instead to keep collections in sync. - * - * @private - */ -export function collectionsSnapshot(entry) { - return collectionSpecs(entry).reduce((result, {collection, name, ...spec}) => { - result[name] = collectionSnapshot(collection, spec); - return result; - }, {}); -} +import {watchCollection} from '../collections'; export function watchCollections(entry, {dispatch}) { const teardownFns = collectionSpecs(entry).map( @@ -103,7 +90,8 @@ function collectionSpecs(entry) { {variants: variants => variants && variants.map(variant => camelize(variant))}, {durationInMs: 'duration_in_ms'}, {parentFileId: 'parent_file_id'}, - {parentFileModelType: 'parent_file_model_type'}], + {parentFileModelType: 'parent_file_model_type'}, + {isReferenced: 'is_referenced'}], keyAttribute: 'permaId', includeConfiguration: true })) diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index 5a2b7dc67a..e5930b3e22 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -36,7 +36,7 @@ export {useWidget, useActiveWidgets} from './widgets'; export {EntryStateProvider, useEntryStateDispatch} from './EntryStateProvider'; -export {collectionsSnapshot, watchCollections} from './collections'; +export {watchCollections} from './collections'; export {updateContentElementConfiguration} from './updateContentElementConfiguration'; export {updateWidgetConfiguration} from './updateWidgetConfiguration'; From e84d0828c92c254cbe30a12aba7ba199065dfb5b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Mon, 19 Oct 2026 11:58:16 +0200 Subject: [PATCH 05/10] Read referenced flags of files for credits Let file rights rely on the isReferenced flag provided by the published seed and the editor instead of walking all configurations in the frontend. Only render file reference locations in the editor's seed, since published entries no longer need them. Look up locations of content element types on demand instead of limiting them to the types an entry uses. --- .../editor/entry_json_seed_helper.rb | 2 +- .../editor/seed_html_helper.rb | 1 - .../entry_json_seed_helper.rb | 6 +- .../entry_json_seed/_entry.json.jbuilder | 10 +- .../doc/internal/configuration_schemas.md | 42 ++-- .../entry_file_reference_locations.rb | 35 ++-- .../lib/pageflow_scrolled/referenced_files.rb | 5 +- .../lib/pageflow_scrolled/sections_copy.rb | 10 +- .../package/spec/entryState/legalInfo-spec.js | 110 +++-------- .../spec/entryState/useFileReferences-spec.js | 80 -------- .../shared/collectEntryFileReferences-spec.js | 179 ------------------ .../scrolled/package/src/entryState/index.js | 1 - .../package/src/entryState/legalInfo.js | 6 +- .../src/entryState/useFileReferences.js | 67 ------- .../src/shared/collectEntryFileReferences.js | 114 ----------- .../editor/seed_html_helper_spec.rb | 22 +-- .../entry_json_seed_helper_spec.rb | 30 ++- .../entry_file_reference_locations_spec.rb | 38 ++++ .../referenced_files_spec.rb | 2 +- 19 files changed, 133 insertions(+), 627 deletions(-) delete mode 100644 entry_types/scrolled/package/spec/entryState/useFileReferences-spec.js delete mode 100644 entry_types/scrolled/package/spec/shared/collectEntryFileReferences-spec.js delete mode 100644 entry_types/scrolled/package/src/entryState/useFileReferences.js delete mode 100644 entry_types/scrolled/package/src/shared/collectEntryFileReferences.js create mode 100644 entry_types/scrolled/spec/pageflow_scrolled/entry_file_reference_locations_spec.rb diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb index c47b6506e3..2ee588f51d 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/entry_json_seed_helper.rb @@ -19,7 +19,7 @@ def scrolled_entry_editor_json_seed(json, scrolled_entry) skip_i18n: true, include_hidden_sections: true, include_unused_additional_seed_data: true, - include_unused_file_reference_locations: true, + include_file_reference_locations: true, include_theme_translations: true) end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/seed_html_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/seed_html_helper.rb index b344e4f803..a1cb865442 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/seed_html_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/editor/seed_html_helper.rb @@ -44,7 +44,6 @@ def seed_html(entry, **seed_options) seed_options: { skip_collections: true, include_unused_additional_seed_data: true, - include_unused_file_reference_locations: true, include_theme_translations: true, **seed_options } 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 7fc89b16f5..568fe5a1ec 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 @@ -31,11 +31,7 @@ def scrolled_entry_json_seed(json, scrolled_entry, options = {}) cutoff: scrolled_entry.cutoff_mode_enabled_for?(request)) widgets = scrolled_entry.resolve_widgets(insert_point: :react) entry_config = Pageflow.config_for(scrolled_entry) - file_reference_locations = EntryFileReferenceLocations.new( - entry_config, - content.content_elements, - include_unused: options[:include_unused_file_reference_locations] - ) + file_reference_locations = EntryFileReferenceLocations.new(entry_config) json.partial!('pageflow_scrolled/entry_json_seed/entry', entry: scrolled_entry, 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 56cdb07c4a..2a05c14aa2 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 @@ -8,10 +8,12 @@ json.config do config_file_model_types_seed(json, entry_config) end - json.file_reference_locations do - json.content_elements file_reference_locations.content_element_locations - json.sections file_reference_locations.section_locations - json.entry file_reference_locations.entry_locations + if options[:include_file_reference_locations] + json.file_reference_locations do + json.content_elements file_reference_locations.content_element_locations + json.sections file_reference_locations.section_locations + json.entry file_reference_locations.entry_locations + end end json.pretty_url pretty_entry_url(entry) diff --git a/entry_types/scrolled/doc/internal/configuration_schemas.md b/entry_types/scrolled/doc/internal/configuration_schemas.md index 5b1c4f72e2..0d596c0fb3 100644 --- a/entry_types/scrolled/doc/internal/configuration_schemas.md +++ b/entry_types/scrolled/doc/internal/configuration_schemas.md @@ -18,16 +18,18 @@ schema file and follow the terms below through both languages. `{model: 'section'}` and `{model: 'entry'}`. Declared as `x-subject`. - **File reference location** — a place in a configuration where a file *can* be referenced. Declared by `x-fileCollection`, flattened by - `FileReferenceLocations` and shipped in the entry seed as + `FileReferenceLocations` and shipped in the editor's entry seed as `fileReferenceLocations`. - **File reference** — a location resolved against actual configuration data, as `{subject, path, active}`. Produced by - `collectFileReferences`, indexed by file by - `collectEntryFileReferences`. Links to files (`href: {file: {permaId, + `collectFileReferences` and `FileReferences`, indexed by file by + `watchFileReferences` in the editor and by `ReferencedFiles` on the + server. Links to files (`href: {file: {permaId, collectionName}}`) are found anywhere in a configuration, in rich text and link properties alike, and need no location. -- **Referenced file** — a file some configuration points at. Not the - same as a *used* file, see below. +- **Referenced file** — a file some active reference points at. Not the + same as a *used* file, see below. Flagged as `isReferenced` in entry + state. - **Configuration place** — the editor's presentation of a reference, as `{label, detail, pictogram, select}`. Built by `getConfigurationPlace(path)` on the referencing model. @@ -76,21 +78,21 @@ open, yet credits must list those files whether or not a visitor opened them. Render time collection under-reports exactly the files that are easiest to forget. -### Computing the answer on the server +### Computing the editor's answer on the server A server computed list of referenced files is a cache whose invalidation trigger is "any configuration change" - and the editor is -where configuration changes. So the seed carries locations and the -algorithm ships in the bundle. Server side rendering is unaffected, -since the same JavaScript runs in Node with the seed in hand. - -Bundling the schemas into the JavaScript packages instead would undo -the seed's pruning: the seed already carries only the content element -types an entry uses, so an entry using five of thirty types pays for -five, while a bundle import pays for all of them in every entry and -grows with every plugin pack. The editor is the exception and receives -all types, since content elements can be added there without reloading -the seed. +where configuration changes. So the editor seed carries locations and +the editor computes references itself, rescanning only the subject +whose configuration changed, and passes `is_referenced` to the preview +with the other file attributes. Published revisions never change, so +their seed carries `isReferenced` flags computed by `ReferencedFiles` +instead of locations. Both sides walk configurations with the same +algorithm, kept aligned by `spec/fixtures/file_references.json`. + +Bundling the schemas into the JavaScript packages instead would make +every published entry pay for them, although only the editor reads +them, and would grow with every plugin pack. ### Structured references in the data @@ -128,9 +130,3 @@ which requires the schema. another file (text tracks belonging to their parent video) and file configurations (video poster images). Each produces a false "unreferenced" verdict on its own. - -* **The attribute mapping wants to exist twice.** - `collectEntryFileReferences` takes entry state collections rather - than Backbone models so that the snake cased to camel cased mapping - is shared with the preview. When it existed twice it drifted - the - share image reached the published entry before it reached the editor. diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb index fa5bca0e22..4f70f8bfaa 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb @@ -4,23 +4,24 @@ module PageflowScrolled # # @api private class EntryFileReferenceLocations - def initialize(entry_config, content_elements, include_unused: false) + def initialize(entry_config) @entry_config = entry_config - @content_elements = content_elements - @include_unused = include_unused + @content_element_locations = {} end def content_element_locations - @content_element_locations ||= - content_element_type_names.each_with_object({}) do |type_name, result| - schema = schemas.find(model: 'contentElement', type_name:) + schemas.type_names(model: 'contentElement').index_with do |type_name| + content_element_locations_for(type_name) + end + end + + def content_element_locations_for(type_name) + return @content_element_locations[type_name] if @content_element_locations.key?(type_name) + + schema = schemas.find(model: 'contentElement', type_name:) + log_missing_schema(type_name) unless schema - if schema - result[type_name] = locations.for(schema) - else - log_missing_schema(type_name) - end - end + @content_element_locations[type_name] = schema ? locations.for(schema) : [] end def section_locations @@ -45,16 +46,6 @@ def locations_for(model:) locations.for(schema) end - # Content elements of any type can be added in the editor without - # reloading the seed. - def content_element_type_names - if @include_unused - schemas.type_names(model: 'contentElement') - else - @content_elements.map(&:type_name).uniq - end - end - def log_missing_schema(type_name) return unless @entry_config.features.enabled?('file_rights_from_references') diff --git a/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb index d1f321d330..df2900e59a 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb @@ -34,12 +34,11 @@ def referenced_keys end def subjects - content_element_locations = @locations.content_element_locations - [[{'shareImageId' => @entry.share_image_id}, @locations.entry_locations]] + @sections.map { |section| [section.configuration, @locations.section_locations] } + @content_elements.map do |content_element| - [content_element.configuration, content_element_locations[content_element.type_name]] + [content_element.configuration, + @locations.content_element_locations_for(content_element.type_name)] end end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/sections_copy.rb b/entry_types/scrolled/lib/pageflow_scrolled/sections_copy.rb index 8e001276f7..e0dddd8a12 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/sections_copy.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/sections_copy.rb @@ -32,7 +32,7 @@ def copy_section(section, chapter:, position:) copy.content_elements.each do |content_element| rewrite_file_references(content_element, - content_element_locations[content_element.type_name]) + locations.content_element_locations_for(content_element.type_name)) end copy @@ -107,14 +107,8 @@ def file_types @file_types ||= Pageflow.config_for(@source_entry).file_types end - def content_element_locations - @content_element_locations ||= locations.content_element_locations - end - def locations - @locations ||= EntryFileReferenceLocations.new( - Pageflow.config_for(@source_entry), [], include_unused: true - ) + @locations ||= EntryFileReferenceLocations.new(Pageflow.config_for(@source_entry)) end def next_position(chapter) diff --git a/entry_types/scrolled/package/spec/entryState/legalInfo-spec.js b/entry_types/scrolled/package/spec/entryState/legalInfo-spec.js index 88e433f582..606809d2e4 100644 --- a/entry_types/scrolled/package/spec/entryState/legalInfo-spec.js +++ b/entry_types/scrolled/package/spec/entryState/legalInfo-spec.js @@ -87,20 +87,14 @@ describe('useFileRights', () => { describe('with file_rights_from_references feature', () => { useFakeFeatures('frontend', ['file_rights_from_references']); - it('only includes referenced files', () => { + it('only includes files flagged as referenced', () => { const {result} = renderHookInEntry( () => useFileRights(), { seed: { imageFiles: [ - {permaId: 5, rights: 'referenced'}, - {permaId: 6, rights: 'unreferenced'} - ], - contentElements: [{typeName: 'someType', configuration: {image: 5}}], - fileReferenceLocations: { - contentElements: { - someType: [{path: ['image'], collection: 'imageFiles'}] - } - } + {permaId: 5, rights: 'referenced', isReferenced: true}, + {permaId: 6, rights: 'unreferenced', isReferenced: false} + ] } } ); @@ -108,72 +102,34 @@ describe('useFileRights', () => { expect(result.current).toMatchObject([{text: 'referenced'}]); }); - it('includes files nested in a referenced file', () => { - const {result} = renderHookInEntry( - () => useFileRights(), { - seed: { - videoFiles: [{id: 100, permaId: 5, rights: 'referenced'}], - textTrackFiles: [ - {id: 200, permaId: 6, parentFileId: 100, - parentFileModelType: 'Pageflow::VideoFile', rights: 'nested'} - ], - contentElements: [{typeName: 'someType', configuration: {video: 5}}], - fileReferenceLocations: { - contentElements: { - someType: [{path: ['video'], collection: 'videoFiles'}] - } - } - } - } - ); - - expect(result.current).toMatchObject([{text: 'nested'}, {text: 'referenced'}]); - }); - - it('skips files nested in an unreferenced file', () => { - const {result} = renderHookInEntry( - () => useFileRights(), { - seed: { - videoFiles: [{id: 100, permaId: 5, rights: 'unreferenced'}], - textTrackFiles: [ - {id: 200, permaId: 6, parentFileId: 100, - parentFileModelType: 'Pageflow::VideoFile', rights: 'nested'} - ], - contentElements: [{typeName: 'someType', configuration: {}}], - fileReferenceLocations: { - contentElements: { - someType: [{path: ['video'], collection: 'videoFiles'}] - } - } - } - } - ); - - expect(result.current).toEqual([]); - }); - - it('skips inactive references', () => { + it('only includes files referenced in watched collections', () => { const {result} = renderHookInEntry( () => useFileRights(), { - seed: { - imageFiles: [{permaId: 5, rights: 'author'}], - contentElements: [ - {typeName: 'someType', configuration: {image: 5, backgroundType: 'color'}} - ], - fileReferenceLocations: { - contentElements: { - someType: [{ - path: ['image'], - collection: 'imageFiles', - activeIf: {path: ['backgroundType'], not: 'color'} - }] - } - } - } + setup: dispatch => + watchCollections( + factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{typeName: 'someType', configuration: {image: 5}}], + fileReferenceLocations: { + contentElements: { + someType: [{path: ['image'], collection: 'imageFiles'}] + } + } + }), + fileTypes: factories.fileTypesWithImageFileType(), + filesAttributes: { + image_files: [ + {id: 1, perma_id: 5, rights: 'referenced'}, + {id: 2, perma_id: 6, rights: 'unreferenced'} + ] + } + }), + {dispatch} + ) } ); - expect(result.current).toEqual([]); + expect(result.current).toMatchObject([{text: 'referenced'}]); }); }); @@ -182,15 +138,9 @@ describe('useFileRights', () => { () => useFileRights(), { seed: { imageFiles: [ - {permaId: 5, rights: 'referenced'}, - {permaId: 6, rights: 'unreferenced'} - ], - contentElements: [{typeName: 'someType', configuration: {image: 5}}], - fileReferenceLocations: { - contentElements: { - someType: [{path: ['image'], collection: 'imageFiles'}] - } - } + {permaId: 5, rights: 'referenced', isReferenced: true}, + {permaId: 6, rights: 'unreferenced', isReferenced: false} + ] } } ); diff --git a/entry_types/scrolled/package/spec/entryState/useFileReferences-spec.js b/entry_types/scrolled/package/spec/entryState/useFileReferences-spec.js deleted file mode 100644 index a70329771d..0000000000 --- a/entry_types/scrolled/package/spec/entryState/useFileReferences-spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import {useFileReferences} from 'entryState'; - -import {renderHookInEntry} from 'support'; - -describe('useFileReferences', () => { - it('indexes files referenced by content elements', () => { - const {result} = renderHookInEntry(() => useFileReferences(), { - seed: { - imageFiles: [{permaId: 5}], - contentElements: [{permaId: 20, typeName: 'someType', configuration: {image: 5}}], - fileReferenceLocations: { - contentElements: { - someType: [{path: ['image'], collection: 'imageFiles'}] - } - } - } - }); - - expect(result.current.of('imageFiles', 5)) - .toEqual([{subject: {model: 'contentElement', permaId: 20}, - path: ['image'], active: true}]); - }); - - it('indexes files referenced by sections', () => { - const {result} = renderHookInEntry(() => useFileReferences(), { - seed: { - sections: [{permaId: 10, configuration: {backdrop: {image: 5}}}], - fileReferenceLocations: { - sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}] - } - } - }); - - expect(result.current.of('imageFiles', 5)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'image'], active: true}]); - }); - - it('indexes files referenced by entry metadata', () => { - const {result} = renderHookInEntry(() => useFileReferences(), { - seed: { - entry: {shareImageId: 5}, - fileReferenceLocations: { - entry: [{path: ['shareImageId'], collection: 'imageFiles'}] - } - } - }); - - expect(result.current.of('imageFiles', 5)) - .toEqual([{subject: {model: 'entry'}, path: ['shareImageId'], active: true}]); - }); - - it('indexes files nested in a referenced file', () => { - const {result} = renderHookInEntry(() => useFileReferences(), { - seed: { - videoFiles: [{id: 100, permaId: 5}], - textTrackFiles: [{id: 200, permaId: 6, parentFileId: 100, - parentFileModelType: 'Pageflow::VideoFile'}], - sections: [{permaId: 10, configuration: {backdrop: {video: 5}}}], - fileReferenceLocations: { - sections: [{path: ['backdrop', 'video'], collection: 'videoFiles'}] - } - } - }); - - expect(result.current.of('textTrackFiles', 6)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'video'], active: true}]); - }); - - it('is empty without locations in seed', () => { - const {result} = renderHookInEntry(() => useFileReferences(), { - seed: { - contentElements: [{permaId: 20, typeName: 'someType', configuration: {image: 5}}] - } - }); - - expect(result.current.of('imageFiles', 5)).toEqual([]); - }); -}); diff --git a/entry_types/scrolled/package/spec/shared/collectEntryFileReferences-spec.js b/entry_types/scrolled/package/spec/shared/collectEntryFileReferences-spec.js deleted file mode 100644 index c432311739..0000000000 --- a/entry_types/scrolled/package/spec/shared/collectEntryFileReferences-spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import {collectEntryFileReferences} from 'shared/collectEntryFileReferences'; - -describe('collectEntryFileReferences', () => { - function collect({sections = [], contentElements = [], files = {}, locations = {}}) { - return collectEntryFileReferences({ - collections: {sections, contentElements, ...files}, - locations, - fileModelTypes: { - imageFiles: 'Pageflow::ImageFile', - videoFiles: 'Pageflow::VideoFile', - textTrackFiles: 'Pageflow::TextTrackFile' - } - }); - } - - it('finds file referenced by section', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5}}}], - locations: {sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}]} - }); - - expect(references.of('imageFiles', 5)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'image'], active: true}]); - }); - - it('finds file referenced by content element', () => { - const references = collect({ - sections: [{id: 1, permaId: 10}], - contentElements: [{id: 2, permaId: 20, sectionId: 1, typeName: 'inlineImage', - configuration: {id: 5}}], - locations: {contentElements: {inlineImage: [{path: ['id'], collection: 'imageFiles'}]}} - }); - - expect(references.of('imageFiles', 5)) - .toEqual([{subject: {model: 'contentElement', permaId: 20}, - path: ['id'], active: true}]); - }); - - it('is empty for file that is not referenced', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {}}], - locations: {sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}]} - }); - - expect(references.of('imageFiles', 5)).toEqual([]); - }); - - it('ignores content element types without locations', () => { - const references = collect({ - sections: [{id: 1, permaId: 10}], - contentElements: [{id: 2, permaId: 20, sectionId: 1, typeName: 'unknownType', - configuration: {id: 5}}], - locations: {contentElements: {inlineImage: [{path: ['id'], collection: 'imageFiles'}]}} - }); - - expect(references.of('imageFiles', 5)).toEqual([]); - }); - - it('lists places in the order they appear in the entry', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5}}}, - {id: 2, permaId: 11, configuration: {backdrop: {image: 5}}}], - contentElements: [{id: 3, permaId: 30, sectionId: 1, typeName: 'inlineImage', - configuration: {id: 5}}], - locations: { - sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}], - contentElements: {inlineImage: [{path: ['id'], collection: 'imageFiles'}]} - } - }); - - expect(references.of('imageFiles', 5).map(({subject}) => subject)) - .toEqual([{model: 'section', permaId: 10}, - {model: 'contentElement', permaId: 30}, - {model: 'section', permaId: 11}]); - }); - - it('includes resolved path of location', () => { - const references = collect({ - sections: [{id: 1, permaId: 10}], - contentElements: [{id: 2, permaId: 20, sectionId: 1, typeName: 'hotspots', - configuration: {areas: [{}, {tooltipImage: 5}]}}], - locations: { - contentElements: { - hotspots: [{path: ['areas', '*', 'tooltipImage'], collection: 'imageFiles'}] - } - } - }); - - expect(references.of('imageFiles', 5).map(({path}) => path)) - .toEqual([['areas', '1', 'tooltipImage']]); - }); - - it('lists one reference per location', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5, imageMobile: 5}}}], - locations: { - sections: [{path: ['backdrop', 'image'], collection: 'imageFiles'}, - {path: ['backdrop', 'imageMobile'], collection: 'imageFiles'}] - } - }); - - expect(references.of('imageFiles', 5).length).toEqual(2); - }); - - it('reports activeness per location', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5, imageMobile: 5}}}], - locations: { - sections: [ - {path: ['backdrop', 'image'], collection: 'imageFiles', - activeIf: {path: ['backdrop', 'color'], present: true}}, - {path: ['backdrop', 'imageMobile'], collection: 'imageFiles'} - ] - } - }); - - expect(references.of('imageFiles', 5).map(({active}) => active)).toEqual([false, true]); - }); - - it('reports inactive reference', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {image: 5, video: 6}}}], - locations: { - sections: [{path: ['backdrop', 'image'], collection: 'imageFiles', - activeIf: {path: ['backdrop', 'video'], present: false}}] - } - }); - - expect(references.of('imageFiles', 5)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'image'], active: false}]); - }); - - describe('nested files', () => { - const files = { - videoFiles: [{id: 100, permaId: 5}], - textTrackFiles: [{id: 200, permaId: 6, parentFileId: 100, - parentFileModelType: 'Pageflow::VideoFile'}] - }; - - it('inherits references of parent file', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {video: 5}}}], - files, - locations: {sections: [{path: ['backdrop', 'video'], collection: 'videoFiles'}]} - }); - - expect(references.of('textTrackFiles', 6)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'video'], active: true}]); - }); - - it('is empty if parent file is not referenced', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {}}], - files, - locations: {sections: [{path: ['backdrop', 'video'], collection: 'videoFiles'}]} - }); - - expect(references.of('textTrackFiles', 6)).toEqual([]); - }); - - it('inherits activeness of parent file', () => { - const references = collect({ - sections: [{id: 1, permaId: 10, configuration: {backdrop: {video: 5, color: '#fff'}}}], - files, - locations: { - sections: [{path: ['backdrop', 'video'], collection: 'videoFiles', - activeIf: {path: ['backdrop', 'color'], present: false}}] - } - }); - - expect(references.of('textTrackFiles', 6)) - .toEqual([{subject: {model: 'section', permaId: 10}, - path: ['backdrop', 'video'], active: false}]); - }); - }); -}); diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index e5930b3e22..bce317aea9 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -29,7 +29,6 @@ export {useDownloadableFile} from './useDownloadableFile'; export {useNestedFiles} from './useNestedFiles'; export {getFileUrlTemplateHost} from './getFileUrlTemplateHost'; export {useFileRights, useLegalInfo, useCredits} from './legalInfo'; -export {useFileReferences, useIsFileReferenced} from './useFileReferences'; export {useAvailableQualities} from './useAvailableQualities'; export {useTheme} from './theme'; export {useWidget, useActiveWidgets} from './widgets'; diff --git a/entry_types/scrolled/package/src/entryState/legalInfo.js b/entry_types/scrolled/package/src/entryState/legalInfo.js index f9ff46d7bb..5ea9f76a44 100644 --- a/entry_types/scrolled/package/src/entryState/legalInfo.js +++ b/entry_types/scrolled/package/src/entryState/legalInfo.js @@ -3,7 +3,7 @@ import { useEntryStateConfig, useMultipleEntryStateCollectionItems } from "./EntryStateProvider"; -import {useIsFileReferenced} from './useFileReferences'; +import {features} from 'pageflow/frontend'; /** * Returns a collection of rights and source urls of the files @@ -24,7 +24,7 @@ export function useFileRights() { const config = useEntryStateConfig(); const fileCollectionNames = Object.keys(config.fileModelTypes); const files = useMultipleEntryStateCollectionItems(fileCollectionNames); - const isFileReferenced = useIsFileReferenced(); + const onlyReferenced = features.isEnabled('file_rights_from_references'); const defaultFileRights = config.defaultFileRights?.trim(); @@ -33,7 +33,7 @@ export function useFileRights() { Object.keys(files).forEach(collectionName => files[collectionName] .filter(file => file.configuration.rights_display !== 'inline') - .filter(file => isFileReferenced(collectionName, file.permaId)) + .filter(file => !onlyReferenced || file.isReferenced) .forEach(file => { const text = file.rights?.trim() || defaultFileRights; diff --git a/entry_types/scrolled/package/src/entryState/useFileReferences.js b/entry_types/scrolled/package/src/entryState/useFileReferences.js deleted file mode 100644 index 5b9a8b00c2..0000000000 --- a/entry_types/scrolled/package/src/entryState/useFileReferences.js +++ /dev/null @@ -1,67 +0,0 @@ -import {useMemo} from 'react'; - -import {features} from 'pageflow/frontend'; - -import { - useEntryStateCollectionItems, - useEntryStateConfig, - useMultipleEntryStateCollectionItems -} from './EntryStateProvider'; -import {useEntryMetadata} from './metadata'; -import {collectEntryFileReferences} from '../shared/collectEntryFileReferences'; - -/** - * Returns the file references of the entry, indexed by file. - * - * @example - * - * const references = useFileReferences(); - * references.of('imageFiles', 5) - * // => [{subject: {model: 'section', permaId: 12}, active: true}] - * - * @private - */ -export function useFileReferences() { - const config = useEntryStateConfig(); - const entry = useEntryMetadata(); - const sections = useEntryStateCollectionItems('sections'); - const contentElements = useEntryStateCollectionItems('contentElements'); - const collectionNames = useMemo(() => Object.keys(config.fileModelTypes), [config]); - const files = useMultipleEntryStateCollectionItems(collectionNames); - - return useMemo( - () => collectEntryFileReferences({ - collections: {entries: [entry], sections, contentElements, ...files}, - locations: config.fileReferenceLocations || {}, - fileModelTypes: config.fileModelTypes - }), - [config, entry, sections, contentElements, files] - ); -} - -/** - * Returns a function telling whether a file is referenced in the entry. - * - * Considers every file referenced unless the file_rights_from_references - * feature is enabled, since schemas do not cover all content element - * types yet. - * - * @example - * - * const isFileReferenced = useIsFileReferenced(); - * isFileReferenced('imageFiles', 5) // => true - * - * @private - */ -export function useIsFileReferenced() { - const references = useFileReferences(); - - return useMemo(() => { - if (!features.isEnabled('file_rights_from_references')) { - return () => true; - } - - return (collectionName, permaId) => - references.of(collectionName, permaId).some(({active}) => active); - }, [references]); -} diff --git a/entry_types/scrolled/package/src/shared/collectEntryFileReferences.js b/entry_types/scrolled/package/src/shared/collectEntryFileReferences.js deleted file mode 100644 index 24f2d46d3b..0000000000 --- a/entry_types/scrolled/package/src/shared/collectEntryFileReferences.js +++ /dev/null @@ -1,114 +0,0 @@ -import {collectFileReferences} from './collectFileReferences'; - -/** - * Find the file references of an entry, indexed by file. - * - * @param {Object} options - * @param {Object} options.collections - Entry state collections. - * @param {Object} options.locations - File reference locations by subject. - * @param {Object} options.fileModelTypes - Model type by file collection name. - * - * @example - * - * const references = collectEntryFileReferences({collections, locations, fileModelTypes}); - * references.of('imageFiles', 5) - * // => [{subject: {model: 'section', permaId: 12}, - * // path: ['backdrop', 'image'], active: true}] - * - * @private - */ -export function collectEntryFileReferences({collections, locations, fileModelTypes}) { - const references = {}; - - subjects(collections, locations).forEach(subject => - collectFileReferences({ - locations: subject.locations, - configuration: subject.configuration - }).forEach(reference => add(references, reference, subject.subject)) - ); - - addNestedFileReferences(references, collections, fileModelTypes); - - return { - of(collectionName, permaId) { - return references[key({collectionName, permaId})] || []; - } - }; -} - -function subjects({entries = [], sections = [], contentElements = []}, locations) { - const bySectionId = groupBySectionId(contentElements); - - return [ - ...entries.map(entry => ({ - subject: {model: 'entry'}, - configuration: entry, - locations: locations.entry || [] - })), - ...sections.flatMap(section => [ - { - subject: {model: 'section', permaId: section.permaId}, - configuration: section.configuration, - locations: locations.sections || [] - }, - ...(bySectionId[section.id] || []).map(contentElement => ({ - subject: {model: 'contentElement', permaId: contentElement.permaId}, - configuration: contentElement.configuration, - locations: (locations.contentElements || {})[contentElement.typeName] || [] - })) - ]) - ]; -} - -// Text tracks and the like are not referenced by any configuration. -function addNestedFileReferences(references, collections, fileModelTypes = {}) { - const filesById = indexFilesById(collections, fileModelTypes); - - Object.keys(fileModelTypes).forEach(collectionName => - (collections[collectionName] || []).forEach(file => { - if (!file.parentFileId) { - return; - } - - const parent = filesById[modelTypeAndId(file.parentFileModelType, file.parentFileId)]; - - references[key(parent || {})]?.forEach(({subject, path, active}) => - add(references, {collectionName, permaId: file.permaId, path, active}, subject) - ); - }) - ); -} - -function indexFilesById(collections, fileModelTypes) { - return Object.entries(fileModelTypes).reduce((result, [collectionName, modelType]) => { - (collections[collectionName] || []).forEach(file => { - result[modelTypeAndId(modelType, file.id)] = {collectionName, permaId: file.permaId}; - }); - - return result; - }, {}); -} - -function add(references, {collectionName, permaId, path, active}, subject) { - const list = references[key({collectionName, permaId})] = - references[key({collectionName, permaId})] || []; - - list.push({subject, path, active}); -} - -function groupBySectionId(contentElements) { - return contentElements.reduce((result, contentElement) => { - (result[contentElement.sectionId] = result[contentElement.sectionId] || []) - .push(contentElement); - - return result; - }, {}); -} - -function key({collectionName, permaId}) { - return `${collectionName}/${permaId}`; -} - -function modelTypeAndId(modelType, id) { - return `${modelType}/${id}`; -} diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/editor/seed_html_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/editor/seed_html_helper_spec.rb index cc137626db..b99ef33198 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/editor/seed_html_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/editor/seed_html_helper_spec.rb @@ -56,28 +56,14 @@ module Editor visible: false) end - it 'renders file reference locations of content element types not used in entry' do - dir = Dir.mktmpdir - File.write( - File.join(dir, 'schema.json'), - JSON.generate('x-subject' => {'model' => 'contentElement', - 'typeName' => 'someType'}, - 'properties' => {'image' => {'x-fileCollection' => 'image_files'}}) - ) - - pageflow_configure do |config| - config.for_entry_type(PageflowScrolled.entry_type) do |entry_type_config| - entry_type_config.configuration_schema_load_path << File.join(dir, '*.json') - end - end - + it 'does not render file reference locations' do entry = create(:published_entry, type_name: 'scrolled') result = helper.scrolled_editor_iframe_seed_html_script_tag(entry) - expect(result).to have_selector('script', text: 'someType', visible: false) - ensure - FileUtils.remove_entry(dir) + expect(result).not_to have_selector('script', + text: 'fileReferenceLocations', + visible: false) end it 'does not server side render entry' do 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 0248623030..0882947828 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 @@ -528,7 +528,7 @@ def render(helper, entry, options = {}) section = create(:section, chapter:) create(:content_element, section:, type_name: 'someType') - result = render(helper, entry) + result = render(helper, entry, include_file_reference_locations: true) expect(result).to include_json( config: { @@ -541,20 +541,12 @@ def render(helper, entry, options = {}) ) end - it 'omits content element types not used in entry' do - register_schema( - 'x-subject' => {'model' => 'contentElement', 'typeName' => 'someType'}, - 'properties' => {'image' => {'x-fileCollection' => 'image_files'}} - ) - + it 'does not render file reference locations by default' do entry = create(:published_entry, type_name: 'scrolled') - chapter = create(:scrolled_chapter, revision: entry.revision) - section = create(:section, chapter:) - create(:content_element, section:, type_name: 'otherType') result = render(helper, entry) - expect(json_config(result)['fileReferenceLocations']['contentElements']).to eq({}) + expect(json_config(result)).not_to have_key('fileReferenceLocations') end it 'renders empty list for content element types without file references' do @@ -568,10 +560,10 @@ def render(helper, entry, options = {}) section = create(:section, chapter:) create(:content_element, section:, type_name: 'someType') - result = render(helper, entry) + result = render(helper, entry, include_file_reference_locations: true) expect(json_config(result)['fileReferenceLocations']['contentElements']) - .to eq('someType' => []) + .to include('someType' => []) end it 'renders references of sections' do @@ -582,7 +574,7 @@ def render(helper, entry, options = {}) entry = create(:published_entry, type_name: 'scrolled') - result = render(helper, entry) + result = render(helper, entry, include_file_reference_locations: true) expect(json_config(result)['fileReferenceLocations']['sections']) .to include('path' => ['someFile'], 'collection' => 'imageFiles') @@ -596,7 +588,7 @@ def render(helper, entry, options = {}) entry = create(:published_entry, type_name: 'scrolled') - result = render(helper, entry) + result = render(helper, entry, include_file_reference_locations: true) expect(json_config(result)['fileReferenceLocations']['entry']) .to include('path' => ['someFile'], 'collection' => 'imageFiles') @@ -611,7 +603,8 @@ def render(helper, entry, options = {}) entry = create(:published_entry, type_name: 'scrolled') - expect { render(helper, entry) }.to raise_error(/schema for section/) + expect { render(helper, entry, include_file_reference_locations: true) } + .to raise_error(/schema for section/) end it 'logs content element types without schema' do @@ -620,7 +613,10 @@ def render(helper, entry, options = {}) 'properties' => {} ) - entry = create(:published_entry, type_name: 'scrolled') + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'file_rights_from_references') + create_used_file(:image_file, entry:) chapter = create(:scrolled_chapter, revision: entry.revision) section = create(:section, chapter:) create(:content_element, section:, type_name: 'otherType') diff --git a/entry_types/scrolled/spec/pageflow_scrolled/entry_file_reference_locations_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/entry_file_reference_locations_spec.rb new file mode 100644 index 0000000000..91aa8f7a1a --- /dev/null +++ b/entry_types/scrolled/spec/pageflow_scrolled/entry_file_reference_locations_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe EntryFileReferenceLocations do + describe '#content_element_locations' do + it 'includes locations of all content element types' do + entry = create(:published_entry, type_name: 'scrolled') + locations = EntryFileReferenceLocations.new(Pageflow.config_for(entry)) + + expect(locations.content_element_locations) + .to include('inlineImage' => include('path' => ['id'], 'collection' => 'imageFiles'), + 'textBlock' => []) + end + end + + describe '#content_element_locations_for' do + it 'returns locations of content element type' do + entry = create(:published_entry, type_name: 'scrolled') + locations = EntryFileReferenceLocations.new(Pageflow.config_for(entry)) + + expect(locations.content_element_locations_for('inlineImage')) + .to include('path' => ['id'], 'collection' => 'imageFiles') + end + + it 'returns empty list and logs for content element type without schema' do + entry = create(:published_entry, + type_name: 'scrolled', + with_feature: 'file_rights_from_references') + locations = EntryFileReferenceLocations.new(Pageflow.config_for(entry)) + + expect(Rails.logger).to receive(:warn).with(/unknownType/).once + + expect(locations.content_element_locations_for('unknownType')).to eq([]) + expect(locations.content_element_locations_for('unknownType')).to eq([]) + end + end + end +end diff --git a/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb b/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb index 06b291b8b3..cc11b0300a 100644 --- a/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb +++ b/entry_types/scrolled/spec/pageflow_scrolled/referenced_files_spec.rb @@ -148,7 +148,7 @@ def referenced_files_for(entry, entry, sections:, content_elements:, - locations: EntryFileReferenceLocations.new(Pageflow.config_for(entry), content_elements) + locations: EntryFileReferenceLocations.new(Pageflow.config_for(entry)) ) end From caeb535ebb9896a16b128bd6e4582f6ebd645c29 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 21 Oct 2026 12:25:26 +0200 Subject: [PATCH 06/10] Describe share image of entries by its attribute name The entry schema used the camel cased name of entry state, since file references used to be collected from entry state items. Now that the editor and the server read the entry directly, use the attribute name both of them see. --- entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb | 2 +- .../spec/editor/models/ScrolledEntry/fileReferences-spec.js | 2 +- .../spec/editor/models/ScrolledEntry/isReferenced-spec.js | 2 +- .../package/src/editor/models/ScrolledEntry/fileReferences.js | 2 +- entry_types/scrolled/package/src/frontend/schemas/entry.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb index df2900e59a..506750765b 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb @@ -34,7 +34,7 @@ def referenced_keys end def subjects - [[{'shareImageId' => @entry.share_image_id}, @locations.entry_locations]] + + [[{'share_image_id' => @entry.share_image_id}, @locations.entry_locations]] + @sections.map { |section| [section.configuration, @locations.section_locations] } + @content_elements.map do |content_element| [content_element.configuration, diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js index 6f0b6f7dd2..38c037c961 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js @@ -251,7 +251,7 @@ describe('ScrolledEntry', () => { metadata: {share_image_id: 5}, imageFiles: [{perma_id: 5}], fileReferenceLocations: { - entry: [{path: ['shareImageId'], collection: 'imageFiles'}] + entry: [{path: ['share_image_id'], collection: 'imageFiles'}] } }); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js index f4039e1cd4..25dab57124 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/isReferenced-spec.js @@ -182,7 +182,7 @@ describe('ScrolledEntry', () => { imageFiles: [{id: 1, perma_id: 5}], contentElements: [], fileReferenceLocations: { - entry: [{path: ['shareImageId'], collection: 'imageFiles'}] + entry: [{path: ['share_image_id'], collection: 'imageFiles'}] } }); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js index afc4ab2036..0cf9242530 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/fileReferences.js @@ -126,7 +126,7 @@ function subjectSources(entry, locations) { models: () => [entry.metadata], changeEvents: 'change:share_image_id', locations: () => locations.entry || [], - configuration: metadata => ({shareImageId: metadata.get('share_image_id')}), + configuration: metadata => ({share_image_id: metadata.get('share_image_id')}), placeModel: () => entry }, { diff --git a/entry_types/scrolled/package/src/frontend/schemas/entry.json b/entry_types/scrolled/package/src/frontend/schemas/entry.json index fe61acee1d..19ca276efe 100644 --- a/entry_types/scrolled/package/src/frontend/schemas/entry.json +++ b/entry_types/scrolled/package/src/frontend/schemas/entry.json @@ -3,7 +3,7 @@ "x-subject": {"model": "entry"}, "type": "object", "properties": { - "shareImageId": { + "share_image_id": { "type": "integer", "x-fileCollection": "image_files" } From 30d7b38634ae742aecbbfe1340224cda0794b9aa Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 21 Oct 2026 15:06:12 +0200 Subject: [PATCH 07/10] Open hotspot areas holding linked files Name links in tooltip texts and tooltip links as places of file references and activate the area that holds them on select, so editors can find downloads they are about to delete. --- entry_types/scrolled/config/locales/de.yml | 3 + entry_types/scrolled/config/locales/en.yml | 3 + .../editor/configurationPlace-spec.js | 69 ++++++++++++++++++- .../contentElements/hotspots/editor/index.js | 34 ++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 8e891dde4f..2ca8ff1f4e 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1016,6 +1016,9 @@ de: description: Interaktives Panorama aus Bild mit rektangularer Projektion name: 360°-Bild hotspots: + configuration_places: + tooltipLinks: Tooltip-Link + tooltipTexts: Link im Tooltip-Text edit_area: back: Zurück confirm_destroy: Soll der Hotspot-Bereich wirklich gelöscht werden? diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 007c92262a..fc2a29ba5b 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1000,6 +1000,9 @@ en: description: Interactive panorama using an image with rectangular projection name: 360° Image hotspots: + configuration_places: + tooltipLinks: Tooltip link + tooltipTexts: Link in tooltip text edit_area: back: Back confirm_destroy: Are you sure you want to delete this area? diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/configurationPlace-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/configurationPlace-spec.js index 062ecd9191..25e42dd57b 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/configurationPlace-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/configurationPlace-spec.js @@ -13,7 +13,11 @@ describe('hotspots configuration place', () => { 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.attributes.tooltipImage.label': 'Image in tooltip', 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.attributes.portraitActiveImage.label': - 'Active image (Portrait)' + 'Active image (Portrait)', + 'pageflow_scrolled.editor.content_elements.hotspots.configuration_places.tooltipTexts': + 'Link in tooltip text', + 'pageflow_scrolled.editor.content_elements.hotspots.configuration_places.tooltipLinks': + 'Tooltip link' }); const {createEntry} = useEditorGlobals(); @@ -105,4 +109,67 @@ describe('hotspots configuration place', () => { expect(editor.router.navigate) .toHaveBeenCalledWith('/scrolled/hotspots/2/7/portrait', {trigger: true}); }); + + it('names links in tooltip texts', () => { + const entry = create({ + areas: [{id: 7}], + tooltipTexts: {7: {description: [linkNode()]}} + }); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Hotspots', 'Link in tooltip text']]); + }); + + it('names tooltip links', () => { + const entry = create({ + areas: [{id: 7}], + tooltipLinks: {7: {href: fileHref()}} + }); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Hotspots', 'Tooltip link']]); + }); + + it('activates the area of a tooltip link on select', () => { + const entry = create({ + areas: [{id: 7}, {id: 8}], + tooltipLinks: {8: {href: fileHref()}} + }); + const commandListener = jest.fn(); + const selectListener = jest.fn(); + entry.contentElements.get(2).on('postCommand', commandListener); + entry.on('selectContentElement', selectListener); + + places(entry)[0].select(); + + expect(commandListener).toHaveBeenCalledWith(2, {type: 'SET_ACTIVE_AREA', index: 1}); + expect(selectListener).toHaveBeenCalledWith(entry.contentElements.get(2), + {navigate: false}); + expect(editor.router.navigate) + .toHaveBeenCalledWith('/scrolled/hotspots/2/8/area', {trigger: true}); + }); + + it('selects the content element for tooltip links of removed areas', () => { + const entry = create({ + areas: [{id: 7}], + tooltipLinks: {8: {href: fileHref()}} + }); + const commandListener = jest.fn(); + entry.contentElements.get(2).on('postCommand', commandListener); + + places(entry)[0].select(); + + expect(commandListener).toHaveBeenCalledWith(2, {type: 'SET_ACTIVE_AREA', index: -1}); + expect(editor.router.navigate).not.toHaveBeenCalled(); + }); + + function fileHref() { + return {file: {permaId: 5, collectionName: 'imageFiles'}}; + } + + function linkNode() { + return {type: 'paragraph', children: [ + {type: 'link', href: fileHref(), children: [{text: 'Download'}]} + ]}; + } }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js index 98fb41dda0..c7a1764866 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/index.js @@ -36,11 +36,15 @@ editor.contentElementTypes.register('hotspots', { configurationPlace(contentElement, path) { const [propertyName] = path; - if (propertyName !== 'areas') { - return elementConfigurationPlace(contentElement); + if (propertyName === 'areas') { + return areaConfigurationPlace(contentElement, path); } - return areaConfigurationPlace(contentElement, path); + if (propertyName === 'tooltipTexts' || propertyName === 'tooltipLinks') { + return tooltipConfigurationPlace(contentElement, path); + } + + return elementConfigurationPlace(contentElement); }, configurationEditor({entry, contentElement}) { @@ -124,6 +128,30 @@ function areaConfigurationPlace(contentElement, [, index, propertyName]) { }; } +function tooltipConfigurationPlace(contentElement, [propertyName, areaId]) { + const areas = contentElement.configuration.get('areas') || []; + const index = areas.findIndex(area => String(area.id) === String(areaId)); + const label = I18n.t( + `pageflow_scrolled.editor.content_elements.hotspots.configuration_places.${propertyName}` + ); + + if (index < 0) { + return {...elementConfigurationPlace(contentElement), label}; + } + + return { + label, + + select() { + contentElement.postCommand({type: 'SET_ACTIVE_AREA', index}); + contentElement.select({navigate: false}); + + editor.navigate(`/scrolled/hotspots/${contentElement.id}/${areas[index].id}/area`, + {trigger: true}); + } + }; +} + function areaAttributeLabel(propertyName) { return I18n.t(`${propertyName}.label`, { scope: 'pageflow_scrolled.editor.content_elements.hotspots.edit_area.attributes' From d4340af00324024ea9515a373fb42f646a61775a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 21 Oct 2026 15:06:56 +0200 Subject: [PATCH 08/10] Open gallery items holding linked files Name links in captions of gallery items as places of file references and switch the gallery to the item that holds them on select. --- entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../editor/configurationPlace-spec.js | 46 ++++++++++++++++++- .../imageGallery/editor/index.js | 43 +++++++++++------ 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 2ca8ff1f4e..c7a05f6e0f 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -620,6 +620,8 @@ de: Zeigt die aktuelle Bildposition und die Gesamtzahl der Bilder an, um die Navigation durch die Galerie zu erleichtern. + configuration_places: + captions: Link in Bildunterschrift description: Eine Serie von Bildern zum Durchblättern items: add: Hinzufügen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index fc2a29ba5b..920451f9cc 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -606,6 +606,8 @@ en: Displays the current image position and the total number of images to make navigating the gallery easier. + configuration_places: + captions: Link in caption description: A series of images to flip through items: add: Add diff --git a/entry_types/scrolled/package/spec/contentElements/imageGallery/editor/configurationPlace-spec.js b/entry_types/scrolled/package/spec/contentElements/imageGallery/editor/configurationPlace-spec.js index 3b1743f286..f29d1ac184 100644 --- a/entry_types/scrolled/package/spec/contentElements/imageGallery/editor/configurationPlace-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/imageGallery/editor/configurationPlace-spec.js @@ -12,7 +12,9 @@ describe('imageGallery configuration place', () => { 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item.attributes.image.label': 'Image', 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item.attributes.portraitImage.label': - 'Image (Portrait)' + 'Image (Portrait)', + 'pageflow_scrolled.editor.content_elements.imageGallery.configuration_places.captions': + 'Link in caption' }); const {createEntry} = useEditorGlobals(); @@ -84,4 +86,46 @@ describe('imageGallery configuration place', () => { expect(listener).toHaveBeenCalledWith(entry.contentElements.get(2), {navigate: false}); }); + + it('names links in captions of items', () => { + const entry = create({items: [{id: 7}], captions: {7: [linkNode()]}}); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Gallery', 'Link in caption']]); + }); + + it('switches the gallery to the item of a caption on select', () => { + const entry = create({items: [{id: 7}, {id: 8}], captions: {8: [linkNode()]}}); + const commandListener = jest.fn(); + const selectListener = jest.fn(); + entry.contentElements.get(2).on('postCommand', commandListener); + entry.on('selectContentElement', selectListener); + + places(entry)[0].select(); + + expect(commandListener).toHaveBeenCalledWith(2, {type: 'SET_CURRENT_ITEM', index: 1}); + expect(selectListener).toHaveBeenCalledWith(entry.contentElements.get(2), + {navigate: false}); + expect(editor.router.navigate) + .toHaveBeenCalledWith('/scrolled/imageGalleries/2/8', {trigger: true}); + }); + + it('selects the content element for captions of removed items', () => { + const entry = create({items: [{id: 7}], captions: {8: [linkNode()]}}); + const selectListener = jest.fn(); + entry.on('selectContentElement', selectListener); + + places(entry)[0].select(); + + expect(selectListener).toHaveBeenCalledWith(entry.contentElements.get(2), + {navigate: true}); + expect(editor.router.navigate).not.toHaveBeenCalled(); + }); + + function linkNode() { + return {type: 'paragraph', children: [ + {type: 'link', href: {file: {permaId: 5, collectionName: 'imageFiles'}}, + children: [{text: 'Download'}]} + ]}; + } }); diff --git a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js index 61704d5e81..27530e960a 100644 --- a/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/imageGallery/editor/index.js @@ -26,25 +26,22 @@ editor.contentElementTypes.register('imageGallery', { supportedStyles: ['boxShadow', 'outline'], configurationPlace(contentElement, path) { - const [propertyName, index, itemProperty] = path; + const [propertyName, indexOrId, itemProperty] = path; - if (propertyName !== 'items') { - return; + if (propertyName === 'items') { + return itemConfigurationPlace(contentElement, Number(indexOrId), + attributeLabel(itemProperty)); } - const item = contentElement.configuration.get('items')[index]; + if (propertyName === 'captions') { + const items = contentElement.configuration.get('items') || []; + const index = items.findIndex(item => String(item.id) === String(indexOrId)); + const label = I18n.t( + 'pageflow_scrolled.editor.content_elements.imageGallery.configuration_places.captions' + ); - return { - label: attributeLabel(itemProperty), - - select() { - contentElement.postCommand({type: 'SET_CURRENT_ITEM', index: Number(index)}); - contentElement.select({navigate: false}); - - editor.navigate(`/scrolled/imageGalleries/${contentElement.id}/${item.id}`, - {trigger: true}); - } - }; + return index < 0 ? {label} : itemConfigurationPlace(contentElement, index, label); + } }, configurationEditor({entry, contentElement}) { @@ -85,6 +82,22 @@ editor.registerFileSelectionHandler('imageGalleryItem', function (options) { }; }); +function itemConfigurationPlace(contentElement, index, label) { + const item = contentElement.configuration.get('items')[index]; + + return { + label, + + select() { + contentElement.postCommand({type: 'SET_CURRENT_ITEM', index}); + contentElement.select({navigate: false}); + + editor.navigate(`/scrolled/imageGalleries/${contentElement.id}/${item.id}`, + {trigger: true}); + } + }; +} + function attributeLabel(propertyName) { return I18n.t(`${propertyName}.label`, { scope: 'pageflow_scrolled.editor.content_elements.imageGallery.edit_item.attributes' From 237aed52f0e035d9cf4c072599f03a2ca011f2b8 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 21 Oct 2026 15:08:56 +0200 Subject: [PATCH 09/10] Open link list items holding linked files Name links in item texts and item links of external link lists as places of file references and select the item that holds them on select. --- entry_types/scrolled/config/locales/de.yml | 3 + entry_types/scrolled/config/locales/en.yml | 3 + .../editor/configurationPlace-spec.js | 58 ++++++++++++++++++- .../externalLinkList/editor/index.js | 43 +++++++++----- 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index c7a05f6e0f..17b61c3be7 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -382,6 +382,9 @@ de: general: Diagramm externalLinkList: add: Hinzufügen + configuration_places: + itemLinks: Link des Eintrags + itemTexts: Link im Text des Eintrags attributes: thumbnail: inline_help: Dem Link zugeordnertes Bild für die Darstellung im Beitrag. diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 920451f9cc..3f245c0ea4 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -375,6 +375,9 @@ en: general: Chart externalLinkList: add: Add + configuration_places: + itemLinks: Item link + itemTexts: Link in item text attributes: thumbnail: inline_help: Link thumbnail image for display in entry. diff --git a/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/configurationPlace-spec.js b/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/configurationPlace-spec.js index 397068bb83..c63156bc93 100644 --- a/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/configurationPlace-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/externalLinkList/editor/configurationPlace-spec.js @@ -10,7 +10,11 @@ describe('externalLinkList configuration place', () => { 'pageflow_scrolled.editor.configuration_places.label': '%{chapter} - %{subject}', 'pageflow_scrolled.editor.content_elements.externalLinkList.name': 'Link list', 'pageflow_scrolled.editor.content_elements.externalLinkList.attributes.thumbnail.label': - 'Thumbnail' + 'Thumbnail', + 'pageflow_scrolled.editor.content_elements.externalLinkList.configuration_places.itemTexts': + 'Link in item text', + 'pageflow_scrolled.editor.content_elements.externalLinkList.configuration_places.itemLinks': + 'Item link' }); const {createEntry} = useEditorGlobals(); @@ -68,4 +72,56 @@ describe('externalLinkList configuration place', () => { expect(listener).toHaveBeenCalledWith(entry.contentElements.get(2), {navigate: false}); }); + + it('names links in item texts', () => { + const entry = create({links: [{id: 7}], itemTexts: {7: {description: [linkNode()]}}}); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Link list', 'Link in item text']]); + }); + + it('names item links', () => { + const entry = create({links: [{id: 7}], itemLinks: {7: {href: fileHref()}}}); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Link list', 'Item link']]); + }); + + it('selects the link of an item link on select', () => { + const entry = create({links: [{id: 7}, {id: 8}], itemLinks: {8: {href: fileHref()}}}); + const commandListener = jest.fn(); + const selectListener = jest.fn(); + entry.contentElements.get(2).on('postCommand', commandListener); + entry.on('selectContentElement', selectListener); + + places(entry)[0].select(); + + expect(commandListener).toHaveBeenCalledWith(2, {type: 'SET_SELECTED_ITEM', index: 1}); + expect(selectListener).toHaveBeenCalledWith(entry.contentElements.get(2), + {navigate: false}); + expect(editor.router.navigate) + .toHaveBeenCalledWith('/scrolled/external_links/2/8', {trigger: true}); + }); + + it('selects the content element for item links of removed links', () => { + const entry = create({links: [{id: 7}], itemLinks: {8: {href: fileHref()}}}); + const selectListener = jest.fn(); + entry.on('selectContentElement', selectListener); + + places(entry)[0].select(); + + expect(selectListener).toHaveBeenCalledWith(entry.contentElements.get(2), + {navigate: true}); + expect(editor.router.navigate).not.toHaveBeenCalled(); + }); + + function fileHref() { + return {file: {permaId: 5, collectionName: 'imageFiles'}}; + } + + function linkNode() { + return {type: 'paragraph', children: [ + {type: 'link', href: fileHref(), children: [{text: 'Download'}]} + ]}; + } }); diff --git a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js index 42198d9184..94ffa946be 100644 --- a/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js +++ b/entry_types/scrolled/package/src/contentElements/externalLinkList/editor/index.js @@ -40,25 +40,22 @@ editor.contentElementTypes.register('externalLinkList', { }, configurationPlace(contentElement, path) { - const [propertyName, index, linkProperty] = path; + const [propertyName, indexOrId, linkProperty] = path; - if (propertyName !== 'links') { - return; + if (propertyName === 'links') { + return linkConfigurationPlace(contentElement, Number(indexOrId), + attributeLabel(linkProperty)); } - const link = contentElement.configuration.get('links')[index]; + if (propertyName === 'itemTexts' || propertyName === 'itemLinks') { + const links = contentElement.configuration.get('links') || []; + const index = links.findIndex(link => String(link.id) === String(indexOrId)); + const label = I18n.t( + `pageflow_scrolled.editor.content_elements.externalLinkList.configuration_places.${propertyName}` + ); - return { - label: attributeLabel(linkProperty), - - select() { - contentElement.postCommand({type: 'SET_SELECTED_ITEM', index: Number(index)}); - contentElement.select({navigate: false}); - - editor.navigate(`/scrolled/external_links/${contentElement.id}/${link.id}`, - {trigger: true}); - } - }; + return index < 0 ? {label} : linkConfigurationPlace(contentElement, index, label); + } }, configurationEditor({entry, contentElement}) { @@ -164,6 +161,22 @@ editor.registerFileSelectionHandler('contentElement.externalLinks.link', functio }; }); +function linkConfigurationPlace(contentElement, index, label) { + const link = contentElement.configuration.get('links')[index]; + + return { + label, + + select() { + contentElement.postCommand({type: 'SET_SELECTED_ITEM', index}); + contentElement.select({navigate: false}); + + editor.navigate(`/scrolled/external_links/${contentElement.id}/${link.id}`, + {trigger: true}); + } + }; +} + function attributeLabel(propertyName) { return I18n.t(`${propertyName}.label`, { scope: 'pageflow_scrolled.editor.content_elements.externalLinkList.attributes' From 285c1a941ba7dfb47113571e3c3147f9e4ffe60f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 21 Oct 2026 15:37:16 +0200 Subject: [PATCH 10/10] Select text blocks holding linked files Name links in text blocks by their text as places of file references and select the block that holds them on select. --- entry_types/scrolled/config/locales/de.yml | 2 + entry_types/scrolled/config/locales/en.yml | 2 + .../editor/configurationPlace-spec.js | 84 +++++++++++++++++++ .../src/contentElements/textBlock/editor.js | 32 +++++++ 4 files changed, 120 insertions(+) create mode 100644 entry_types/scrolled/package/spec/contentElements/textBlock/editor/configurationPlace-spec.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 17b61c3be7..5c7ecb150a 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -892,6 +892,8 @@ de: unmutedText: label: "'Ton aktiviert' Text" textBlock: + configuration_places: + link: 'Link „%{text}“' attributes: color: label: Farbe diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 3f245c0ea4..17a510eebb 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -876,6 +876,8 @@ en: unmutedText: label: "Unmuted Text" textBlock: + configuration_places: + link: 'Link "%{text}"' attributes: color: label: Color diff --git a/entry_types/scrolled/package/spec/contentElements/textBlock/editor/configurationPlace-spec.js b/entry_types/scrolled/package/spec/contentElements/textBlock/editor/configurationPlace-spec.js new file mode 100644 index 0000000000..0360fc114b --- /dev/null +++ b/entry_types/scrolled/package/spec/contentElements/textBlock/editor/configurationPlace-spec.js @@ -0,0 +1,84 @@ +import 'contentElements/textBlock/editor'; + +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; + +describe('textBlock configuration place', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.configuration_places.label': '%{chapter} - %{subject}', + 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text', + 'pageflow_scrolled.editor.content_elements.textBlock.configuration_places.link': + 'Link "%{text}"' + }); + + const {createEntry} = useEditorGlobals(); + + function create(value) { + return createEntry({ + chapters: [{id: 1, permaId: 100, configuration: {title: 'Intro'}}], + imageFiles: [{perma_id: 5}], + sections: [{id: 1, permaId: 10, chapterId: 1}], + contentElements: [{id: 2, permaId: 20, sectionId: 1, typeName: 'textBlock', + configuration: {value}}] + }); + } + + function places(entry) { + return entry.fileReferences().placesFor( + entry.getFileCollection('image_files').findWhere({perma_id: 5}) + ); + } + + it('names the link by its text', () => { + const entry = create([ + {type: 'paragraph', children: [ + {text: 'Get the '}, + linkNode([{text: 'brochure'}, {text: ' now', bold: true}]) + ]} + ]); + + expect(places(entry).map(({label, detail}) => [label, detail])) + .toEqual([['Intro - Text', 'Link "brochure now"']]); + }); + + it('selects the block containing the link on select', () => { + const entry = create([ + {type: 'paragraph', children: [{text: 'Intro'}]}, + {type: 'paragraph', children: [linkNode([{text: 'brochure'}])]} + ]); + const listener = jest.fn(); + entry.on('selectContentElement', listener); + + places(entry)[0].select(); + + expect(listener).toHaveBeenCalledWith(entry.contentElements.get(2), + expect.objectContaining({range: [1, 2]})); + }); + + it('selects the top level block for links nested in lists', () => { + const entry = create([ + {type: 'paragraph', children: [{text: 'Intro'}]}, + {type: 'paragraph', children: [{text: 'More'}]}, + {type: 'bulleted-list', children: [ + {type: 'list-item', children: [{text: 'Item'}]}, + {type: 'list-item', children: [linkNode([{text: 'brochure'}])]} + ]} + ]); + const listener = jest.fn(); + entry.on('selectContentElement', listener); + + places(entry)[0].select(); + + expect(places(entry)[0].detail).toEqual('Link "brochure"'); + expect(listener).toHaveBeenCalledWith(entry.contentElements.get(2), + expect.objectContaining({range: [2, 3]})); + }); + + function linkNode(children) { + return { + type: 'link', + href: {file: {permaId: 5, collectionName: 'imageFiles'}}, + children + }; + } +}); diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js index 12df429482..128018a161 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js @@ -12,6 +12,24 @@ editor.contentElementTypes.register('textBlock', { pictogram, supportedPositions: ['inline'], + configurationPlace(contentElement, path) { + const [propertyName, blockIndex] = path; + + if (propertyName !== 'value') { + return; + } + + const index = Number(blockIndex); + + return { + label: linkLabel(contentElement.configuration.get('value'), path), + + select() { + contentElement.select({range: [index, index + 1]}); + } + }; + }, + configurationEditor({entry, contentElement}) { let lastExampleNodeType = contentElement.transientState.get('exampleNode')?.type; @@ -179,6 +197,20 @@ editor.contentElementTypes.register('textBlock', { } }); +function linkLabel(value, path) { + const nodePath = path.slice(1, path.indexOf('href')) + .filter(segment => segment !== 'children') + .map(Number); + const root = {children: value || []}; + + if (!Node.has(root, nodePath)) { + return; + } + + return I18n.t('pageflow_scrolled.editor.content_elements.textBlock.configuration_places.link', + {text: Node.string(Node.get(root, nodePath))}); +} + function getValue(configuration) { // Value might still be empty if text block has not been edited return configuration.value || [{type: 'paragraph', children: [{text: ''}]}];