diff --git a/app/models/pageflow/entry.rb b/app/models/pageflow/entry.rb index e7c950f9ba..9f8e71c238 100644 --- a/app/models/pageflow/entry.rb +++ b/app/models/pageflow/entry.rb @@ -89,7 +89,7 @@ def publish(options = {}) # rubocop:todo Metrics/AbcSize revisions.depublish_all association(:published_revision).reset - draft.copy do |revision| + draft.copy(purpose: :publish) do |revision| revision.creator = options[:creator] revision.frozen_at = Time.now revision.published_at = Time.now @@ -102,7 +102,7 @@ def publish(options = {}) # rubocop:todo Metrics/AbcSize def snapshot(options) transaction do - draft.copy do |revision| + draft.copy(purpose: :snapshot) do |revision| revision.creator = options[:creator] revision.frozen_at = Time.now revision.snapshot_type = options.fetch(:type, 'auto') @@ -116,7 +116,7 @@ def restore(options) draft.update!(frozen_at: Time.now, creator: options[:creator], snapshot_type: 'before_restore') - restored_revision.copy do |revision| + restored_revision.copy(purpose: :restore) do |revision| revision.restored_from = restored_revision revision.frozen_at = nil revision.snapshot_type = nil diff --git a/app/models/pageflow/revision.rb b/app/models/pageflow/revision.rb index c39b4aad6b..560a76143f 100644 --- a/app/models/pageflow/revision.rb +++ b/app/models/pageflow/revision.rb @@ -171,7 +171,7 @@ def created_with # Public interface for copying a revision # # @since 15.1 - def copy + def copy(purpose: :duplicate) revision = dup yield(revision) if block_given? @@ -190,6 +190,7 @@ def copy [FileFolder, FileUsage] ) + Pageflow.config_for(revision.entry).revision_components.after_copy(revision, purpose:) revision end diff --git a/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb b/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb new file mode 100644 index 0000000000..cdb74e1348 --- /dev/null +++ b/db/migrate/20261016000000_add_role_to_pageflow_scrolled_storylines.rb @@ -0,0 +1,5 @@ +class AddRoleToPageflowScrolledStorylines < ActiveRecord::Migration[7.2] + def change + add_column :pageflow_scrolled_storylines, :role, :string + end +end diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb index 10e56901a3..2189e245c7 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/chapters_controller.rb @@ -25,7 +25,7 @@ def update def destroy chapter = Chapter.all_for_revision(@entry.draft).find(params[:id]) - chapter.destroy + Trash.new(@entry.draft).add_chapter(chapter) head :no_content rescue ActiveRecord::RecordNotFound diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb index 57e592f237..da7ea53ae9 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/content_elements_controller.rb @@ -17,7 +17,7 @@ def batch section = Section.all_for_revision(@entry.draft).find(params[:section_id]) items = params.require(:content_elements).map do |item| - item.transform_keys(&:underscore).permit(:id, :type_name, :_delete, + item.transform_keys(&:underscore).permit(:id, :type_name, :_delete, :_trash, configuration: {}, migrate_comment_threads: []) end @@ -31,7 +31,8 @@ def batch end def update - content_element = ContentElement.all_for_revision(@entry.draft).find(params[:id]) + content_element = + ContentElement.all_for_revision_including_trash(@entry.draft).find(params[:id]) ContentElement.transaction do content_element.update(content_element_params) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb index 0eb77e346d..605ab4eaa9 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/sections_controller.rb @@ -32,7 +32,7 @@ def update def destroy section = Section.all_for_revision(@entry.draft).find(params[:id]) - section.destroy + Trash.new(@entry.draft).add_section(section) head :no_content rescue ActiveRecord::RecordNotFound diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb new file mode 100644 index 0000000000..d034275daa --- /dev/null +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/trash_items_controller.rb @@ -0,0 +1,46 @@ +module PageflowScrolled + module Editor + # @api private + class TrashItemsController < ActionController::Base + include Pageflow::EditorController + + def index + @items = Trash.new(@entry.draft).items + end + + def restore + restored = Trash.new(@entry.draft).find_item(params[:id]).restore + + case restored + when Chapter + render partial: 'pageflow_scrolled/editor/chapters/chapter_with_sections', + locals: {chapter: restored} + when Section + render partial: 'pageflow_scrolled/editor/sections/section_with_content_elements', + locals: {section: restored} + else + render partial: 'pageflow_scrolled/editor/trash_items/content_elements', + locals: {content_elements: restored} + end + rescue ActiveRecord::RecordNotFound + head :not_found + rescue Trash::DestinationMissing + head :unprocessable_entity + end + + def destroy + Trash.new(@entry.draft).find_item(params[:id]).destroy + + head :no_content + rescue ActiveRecord::RecordNotFound + head :not_found + end + + def empty + Trash.new(@entry.draft).empty + + head :no_content + end + end + end +end diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb index f6ba9897b0..2e1d6eb7e0 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb @@ -25,7 +25,7 @@ def scrolled_theme_stylesheet_pack_tags(theme) end) end - def scrolled_theme_properties_style_tag(theme) + def scrolled_theme_properties_style_tag(theme, include_section_thumbnail_rules: false) declarations = [ scrolled_theme_deep_declarations(theme.options.fetch(:font_family, {}), 'font_family'), scrolled_theme_deep_declarations(theme.options.fetch(:colors, {}), 'color') @@ -38,7 +38,7 @@ def scrolled_theme_properties_style_tag(theme) } #{scrolled_theme_typography_rules(theme)} - #{scrolled_theme_properties_rules(theme)} + #{scrolled_theme_properties_rules(theme, include_section_thumbnail_rules:)} CSS end @@ -63,9 +63,27 @@ def scrolled_theme_typography_rules(theme) RuleSet.new(prefix: 'typography').generate(theme.options.fetch(:typography, {})) end - def scrolled_theme_properties_rules(theme) - RuleSet.new(prefix: 'scope', - custom_properties: true).generate(theme.options.fetch(:properties, {})) + def scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: false) + properties = theme.options.fetch(:properties, {}) + rules = RuleSet.new(prefix: 'scope', custom_properties: true).generate(properties) + return rules unless include_section_thumbnail_rules + + # Section thumbnails set viewport unit custom properties on an + # element with this attribute. Root properties using them only + # resolve against the thumbnail size if declared there again. + viewport_dependent_root_properties = properties.fetch(:root, {}).select do |_, value| + value.is_a?(String) && value.match?(/var\(--s?vh\b/) + end + + return rules if viewport_dependent_root_properties.empty? + + [ + rules, + RuleSet.new(prefix: 'scope', + custom_properties: true, + root_selector: '[data-section-thumbnail]') + .generate(root: viewport_dependent_root_properties) + ].join("\n") end private @@ -232,9 +250,10 @@ def descriptors # @api private class RuleSet - def initialize(prefix:, custom_properties: false) + def initialize(prefix:, custom_properties: false, root_selector: ':root') @prefix = prefix @property_prefix = custom_properties ? '--theme-' : '' + @root_selector = root_selector end def generate(rules) @@ -280,7 +299,7 @@ def generate_rule(rule_name, declarations) end def selector(rule_name) - return ':root' if rule_name == :root + return @root_selector if rule_name == :root ".#{@prefix}-#{rule_name.to_s.camelize(:lower)}" end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb index 2e577a9318..2fe16c964e 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/chapter.rb @@ -41,9 +41,17 @@ def duplicate_section(section) end end + def insert_section(section, position:) + transaction do + shift_section_positions(from: position) + section.update!(chapter: self, position:) + end + end + def self.all_for_revision(revision) joins(storyline: :revision) .where(pageflow_scrolled_storylines: {revision_id: revision}) + .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb index 3561831624..48c933e6e9 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/content_element.rb @@ -19,6 +19,10 @@ def entry_for_auto_generated_perma_id end def self.all_for_revision(revision) + all_for_revision_including_trash(revision).merge(Storyline.excluding_trash) + end + + def self.all_for_revision_including_trash(revision) joins(section: {chapter: {storyline: :revision}}) .where(pageflow_scrolled_storylines: {revision_id: revision}) .order(POSITION_ORDER) @@ -42,9 +46,11 @@ def initialize(section, items, comment_thread_subject_ranges: nil) def save! ContentElement.transaction do + trash_content_elements + result = @items.map.with_index { |item, index| if item[:_delete] - @section.content_elements.delete(item[:id]) + @section.content_elements.delete(item[:id]) unless item[:_trash] nil else create_or_update(item, index) @@ -59,6 +65,33 @@ def save! private + def trash_content_elements + trashed_ids = item_ids { |item| item[:_trash] } + return if trashed_ids.empty? + + content_elements = ContentElement.where(section: @section).order(:position).to_a + trashed = content_elements.select { |ce| trashed_ids.include?(ce.id) } + raise ActiveRecord::RecordNotFound if trashed.size < trashed_ids.size + + Trash.new(@storyline.revision).add_content_elements( + trashed, + origin_section: @section, + origin_position: count_remaining_before(content_elements, trashed_ids) + ) + end + + def count_remaining_before(content_elements, trashed_ids) + deleted_ids = item_ids { |item| item[:_delete] } + + content_elements + .take_while { |ce| trashed_ids.exclude?(ce.id) } + .count { |ce| deleted_ids.exclude?(ce.id) } + end + + def item_ids(&) + @items.select(&).map { |item| item[:id].to_i } + end + def create_or_update(item, index) attributes = { section_id: @section.id, diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb index 2450a1a610..28dd659342 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/linked_entry/chapter_loader.rb @@ -53,6 +53,7 @@ def chapters_by_revision_id PageflowScrolled::Chapter .joins(:storyline) .where(pageflow_scrolled_storylines: {revision_id: revision_ids}) + .merge(PageflowScrolled::Storyline.excluding_trash) .order(PageflowScrolled::Chapter::POSITION_ORDER) .select('pageflow_scrolled_chapters.*', 'pageflow_scrolled_storylines.revision_id') .group_by(&:revision_id) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb index e6cb530b8b..0ff9f0543d 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/revision_content.rb @@ -14,7 +14,7 @@ def storylines end def main_storyline - storylines.detect { |storyline| storyline.configuration['main'] } + storylines.detect { |storyline| storyline.role == 'main' } end def chapters diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb index a4ecb68780..8f1e951ed2 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/section.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/section.rb @@ -18,6 +18,18 @@ class Section < Pageflow::ApplicationRecord # rubocop:todo Style/Documentation nested_revision_components :content_elements + def insert_content_elements(inserted, position:) + transaction do + content_elements + .where('position >= ?', position) + .update_all(['position = position + ?', inserted.size]) + + inserted.each_with_index do |content_element, index| + content_element.update!(section: self, position: position + index) + end + end + end + def entry_for_auto_generated_perma_id chapter.storyline.revision.entry end @@ -25,6 +37,7 @@ def entry_for_auto_generated_perma_id def self.all_for_revision(revision) joins(chapter: {storyline: :revision}) .where(pageflow_scrolled_storylines: {revision_id: revision}) + .merge(Storyline.excluding_trash) .order(POSITION_ORDER) end end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb index 7b5f8b9262..2ccc84b87c 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/storyline.rb @@ -28,13 +28,45 @@ class Storyline < Pageflow::ApplicationRecord # rubocop:todo Style/Documentation nested_revision_components :chapters + scope :excluding_trash, -> { where.not(role: 'trash').or(where(role: nil)) } + + def role + super || (configuration['main'] ? 'main' : 'excursions') + end + + def insert_chapter(chapter, position:) + transaction do + chapters.where('position >= ?', position).update_all('position = position + 1') + chapter.update!(storyline: self, position:) + end + end + def sections_before_cutoff_section sections_before(cutoff_section) end def self.create_defaults(draft) - create!(revision: draft, configuration: {main: true}) - create!(revision: draft, position: 1) + create!(revision: draft, role: 'main') + create!(revision: draft, role: 'excursions', position: 1) + end + + def self.all_for_revision(revision) + super.excluding_trash + end + + def self.find_or_create_trash_for(revision) + revision.with_lock do + where(revision:, role: 'trash').first || + create!(revision:, + role: 'trash', + position: where(revision:).maximum(:position).to_i + 1) + end + end + + def self.after_copy(revision, purpose:) + return if %i[snapshot restore].include?(purpose) + + where(revision:, role: 'trash').destroy_all end private diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb new file mode 100644 index 0000000000..548078b2b1 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash.rb @@ -0,0 +1,97 @@ +module PageflowScrolled + # @api private + class Trash + class DestinationMissing < StandardError; end + + def initialize(revision) + @revision = revision + end + + def add_section(section) + Storyline.transaction do + container = create_container(kind: 'section', + origin_perma_id: section.chapter.perma_id, + origin_position: section.position) + + section.update!(chapter: container, position: 0) + end + end + + def add_content_elements(content_elements, origin_section:, origin_position:) + Storyline.transaction do + container = create_container(kind: 'contentElements', + origin_perma_id: origin_section.perma_id, + origin_position:) + container_section = container.sections.create!( + position: 0, + configuration: origin_section.configuration.slice('invert', 'layout') + ) + + content_elements.each_with_index do |content_element, index| + content_element.update!(section: container_section, position: index) + end + end + end + + def add_chapter(chapter) + chapter.update!( + storyline:, + position: next_position, + configuration: chapter.configuration.merge( + 'trash' => metadata(kind: 'chapter', + origin_perma_id: chapter.storyline.perma_id, + origin_position: chapter.position) + ) + ) + end + + def items + chapters.includes(sections: :content_elements).map do |chapter| + Item.new(chapter, @revision) + end + end + + def find_item(id) + Item.new(chapters.find(id), @revision) + end + + def empty + chapters.destroy_all + end + + private + + def chapters + existing_storyline = Storyline.find_by(revision: @revision, role: 'trash') + return Chapter.none unless existing_storyline + + existing_storyline.chapters.reorder(position: :desc) + end + + def create_container(kind:, origin_perma_id:, origin_position:) + storyline.chapters.create!( + position: next_position, + configuration: { + trash: metadata(kind:, origin_perma_id:, origin_position:) + } + ) + end + + def metadata(kind:, origin_perma_id:, origin_position:) + { + 'kind' => kind, + 'originPermaId' => origin_perma_id, + 'originPosition' => origin_position, + 'trashedAt' => Time.current.utc.iso8601 + } + end + + def next_position + (storyline.chapters.maximum(:position) || -1) + 1 + end + + def storyline + @storyline ||= Storyline.find_or_create_trash_for(@revision) + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb new file mode 100644 index 0000000000..491e638357 --- /dev/null +++ b/entry_types/scrolled/app/models/pageflow_scrolled/trash/item.rb @@ -0,0 +1,108 @@ +module PageflowScrolled + class Trash + # @api private + class Item + delegate :id, :sections, to: :@chapter + + def initialize(chapter, revision) + @chapter = chapter + @revision = revision + end + + def kind + metadata['kind'] + end + + def origin_perma_id + metadata['originPermaId'] + end + + def origin_position + metadata['originPosition'] + end + + def trashed_at + metadata['trashedAt'] + end + + def title + @chapter.configuration['title'] + end + + def restore + Storyline.transaction do + case kind + when 'chapter' then restore_chapter + when 'contentElements' then restore_content_elements + else restore_section + end + end + end + + def destroy + @chapter.destroy + end + + private + + def metadata + @chapter.configuration['trash'] + end + + def restore_chapter + origin_storyline.insert_chapter(@chapter, position: origin_position) + @chapter.update!(configuration: @chapter.configuration.except('trash')) + + @chapter + end + + def restore_section + section = sections.first + + insert_into_origin_chapter(section) + @chapter.reload.destroy + + section + end + + def restore_content_elements + content_elements = sections.first.content_elements.to_a + + insert_into_origin_section(content_elements) + @chapter.reload.destroy + + content_elements + end + + def insert_into_origin_chapter(section) + chapters = Chapter.all_for_revision(@revision) + + if (chapter = chapters.find_by(perma_id: origin_perma_id)) + chapter.insert_section(section, position: origin_position) + else + chapter = chapters.first || raise(DestinationMissing) + chapter.insert_section(section, position: (chapter.sections.maximum(:position) || -1) + 1) + end + end + + def insert_into_origin_section(content_elements) + sections = Section.all_for_revision(@revision) + + if (section = sections.find_by(perma_id: origin_perma_id)) + section.insert_content_elements(content_elements, position: origin_position) + else + section = sections.first || raise(DestinationMissing) + section.insert_content_elements( + content_elements, + position: (section.content_elements.maximum(:position) || -1) + 1 + ) + end + end + + def origin_storyline + Storyline.all_for_revision(@revision).find_by(perma_id: origin_perma_id) || + raise(DestinationMissing) + end + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder new file mode 100644 index 0000000000..799e18956e --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/chapters/_chapter_with_sections.json.jbuilder @@ -0,0 +1,9 @@ +json.key_format!(camelize: :lower) + +json.partial!('pageflow_scrolled/chapters/chapter', chapter:) + +json.sections do + json.array!(chapter.sections) do |section| + json.partial!('pageflow_scrolled/editor/sections/section_with_content_elements', section:) + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb index 2c64561341..1bc9f2ca9b 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/entries/_head.html.erb @@ -1,7 +1,7 @@ <%= stylesheet_link_tag 'pageflow_paged/editor', media: 'all' %> <%= stylesheet_pack_tag 'pageflow-scrolled-frontend' %> -<%= scrolled_theme_properties_style_tag(entry.theme) %> +<%= scrolled_theme_properties_style_tag(entry.theme, include_section_thumbnail_rules: true) %> <%= scrolled_theme_stylesheet_pack_tags(entry.theme) %> <%= javascript_include_tag 'pageflow/vendor' %> diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder new file mode 100644 index 0000000000..96cbb89c35 --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/_content_elements.json.jbuilder @@ -0,0 +1,3 @@ +json.array!(content_elements) do |content_element| + json.partial!('pageflow_scrolled/content_elements/content_element', content_element:) +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder new file mode 100644 index 0000000000..5a8018c38d --- /dev/null +++ b/entry_types/scrolled/app/views/pageflow_scrolled/editor/trash_items/index.json.jbuilder @@ -0,0 +1,13 @@ +json.key_format!(camelize: :lower) + +json.array!(@items) do |item| + json.call(item, :id, :kind, :origin_perma_id, :origin_position, :trashed_at) + json.title item.title + + json.sections do + json.array!(item.sections) do |section| + json.partial!('pageflow_scrolled/editor/sections/section_with_content_elements', + section:) + end + end +end diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb index 95803242eb..2149961859 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb @@ -22,7 +22,10 @@ <%= scrolled_frontend_stylesheet_packs_tag(entry, entry_mode: entry_mode, seed_options: seed_options) %> <%= scrolled_theme_font_preload_link_tags(entry.theme) %> - <%= scrolled_theme_properties_style_tag(entry.theme) %> + <%= scrolled_theme_properties_style_tag( + entry.theme, + include_section_thumbnail_rules: entry_mode == :editor + ) %> <%= scrolled_theme_stylesheet_pack_tags(entry.theme) %> <%= render_widget_head_fragments(entry, scope: entry_mode) %> diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder index 522632b4f3..ff10ad9088 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder +++ b/entry_types/scrolled/app/views/pageflow_scrolled/storylines/_storyline.json.jbuilder @@ -3,5 +3,6 @@ json.key_format!(camelize: :lower) json.call(storyline, :id, :perma_id, + :role, :position, :configuration) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 5c7ecb150a..e86c948022 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1298,10 +1298,13 @@ de: confirm_destroy: |- Kapitel inklusive ALLER Abschnitte wirklich löschen? - Diese Aktion kann nicht rückgängig gemacht werden. + Es kann aus dem Papierkorb wiederhergestellt werden. destroy: Kapitel löschen destroy_content_element_menu_item: - confirm_destroy: Element wirklich löschen? + confirm_destroy: |- + Element wirklich löschen? + + Es kann aus dem Papierkorb wiederhergestellt werden. destroy: Element löschen selection_label: Auswahl löschen duplicate_content_element_menu_item: @@ -1311,7 +1314,10 @@ de: move: Element verschieben... move_selection: Auswahl verschieben... destroy_section_menu_item: - confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? + confirm_destroy: |- + Abschnitt inklusive aller Elemente wirklich löschen? + + Er kann aus dem Papierkorb wiederhergestellt werden. destroy: Abschnitt löschen edit_section: hidden_info: Dieser Abschnitt ist außerhalb des Editors ausgeblendet. @@ -1436,6 +1442,7 @@ de: header: Gliederung finish_reorder_chapters: Umsortieren beenden reorder_chapters: "Kapitel umsortieren..." + trash: Gelöschte Inhalte entry_select: no_matches: Keine passenden Beiträge not_published: Nicht veröffentlicht @@ -1781,6 +1788,38 @@ de: back: Kommentare tabs: newComment: Neues Thema + trash_view: + actions: Aktionen + back: Gliederung + blank_slate: Aus diesem Beitrag wurde noch nichts gelöscht. + chapter: + one: "Kapitel „%{title}“ mit 1 Abschnitt" + other: "Kapitel „%{title}“ mit %{count} Abschnitten" + content_elements: "%{types} aus %{section}" + content_elements_origin_missing: "%{types} aus einem gelöschten Abschnitt" + delete_item: + confirm_destroy: |- + Diesen Inhalt endgültig löschen? + + Diese Aktion kann nicht rückgängig gemacht werden. + destroy: Endgültig löschen + delete_failed: Der Inhalt konnte nicht gelöscht werden. + empty_failed: Der Papierkorb konnte nicht geleert werden. + empty_trash: + confirm_destroy: |- + Alle Inhalte im Papierkorb endgültig löschen? + + Diese Aktion kann nicht rückgängig gemacht werden. + destroy: Papierkorb leeren + hint: Aus diesem Beitrag gelöschte Kapitel, Abschnitte und Inhaltselemente können hier wiederhergestellt werden. + load_failed: Gelöschte Inhalte konnten nicht geladen werden. + origin: "Abschnitt aus %{chapter}" + origin_missing: Abschnitt aus einem gelöschten Kapitel + restore: Wiederherstellen + restore_chapter_failed: Das Kapitel konnte nicht wiederhergestellt werden. + restore_content_elements_failed: Die Inhaltselemente konnten nicht wiederhergestellt werden. + restore_failed: Der Abschnitt konnte nicht wiederhergestellt werden. + trashed_at: "Gelöscht %{time}" configuration_places: entry: "Einstellungen" label: "%{chapter} - %{subject}" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 17a510eebb..6246f5faa3 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1280,10 +1280,13 @@ en: confirm_destroy: |- Really delete this chapter including ALL its sections? - This operation cannot be undone. + It can be restored from the trash. destroy: Delete chapter destroy_content_element_menu_item: - confirm_destroy: Really delete this element? + confirm_destroy: |- + Really delete this element? + + It can be restored from the trash. destroy: Delete element selection_label: Delete selection duplicate_content_element_menu_item: @@ -1293,7 +1296,10 @@ en: move: Move element... move_selection: Move selection... destroy_section_menu_item: - confirm_destroy: Really delete this section including all its elements? + confirm_destroy: |- + Really delete this section including all its elements? + + It can be restored from the trash. destroy: Delete section edit_section: hidden_info: This section is hidden outside of the editor. @@ -1418,6 +1424,7 @@ en: header: Outline finish_reorder_chapters: Finish reordering reorder_chapters: "Reorder chapters..." + trash: Deleted content entry_select: no_matches: No matching stories not_published: Not published @@ -1761,6 +1768,38 @@ en: back: Comments tabs: newComment: New topic + trash_view: + actions: Actions + back: Outline + blank_slate: Nothing has been deleted from this story yet. + chapter: + one: "Chapter “%{title}” with 1 section" + other: "Chapter “%{title}” with %{count} sections" + content_elements: "%{types} from %{section}" + content_elements_origin_missing: "%{types} from a deleted section" + delete_item: + confirm_destroy: |- + Permanently delete this item? + + This cannot be undone. + destroy: Delete permanently + delete_failed: Could not delete the item. + empty_failed: Could not empty the trash. + empty_trash: + confirm_destroy: |- + Permanently delete all items in the trash? + + This cannot be undone. + destroy: Empty trash + hint: Chapters, sections and content elements deleted from this story can be restored here. + load_failed: Deleted content could not be loaded. + origin: "Section from %{chapter}" + origin_missing: Section from a deleted chapter + restore: Restore + restore_chapter_failed: Could not restore the chapter. + restore_content_elements_failed: Could not restore the content elements. + restore_failed: Could not restore the section. + trashed_at: "Deleted %{time}" configuration_places: entry: "Settings" label: "%{chapter} - %{subject}" diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 8cb3812120..13d10efb8d 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -2,6 +2,15 @@ scope module: 'editor' do resources :fragment_libraries, only: [:index] resources :linkable_entries, only: [:index, :show] + resources :trash_items, only: [:index, :destroy] do + member do + post :restore + end + + collection do + delete :empty + end + end shallow do # Legacy path to support editor sessions that span the deploy that diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 72ce70c699..e6d9550c54 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -20,11 +20,11 @@ def configure(config) 'xxs' => '3em', 'xs' => '4.375em', 'sm' => '6em', - 'md' => 'max(7em, 10svh)', - 'lg' => 'max(8em, 10svh)', - 'xl' => 'max(9em, 15svh)', - 'xxl' => 'max(10em, 20svh)', - 'xxxl' => 'max(11em, 30svh)' + 'md' => 'max(7em, calc(10 * var(--svh, 1svh)))', + 'lg' => 'max(8em, calc(10 * var(--svh, 1svh)))', + 'xl' => 'max(9em, calc(15 * var(--svh, 1svh)))', + 'xxl' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'xxxl' => 'max(11em, calc(30 * var(--svh, 1svh)))' } margin_scale = { @@ -101,8 +101,8 @@ def configure(config) c.themes.register_default_options( properties: { root: { - 'section_default_padding_top' => 'max(10em, 20svh)', - 'section_default_padding_bottom' => 'max(10em, 20svh)', + 'section_default_padding_top' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'section_default_padding_bottom' => 'max(10em, calc(20 * var(--svh, 1svh)))', 'content_element_margin_style_default' => '2em', 'content_element_box_shadow_style_default' => '0 4px 6px -1px rgb(0 0 0 / 0.2), 0 2px 4px -2px rgb(0 0 0 / 0.15)', @@ -112,8 +112,8 @@ def configure(config) 'outline_color' => '#a0a0a080' }, cards_appearance_section: { - 'section_default_padding_top' => 'max(10em, 20svh)', - 'section_default_padding_bottom' => 'max(10em, 20svh)' + 'section_default_padding_top' => 'max(10em, calc(20 * var(--svh, 1svh)))', + 'section_default_padding_bottom' => 'max(10em, calc(20 * var(--svh, 1svh)))' } } ) @@ -194,7 +194,7 @@ def configure(config) c.file_types.register(Pageflow::BuiltInFileType.audio) c.file_types.register(Pageflow::BuiltInFileType.other) - c.revision_components.register(Storyline, create_defaults: true) + c.revision_components.register(Storyline, create_defaults: true, after_copy: true) ['tikTokEmbed', 'twitterEmbed', 'hotspots', 'socialEmbed', 'videoEmbed', 'inlineBeforeAfter'].each do |name| diff --git a/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js index 11153c437f..91b3ed1111 100644 --- a/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/FragmentLibrariesCollection-spec.js @@ -7,7 +7,7 @@ describe('FragmentLibrariesCollection', () => { return { ...attributes, collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters, sections, contentElements, diff --git a/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js index 5767de67f7..8bb548f938 100644 --- a/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js +++ b/entry_types/scrolled/package/spec/editor/collections/SectionsCollection-spec.js @@ -8,7 +8,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -32,7 +32,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -54,7 +54,7 @@ describe('SectionsCollection', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, position: 0, storylineId: 100}, diff --git a/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js new file mode 100644 index 0000000000..2029b116f1 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/collections/TrashItemsCollection-spec.js @@ -0,0 +1,506 @@ +import {useFakeXhr, useEditorGlobals} from 'support'; + +describe('TrashItemsCollection', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + function respondWithJson(server, method, url, status, body) { + server.respond(method, url, [status, {'Content-Type': 'application/json'}, + JSON.stringify(body)]); + } + + it('fetches trash items of entry', () => { + testContext.entry = createEntry({}); + const {entry, server} = testContext; + + entry.trashItems.fetch(); + respondWithJson(server, 'GET', '/editor/entries/1/scrolled/trash_items', 200, [ + {id: 5, kind: 'section', sections: []} + ]); + + expect(entry.trashItems.pluck('id')).toEqual([5]); + }); + + it('provides seed with sections and content elements of trash item', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + sections: [ + {id: 50, permaId: 500, contentElements: [{id: 60, permaId: 600, sectionId: 50}]} + ] + }); + + const seed = entry.trashItems.get(5).seed({config: {}, collections: {chapters: []}}); + + expect(seed.config).toEqual({}); + expect(seed.collections.chapters).toEqual([]); + expect(seed.collections.sections).toEqual([{id: 50, permaId: 500}]); + expect(seed.collections.contentElements) + .toEqual([{id: 60, permaId: 600, sectionId: 50}]); + }); + + it('renders content elements in full height section with small paddings in preview seed', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + kind: 'contentElements', + sections: [ + {id: 50, permaId: 500, configuration: {layout: 'right', invert: true}, + contentElements: []} + ] + }); + const entrySeed = { + config: { + theme: { + options: { + properties: { + root: { + 'sectionPaddingTop-none': '0', + 'sectionPaddingTop-sm': '1em', + 'sectionPaddingTop-lg': '4em', + 'sectionPaddingBottom-none': '0', + 'sectionPaddingBottom-xs': '0.5em' + } + } + } + } + }, + collections: {} + }; + + const seed = entry.trashItems.get(5).seed(entrySeed); + + expect(seed.collections.sections[0].configuration).toEqual({ + layout: 'right', + invert: true, + fullHeight: true, + paddingTop: 'sm', + paddingBottom: 'xs' + }); + }); + + it('keeps section configuration of section items in preview seed', () => { + testContext.entry = createEntry({}); + const {entry} = testContext; + entry.trashItems.add({ + id: 5, + kind: 'section', + sections: [ + {id: 50, permaId: 500, configuration: {layout: 'left'}, contentElements: []} + ] + }); + + const seed = entry.trashItems.get(5).seed({config: {}, collections: {}}); + + expect(seed.collections.sections[0].configuration).toEqual({layout: 'left'}); + }); + + describe('#restore', () => { + beforeEach(() => { + testContext.entry = createEntry({ + chapters: [{id: 10, permaId: 1000}], + sections: [ + {id: 100, chapterId: 10, position: 0}, + {id: 102, chapterId: 10, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 5, kind: 'section', originPermaId: 1000, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredSection(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 200, { + id: 101, + permaId: 501, + chapterId: 10, + position: 1, + configuration: {transition: 'fade'}, + contentElements: [{id: 1000, permaId: 601, sectionId: 101, typeName: 'heading'}] + }); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(5).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/5/restore'); + }); + + it('inserts restored section into origin chapter', async () => { + const {entry, server} = testContext; + const chapter = entry.chapters.get(10); + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(chapter.sections.pluck('id')).toEqual([100, 101, 102]); + expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]); + expect(chapter.sections.get(101).configuration.get('transition')).toEqual('fade'); + }); + + it('adds content elements of restored section', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(entry.sections.get(101).contentElements.pluck('id')).toEqual([1000]); + }); + + it('does not add section before restore succeeds', () => { + const {entry} = testContext; + + entry.trashItems.get(5).restore(); + + expect(entry.chapters.get(10).sections.pluck('id')).toEqual([100, 102]); + }); + + it('appends section to first chapter if origin chapter is missing', async () => { + const {entry, server} = testContext; + entry.trashItems.add({id: 6, kind: 'section', originPermaId: 999, originPosition: 0, + sections: []}); + + const promise = entry.trashItems.get(6).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/6/restore', 200, { + id: 103, permaId: 503, chapterId: 10, position: 2, configuration: {}, + contentElements: [] + }); + await promise; + + expect(entry.chapters.get(10).sections.pluck('id')).toEqual([100, 102, 103]); + expect(entry.chapters.get(10).sections.pluck('position')).toEqual([0, 1, 2]); + }); + + it('rejects without request if no chapter exists', async () => { + const {requests} = testContext; + const entry = createEntry({}); + entry.trashItems.add({id: 6, kind: 'section', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(6).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithRestoredSection(server); + await promise; + + expect(entry.trashItems.length).toEqual(0); + }); + + it('keeps trash item if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.trashItems.pluck('id')).toEqual([5]); + }); + + it('keeps positions of sections if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.chapters.get(10).sections.pluck('position')).toEqual([0, 1]); + }); + }); + + describe('#restore of chapter', () => { + beforeEach(() => { + testContext.entry = createEntry({ + storylines: [ + {id: 1, permaId: 10, role: 'main'}, + {id: 2, permaId: 20, configuration: {}} + ], + chapters: [ + {id: 10, storylineId: 1, position: 0}, + {id: 12, storylineId: 1, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 11, kind: 'chapter', originPermaId: 10, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredChapter(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/11/restore', 200, { + id: 11, + permaId: 110, + storylineId: 1, + position: 1, + configuration: {title: 'Intro'}, + sections: [ + { + id: 101, + permaId: 501, + chapterId: 11, + position: 0, + configuration: {}, + contentElements: [{id: 1000, permaId: 601, sectionId: 101, typeName: 'heading'}] + } + ] + }); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(11).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/11/restore'); + }); + + it('inserts restored chapter into origin storyline', async () => { + const {entry, server} = testContext; + const storyline = entry.storylines.get(1); + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(storyline.chapters.pluck('id')).toEqual([10, 11, 12]); + expect(storyline.chapters.pluck('position')).toEqual([0, 1, 2]); + expect(entry.chapters.get(11).configuration.get('title')).toEqual('Intro'); + }); + + it('adds sections and content elements of restored chapter', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.chapters.get(11).sections.pluck('id')).toEqual([101]); + expect(entry.sections.get(101).contentElements.pluck('id')).toEqual([1000]); + }); + + it('selects and scrolls to first section of restored chapter', async () => { + const {entry, server} = testContext; + const events = []; + entry.on('selectSection scrollToSection', section => events.push(section.id)); + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(events).toEqual([101, 101]); + }); + + it('keeps resource url of restored chapter', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.chapters.get(11).url()) + .toEqual('/editor/entries/1/scrolled/chapters/11'); + }); + + it('rejects without request if origin storyline is missing', async () => { + const {entry, requests} = testContext; + entry.trashItems.add({id: 13, kind: 'chapter', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(13).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('keeps positions of chapters if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/11/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.storylines.get(1).chapters.pluck('position')).toEqual([0, 1]); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(11).restore(); + respondWithRestoredChapter(server); + await promise; + + expect(entry.trashItems.get(11)).toBeUndefined(); + }); + }); + + describe('#restore of content elements', () => { + beforeEach(() => { + testContext.entry = createEntry({ + sections: [{id: 10, permaId: 100}], + contentElements: [ + {id: 1, sectionId: 10, position: 0}, + {id: 3, sectionId: 10, position: 1} + ] + }); + testContext.entry.trashItems.add({ + id: 20, kind: 'contentElements', originPermaId: 100, originPosition: 1, sections: [] + }); + }); + + function respondWithRestoredContentElements(server) { + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/20/restore', 200, [ + {id: 2, permaId: 22, sectionId: 10, position: 1, typeName: 'heading', + configuration: {}} + ]); + } + + it('posts to restore endpoint of trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(20).restore(); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/20/restore'); + }); + + it('inserts restored content elements into origin section', async () => { + const {entry, server} = testContext; + const section = entry.sections.get(10); + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(section.contentElements.pluck('id')).toEqual([1, 2, 3]); + expect(section.contentElements.pluck('position')).toEqual([0, 1, 2]); + }); + + it('removes trash item', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(entry.trashItems.get(20)).toBeUndefined(); + }); + + it('selects and scrolls to first restored content element', async () => { + const {entry, server} = testContext; + const events = []; + entry.on('selectContentElement scrollToContentElement', + contentElement => events.push(contentElement.id)); + + const promise = entry.trashItems.get(20).restore(); + respondWithRestoredContentElements(server); + await promise; + + expect(events).toEqual([2, 2]); + }); + + it('appends content elements to first section if origin section is missing', async () => { + const {entry, server} = testContext; + entry.trashItems.add({id: 21, kind: 'contentElements', originPermaId: 999, + originPosition: 0, sections: []}); + + const promise = entry.trashItems.get(21).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/21/restore', 200, [ + {id: 4, permaId: 44, sectionId: 10, position: 2, typeName: 'heading', configuration: {}} + ]); + await promise; + + expect(entry.sections.get(10).contentElements.pluck('id')).toEqual([1, 3, 4]); + expect(entry.sections.get(10).contentElements.pluck('position')).toEqual([0, 1, 2]); + }); + + it('rejects without request if no section exists', async () => { + const {requests} = testContext; + const entry = createEntry({}); + entry.trashItems.add({id: 21, kind: 'contentElements', originPermaId: 999, sections: []}); + + await expect(entry.trashItems.get(21).restore()).rejects.toThrow(); + + expect(requests).toHaveLength(0); + }); + + it('keeps trash item if restore fails', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(20).restore(); + respondWithJson(server, 'POST', '/editor/entries/1/scrolled/trash_items/20/restore', 422, {}); + await promise.catch(() => {}); + + expect(entry.trashItems.pluck('id')).toEqual([20]); + }); + }); + + describe('#permanentlyDelete', () => { + beforeEach(() => { + testContext.entry = createEntry({}); + testContext.entry.trashItems.add([{id: 5, sections: []}, {id: 6, sections: []}]); + }); + + it('sends delete request for trash item', () => { + const {entry, requests} = testContext; + + entry.trashItems.get(5).permanentlyDelete(); + + expect(requests[0].method).toBe('DELETE'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/5'); + }); + + it('removes trash item once deleted', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.get(5).permanentlyDelete(); + expect(entry.trashItems.pluck('id')).toEqual([5, 6]); + server.respond('DELETE', '/editor/entries/1/scrolled/trash_items/5', [204, {}, '']); + await promise; + + expect(entry.trashItems.pluck('id')).toEqual([6]); + }); + }); + + describe('#empty', () => { + beforeEach(() => { + testContext.entry = createEntry({}); + testContext.entry.trashItems.add([{id: 5, sections: []}, {id: 6, sections: []}]); + }); + + it('sends delete request to empty endpoint', () => { + const {entry, requests} = testContext; + + entry.trashItems.empty(); + + expect(requests[0].method).toBe('DELETE'); + expect(requests[0].url).toBe('/editor/entries/1/scrolled/trash_items/empty'); + }); + + it('removes all trash items once emptied', async () => { + const {entry, server} = testContext; + + const promise = entry.trashItems.empty(); + expect(entry.trashItems.length).toEqual(2); + server.respond('DELETE', '/editor/entries/1/scrolled/trash_items/empty', [204, {}, '']); + await promise; + + expect(entry.trashItems.length).toEqual(0); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js index 323874860e..4f01008f3a 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -692,7 +692,7 @@ describe('Chapter', () => { return factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -715,6 +715,20 @@ describe('Chapter', () => { it('returns excursion label for untitled excursion', () => { expect(createEntry().chapters.get(3).getDisplayName()).toEqual('Excursion'); }); + + it('numbers chapters by their index ignoring gaps in positions', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + storylines: [{id: 100, role: 'main'}], + chapters: [ + {id: 1, storylineId: 100, position: 0}, + {id: 2, storylineId: 100, position: 3} + ] + }) + }); + + expect(entry.chapters.get(2).getDisplayName()).toEqual('Chapter 2'); + }); }); describe('#isExcursion', () => { @@ -722,7 +736,7 @@ describe('Chapter', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -739,7 +753,7 @@ describe('Chapter', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -762,7 +776,7 @@ describe('Chapter', () => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js index b2fa8423b8..7acf230e8a 100644 --- a/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/LinkableEntry-spec.js @@ -43,7 +43,7 @@ describe('LinkableEntry', () => { chapters: [], sections: [], collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters: [], sections: [], contentElements: [], diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js index 00456d69e9..7a4466eb52 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/chapterSlugs-spec.js @@ -23,7 +23,7 @@ describe('ScrolledEntry', () => { {id: 1, permaId: 100, storylineId: 2, position: 0, configuration: {title: 'Intro'}}, {id: 2, permaId: 200, storylineId: 1, position: 1, configuration: {title: 'Intro'}} ], [ - {id: 1, position: 0, configuration: {main: true}}, + {id: 1, position: 0, role: 'main'}, {id: 2, position: 1, configuration: {}} ]); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js index c07f8086d0..37932e98cc 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/deleteContentElement-spec.js @@ -34,7 +34,7 @@ describe('ScrolledEntry', () => { entry: () => testContext.entry }); - it('sends item with delete flag to batch endpoint', () => { + it('sends item with delete and trash flags to batch endpoint', () => { const {entry, requests} = testContext; entry.deleteContentElement(entry.contentElements.get(5)); @@ -43,7 +43,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toMatchObject({ content_elements: [ {id: 6}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); @@ -119,7 +119,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toEqual({ content_elements: [ {id: 4, configuration: {items: ['a', 'b', 'c']}}, - {id: 5, _delete: true}, + {id: 5, _delete: true, _trash: true}, {id: 6, _delete: true} ] }); @@ -199,7 +199,7 @@ describe('ScrolledEntry', () => { content_elements: [ {id: 4}, {id: 6}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); }); @@ -251,7 +251,7 @@ describe('ScrolledEntry', () => { expect(JSON.parse(requests[0].requestBody)).toEqual({ content_elements: [ {id: 4}, - {id: 5, _delete: true} + {id: 5, _delete: true, _trash: true} ] }); }); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js new file mode 100644 index 0000000000..678d2bfb3f --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/unseenTrashItems-spec.js @@ -0,0 +1,62 @@ +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('ScrolledEntry hasUnseenTrashItems', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + beforeEach(() => { + testContext.entry = createEntry({ + chapters: [{id: 1, permaId: 10}], + sections: [{id: 10, permaId: 100, chapterId: 1}], + contentElements: [{id: 5, permaId: 50, sectionId: 10, typeName: 'heading'}] + }); + }); + + it('is set once a chapter has been deleted', () => { + const {entry, server} = testContext; + + entry.chapters.get(1).destroyWithDelay(); + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + server.respond('DELETE', '/editor/entries/1/scrolled/chapters/1', [204, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is set once a section has been deleted', () => { + const {entry, server} = testContext; + + entry.sections.get(10).destroyWithDelay(); + server.respond('DELETE', '/editor/entries/1/scrolled/sections/10', [204, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is set once a content element has been deleted', () => { + const {entry, server} = testContext; + + entry.deleteContentElement(entry.contentElements.get(5)); + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + server.respond( + 'PUT', '/editor/entries/1/scrolled/sections/10/content_elements/batch', + [200, {'Content-Type': 'application/json'}, '[]'] + ); + + expect(entry.get('hasUnseenTrashItems')).toBe(true); + }); + + it('is not set if deletion fails', () => { + const {entry, server} = testContext; + + entry.sections.get(10).destroyWithDelay(); + server.respond('DELETE', '/editor/entries/1/scrolled/sections/10', [500, {}, '']); + + expect(entry.get('hasUnseenTrashItems')).toBeFalsy(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/Section-spec.js b/entry_types/scrolled/package/spec/editor/models/Section-spec.js index c018c9caaa..254dc3b87d 100644 --- a/entry_types/scrolled/package/spec/editor/models/Section-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Section-spec.js @@ -52,7 +52,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -92,7 +92,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -118,7 +118,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -144,7 +144,7 @@ describe('Section', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js b/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js index 9a975086ab..09e1f02027 100644 --- a/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Storyline-spec.js @@ -14,7 +14,7 @@ describe('Storyline', () => { beforeEach(() => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}], + storylines: [{id: 10, role: 'main'}], chapters: [{storylineId: 10, position: 5}] }) }); @@ -41,7 +41,7 @@ describe('Storyline', () => { beforeEach(() => { testContext.entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}, {id: 20}], + storylines: [{id: 10, role: 'main'}, {id: 20}], chapters: [ {id: 1, storylineId: 10, position: 0}, {id: 2, storylineId: 10, position: 1}, @@ -71,7 +71,7 @@ describe('Storyline', () => { it('sets position to 0 for empty storyline', () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ - storylines: [{id: 10}, {id: 20}], + storylines: [{id: 10, role: 'main'}, {id: 20}], chapters: [ {id: 1, storylineId: 10, position: 0} ] diff --git a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js index d2680510cf..bb8f77fa00 100644 --- a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js @@ -69,7 +69,7 @@ describe('ChapterMenuItems', () => { it('has Move to main label when chapter is an excursion', () => { const entry = createEntry({ - storylines: [{id: 1, configuration: {main: true}}, {id: 2}], + storylines: [{id: 1, role: 'main'}, {id: 2}], chapters: [{id: 1, storylineId: 2}] }); const chapter = entry.chapters.get(1); diff --git a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js index 15b65cec6f..4ec1931276 100644 --- a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js @@ -47,6 +47,38 @@ describe('ChapterItemView', () => { expect(screen.getByRole('link', {name: /Chapter 1 Some title/})).toBeInTheDocument(); }); + it('updates number when preceding chapter is removed', () => { + const entry = createEntry({ + chapters: [ + {id: 1, permaId: 100, position: 0, configuration: {title: 'First'}}, + {id: 2, permaId: 101, position: 1, configuration: {title: 'Second'}} + ] + }); + const view = new ChapterItemView({ + entry, + model: entry.chapters.get(2) + }); + + render(view); + entry.chapters.remove(entry.chapters.get(1)); + + expect(screen.getByRole('link', {name: /Chapter 1 Second/})).toBeInTheDocument(); + }); + + it('does not fail when own chapter is removed', () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}] + }); + const view = new ChapterItemView({ + entry, + model: entry.chapters.get(1) + }); + + render(view); + + expect(() => entry.chapters.remove(entry.chapters.get(1))).not.toThrow(); + }); + it('marks chapter as hidden in navigation', async () => { const entry = createEntry({ chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js index 6d9f1a10d1..c16fe9439e 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js @@ -14,7 +14,7 @@ describe('EditChapterView', () => { it('does not offer adding chapter to shared fragments without fragments feature', () => { const entry = createEntry({ - storylines: [{id: 100, configuration: {main: true}}], + storylines: [{id: 100, role: 'main'}], chapters: [{id: 1, storylineId: 100}] }); @@ -32,7 +32,7 @@ describe('EditChapterView', () => { it('offers adding chapter to shared fragments', () => { const entry = createEntry({ - storylines: [{id: 100, configuration: {main: true}}], + storylines: [{id: 100, role: 'main'}], chapters: [{id: 1, storylineId: 100}] }); @@ -49,7 +49,7 @@ describe('EditChapterView', () => { it('uses chapter tab for main storyline chapters', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}} + {id: 100, role: 'main'} ], chapters: [ {id: 1, storylineId: 100} @@ -71,7 +71,7 @@ describe('EditChapterView', () => { it('uses excursion tab for excursion chapters', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js index 878e1cf98c..1e8ba1dd09 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js @@ -150,7 +150,7 @@ describe('EntryCommentsView', () => { it('groups excursion chapters below main chapters', () => { const entry = createEntry({ storylines: [ - {id: 1000, permaId: 100, position: 0, configuration: {main: true}}, + {id: 1000, permaId: 100, position: 0, role: 'main'}, {id: 2000, permaId: 200, position: 1, configuration: {}} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index b111bf4f09..b85b6c3d28 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -10,7 +10,8 @@ describe('EntryOutlineView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.storylines_tabs.main': 'Main', 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions', - 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.' + 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.', + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' }); const {createEntry} = useEditorGlobals(); @@ -73,7 +74,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -123,7 +124,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -170,7 +171,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -219,7 +220,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -268,7 +269,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -319,7 +320,7 @@ describe('EntryOutlineView', () => { id: 10, permaId: 200, position: 0, - configuration: {main: true} + role: 'main' }, { id: 11, @@ -362,4 +363,30 @@ describe('EntryOutlineView', () => { expect(screen.queryByText(/Excursions provide additional content outside the main path/)).toBeNull(); }); + + it('renders trash button with tooltip', () => { + const entry = createEntry({}); + + render(new EntryOutlineView({entry})); + + expect(screen.getByRole('button', {name: 'Deleted content'})) + .toHaveAttribute('title', 'Deleted content'); + }); + + it('renders trash button for entries with excursions', () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [ + {id: 1, permaId: 100, position: 0, storylineId: 10}, + {id: 2, permaId: 101, position: 0, storylineId: 11} + ] + }); + + render(new EntryOutlineView({entry})); + + expect(screen.getByRole('button', {name: 'Deleted content'})).toBeInTheDocument(); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js index eac5d8217a..4388a275f4 100644 --- a/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/FragmentLibraryDialogView-spec.js @@ -37,7 +37,7 @@ describe('FragmentLibraryDialogView', () => { function library(attributes, chapters, sections) { return { collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters, sections, contentElements: [], @@ -182,7 +182,7 @@ describe('FragmentLibraryDialogView preview pane', () => { id: 3, title: 'Templates', collections: { - storylines: [{id: 1, permaId: 10, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, role: 'main'}], chapters: [{id: 1, permaId: 100, configuration: {title: 'Intro'}}, {id: 2, permaId: 200, configuration: {title: 'Outro'}}], sections: [{id: 10, permaId: 1000, chapterId: 1, configuration: {}}, diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js index 62431f73f3..7d4c0ae825 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -173,7 +173,7 @@ describe('SectionItemView', () => { it('marks section as active when in excursion and section is current in that excursion', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ @@ -202,7 +202,7 @@ describe('SectionItemView', () => { it('does not mark section as active when in excursion but belongs to different chapter', () => { const entry = createEntry({ storylines: [ - {id: 100, configuration: {main: true}}, + {id: 100, role: 'main'}, {id: 200} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js index 6dd91aa732..d842ac7e04 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectLinkDestinationDialogView-spec.js @@ -182,7 +182,7 @@ describe('SelectLinkDestinationDialogView', () => { chapters: chapterDestinations || destinations(signatures.chapters), sections: destinations(signatures.sections), collections: { - storylines: [{id: 1, permaId: 1, configuration: {main: true}}], + storylines: [{id: 1, permaId: 1, role: 'main'}], chapters, sections, contentElements: [] diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js index 1053859b7c..369575ef73 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -299,7 +299,7 @@ describe('SelectableEntryOutlineView', () => { return factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js new file mode 100644 index 0000000000..b902a8d1b9 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/TrashButtonView-spec.js @@ -0,0 +1,116 @@ +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import {screen} from '@testing-library/dom'; + +import {editor} from 'pageflow-scrolled/editor'; + +import {TrashButtonView} from 'editor/views/TrashButtonView'; +import styles from 'editor/views/TrashButtonView.module.css'; + +import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; + +describe('TrashButtonView', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' + }); + + const {createEntry} = useEditorGlobals(); + + function createView(entry = createEntry({})) { + const nudgeContainer = document.createElement('div'); + document.body.appendChild(nudgeContainer); + + return new TrashButtonView({entry, nudgeContainer}); + } + + function nextFrame() { + return new Promise(resolve => window.requestAnimationFrame(resolve)); + } + + it('renders button with tooltip', () => { + render(createView()); + + expect(screen.getByRole('button', {name: 'Deleted content'})) + .toHaveAttribute('title', 'Deleted content'); + }); + + it('navigates to trash when clicked', async () => { + const user = userEvent.setup(); + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + render(createView()); + + await user.click(screen.getByRole('button', {name: 'Deleted content'})); + + expect(navigate).toHaveBeenCalledWith('/scrolled/trash', {trigger: true}); + navigate.mockRestore(); + }); + + describe('nudge', () => { + it('nudges when rendered with unseen trash items', async () => { + const entry = createEntry({}); + entry.set('hasUnseenTrashItems', true); + const view = createView(entry); + + render(view); + await nextFrame(); + + expect(view.el).toHaveClass(styles.nudging); + expect(entry.get('hasUnseenTrashItems')).toBe(false); + }); + + it('nudges when trash items become unseen while shown', async () => { + const entry = createEntry({}); + const view = createView(entry); + + render(view); + entry.set('hasUnseenTrashItems', true); + await nextFrame(); + + expect(view.el).toHaveClass(styles.nudging); + expect(entry.get('hasUnseenTrashItems')).toBe(false); + }); + + it('does not nudge without unseen trash items', async () => { + const view = createView(); + + render(view); + await nextFrame(); + + expect(view.el).not.toHaveClass(styles.nudging); + }); + + it('centers contracting circle on button inside nudge container', async () => { + const entry = createEntry({}); + const view = createView(entry); + render(view); + jest.spyOn(view.options.nudgeContainer, 'getBoundingClientRect') + .mockReturnValue({left: 100, top: 50, width: 300, height: 500}); + jest.spyOn(view.el, 'getBoundingClientRect') + .mockReturnValue({left: 350, top: 60, width: 32, height: 36}); + + entry.set('hasUnseenTrashItems', true); + await nextFrame(); + + const circle = view.options.nudgeContainer.querySelector(`.${styles.circle}`); + expect(circle).toHaveClass(styles.nudging); + expect(circle.style.left).toBe('266px'); + expect(circle.style.top).toBe('28px'); + }); + + it('stops nudging once icon animation ended', async () => { + const entry = createEntry({}); + entry.set('hasUnseenTrashItems', true); + const view = createView(entry); + + render(view); + await nextFrame(); + view.el.querySelector(`.${styles.icon}`) + .dispatchEvent(new Event('animationend', {bubbles: true})); + + expect(view.el).not.toHaveClass(styles.nudging); + expect(view.options.nudgeContainer.querySelector(`.${styles.circle}`)) + .not.toHaveClass(styles.nudging); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js new file mode 100644 index 0000000000..c4644af677 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/TrashView-spec.js @@ -0,0 +1,383 @@ +import '@testing-library/jest-dom/extend-expect'; +import {fireEvent, waitFor, within} from '@testing-library/dom'; + +import {editor} from 'pageflow-scrolled/editor'; + +import {TrashView} from 'editor/views/TrashView'; +import styles from 'editor/views/TrashView.module.css'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('TrashView', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + const {createEntry} = useEditorGlobals(); + + useFakeXhr(() => testContext); + + useFakeTranslations({ + 'pageflow_scrolled.editor.chapter_item.chapter': 'Chapter', + 'pageflow_scrolled.editor.trash_view.back': 'Outline', + 'pageflow_scrolled.editor.trash_view.blank_slate': 'Nothing deleted', + 'pageflow_scrolled.editor.trash_view.load_failed': 'Could not load', + 'pageflow_scrolled.editor.trash_view.actions': 'Actions', + 'pageflow_scrolled.editor.trash_view.empty_trash.destroy': 'Empty trash', + 'pageflow_scrolled.editor.trash_view.empty_trash.confirm_destroy': 'Empty?', + 'pageflow_scrolled.editor.trash_view.delete_item.destroy': 'Delete permanently', + 'pageflow_scrolled.editor.trash_view.delete_item.confirm_destroy': 'Delete?', + 'pageflow_scrolled.editor.trash_view.empty_failed': 'Could not empty', + 'pageflow_scrolled.editor.trash_view.delete_failed': 'Could not delete', + 'pageflow_scrolled.editor.trash_view.hint': 'Deleted sections can be restored here.', + 'pageflow_scrolled.editor.trash_view.origin': 'Section from %{chapter}', + 'pageflow_scrolled.editor.trash_view.origin_missing': 'Section from deleted chapter', + 'pageflow_scrolled.editor.trash_view.restore': 'Restore', + 'pageflow_scrolled.editor.trash_view.restore_failed': 'Could not restore', + 'pageflow_scrolled.editor.trash_view.restore_chapter_failed': 'Could not restore chapter', + 'pageflow_scrolled.editor.trash_view.trashed_at': 'Deleted %{time}', + 'pageflow_scrolled.editor.trash_view.chapter.one': 'Chapter %{title} with 1 section', + 'pageflow_scrolled.editor.trash_view.chapter.other': + 'Chapter %{title} with %{count} sections', + 'pageflow_scrolled.editor.chapter_item.unnamed': 'Untitled', + 'pageflow_scrolled.editor.trash_view.content_elements': '%{types} from %{section}', + 'pageflow_scrolled.editor.trash_view.content_elements_origin_missing': + '%{types} from deleted section', + 'pageflow_scrolled.editor.trash_view.restore_content_elements_failed': + 'Could not restore elements', + 'pageflow_scrolled.editor.configuration_places.label': '%{chapter} - %{subject}', + 'pageflow_scrolled.editor.configuration_places.section': 'Section %{number}', + 'pageflow_scrolled.editor.content_elements.heading.name': 'Heading', + 'pageflow_scrolled.editor.content_elements.inlineImage.name': 'Image' + }); + + function trashItem({id, originPermaId, sectionId = id * 10}) { + return { + id, + kind: 'section', + originPermaId, + originPosition: 0, + trashedAt: '2026-10-01T12:00:00Z', + sections: [ + {id: sectionId, permaId: sectionId, chapterId: id, configuration: {}, + contentElements: []} + ] + }; + } + + function trashItems(getAllByRole) { + return getAllByRole('listitem').filter(item => item.classList.contains(styles.item)); + } + + function render(items) { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}] + }); + testContext.entry = entry; + + const result = renderBackboneView(new TrashView({entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify(items)] + ); + + return result; + } + + it('navigates to the outline when the back link is clicked', () => { + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + const {getByRole} = render([]); + + fireEvent.click(getByRole('link', {name: 'Outline'})); + + expect(navigate).toHaveBeenCalledWith('/', {trigger: true}); + navigate.mockRestore(); + }); + + it('lists trash items with origin chapter', () => { + const {getAllByRole} = render([ + trashItem({id: 5, originPermaId: 100}), + trashItem({id: 6, originPermaId: 999}) + ]); + + const items = trashItems(getAllByRole); + + expect(items[0]).toHaveTextContent('Section from Intro'); + expect(items[1]).toHaveTextContent('Section from deleted chapter'); + }); + + it('prefixes deletion time', () => { + const {getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + expect(trashItems(getAllByRole)[0].querySelector('time')) + .toHaveTextContent(/^Deleted .*2026/); + }); + + it('lists most recently deleted items first when shown again', () => { + render([trashItem({id: 5, originPermaId: 100})]); + const view = renderBackboneView(new TrashView({entry: testContext.entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + trashItem({id: 6, originPermaId: 999}), + trashItem({id: 5, originPermaId: 100}) + ])] + ); + + expect(trashItems(view.getAllByRole).map(item => item.textContent)) + .toEqual([ + expect.stringContaining('Section from deleted chapter'), + expect.stringContaining('Section from Intro') + ]); + }); + + it('shows loading indicator until trash items have been fetched', async () => { + const entry = createEntry({}); + const view = new TrashView({entry}); + renderBackboneView(view); + + expect(view.el.querySelector('.loading')).not.toBeNull(); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, '[]'] + ); + + await waitFor(() => expect(view.el.querySelector('.loading')).toBeNull()); + }); + + it('displays error instead of loading indicator if loading fails', async () => { + const entry = createEntry({}); + const view = new TrashView({entry}); + const {findByText} = renderBackboneView(view); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [500, {'Content-Type': 'application/json'}, '{}'] + ); + + expect(await findByText('Could not load')).toBeInTheDocument(); + expect(view.el.querySelector('.loading')).toBeNull(); + }); + + it('labels trashed chapters with title and section count', () => { + const {getAllByRole} = render([ + { + ...trashItem({id: 5}), + kind: 'chapter', + title: 'Outro', + sections: [ + {id: 50, permaId: 50, chapterId: 5, configuration: {}, contentElements: []}, + {id: 51, permaId: 51, chapterId: 5, configuration: {}, contentElements: []} + ] + }, + {...trashItem({id: 6}), kind: 'chapter'} + ]); + + const items = trashItems(getAllByRole); + + expect(items[0]).toHaveTextContent('Chapter Outro with 2 sections'); + expect(items[1]).toHaveTextContent('Chapter Untitled with 1 section'); + }); + + it('renders blank slate when nothing has been deleted', () => { + const {getByText} = render([]); + + expect(getByText('Nothing deleted')).toBeInTheDocument(); + }); + + it('explains what can be restored', () => { + const {getByText} = render([]); + + expect(getByText('Deleted sections can be restored here.')).toBeInTheDocument(); + }); + + it('restores entry when restore button is clicked', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + + expect(testContext.requests.at(-1).url) + .toBe('/editor/entries/1/scrolled/trash_items/5/restore'); + }); + + it('displays error when restore fails', async () => { + const {getByRole, getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + testContext.server.respond( + 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', + [422, {'Content-Type': 'application/json'}, '{}'] + ); + + await waitFor(() => + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore')) + .toBeInTheDocument() + ); + }); + + it('displays generic error when restoring chapter fails', async () => { + const {getByRole, getAllByRole} = render([ + {...trashItem({id: 5}), kind: 'chapter', originPermaId: 999} + ]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + + await waitFor(() => + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore chapter')) + .toBeInTheDocument() + ); + }); + + function contentElementsTrashItem({id, originPermaId}) { + return { + ...trashItem({id}), + kind: 'contentElements', + originPermaId, + sections: [ + { + id: id * 10, permaId: id * 10, chapterId: id, configuration: {}, + contentElements: [ + {id: 1, permaId: 1, typeName: 'heading', configuration: {}}, + {id: 2, permaId: 2, typeName: 'inlineImage', configuration: {}}, + {id: 3, permaId: 3, typeName: 'heading', configuration: {}} + ] + } + ] + }; + } + + function renderWithSections(items) { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0, configuration: {title: 'Intro'}}], + sections: [ + {id: 10, permaId: 1000, chapterId: 1, position: 0}, + {id: 11, permaId: 1100, chapterId: 1, position: 1} + ] + }); + + const result = renderBackboneView(new TrashView({entry})); + + testContext.server.respond( + 'GET', '/editor/entries/1/scrolled/trash_items', + [200, {'Content-Type': 'application/json'}, JSON.stringify(items)] + ); + + return result; + } + + it('labels trashed content elements with type names and origin section', () => { + const {getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}), + contentElementsTrashItem({id: 6, originPermaId: 999}) + ]); + + const items = trashItems(getAllByRole); + + expect(items[0]).toHaveTextContent('Heading, Image from Intro - Section 2'); + expect(items[1]).toHaveTextContent('Heading, Image from deleted section'); + }); + + it('marks content element items to render their preview out of context', () => { + const {getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}), + {...trashItem({id: 6}), originPermaId: 100} + ]); + + const items = trashItems(getAllByRole); + + expect(items[0]).toHaveClass(styles.contentElementsItem); + expect(items[1]).not.toHaveClass(styles.contentElementsItem); + }); + + it('displays content element specific error when restore fails', async () => { + const {getByRole, getAllByRole} = renderWithSections([ + contentElementsTrashItem({id: 5, originPermaId: 1100}) + ]); + + fireEvent.click(getByRole('button', {name: 'Restore'})); + testContext.server.respond( + 'POST', '/editor/entries/1/scrolled/trash_items/5/restore', + [422, {'Content-Type': 'application/json'}, '{}'] + ); + + await waitFor(() => + expect(within(trashItems(getAllByRole)[0]).getByText('Could not restore elements')) + .toBeInTheDocument() + ); + }); + + describe('permanent deletion', () => { + beforeEach(() => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + }); + + afterEach(() => { + window.confirm.mockRestore(); + }); + + it('empties trash via actions menu', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Empty trash'})); + + expect(window.confirm).toHaveBeenCalledWith('Empty?'); + expect(testContext.requests.at(-1)).toMatchObject({ + method: 'DELETE', + url: '/editor/entries/1/scrolled/trash_items/empty' + }); + }); + + it('deletes single item via item menu', () => { + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + + expect(window.confirm).toHaveBeenCalledWith('Delete?'); + expect(testContext.requests.at(-1)).toMatchObject({ + method: 'DELETE', + url: '/editor/entries/1/scrolled/trash_items/5' + }); + }); + + it('displays error when emptying trash fails', async () => { + const {getByRole, findByText} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Empty trash'})); + testContext.server.respond( + 'DELETE', '/editor/entries/1/scrolled/trash_items/empty', [500, {}, ''] + ); + + expect(await findByText('Could not empty')).toBeInTheDocument(); + }); + + it('displays error in item when deleting it fails', async () => { + const {getByRole, getAllByRole} = render([trashItem({id: 5, originPermaId: 100})]); + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + testContext.server.respond( + 'DELETE', '/editor/entries/1/scrolled/trash_items/5', [500, {}, ''] + ); + + await waitFor(() => + expect(within(trashItems(getAllByRole)[0]).getByText('Could not delete')) + .toBeInTheDocument() + ); + }); + + it('does not delete if confirmation is canceled', () => { + window.confirm.mockReturnValue(false); + const {getByRole} = render([trashItem({id: 5, originPermaId: 100})]); + const requestCount = testContext.requests.length; + + fireEvent.click(getByRole('link', {name: 'Delete permanently'})); + + expect(testContext.requests).toHaveLength(requestCount); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/collections-spec.js b/entry_types/scrolled/package/spec/entryState/collections-spec.js index e0c12d4602..afa32c76b1 100644 --- a/entry_types/scrolled/package/spec/entryState/collections-spec.js +++ b/entry_types/scrolled/package/spec/entryState/collections-spec.js @@ -1,4 +1,4 @@ -import {watchCollections} from 'entryState'; +import {watchCollections, watchFileCollections} from 'entryState'; import {useEditorGlobals} from 'support'; @@ -54,3 +54,29 @@ describe('watchCollections', () => { teardown(); }); }); + +describe('watchFileCollections', () => { + const {createEntry} = useEditorGlobals(); + + it('dispatches file collections only', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 5}], + sections: [{id: 1, permaId: 10}] + }); + const collectionNames = []; + + const teardown = watchFileCollections(entry, { + dispatch(action) { + if (action.payload.items) { + collectionNames.push(action.payload.collectionName); + } + } + }); + + expect(collectionNames).toContain('imageFiles'); + expect(collectionNames).not.toContain('sections'); + expect(collectionNames).not.toContain('contentElements'); + + teardown(); + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index aa9a06d301..070c59190e 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -24,9 +24,7 @@ const storylinesSeed = [ id: 1, permaId: 10, position: 1, - configuration: { - main: true - } + role: 'main' }, { id: 2, diff --git a/entry_types/scrolled/package/spec/frontend/Link-spec.js b/entry_types/scrolled/package/spec/frontend/Link-spec.js index 1a92aced14..c441abbb7f 100644 --- a/entry_types/scrolled/package/spec/frontend/Link-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Link-spec.js @@ -341,7 +341,7 @@ describe('Link', () => { const storylinesSeed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js b/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js index 2db107297d..6560aefaa5 100644 --- a/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js +++ b/entry_types/scrolled/package/spec/frontend/SectionThumbnail-spec.js @@ -1,7 +1,10 @@ import React from 'react'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; -import {StandaloneSectionThumbnail} from 'frontend/SectionThumbnail'; +import { + StandaloneSectionThumbnail, + viewportUnitCustomProperties +} from 'frontend/SectionThumbnail'; import {watchCollections} from 'entryState'; import 'contentElements/heading/frontend'; @@ -102,4 +105,27 @@ describe('SectionThumbnail', () => { expect(consoleError).not.toHaveBeenCalled(); }); + + it('marks element with viewport custom properties for theme root properties', () => { + const seed = normalizeSeed({ + sections: [ + {id: 3, permaId: 1}, + ] + }); + const {container} = render( + + ); + + expect(container.querySelector('[data-section-thumbnail]')).not.toBeNull(); + }); + + describe('viewportUnitCustomProperties', () => { + it('derives viewport units from thumbnail dimensions', () => { + expect(viewportUnitCustomProperties({width: 800, height: 400})).toEqual({ + '--vw': '8px', + '--vh': '4px', + '--svh': '4px' + }); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js index f19d94be5e..dd4192aed0 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js @@ -19,7 +19,7 @@ describe('comment navigation across excursions', () => { const seed = { widgets: [{typeName: 'excursionOverlay', role: 'excursion'}], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js index 3a7bf61121..6c476dace2 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeSectionEvent-spec.js @@ -89,7 +89,7 @@ describe('change section event', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -150,7 +150,7 @@ describe('change section event', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js index 1a156e5ac7..010e98e982 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeSectionMessage-spec.js @@ -45,7 +45,7 @@ describe('CHANGE_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -93,7 +93,7 @@ describe('CHANGE_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js b/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js index 642c2c533c..f70debbac6 100644 --- a/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/changeURLFragment-spec.js @@ -115,7 +115,7 @@ describe('change url fragment on section update', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -164,7 +164,7 @@ describe('change url fragment on section update', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js b/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js index 7775bac95f..e306f0657b 100644 --- a/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/mainStorylineWidget-spec.js @@ -26,7 +26,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -79,7 +79,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -118,7 +118,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' } ], chapters: [ @@ -175,7 +175,7 @@ describe('main storyline widget', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js b/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js index 02fc36b907..39724e2c96 100644 --- a/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/renderingExcursions-spec.js @@ -27,7 +27,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -83,7 +83,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 @@ -148,7 +148,7 @@ describe('rendering excursions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js index 032cc194c0..9594a19b88 100644 --- a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js @@ -273,7 +273,7 @@ describe('SCROLL_TO_SECTION message', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js b/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js index cec22b4809..16b9db3e50 100644 --- a/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/sectionFirstClass-spec.js @@ -47,7 +47,7 @@ describe('section first class', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js b/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js index 8c9917a9b3..1adf80fe3f 100644 --- a/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/transitions-spec.js @@ -58,7 +58,7 @@ describe('transitions', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js index 78eeb820ed..697971ff30 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/LinkTooltip-spec.js @@ -396,7 +396,7 @@ describe('LinkTooltip', () => { it('displays tooltip for excursion chapter link with title', async () => { const seed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -423,7 +423,7 @@ describe('LinkTooltip', () => { it('displays tooltip for excursion chapter link without title', async () => { const seed = { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js index 99e8897919..36f1b3abf1 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementSelection-spec.js @@ -59,7 +59,7 @@ describe('inline editing content element selection', () => { storylines: [ { id: 1, - configuration: {main: true} + role: 'main' }, { id: 2 diff --git a/entry_types/scrolled/package/spec/frontend/transitions-spec.js b/entry_types/scrolled/package/spec/frontend/transitions-spec.js index 63f5d1154e..db35e5856b 100644 --- a/entry_types/scrolled/package/spec/frontend/transitions-spec.js +++ b/entry_types/scrolled/package/spec/frontend/transitions-spec.js @@ -255,7 +255,7 @@ describe('getTransitionStylesName', () => { const {result} = renderHookInEntry(() => useEntryStructure(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2, configuration: {}} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js index a35e0112d4..97334e8f42 100644 --- a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js @@ -31,7 +31,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -53,7 +53,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -75,7 +75,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -102,7 +102,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -132,7 +132,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -167,7 +167,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -196,7 +196,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -222,7 +222,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -250,7 +250,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -270,7 +270,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -296,7 +296,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -325,7 +325,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -353,7 +353,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -382,7 +382,7 @@ describe('ActiveExcursionProvider', () => { renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -420,7 +420,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -449,7 +449,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -476,7 +476,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -502,7 +502,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -540,7 +540,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -566,7 +566,7 @@ describe('ActiveExcursionProvider', () => { const {result} = renderHookInEntry(() => useActiveExcursion(), { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js index fb330d6e6b..15f9f81ef8 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js @@ -94,7 +94,7 @@ describe('useContentElementLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -462,7 +462,7 @@ describe('useContentElementLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -507,7 +507,7 @@ describe('useContentElementLifecycle', () => { renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -551,7 +551,7 @@ describe('useContentElementLifecycle', () => { renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -617,7 +617,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -665,7 +665,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -710,7 +710,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -761,7 +761,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ @@ -808,7 +808,7 @@ describe('useContentElementLifecycle', () => { role: 'excursion' }], storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js b/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js index 7b4d8bdc1e..f4fa70c41e 100644 --- a/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useSectionLifecycle-spec.js @@ -68,7 +68,7 @@ describe('useSectionLifecycle', () => { const {getByTestId} = renderInEntry(, { seed: { storylines: [ - {id: 1, configuration: {main: true}}, + {id: 1, role: 'main'}, {id: 2} ], chapters: [ diff --git a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js index c5ff697e76..fb214ffce7 100644 --- a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js +++ b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js @@ -5,7 +5,7 @@ import {renderHookInEntry} from 'pageflow-scrolled/testHelpers'; import {renderHookWithReviewState} from 'support/renderWithReviewState'; const storylinesSeed = [ - {id: 1, permaId: 10, position: 1, configuration: {main: true}}, + {id: 1, permaId: 10, position: 1, role: 'main'}, {id: 2, permaId: 11, position: 2, configuration: {}} ]; const chaptersSeed = [ diff --git a/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js b/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js index 5b85a620a5..36d0f04bc9 100644 --- a/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js +++ b/entry_types/scrolled/package/spec/review/useLocatedCommentThreadsForSubject-spec.js @@ -3,7 +3,7 @@ import {useLocatedCommentThreadsForSubject} from 'review/useLocatedCommentThread import {renderHookWithReviewState} from 'support/renderWithReviewState'; const seed = { - storylines: [{id: 1, permaId: 10, position: 1, configuration: {main: true}}], + storylines: [{id: 1, permaId: 10, position: 1, role: 'main'}], chapters: [{id: 1, permaId: 100, storylineId: 1, position: 1}], sections: [{id: 1, permaId: 1000, chapterId: 1, position: 1}], contentElements: [{id: 1, permaId: 10001, sectionId: 1, typeName: 'textBlock'}] diff --git a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js index 7a701eeb2b..445da041f2 100644 --- a/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js +++ b/entry_types/scrolled/package/spec/testHelpers/normalizeSeed-spec.js @@ -251,9 +251,7 @@ describe('normalizeSeed', () => { { id: expect.any(Number), permaId: expect.any(Number), - configuration: { - main: true - } + role: 'main' } ] } @@ -270,9 +268,7 @@ describe('normalizeSeed', () => { { id: expect.any(Number), permaId: expect.any(Number), - configuration: { - main: true - } + role: 'main' } ] } diff --git a/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js b/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js index 7c05bab073..695bc36113 100644 --- a/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js +++ b/entry_types/scrolled/package/src/editor/collections/StorylinesCollection.js @@ -16,10 +16,10 @@ export const StorylinesCollection = Backbone.Collection.extend({ }, main() { - return this.at(0); + return this.find(storyline => storyline.isMain()); }, excursions() { - return this.at(1); + return this.find(storyline => !storyline.isMain()); } }); diff --git a/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js new file mode 100644 index 0000000000..637f97a81e --- /dev/null +++ b/entry_types/scrolled/package/src/editor/collections/TrashItemsCollection.js @@ -0,0 +1,23 @@ +import $ from 'jquery'; +import Backbone from 'backbone'; + +import {entryTypeEditorControllerUrls} from 'pageflow/editor'; + +import {TrashItem} from '../models/TrashItem'; + +export const TrashItemsCollection = Backbone.Collection.extend({ + model: TrashItem, + + mixins: [entryTypeEditorControllerUrls.forCollection({resources: 'trash_items'})], + + initialize(models, {entry} = {}) { + this.entry = entry; + }, + + empty() { + return Promise.resolve($.ajax({ + url: `${this.url()}/empty`, + type: 'DELETE' + })).then(() => this.remove(this.models.slice())); + } +}); diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index de911442ce..12cbeedf29 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -10,6 +10,7 @@ import {EditContentElementView} from '../views/EditContentElementView'; import {CommentsView} from '../views/CommentsView'; import {CommentActivityView} from '../views/CommentActivityView'; import {NewThreadView} from '../views/NewThreadView'; +import {TrashView} from '../views/TrashView'; export const SideBarController = Marionette.Controller.extend({ initialize: function(options) { @@ -65,6 +66,12 @@ export const SideBarController = Marionette.Controller.extend({ })); }, + trash: function() { + this.region.show(new TrashView({ + entry: this.entry + })); + }, + contentElement: function(id, tab) { this.region.show(new EditContentElementView({ entry: this.entry, diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 9a9c654594..ae4b679724 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -48,7 +48,8 @@ export const Chapter = Backbone.Model.extend({ getDisplayNumber() { if (this.storyline.isMain()) { - return I18n.t('pageflow_scrolled.editor.chapter_item.chapter') + ' ' + (this.get('position') + 1); + return I18n.t('pageflow_scrolled.editor.chapter_item.chapter') + ' ' + + (this.storyline.chapters.indexOf(this) + 1); } }, @@ -175,6 +176,25 @@ export const Chapter = Backbone.Model.extend({ return newSection; }, + restoreSection(trashItem, {position}) { + const section = this.addSection({position}, { + url: `${trashItem.url()}/restore`, + skipDefaults: true, + wait: true, + success: () => { + this.sections.each(sibling => { + if (sibling !== section && sibling.get('position') >= position) { + sibling.set('position', sibling.get('position') + 1); + } + }); + + this.sections.sort(); + } + }); + + return section; + }, + moveSection(section, {after, before} = {}) { const targetSection = after || before; const sourceChapter = section.chapter; diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js index 94a64dc69b..4e19b61a8e 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/Batch.js @@ -16,6 +16,7 @@ export function Batch(entry, section, {reviewSession} = {}) { // Content elements that have been removed from contentElements // and shall be deleted on the server. const markedForDeletion = []; + const markedForTrash = new Set(); // New ranges for comment threads, keyed by threadId. Includes the // post-shift range of threads being migrated. @@ -201,9 +202,13 @@ export function Batch(entry, section, {reviewSession} = {}) { } } - function markForDeletion(contentElement) { + function markForDeletion(contentElement, {trash} = {}) { isDirty = true; markedForDeletion.push(contentElement); + + if (trash) { + markedForTrash.add(contentElement); + } } function remove(contentElement) { @@ -340,7 +345,8 @@ export function Batch(entry, section, {reviewSession} = {}) { }), ...markedForDeletion.map(contentElement => ({ id: contentElement.id, - _delete: true + _delete: true, + ...(markedForTrash.has(contentElement) && {_trash: true}) })) ]; } diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js index 6007ecd295..a05d688eb0 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/deleteContentElement.js @@ -8,8 +8,12 @@ export function deleteContentElement(entry, contentElement) { const [before, after] = batch.getAdjacent(contentElement); batch.remove(contentElement); - batch.markForDeletion(contentElement); + batch.markForDeletion(contentElement, {trash: true}); batch.maybeMerge(before, after); - batch.save(); + batch.save({ + success() { + entry.markUnseenTrashItems(); + } + }); } diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 056248339b..ded7a10fad 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -14,6 +14,7 @@ import { } from '../../collections'; import {LinkableEntriesCollection} from '../../collections/LinkableEntriesCollection'; import {LinkedEntriesCollection} from '../../collections/LinkedEntriesCollection'; +import {TrashItemsCollection} from '../../collections/TrashItemsCollection'; import {CommentDisplayFilter} from '../CommentDisplayFilter'; import {ContentElement} from '../ContentElement'; @@ -85,6 +86,7 @@ export const ScrolledEntry = Entry.extend({ this.linkedEntries = new LinkedEntriesCollection(seed.collections.linkedEntries); this.linkableEntries = new LinkableEntriesCollection(); + this.trashItems = new TrashItemsCollection([], {entry: this}); this.cutoff = new Cutoff({entry: this}); @@ -92,6 +94,9 @@ export const ScrolledEntry = Entry.extend({ editor.failures.watch(this.sections); editor.failures.watch(this.chapters); + this.listenTo(this.chapters, 'destroy', this.markUnseenTrashItems); + this.listenTo(this.sections, 'destroy', this.markUnseenTrashItems); + editor.savingRecords.watch(this.contentElements); editor.savingRecords.watch(this.sections); editor.savingRecords.watch(this.chapters); @@ -171,6 +176,10 @@ export const ScrolledEntry = Entry.extend({ updateContentElement(this, contentElement, configuration, options); }, + markUnseenTrashItems() { + this.set('hasUnseenTrashItems', true); + }, + deleteContentElement(contentElement) { deleteContentElement(this, contentElement); }, diff --git a/entry_types/scrolled/package/src/editor/models/Section.js b/entry_types/scrolled/package/src/editor/models/Section.js index 2bde77a28e..2d27555206 100644 --- a/entry_types/scrolled/package/src/editor/models/Section.js +++ b/entry_types/scrolled/package/src/editor/models/Section.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Backbone from 'backbone'; import I18n from 'i18n-js'; import {getAvailableTransitionNames} from 'pageflow-scrolled/frontend'; @@ -36,6 +37,33 @@ export const Section = Backbone.Model.extend({ }); }, + restoreContentElements(trashItem, {position}) { + return Promise.resolve($.ajax({ + url: `${trashItem.url()}/restore`, + type: 'POST', + dataType: 'json' + })).then(contentElements => { + this.contentElements.each(contentElement => { + if (contentElement.get('position') >= position) { + contentElement.set('position', + contentElement.get('position') + contentElements.length, + {autoSave: false}); + } + }); + + const {entry} = this.chapter; + entry.contentElements.add(contentElements); + this.contentElements.sort(); + + const first = contentElements.length && entry.contentElements.get(contentElements[0].id); + + if (first) { + entry.trigger('selectContentElement', first); + entry.trigger('scrollToContentElement', first); + } + }); + }, + chapterPosition: function() { return this.chapter && this.chapter.has('position') ? this.chapter.get('position') : -1; }, diff --git a/entry_types/scrolled/package/src/editor/models/Storyline.js b/entry_types/scrolled/package/src/editor/models/Storyline.js index 372e4921b9..06a500ce0c 100644 --- a/entry_types/scrolled/package/src/editor/models/Storyline.js +++ b/entry_types/scrolled/package/src/editor/models/Storyline.js @@ -5,6 +5,8 @@ import { ForeignKeySubsetCollection } from 'pageflow/editor'; +import {Chapter} from './Chapter'; + export const Storyline = Backbone.Model.extend({ mixins: [ configurationContainer({ @@ -34,6 +36,46 @@ export const Storyline = Backbone.Model.extend({ }); }, + restoreChapter(trashItem) { + const position = trashItem.get('originPosition'); + + const chapter = this.chapters.create( + new Chapter({position, storylineId: this.id}, { + entry: this.entry, + sections: this.entry.sections + }), + { + url: `${trashItem.url()}/restore`, + wait: true, + success: () => { + this.chapters.each(sibling => { + if (sibling !== chapter && sibling.get('position') >= position) { + sibling.set('position', sibling.get('position') + 1); + } + }); + + this.chapters.sort(); + } + } + ); + + chapter.once('sync', (model, response) => { + chapter.configuration.set(response.configuration, {autoSave: false}); + + response.sections.forEach(({contentElements, ...attributes}) => { + chapter.sections.add(attributes, {contentElements: this.entry.contentElements}); + this.entry.contentElements.add(contentElements); + }); + + if (chapter.sections.length) { + this.entry.trigger('selectSection', chapter.sections.first()); + this.entry.trigger('scrollToSection', chapter.sections.first()); + } + }); + + return chapter; + }, + appendChapter(chapter) { const position = this.chapters.length ? Math.max(...this.chapters.pluck('position')) + 1 : @@ -46,6 +88,6 @@ export const Storyline = Backbone.Model.extend({ }, isMain() { - return !!this.configuration.get('main'); + return this.get('role') === 'main'; } }); diff --git a/entry_types/scrolled/package/src/editor/models/TrashItem.js b/entry_types/scrolled/package/src/editor/models/TrashItem.js new file mode 100644 index 0000000000..2f7b31bc51 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/TrashItem.js @@ -0,0 +1,107 @@ +import Backbone from 'backbone'; + +import {Scale} from '../../shared/Scale'; + +export const TrashItem = Backbone.Model.extend({ + firstSectionPermaId() { + return this.get('sections')[0]?.permaId; + }, + + seed(entrySeed) { + const sections = this.get('sections'); + const previewSection = this.get('kind') === 'contentElements' ? + withContentElementsPreviewConfiguration(entrySeed) : + section => section; + + return { + ...entrySeed, + collections: { + ...entrySeed.collections, + sections: sections.map(({contentElements, ...section}) => previewSection(section)), + contentElements: sections.flatMap(section => section.contentElements) + } + }; + }, + + restore() { + const {target, restoreInto} = kinds[this.get('kind')]; + const {parent, position} = target(this.collection.entry, this); + + if (!parent) { + return Promise.reject(new Error('Nothing to restore into.')); + } + + return restoreInto(parent, this, position).then(restored => { + this.collection.remove(this); + return restored; + }); + }, + + permanentlyDelete() { + return Promise.resolve(this.destroy({wait: true})); + }, + + restoreTarget(parents, getChildren) { + const origin = parents.findWhere({permaId: this.get('originPermaId')}); + + if (origin) { + return {parent: origin, position: this.get('originPosition')}; + } + + const parent = parents.first(); + + return { + parent, + position: parent && Math.max(-1, ...getChildren(parent).pluck('position')) + 1 + }; + } +}); + +const kinds = { + chapter: { + target: (entry, trashItem) => ({ + parent: entry.storylines.findWhere({permaId: trashItem.get('originPermaId')}) + }), + restoreInto: (storyline, trashItem) => whenSynced(storyline.restoreChapter(trashItem)) + }, + section: { + target: (entry, trashItem) => + trashItem.restoreTarget(entry.chapters, chapter => chapter.sections), + restoreInto: (chapter, trashItem, position) => + whenSynced(chapter.restoreSection(trashItem, {position})) + }, + contentElements: { + target: (entry, trashItem) => + trashItem.restoreTarget(entry.sections, section => section.contentElements), + restoreInto: (section, trashItem, position) => + section.restoreContentElements(trashItem, {position}) + } +}; + +function whenSynced(model) { + return new Promise((resolve, reject) => { + model.once('sync', () => resolve(model)); + model.once('error', reject); + }); +} + +function withContentElementsPreviewConfiguration(entrySeed) { + const themeProperties = entrySeed.config.theme?.options?.properties || {}; + const scale = scaleName => Scale({scaleName, themeProperties, scaleTranslations: {}}); + + const previewConfiguration = { + fullHeight: true, + paddingTop: smallestNonZeroValue(scale('sectionPaddingTop')), + paddingBottom: smallestNonZeroValue(scale('sectionPaddingBottom')) + }; + + return section => ({ + ...section, + configuration: {...section.configuration, ...previewConfiguration} + }); +} + +function smallestNonZeroValue({values, cssValues}) { + const index = cssValues.findIndex(cssValue => parseFloat(cssValue) !== 0); + return values[index >= 0 ? index : 0]; +} diff --git a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js index d71d779e6d..45d97f20c5 100644 --- a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js +++ b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js @@ -5,6 +5,7 @@ export const SideBarRouter = Marionette.AppRouter.extend({ 'scrolled/comments?tab=:tab': 'comments', 'scrolled/comments': 'comments', 'scrolled/comments/activity': 'commentActivity', + 'scrolled/trash': 'trash', 'scrolled/chapters/:id': 'chapter', 'scrolled/sections/:id/transition': 'sectionTransition', 'scrolled/sections/:id/paddings?position=:position': 'sectionPaddings', diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index faf4a4011a..14e0123368 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -23,7 +23,6 @@ export const ChapterItemView = Marionette.Layout.extend({ - @@ -62,6 +61,14 @@ export const ChapterItemView = Marionette.Layout.extend({ change: 'update' }, + initialize() { + this.listenTo(this.model.storyline.chapters, 'add remove sort', changed => { + if (changed !== this.model) { + this.update(); + } + }); + }, + onRender() { this.subview(new SortableCollectionView({ el: this.ui.sections, diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css index 2f92430052..0ef4fe1913 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css @@ -69,12 +69,11 @@ } .creating .creatingIndicator { display: block; } -.destroying .destroyingIndicator { display: block; } .failed .failedIndicator { display: block; } .creatingIndicator { composes: creatingIndicator from './outline.module.css'; } -.destroyingIndicator { composes: destroyingIndicator from './outline.module.css'; } +.destroying { composes: destroying from './outline.module.css'; } .failedIndicator { composes: failedIndicator from './outline.module.css'; } .addButtons { diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 2146cbeb0c..79af1c5f0d 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -6,6 +6,7 @@ import {DropDownButtonView} from 'pageflow/editor'; import {StorylinesTabsView} from './StorylinesTabsView'; import {StorylineItemView} from './StorylineItemView'; +import {TrashButtonView} from './TrashButtonView'; import styles from './EntryOutlineView.module.css'; @@ -20,9 +21,14 @@ export const EntryOutlineView = Marionette.Layout.extend({ `, - ui: cssModulesUtils.ui(styles, 'tabs', 'dropDownButton'), + ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), onRender() { + this.ui.toolbar.prepend(this.subview(new TrashButtonView({ + entry: this.options.entry, + nudgeContainer: this.el + })).render().el); + const viewModel = new Backbone.Model({collapsed: false}); const dropDownMenuItems = new Backbone.Collection(); @@ -52,6 +58,8 @@ export const EntryOutlineView = Marionette.Layout.extend({ }), {to: this.ui.tabs} ); + + this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); } }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css index a8a962b1bf..edc805967c 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css @@ -8,12 +8,14 @@ .toolbar { position: absolute; - top: 3px; - right: -1px; + top: 0; + right: 0; + display: flex; + align-items: center; + height: 100%; } .dropDownButton { - --drop-down-button-width: 26px; --ui-on-button-color: var(--ui-primary-color-light); button.hover, diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 455673f220..3e69e6d794 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -37,7 +37,6 @@ export const SectionItemView = Marionette.ItemView.extend({ - ({ + title: I18n.t('pageflow_scrolled.editor.entry_outline.trash'), + 'aria-label': I18n.t('pageflow_scrolled.editor.entry_outline.trash') + }), + + template: () => ` + + `, + + events: { + click() { + editor.navigate('/scrolled/trash', {trigger: true}); + }, + + ...cssModulesUtils.events(styles, { + 'animationend icon': function() { + this.$el.removeClass(styles.nudging); + this.circle?.removeClass(styles.nudging); + } + }) + }, + + initialize() { + this.listenTo(this.options.entry, 'change:hasUnseenTrashItems', this.nudge); + }, + + onRender() { + this.nudge(); + }, + + onClose() { + this.circle?.remove(); + }, + + nudge() { + const {entry, nudgeContainer} = this.options; + + if (!entry.get('hasUnseenTrashItems')) { + return; + } + + entry.set('hasUnseenTrashItems', false); + + window.requestAnimationFrame(() => { + if (this.isClosed) { + return; + } + + const containerRect = nudgeContainer.getBoundingClientRect(); + const buttonRect = this.el.getBoundingClientRect(); + + this.circle = this.circle || $(`
`) + .appendTo(nudgeContainer); + + this.circle.css({ + left: buttonRect.left - containerRect.left + buttonRect.width / 2, + top: buttonRect.top - containerRect.top + buttonRect.height / 2 + }); + + this.circle.addClass(styles.nudging); + this.$el.addClass(styles.nudging); + }); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css new file mode 100644 index 0000000000..83c5d89d1d --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashButtonView.module.css @@ -0,0 +1,69 @@ +.button { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: none; + border: 0; + padding: 0 space(2); + color: var(--ui-primary-color-light); + cursor: pointer; +} + +.icon { + display: block; +} + +.button:hover, +.button:focus-visible { + color: var(--ui-primary-color); +} + +.circle { + position: absolute; + z-index: 1; + width: 10px; + height: 10px; + margin: -5px; + border-radius: 50%; + background-color: var(--ui-primary-color); + opacity: 0; + pointer-events: none; +} + +.circle.nudging { + animation: contract 0.35s ease-in 0.5s; +} + +.nudging .icon { + animation: tint 0.5s ease-out 0.5s; +} + +@keyframes contract { + from { + transform: scale(40); + opacity: 0; + } + + 50% { + opacity: 0.1; + } + + to { + transform: scale(1); + opacity: 0.5; + } +} + +@keyframes tint { + 60%, + 80% { + color: var(--ui-primary-color); + } +} + +@media (prefers-reduced-motion: reduce) { + .circle.nudging { + animation: none; + } +} diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.js b/entry_types/scrolled/package/src/editor/views/TrashView.js new file mode 100644 index 0000000000..8674c5d2c2 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashView.js @@ -0,0 +1,238 @@ +import I18n from 'i18n-js'; +import Backbone from 'backbone'; +import Marionette from 'backbone.marionette'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { + editor, CollectionView, DestroyMenuItem, DropDownButtonView, LoadingView +} from 'pageflow/editor'; +import {cssModulesUtils} from 'pageflow/ui'; +import {StandaloneSectionThumbnail} from 'pageflow-scrolled/frontend'; +import {watchFileCollections} from 'pageflow-scrolled/entryState'; + +import styles from './TrashView.module.css'; + +const t = (key, options) => I18n.t(`pageflow_scrolled.editor.trash_view.${key}`, options); + +export const TrashView = Marionette.ItemView.extend({ + template: () => ` +
+ ${t('back')} +
+
+ +

