diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb index 01ad304357..22b204bcce 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/linkable_entries_controller.rb @@ -4,15 +4,27 @@ module Editor class LinkableEntriesController < ActionController::Base include Pageflow::EditorController - helper PageflowScrolled::LinkedEntriesHelper + helper Pageflow::FilesHelper + helper Pageflow::SocialShareHelper def index - @entries = Pageflow::Entry - .accessible_by(current_ability, :read) - .where(account: @entry.account, fragment_library: nil) - .where.not(id: @entry.id) - .preload(:site, :account, :published_revision, :draft, permalink: :directory) - .order(:title) + @linkable_entries = LinkableEntry.all( + entries + .preload(:site, :account, :published_revision, :draft, permalink: :directory) + .order(:title), + except: @entry + ) + end + + def show + @linkable_entry = LinkableEntry.find(entries, params[:id], except: @entry) + head :not_found unless @linkable_entry + end + + private + + def entries + @entry.site.entries.accessible_by(current_ability, :read) end end end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb index ee3421e72f..65cb48e100 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/entry_json_seed_helper.rb @@ -26,80 +26,23 @@ def scrolled_entry_json_seed_script_tag(scrolled_entry, options = {}) end def scrolled_entry_json_seed(json, scrolled_entry, options = {}) - revision = scrolled_entry.revision - sections = scrolled_entry_json_seed_sections(scrolled_entry, options) - content_elements = scrolled_entry_json_seed_content_elements(revision, sections) + content = RevisionContent.new(scrolled_entry.revision, + include_hidden: options[:include_hidden_sections], + cutoff: scrolled_entry.cutoff_mode_enabled_for?(request)) widgets = scrolled_entry.resolve_widgets(insert_point: :react) entry_config = Pageflow.config_for(scrolled_entry) json.partial!('pageflow_scrolled/entry_json_seed/entry', entry: scrolled_entry, entry_config:, - storylines: scrolled_entry_json_seed_storylines(revision), - chapters: scrolled_entry_json_seed_chapters(revision, options), - sections:, - content_elements:, + content:, file_reference_locations: EntryFileReferenceLocations.new( entry_config, - content_elements, + content.content_elements, include_unused: options[:include_unused_file_reference_locations] ), - linked_entries: scrolled_linked_entries(scrolled_entry, - content_elements + widgets), widgets:, options:) end - - private - - def scrolled_entry_json_seed_storylines(revision) - Storyline.all_for_revision(revision) - end - - def scrolled_entry_json_seed_sections(scrolled_entry, options) - main_storyline = Storyline.all_for_revision(scrolled_entry.revision).first || Storyline.new - - sections = - if scrolled_entry.cutoff_mode_enabled_for?(request) - main_storyline.sections_before_cutoff_section - else - Section.all_for_revision(scrolled_entry.revision) - end - - return sections if options[:include_hidden_sections] - - sections.reject { |section| section.configuration['hidden'] } - end - - def scrolled_entry_json_seed_chapters(revision, options) - chapters = Chapter.all_for_revision(revision) - return chapters if options[:include_hidden_sections] - - has_visible_sections, has_hidden_sections = - scrolled_entry_json_seed_chapter_section_visibilites(revision) - - chapters.reject do |chapter| - has_hidden_sections[chapter.id] && !has_visible_sections[chapter.id] - end - end - - def scrolled_entry_json_seed_chapter_section_visibilites(revision) - has_visible_sections = {} - has_hidden_sections = {} - - Section.all_for_revision(revision).each do |section| - if section.configuration['hidden'] - has_hidden_sections[section.chapter_id] = true - else - has_visible_sections[section.chapter_id] = true - end - end - - [has_visible_sections, has_hidden_sections] - end - - def scrolled_entry_json_seed_content_elements(revision, sections) - ContentElement.all_for_revision(revision).where(section: sections) - end end end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb index 5e31fd7851..9456ff2e85 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/linked_entries_helper.rb @@ -1,32 +1,31 @@ module PageflowScrolled - # Determine urls of entries that links point to. + # Render entries and chapters that links point to. # # @api private module LinkedEntriesHelper include Pageflow::SocialShareHelper - def scrolled_linked_entries(scrolled_entry, configurables) - entry_ids = EntryLinkReferences.new(configurables.map(&:configuration)).entry_ids - return [] if entry_ids.empty? - - Pageflow::Entry - .where(id: entry_ids, account_id: scrolled_entry.account.id) - .preload(:site, :account, :published_revision, :draft, permalink: :directory) - .order(:id) - .map do |entry| - scrolled_linked_entry(entry, include_preview: !scrolled_entry.published_revision?) - end + def scrolled_linked_entries_json_seed(json, scrolled_entry, configurables) + json.linked_entries(scrolled_linked_entries(scrolled_entry, configurables)) do |linked_entry| + json.partial!(scrolled_linked_entry_partial(scrolled_entry), linked_entry:) + end end - def scrolled_linked_entry(entry, include_preview: true) - linked_entry = Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) - result = {entry: linked_entry, url: social_share_entry_url(linked_entry)} + private - return result unless include_preview + def scrolled_linked_entries(scrolled_entry, configurables) + LinkedEntry.all( + scrolled_entry.account.entries.preload(:site, :account, permalink: :directory), + EntryLinkReferences.new(configurables.map(&:configuration)).chapter_perma_ids_by_entry_id + ) + end - result.merge(preview_url: main_app.preview_admin_entry_path(entry), - publication_state: entry.publication_state, - signature: EntryLinkSignature.generate(entry.id)) + def scrolled_linked_entry_partial(scrolled_entry) + if scrolled_entry.published_revision? + 'pageflow_scrolled/linked_entries/linked_entry' + else + 'pageflow_scrolled/linked_entries/preview_linked_entry' + end end end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb new file mode 100644 index 0000000000..db5472f669 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter_slugs.rb @@ -0,0 +1,30 @@ +module PageflowScrolled + # Compute chapter slugs the same way as the frontend. + # + # @api private + class ChapterSlugs + def initialize(chapters) + @chapters = chapters + end + + def to_h + used_slugs = Set.new + + @chapters.to_h do |chapter| + [chapter.perma_id, slug(chapter, used_slugs)] + end + end + + private + + def slug(chapter, used_slugs) + slug = Slugify.call(chapter.configuration['title'] || '') + return "chapter-#{chapter.perma_id}" if slug.empty? + + slug = "#{slug}-#{chapter.perma_id}" if used_slugs.include?(slug) + used_slugs << slug + + slug + 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 new file mode 100644 index 0000000000..b1f6d22342 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/entry_link_references.rb @@ -0,0 +1,59 @@ +module PageflowScrolled + # Find signed links to other entries and their chapters and + # sections in configurations. + # + # @api private + class EntryLinkReferences + def initialize(configurations) + @configurations = configurations + end + + def chapter_perma_ids_by_entry_id + valid_links + .group_by(&:entry_id) + .transform_values { |links| links.filter_map(&:chapter).uniq } + end + + private + + Link = Struct.new(:entry_id, :chapter, :section, :signature) + + def valid_links + @valid_links ||= links.uniq.select do |link| + EntryLinkSignature.valid?(link.entry_id, + link.signature, + chapter: link.chapter, + section: link.section) + end + end + + def links + @configurations.each_with_object([]) do |configuration, result| + each_href(configuration) do |href| + entry_id = to_id(href['entry']) + next unless entry_id + + result << Link.new(entry_id, + to_id(href['chapter']), + to_id(href['section']), + href['signature']) + end + 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? + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 5e3f172cce..4381a2a495 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -35,26 +35,14 @@ def title entry.entry_title end - def storylines - Storyline.all_for_revision(entry.draft) - end - - def chapters - Chapter.all_for_revision(entry.draft) - end - - def sections - Section.all_for_revision(entry.draft) - end - - def content_elements - ContentElement.all_for_revision(entry.draft) + def content + @content ||= RevisionContent.new(entry.draft, include_hidden: true) end def copy_fragment_to(fragment_perma_id:, entry:, chapter:) SectionsCopy .new(source_entry: self.entry, destination_entry: entry) - .perform(chapters.find_by!(perma_id: fragment_perma_id).sections, chapter:) + .perform(fragment_chapter(fragment_perma_id).sections, chapter:) end def extract_fragment_from(entry:, chapter:, title: nil) @@ -69,6 +57,11 @@ def extract_fragment_from(entry:, chapter:, title: nil) private + def fragment_chapter(perma_id) + content.chapters.find { |chapter| chapter.perma_id == perma_id.to_i } || + raise(ActiveRecord::RecordNotFound) + end + def create_entry(locale:) Pageflow::DraftEntry.new( Pageflow::Entry.create!( @@ -84,7 +77,7 @@ def create_entry(locale:) end def create_fragment_chapter(chapter, title) - storyline = storylines.detect { |candidate| candidate.configuration['main'] } + storyline = content.main_storyline storyline.chapters.create!(position: (storyline.chapters.maximum(:position) || -1) + 1, configuration: fragment_configuration(chapter, title)) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb new file mode 100644 index 0000000000..5362225fa3 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linkable_entry.rb @@ -0,0 +1,39 @@ +module PageflowScrolled + # Entry that editors can link to. + # + # @api private + class LinkableEntry + attr_reader :entry + + delegate :id, :title, :type_name, :publication_state, :draft, to: :entry + + class << self + def all(scope, except:) + linkable(scope, except).map { |entry| new(entry) } + end + + def find(scope, id, except:) + entry = linkable(scope, except).find_by(id:, type_name: 'scrolled') + entry && new(entry) + end + + private + + def linkable(scope, except) + scope.where(fragment_library: nil).where.not(id: except.id) + end + end + + def initialize(entry) + @entry = entry + end + + def published_entry + Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) + end + + def content + @content ||= RevisionContent.new(draft, include_hidden: true) + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb new file mode 100644 index 0000000000..3159fc9121 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry.rb @@ -0,0 +1,37 @@ +module PageflowScrolled + # Entry that links point to along with the chapters links point to. + # + # @api private + class LinkedEntry + attr_reader :entry, :chapters + + delegate :id, :title, :publication_state, to: :entry + + class << self + def all(scope, chapter_perma_ids_by_entry_id) + return [] if chapter_perma_ids_by_entry_id.empty? + + entries = scope + .where(id: chapter_perma_ids_by_entry_id.keys) + .preload(:published_revision, :draft) + .order(:id) + .to_a + chapter_loader = ChapterLoader.new(entries) + + entries.map do |entry| + new(entry, + chapters: chapter_loader.for(entry, chapter_perma_ids_by_entry_id[entry.id])) + end + end + end + + def initialize(entry, chapters: []) + @entry = entry + @chapters = chapters + end + + def published_entry + Pageflow::PublishedEntry.new(entry, entry.published_revision || entry.draft) + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb new file mode 100644 index 0000000000..9e29678931 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter.rb @@ -0,0 +1,8 @@ +module PageflowScrolled + class LinkedEntry + # Chapter of a linked entry that links point to. + # + # @api private + Chapter = Struct.new(:perma_id, :slug, :preview_slug, :title, keyword_init: true) + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb new file mode 100644 index 0000000000..2450a1a610 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb @@ -0,0 +1,66 @@ +module PageflowScrolled + class LinkedEntry + # Load chapters of linked entries. Chapters of the published + # revisions and drafts of all entries are loaded at once. + # + # @api private + class ChapterLoader + def initialize(entries) + @entries = entries + @chapters_by_revision = {} + end + + def for(entry, perma_ids) + return [] if perma_ids.empty? + + published = revision_chapters(entry.published_revision) + draft = revision_chapters(entry.draft) + + perma_ids.filter_map do |perma_id| + linked_chapter(perma_id, published[perma_id], draft[perma_id]) + end + end + + private + + RevisionChapter = Struct.new(:slug, :title) + + def linked_chapter(perma_id, published, draft) + return unless published || draft + + Chapter.new(perma_id:, + slug: (published || draft).slug, + preview_slug: (draft || published).slug, + title: (draft || published).title) + end + + def revision_chapters(revision) + return {} unless revision + + @chapters_by_revision[revision.id] ||= begin + chapters = chapters_by_revision_id.fetch(revision.id, []) + slugs = ChapterSlugs.new(chapters).to_h + + chapters.to_h do |chapter| + [chapter.perma_id, + RevisionChapter.new(slugs[chapter.perma_id], chapter.configuration['title'])] + end + end + end + + def chapters_by_revision_id + @chapters_by_revision_id ||= + PageflowScrolled::Chapter + .joins(:storyline) + .where(pageflow_scrolled_storylines: {revision_id: revision_ids}) + .order(PageflowScrolled::Chapter::POSITION_ORDER) + .select('pageflow_scrolled_chapters.*', 'pageflow_scrolled_storylines.revision_id') + .group_by(&:revision_id) + end + + def revision_ids + @entries.flat_map { |entry| [entry.published_revision&.id, entry.draft&.id] }.compact + end + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb new file mode 100644 index 0000000000..e6cb530b8b --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -0,0 +1,69 @@ +module PageflowScrolled + # Storylines, chapters, sections and content elements of a revision. + # + # @api private + class RevisionContent + def initialize(revision, include_hidden: false, cutoff: false) + @revision = revision + @include_hidden = include_hidden + @cutoff = cutoff + end + + def storylines + @storylines ||= Storyline.all_for_revision(@revision).to_a + end + + def main_storyline + storylines.detect { |storyline| storyline.configuration['main'] } + end + + def chapters + @chapters ||= all_chapters.reject do |chapter| + !@include_hidden && only_hidden_sections?(chapter) + end + end + + def chapter_slug_for(chapter) + chapter_slugs[chapter.perma_id] + end + + def sections + @sections ||= (@cutoff ? sections_before_cutoff : all_sections).reject do |section| + !@include_hidden && section.configuration['hidden'] + end + end + + def content_elements + @content_elements ||= ContentElement.all_for_revision(@revision).where(section: sections).to_a + end + + private + + def only_hidden_sections?(chapter) + chapter_sections = sections_by_chapter_id.fetch(chapter.id, []) + + chapter_sections.any? && + chapter_sections.all? { |section| section.configuration['hidden'] } + end + + def sections_by_chapter_id + @sections_by_chapter_id ||= all_sections.group_by(&:chapter_id) + end + + def sections_before_cutoff + (storylines.first || Storyline.new).sections_before_cutoff_section + end + + def all_sections + @all_sections ||= Section.all_for_revision(@revision).to_a + end + + def all_chapters + @all_chapters ||= Chapter.all_for_revision(@revision).to_a + end + + def chapter_slugs + @chapter_slugs ||= ChapterSlugs.new(all_chapters).to_h + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder index ecb946e699..7e04cf3278 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_libraries/index.json.jbuilder @@ -5,35 +5,10 @@ json.array!(@libraries) do |library| json.title library.title json.collections do - json.storylines do - json.array!(library.storylines) do |storyline| - json.partial! 'pageflow_scrolled/storylines/storyline', storyline: - end - end + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', + content: library.content) - json.chapters do - json.array!(library.chapters) do |chapter| - json.partial! 'pageflow_scrolled/chapters/chapter', chapter: - end - end - - json.sections do - json.array!(library.sections) do |section| - json.partial! 'pageflow_scrolled/sections/section', section: - end - end - - json.content_elements do - json.array!(library.content_elements) do |content_element| - json.partial! 'pageflow_scrolled/content_elements/content_element', - content_element: - end - end - - json.linked_entries(scrolled_linked_entries(library.entry, - library.content_elements)) do |linked_entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) - end + scrolled_linked_entries_json_seed(json, library.entry, library.content.content_elements) files_json_seed(json, library.entry) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder new file mode 100644 index 0000000000..865caaf0ba --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/_linkable_entry.json.jbuilder @@ -0,0 +1,7 @@ +json.id linkable_entry.id +json.title linkable_entry.title +json.type_name linkable_entry.type_name +json.url social_share_entry_url(linkable_entry.published_entry) +json.preview_url main_app.preview_admin_entry_path(linkable_entry.entry) +json.publication_state linkable_entry.publication_state +json.signature PageflowScrolled::EntryLinkSignature.generate(linkable_entry.id) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder index 1ef16a5f11..9a3e19ba50 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/index.json.jbuilder @@ -1,5 +1,5 @@ json.key_format!(camelize: :lower) -json.array!(@entries) do |entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **scrolled_linked_entry(entry)) +json.array!(@linkable_entries) do |linkable_entry| + json.partial!('pageflow_scrolled/editor/linkable_entries/linkable_entry', linkable_entry:) end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder new file mode 100644 index 0000000000..d1893b1fe7 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/linkable_entries/show.json.jbuilder @@ -0,0 +1,25 @@ +json.key_format!(camelize: :lower) + +json.partial!('pageflow_scrolled/editor/linkable_entries/linkable_entry', + linkable_entry: @linkable_entry) + +json.chapters(@linkable_entry.content.chapters) do |chapter| + json.perma_id chapter.perma_id + json.title chapter.configuration['title'] + json.slug @linkable_entry.content.chapter_slug_for(chapter) + json.signature PageflowScrolled::EntryLinkSignature.generate(@linkable_entry.id, + chapter: chapter.perma_id) +end + +json.sections(@linkable_entry.content.sections) do |section| + json.perma_id section.perma_id + json.signature PageflowScrolled::EntryLinkSignature.generate(@linkable_entry.id, + section: section.perma_id) +end + +json.collections do + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', + content: @linkable_entry.content) + + files_json_seed(json, @linkable_entry.draft) +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder new file mode 100644 index 0000000000..8b248d58c8 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_content_collections.json.jbuilder @@ -0,0 +1,24 @@ +json.storylines do + json.array!(content.storylines) do |storyline| + json.partial!('pageflow_scrolled/storylines/storyline', storyline:) + end +end + +json.chapters do + json.array!(content.chapters) do |chapter| + json.partial!('pageflow_scrolled/chapters/chapter', chapter:) + json.chapter_slug content.chapter_slug_for(chapter) + end +end + +json.sections do + json.array!(content.sections) do |section| + json.partial!('pageflow_scrolled/sections/section', section:) + end +end + +json.content_elements do + json.array!(content.content_elements) do |content_element| + json.partial!('pageflow_scrolled/content_elements/content_element', content_element:) + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder index 8dd3d1964c..308e7d59dc 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_entry.json.jbuilder @@ -90,34 +90,9 @@ unless options[:skip_collections] end end - json.storylines do - json.array!(storylines) do |storyline| - json.partial! 'pageflow_scrolled/storylines/storyline', storyline: - end - end - - json.chapters do - json.array!(chapters) do |chapter| - json.partial! 'pageflow_scrolled/chapters/chapter', chapter: - end - end - - json.sections do - json.array!(sections) do |section| - json.partial! 'pageflow_scrolled/sections/section', section: - end - end - - json.content_elements do - json.array!(content_elements) do |content_element| - json.partial! 'pageflow_scrolled/content_elements/content_element', - content_element: - end - end + json.partial!('pageflow_scrolled/entry_json_seed/content_collections', content:) - json.linked_entries(linked_entries) do |linked_entry| - json.partial!('pageflow_scrolled/entry_json_seed/linked_entry', **linked_entry) - end + scrolled_linked_entries_json_seed(json, entry, content.content_elements + widgets) json.widgets do json.array!(widgets) do |widget| diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder deleted file mode 100644 index 73f10a07cb..0000000000 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entry_json_seed/_linked_entry.json.jbuilder +++ /dev/null @@ -1,10 +0,0 @@ -json.id entry.id -json.perma_id entry.id -json.title entry.title -json.url url - -if local_assigns.key?(:preview_url) - json.preview_url preview_url - json.publication_state publication_state - json.signature signature -end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder new file mode 100644 index 0000000000..341dc99f16 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_attributes.json.jbuilder @@ -0,0 +1,3 @@ +json.id linked_entry.id +json.perma_id linked_entry.id +json.url social_share_entry_url(linked_entry.published_entry) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder new file mode 100644 index 0000000000..7fe47e417b --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_chapter.json.jbuilder @@ -0,0 +1,2 @@ +json.perma_id chapter.perma_id +json.slug chapter.slug diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder new file mode 100644 index 0000000000..5d9275046b --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_linked_entry.json.jbuilder @@ -0,0 +1,7 @@ +json.partial!('pageflow_scrolled/linked_entries/attributes', linked_entry:) + +if linked_entry.chapters.any? + json.chapters(linked_entry.chapters) do |chapter| + json.partial!('pageflow_scrolled/linked_entries/chapter', chapter:) + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder new file mode 100644 index 0000000000..9f4a43ce2d --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/linked_entries/_preview_linked_entry.json.jbuilder @@ -0,0 +1,13 @@ +json.partial!('pageflow_scrolled/linked_entries/attributes', linked_entry:) + +json.title linked_entry.title +json.preview_url main_app.preview_admin_entry_path(linked_entry.entry) +json.publication_state linked_entry.publication_state + +if linked_entry.chapters.any? + json.chapters(linked_entry.chapters) do |chapter| + json.partial!('pageflow_scrolled/linked_entries/chapter', chapter:) + json.preview_slug chapter.preview_slug + json.title chapter.title + end +end diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 02d1dcf60a..08bebd63e4 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1488,6 +1488,7 @@ de: edit_header: Link bearbeiten file: Datei header: Link erstellen + inaccessible_entry: Dieser Link verweist auf den Beitrag „%{title}“, auf den du keinen Zugriff hast. Wähle einen anderen Beitrag aus, um den Link zu ändern. kinds: file: description: Lasse Leser eine Datei herunterladen @@ -1501,8 +1502,11 @@ de: url: description: Verlinke eine beliebige Seite im Internet title: Andere Website + link_to_beginning_of_entry: Stattdessen mit Beginn des Beitrags verlinken + link_to_chapter_or_section: Mit Kapitel oder Abschnitt des Beitrags verlinken no_file_selected: Keine Datei ausgewählt open_in_new_tab: In neuem Tab öffnen + outline_failed: Die Gliederung des Beitrags konnte nicht geladen werden. remove: Link entfernen save: Speichern select_in_sidebar: Datei auswählen @@ -2079,6 +2083,7 @@ de: publication_states: not_published: Noch nicht veröffentlicht published_with_password_protection: Passwortgeschützt + section_in: Abschnitt in unavailable_entry: Nicht verfügbarer Beitrag untitled_excursion: Unbenannter Exkurs visit_chapter: Gehe zu Kapitel @@ -2185,3 +2190,4 @@ de: open_draft_preview: Vorschau des Entwurfs öffnen open_preview: Vorschau öffnen password_protected: Passwortgeschützt + section_in: Abschnitt in diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index ca56f4bae9..1bf0083bf8 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1470,6 +1470,7 @@ en: edit_header: Edit link file: File header: Create link + inaccessible_entry: This link points to the story "%{title}" which you cannot access. Select another story to change the link. kinds: file: description: Let readers download a file @@ -1483,8 +1484,11 @@ en: url: description: Link to any page on the internet title: Other website + link_to_beginning_of_entry: Link to beginning of story instead + link_to_chapter_or_section: Link to chapter or section of story no_file_selected: No file selected open_in_new_tab: Open in new tab + outline_failed: The outline of the story could not be loaded. remove: Remove link save: Save select_in_sidebar: Select file @@ -1905,6 +1909,7 @@ en: publication_states: not_published: Not published yet published_with_password_protection: Password protected + section_in: Section in unavailable_entry: Unavailable story untitled_excursion: Untitled excursion visit_chapter: Go to chapter @@ -2011,3 +2016,4 @@ en: open_draft_preview: Open draft preview open_preview: Open preview password_protected: Password protected + section_in: Section in diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 4fecd43a8e..8cb3812120 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -1,7 +1,7 @@ PageflowScrolled::Engine.routes.draw do scope module: 'editor' do resources :fragment_libraries, only: [:index] - resources :linkable_entries, only: [:index] + resources :linkable_entries, only: [:index, :show] shallow do # Legacy path to support editor sessions that span the deploy that diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb deleted file mode 100644 index ae83c04b04..0000000000 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_references.rb +++ /dev/null @@ -1,44 +0,0 @@ -module PageflowScrolled - # Find signed links to other entries in configurations. - # - # @api private - class EntryLinkReferences - def initialize(configurations) - @configurations = configurations - end - - def entry_ids - signed_entry_ids - .uniq - .select { |entry_id, signature| EntryLinkSignature.valid?(entry_id, signature) } - .map(&:first) - .uniq - end - - private - - def signed_entry_ids - @configurations.each_with_object([]) do |configuration, result| - each_href(configuration) do |href| - entry_id = to_id(href['entry']) - result << [entry_id, href['signature']] if entry_id - end - 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? - end - end -end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb index cfc4104492..3d5de6e7ef 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/entry_link_signature.rb @@ -10,17 +10,28 @@ module EntryLinkSignature private_constant :PURPOSE class << self - def generate(entry_id) - verifier.generate(entry_id, purpose: PURPOSE) + def generate(entry_id, chapter: nil, section: nil) + verifier.generate(payload(entry_id, chapter:, section:), purpose: PURPOSE) end - def valid?(entry_id, signature) + def valid?(entry_id, signature, chapter: nil, section: nil) signature.is_a?(String) && - verifier.verified(signature, purpose: PURPOSE) == entry_id + verifier.verified(signature, purpose: PURPOSE) == + payload(entry_id, chapter:, section:) end private + def payload(entry_id, chapter:, section:) + if chapter + [entry_id, 'chapter', chapter] + elsif section + [entry_id, 'section', section] + else + entry_id + end + end + def verifier Rails.application.message_verifier('pageflow_scrolled_entry_link') end diff --git a/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb b/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb new file mode 100644 index 0000000000..fdc791955f --- /dev/null +++ b/entry_types/scrolled/lib/pageflow_scrolled/slugify.rb @@ -0,0 +1,38 @@ +module PageflowScrolled + # Port of the slugify function of the scrolled package. Both use + # the same char map. Slugs need to match, otherwise links to + # chapters computed on the server miss the anchors the frontend + # renders. + # + # @api private + module Slugify + WHITESPACE = "\t\n\v\f\r - ".freeze + + class << self + def call(string) + string + .unicode_normalize(:nfc) + .each_char + .map { |character| map(character).gsub(/[^a-zA-Z0-9_#{WHITESPACE}$*+~.()'"!\-:@]+/o, '') } + .join + .gsub(/\A[#{WHITESPACE}]+|[#{WHITESPACE}]+\z/o, '') + .gsub(/[#{WHITESPACE}-]+/o, '-') + .downcase + .gsub(/[^a-z0-9-]/, '') + .gsub(/-+/, '-') + end + + private + + def map(character) + char_map[character].presence || character + end + + def char_map + @char_map ||= JSON.parse( + File.read(Engine.root.join('package', 'config', 'slugCharMap.json')) + ) + end + end + end +end diff --git a/entry_types/scrolled/package/config/slugCharMap.json b/entry_types/scrolled/package/config/slugCharMap.json new file mode 100644 index 0000000000..4f07160150 --- /dev/null +++ b/entry_types/scrolled/package/config/slugCharMap.json @@ -0,0 +1,531 @@ +{ + "$": "dollar", + "%": "prozent", + "&": "und", + "<": "less", + ">": "greater", + "|": "oder", + "¢": "cent", + "£": "pound", + "¤": "currency", + "¥": "yen", + "©": "(c)", + "ª": "a", + "®": "(r)", + "º": "o", + "À": "A", + "Á": "A", + "Â": "A", + "Ã": "A", + "Ä": "AE", + "Å": "A", + "Æ": "AE", + "Ç": "C", + "È": "E", + "É": "E", + "Ê": "E", + "Ë": "E", + "Ì": "I", + "Í": "I", + "Î": "I", + "Ï": "I", + "Ð": "D", + "Ñ": "N", + "Ò": "O", + "Ó": "O", + "Ô": "O", + "Õ": "O", + "Ö": "OE", + "Ø": "O", + "Ù": "U", + "Ú": "U", + "Û": "U", + "Ü": "UE", + "Ý": "Y", + "Þ": "TH", + "ß": "ss", + "à": "a", + "á": "a", + "â": "a", + "ã": "a", + "ä": "ae", + "å": "a", + "æ": "ae", + "ç": "c", + "è": "e", + "é": "e", + "ê": "e", + "ë": "e", + "ì": "i", + "í": "i", + "î": "i", + "ï": "i", + "ð": "d", + "ñ": "n", + "ò": "o", + "ó": "o", + "ô": "o", + "õ": "o", + "ö": "oe", + "ø": "o", + "ù": "u", + "ú": "u", + "û": "u", + "ü": "ue", + "ý": "y", + "þ": "th", + "ÿ": "y", + "Ā": "A", + "ā": "a", + "Ă": "A", + "ă": "a", + "Ą": "A", + "ą": "a", + "Ć": "C", + "ć": "c", + "Č": "C", + "č": "c", + "Ď": "D", + "ď": "d", + "Đ": "DJ", + "đ": "dj", + "Ē": "E", + "ē": "e", + "Ė": "E", + "ė": "e", + "Ę": "e", + "ę": "e", + "Ě": "E", + "ě": "e", + "Ğ": "G", + "ğ": "g", + "Ģ": "G", + "ģ": "g", + "Ĩ": "I", + "ĩ": "i", + "Ī": "i", + "ī": "i", + "Į": "I", + "į": "i", + "İ": "I", + "ı": "i", + "Ķ": "k", + "ķ": "k", + "Ļ": "L", + "ļ": "l", + "Ľ": "L", + "ľ": "l", + "Ł": "L", + "ł": "l", + "Ń": "N", + "ń": "n", + "Ņ": "N", + "ņ": "n", + "Ň": "N", + "ň": "n", + "Ō": "O", + "ō": "o", + "Ő": "O", + "ő": "o", + "Œ": "OE", + "œ": "oe", + "Ŕ": "R", + "ŕ": "r", + "Ř": "R", + "ř": "r", + "Ś": "S", + "ś": "s", + "Ş": "S", + "ş": "s", + "Š": "S", + "š": "s", + "Ţ": "T", + "ţ": "t", + "Ť": "T", + "ť": "t", + "Ũ": "U", + "ũ": "u", + "Ū": "u", + "ū": "u", + "Ů": "U", + "ů": "u", + "Ű": "U", + "ű": "u", + "Ų": "U", + "ų": "u", + "Ŵ": "W", + "ŵ": "w", + "Ŷ": "Y", + "ŷ": "y", + "Ÿ": "Y", + "Ź": "Z", + "ź": "z", + "Ż": "Z", + "ż": "z", + "Ž": "Z", + "ž": "z", + "Ə": "E", + "ƒ": "f", + "Ơ": "O", + "ơ": "o", + "Ư": "U", + "ư": "u", + "Lj": "LJ", + "lj": "lj", + "Nj": "NJ", + "nj": "nj", + "Ș": "S", + "ș": "s", + "Ț": "T", + "ț": "t", + "ə": "e", + "˚": "o", + "Ά": "A", + "Έ": "E", + "Ή": "H", + "Ί": "I", + "Ό": "O", + "Ύ": "Y", + "Ώ": "W", + "ΐ": "i", + "Α": "A", + "Β": "B", + "Γ": "G", + "Δ": "D", + "Ε": "E", + "Ζ": "Z", + "Η": "H", + "Θ": "8", + "Ι": "I", + "Κ": "K", + "Λ": "L", + "Μ": "M", + "Ν": "N", + "Ξ": "3", + "Ο": "O", + "Π": "P", + "Ρ": "R", + "Σ": "S", + "Τ": "T", + "Υ": "Y", + "Φ": "F", + "Χ": "X", + "Ψ": "PS", + "Ω": "W", + "Ϊ": "I", + "Ϋ": "Y", + "ά": "a", + "έ": "e", + "ή": "h", + "ί": "i", + "ΰ": "y", + "α": "a", + "β": "b", + "γ": "g", + "δ": "d", + "ε": "e", + "ζ": "z", + "η": "h", + "θ": "8", + "ι": "i", + "κ": "k", + "λ": "l", + "μ": "m", + "ν": "n", + "ξ": "3", + "ο": "o", + "π": "p", + "ρ": "r", + "ς": "s", + "σ": "s", + "τ": "t", + "υ": "y", + "φ": "f", + "χ": "x", + "ψ": "ps", + "ω": "w", + "ϊ": "i", + "ϋ": "y", + "ό": "o", + "ύ": "y", + "ώ": "w", + "Ё": "Yo", + "Ђ": "DJ", + "Є": "Ye", + "І": "I", + "Ї": "Yi", + "Ј": "J", + "Љ": "LJ", + "Њ": "NJ", + "Ћ": "C", + "Џ": "DZ", + "А": "A", + "Б": "B", + "В": "V", + "Г": "G", + "Д": "D", + "Е": "E", + "Ж": "Zh", + "З": "Z", + "И": "I", + "Й": "J", + "К": "K", + "Л": "L", + "М": "M", + "Н": "N", + "О": "O", + "П": "P", + "Р": "R", + "С": "S", + "Т": "T", + "У": "U", + "Ф": "F", + "Х": "H", + "Ц": "C", + "Ч": "Ch", + "Ш": "Sh", + "Щ": "Sh", + "Ъ": "U", + "Ы": "Y", + "Ь": "", + "Э": "E", + "Ю": "Yu", + "Я": "Ya", + "а": "a", + "б": "b", + "в": "v", + "г": "g", + "д": "d", + "е": "e", + "ж": "zh", + "з": "z", + "и": "i", + "й": "j", + "к": "k", + "л": "l", + "м": "m", + "н": "n", + "о": "o", + "п": "p", + "р": "r", + "с": "s", + "т": "t", + "у": "u", + "ф": "f", + "х": "h", + "ц": "c", + "ч": "ch", + "ш": "sh", + "щ": "sh", + "ъ": "u", + "ы": "y", + "ь": "", + "э": "e", + "ю": "yu", + "я": "ya", + "ё": "yo", + "ђ": "dj", + "є": "ye", + "і": "i", + "ї": "yi", + "ј": "j", + "љ": "lj", + "њ": "nj", + "ћ": "c", + "ѝ": "u", + "џ": "dz", + "Ґ": "G", + "ґ": "g", + "Ғ": "GH", + "ғ": "gh", + "Қ": "KH", + "қ": "kh", + "Ң": "NG", + "ң": "ng", + "Ү": "UE", + "ү": "ue", + "Ұ": "U", + "ұ": "u", + "Һ": "H", + "һ": "h", + "Ә": "AE", + "ә": "ae", + "Ө": "OE", + "ө": "oe", + "฿": "baht", + "ა": "a", + "ბ": "b", + "გ": "g", + "დ": "d", + "ე": "e", + "ვ": "v", + "ზ": "z", + "თ": "t", + "ი": "i", + "კ": "k", + "ლ": "l", + "მ": "m", + "ნ": "n", + "ო": "o", + "პ": "p", + "ჟ": "zh", + "რ": "r", + "ს": "s", + "ტ": "t", + "უ": "u", + "ფ": "f", + "ქ": "k", + "ღ": "gh", + "ყ": "q", + "შ": "sh", + "ჩ": "ch", + "ც": "ts", + "ძ": "dz", + "წ": "ts", + "ჭ": "ch", + "ხ": "kh", + "ჯ": "j", + "ჰ": "h", + "Ẁ": "W", + "ẁ": "w", + "Ẃ": "W", + "ẃ": "w", + "Ẅ": "W", + "ẅ": "w", + "ẞ": "SS", + "Ạ": "A", + "ạ": "a", + "Ả": "A", + "ả": "a", + "Ấ": "A", + "ấ": "a", + "Ầ": "A", + "ầ": "a", + "Ẩ": "A", + "ẩ": "a", + "Ẫ": "A", + "ẫ": "a", + "Ậ": "A", + "ậ": "a", + "Ắ": "A", + "ắ": "a", + "Ằ": "A", + "ằ": "a", + "Ẳ": "A", + "ẳ": "a", + "Ẵ": "A", + "ẵ": "a", + "Ặ": "A", + "ặ": "a", + "Ẹ": "E", + "ẹ": "e", + "Ẻ": "E", + "ẻ": "e", + "Ẽ": "E", + "ẽ": "e", + "Ế": "E", + "ế": "e", + "Ề": "E", + "ề": "e", + "Ể": "E", + "ể": "e", + "Ễ": "E", + "ễ": "e", + "Ệ": "E", + "ệ": "e", + "Ỉ": "I", + "ỉ": "i", + "Ị": "I", + "ị": "i", + "Ọ": "O", + "ọ": "o", + "Ỏ": "O", + "ỏ": "o", + "Ố": "O", + "ố": "o", + "Ồ": "O", + "ồ": "o", + "Ổ": "O", + "ổ": "o", + "Ỗ": "O", + "ỗ": "o", + "Ộ": "O", + "ộ": "o", + "Ớ": "O", + "ớ": "o", + "Ờ": "O", + "ờ": "o", + "Ở": "O", + "ở": "o", + "Ỡ": "O", + "ỡ": "o", + "Ợ": "O", + "ợ": "o", + "Ụ": "U", + "ụ": "u", + "Ủ": "U", + "ủ": "u", + "Ứ": "U", + "ứ": "u", + "Ừ": "U", + "ừ": "u", + "Ử": "U", + "ử": "u", + "Ữ": "U", + "ữ": "u", + "Ự": "U", + "ự": "u", + "Ỳ": "Y", + "ỳ": "y", + "Ỵ": "Y", + "ỵ": "y", + "Ỷ": "Y", + "ỷ": "y", + "Ỹ": "Y", + "ỹ": "y", + "–": "-", + "‘": "'", + "’": "'", + "“": "\"", + "”": "\"", + "„": "\"", + "†": "+", + "•": "*", + "…": "...", + "₠": "ecu", + "₢": "cruzeiro", + "₣": "french franc", + "₤": "lira", + "₥": "mill", + "₦": "naira", + "₧": "peseta", + "₨": "rupee", + "₩": "won", + "₪": "new shequel", + "₫": "dong", + "€": "euro", + "₭": "kip", + "₮": "tugrik", + "₯": "drachma", + "₰": "penny", + "₱": "peso", + "₲": "guarani", + "₳": "austral", + "₴": "hryvnia", + "₵": "cedi", + "₸": "kazakhstani tenge", + "₹": "indian rupee", + "₺": "turkish lira", + "₽": "russian ruble", + "₿": "bitcoin", + "℠": "sm", + "™": "tm", + "∂": "d", + "∆": "delta", + "∑": "summe", + "∞": "unendlich", + "♥": "liebe", + "元": "yuan", + "円": "yen", + "﷼": "rial" +} diff --git a/entry_types/scrolled/package/package.json b/entry_types/scrolled/package/package.json index 1b648e6ad9..e06977846b 100644 --- a/entry_types/scrolled/package/package.json +++ b/entry_types/scrolled/package/package.json @@ -35,7 +35,6 @@ "slate": "^0.57.3", "slate-history": "^0.57.3", "slate-react": "^0.57.3", - "slugify": "^1.4.6", "striptags": "^3.2.0", "use-context-selector": "^1.2.11", "video.js": "https://github.com/tf/video.js#pageflow-scrolled-8", diff --git a/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js new file mode 100644 index 0000000000..a246053c90 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/collections/LinkedEntriesCollection-spec.js @@ -0,0 +1,85 @@ +import {LinkedEntriesCollection} from 'editor/collections/LinkedEntriesCollection'; +import Backbone from 'backbone'; + +describe('LinkedEntriesCollection', () => { + describe('#addLinkableEntry', () => { + it('adds attributes needed to render links', () => { + const linkedEntries = new LinkedEntriesCollection(); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, title: 'Other', url: '/other', previewUrl: '/admin/other', + publicationState: 'not_published', signature: 'signed' + })); + + expect(linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'Other', url: '/other', previewUrl: '/admin/other', + publicationState: 'not_published' + }); + }); + + it('adds chapter of linkable entry', () => { + const linkedEntries = new LinkedEntriesCollection([{id: 5, chapters: []}]); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', signature: 'signed'}] + }), {chapter: 20}); + + expect(linkedEntries.get(5).get('chapters')).toEqual([ + {permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'intro'} + ]); + }); + + it('updates existing linked entry', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, title: 'Seed', publicationState: 'not_published'} + ]); + + linkedEntries.addLinkableEntry(new Backbone.Model({ + id: 5, title: 'Other', publicationState: 'published_without_password_protection' + })); + + expect(linkedEntries.get(5).attributes).toMatchObject({ + title: 'Other', publicationState: 'published_without_password_protection' + }); + }); + }); + + describe('#mergeLinkedEntries', () => { + it('adds new linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([{id: 5}]); + + linkedEntries.mergeLinkedEntries([{id: 9, title: 'Other'}]); + + expect(linkedEntries.pluck('id')).toEqual([5, 9]); + }); + + it('adds chapters to existing linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, chapters: [{permaId: 20, slug: 'intro'}]} + ]); + + linkedEntries.mergeLinkedEntries([{ + id: 5, + chapters: [{permaId: 20, slug: 'other'}, {permaId: 30, slug: 'outro'}] + }]); + + expect(linkedEntries.get(5).get('chapters')).toEqual([ + {permaId: 20, slug: 'intro'}, + {permaId: 30, slug: 'outro'} + ]); + }); + + it('updates attributes of existing linked entries', () => { + const linkedEntries = new LinkedEntriesCollection([ + {id: 5, title: 'Old', chapters: [{permaId: 20, slug: 'intro'}]} + ]); + + linkedEntries.mergeLinkedEntries([{id: 5, title: 'New'}]); + + expect(linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'New', chapters: [{permaId: 20, slug: 'intro'}] + }); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js index 70c887a327..323874860e 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -341,7 +341,7 @@ describe('Chapter', () => { expect(chapter.sections.get(101).contentElements.pluck('id')).toEqual([1000]); }); - it('makes entries linked from fragment available as linkable entries', () => { + it('adds entries linked from fragment to linked entries', () => { const {entry, server} = testContext; const chapter = entry.chapters.first(); const fragmentWithLinks = new Backbone.Model({ @@ -358,13 +358,13 @@ describe('Chapter', () => { contentElements: []} ]); - expect(entry.linkableEntries.get(9).get('title')).toEqual('Linked story'); + expect(entry.linkedEntries.get(9).get('title')).toEqual('Linked story'); }); - it('keeps existing linkable entries when adding entries linked from fragment', () => { + it('keeps existing linked entries when adding entries linked from fragment', () => { const {entry, server} = testContext; const chapter = entry.chapters.first(); - entry.linkableEntries.add({id: 5, title: 'Other story'}); + entry.linkedEntries.add({id: 5, title: 'Other story'}); const fragmentWithLinks = new Backbone.Model({ libraryId: 3, chapterPermaId: 700, @@ -377,7 +377,31 @@ describe('Chapter', () => { contentElements: []} ]); - expect(entry.linkableEntries.pluck('id')).toEqual([5, 9]); + expect(entry.linkedEntries.pluck('id')).toEqual([5, 9]); + }); + + it('merges chapters of entries linked from fragment', () => { + const {entry, server} = testContext; + const chapter = entry.chapters.first(); + entry.linkedEntries.add({id: 9, chapters: [{permaId: 20, slug: 'intro'}]}); + const fragmentWithLinks = new Backbone.Model({ + libraryId: 3, + chapterPermaId: 700, + collections: { + linkedEntries: [{id: 9, chapters: [{permaId: 30, slug: 'outro'}]}] + } + }); + + chapter.insertFragment(fragmentWithLinks); + respond(server, [ + {id: 101, permaId: 501, chapterId: 10, position: 1, configuration: {}, + contentElements: []} + ]); + + expect(entry.linkedEntries.get(9).get('chapters')).toEqual([ + {permaId: 20, slug: 'intro'}, + {permaId: 30, slug: 'outro'} + ]); }); it('refreshes files before adding sections', () => { diff --git a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js new file mode 100644 index 0000000000..b2fa8423b8 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js @@ -0,0 +1,176 @@ +import {LinkableEntriesCollection} from 'editor/collections/LinkableEntriesCollection'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView'; + +import {renderBackboneView, setupGlobals} from 'pageflow/testHelpers'; +import {act} from '@testing-library/react'; +import 'contentElements/heading/frontend'; +import {factories, normalizeSeed, useFakeXhr} from 'support'; + +describe('LinkableEntry', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + useFakeXhr(() => testContext); + + setupGlobals({ + entry: () => factories.entry(ScrolledEntry, {id: 1}) + }); + + function fetchOutline(response, {entry} = {}) { + entry = entry || factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Other'}]); + + const promise = linkableEntries.get(5).fetchOutline({entry}); + + testContext.server.respondWith( + 'GET', /linkable_entries\/5/, [200, {'Content-Type': 'application/json'}, + JSON.stringify(response)] + ); + testContext.server.respond(); + + return promise; + } + + function structure(collections) { + return { + id: 5, + title: 'Other', + url: '/other', + chapters: [], + sections: [], + collections: { + storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + chapters: [], + sections: [], + contentElements: [], + imageFiles: [], + ...collections + } + }; + } + + it('updates title, urls, chapters and sections', async () => { + const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Old'}]); + const linkableEntry = linkableEntries.get(5); + + const promise = linkableEntry.fetchOutline({entry}); + testContext.server.respondWith( + 'GET', /linkable_entries\/5/, [200, {'Content-Type': 'application/json'}, + JSON.stringify({ + ...structure(), + title: 'Other', + url: '/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'not_published', + chapters: [{permaId: 20, slug: 'intro'}], + sections: [{permaId: 30, state: 'not_published'}] + })] + ); + testContext.server.respond(); + await promise; + + expect(linkableEntry.pick('title', 'url', 'previewUrl', 'publicationState', + 'chapters', 'sections')).toEqual({ + title: 'Other', + url: '/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'not_published', + chapters: [{permaId: 20, slug: 'intro'}], + sections: [{permaId: 30, state: 'not_published'}] + }); + }); + + it('exposes signatures of chapters and sections', () => { + const linkableEntries = new LinkableEntriesCollection([{ + id: 5, + chapters: [{permaId: 20, signature: 'signed-20'}], + sections: [{permaId: 30, signature: 'signed-30'}] + }]); + + expect(linkableEntries.get(5).chapterLinkSignature(20)).toEqual('signed-20'); + expect(linkableEntries.get(5).sectionLinkSignature(30)).toEqual('signed-30'); + }); + + it('rejects when outline can not be fetched', async () => { + const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); + const linkableEntries = new LinkableEntriesCollection([{id: 5, title: 'Other'}]); + + const promise = linkableEntries.get(5).fetchOutline({entry}); + testContext.server.respondWith('GET', /linkable_entries\/5/, [404, {}, '']); + testContext.server.respond(); + + await expect(promise).rejects.toBeDefined(); + }); + + it('builds chapters and sections of storylines', async () => { + const outline = await fetchOutline(structure({ + chapters: [{id: 1, permaId: 20, storylineId: 1, configuration: {title: 'Intro'}}], + sections: [{id: 2, permaId: 30, chapterId: 1}] + })); + + const chapter = outline.storylines.main().chapters.first(); + + expect(chapter.configuration.get('title')).toEqual('Intro'); + expect(chapter.sections.first().get('permaId')).toEqual(30); + }); + + it('merges collections into seed of current entry', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + themeOptions: {colors: {accent: '#f00'}}, + sections: [{id: 100, permaId: 1000}] + }) + }); + + const outline = await fetchOutline(structure({ + sections: [{id: 2, permaId: 30, chapterId: 1}] + }), {entry}); + + expect(outline.scrolledSeed.config.theme.options.colors.accent).toEqual('#f00'); + expect(outline.scrolledSeed.collections.sections).toEqual([ + {id: 2, permaId: 30, chapterId: 1} + ]); + }); + + it('does not render widgets of current entry in thumbnails', async () => { + const outline = await fetchOutline(structure()); + + expect(outline.scrolledSeed.collections.widgets).toEqual([]); + }); + + it('is read only', async () => { + const outline = await fetchOutline(structure()); + + expect(outline.readOnly).toEqual(true); + expect(outline.isCurrentSectionInExcursion()).toEqual(false); + }); + + it('can be rendered with section thumbnails in outline view', async () => { + const outline = await fetchOutline(structure({ + chapters: [{id: 1, permaId: 20, storylineId: 1, configuration: {}}], + sections: [{id: 2, permaId: 30, chapterId: 1, configuration: {}}], + contentElements: [{ + id: 3, permaId: 40, sectionId: 2, typeName: 'heading', + configuration: {children: 'Other heading'} + }] + })); + jest.useFakeTimers(); + + const view = new SelectableEntryOutlineView({ + entry: outline, + onSelectChapter: jest.fn(), + onSelectSection: jest.fn() + }); + const {findByText} = renderBackboneView(view); + act(() => jest.runAllTimers()); + jest.useRealTimers(); + + expect(await findByText('Other heading')).not.toBeNull(); + view.close(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js new file mode 100644 index 0000000000..00456d69e9 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js @@ -0,0 +1,74 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories} from 'pageflow/testHelpers'; +import {normalizeSeed} from 'support'; + +describe('ScrolledEntry', () => { + describe('chapter slugs', () => { + function createEntry(chapters, storylines) { + return factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({chapters, storylines}) + }); + } + + it('sets chapter slugs on chapters', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('intro'); + }); + + it('orders chapters by storyline position like the server', () => { + const entry = createEntry([ + {id: 1, permaId: 100, storylineId: 2, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 200, storylineId: 1, position: 1, configuration: {title: 'Intro'}} + ], [ + {id: 1, position: 0, configuration: {main: true}}, + {id: 2, position: 1, configuration: {}} + ]); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('intro-100'); + }); + + it('updates chapter slug when title changes', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + entry.chapters.get(1).configuration.set('title', 'Outro', {autoSave: false}); + + expect(entry.chapters.get(1).get('chapterSlug')).toEqual('outro'); + }); + + it('updates slugs of other chapters with same title', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 200, position: 1, configuration: {title: 'Intro'}} + ]); + + entry.chapters.get(1).configuration.set('title', 'Other', {autoSave: false}); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + }); + + it('updates slugs when chapters are removed', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 200, position: 1, configuration: {title: 'Intro'}} + ]); + + entry.chapters.remove(entry.chapters.get(1)); + + expect(entry.chapters.get(2).get('chapterSlug')).toEqual('intro'); + }); + + it('does not include chapter slugs when saving chapters', () => { + const entry = createEntry([ + {id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}} + ]); + + expect(entry.chapters.get(1).toJSON()).not.toHaveProperty('chapterSlug'); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js b/entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js similarity index 75% rename from entry_types/scrolled/package/spec/shared/chapterSlug-spec.js rename to entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js index 41810154a2..33249503ea 100644 --- a/entry_types/scrolled/package/spec/shared/chapterSlug-spec.js +++ b/entry_types/scrolled/package/spec/editor/utils/chapterSlug-spec.js @@ -1,4 +1,4 @@ -import {getChapterSlugs} from 'shared/chapterSlug'; +import {getChapterSlugs} from 'editor/utils/chapterSlug'; describe('getChapterSlugs', () => { it('returns slugified title', () => { @@ -25,6 +25,14 @@ describe('getChapterSlugs', () => { expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); }); + it('returns chapter-permaId for title without characters allowed in slugs', () => { + const chapters = [ + {configuration: {title: '中文标题'}, permaId: 100} + ]; + + expect(getChapterSlugs(chapters)[100]).toBe('chapter-100'); + }); + it('appends permaId if slug would not be unique', () => { const chapters = [ {configuration: {title: 'Same Title'}, permaId: 100}, @@ -42,6 +50,6 @@ describe('getChapterSlugs', () => { {configuration: {title: 'Über uns & mehr!'}, permaId: 100} ]; - expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-and-mehr'); + expect(getChapterSlugs(chapters)[100]).toBe('ueber-uns-und-mehr'); }); }); diff --git a/entry_types/scrolled/package/spec/editor/utils/slugify-spec.js b/entry_types/scrolled/package/spec/editor/utils/slugify-spec.js new file mode 100644 index 0000000000..b87927b888 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/utils/slugify-spec.js @@ -0,0 +1,11 @@ +import {slugify} from 'editor/utils/slugify'; + +import cases from '../../fixtures/slugify.json'; + +describe('slugify', () => { + cases.forEach(({title, slug}) => { + it(`turns ${JSON.stringify(title)} into ${JSON.stringify(slug)}`, () => { + expect(slugify(title)).toBe(slug); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js index e732253776..6dd91aa732 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -27,15 +27,30 @@ describe('SelectLinkDestinationDialogView', () => { useFakeXhr(() => testContext); + function respondWithLinkableEntries(linkableEntries) { + testContext.server.respondWith( + 'GET', /linkable_entries$/, [200, {'Content-Type': 'application/json'}, + JSON.stringify(linkableEntries)] + ); + testContext.server.respond(); + } + setupGlobals({ entry: () => factories.entry(ScrolledEntry, {id: 1}) }); useFakeTranslations({ + 'pageflow_scrolled.editor.select_link_destination.link_to_chapter_or_section': + 'Link to chapter or section of story', + 'pageflow_scrolled.editor.select_link_destination.link_to_beginning_of_entry': + 'Link to beginning of story instead', + 'pageflow_scrolled.editor.select_link_destination.outline_failed': + 'Outline could not be loaded', 'pageflow_scrolled.editor.entry_select.show_entries': 'Show stories', 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', 'pageflow_scrolled.editor.select_link_destination.url': 'URL', 'pageflow_scrolled.editor.select_link_destination.story': 'Story', + 'pageflow_scrolled.editor.select_link_destination.inaccessible_entry': 'No access to %{title}', 'pageflow_scrolled.editor.select_link_destination.kinds.this_entry.title': 'Chapter or section of this story', 'pageflow_scrolled.editor.select_link_destination.kinds.other_entry.title': 'Another story', 'pageflow_scrolled.editor.select_link_destination.kinds.file.title': 'File download', @@ -153,15 +168,39 @@ describe('SelectLinkDestinationDialogView', () => { expect(getByRole('radio', {name: /Chapter or section of this story/})).toBeChecked(); }); - describe('with other entry selected', () => { - function respondWithLinkableEntries(linkableEntries) { - testContext.server.respondWith( - 'GET', /linkable_entries$/, [200, {'Content-Type': 'application/json'}, - JSON.stringify(linkableEntries)] - ); - testContext.server.respond(); - } + function respondWithOutline(id, { + chapters = [], sections = [], signatures = {}, chapterDestinations + } = {}) { + testContext.server.respondWith( + 'GET', new RegExp(`linkable_entries/${id}$`), [ + 200, {'Content-Type': 'application/json'}, + JSON.stringify({ + id, + permaId: id, + title: 'Other story', + url: '/other-story', + chapters: chapterDestinations || destinations(signatures.chapters), + sections: destinations(signatures.sections), + collections: { + storylines: [{id: 1, permaId: 1, configuration: {main: true}}], + chapters, + sections, + contentElements: [] + } + }) + ] + ); + testContext.server.respond(); + } + + function destinations(signatures = {}) { + return Object.entries(signatures).map(([permaId, signature]) => ({ + permaId: Number(permaId), + signature + })); + } + describe('with other entry selected', () => { function renderDialog({seed, onSelect = jest.fn()} = {}) { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed(seed) @@ -171,9 +210,11 @@ describe('SelectLinkDestinationDialogView', () => { return {entry, ...render(view)}; } - async function selectOtherEntry(user, {getByRole}) { + async function selectOtherEntry(user, {getByRole}, {moreEntries = []} = {}) { await user.click(getByRole('radio', {name: /Another story/})); - respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', typeName: 'scrolled', signature: 'signed-5' + }, ...moreEntries]); await user.click(getByRole('button', {name: /Show stories/})); await user.click(getByRole('option', {name: 'Other story'})); } @@ -194,15 +235,17 @@ describe('SelectLinkDestinationDialogView', () => { expect(testContext.requests).toHaveLength(1); }); - it('keeps linked entries from seed when fetching entries', async () => { + it('only offers entries returned by server', async () => { const user = userEvent.setup(); const result = renderDialog({ seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]} }); await user.click(result.getByRole('radio', {name: /Another story/})); respondWithLinkableEntries([{id: 5, title: 'Other story'}]); + await user.click(result.getByRole('button', {name: /Show stories/})); - expect(result.entry.linkableEntries.pluck('id')).toEqual([9, 5]); + expect(result.getAllByRole('option').map(option => option.textContent)) + .toEqual(['Other story']); }); it('disables create button until story is selected', async () => { @@ -223,19 +266,186 @@ describe('SelectLinkDestinationDialogView', () => { expect(listener).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); }); - it('links to entry from seed with signature', async () => { + it('makes linked entry available to render created link', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await user.click(result.getByRole('radio', {name: /Another story/})); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', url: '/other-story', previewUrl: '/admin/other-story', + publicationState: 'not_published', signature: 'signed-5' + }]); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: /Other story/})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(result.entry.linkedEntries.get(5).attributes).toEqual({ + id: 5, title: 'Other story', url: '/other-story', previewUrl: '/admin/other-story', + publicationState: 'not_published' + }); + }); + + async function expandOutline(user, {getByRole}, outline) { + await user.click(getByRole('button', {name: 'Link to chapter or section of story'})); + respondWithOutline(5, outline); + } + + it('allows selecting chapter of other entry', async () => { const listener = jest.fn(); const user = userEvent.setup(); - const result = renderDialog({ - seed: {linkedEntries: [{id: 9, title: 'Linked story', signature: 'signed-9'}]}, - onSelect: listener + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + + it('collapses outline when selecting entry without outline', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result, { + moreEntries: [{id: 6, title: 'Paged story', typeName: 'paged', signature: 'signed-6'}] + }); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}] }); - await user.click(result.getByRole('radio', {name: /Another story/})); await user.click(result.getByRole('button', {name: /Show stories/})); - await user.click(result.getByRole('option', {name: 'Linked story'})); + await user.click(result.getByRole('option', {name: 'Paged story'})); await user.click(result.getByRole('button', {name: 'Create link'})); - expect(listener).toHaveBeenCalledWith({href: {entry: 9, signature: 'signed-9'}}); + expect(listener).toHaveBeenCalledWith({href: {entry: 6, signature: 'signed-6'}}); + expect(testContext.requests.map(request => request.url)) + .not.toContainEqual(expect.stringMatching(/linkable_entries\/6$/)); + }); + + it('displays outline only once when expanding again while loading', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await user.click(result.getByRole('button', {name: 'Link to chapter or section of story'})); + await user.click(result.getByRole('button', {name: 'Link to beginning of story instead'})); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}] + }); + + expect(await result.findAllByText('Other chapter')).toHaveLength(1); + }); + + it('keeps selected chapter when selecting same entry again', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Other story'})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + + it('makes linked chapter available to render created link', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + chapterDestinations: [{permaId: 70, title: 'Other chapter', slug: 'other-chapter', + signature: 'signed-chapter-70'}] + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(result.entry.linkedEntries.get(5).get('chapters')).toEqual([{ + permaId: 70, title: 'Other chapter', slug: 'other-chapter', previewSlug: 'other-chapter' + }]); + }); + + it('allows selecting section of other entry', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, configuration: {}}], + sections: [{id: 8, permaId: 80, chapterId: 7, configuration: {}}], + signatures: {sections: {80: 'signed-section-80'}} + }); + await user.click(await result.findByTitle('Select section')); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({ + href: {entry: 5, section: 80, signature: 'signed-section-80'} + }); + }); + + it('disables create button while outline is expanded without selection', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await expandOutline(user, result); + + expect(result.getByRole('button', {name: 'Create link'})).toBeDisabled(); + }); + + it('allows switching back to linking to beginning of story', async () => { + const listener = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({onSelect: listener}); + await selectOtherEntry(user, result); + await expandOutline(user, result, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + await user.click(await result.findByText('Other chapter')); + await user.click(result.getByRole('button', {name: 'Link to beginning of story instead'})); + await user.click(result.getByRole('button', {name: 'Create link'})); + + expect(listener).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); + }); + + it('does not offer linking to chapters of entries of other types', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await user.click(result.getByRole('radio', {name: /Another story/})); + respondWithLinkableEntries([{ + id: 6, title: 'Paged story', typeName: 'paged', signature: 'signed-6' + }]); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Paged story'})); + + expect(result.queryByRole('button', {name: 'Link to chapter or section of story'})) + .toBeNull(); + }); + + it('displays error if outline can not be loaded', async () => { + const user = userEvent.setup(); + const result = renderDialog(); + await selectOtherEntry(user, result); + await user.click(result.getByRole('button', {name: 'Link to chapter or section of story'})); + testContext.server.respondWith('GET', /linkable_entries\/5$/, [404, {}, '']); + testContext.server.respond(); + + expect(await result.findByText('Outline could not be loaded')).toBeInTheDocument(); }); it('allows opening other entry in new tab', async () => { @@ -470,6 +680,7 @@ describe('SelectLinkDestinationDialogView', () => { }); it('preselects chapter of this entry', async () => { + Element.prototype.scrollIntoView = jest.fn(); const onSelect = jest.fn(); const user = userEvent.setup(); const result = renderDialog({href: {chapter: 10}}, { @@ -478,10 +689,12 @@ describe('SelectLinkDestinationDialogView', () => { }); expect(result.getByRole('radio', {name: /Chapter or section of this story/})).toBeChecked(); + expect(result.getByText('Intro').closest('li')).toHaveAttribute('aria-current', 'true'); await user.click(result.getByRole('button', {name: 'Save'})); expect(onSelect).toHaveBeenCalledWith({href: {chapter: 10}}); + delete Element.prototype.scrollIntoView; }); it('preselects file', async () => { @@ -513,6 +726,8 @@ describe('SelectLinkDestinationDialogView', () => { onSelect }); + respondWithLinkableEntries([{id: 9, title: 'Linked story', signature: 'other'}]); + expect(result.getByRole('radio', {name: /Another story/})).toBeChecked(); expect(result.getByRole('combobox', {name: 'Story'})).toHaveValue('Linked story'); @@ -524,6 +739,90 @@ describe('SelectLinkDestinationDialogView', () => { }); }); + it('explains that link to entry that cannot be linked to cannot be changed', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, signature: 'signed-9'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + + expect(result.getByText('No access to Linked story')).toBeInTheDocument(); + expect(result.getByRole('combobox', {name: 'Story'})).toHaveValue(''); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({href: {entry: 9, signature: 'signed-9'}}); + }); + + it('allows replacing link to entry that cannot be linked to', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, signature: 'signed-9'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{id: 5, title: 'Other story', signature: 'signed-5'}]); + await user.click(result.getByRole('button', {name: /Show stories/})); + await user.click(result.getByRole('option', {name: 'Other story'})); + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(result.queryByText('No access to Linked story')).not.toBeInTheDocument(); + expect(onSelect).toHaveBeenCalledWith({href: {entry: 5, signature: 'signed-5'}}); + }); + + it('expands outline of other entry for chapter links', async () => { + Element.prototype.scrollIntoView = jest.fn(); + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{ + id: 9, title: 'Linked story', typeName: 'scrolled', signature: 'signed-9' + }]); + respondWithOutline(9, { + chapters: [{id: 7, permaId: 70, storylineId: 1, + configuration: {title: 'Other chapter'}}], + signatures: {chapters: {70: 'signed-chapter-70'}} + }); + + expect((await result.findByText('Other chapter')).closest('li')) + .toHaveAttribute('aria-current', 'true'); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({ + href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'} + }); + delete Element.prototype.scrollIntoView; + }); + + it('does not offer outline for chapter link to entry that cannot be linked to', async () => { + const onSelect = jest.fn(); + const user = userEvent.setup(); + const result = renderDialog({href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'}}, { + seed: {linkedEntries: [{id: 9, title: 'Linked story', url: '/linked'}]}, + onSelect + }); + respondWithLinkableEntries([{ + id: 5, title: 'Other story', typeName: 'scrolled', signature: 'signed-5' + }]); + + expect(result.getByText('No access to Linked story')).toBeInTheDocument(); + expect(result.queryByRole('button', {name: 'Link to beginning of story instead'})) + .toBeNull(); + expect(testContext.requests).toHaveLength(1); + + await user.click(result.getByRole('button', {name: 'Save'})); + + expect(onSelect).toHaveBeenCalledWith({ + href: {entry: 9, chapter: 70, signature: 'signed-chapter-70'} + }); + }); + it('fetches entries when other entry is preselected', () => { renderDialog({href: {entry: 9, signature: 'signed-9'}}); diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js index b279b1cb17..022a9726ea 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -1,3 +1,5 @@ +import Backbone from 'backbone'; + import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; @@ -5,6 +7,7 @@ import {factories, normalizeSeed} from 'support'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; describe('SelectableEntryOutlineView', () => { useFakeTranslations({ @@ -15,7 +18,9 @@ describe('SelectableEntryOutlineView', () => { 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here', 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning', - 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end' + 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end', + 'pageflow_scrolled.editor.storylines_tabs.main': 'Outline', + 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions' }); describe('in default mode', () => { @@ -256,4 +261,98 @@ describe('SelectableEntryOutlineView', () => { expect(getByText('No chapters')).toBeTruthy(); }); }); + + describe('with selection', () => { + beforeEach(() => { + jest.useFakeTimers(); + Element.prototype.scrollIntoView = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + delete Element.prototype.scrollIntoView; + }); + + function createEntry() { + return factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 1, storylineId: 1, permaId: 10, configuration: {title: 'Intro'}}, + {id: 2, storylineId: 1, permaId: 20, configuration: {title: 'Outro'}}, + {id: 3, storylineId: 2, permaId: 30, configuration: {title: 'Excursion'}} + ], + sections: [ + {id: 1, chapterId: 1, permaId: 100}, + {id: 2, chapterId: 2, permaId: 200}, + {id: 3, chapterId: 3, permaId: 300} + ] + }) + }); + } + + function renderOutline(selection) { + const view = new SelectableEntryOutlineView({ + entry: createEntry(), + selection, + onSelectChapter: jest.fn(), + onSelectSection: jest.fn() + }); + + return render(view); + } + + it('marks selected chapter as current', () => { + const {getByText} = renderOutline(new Backbone.Model({chapter: 20})); + + expect(getByText('Outro').closest('li')).toHaveAttribute('aria-current', 'true'); + expect(getByText('Intro').closest('li')).not.toHaveAttribute('aria-current'); + }); + + it('marks selected section as current', () => { + const {getAllByTitle} = renderOutline(new Backbone.Model({section: 200})); + + expect(getAllByTitle('Select section').map(link => + link.closest('li').getAttribute('aria-current') + )).toEqual([null, 'true']); + }); + + it('updates when selection changes', () => { + const selection = new Backbone.Model({chapter: 20}); + const {getByText} = renderOutline(selection); + + selection.set({chapter: 10}); + + expect(getByText('Intro').closest('li')).toHaveAttribute('aria-current', 'true'); + expect(getByText('Outro').closest('li')).not.toHaveAttribute('aria-current'); + }); + + it('displays excursions tab if selected chapter is an excursion', () => { + const {getByRole} = renderOutline(new Backbone.Model({chapter: 30})); + + expect(getByRole('tab', {name: 'Excursions'})).toHaveAttribute('aria-selected', 'true'); + }); + + it('scrolls top of selected chapter into view', () => { + const {getByText} = renderOutline(new Backbone.Model({chapter: 20})); + jest.runAllTimers(); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({block: 'start'}); + expect(Element.prototype.scrollIntoView.mock.instances[0]) + .toBe(getByText('Outro').closest('li')); + }); + + it('scrolls selected section into view', () => { + const {getAllByTitle} = renderOutline(new Backbone.Model({section: 200})); + jest.runAllTimers(); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalledTimes(1); + expect(Element.prototype.scrollIntoView.mock.instances[0]) + .toBe(getAllByTitle('Select section')[1].closest('li')); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js index 8e8fc5fc11..02d713672f 100644 --- a/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js +++ b/entry_types/scrolled/package/spec/entryState/linkedEntries-spec.js @@ -1,4 +1,9 @@ -import {useLinkedEntry, useLinkedEntryUrl, watchCollections} from 'entryState'; +import { + useLinkedEntry, + useLinkedEntryDestination, + useLinkedEntryUrl, + watchCollections +} from 'entryState'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; @@ -19,6 +24,66 @@ describe('useLinkedEntry', () => { }); }); +describe('useLinkedEntryDestination', () => { + function renderDestination(href, linkedEntry) { + return renderHookInEntry( + () => useLinkedEntryDestination(href), { + seed: { + linkedEntries: [{ + id: 5, + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + ...linkedEntry + }] + } + } + ).result.current; + } + + it('returns undefined for unknown entry', () => { + expect(renderDestination({entry: 6})).toBeUndefined(); + }); + + it('returns urls of entry', () => { + expect(renderDestination({entry: 5})).toMatchObject({ + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview' + }); + }); + + it('returns chapter with published and preview anchors', () => { + const destination = renderDestination({entry: 5, chapter: 20}, { + chapters: [{permaId: 20, title: 'Intro', slug: 'old', previewSlug: 'new'}] + }); + + expect(destination).toMatchObject({ + url: 'https://example.com/other#old', + previewUrl: '/admin/entries/other/preview#new', + chapter: {title: 'Intro'} + }); + }); + + it('returns urls without anchor for unknown chapters', () => { + const destination = renderDestination({entry: 5, chapter: 20}); + + expect(destination).toMatchObject({ + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + chapter: undefined + }); + }); + + it('returns section anchors', () => { + const destination = renderDestination({entry: 5, section: 30}); + + expect(destination).toMatchObject({ + url: 'https://example.com/other#section-30', + previewUrl: '/admin/entries/other/preview#section-30', + section: true + }); + }); +}); + describe('useLinkedEntryUrl', () => { it('returns url of linked entry', () => { const {result} = renderHookInEntry( @@ -32,6 +97,46 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toEqual('https://example.com/other'); }); + it('returns url with slug of linked chapter', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, chapter: 20}), { + seed: { + linkedEntries: [{ + id: 5, + url: 'https://example.com/other', + chapters: [{permaId: 20, slug: 'the-outro'}] + }] + } + } + ); + + expect(result.current).toEqual('https://example.com/other#the-outro'); + }); + + it('returns url without anchor for unknown chapter', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, chapter: 30}), { + seed: { + linkedEntries: [{id: 5, url: 'https://example.com/other'}] + } + } + ); + + expect(result.current).toEqual('https://example.com/other'); + }); + + it('returns url with section anchor', () => { + const {result} = renderHookInEntry( + () => useLinkedEntryUrl({entry: 5, section: 10}), { + seed: { + linkedEntries: [{id: 5, url: 'https://example.com/other'}] + } + } + ); + + expect(result.current).toEqual('https://example.com/other#section-10'); + }); + it('returns undefined for unknown entry', () => { const {result} = renderHookInEntry( () => useLinkedEntryUrl({entry: 6}), { @@ -44,10 +149,10 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toBeUndefined(); }); - describe('with watched linkable entries', () => { + describe('with watched linked entries', () => { it('returns url of added entry', () => { const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); - entry.linkableEntries.add({id: 5, title: 'Other', url: 'http://example.com/other'}); + entry.linkedEntries.add({id: 5, title: 'Other', url: 'http://example.com/other'}); const {result} = renderHookInEntry( () => useLinkedEntryUrl({entry: 5}), { @@ -74,9 +179,31 @@ describe('useLinkedEntryUrl', () => { expect(result.current).toEqual('http://example.com/other'); }); + it('syncs linked chapters', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + linkedEntries: [{ + id: 5, + url: 'http://example.com/other', + chapters: [{permaId: 20, slug: 'intro'}] + }] + }) + }); + + const {result} = renderHookInEntry( + () => useLinkedEntry(5), { + setup: dispatch => watchCollections(entry, {dispatch}) + } + ); + + expect(result.current).toMatchObject({ + chapters: [{permaId: 20, slug: 'intro'}] + }); + }); + it('syncs preview url and publication state', () => { const entry = factories.entry(ScrolledEntry, {}, {entryTypeSeed: normalizeSeed()}); - entry.linkableEntries.add({ + entry.linkedEntries.add({ id: 5, title: 'Other', url: 'http://example.com/other', diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index 21d1bbea1f..aa9a06d301 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -16,6 +16,7 @@ import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories} from 'pageflow/testHelpers'; import {renderHookInEntry, normalizeSeed} from 'support'; +import {act} from '@testing-library/react'; const storylinesSeed = [ @@ -585,54 +586,34 @@ describe('useChapters', () => { ]); }); - it('sanitizes chapter titles', () => { + it('reads chapter slugs from seed', () => { const {result} = renderHookInEntry( () => useChapters(), { seed: { chapters: [ - { - configuration: { - title: 'SmallCase', - } - }, - { - configuration: { - title: 'RemöveUmläütß', - } - }, - { - configuration: { - title: 'remove space', - } - }, - { - configuration: { - title: 'remove#special$character', - } - }, - + {permaId: 10, chapterSlug: 'computed-on-server', configuration: {title: 'Intro'}} ] } } ); - const chapters = result.current; + expect(result.current).toMatchObject([{chapterSlug: 'computed-on-server'}]); + }); - expect(chapters).toMatchObject([ - { - chapterSlug: "smallcase", - }, - { - chapterSlug: 'remoeveumlaeuetss', - }, - { - chapterSlug: 'remove-space', - }, - { - chapterSlug: 'removespecialdollarcharacter', - } - ]); + it('updates chapter slugs when titles change in editor', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 1, permaId: 10, configuration: {title: 'Intro'}}] + }) + }); + const {result} = renderHookInEntry(() => useChapters(), { + setup: dispatch => watchCollections(entry, {dispatch}) + }); + + act(() => entry.chapters.get(1).configuration.set('title', 'Outro', {autoSave: false})); + + expect(result.current).toMatchObject([{chapterSlug: 'outro'}]); }); }); diff --git a/entry_types/scrolled/package/spec/fixtures/slugify.json b/entry_types/scrolled/package/spec/fixtures/slugify.json new file mode 100644 index 0000000000..264ff68acf --- /dev/null +++ b/entry_types/scrolled/package/spec/fixtures/slugify.json @@ -0,0 +1,126 @@ +[ + { + "title": "My Chapter", + "slug": "my-chapter" + }, + { + "title": "Über uns & mehr!", + "slug": "ueber-uns-und-mehr" + }, + { + "title": "Straße & Co.", + "slug": "strasse-und-co" + }, + { + "title": "Ölförderung", + "slug": "oelfoerderung" + }, + { + "title": "Привет мир", + "slug": "privet-mir" + }, + { + "title": "Ελληνικά", + "slug": "ellhnika" + }, + { + "title": "中文标题", + "slug": "" + }, + { + "title": "😀 Emoji", + "slug": "emoji" + }, + { + "title": " Leading and trailing ", + "slug": "leading-and-trailing" + }, + { + "title": "Non breaking space", + "slug": "non-breaking-space" + }, + { + "title": "Mixed—dash – and - hyphen", + "slug": "mixeddash-and-hyphen" + }, + { + "title": "It's \"quoted\"", + "slug": "its-quoted" + }, + { + "title": "$100 % off", + "slug": "dollar100-prozent-off" + }, + { + "title": "Ь", + "slug": "" + }, + { + "title": "À la carte", + "slug": "a-la-carte" + }, + { + "title": "Đà Nẵng", + "slug": "dja-nang" + }, + { + "title": "Ünïcödé", + "slug": "uenicoede" + }, + { + "title": "tab\tseparated", + "slug": "tab-separated" + }, + { + "title": "a.b,c;d", + "slug": "abcd" + }, + { + "title": "___", + "slug": "" + }, + { + "title": "Kapitel 1: Einleitung", + "slug": "kapitel-1-einleitung" + }, + { + "title": "100€ für 5£", + "slug": "100euro-fuer-5pound" + }, + { + "title": "♥ love ♥", + "slug": "liebe-love-liebe" + }, + { + "title": "LJubljana", + "slug": "ubljana" + }, + { + "title": "ﷺ test", + "slug": "test" + }, + { + "title": "Fragen & Antworten", + "slug": "fragen-und-antworten" + }, + { + "title": "50 % Rabatt", + "slug": "50-prozent-rabatt" + }, + { + "title": "Ja | Nein", + "slug": "ja-oder-nein" + }, + { + "title": "Müller", + "slug": "mueller" + }, + { + "title": "Vorher – nachher", + "slug": "vorher-nachher" + }, + { + "title": "Zitat „Hallo“", + "slug": "zitat-hallo" + } +] diff --git a/entry_types/scrolled/package/spec/frontend/Link-spec.js b/entry_types/scrolled/package/spec/frontend/Link-spec.js index 6db884ff4c..1a92aced14 100644 --- a/entry_types/scrolled/package/spec/frontend/Link-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Link-spec.js @@ -248,6 +248,36 @@ describe('Link', () => { expect(getByRole('link')).not.toHaveAttribute('target'); }); + it('renders link to chapter of linked entry', () => { + const {getByRole} = renderInEntry( + Click, + { + seed: { + linkedEntries: [{ + id: 5, + url: 'http://example.com/other', + chapters: [{permaId: 20, slug: 'the-intro'}] + }] + } + } + ); + + expect(getByRole('link')).toHaveAttribute('href', 'http://example.com/other#the-intro'); + }); + + it('renders link to section of linked entry', () => { + const {getByRole} = renderInEntry( + Click, + { + seed: { + linkedEntries: [{id: 5, url: 'http://example.com/other'}] + } + } + ); + + expect(getByRole('link')).toHaveAttribute('href', 'http://example.com/other#section-10'); + }); + it('renders link without href for unknown entry', () => { const {getByText} = renderInEntry( Click diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js index 93db32a7e9..3e4f586709 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/linkTooltip-spec.js @@ -14,10 +14,11 @@ describe('link tooltip in commenting mode', () => { 'pageflow_scrolled.review.link_tooltip.password_protected': 'Password protected', 'pageflow_scrolled.review.link_tooltip.open_preview': 'Open preview', 'pageflow_scrolled.review.link_tooltip.open_draft_preview': 'Open draft preview', + 'pageflow_scrolled.review.link_tooltip.section_in': 'Section in', 'pageflow_scrolled.inline_editing.link_tooltip.opens_in_same_tab': 'Opens in same tab' }); - function renderLink(href, {publicationState = 'not_published'} = {}) { + function renderLink(href, {publicationState = 'not_published', chapters} = {}) { return renderEntry({ seed: { linkedEntries: [{ @@ -25,7 +26,8 @@ describe('link tooltip in commenting mode', () => { title: 'Other story', url: 'https://example.com/other', previewUrl: '/admin/entries/other/preview', - publicationState + publicationState, + chapters }] }, contentElement: {ui: A link} @@ -92,4 +94,43 @@ describe('link tooltip in commenting mode', () => { expect(entry.getAllByRole('link')).toHaveLength(1); }); + + describe('with chapter or section', () => { + it('offers preview of chapter', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, chapter: 20, signature: 's'}, { + chapters: [{permaId: 20, title: 'New chapter', slug: 'new-chapter', + previewSlug: 'new-chapter'}] + }); + + await user.hover(entry.getByText('A link')); + + expect(entry.getByText('Other story › New chapter')).toBeInTheDocument(); + expect(entry.getByRole('link', {name: 'Open preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#new-chapter'); + }); + + it('offers preview of section', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, section: 30, signature: 's'}); + + await user.hover(entry.getByText('A link')); + + expect(entry.getByText('Section in Other story')).toBeInTheDocument(); + expect(entry.getByRole('link', {name: 'Open preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#section-30'); + }); + + it('does not display tooltip for chapters of published entries', async () => { + const user = userEvent.setup(); + const entry = renderLink({entry: 7, chapter: 20, signature: 's'}, { + publicationState: 'published_without_password_protection', + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'intro'}] + }); + + await user.hover(entry.getByText('A link')); + + expect(entry.getAllByRole('link')).toHaveLength(1); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js index 2137e27f9c..78eeb820ed 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -21,7 +21,8 @@ describe('LinkTooltip', () => { 'pageflow_scrolled.inline_editing.link_tooltip.publication_states.published_with_password_protection': 'Password protected', 'pageflow_scrolled.inline_editing.link_tooltip.open_preview': 'Open preview', - 'pageflow_scrolled.inline_editing.link_tooltip.open_draft_preview': 'Open draft preview' + 'pageflow_scrolled.inline_editing.link_tooltip.open_draft_preview': 'Open draft preview', + 'pageflow_scrolled.inline_editing.link_tooltip.section_in': 'Section in' }); beforeEach(() => { @@ -324,6 +325,46 @@ describe('LinkTooltip', () => { expect(getByText('Unavailable story')).toBeInTheDocument(); }); + describe('with chapter or section', () => { + const linkedEntry = { + title: 'Other story', + url: 'https://example.com/other', + previewUrl: '/admin/entries/other/preview', + publicationState: 'published_without_password_protection' + }; + + it('displays chapter title linking to chapter', async () => { + const {getByText, getByRole} = renderLinkPreview({entry: 7, chapter: 20}, { + linkedEntry: { + ...linkedEntry, + chapters: [{permaId: 20, title: 'Intro', slug: 'intro', previewSlug: 'new-intro'}] + } + }); + + const user = userEvent.setup(); + await user.hover(getByText('A link')); + + expect(getByRole('link', {name: 'Other story › Intro'})) + .toHaveAttribute('href', 'https://example.com/other#intro'); + expect(getByRole('link', {name: 'Open draft preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#new-intro'); + }); + + it('displays section hint', async () => { + const {getByText, getByRole} = renderLinkPreview({entry: 7, section: 30}, { + linkedEntry + }); + + const user = userEvent.setup(); + await user.hover(getByText('A link')); + + expect(getByText('Section in', {exact: false})).toBeInTheDocument(); + expect(getByRole('link', {name: 'Other story'})) + .toHaveAttribute('href', 'https://example.com/other#section-30'); + expect(getByRole('link', {name: 'Open draft preview'})) + .toHaveAttribute('href', '/admin/entries/other/preview#section-30'); + }); + }); }); it('displays tooltip for file link', async () => { diff --git a/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js b/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js index 3349c7c534..87420117e1 100644 --- a/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/ChaptersCollection.js @@ -10,7 +10,12 @@ export const ChaptersCollection = Backbone.Collection.extend({ entryTypeEditorControllerUrls.forCollection({resources: 'chapters'}) ], - comparator: function(chapter) { - return chapter.get('position'); + comparator: function(chapterA, chapterB) { + return storylinePosition(chapterA) - storylinePosition(chapterB) || + chapterA.get('position') - chapterB.get('position'); } }); + +function storylinePosition(chapter) { + return chapter.storyline?.get('position') || 0; +} diff --git a/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js index 1ba2ca404b..f438e85cf3 100644 --- a/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/LinkableEntriesCollection.js @@ -2,6 +2,10 @@ import Backbone from 'backbone'; import {entryTypeEditorControllerUrls} from 'pageflow/editor'; +import {LinkableEntry} from '../models/LinkableEntry'; + export const LinkableEntriesCollection = Backbone.Collection.extend({ + model: LinkableEntry, + mixins: [entryTypeEditorControllerUrls.forCollection({resources: 'linkable_entries'})] }); diff --git a/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js new file mode 100644 index 0000000000..bbf2dbd1ee --- /dev/null +++ b/entry_types/scrolled/package/src/editor/collections/LinkedEntriesCollection.js @@ -0,0 +1,49 @@ +import Backbone from 'backbone'; + +export const LinkedEntriesCollection = Backbone.Collection.extend({ + addLinkableEntry(linkableEntry, {chapter} = {}) { + const linkedChapter = findLinkedChapter(linkableEntry, chapter); + + this.mergeLinkedEntries([{ + ...linkableEntry.pick('id', 'title', 'url', 'previewUrl', 'publicationState'), + ...(linkedChapter && {chapters: [linkedChapter]}) + }]); + }, + + mergeLinkedEntries(linkedEntries) { + linkedEntries.forEach(attributes => { + const linkedEntry = this.get(attributes.id); + + if (linkedEntry) { + linkedEntry.set({ + ...attributes, + chapters: mergeChapters(linkedEntry.get('chapters'), attributes.chapters) + }); + } + else { + this.add(attributes); + } + }); + } +}); + +function mergeChapters(chapters = [], newChapters = []) { + return [ + ...chapters, + ...newChapters.filter(chapter => + !chapters.some(existingChapter => existingChapter.permaId === chapter.permaId) + ) + ]; +} + +function findLinkedChapter(linkableEntry, permaId) { + const chapter = permaId && + (linkableEntry.get('chapters') || []).find(item => item.permaId === permaId); + + return chapter && { + permaId, + title: chapter.title, + slug: chapter.slug, + previewSlug: chapter.slug + }; +} diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index a9c424b476..9a9c654594 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -131,8 +131,9 @@ export const Chapter = Backbone.Model.extend({ } } }).then(sections => { - this.entry.linkableEntries.set(fragment.get('collections')?.linkedEntries || [], - {remove: false}); + this.entry.linkedEntries.mergeLinkedEntries( + fragment.get('collections')?.linkedEntries || [] + ); return this.entry.refreshFiles().then(() => this.addFragmentSections(sections)); }); diff --git a/entry_types/scrolled/package/src/editor/models/LinkableEntry.js b/entry_types/scrolled/package/src/editor/models/LinkableEntry.js new file mode 100644 index 0000000000..26f1003b66 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/LinkableEntry.js @@ -0,0 +1,64 @@ +import Backbone from 'backbone'; + +import { + StorylinesCollection, + ChaptersCollection, + SectionsCollection, + ContentElementsCollection +} from '../collections'; + +export const LinkableEntry = Backbone.Model.extend({ + chapterLinkSignature(permaId) { + return findSignature(this.get('chapters'), permaId); + }, + + sectionLinkSignature(permaId) { + return findSignature(this.get('sections'), permaId); + }, + + fetchOutline({entry}) { + return Promise.resolve(Backbone.$.getJSON(this.url())).then(response => { + const {collections, ...attributes} = response; + this.set(attributes); + + return new LinkableEntryOutline({id: this.id}, { + collections, + seed: entry.scrolledSeed + }); + }); + } +}); + +const LinkableEntryOutline = Backbone.Model.extend({ + readOnly: true, + + initialize(attributes, {collections, seed}) { + this.contentElements = new ContentElementsCollection(collections.contentElements); + this.sections = new SectionsCollection(collections.sections, + {contentElements: this.contentElements}); + this.chapters = new ChaptersCollection(collections.chapters, + {sections: this.sections, + entry: this}); + this.storylines = new StorylinesCollection(collections.storylines, + {chapters: this.chapters, + entry: this}); + this.sections.sort(); + + this.scrolledSeed = { + ...seed, + collections: { + ...seed.collections, + ...collections, + widgets: [] + } + }; + }, + + isCurrentSectionInExcursion() { + return false; + } +}); + +function findSignature(destinations, permaId) { + return (destinations || []).find(destination => destination.permaId === permaId)?.signature; +} diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index dba6f4986e..505fd95ba5 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -13,6 +13,7 @@ import { ContentElementsCollection } from '../../collections'; import {LinkableEntriesCollection} from '../../collections/LinkableEntriesCollection'; +import {LinkedEntriesCollection} from '../../collections/LinkedEntriesCollection'; import {CommentDisplayFilter} from '../CommentDisplayFilter'; import {ContentElement} from '../ContentElement'; @@ -29,7 +30,7 @@ import {updateContentElement} from './updateContentElement'; import {sortColors} from './sortColors'; import {Scale} from '../../../shared/Scale'; -import {getChapterSlugs} from '../../../shared/chapterSlug'; +import {getChapterSlugs} from '../../utils/chapterSlug'; const typographySizeSuffixes = ['xxxl', 'xxl', 'xl', 'lg', 'md', 'sm', 'xs', 'xxs', 'xxxs']; @@ -73,10 +74,17 @@ export const ScrolledEntry = Entry.extend({ this.storylines = new StorylinesCollection(seed.collections.storylines, {chapters: this.chapters, entry: this}); + this.chapters.sort(); + + this.listenTo(this.chapters, + 'add remove sort change:permaId change:configuration:title', + this.updateChapterSlugs); + this.updateChapterSlugs(); this.sections.sort(); - this.linkableEntries = new LinkableEntriesCollection(seed.collections.linkedEntries); + this.linkedEntries = new LinkedEntriesCollection(seed.collections.linkedEntries); + this.linkableEntries = new LinkableEntriesCollection(); this.cutoff = new Cutoff({entry: this}); @@ -333,13 +341,18 @@ export const ScrolledEntry = Entry.extend({ }, getChapterPermalink(chapter) { - const allChapters = this.chapters.map(c => ({ - permaId: c.get('permaId'), - configuration: c.configuration.attributes - })); - const chapterSlugs = getChapterSlugs(allChapters); + return `${this.get('pretty_url')}#${chapter.get('chapterSlug')}`; + }, - return `${this.get('pretty_url')}#${chapterSlugs[chapter.get('permaId')]}`; + updateChapterSlugs() { + const chapterSlugs = getChapterSlugs(this.chapters.map(chapter => ({ + permaId: chapter.get('permaId'), + configuration: chapter.configuration.attributes + }))); + + this.chapters.each(chapter => + chapter.set('chapterSlug', chapterSlugs[chapter.get('permaId')]) + ); }, getPaletteColors({name} = {}) { diff --git a/entry_types/scrolled/package/src/shared/chapterSlug.js b/entry_types/scrolled/package/src/editor/utils/chapterSlug.js similarity index 68% rename from entry_types/scrolled/package/src/shared/chapterSlug.js rename to entry_types/scrolled/package/src/editor/utils/chapterSlug.js index 41238e6b96..c26205946c 100644 --- a/entry_types/scrolled/package/src/shared/chapterSlug.js +++ b/entry_types/scrolled/package/src/editor/utils/chapterSlug.js @@ -1,19 +1,13 @@ -import slugify from 'slugify'; +import {slugify} from './slugify'; export function getChapterSlugs(chapters) { const result = {}; const usedSlugs = {}; chapters.forEach(chapter => { - let chapterSlug = chapter.configuration.title; + let chapterSlug = chapter.configuration.title && slugify(chapter.configuration.title); if (chapterSlug) { - chapterSlug = slugify(chapterSlug, { - lower: true, - locale: 'de', - strict: true - }); - if (usedSlugs[chapterSlug]) { chapterSlug = chapterSlug + '-' + chapter.permaId; } diff --git a/entry_types/scrolled/package/src/editor/utils/slugify.js b/entry_types/scrolled/package/src/editor/utils/slugify.js new file mode 100644 index 0000000000..44fe8561e8 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/utils/slugify.js @@ -0,0 +1,17 @@ +import charMap from 'pageflow-scrolled/config/slugCharMap.json'; + +export function slugify(string) { + return string + .normalize() + .split('') + .map(character => + (charMap[character] || character) + .replace(/[^\w\s$*_+~.()'"!\-:@]+/g, '') + ) + .join('') + .trim() + .replace(/[\s-]+/g, '-') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-'); +} diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js index 0b877b0c72..9f0b74126f 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.js @@ -4,21 +4,55 @@ import Marionette from 'backbone.marionette'; import {cssModulesUtils, CheckBoxInputView} from 'pageflow/ui'; import {EntrySelectView} from './EntrySelectView'; +import {SelectableEntryOutlineView} from './SelectableEntryOutlineView'; import styles from './LinkToOtherEntryView.module.css'; export const LinkToOtherEntryView = Marionette.ItemView.extend({ className: styles.view, template: () => ` +
+ + + `, - ui: cssModulesUtils.ui(styles, 'entrySelectContainer', 'openInNewTabContainer'), + ui: cssModulesUtils.ui(styles, + 'inaccessibleEntryNotice', + 'entrySelectContainer', + 'openInNewTabContainer', + 'expandButton', + 'outlineContainer', + 'collapseButton'), + + events: cssModulesUtils.events(styles, { + 'click expandButton': function() { + this.expandOutline(); + }, + + 'click collapseButton': function() { + this.collapseOutline(); + } + }), + + initialize() { + this.listenTo(this.options.entry.linkableEntries, 'sync', this.handleLinkableEntriesSync); + }, onRender() { + const linkableEntries = this.options.entry.linkableEntries; + this.appendSubview(new EntrySelectView({ - collection: this.options.entry.linkableEntries, + collection: linkableEntries, selectedEntryId: this.model.get('entryId'), label: I18n.t('pageflow_scrolled.editor.select_link_destination.story'), onSelect: linkableEntry => this.selectEntry(linkableEntry) @@ -29,12 +63,176 @@ export const LinkToOtherEntryView = Marionette.ItemView.extend({ propertyName: 'openInNewTab', label: I18n.t('pageflow_scrolled.editor.select_link_destination.open_in_new_tab') }), {to: this.ui.openInNewTabContainer}); + + if (this.model.get('entryId') && this.model.get('expanded')) { + this.ui.outlineContainer.prop('hidden', false); + this.options.onToggleOutline(true); + this.loadOutline(); + } + + this.updateButtons(); }, selectEntry(linkableEntry) { + if (linkableEntry && linkableEntry.id === this.model.get('entryId')) { + return; + } + + this.model.unset('chapter'); + this.model.unset('section'); + this.model.unset('targetSignature'); this.model.set({ entryId: linkableEntry?.id, signature: linkableEntry?.get('signature') }); + + if (!this.hasOutline()) { + this.collapseOutline(); + } + else if (this.model.get('expanded')) { + this.loadOutline(); + } + + this.updateInaccessibleEntryNotice(); + this.updateButtons(); + }, + + handleLinkableEntriesSync() { + const linkableEntry = this.linkableEntry(); + + if (linkableEntry) { + if (!this.model.get('signature')) { + this.model.set('signature', linkableEntry.get('signature')); + } + + if (this.model.get('expanded') && this.outlineEntryId !== linkableEntry.id) { + this.loadOutline(); + } + } + else if (this.model.get('expanded')) { + this.ui.outlineContainer.prop('hidden', true); + this.options.onToggleOutline(false); + } + + this.updateInaccessibleEntryNotice(); + this.updateButtons(); + }, + + expandOutline() { + this.model.set('expanded', true); + this.ui.outlineContainer.prop('hidden', false); + this.options.onToggleOutline(true); + this.updateButtons(); + this.loadOutline(); + }, + + collapseOutline() { + this.model.set('expanded', false); + this.model.unset('chapter'); + this.model.unset('section'); + this.model.unset('targetSignature'); + this.ui.outlineContainer.prop('hidden', true); + this.closeOutline(); + this.options.onToggleOutline(false); + this.updateButtons(); + }, + + updateButtons() { + const expanded = !!this.model.get('expanded'); + + this.ui.expandButton.prop('hidden', expanded || !this.hasOutline()); + this.ui.expandButton.attr('aria-expanded', String(expanded)); + this.ui.collapseButton.prop('hidden', !expanded || !this.linkableEntry()); + }, + + updateInaccessibleEntryNotice() { + const linkedEntry = this.options.entry.linkedEntries.get(this.model.get('entryId')); + const inaccessible = !!linkedEntry && !this.linkableEntry(); + + this.ui.inaccessibleEntryNotice.prop('hidden', !inaccessible); + this.ui.inaccessibleEntryNotice.text(inaccessible ? + I18n.t('pageflow_scrolled.editor.select_link_destination.inaccessible_entry', + {title: linkedEntry.get('title')}) : + ''); + }, + + hasOutline() { + return this.linkableEntry()?.get('typeName') === 'scrolled'; + }, + + linkableEntry() { + return this.options.entry.linkableEntries.get(this.model.get('entryId')); + }, + + loadOutline() { + const linkableEntry = this.linkableEntry(); + this.closeOutline(); + this.outlineEntryId = linkableEntry?.id; + + if (!linkableEntry) { + return; + } + + const request = this.outlineRequest = {}; + + linkableEntry.fetchOutline({entry: this.options.entry}).then( + outline => { + if (this.isCurrentOutlineRequest(request)) { + this.showOutline(outline, linkableEntry); + } + }, + () => { + if (this.isCurrentOutlineRequest(request)) { + this.showOutlineError(); + } + } + ); + }, + + isCurrentOutlineRequest(request) { + return this.outlineRequest === request && !this.isClosed; + }, + + showOutline(outline, linkableEntry) { + this.outlineView = this.subview(new SelectableEntryOutlineView({ + entry: outline, + selection: this.model, + + onSelectChapter: chapter => { + this.model.unset('section'); + this.model.set({ + chapter: chapter.get('permaId'), + targetSignature: linkableEntry.chapterLinkSignature(chapter.get('permaId')) + }); + }, + onSelectSection: section => { + this.model.unset('chapter'); + this.model.set({ + section: section.get('permaId'), + targetSignature: linkableEntry.sectionLinkSignature(section.get('permaId')) + }); + } + })); + + this.ui.outlineContainer.append(this.outlineView.el); + }, + + showOutlineError() { + this.ui.outlineContainer.append( + `+ ${I18n.t('pageflow_scrolled.editor.select_link_destination.outline_failed')} +
` + ); + }, + + closeOutline() { + this.outlineRequest = null; + + if (this.outlineView) { + this.outlineView.close(); + this.outlineView = null; + } + + this.ui.outlineContainer.empty(); } }); diff --git a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css index f6870a4ac5..f6af049ed4 100644 --- a/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css +++ b/entry_types/scrolled/package/src/editor/views/LinkToOtherEntryView.module.css @@ -2,8 +2,54 @@ display: flex; flex-direction: column; gap: space(3); + flex: 1; + min-height: 0; +} + +.inaccessibleEntryNotice { + margin: 0; } .entrySelectContainer {} .openInNewTabContainer {} + +.textButton { + composes: unstyledButton from './buttons.module.css'; + align-self: flex-start; + padding: 0; + background: transparent; + color: var(--ui-primary-color); + cursor: pointer; +} + +.textButton::before { + margin-right: space(2); +} + +.textButton[hidden] { + display: none; +} + +.expandButton { + composes: textButton; + composes: rightOpen icon from './icons.module.css'; +} + +.collapseButton { + composes: textButton; + composes: cancel icon from './icons.module.css'; +} + +.outlineContainer { + flex: 1; + container-type: size; +} + +.outlineContainer[hidden] { + display: none; +} + +.outlineError { + color: var(--ui-error-color); +} diff --git a/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js b/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js index ce4c265d68..31ec2a0316 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionThumbnailView.js @@ -32,12 +32,16 @@ export const SectionThumbnailView = Marionette.ItemView.extend({ renderThumbnail() { if (!this.model.isNew()) { + const entry = this.options.entry; + ReactDOM.render(React.createElement(StandaloneSectionThumbnail, { sectionPermaId: this.model.get('permaId'), - seed: this.options.entry.scrolledSeed, - subscribe: dispatch => - watchCollections(this.options.entry, {dispatch}) + seed: entry.scrolledSeed, + subscribe: entry.readOnly ? + undefined : + dispatch => + watchCollections(entry, {dispatch}) }), this.ui.thumbnail[0]); } diff --git a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js index d8c816c7dd..385fe9fb97 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.js @@ -87,9 +87,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ href && kindOf(href) === 'thisEntry' ? _.pick(href, 'chapter', 'section') : {} ); this.otherEntryLink = new Backbone.Model( - href && href.entry ? - {entryId: href.entry, signature: href.signature, openInNewTab} : - {} + href && href.entry ? otherEntryLinkAttributes(href, {openInNewTab}) : {} ); this.otherEntryLink.modelName = 'otherEntryLink'; this.externalLink = new Backbone.Model( @@ -116,6 +114,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ this.view(OutlineContainerView, { outlineView: new SelectableEntryOutlineView({ entry: dialog.options.entry, + selection: dialog.outlineSelection, onSelectChapter: chapter => { dialog.outlineSelection.clear({silent: true}); @@ -132,7 +131,8 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ accordion.section(sectionTitle('other_entry'), sectionOptions('otherEntry'), function() { this.view(LinkToOtherEntryView, { entry: dialog.options.entry, - model: dialog.otherEntryLink + model: dialog.otherEntryLink, + onToggleOutline: expanded => accordion.setSectionFill('otherEntry', expanded) }); }); @@ -178,7 +178,7 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ fetchLinkableEntries() { if (this.selection.get('kind') === 'otherEntry' && !this.linkableEntriesFetched) { this.linkableEntriesFetched = true; - this.options.entry.linkableEntries.fetch({remove: false}); + this.options.entry.linkableEntries.fetch(); } }, @@ -213,6 +213,12 @@ export const SelectLinkDestinationDialogView = Marionette.ItemView.extend({ }, apply(link) { + const linkableEntry = link.href.entry && this.options.entry.linkableEntries.get(link.href.entry); + + if (linkableEntry) { + this.options.entry.linkedEntries.addLinkableEntry(linkableEntry, {chapter: link.href.chapter}); + } + this.options.onSelect(link); this.close(); }, @@ -264,8 +270,36 @@ function kindOf(href) { return 'thisEntry'; } -function otherEntryLink({entryId, signature, openInNewTab}) { - if (!entryId || !signature) { +function otherEntryLinkAttributes(href, {openInNewTab}) { + if (href.chapter || href.section) { + return { + entryId: href.entry, + ..._.pick(href, 'chapter', 'section'), + targetSignature: href.signature, + expanded: true, + openInNewTab + }; + } + + return {entryId: href.entry, signature: href.signature, openInNewTab}; +} + +function otherEntryLink({ + entryId, signature, chapter, section, targetSignature, expanded, openInNewTab +}) { + if (!entryId) { + return null; + } + else if (expanded) { + if (!targetSignature || !(chapter || section)) { + return null; + } + + return withTarget({ + href: {entry: entryId, ...(chapter ? {chapter} : {section}), signature: targetSignature} + }, openInNewTab); + } + else if (!signature) { return null; } diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js index 3568965c8a..6097a986b5 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -84,6 +84,12 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ change: 'update' }, + initialize() { + if (this.options.selection) { + this.listenTo(this.options.selection, 'change', this.updateSelected); + } + }, + onRender() { this.subview(new CollectionView({ el: this.ui.sections, @@ -92,6 +98,7 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelect: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, onSelectSectionPart: this.options.onSelectSectionPart @@ -99,6 +106,20 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ })); this.update(); + + if (this.updateSelected()) { + setTimeout(() => this.el.scrollIntoView({block: 'start'}), 10); + } + }, + + updateSelected() { + const selected = !!this.options.selection && + this.options.selection.get('chapter') === this.model.get('permaId'); + + this.$el.toggleClass(styles.selected, selected); + this.$el.attr('aria-current', selected ? 'true' : null); + + return selected; }, update() { diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css index fd5c037b30..1aae4e7b6d 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css @@ -1,3 +1,4 @@ +@value selectionColor from './colors.module.css'; @value insertLineWidth, insertLineColor from './insertIndicator.module.css'; .root { @@ -6,6 +7,11 @@ position: relative; } +.selected { + box-shadow: 0 0 0 2px selectionColor; + background-color: var(--ui-selection-color-lighter); +} + .empty { cursor: pointer; } diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js index e6c2a74d20..b1cac042e4 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js @@ -17,10 +17,12 @@ export const SelectableEntryOutlineView = Marionette.Layout.extend({ this.appendSubview( new StorylinesTabsView({ entry: this.options.entry, + defaultTab: this.isSelectionInExcursion() ? 'excursions' : undefined, itemViewContstuctor: SelectableStorylineItemView, itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelectChapter: this.options.onSelectChapter, onSelectSection: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, @@ -29,5 +31,25 @@ export const SelectableEntryOutlineView = Marionette.Layout.extend({ }), {to: this.ui.tabs} ); + }, + + isSelectionInExcursion() { + const chapter = this.selectedChapter(); + return !!chapter && chapter.isExcursion(); + }, + + selectedChapter() { + const {entry, selection} = this.options; + + if (!selection) { + return null; + } + + if (selection.get('section')) { + const section = entry.sections.findWhere({permaId: selection.get('section')}); + return section && section.chapter; + } + + return entry.chapters.findWhere({permaId: selection.get('chapter')}); } }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css index bd62b6927b..4de824cf1f 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css @@ -8,6 +8,7 @@ min-height: 0; flex: 1; overflow: auto; + scroll-padding-block: space(4); scrollbar-gutter: stable; padding-right: space(2); border-bottom: solid 1px var(--ui-on-surface-color-lightest); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js index ff88274616..97cc0b9efd 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js @@ -96,11 +96,31 @@ export const SelectableSectionItemView = Marionette.ItemView.extend({ } }), + initialize() { + if (this.options.selection) { + this.listenTo(this.options.selection, 'change', this.updateSelected); + } + }, + onRender() { this.subview(new SectionThumbnailView({ el: this.ui.thumbnail, model: this.model, entry: this.options.entry })); + + if (this.updateSelected()) { + setTimeout(() => this.el.scrollIntoView({block: 'center'}), 10); + } + }, + + updateSelected() { + const selected = !!this.options.selection && + this.options.selection.get('section') === this.model.get('permaId'); + + this.$el.toggleClass(baseStyles.active, selected); + this.$el.attr('aria-current', selected ? 'true' : null); + + return selected; } }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js index 6df75df3ab..1771574685 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js @@ -38,6 +38,7 @@ export const SelectableStorylineItemView = Marionette.ItemView.extend({ itemViewOptions: { entry: this.options.entry, mode: this.options.mode, + selection: this.options.selection, onSelectChapter: this.options.onSelectChapter, onSelectSection: this.options.onSelectSection, onSelectInsertPosition: this.options.onSelectInsertPosition, diff --git a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js index eaed36cad4..0ddfda42ad 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js @@ -9,7 +9,8 @@ export const StorylinesTabsView = Marionette.View.extend({ render() { this.tabsView = new TabsView({ i18n: 'pageflow_scrolled.editor.storylines_tabs', - defaultTab: this.options.entry.isCurrentSectionInExcursion() ? 'excursions' : 'main' + defaultTab: this.options.defaultTab || + (this.options.entry.isCurrentSectionInExcursion() ? 'excursions' : 'main') }); ['main', 'excursions'].forEach(name => { diff --git a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js index 7f9b59a5d1..e97f0499df 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/TypographyVariantSelectInputView.js @@ -98,7 +98,7 @@ function buildEntry(entry, contentElement, item, getPreviewConfiguration) { metadata: entry.metadata, widgets: entry.widgets, files: entry.files, - linkableEntries: entry.linkableEntries, + linkedEntries: entry.linkedEntries, storylines: entry.storylines, chapters: new Backbone.Collection([section.chapter]), diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index e5cb10f473..242cdc7dab 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -25,7 +25,7 @@ export function watchCollections(entry, {dispatch}) { function collectionSpecs(entry) { const { - storylines, chapters, sections, contentElements, widgets, files, linkableEntries + storylines, chapters, sections, contentElements, widgets, files, linkedEntries } = entry; return [ @@ -54,7 +54,7 @@ function collectionSpecs(entry) { { collection: chapters, name: 'chapters', - attributes: ['id', 'permaId', 'storylineId'], + attributes: ['id', 'permaId', 'storylineId', 'chapterSlug'], keyAttribute: 'permaId', includeConfiguration: true }, @@ -73,9 +73,10 @@ function collectionSpecs(entry) { includeConfiguration: true }, { - collection: linkableEntries, + collection: linkedEntries, name: 'linkedEntries', - attributes: ['id', {permaId: 'id'}, 'title', 'url', 'previewUrl', 'publicationState'], + attributes: ['id', {permaId: 'id'}, 'title', 'url', 'previewUrl', 'publicationState', + 'chapters'], keyAttribute: 'permaId' }, { diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index 1a0b5c4e0c..5a2b7dc67a 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -4,7 +4,7 @@ export {useCutOff} from './cutoff'; export {useEmbedOriginUrl} from './embedOriginUrl'; export {useShareProviders, useShareUrl} from './sharing'; export {useEntryTranslations} from './entryTranslations'; -export {useLinkedEntry, useLinkedEntryUrl} from './linkedEntries'; +export {useLinkedEntry, useLinkedEntryDestination, useLinkedEntryUrl} from './linkedEntries'; export { useDarkWidgets, useEntryMetadata diff --git a/entry_types/scrolled/package/src/entryState/linkedEntries.js b/entry_types/scrolled/package/src/entryState/linkedEntries.js index 835d5797c3..2a9ce5fae6 100644 --- a/entry_types/scrolled/package/src/entryState/linkedEntries.js +++ b/entry_types/scrolled/package/src/entryState/linkedEntries.js @@ -4,6 +4,46 @@ export function useLinkedEntry(entryId) { return useEntryStateCollectionItem('linkedEntries', entryId); } -export function useLinkedEntryUrl({entry: entryId}) { - return useLinkedEntry(entryId)?.url; +export function useLinkedEntryUrl(href) { + return useLinkedEntryDestination(href)?.url; +} + +export function useLinkedEntryDestination({entry: entryId, chapter: chapterPermaId, section}) { + const linkedEntry = useLinkedEntry(entryId); + + if (!linkedEntry) { + return undefined; + } + + const chapter = findLinkedChapter(linkedEntry, chapterPermaId); + + return { + linkedEntry, + chapter, + section: !!section, + url: withAnchor(linkedEntry.url, { + chapterSlug: chapter?.slug, + section + }), + previewUrl: linkedEntry.previewUrl && withAnchor(linkedEntry.previewUrl, { + chapterSlug: chapter?.previewSlug, + section + }) + }; +} + +function findLinkedChapter(linkedEntry, chapterPermaId) { + return chapterPermaId && + (linkedEntry.chapters || []).find(chapter => chapter.permaId === chapterPermaId); +} + +function withAnchor(url, {chapterSlug, section}) { + if (section) { + return `${url}#section-${section}`; + } + else if (chapterSlug) { + return `${url}#${chapterSlug}`; + } + + return url; } diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index 5bae9db829..78261ca34a 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -1,6 +1,5 @@ import {useMemo, useCallback} from 'react'; import {useEntryStateCollectionItems, useEntryStateCollectionItem} from './EntryStateProvider'; -import {getChapterSlugs} from '../shared/chapterSlug'; /** * Returns a nested data structure representing the chapters and sections @@ -349,13 +348,11 @@ export function useChapters() { const chapters = useEntryStateCollectionItems('chapters'); return useMemo(() => { - const chapterSlugs = getChapterSlugs(chapters); - return chapters.map((chapter, index) => ({ id: chapter.id, permaId: chapter.permaId, storylineId: chapter.storylineId, - chapterSlug: chapterSlugs[chapter.permaId], + chapterSlug: chapter.chapterSlug, index, ...chapter.configuration })); diff --git a/entry_types/scrolled/package/src/frontend/commenting/Link.js b/entry_types/scrolled/package/src/frontend/commenting/Link.js index d12d2448f8..461a5c2219 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/Link.js +++ b/entry_types/scrolled/package/src/frontend/commenting/Link.js @@ -2,13 +2,13 @@ import React from 'react'; import {PlainLink} from '../Link'; import {useLinkTooltip} from '../../shared/linkTooltip'; -import {usePreviewableLinkedEntry} from './LinkTooltip'; +import {usePreviewableLinkDestination} from './LinkTooltip'; export function Link(props) { const linkTooltip = useLinkTooltip(); - const linkedEntry = usePreviewableLinkedEntry(props.href); + const destination = usePreviewableLinkDestination(props.href); - if (!linkedEntry || !linkTooltip) { + if (!destination || !linkTooltip) { return