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/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 65cb48e100..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,18 +31,30 @@ 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) 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/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/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..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) @@ -101,6 +103,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/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 8e891dde4f..5c7ecb150a 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. @@ -620,6 +623,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 @@ -887,6 +892,8 @@ de: unmutedText: label: "'Ton aktiviert' Text" textBlock: + configuration_places: + link: 'Link „%{text}“' attributes: color: label: Farbe @@ -1016,6 +1023,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..17a510eebb 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. @@ -606,6 +609,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 @@ -871,6 +876,8 @@ en: unmutedText: label: "Unmuted Text" textBlock: + configuration_places: + link: 'Link "%{text}"' attributes: color: label: Color @@ -1000,6 +1007,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/doc/internal/configuration_schemas.md b/entry_types/scrolled/doc/internal/configuration_schemas.md index 14c32827fb..0d596c0fb3 100644 --- a/entry_types/scrolled/doc/internal/configuration_schemas.md +++ b/entry_types/scrolled/doc/internal/configuration_schemas.md @@ -18,14 +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`. -- **Referenced file** — a file some configuration points at. Not the - same as a *used* file, see below. + `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 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. @@ -74,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 @@ -126,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/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/entry_file_reference_locations.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_file_reference_locations.rb index 46ef74320c..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,30 +4,32 @@ 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_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) - end + 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 + + @content_element_locations[type_name] = schema ? locations.for(schema) : [] + 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 @@ -44,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/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/lib/pageflow_scrolled/referenced_files.rb b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb new file mode 100644 index 0000000000..506750765b --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/referenced_files.rb @@ -0,0 +1,78 @@ +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 + [[{'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, + @locations.content_element_locations_for(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/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/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/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/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/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/spec/editor/models/ScrolledEntry/fileReferences-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/fileReferences-spec.js index d426822a61..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 @@ -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'}); @@ -209,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 new file mode 100644 index 0000000000..25dab57124 --- /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: ['share_image_id'], 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/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/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/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' 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' 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' 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: ''}]}]; 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..0cf9242530 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 => ({share_image_id: 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..bce317aea9 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -29,14 +29,13 @@ 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'; export {EntryStateProvider, useEntryStateDispatch} from './EntryStateProvider'; -export {collectionsSnapshot, watchCollections} from './collections'; +export {watchCollections} from './collections'; export {updateContentElementConfiguration} from './updateContentElementConfiguration'; export {updateWidgetConfiguration} from './updateWidgetConfiguration'; 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/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" } 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/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/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 5e1c91a6b8..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') @@ -664,6 +660,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/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/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 new file mode 100644 index 0000000000..cc11b0300a --- /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)) + ) + end + + def perma_id_of(entry, file) + entry.draft.find_file(file.class, file.id).perma_id + 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') 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')