${t('hint')}

+
    + `, + + ui: cssModulesUtils.ui(styles, 'actions', 'actionError', 'items'), + + events: { + 'click a.back': function() { + editor.navigate('/', {trigger: true}); + return false; + } + }, + + onRender() { + const {entry} = this.options; + const loadingView = this.subview(new LoadingView()); + + this.appendSubview(new DropDownButtonView({ + label: t('actions'), + alignMenu: 'right', + ellipsisIcon: true, + openOnClick: true, + items: new Backbone.Collection([ + new EmptyTrashMenuItem({}, { + trashItems: entry.trashItems, + onFailure: () => this.ui.actionError.text(t('empty_failed')) + }) + ]) + }), {to: this.ui.actions}); + + this.ui.items.append(loadingView.render().el); + + entry.trashItems.fetch({reset: true}).then(() => { + if (this.isClosed) { + return; + } + + loadingView.close(); + + this.subview(new CollectionView({ + el: this.ui.items, + collection: entry.trashItems, + itemViewConstructor: TrashItemView, + itemViewOptions: {entry}, + blankSlateViewConstructor: BlankSlateView + })); + }, () => { + if (this.isClosed) { + return; + } + + loadingView.close(); + this.ui.items.append(``); + }); + } +}); + +const BlankSlateView = Marionette.ItemView.extend({ + tagName: 'li', + className: styles.blankSlate, + template: () => t('blank_slate') +}); + +const TrashItemView = Marionette.ItemView.extend({ + tagName: 'li', + className: styles.item, + + template: () => ` +
    +
    + + + +
    +
    + +
    +
    + `, + + ui: cssModulesUtils.ui(styles, + 'thumbnail', 'origin', 'trashedAt', 'error', 'restore', 'itemMenu'), + + events: cssModulesUtils.events(styles, { + 'click restore': function() { + this.ui.restore.prop('disabled', true); + this.ui.error.text(''); + + this.model.restore().catch(() => { + this.ui.restore.prop('disabled', false); + this.ui.error.text(t(restoreFailedKeys[this.model.get('kind')])); + }); + } + }), + + onRender() { + this.$el.toggleClass(styles.contentElementsItem, + this.model.get('kind') === 'contentElements'); + + this.appendSubview(new DropDownButtonView({ + title: t('actions'), + alignMenu: 'right', + ellipsisIcon: true, + borderless: true, + openOnClick: true, + items: new Backbone.Collection([ + new DeleteTrashItemMenuItem({}, { + trashItem: this.model, + onFailure: () => this.ui.error.text(t('delete_failed')) + }) + ]) + }), {to: this.ui.itemMenu}); + + this.ui.origin.text(this.originLabel()); + this.ui.trashedAt + .attr('datetime', this.model.get('trashedAt')) + .text(t('trashed_at', { + time: new Date(this.model.get('trashedAt')).toLocaleString(I18n.locale, { + dateStyle: 'medium', + timeStyle: 'short' + }) + })); + + ReactDOM.render( + React.createElement(StandaloneSectionThumbnail, { + sectionPermaId: this.model.firstSectionPermaId(), + seed: this.model.seed(this.options.entry.scrolledSeed), + subscribe: dispatch => watchFileCollections(this.options.entry, {dispatch}) + }), + this.ui.thumbnail[0] + ); + }, + + onClose() { + ReactDOM.unmountComponentAtNode(this.ui.thumbnail[0]); + }, + + originLabel() { + const {entry} = this.options; + const originPermaId = this.model.get('originPermaId'); + + if (this.model.get('kind') === 'chapter') { + return t('chapter', { + title: this.model.get('title') || + I18n.t('pageflow_scrolled.editor.chapter_item.unnamed'), + count: this.model.get('sections').length + }); + } + else if (this.model.get('kind') === 'contentElements') { + const types = contentElementTypeNames(this.model); + const section = entry.sections.findWhere({permaId: originPermaId}); + + return section ? + t('content_elements', {types, section: sectionName(section)}) : + t('content_elements_origin_missing', {types}); + } + + const chapter = entry.chapters.findWhere({permaId: originPermaId}); + + return chapter ? + t('origin', {chapter: chapter.getDisplayName()}) : + t('origin_missing'); + } +}); + +const EmptyTrashMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.trash_view.empty_trash', + + destroyModel() { + this.options.trashItems.empty().catch(this.options.onFailure); + } +}); + +const DeleteTrashItemMenuItem = DestroyMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.trash_view.delete_item', + + destroyModel() { + this.options.trashItem.permanentlyDelete().catch(this.options.onFailure); + } +}); + +const restoreFailedKeys = { + chapter: 'restore_chapter_failed', + section: 'restore_failed', + contentElements: 'restore_content_elements_failed' +}; + +function contentElementTypeNames(trashItem) { + const typeNames = trashItem.get('sections').flatMap(section => + section.contentElements.map(contentElement => contentElement.typeName) + ); + + return [...new Set(typeNames)] + .map(typeName => I18n.t(`pageflow_scrolled.editor.content_elements.${typeName}.name`)) + .join(', '); +} + +function sectionName(section) { + return I18n.t('pageflow_scrolled.editor.configuration_places.label', { + chapter: section.chapter.getDisplayName(), + subject: I18n.t('pageflow_scrolled.editor.configuration_places.section', { + number: section.chapter.sections.indexOf(section) + 1 + }) + }); +} diff --git a/entry_types/scrolled/package/src/editor/views/TrashView.module.css b/entry_types/scrolled/package/src/editor/views/TrashView.module.css new file mode 100644 index 0000000000..f9ac5d43c3 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/TrashView.module.css @@ -0,0 +1,105 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.actions {} + +.actionError { + margin: space(4) 0 0; + color: var(--ui-error-color); +} + +.actionError:empty { + display: none; +} + +.hint { + composes: info_box info from global; + margin: space(4) 0 0; +} + +.items { + list-style: none; + margin: space(4) 0 0; + padding: 0; +} + +.loadFailed { + color: var(--ui-error-color); +} + +.blankSlate { + color: var(--ui-on-surface-color-light); +} + +.item { + display: flex; + flex-direction: column; + gap: space(2); + margin-bottom: 10px; + padding: 10px; + background-color: var(--ui-surface-color); + border: solid 1px var(--ui-on-surface-color-lighter); + border-radius: rounded(lg); +} + +.thumbnail { + position: relative; + padding-top: 50%; + overflow: hidden; + border-radius: rounded(sm); + border: solid 1px var(--ui-on-surface-color-lighter); +} + +.contentElementsItem .thumbnail { + --backdrop-default-background: + linear-gradient(45deg, #e5e5e5 25%, transparent 25%) 0 0 / 96px 96px, + linear-gradient(135deg, #e5e5e5 25%, transparent 25%) 48px 0 / 96px 96px, + linear-gradient(45deg, transparent 75%, #e5e5e5 75%) 48px -48px / 96px 96px, + linear-gradient(135deg, transparent 75%, #e5e5e5 75%) 0 48px / 96px 96px, + #fff; +} + +.details { + display: flex; + flex-direction: column; + gap: space(1); + min-width: 0; +} + +.origin { + font-weight: 500; +} + +.trashedAt { + color: var(--ui-on-surface-color-light); +} + +.error { + color: var(--ui-error-color); +} + +.error:empty { + display: none; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.restore { + composes: secondaryIconButton from './buttons.module.css'; +} + +.itemMenu { + --ui-on-button-color: var(--ui-primary-color-light); +} + +.restoreIcon { + margin-right: 6px; + vertical-align: top; +} diff --git a/entry_types/scrolled/package/src/editor/views/outline.module.css b/entry_types/scrolled/package/src/editor/views/outline.module.css index b11351a155..6cb5a9e93c 100644 --- a/entry_types/scrolled/package/src/editor/views/outline.module.css +++ b/entry_types/scrolled/package/src/editor/views/outline.module.css @@ -48,10 +48,9 @@ composes: blink from './animations.module.css'; } -.destroyingIndicator { - composes: indicator; - composes: trash from './icons.module.css'; - composes: blink from './animations.module.css'; +.destroying { + opacity: 0.4; + transition: opacity 0.2s ease; } .failedIndicator { diff --git a/entry_types/scrolled/package/src/entryState/collections.js b/entry_types/scrolled/package/src/entryState/collections.js index 60ee2caa24..465a41d139 100644 --- a/entry_types/scrolled/package/src/entryState/collections.js +++ b/entry_types/scrolled/package/src/entryState/collections.js @@ -1,7 +1,15 @@ import {watchCollection} from '../collections'; export function watchCollections(entry, {dispatch}) { - const teardownFns = collectionSpecs(entry).map( + return watchSpecs([...collectionSpecs(entry), ...fileCollectionSpecs(entry)], dispatch); +} + +export function watchFileCollections(entry, {dispatch}) { + return watchSpecs(fileCollectionSpecs(entry), dispatch); +} + +function watchSpecs(specs, dispatch) { + const teardownFns = specs.map( ({collection, ...spec}) => watchCollection(collection, {...spec, dispatch}) ); @@ -12,7 +20,7 @@ export function watchCollections(entry, {dispatch}) { function collectionSpecs(entry) { const { - storylines, chapters, sections, contentElements, widgets, files, linkedEntries + storylines, chapters, sections, contentElements, widgets, linkedEntries } = entry; return [ @@ -34,7 +42,7 @@ function collectionSpecs(entry) { { collection: storylines, name: 'storylines', - attributes: ['id', 'permaId'], + attributes: ['id', 'permaId', 'role'], keyAttribute: 'permaId', includeConfiguration: true }, @@ -75,29 +83,34 @@ function collectionSpecs(entry) { attributes: [{typeName: 'type_name'}, 'role', {permaId: 'role'}], keyAttribute: 'permaId', includeConfiguration: true - }, - ...Object.keys(files).map(collectionName => ({ - collection: files[collectionName], - name: camelize(collectionName), - attributes: ['id', {permaId: 'perma_id'}, 'width', 'height', - 'basename', 'extension', 'rights', - {displayName: [ - 'display_name', 'file_name', - (displayName, fileName) => displayName || fileName - ]}, - {processedExtension: 'processed_extension'}, - {isReady: 'is_ready'}, - {variants: variants => variants && variants.map(variant => camelize(variant))}, - {durationInMs: 'duration_in_ms'}, - {parentFileId: 'parent_file_id'}, - {parentFileModelType: 'parent_file_model_type'}, - {isReferenced: 'is_referenced'}], - keyAttribute: 'permaId', - includeConfiguration: true - })) + } ]; } +function fileCollectionSpecs(entry) { + const {files} = entry; + + return Object.keys(files).map(collectionName => ({ + collection: files[collectionName], + name: camelize(collectionName), + attributes: ['id', {permaId: 'perma_id'}, 'width', 'height', + 'basename', 'extension', 'rights', + {displayName: [ + 'display_name', 'file_name', + (displayName, fileName) => displayName || fileName + ]}, + {processedExtension: 'processed_extension'}, + {isReady: 'is_ready'}, + {variants: variants => variants && variants.map(variant => camelize(variant))}, + {durationInMs: 'duration_in_ms'}, + {parentFileId: 'parent_file_id'}, + {parentFileModelType: 'parent_file_model_type'}, + {isReferenced: 'is_referenced'}], + keyAttribute: 'permaId', + includeConfiguration: true + })); +} + function camelize(snakeCase) { return snakeCase.replace(/_[a-z]/g, function(match) { return match[1].toUpperCase(); diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index bce317aea9..3241390089 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -35,7 +35,7 @@ export {useWidget, useActiveWidgets} from './widgets'; export {EntryStateProvider, useEntryStateDispatch} from './EntryStateProvider'; -export {watchCollections} from './collections'; +export {watchCollections, watchFileCollections} from './collections'; export {updateContentElementConfiguration} from './updateContentElementConfiguration'; export {updateWidgetConfiguration} from './updateWidgetConfiguration'; diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index 78261ca34a..736b5d1e0a 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -373,7 +373,7 @@ export function useMainStoryline() { const storylines = useEntryStateCollectionItems('storylines'); return useMemo( - () => storylines.find(storyline => storyline.configuration.main), + () => storylines.find(storyline => storyline.role === 'main'), [storylines] ); } diff --git a/entry_types/scrolled/package/src/frontend/Backdrop.module.css b/entry_types/scrolled/package/src/frontend/Backdrop.module.css index 4d6d24ac48..d001ef4e27 100644 --- a/entry_types/scrolled/package/src/frontend/Backdrop.module.css +++ b/entry_types/scrolled/package/src/frontend/Backdrop.module.css @@ -13,7 +13,7 @@ } .defaultBackground { - background-color: #333; + background: var(--backdrop-default-background, #333); } @media print { diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index 30cdb528a2..53a981acde 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -15,15 +15,16 @@ } .fullFadeHeight { - min-height: 51vh; + min-height: calc(51 * var(--vh)); } .fullHeight { /* First sections set --foreground-widget-padding-top to make room - for the widget, so the terms cancel out to 100vh. Other sections - leave it undefined, causing the fallback to --widget-margin-top-min - to reduce the height to fit below the navigation widget. */ - min-height: calc(100vh + + for the widget, so the terms cancel out to the viewport height. + Other sections leave it undefined, causing the fallback to + --widget-margin-top-min to reduce the height to fit below the + navigation widget. */ + min-height: calc(100 * var(--vh) + var(--foreground-widget-padding-top, 0px) - var(--foreground-widget-padding-top, var(--widget-margin-top-min, 0px))); } diff --git a/entry_types/scrolled/package/src/frontend/SectionThumbnail.js b/entry_types/scrolled/package/src/frontend/SectionThumbnail.js index 0322eea646..ac54db0705 100644 --- a/entry_types/scrolled/package/src/frontend/SectionThumbnail.js +++ b/entry_types/scrolled/package/src/frontend/SectionThumbnail.js @@ -40,6 +40,7 @@ export function SectionThumbnail({sectionPermaId, subscribe, scale}) {
    @@ -77,9 +78,10 @@ function clientDimensions(contentRect, scaleFactor) { Math.ceil(contentRect.client.height * scaleFactor) } } -function viewportUnitCustomProperties({width, height}) { +export function viewportUnitCustomProperties({width, height}) { return { '--vw': width && `${width / 100}px`, - '--vh': height && `${height / 100}px` + '--vh': height && `${height / 100}px`, + '--svh': height && `${height / 100}px` } } diff --git a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js index 7d2781ad02..66c3f28090 100644 --- a/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js +++ b/entry_types/scrolled/package/src/testHelpers/normalizeSeed.js @@ -253,9 +253,8 @@ function normalizeStorylines(storylines = [], chapters) { { id: 1000, permaId: 100, - configuration: { - main: true - } + role: 'main', + configuration: {} } ] } diff --git a/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js b/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js index 686e0efbce..4a229c61dc 100644 --- a/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js +++ b/entry_types/scrolled/package/src/widgets/excursionSheet/stories.js @@ -23,7 +23,7 @@ function getSeed({ { id: 1000, permaId: 100, - configuration: {main: true} + role: 'main' }, { id: 2000, diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb index 5e1f7f9a60..1f4e8819d1 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/chapters_controller_spec.rb @@ -244,6 +244,22 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'moves the chapter to the trash' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: chapter + }, format: 'json') + + expect(chapter.reload.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + it 'does not allow deleting a chapter from a different entry' do entry = create(:entry, type_name: 'scrolled') create(:scrolled_chapter, revision: entry.draft) diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb index 379057b72f..97bcffc273 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/content_elements_controller_spec.rb @@ -166,6 +166,77 @@ module PageflowScrolled expect(section.content_elements.map(&:id)).to eq([content_elements.last.id]) end + it 'moves content elements marked by _trash flag to the trash' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + kept, trashed, merged = create_list(:content_element, 3, section:) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: kept.id}, + {id: trashed.id, _delete: true, _trash: true}, + {id: merged.id, _delete: true} + ] + }, format: 'json', as: :json) + + expect(section.reload.content_elements).to eq([kept]) + expect(trashed.reload.section.chapter.configuration['trash']) + .to include('kind' => 'contentElements', + 'originPermaId' => section.perma_id, + 'originPosition' => 1) + expect(ContentElement.exists?(merged.id)).to eq(false) + end + + it 'does not trash content elements of other sections' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + other_section = create(:section, revision: entry.draft) + content_element = create(:content_element, section: other_section) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: content_element.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(response.status).to eq(404) + expect(content_element.reload.section).to eq(other_section) + end + + it 'records number of remaining preceding elements as origin position' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + merged = create(:content_element, section:, position: 0) + kept = create(:content_element, section:, position: 1) + trashed = create(:content_element, section:, position: 5) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: section.id, + content_elements: [ + {id: kept.id}, + {id: merged.id, _delete: true}, + {id: trashed.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(trashed.reload.section.chapter.configuration['trash']) + .to include('originPosition' => 1) + end + it 'responds with array of objects containging content element ids and perma ids' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) @@ -390,6 +461,33 @@ module PageflowScrolled expect(thread.reload.section_perma_id).to eq(target_section.perma_id) end + it 'updates section_perma_id of threads on moved in elements when trashing in same batch' do + entry = create(:entry, type_name: 'scrolled') + source_section = create(:section, revision: entry.draft) + target_section = create(:section, revision: entry.draft) + trashed = create(:content_element, section: target_section) + element = create(:content_element, :text_block, section: source_section) + thread = create(:comment_thread, + revision: entry.draft, + subject_type: 'ContentElement', + subject_id: element.perma_id, + section_perma_id: source_section.perma_id) + + authorize_for_editor_controller(entry) + post(:batch, + params: { + entry_type: 'scrolled', + entry_id: entry.id, + section_id: target_section.id, + content_elements: [ + {id: element.id}, + {id: trashed.id, _delete: true, _trash: true} + ] + }, format: 'json', as: :json) + + expect(thread.reload.section_perma_id).to eq(target_section.perma_id) + end + it 'leaves section_perma_id untouched for threads that stay in their section' do entry = create(:entry, type_name: 'scrolled') section = create(:section, revision: entry.draft) @@ -536,6 +634,29 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'allows updating trashed content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, :text_block, section:) + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + authorize_for_editor_controller(entry) + patch(:update, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: content_element, + content_element: { + configuration: {children: 'late edit'} + } + }, format: 'json') + + expect(content_element.reload.configuration).to eq('children' => 'late edit') + expect(response.status).to eq(204) + end + it 'does not allow updating a content element from a different entry' do entry = create(:entry, type_name: 'scrolled') other_entry = create(:entry, type_name: 'scrolled') diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb index a8155fc29b..0d8c73ee31 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb @@ -146,7 +146,7 @@ module PageflowScrolled post_extraction(entry:, chapter:) fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.storyline.configuration).to include('main' => true) + expect(fragment.storyline.role).to eq('main') end it 'keeps sections of chapter in entry' do diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb index 83d29553b4..bddeebbe85 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/sections_controller_spec.rb @@ -330,6 +330,22 @@ module PageflowScrolled expect(response.status).to eq(204) end + it 'moves the section to the trash' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: { + entry_type: 'scrolled', + entry_id: entry, + id: section + }, format: 'json') + + expect(section.reload.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + it 'does not allow deleting a section from a different entry' do entry = create(:entry, type_name: 'scrolled') create(:section, revision: entry.draft) diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb new file mode 100644 index 0000000000..8cdb10148e --- /dev/null +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/trash_items_controller_spec.rb @@ -0,0 +1,300 @@ +require 'spec_helper' +require 'pageflow/editor_controller_test_helper' + +module PageflowScrolled + RSpec.describe Editor::TrashItemsController, type: :controller do + render_views + include Pageflow::EditorControllerTestHelper + include ActiveSupport::Testing::TimeHelpers + + routes { PageflowScrolled::Engine.routes } + + describe '#index' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(401) + end + + it 'lists trashed sections with origin and trash time' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:, position: 3) + content_element = create(:content_element, section:) + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_section(section) + end + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.body).to include_json( + [ + { + id: section.reload.chapter_id, + kind: 'section', + originPermaId: chapter.perma_id, + originPosition: 3, + trashedAt: '2026-10-01T12:00:00Z', + sections: [ + { + id: section.id, + permaId: section.perma_id, + contentElements: [{id: content_element.id}] + } + ] + } + ] + ) + end + + it 'lists trashed chapters with title' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft, + configuration: {'title' => 'Intro'}) + section = create(:section, chapter:) + Trash.new(entry.draft).add_chapter(chapter) + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.body).to include_json( + [ + { + id: chapter.id, + kind: 'chapter', + title: 'Intro', + sections: [{id: section.id}] + } + ] + ) + end + + it 'lists newest items first' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_section(first_section) + trash.add_section(second_section) + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(JSON.parse(response.body).map { |e| e['sections'].first['id'] }) + .to eq([second_section.id, first_section.id]) + end + + it 'renders empty list if nothing has been trashed' do + entry = create(:entry, type_name: 'scrolled') + + authorize_for_editor_controller(entry) + get(:index, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(JSON.parse(response.body)).to eq([]) + end + end + + describe '#restore' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + Trash.new(entry.draft).add_section(section) + + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(401) + end + + it 'restores section and renders it with content elements' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + id: section.id, + permaId: section.perma_id, + chapterId: chapter.id, + contentElements: [{id: content_element.id, permaId: content_element.perma_id}] + ) + end + + it 'restores chapter and renders it with sections and content elements' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + chapter = create(:scrolled_chapter, storyline:, configuration: {'title' => 'Intro'}) + section = create(:section, chapter:) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_chapter(chapter) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: chapter.id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + id: chapter.id, + permaId: chapter.perma_id, + storylineId: storyline.id, + configuration: {title: 'Intro'}, + sections: [ + {id: section.id, contentElements: [{id: content_element.id}]} + ] + ) + expect(JSON.parse(response.body)['configuration']).not_to have_key('trash') + end + + it 'restores content elements and renders them' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, + id: content_element.reload.section.chapter_id}, + format: 'json') + + expect(response.status).to eq(200) + expect(response.body).to include_json( + [{id: content_element.id, permaId: content_element.perma_id, + sectionId: section.id, position: 0}] + ) + end + + it 'responds with unprocessable entity if no chapter is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + origin_chapter = section.chapter + Trash.new(entry.draft).add_section(section) + origin_chapter.destroy + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(422) + end + + it 'does not allow restoring trash items of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + post(:restore, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(404) + end + end + + describe '#destroy' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + Trash.new(entry.draft).add_section(section) + + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(401) + end + + it 'permanently deletes trash item with its content' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + other_section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + trash.add_section(other_section) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(204) + expect(trash.items.map(&:id)).to eq([other_section.reload.chapter_id]) + expect(Section.exists?(section.id)).to eq(false) + expect(ContentElement.exists?(content_element.id)).to eq(false) + end + + it 'does not allow deleting trash items of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + delete(:destroy, + params: {entry_type: 'scrolled', entry_id: entry, id: section.reload.chapter_id}, + format: 'json') + + expect(response.status).to eq(404) + expect(Section.exists?(section.id)).to eq(true) + end + end + + describe '#empty' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(401) + end + + it 'permanently deletes all trash items' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + chapter = create(:scrolled_chapter, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + trash.add_chapter(chapter) + + authorize_for_editor_controller(entry) + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(response.status).to eq(204) + expect(trash.items).to be_empty + expect(Chapter.exists?(chapter.id)).to eq(false) + expect(Section.exists?(section.id)).to eq(false) + end + + it 'keeps content of other entries' do + entry = create(:entry, type_name: 'scrolled') + other_entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: other_entry.draft) + Trash.new(other_entry.draft).add_section(section) + + authorize_for_editor_controller(entry) + delete(:empty, params: {entry_type: 'scrolled', entry_id: entry}, format: 'json') + + expect(Section.exists?(section.id)).to eq(true) + end + end + end +end diff --git a/entry_types/scrolled/spec/factories/chapters.rb b/entry_types/scrolled/spec/factories/chapters.rb index 346329a2b6..e57c0a8e9b 100644 --- a/entry_types/scrolled/spec/factories/chapters.rb +++ b/entry_types/scrolled/spec/factories/chapters.rb @@ -9,9 +9,7 @@ module PageflowScrolled if chapter.storyline.nil? revision = evaluator.revision || build(:revision) chapter.storyline = Storyline.all_for_revision(revision).first || - create(:scrolled_storyline, - revision:, - configuration: {main: true}) + create(:scrolled_storyline, revision:) end end diff --git a/entry_types/scrolled/spec/factories/storylines.rb b/entry_types/scrolled/spec/factories/storylines.rb index 5ea006ef43..c99d868dd3 100644 --- a/entry_types/scrolled/spec/factories/storylines.rb +++ b/entry_types/scrolled/spec/factories/storylines.rb @@ -2,11 +2,7 @@ module PageflowScrolled FactoryBot.define do factory :scrolled_storyline, class: Storyline do revision - configuration do - { - main: true - } - end + role { 'main' } end end end diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb index 0882947828..a9f4475cc9 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/entry_json_seed_helper_spec.rb @@ -16,13 +16,24 @@ def render(helper, entry, options = {}) end context 'storylines' do - it 'renders storylines with id, perma_id, position and configuration' do + it 'renders role of storylines' do + entry = create(:published_entry, type_name: 'scrolled') + create(:scrolled_storyline, revision: entry.revision, role: nil, + configuration: {main: true}) + + result = render(helper, entry) + + expect(result).to include_json(collections: {storylines: [{role: 'main'}]}) + end + + it 'renders storylines with id, perma_id, role, position and configuration' do entry = create(:published_entry, type_name: 'scrolled') main_storyline = create( :scrolled_storyline, revision: entry.revision, + role: 'main', position: 0, - configuration: {main: true} + configuration: {some: 'value'} ) excursion_storyline = create( :scrolled_storyline, @@ -38,9 +49,10 @@ def render(helper, entry, options = {}) { id: main_storyline.id, permaId: main_storyline.perma_id, + role: 'main', position: 0, configuration: { - main: true + some: 'value' } }, { @@ -53,6 +65,43 @@ def render(helper, entry, options = {}) end end + context 'trash' do + it 'omits trashed content from published entries' do + entry = create(:published_entry, type_name: 'scrolled') + create(:content_element, revision: entry.revision) + trashed_content_element = create_trashed_content_element(entry.revision) + + result = JSON.parse(render(helper, entry)) + + expect(result['collections']['storylines'].pluck('id')) + .not_to include(trashed_content_element.section.chapter.storyline_id) + expect(result['collections']['chapters'].pluck('id')) + .not_to include(trashed_content_element.section.chapter_id) + expect(result['collections']['sections'].pluck('id')) + .not_to include(trashed_content_element.section_id) + expect(result['collections']['contentElements'].pluck('id')) + .not_to include(trashed_content_element.id) + end + + it 'omits trashed content from draft entries' do + entry = create(:draft_entry, type_name: 'scrolled') + trashed_content_element = create_trashed_content_element(entry.revision) + + result = JSON.parse(render(helper, entry, include_hidden_sections: true)) + + expect(result['collections']['sections'].pluck('id')) + .not_to include(trashed_content_element.section_id) + expect(result['collections']['contentElements'].pluck('id')) + .not_to include(trashed_content_element.id) + end + + def create_trashed_content_element(revision) + trash_storyline = Storyline.find_or_create_trash_for(revision) + chapter = create(:scrolled_chapter, storyline: trash_storyline) + create(:content_element, section: create(:section, chapter:)) + end + end + context 'entries' do it 'renders single-element entry array' do entry = create(:published_entry, @@ -157,7 +206,7 @@ def render(helper, entry, options = {}) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, @@ -313,7 +362,7 @@ def render(helper, entry, options = {}) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, @@ -806,7 +855,7 @@ def json_config(result) :scrolled_storyline, revision: entry.revision, position: 0, - configuration: {main: true} + role: 'main' ) excursions_storyline = create( :scrolled_storyline, diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb index 74301e6bee..d8a520cfa8 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb @@ -71,6 +71,23 @@ module PageflowScrolled end describe '#scrolled_theme_properties_style_tag' do + it 'supports including section thumbnail rules' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + html = helper.scrolled_theme_properties_style_tag(theme, + include_section_thumbnail_rules: true) + + expect(html).to have_css('style', + text: '[data-section-thumbnail]', + visible: false) + end + it 'renders style tag with custom color properties for theme' do theme = Pageflow::Theme.new(:test, colors: { @@ -672,6 +689,52 @@ def upload_font(entry, file_name) CSS end + it 'declares viewport dependent root properties again for section thumbnails' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + widget_surface_color: '#fff', + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + css = helper.scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: true) + + expect(css).to include(<<~CSS) + [data-section-thumbnail] { + --theme-section-default-padding-top: max(10em, calc(20 * var(--svh, 1svh))); + } + CSS + end + + it 'does not render section thumbnail rule without viewport dependent root properties' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + widget_surface_color: '#fff' + } + }) + + css = helper.scrolled_theme_properties_rules(theme, include_section_thumbnail_rules: true) + + expect(css).not_to include('data-section-thumbnail') + end + + it 'does not render section thumbnail rule unless requested' do + theme = Pageflow::Theme.new(:test, + properties: { + root: { + section_default_padding_top: + 'max(10em, calc(20 * var(--svh, 1svh)))' + } + }) + + css = helper.scrolled_theme_properties_rules(theme) + + expect(css).not_to include('data-section-thumbnail') + end + it 'supports media queries' do theme = Pageflow::Theme.new(:test, properties: { diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb index 8803f6df0b..88a3067e77 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/chapter_spec.rb @@ -26,6 +26,17 @@ module PageflowScrolled expect(result).to eq([chapter1, chapter2]) end + + it 'excludes chapters in trash storyline' do + revision = create(:revision) + chapter = create(:scrolled_chapter, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + create(:scrolled_chapter, storyline: trash_storyline) + + result = Chapter.all_for_revision(revision) + + expect(result).to eq([chapter]) + end end describe '.create_section' do diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb index d44b22e5e4..406841f0c3 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/content_element_spec.rb @@ -15,6 +15,32 @@ module PageflowScrolled expect(result).to eq([content_element1, content_element2, content_element3]) end + + it 'excludes content elements in trash storyline' do + revision = create(:revision) + content_element = create(:content_element, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + trash_chapter = create(:scrolled_chapter, storyline: trash_storyline) + create(:content_element, section: create(:section, chapter: trash_chapter)) + + result = ContentElement.all_for_revision(revision) + + expect(result).to eq([content_element]) + end + end + + describe '.all_for_revision_including_trash' do + it 'includes content elements in trash storyline' do + revision = create(:revision) + content_element = create(:content_element, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + trash_chapter = create(:scrolled_chapter, storyline: trash_storyline) + trashed = create(:content_element, section: create(:section, chapter: trash_chapter)) + + result = ContentElement.all_for_revision_including_trash(revision) + + expect(result).to eq([content_element, trashed]) + end end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb index 524fcf9fa0..b6764e20c2 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/linked_entry_spec.rb @@ -29,6 +29,16 @@ module PageflowScrolled expect(linked_entry.chapters.map(&:perma_id)).to eq([chapter.perma_id]) end + it 'skips chapters in trash storyline' do + entry = create(:entry, type_name: 'scrolled') + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + chapter = create(:scrolled_chapter, storyline: trash_storyline) + + linked_entry = LinkedEntry.all(Pageflow::Entry.all, entry.id => [chapter.perma_id]).first + + expect(linked_entry.chapters).to eq([]) + end + it 'skips chapters that can not be found' do entry = create(:published_entry, type_name: 'scrolled') diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb index db319a8b99..e0caa19b94 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/revision_content_spec.rb @@ -5,18 +5,15 @@ module PageflowScrolled describe '#main_storyline' do it 'returns storyline marked as main' do revision = create(:revision) - create(:scrolled_storyline, revision:, position: 0, configuration: {}) - main_storyline = create(:scrolled_storyline, - revision:, - position: 1, - configuration: {main: true}) + create(:scrolled_storyline, revision:, position: 0, role: 'excursions') + main_storyline = create(:scrolled_storyline, revision:, position: 1, role: 'main') expect(RevisionContent.new(revision).main_storyline).to eq(main_storyline) end it 'returns nil if no storyline is marked as main' do revision = create(:revision) - create(:scrolled_storyline, revision:, configuration: {}) + create(:scrolled_storyline, revision:, role: 'excursions') expect(RevisionContent.new(revision).main_storyline).to be_nil end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb index 9e1bc08cfb..fa1ef6cb3c 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/section_spec.rb @@ -14,6 +14,17 @@ module PageflowScrolled expect(result).to eq([section1, section2]) end + + it 'excludes sections in trash storyline' do + revision = create(:revision) + section = create(:section, revision:) + trash_storyline = Storyline.find_or_create_trash_for(revision) + create(:section, chapter: create(:scrolled_chapter, storyline: trash_storyline)) + + result = Section.all_for_revision(revision) + + expect(result).to eq([section]) + end end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb index 825fb32ae4..bcdbbc7067 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/storyline_spec.rb @@ -12,6 +12,14 @@ module PageflowScrolled expect(storylines.map(&:position)).to eq([0, 1]) end + it 'assigns main and excursions roles to default storylines' do + entry = create(:entry, type_name: 'scrolled') + + storylines = Storyline.all_for_revision(entry.draft) + + expect(storylines.map(&:role)).to eq(%w[main excursions]) + end + it 'copies a duplicate of itself to new revision' do Pageflow.config.revision_components.register(Storyline) revision = create(:revision) @@ -25,6 +33,129 @@ module PageflowScrolled expect(copied_storylines.first).not_to eq(storyline) expect(copied_storylines.first.configuration['some']).to eq('value') end + + it 'removes trash storyline with its content from published revisions' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) + + entry.publish(creator: create(:user)) + + published_revision = entry.reload.published_revision + expect(Storyline.where(revision: published_revision, role: 'trash')).to be_empty + expect(Chapter.joins(:storyline) + .where(pageflow_scrolled_storylines: {revision_id: published_revision})) + .to have(0).items + end + + it 'removes trash storyline from duplicated entries' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) + + duplicate = entry.duplicate + + expect(Storyline.where(revision: duplicate.draft, role: 'trash')).to be_empty + end + + it 'removes trash storyline from revisions copied without purpose' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) + + copied_revision = entry.draft.copy + + expect(Storyline.where(revision: copied_revision, role: 'trash')).to be_empty + end + + it 'keeps trash storyline in snapshots' do + entry = create(:entry, type_name: 'scrolled') + create_trashed_section(entry.draft) + + snapshot = entry.snapshot(creator: create(:user)) + + expect(Storyline.where(revision: snapshot, role: 'trash')).to have(1).item + end + + it 'restores trash of snapshot with configuration of trashed chapters' do + entry = create(:entry, type_name: 'scrolled') + section = create_trashed_section( + entry.draft, + chapter_configuration: {'trashedAt' => '2026-10-01T12:00:00Z'} + ) + snapshot = entry.snapshot(creator: create(:user)) + section.chapter.destroy + + entry.restore(revision: snapshot, creator: create(:user)) + + trash_storyline = Storyline.find_by(revision: entry.reload.draft, role: 'trash') + expect(trash_storyline.chapters.first.configuration) + .to include('trashedAt' => '2026-10-01T12:00:00Z') + end + end + + def create_trashed_section(revision, chapter_configuration: {}) + trash_storyline = Storyline.find_or_create_trash_for(revision) + chapter = create(:scrolled_chapter, + storyline: trash_storyline, + configuration: chapter_configuration) + create(:section, chapter:) + end + + describe '.all_for_revision' do + it 'excludes trash storyline' do + entry = create(:entry, type_name: 'scrolled') + Storyline.find_or_create_trash_for(entry.draft) + + storylines = Storyline.all_for_revision(entry.draft) + + expect(storylines.map(&:role)).to eq(%w[main excursions]) + end + + it 'includes storylines without role' do + revision = create(:revision) + storyline = create(:scrolled_storyline, revision:, role: nil) + + storylines = Storyline.all_for_revision(revision) + + expect(storylines).to eq([storyline]) + end + end + + describe '.find_or_create_trash_for' do + it 'creates trash storyline after existing storylines' do + entry = create(:entry, type_name: 'scrolled') + + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + + expect(trash_storyline).to have_attributes(role: 'trash', position: 2) + end + + it 'returns existing trash storyline' do + entry = create(:entry, type_name: 'scrolled') + trash_storyline = Storyline.find_or_create_trash_for(entry.draft) + + result = Storyline.find_or_create_trash_for(entry.draft) + + expect(result).to eq(trash_storyline) + end + end + + describe '#role' do + it 'returns stored role' do + storyline = build(:scrolled_storyline, role: 'trash', configuration: {'main' => true}) + + expect(storyline.role).to eq('trash') + end + + it 'falls back to main for storylines with main flag' do + storyline = build(:scrolled_storyline, role: nil, configuration: {'main' => true}) + + expect(storyline.role).to eq('main') + end + + it 'falls back to excursions for other storylines' do + storyline = build(:scrolled_storyline, role: nil, configuration: {}) + + expect(storyline.role).to eq('excursions') + end end end end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb new file mode 100644 index 0000000000..74eda4976e --- /dev/null +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/trash_spec.rb @@ -0,0 +1,470 @@ +require 'spec_helper' + +module PageflowScrolled + RSpec.describe Trash do + include ActiveSupport::Testing::TimeHelpers + + describe '#add_section' do + it 'removes section from revision' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + Trash.new(entry.draft).add_section(section) + + expect(Section.all_for_revision(entry.draft)).to be_empty + end + + it 'moves section into container chapter in trash storyline' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + Trash.new(entry.draft).add_section(section) + + expect(section.reload.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + end + + it 'records kind, origin and trash time in container chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + create(:section, chapter:, position: 0) + section = create(:section, chapter:, position: 1) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_section(section) + end + + expect(section.reload.chapter.configuration['trash']).to eq( + 'kind' => 'section', + 'originPermaId' => chapter.perma_id, + 'originPosition' => 1, + 'trashedAt' => '2026-10-01T12:00:00Z' + ) + end + + it 'appends container chapters after existing items' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + + trash.add_section(first_section) + trash.add_section(second_section) + + expect(second_section.reload.chapter.position) + .to be > first_section.reload.chapter.position + end + + it 'keeps perma_ids of section and its content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + expect { + Trash.new(entry.draft).add_section(section) + }.not_to(change { [section.reload.perma_id, content_element.reload.perma_id] }) + end + end + + describe '#add_chapter' do + it 'removes chapter and its sections from revision' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + create(:section, chapter:) + + Trash.new(entry.draft).add_chapter(chapter) + + expect(Chapter.all_for_revision(entry.draft)).to be_empty + expect(Section.all_for_revision(entry.draft)).to be_empty + end + + it 'moves chapter into trash storyline without container' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + + Trash.new(entry.draft).add_chapter(chapter) + + expect(chapter.reload.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + expect(section.reload.chapter).to eq(chapter) + end + + it 'records kind, origin and trash time next to chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + create(:scrolled_chapter, storyline:, position: 0) + chapter = create(:scrolled_chapter, storyline:, position: 1, + configuration: {'title' => 'Intro'}) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_chapter(chapter) + end + + expect(chapter.reload.configuration).to eq( + 'title' => 'Intro', + 'trash' => { + 'kind' => 'chapter', + 'originPermaId' => storyline.perma_id, + 'originPosition' => 1, + 'trashedAt' => '2026-10-01T12:00:00Z' + } + ) + end + + it 'appends chapter after existing items' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + chapter = create(:scrolled_chapter, revision: entry.draft) + trash = Trash.new(entry.draft) + + trash.add_section(section) + trash.add_chapter(chapter) + + expect(trash.items.map(&:id)).to eq([chapter.id, section.reload.chapter_id]) + end + + it 'keeps perma_ids of chapter and its sections' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + + expect { + Trash.new(entry.draft).add_chapter(chapter) + }.not_to(change { [chapter.reload.perma_id, section.reload.perma_id] }) + end + end + + describe '#add_content_elements' do + it 'moves content elements into container section keeping their order' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + first = create(:content_element, section:, position: 0) + second = create(:content_element, section:, position: 1) + + Trash.new(entry.draft).add_content_elements([first, second], + origin_section: section, + origin_position: 0) + + container_section = first.reload.section + expect(container_section.chapter.storyline) + .to eq(Storyline.find_or_create_trash_for(entry.draft)) + expect(container_section.content_elements).to eq([first, second.reload]) + expect(section.reload.content_elements).to be_empty + end + + it 'records kind, origin section and position in container chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + travel_to(Time.utc(2026, 10, 1, 12)) do + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 2) + end + + expect(content_element.reload.section.chapter.configuration['trash']).to eq( + 'kind' => 'contentElements', + 'originPermaId' => section.perma_id, + 'originPosition' => 2, + 'trashedAt' => '2026-10-01T12:00:00Z' + ) + end + + it 'copies invert and layout of origin section to container section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft, + configuration: {'invert' => true, + 'layout' => 'right', + 'backdrop' => {'image' => 5}}) + content_element = create(:content_element, section:) + + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + + expect(content_element.reload.section.configuration) + .to eq('invert' => true, 'layout' => 'right') + end + + it 'keeps perma_ids of content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + + expect { + Trash.new(entry.draft).add_content_elements([content_element], + origin_section: section, + origin_position: 0) + }.not_to(change { content_element.reload.perma_id }) + end + end + + describe '#restore with trashed section' do + it 'moves section back to origin chapter at origin position' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:, position: 0) + section = create(:section, chapter:, position: 1) + last_section = create(:section, chapter:, position: 2) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(chapter.reload.sections).to eq([first_section, section, last_section]) + end + + it 'shifts positions of sections at or after origin position' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:, position: 0) + trash = Trash.new(entry.draft) + trash.add_section(section) + new_section = create(:section, chapter:, position: 0) + + trash.find_item(section.reload.chapter_id).restore + + expect(chapter.reload.sections.map(&:position)).to eq([0, 1]) + expect(chapter.sections).to eq([section, new_section]) + end + + it 'destroys container chapter' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(trash.items).to be_empty + end + + it 'returns restored section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + trash = Trash.new(entry.draft) + trash.add_section(section) + + result = trash.find_item(section.reload.chapter_id).restore + + expect(result).to eq(section) + end + + it 'keeps perma_ids of section and its content elements' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_section(section) + + trash.find_item(section.reload.chapter_id).restore + + expect(ContentElement.all_for_revision(entry.draft).map(&:perma_id)) + .to eq([content_element.perma_id]) + expect(Section.all_for_revision(entry.draft).map(&:perma_id)) + .to eq([section.perma_id]) + end + + it 'moves section to end of first chapter if origin chapter no longer exists' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + first_chapter = create(:scrolled_chapter, storyline:, position: 0) + existing_section = create(:section, chapter: first_chapter, position: 0) + origin_chapter = create(:scrolled_chapter, storyline:, position: 1) + section = create(:section, chapter: origin_chapter, position: 0) + trash = Trash.new(entry.draft) + trash.add_section(section) + origin_chapter.destroy + + trash.find_item(section.reload.chapter_id).restore + + expect(first_chapter.reload.sections).to eq([existing_section, section]) + end + + it 'raises if no chapter is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + origin_chapter = section.chapter + trash = Trash.new(entry.draft) + trash.add_section(section) + origin_chapter.destroy + + expect { + trash.find_item(section.reload.chapter_id).restore + }.to raise_error(Trash::DestinationMissing) + end + end + + describe '#restore with trashed chapter' do + it 'moves chapter back to origin storyline at origin position' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + first_chapter = create(:scrolled_chapter, storyline:, position: 0) + chapter = create(:scrolled_chapter, storyline:, position: 1) + last_chapter = create(:scrolled_chapter, storyline:, position: 2) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(storyline.reload.chapters).to eq([first_chapter, chapter, last_chapter]) + end + + it 'shifts positions of chapters at or after origin position' do + entry = create(:entry, type_name: 'scrolled') + storyline = Storyline.all_for_revision(entry.draft).first + chapter = create(:scrolled_chapter, storyline:, position: 0) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + new_chapter = create(:scrolled_chapter, storyline:, position: 0) + + trash.find_item(chapter.id).restore + + expect(storyline.reload.chapters.map(&:position)).to eq([0, 1]) + expect(storyline.chapters).to eq([chapter, new_chapter]) + end + + it 'restores excursion chapters to excursions storyline' do + entry = create(:entry, type_name: 'scrolled') + excursions = Storyline.all_for_revision(entry.draft).find_by(role: 'excursions') + chapter = create(:scrolled_chapter, storyline: excursions) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(chapter.reload.storyline).to eq(excursions) + end + + it 'clears trash metadata and keeps chapter configuration' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft, + configuration: {'title' => 'Intro'}) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + trash.find_item(chapter.id).restore + + expect(chapter.reload.configuration).to eq('title' => 'Intro') + end + + it 'keeps sections and returns restored chapter' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_chapter(chapter) + + result = trash.find_item(chapter.id).restore + + expect(result).to eq(chapter) + expect(Section.all_for_revision(entry.draft)).to eq([section]) + end + end + + describe '#restore with trashed content elements' do + it 'moves content elements back to origin section at origin position' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + first = create(:content_element, section:, position: 0) + trashed = create_list(:content_element, 2, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements(trashed, origin_section: section, origin_position: 1) + last = create(:content_element, section:, position: 1) + + trash.find_item(trashed.first.reload.section.chapter_id).restore + + expect(section.reload.content_elements).to eq([first, *trashed, last]) + expect(section.content_elements.map(&:position)).to eq([0, 1, 2, 3]) + end + + it 'destroys container chapter and section' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + container_section = content_element.reload.section + + trash.find_item(container_section.chapter_id).restore + + expect(trash.items).to be_empty + expect(Section.exists?(container_section.id)).to eq(false) + end + + it 'returns restored content elements keeping their perma_ids' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + + result = trash.find_item(content_element.reload.section.chapter_id).restore + + expect(result).to eq([content_element]) + expect(result.first.perma_id).to eq(content_element.perma_id) + end + + it 'appends content elements to first section if origin section has been trashed' do + entry = create(:entry, type_name: 'scrolled') + first_section = create(:section, revision: entry.draft) + existing = create(:content_element, section: first_section, position: 0) + section = create(:section, chapter: first_section.chapter, position: 1) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + trash.add_section(section) + + trash.find_item(content_element.reload.section.chapter_id).restore + + expect(first_section.reload.content_elements).to eq([existing, content_element]) + end + + it 'raises if no section is left' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + content_element = create(:content_element, section:) + trash = Trash.new(entry.draft) + trash.add_content_elements([content_element], origin_section: section, + origin_position: 0) + trash.add_section(section) + + expect { + trash.find_item(content_element.reload.section.chapter_id).restore + }.to raise_error(Trash::DestinationMissing) + end + end + + describe '#items' do + it 'returns items newest first' do + entry = create(:entry, type_name: 'scrolled') + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:) + second_section = create(:section, chapter:) + trash = Trash.new(entry.draft) + trash.add_section(first_section) + trash.add_section(second_section) + + result = Trash.new(entry.draft).items + + expect(result.map(&:id)) + .to eq([second_section.reload.chapter_id, first_section.reload.chapter_id]) + end + + it 'does not create trash storyline' do + entry = create(:entry, type_name: 'scrolled') + + result = Trash.new(entry.draft).items + + expect(result).to be_empty + expect(Storyline.where(revision: entry.draft, role: 'trash')).to be_empty + end + end + end +end diff --git a/lib/pageflow/revision_components.rb b/lib/pageflow/revision_components.rb index ff656f5f36..2c5791c36f 100644 --- a/lib/pageflow/revision_components.rb +++ b/lib/pageflow/revision_components.rb @@ -5,16 +5,23 @@ class RevisionComponents # rubocop:todo Style/Documentation def initialize @revision_components = [] @create_defaults_hooks = [] + @after_copy_hooks = [] end - def register(revision_component, create_defaults: false) + def register(revision_component, create_defaults: false, after_copy: false) return if @revision_components.include?(revision_component) @revision_components << revision_component - return unless create_defaults + if create_defaults + @create_defaults_hooks << ->(revision) { revision_component.create_defaults(revision) } + end - @create_defaults_hooks << ->(revision) { revision_component.create_defaults(revision) } + return unless after_copy + + @after_copy_hooks << lambda do |revision, purpose| + revision_component.after_copy(revision, purpose:) + end end def each(&) @@ -24,5 +31,9 @@ def each(&) def create_defaults(revision) @create_defaults_hooks.each { |hook| hook.call(revision) } end + + def after_copy(revision, purpose:) + @after_copy_hooks.each { |hook| hook.call(revision, purpose) } + end end end diff --git a/spec/models/pageflow/entry_spec.rb b/spec/models/pageflow/entry_spec.rb index 752e7a632e..2841daaaca 100644 --- a/spec/models/pageflow/entry_spec.rb +++ b/spec/models/pageflow/entry_spec.rb @@ -182,6 +182,27 @@ def self.create_defaults(draft) end describe '#publish' do + it 'passes publish purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + entry.publish(creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(entry.reload.published_revision)) + .to contain_exactly(have_attributes(text: 'publish')) + expect(TestRevisionComponent.all_for_revision(entry.draft)) + .to contain_exactly(have_attributes(text: 'draft')) + end + it 'creates a revision' do creator = create(:user) entry = create(:entry) @@ -415,6 +436,26 @@ def self.create_defaults(draft) expect(draft_before_restore.reload).to be_frozen end + it 'passes restore purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + earlier_revision = create(:revision, :published, entry:) + TestRevisionComponent.create!(revision: earlier_revision, text: 'earlier') + + entry.restore(revision: earlier_revision, creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(entry.reload.draft)) + .to contain_exactly(have_attributes(text: 'restore')) + end + it 'turns previous draft into snapshot of type before_restore' do creator = create(:user) entry = create(:entry) @@ -566,6 +607,46 @@ def self.create_defaults(draft) expect { entry.duplicate }.to(change { Entry.count }) end + + it 'passes duplicate purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + duplicate = entry.duplicate + + expect(TestRevisionComponent.all_for_revision(duplicate.draft)) + .to contain_exactly(have_attributes(text: 'duplicate')) + end + end + + describe '#snapshot' do + it 'passes snapshot purpose to after copy hooks of revision components' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + entry = create(:entry) + TestRevisionComponent.create!(revision: entry.draft, text: 'draft') + + revision = entry.snapshot(creator: create(:user)) + + expect(TestRevisionComponent.all_for_revision(revision)) + .to contain_exactly(have_attributes(text: 'snapshot')) + end end describe '.include_account_name' do diff --git a/spec/models/pageflow/revision_spec.rb b/spec/models/pageflow/revision_spec.rb index cf318133da..6ed6dd5198 100644 --- a/spec/models/pageflow/revision_spec.rb +++ b/spec/models/pageflow/revision_spec.rb @@ -79,6 +79,67 @@ module Pageflow end describe '#copy' do + it 'passes duplicate purpose to after copy hooks by default' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + revision = create(:revision) + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'duplicate')) + end + + it 'does not call after copy hooks registered for other entry types' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + rainbow_entry_type = TestEntryType.register(config, name: 'rainbow') + + config.for_entry_type(rainbow_entry_type) do |c| + c.revision_components.register(revision_component, after_copy: true) + end + end + revision = create(:entry).draft + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'original')) + end + + it 'passes given purpose to after copy hooks' do + revision_component = Class.new(TestRevisionComponent) do + def self.after_copy(revision, purpose:) + all_for_revision(revision).update_all(text: purpose.to_s) + end + end + + pageflow_configure do |config| + config.revision_components.register(revision_component, after_copy: true) + end + revision = create(:revision) + TestRevisionComponent.create!(revision:, text: 'original') + + copied_revision = revision.copy(purpose: :snapshot) + + expect(TestRevisionComponent.all_for_revision(copied_revision)) + .to contain_exactly(have_attributes(text: 'snapshot')) + end + it 'creates a new revision' do revision = create(:revision)