diff --git a/app/cells/folio/console/index/actions_cell.rb b/app/cells/folio/console/index/actions_cell.rb index c5633568b..9df239d96 100644 --- a/app/cells/folio/console/index/actions_cell.rb +++ b/app/cells/folio/console/index/actions_cell.rb @@ -35,6 +35,11 @@ def default_actions icon: :edit_box, url: -> (record) { through_aware_console_url_for(record, action: :edit, safe: true) }, }, + duplicate: { + name: :clone, + icon: :plus_circle_multiple_outline, + url: -> (record) { through_aware_console_url_for(record, action: :duplicate, safe: true) }, + }, show: { name: :show, icon: :eye, @@ -58,7 +63,6 @@ def default_actions def actions acts = [] with_default = (options[:actions].presence || %i[edit destroy]) - with_default.each do |sym_or_hash| if sym_or_hash.is_a?(Symbol) next if sym_or_hash == :destroy && model.class.try(:indestructible?) diff --git a/app/controllers/concerns/folio/console/default_actions.rb b/app/controllers/concerns/folio/console/default_actions.rb index d1f6f6cd9..0d981d0b9 100644 --- a/app/controllers/concerns/folio/console/default_actions.rb +++ b/app/controllers/concerns/folio/console/default_actions.rb @@ -43,6 +43,14 @@ def edit folio_console_record.valid? if params[:prevalidate] end + def duplicate + cloned_record = Folio::Clonable::Cloner.new(folio_console_record).create_clone + cloned_record.after_clone + + instance_variable_set(folio_console_record_variable_name, cloned_record) + render :new + end + def merge @folio_console_merge = @klass index diff --git a/app/lib/folio/clonable/cloner.rb b/app/lib/folio/clonable/cloner.rb new file mode 100644 index 000000000..cace1f087 --- /dev/null +++ b/app/lib/folio/clonable/cloner.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class Folio::Clonable::Cloner + def initialize(record) + @record = record + validate_associations!(@record.class.clonable_ignored_associations) + validate_associations!(@record.class.clonable_referenced_associations) + validate_attributes!(@record.class.clonable_reset_attributes) + end + + def create_clone + log("CLONING", :info) + log(I18n.t("cloning.start", model: self.class.name, id: @record.id)) + clone, duplicated = clone_nested_records_recursively(@record) + log(I18n.t("cloning.associations_duplicated", associations: duplicated)) + reset_clone_attributes(clone) + log(I18n.t("cloning.finished")) + + if clone.respond_to?(:title=) + clone.title = @record.class.generate_cloned_title(@record.title) + end + clone + rescue => e + log(I18n.t("cloning.error", message: e.message), :error) + log(e.backtrace.first(5).join("\n"), :error) + raise + end + + private + def clone_nested_records_recursively(original) + duplicated = [] + cloned = original.deep_dup + copy_references(original, cloned) + original.class.reflect_on_all_associations.each do |association| + next if @record.class.clonable_ignored_associations.include?(association.name) + next if @record.class.clonable_referenced_associations.include?(association.name) + if original.public_send(association.name).present? + duplicated << association.name + if association.macro == :has_many + associated_record = original.public_send(association.name).map { |a| clone_nested_records_recursively(a).first } + else + associated_record = @record.class.clonable_referenced_associations.include?(association.name) ? original.public_send(association.name) : original.public_send(association.name).deep_dup + end + cloned.public_send("#{association.name}=", associated_record) + end + end + [cloned, duplicated] + end + + def validate_associations!(associations) + associations.each do |assoc| + unless @record.class.reflect_on_association(assoc) + raise ArgumentError, I18n.t("activerecord.errors.clonable.association_not_found", + association: assoc, + model: @record.class.name) + end + end + end + + def validate_attributes!(attributes) + attributes.each do |attr| + unless @record.class.column_names.include?(attr.to_s) + raise ArgumentError, I18n.t("activerecord.errors.clonable.attribute_not_found", + attribute: attr, + model: self.name) + end + end + end + + def reset_clone_attributes(clone) + @record.class.clonable_reset_attributes.each do |attr| + clone[attr] = nil if clone.has_attribute?(attr) + end + end + + def copy_references(original, cloned) + return unless @record.class.clonable_referenced_associations.present? + @record.class.clonable_referenced_associations.each do |assoc| + next unless original.class.reflect_on_association(assoc) + cloned.public_send("#{assoc}=", original.public_send(assoc)) + end + end + + + def log(message, level = :info) + if Rails.env.development? && Rails.logger + Rails.logger.tagged("CLONING") do + Rails.logger.public_send(level, message) + end + else + puts "[CLONING] #{message}" + end + end +end diff --git a/app/models/concerns/folio/console/clonable.rb b/app/models/concerns/folio/console/clonable.rb new file mode 100644 index 000000000..832ae054a --- /dev/null +++ b/app/models/concerns/folio/console/clonable.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Folio::Console::Clonable + extend ActiveSupport::Concern + + DEFAULT_RESET_ATTRIBUTES = [:published_at, :published] + + included do + def after_clone + end + end + + class_methods do + def is_clonable? + true + end + + def clonable_ignored_associations + [] + end + + def clonable_referenced_associations + [] + end + + def clonable_reset_attributes + [] + end + + def generate_cloned_title(original_title) + I18n.t("folio.console.clone.cloned_title", + original_title:, + date: Date.today.strftime("%d. %m. %Y")) + end + + private + def validate_associations!(associations) + associations.each do |assoc| + unless reflect_on_association(assoc) + raise ArgumentError, I18n.t("activerecord.errors.clonable.association_not_found", + association: assoc, + model: self.name) + end + end + end + + def validate_attributes!(attributes) + attributes.each do |attr| + unless column_names.include?(attr.to_s) + raise ArgumentError, I18n.t("activerecord.errors.clonable.attribute_not_found", + attribute: attr, + model: self.name) + end + end + end + end +end diff --git a/app/models/folio/ability.rb b/app/models/folio/ability.rb index dba1d4e30..3e7ee162e 100644 --- a/app/models/folio/ability.rb +++ b/app/models/folio/ability.rb @@ -72,6 +72,7 @@ def folio_rules cannot :impersonate, Folio::User # `can :do_anything` enabled it, so we must deny it here cannot :set_superadmin, Folio::User cannot :change_auth_site, Folio::User + cannot :duplicate, :all unless Rails.application.config.folio_console_clonable_enabled end end alias_method :folio_console_rules, :folio_rules diff --git a/app/models/folio/page.rb b/app/models/folio/page.rb index 634e05d2a..e46c1440d 100644 --- a/app/models/folio/page.rb +++ b/app/models/folio/page.rb @@ -30,6 +30,8 @@ class Folio::Page < Folio::ApplicationRecord include Folio::Taggable include Folio::Transportable::Model include PgSearch::Model + include Folio::Console::Clonable + if Rails.application.config.folio_pages_audited include Folio::Audited @@ -64,6 +66,18 @@ class Folio::Page < Folio::ApplicationRecord presence: true end + def self.clonable_referenced_associations + [:cover] + end + + def self.clonable_ignored_associations + [:files, :slugs, :file_placements, :pg_search_document, :site] + end + + def self.clonable_reset_attributes + [:published, :published_at] + end + # Scopes scope :ordered, -> { order(position: :asc, created_at: :asc) } scope :featured, -> { where(featured: true) } diff --git a/app/views/folio/console/pages/index.slim b/app/views/folio/console/pages/index.slim index 8b573ae5a..8d95f46c3 100644 --- a/app/views/folio/console/pages/index.slim +++ b/app/views/folio/console/pages/index.slim @@ -26,7 +26,7 @@ Folio::Publishable::PREVIEW_PARAM_NAME => record.preview_token) end - actions({ preview: }, :edit, :destroy) + actions({ preview: }, :edit, :destroy, :duplicate) elsif record.is_a?(Folio::Page) && record.class.try(:public_rails_path) actions({ preview: controller.main_app.send(record.class.public_rails_path, locale: locale) }, :edit, diff --git a/config/locales/activerecord.cs.yml b/config/locales/activerecord.cs.yml index f435a91e5..34fcab423 100644 --- a/config/locales/activerecord.cs.yml +++ b/config/locales/activerecord.cs.yml @@ -183,6 +183,9 @@ cs: attributes: roles: not_available_for_site: "Role %{roles} nejsou všechny dostupné pro web '%{site}'." + clonable: + association_not_found: "Asociace '%{association}' neexistuje pro %{model}" + attribute_not_found: "Atribut '%{attribute}' neexistuje pro %{model}" models: folio/atom: few: Kapitoly diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index e64bcf63d..fc7452dde 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -156,6 +156,9 @@ en: attributes: roles: not_available_for_site: "Roles %{roles} are not completelly available for site '%{site}'." + clonable: + association_not_found: "Association '%{association}' does not exist for %{model}" + attribute_not_found: "Attribute '%{attribute}' does not exist for %{model}" models: folio/atom: diff --git a/config/locales/console.cs.yml b/config/locales/console.cs.yml index 7b8cf5914..4865007b3 100644 --- a/config/locales/console.cs.yml +++ b/config/locales/console.cs.yml @@ -9,6 +9,7 @@ cs: cancel: Zrušit confirm: Potvrdit continue: Pokračovat + clone: Duplikovat close: Zavřít destroy: Smazat discard: Archivovat diff --git a/config/locales/console.en.yml b/config/locales/console.en.yml index 694b9a6b1..c231c9856 100644 --- a/config/locales/console.en.yml +++ b/config/locales/console.en.yml @@ -9,6 +9,7 @@ en: cancel: Cancel confirm: Confirm continue: Continue + clone: Clone close: Close destroy: Destroy discard: Archive diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 529444630..5036b87e4 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -54,6 +54,9 @@ cs: accept: Ano cancel: Ne confirmation: Určitě? + console: + clone: + cloned_title: "%{original_title} - duplikát %{date}" head: description: '' @@ -69,3 +72,11 @@ cs: time: formats: folio_short: "%d. %m. %Y, %H:%M" + + cloning: + start: "Začátek klonování %{model} #%{id}" + deep_dup_finished: "Deep dup dokončen" + references_copied: "Reference zkopirovány: %{references}" + associations_duplicated: "Asociace duplikovány: %{associations}" + finished: "Klonování dokončeno" + error: "Chyba při klonování: %{message}" diff --git a/config/locales/en.yml b/config/locales/en.yml index 992ad590a..ec5bcdbcd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -52,6 +52,10 @@ en: content: This image contains sensitive content. To see it, verify your age. Are you over 18? accept: Yes cancel: No + console: + pages: + clone: + cloned_title: "%{original_title} - copy %{date}" head: description: '' @@ -68,3 +72,11 @@ en: time: formats: folio_short: "%d. %m. %Y, %H:%M" + + cloning: + start: "Started cloning %{model} #%{id}" + deep_dup_finished: "Deep dup finished" + references_copied: "References copied: %{references}" + associations_duplicated: "Associations duplicated: %{associations}" + finished: "Cloning finished" + error: "Cloning error: %{message}" diff --git a/config/routes.rb b/config/routes.rb index 6f1cfbedb..0e270bd87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -54,6 +54,7 @@ get :revision, path: "revision/:version" post :restore, path: "restore/:version" end + get :duplicate end end diff --git a/lib/folio/engine.rb b/lib/folio/engine.rb index 814d190a6..a576177d9 100644 --- a/lib/folio/engine.rb +++ b/lib/folio/engine.rb @@ -37,6 +37,7 @@ class Engine < ::Rails::Engine config.folio_console_default_routes_contstraints = {} config.folio_console_add_locale_to_preview_links = false config.folio_console_files_additional_html_api_url_lambda = -> (file) { nil } + config.folio_console_clonable_enabled = false config.folio_newsletter_subscription_service = :mailchimp config.folio_server_names = [] diff --git a/test/controllers/folio/console/pages_controller_test.rb b/test/controllers/folio/console/pages_controller_test.rb index 8f1415f02..2aec2f32f 100644 --- a/test/controllers/folio/console/pages_controller_test.rb +++ b/test/controllers/folio/console/pages_controller_test.rb @@ -49,4 +49,10 @@ class Folio::Console::PagesControllerTest < Folio::Console::BaseControllerTest assert_redirected_to url_for([:edit, :console, page]) end + + test "clone" do + page = create(:folio_page) + get url_for([:duplicate, :console, page]) + assert_response :success + end end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 87f7f95eb..28945fcd7 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -19,6 +19,7 @@ class Application < Rails::Application config.folio_leads_from_component_class_name = "Folio::Leads::FormComponent" config.folio_newsletter_subscriptions = true config.folio_site_default_test_factory = :dummy_site + config.folio_console_clonable_enabled = true I18n.available_locales = [:cs, :en] I18n.default_locale = :cs diff --git a/test/models/concerns/folio/clonable_test.rb b/test/models/concerns/folio/clonable_test.rb new file mode 100644 index 000000000..3ec1309df --- /dev/null +++ b/test/models/concerns/folio/clonable_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "test_helper" + +class Folio::ClonableTest < ActiveSupport::TestCase + test "create clone of page" do + page = create(:folio_page) + + create_atom(Dummy::Atom::Contents::Text, + placement: page, + content: "Původní text") + + image = create(:folio_file_image) + + create_atom(Dummy::Atom::Cards::Image, + placement: page, + title: "Původní titulek", + description: "Původní popis", + url: "https://example.com", + cover: image) + page.cover = image + + original_attributes = page.attributes + clone = Folio::Clonable::Cloner.new(page).create_clone + + clone.title = "clone" + assert clone.valid? + + assert_not_equal page.atoms, clone.atoms + assert_equal page.cover, clone.cover + assert_not_equal page.cover_placement, clone.cover_placement + + clone.atoms.first.update!(content: "Změněný text") + clone.atoms.last.update!(title: "Změněný titulek", description: "Změněný popis", url: "https://example2.com") + + clone.update!( + title: "Nový titulek", + perex: "Nový perex", + published_at: Time.current, + published: true, + ) + + page.reload + assert_equal original_attributes, page.attributes + assert_equal "Původní text", page.atoms.first.content + assert_not_equal page.atoms.first.content, clone.atoms.first.content + assert_equal image, page.atoms.second.cover + assert_equal image, clone.atoms.second.cover + end +end