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(
+ ({
+ 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 || $(`
${t('hint')}
+