From 2b078f9cc3b9e796f531f8e0f5655a9c7498b915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Tue, 4 Apr 2023 14:28:32 +0200 Subject: [PATCH] add proposal answer templates (#64) * add proposal answer templates * fix tests --- .eslintrc.json | 1 + Gemfile.lock | 5 + .../admin/copy_proposal_answer_template.rb | 35 +++ .../admin/copy_questionnaire_template.rb | 56 +++++ .../admin/create_proposal_answer_template.rb | 48 ++++ .../admin/create_questionnaire_template.rb | 35 +++ .../admin/update_proposal_answer_template.rb | 52 +++++ .../admin/application_controller_override.rb | 20 ++ .../proposal_answer_templates_controller.rb | 173 ++++++++++++++ .../admin/proposal_answer_template_form.rb | 21 ++ .../add_template_chooser.html.erb.deface | 5 + .../entrypoints/decidim_templates_admin.js | 1 + .../admin/proposal_answer_template_chooser.js | 21 ++ .../proposal_answer_templates/_form.html.erb | 34 +++ .../_template_chooser.html.erb | 16 ++ .../proposal_answer_templates/edit.html.erb | 3 + .../proposal_answer_templates/index.html.erb | 52 +++++ .../proposal_answer_templates/new.html.erb | 6 + config/assets.rb | 3 +- config/i18n-tasks.yml | 3 + config/locales/en.yml | 31 +++ ...d_values_to_decidim_templates_templates.rb | 12 + .../20230404104741_migrate_templatable.rb | 13 ++ decidim-reporting_proposals.gemspec | 1 + .../reporting_proposals/admin_engine.rb | 15 ++ lib/decidim/reporting_proposals/engine.rb | 3 +- .../reporting_proposals/test/factories.rb | 10 + .../copy_proposal_answer_template_spec.rb | 40 ++++ .../templates/admin/destroy_template_spec.rb | 20 ++ spec/factories.rb | 1 + .../proposal_answer_template_form_spec.rb | 63 +++++ spec/lib/overrides_spec.rb | 3 +- ..._manages_proposal_answer_templates_spec.rb | 217 ++++++++++++++++++ 33 files changed, 1016 insertions(+), 3 deletions(-) create mode 100644 app/commands/decidim/templates/admin/copy_proposal_answer_template.rb create mode 100644 app/commands/decidim/templates/admin/copy_questionnaire_template.rb create mode 100644 app/commands/decidim/templates/admin/create_proposal_answer_template.rb create mode 100644 app/commands/decidim/templates/admin/create_questionnaire_template.rb create mode 100644 app/commands/decidim/templates/admin/update_proposal_answer_template.rb create mode 100644 app/controllers/concerns/decidim/templates/admin/application_controller_override.rb create mode 100644 app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb create mode 100644 app/forms/decidim/templates/admin/proposal_answer_template_form.rb create mode 100644 app/overrides/decidim/proposals/admin/proposal_answers/_form/add_template_chooser.html.erb.deface create mode 100644 app/packs/entrypoints/decidim_templates_admin.js create mode 100644 app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js create mode 100644 app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb create mode 100644 app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb create mode 100644 app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb create mode 100644 app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb create mode 100644 app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb create mode 100644 db/migrate/20230404103706_add_target_and_field_values_to_decidim_templates_templates.rb create mode 100644 db/migrate/20230404104741_migrate_templatable.rb create mode 100644 spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb create mode 100644 spec/commands/decidim/templates/admin/destroy_template_spec.rb create mode 100644 spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb create mode 100644 spec/system/decidim/templates/admin/admin_manages_proposal_answer_templates_spec.rb diff --git a/.eslintrc.json b/.eslintrc.json index 44bccca6..c5e41a77 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,6 +16,7 @@ "Decidim": false, "$": false, "jQuery": false, + "Quill": false, "L": false }, "rules": { diff --git a/Gemfile.lock b/Gemfile.lock index 062778c2..87bcbac4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,6 +6,7 @@ PATH decidim-core (>= 0.25.0, < 0.27) decidim-participatory_processes (>= 0.25.0, < 0.27) decidim-proposals (>= 0.25.0, < 0.27) + decidim-templates (>= 0.25.0, < 0.27) deface (>= 1.9) GEM @@ -481,6 +482,7 @@ GEM mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.2) + mini_portile2 (2.8.1) minitest (5.17.0) mixlib-cli (2.1.8) mixlib-config (3.0.27) @@ -500,6 +502,9 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.8) + nokogiri (1.13.10) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) nokogiri (1.13.10-arm64-darwin) racc (~> 1.4) nokogiri (1.13.10-x86_64-linux) diff --git a/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb b/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb new file mode 100644 index 00000000..1942e444 --- /dev/null +++ b/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Decidim + module Templates + # A command with all the business logic when duplicating a proposal's answer template + module Admin + class CopyProposalAnswerTemplate < Rectify::Command + def initialize(template) + @template = template + end + + def call + return broadcast(:invalid) unless @template.valid? + + Template.transaction do + copy_template + end + + broadcast(:ok, @copied_template) + end + + def copy_template + @copied_template = Template.create!( + organization: @template.organization, + name: @template.name, + description: @template.description, + target: :proposal_answer, + field_values: @template.field_values, + templatable: @template.templatable + ) + end + end + end + end +end diff --git a/app/commands/decidim/templates/admin/copy_questionnaire_template.rb b/app/commands/decidim/templates/admin/copy_questionnaire_template.rb new file mode 100644 index 00000000..909dba99 --- /dev/null +++ b/app/commands/decidim/templates/admin/copy_questionnaire_template.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Decidim + module Templates + # A command with all the business logic when duplicating a questionnaire template + module Admin + class CopyQuestionnaireTemplate < Rectify::Command + include Decidim::Templates::Admin::QuestionnaireCopier + + # Public: Initializes the command. + # + # template - A template we want to duplicate + def initialize(template) + @template = template + end + + # Executes the command. Broadcasts these events: + # + # - :ok when everything is valid. + # - :invalid if the form wasn't valid and we couldn't proceed. + # + # Returns nothing. + def call + return broadcast(:invalid) unless @template.valid? + + Template.transaction do + copy_template + copy_questionnaire_questions(@template.templatable, @copied_template.templatable) + end + + broadcast(:ok, @copied_template) + end + + private + + attr_reader :form + + def copy_template + @copied_template = Template.create!( + organization: @template.organization, + name: @template.name, + description: @template.description, + target: :questionnaire + ) + @resource = Decidim::Forms::Questionnaire.create!( + @template.templatable.dup.attributes.merge( + questionnaire_for: @copied_template + ) + ) + + @copied_template.update!(templatable: @resource) + end + end + end + end +end diff --git a/app/commands/decidim/templates/admin/create_proposal_answer_template.rb b/app/commands/decidim/templates/admin/create_proposal_answer_template.rb new file mode 100644 index 00000000..6b79d3fe --- /dev/null +++ b/app/commands/decidim/templates/admin/create_proposal_answer_template.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class CreateProposalAnswerTemplate < Rectify::Command + # Initializes the command. + # + # form - The source for this ProposalAnswerTemplate. + def initialize(form) + @form = form + end + + def call + return broadcast(:invalid) unless @form.valid? + + @template = Decidim.traceability.create!( + Template, + @form.current_user, + name: @form.name, + description: @form.description, + organization: @form.current_organization, + field_values: { internal_state: @form.internal_state }, + target: :proposal_answer + ) + + resource = identify_templateable_resource + @template.update!(templatable: resource) + + broadcast(:ok, @template) + end + + private + + def identify_templateable_resource + resource = @form.scope_for_availability.split("-") + case resource.first + when "organizations" + @form.current_organization + when "components" + component = Decidim::Component.find_by(id: resource.last) + component&.participatory_space&.decidim_organization_id == @form.current_organization.id ? component : nil + end + end + end + end + end +end diff --git a/app/commands/decidim/templates/admin/create_questionnaire_template.rb b/app/commands/decidim/templates/admin/create_questionnaire_template.rb new file mode 100644 index 00000000..bbf5e10a --- /dev/null +++ b/app/commands/decidim/templates/admin/create_questionnaire_template.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + # Creates a QuestionnaireTemplate. + class CreateQuestionnaireTemplate < Rectify::Command + # Initializes the command. + # + # form - The source for this QuestionnaireTemplate. + def initialize(form) + @form = form + end + + def call + return broadcast(:invalid) unless @form.valid? + + @template = Decidim.traceability.create!( + Template, + @form.current_user, + name: @form.name, + description: @form.description, + organization: @form.current_organization, + target: :questionnaire + ) + + @questionnaire = Decidim::Forms::Questionnaire.create!(questionnaire_for: @template) + @template.update!(templatable: @questionnaire) + + broadcast(:ok, @template) + end + end + end + end +end diff --git a/app/commands/decidim/templates/admin/update_proposal_answer_template.rb b/app/commands/decidim/templates/admin/update_proposal_answer_template.rb new file mode 100644 index 00000000..064c9e4c --- /dev/null +++ b/app/commands/decidim/templates/admin/update_proposal_answer_template.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class UpdateProposalAnswerTemplate < Rectify::Command + # Initializes the command. + # + # template - The Template to update. + # form - The form object containing the data to update. + # user - The user that updates the template. + def initialize(template, form, user) + @template = template + @form = form + @user = user + end + + def call + return broadcast(:invalid) unless @form.valid? + return broadcast(:invalid) unless @user.organization == @template.organization + + @template = Decidim.traceability.update!( + @template, + @user, + name: @form.name, + description: @form.description, + field_values: { internal_state: @form.internal_state }, + target: :proposal_answer + ) + + resource = identify_templateable_resource + @template.update!(templatable: resource) + + broadcast(:ok, @template) + end + + private + + def identify_templateable_resource + resource = @form.scope_for_availability.split("-") + case resource.first + when "organizations" + @form.current_organization + when "components" + component = Decidim::Component.find_by(id: resource.last) + component&.participatory_space&.decidim_organization_id == @form.current_organization.id ? component : nil + end + end + end + end + end +end diff --git a/app/controllers/concerns/decidim/templates/admin/application_controller_override.rb b/app/controllers/concerns/decidim/templates/admin/application_controller_override.rb new file mode 100644 index 00000000..e7c9a915 --- /dev/null +++ b/app/controllers/concerns/decidim/templates/admin/application_controller_override.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + module ApplicationControllerOverride + extend ActiveSupport::Concern + + included do + def template_types + @template_types ||= { + I18n.t("template_types.questionnaires", scope: "decidim.templates") => decidim_admin_templates.questionnaire_templates_path, + I18n.t("template_types.proposal_answer_templates", scope: "decidim.templates") => decidim_admin_templates.proposal_answer_templates_path + } + end + end + end + end + end +end diff --git a/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb b/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb new file mode 100644 index 00000000..6d42ef11 --- /dev/null +++ b/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class ProposalAnswerTemplatesController < Decidim::Templates::Admin::ApplicationController + include Decidim::TranslatableAttributes + include Decidim::Paginable + + helper_method :availability_option_as_text, :availability_options_for_select + + def new + enforce_permission_to :create, :template + @form = form(ProposalAnswerTemplateForm).instance + end + + def edit + enforce_permission_to :update, :template, template: template + @form = form(ProposalAnswerTemplateForm).from_model(template) + end + + def create + enforce_permission_to :create, :template + + @form = form(ProposalAnswerTemplateForm).from_params(params) + + CreateProposalAnswerTemplate.call(@form) do + on(:ok) do |_template| + flash[:notice] = I18n.t("templates.create.success", scope: "decidim.admin") + redirect_to proposal_answer_templates_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("templates.create.error", scope: "decidim.admin") + render :new + end + end + end + + def destroy + enforce_permission_to :destroy, :template, template: template + + DestroyTemplate.call(template, current_user) do + on(:ok) do + flash[:notice] = I18n.t("templates.destroy.success", scope: "decidim.admin") + redirect_to action: :index + end + end + end + + def fetch + enforce_permission_to :read, :template, template: template + + response_object = { + state: template.field_values["internal_state"], + template: populate_template_interpolations(proposal) + } + + respond_to do |format| + format.json do + render json: response_object.to_json + end + end + end + + def update + enforce_permission_to :update, :template, template: template + @form = form(ProposalAnswerTemplateForm).from_params(params) + UpdateProposalAnswerTemplate.call(template, @form, current_user) do + on(:ok) do |_questionnaire_template| + flash[:notice] = I18n.t("templates.update.success", scope: "decidim.admin") + redirect_to proposal_answer_templates_path + end + + on(:invalid) do |template| + @template = template + flash.now[:error] = I18n.t("templates.update.error", scope: "decidim.admin") + render action: :edit + end + end + end + + def copy + enforce_permission_to :copy, :template + + CopyProposalAnswerTemplate.call(template) do + on(:ok) do + flash[:notice] = I18n.t("templates.copy.success", scope: "decidim.admin") + redirect_to action: :index + end + + on(:invalid) do + flash[:alert] = I18n.t("templates.copy.error", scope: "decidim.admin") + redirect_to action: :index + end + end + end + + def index + enforce_permission_to :index, :templates + @templates = collection + + respond_to do |format| + format.html { render :index } + format.json do + term = params[:term] + + @templates = search(term) + + render json: @templates.map { |t| { value: t.id, label: translated_attribute(t.name) } } + end + end + end + + private + + def populate_template_interpolations(proposal) + template.description.map do |row| + language = row.first + value = row.last + value.gsub!("%{organization}", proposal.organization.name) + value.gsub!("%{name}", proposal.creator_author.name) + value.gsub!("%{admin}", current_user.name) + + [language, value] + end.to_h + end + + def proposal + @proposal ||= Decidim::Proposals::Proposal.find(params[:proposal_id]) + end + + def availability_option_as_text(template) + return unless template.templatable_type + + key = "#{template.templatable_type.demodulize.tableize}-#{template.templatable_id}" + avaliablity_options[key].presence || t("templates.missing_resource", scope: "decidim.admin") + end + + def availability_options_for_select + avaliablity_options.collect { |key, value| [value, key] }.to_a + end + + def avaliablity_options + @avaliablity_options = {} + Decidim::Component.includes(:participatory_space).where(manifest_name: accepted_components) + .select { |a| a.participatory_space.decidim_organization_id == current_organization.id }.each do |component| + @avaliablity_options["components-#{component.id}"] = formated_name(component) + end + global_scope = { "organizations-#{current_organization.id}" => t("global_scope", scope: "decidim.templates.admin.proposal_answer_templates.index") } + @avaliablity_options = global_scope.merge(Hash[@avaliablity_options.sort_by { |_, val| val }]) + end + + def formated_name(component) + space_type = t(component.participatory_space.class.name.underscore, scope: "activerecord.models", count: 1) + "#{space_type}: #{translated_attribute(component.participatory_space.title)} > #{translated_attribute(component.name)}" + end + + def accepted_components + [:proposals] + end + + def template + @template ||= Template.find_by(id: params[:id]) + end + + def collection + @collection ||= paginate(current_organization.templates.where(target: :proposal_answer).order(:id)) + end + end + end + end +end diff --git a/app/forms/decidim/templates/admin/proposal_answer_template_form.rb b/app/forms/decidim/templates/admin/proposal_answer_template_form.rb new file mode 100644 index 00000000..2278a969 --- /dev/null +++ b/app/forms/decidim/templates/admin/proposal_answer_template_form.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class ProposalAnswerTemplateForm < TemplateForm + attribute :internal_state, String + attribute :scope_for_availability, String + + validates :internal_state, presence: true + + def map_model(model) + self.scope_for_availability = "#{model.templatable_type.try(:demodulize).try(:tableize)}-#{model.templatable_id.to_i}" + (model.field_values || []).to_h.map do |k, v| + self[k.to_sym] = v + end + end + end + end + end +end diff --git a/app/overrides/decidim/proposals/admin/proposal_answers/_form/add_template_chooser.html.erb.deface b/app/overrides/decidim/proposals/admin/proposal_answers/_form/add_template_chooser.html.erb.deface new file mode 100644 index 00000000..47a4152e --- /dev/null +++ b/app/overrides/decidim/proposals/admin/proposal_answers/_form/add_template_chooser.html.erb.deface @@ -0,0 +1,5 @@ + + +<% if defined?(Decidim::Templates) %> + <%= render "decidim/templates/admin/proposal_answer_templates/template_chooser", form: f %> +<% end %> \ No newline at end of file diff --git a/app/packs/entrypoints/decidim_templates_admin.js b/app/packs/entrypoints/decidim_templates_admin.js new file mode 100644 index 00000000..63663ae4 --- /dev/null +++ b/app/packs/entrypoints/decidim_templates_admin.js @@ -0,0 +1 @@ +import "src/decidim/templates/admin/proposal_answer_template_chooser"; diff --git a/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js b/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js new file mode 100644 index 00000000..1f6e5335 --- /dev/null +++ b/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js @@ -0,0 +1,21 @@ +$(() => { + $("#proposal_answer_template_chooser").change(function() { + let dropDown = $("#proposal_answer_template_chooser"); + $.getJSON(dropDown.data("url"), { + id: dropDown.val(), + /* eslint camelcase: [0] */ + proposal_id: dropDown.data("proposal") + }).done(function(data) { + $(`#proposal_answer_internal_state_${data.state}`).trigger("click"); + + let $editors = dropDown.parent().parent().find(".tabs-panel").find(".editor-container"); + $editors.each(function(index, element) { + let localElement = $(element); + let $locale = localElement.siblings("input[type=hidden]").attr("id").replace("proposal_answer_answer_", ""); + let editor = Quill.find(element); + let delta = editor.clipboard.convert(data.template[$locale]); + editor.setContents(delta); + }); + }); + }); +}); diff --git a/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb b/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb new file mode 100644 index 00000000..37a3c7ce --- /dev/null +++ b/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb @@ -0,0 +1,34 @@ +
+
+

<%= t(".template_title") %>

+ <%= form.submit t(".save"), class: "button tiny button--title" %> +
+ +
+
+ <%= form.translated :text_field, :name %> +
+ +
+ <%= form.translated :editor, :description, rows: 15, label: t(".answer_template") %> + <%= t(".hint").html_safe %> +
    +
  • <%= t(".hint1").html_safe %>
  • +
  • <%= t(".hint2").html_safe %>
  • +
  • <%= t(".hint3").html_safe %>
  • +
+
+ +
+ <%= form.label :internal_state %> + <%= form.collection_radio_buttons :internal_state, + Decidim::Proposals::Proposal::POSSIBLE_STATES - %w(withdrawn), + :to_s, + ->(mode) { t(mode, scope: "decidim.proposals.admin.proposal_answers.form") } %> +
+ +
+ <%= form.select :scope_for_availability, availability_options_for_select, help_text: t(".scope_for_availability_help") %> +
+
+
diff --git a/app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb b/app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb new file mode 100644 index 00000000..34cef944 --- /dev/null +++ b/app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb @@ -0,0 +1,16 @@ +<% templates = Decidim::Templates::Template .where( + target: :proposal_answer, + templatable: [current_organization, current_component] +).order(:templatable_id) %> +<% if templates.any? %> +
+ <%= javascript_pack_tag "decidim_templates_admin" %> + + +
+<% end %> diff --git a/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb b/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb new file mode 100644 index 00000000..27daf7ef --- /dev/null +++ b/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb @@ -0,0 +1,3 @@ +<%= decidim_form_for(@form, url: proposal_answer_template_path, html: { class: "form edit_proposal_answer_template" }) do |f| %> + <%= render partial: "form", object: f %> +<% end %> diff --git a/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb b/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb new file mode 100644 index 00000000..4a50df53 --- /dev/null +++ b/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb @@ -0,0 +1,52 @@ +
+
+

+ <%= t ".title" %> + <% if allowed_to?(:create, :template) %> + <%= link_to t("actions.new", scope: "decidim.admin", name: t("template.name", scope: "decidim.models").downcase), [:new, :proposal_answer_template], class: "button tiny button--title new" %> + <% end %> +

+
+
+ <% if @templates.any? %> +
+ + + + + + + + + + + + <% @templates.each do |template| %> + + + + + + + + <% end %> + + +
<%= t("template.name", scope: "decidim.models") %><%= t(".internal_state") %><%= t(".scope_for_availability") %><%= t("template.fields.created_at", scope: "decidim.models") %>
<%= link_to_if allowed_to?(:update, :template, template: template) , translated_attribute(template.name), edit_proposal_answer_template_path(template) %> <%= t(template.field_values.dig("internal_state"), scope: "decidim.proposals.admin.proposal_answers.form") %><%= availability_option_as_text(template) %><%= l template.created_at, format: :long %> + <% if allowed_to?(:update, :template, template: template) %> + <%= icon_link_to "pencil", edit_proposal_answer_template_path(template), t("actions.edit", scope: "decidim.admin"), class: "edit" %> + <% end %> + <% if allowed_to?(:copy, :template, template: template) %> + <%= icon_link_to "clipboard", copy_proposal_answer_template_path(template), t("actions.duplicate", scope: "decidim.admin"), method: :post %> + <% end %> + <% if allowed_to?(:destroy, :template, template: template) %> + <%= icon_link_to "circle-x", proposal_answer_template_path(template), t("actions.destroy", scope: "decidim.admin"), method: :delete, data: { confirm: t(".confirm_delete") }, class: "action-icon--remove" %> + <% end %> +
+ <%= paginate @templates, theme: "decidim" %> +
+ <% else %> + <%= t("templates.empty", scope: "decidim.admin") %> + <% end %> +
+
diff --git a/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb b/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb new file mode 100644 index 00000000..e72ad620 --- /dev/null +++ b/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb @@ -0,0 +1,6 @@ +<% content_for :title do %> + <%= t("templates", scope: "decidim.admin.titles") %> +<% end %> +<%= decidim_form_for(@form, url: proposal_answer_templates_path, html: { class: "form new_proposal_answer_template" }) do |f| %> + <%= render partial: "form", object: f %> +<% end %> diff --git a/config/assets.rb b/config/assets.rb index 771f7080..55245d87 100644 --- a/config/assets.rb +++ b/config/assets.rb @@ -9,5 +9,6 @@ decidim_reporting_proposals_list_component_admin: "#{base_path}/app/packs/entrypoints/decidim_reporting_proposals_list_component_admin.js", decidim_reporting_proposals_geocoding: "#{base_path}/app/packs/entrypoints/decidim_reporting_proposals_geocoding.js", decidim_reporting_proposals_camera: "#{base_path}/app/packs/entrypoints/decidim_reporting_proposals_camera.js", - decidim_reporting_proposals_js_validations: "#{base_path}/app/packs/entrypoints/decidim_reporting_proposals_js_validations.js" + decidim_reporting_proposals_js_validations: "#{base_path}/app/packs/entrypoints/decidim_reporting_proposals_js_validations.js", + decidim_templates_admin: "#{base_path}/app/packs/entrypoints/decidim_templates_admin.js" ) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 4d78183c..f33ad95e 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -7,6 +7,9 @@ data: external: - "<%= %x[bundle info decidim-core --path].chomp %>/config/locales/%{locale}.yml" - "<%= %x[bundle info decidim-proposals --path].chomp %>/config/locales/%{locale}.yml" + - "<%= %x[bundle info decidim-admin --path].chomp %>/config/locales/%{locale}.yml" + - "<%= %x[bundle info decidim-participatory_processes --path].chomp %>/config/locales/%{locale}.yml" + - "<%= %x[bundle info decidim-templates --path].chomp %>/config/locales/%{locale}.yml" ignore_unused: diff --git a/config/locales/en.yml b/config/locales/en.yml index 13c7d556..f4766118 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -7,7 +7,12 @@ en: proposal: has_no_address: Has no address has_no_image: Has no image + template: + scope_for_availability: Restrict availability to the component decidim: + admin: + templates: + missing_resource: "(missing resource)" application: geocoding: unconfigured: Geocoding is not configured! @@ -427,3 +432,29 @@ en: title: Nearby proposals form: image: Image/photo + templates: + admin: + proposal_answer_templates: + form: + answer_template: Answer template + hint: "Hint: You can use these variables anywhere on + the answer template that will be replaced when using the template" + hint1: "%{organization} will be replaced by the organization's + name" + hint2: "%{name} will be replaced by the author's name" + hint3: "%{admin} will be replaced by the admin's name + (the one answering the proposal)" + save: Save + scope_for_availability_help: Note that only participatory spaces having + components of the type "proposals" will be listed. + template_title: Template information + index: + confirm_delete: Are you sure you want to delete this template? + global_scope: Global (available everywhere) + internal_state: Internal State + scope_for_availability: Scope + title: Proposal answers + template_chooser: + select_template: Select a template answer + template_types: + proposal_answer_templates: Proposal answers diff --git a/db/migrate/20230404103706_add_target_and_field_values_to_decidim_templates_templates.rb b/db/migrate/20230404103706_add_target_and_field_values_to_decidim_templates_templates.rb new file mode 100644 index 00000000..672ec525 --- /dev/null +++ b/db/migrate/20230404103706_add_target_and_field_values_to_decidim_templates_templates.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddTargetAndFieldValuesToDecidimTemplatesTemplates < ActiveRecord::Migration[6.0] + def change + unless ActiveRecord::Base.connection.column_exists?(:decidim_templates_templates, :field_values) + add_column :decidim_templates_templates, :field_values, :json, default: {} + end + unless ActiveRecord::Base.connection.column_exists?(:decidim_templates_templates, :target) + add_column :decidim_templates_templates, :target, :string + end + end +end diff --git a/db/migrate/20230404104741_migrate_templatable.rb b/db/migrate/20230404104741_migrate_templatable.rb new file mode 100644 index 00000000..a50d3f82 --- /dev/null +++ b/db/migrate/20230404104741_migrate_templatable.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class MigrateTemplatable < ActiveRecord::Migration[6.0] + def self.up + Decidim::Templates::Template.find_each do |template| + next if template.target.present? + + template.update(target: template.templatable_type.demodulize.tableize.singularize) + end + end + + def self.down; end +end diff --git a/decidim-reporting_proposals.gemspec b/decidim-reporting_proposals.gemspec index c7455f2c..e5f84727 100644 --- a/decidim-reporting_proposals.gemspec +++ b/decidim-reporting_proposals.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency "decidim-core", Decidim::ReportingProposals::COMPAT_DECIDIM_VERSION spec.add_dependency "decidim-participatory_processes", Decidim::ReportingProposals::COMPAT_DECIDIM_VERSION spec.add_dependency "decidim-proposals", Decidim::ReportingProposals::COMPAT_DECIDIM_VERSION + spec.add_dependency "decidim-templates", Decidim::ReportingProposals::COMPAT_DECIDIM_VERSION spec.add_dependency "deface", ">= 1.9" spec.add_development_dependency "decidim-dev", Decidim::ReportingProposals::COMPAT_DECIDIM_VERSION diff --git a/lib/decidim/reporting_proposals/admin_engine.rb b/lib/decidim/reporting_proposals/admin_engine.rb index 0b3b1077..e19a5e03 100644 --- a/lib/decidim/reporting_proposals/admin_engine.rb +++ b/lib/decidim/reporting_proposals/admin_engine.rb @@ -17,6 +17,21 @@ class AdminEngine < ::Rails::Engine resources :proposal_notes, only: [:edit, :update] end + initializer "decidim_reporting_proposals.template_routes" do + if defined? Decidim::Templates::AdminEngine + Decidim::Templates::AdminEngine.routes do + resources :proposal_answer_templates do + member do + post :copy + end + collection do + get :fetch + end + end + end + end + end + initializer "decidim_reporting_proposals.admin_mount_routes" do Decidim::Admin::Engine.routes do mount Decidim::ReportingProposals::AdminEngine, at: "/reporting_proposals", as: "decidim_admin_reporting_proposals" diff --git a/lib/decidim/reporting_proposals/engine.rb b/lib/decidim/reporting_proposals/engine.rb index 32d8840d..d8f0f29b 100644 --- a/lib/decidim/reporting_proposals/engine.rb +++ b/lib/decidim/reporting_proposals/engine.rb @@ -41,13 +41,14 @@ class Engine < ::Rails::Engine initializer "decidim_reporting_proposals.overrides", after: "decidim.action_controller" do config.to_prepare do Decidim::Admin::ComponentsController.include(Decidim::ReportingProposals::Admin::NeedsHeaderSnippets) + Decidim::Admin::CategoriesController.include(Decidim::ReportingProposals::Admin::CategoriesControllerOverride) Decidim::Proposals::ProposalsController.include(Decidim::ReportingProposals::ProposalsControllerOverride) Decidim::Proposals::ProposalWizardHelper.include(Decidim::ReportingProposals::ProposalWizardHelperOverride) Decidim::Proposals::Admin::ProposalsController.include(Decidim::ReportingProposals::Admin::NeedsHeaderSnippets) Decidim::Proposals::Admin::ProposalsController.include(Decidim::ReportingProposals::Admin::ProposalsControllerOverride) Decidim::Proposals::Admin::ProposalAnswersController.include(Decidim::ReportingProposals::Admin::ProposalAnswersControllerOverride) Decidim::Proposals::Admin::ProposalsHelper.include(Decidim::ReportingProposals::Admin::ProposalsHelperOverride) - Decidim::Admin::CategoriesController.include(Decidim::ReportingProposals::Admin::CategoriesControllerOverride) + Decidim::Templates::Admin::ApplicationController.include(Decidim::Templates::Admin::ApplicationControllerOverride) begin Decidim::Templates::Admin::ProposalAnswerTemplatesController.include(Decidim::ReportingProposals::Admin::ProposalAnswerTemplatesControllerOverride) rescue StandardError => e diff --git a/lib/decidim/reporting_proposals/test/factories.rb b/lib/decidim/reporting_proposals/test/factories.rb index 34b552df..47d4c167 100644 --- a/lib/decidim/reporting_proposals/test/factories.rb +++ b/lib/decidim/reporting_proposals/test/factories.rb @@ -11,3 +11,13 @@ valuator_role { create :participatory_process_user_role, role: "valuator" } end end + +FactoryBot.modify do + factory :template, class: "Decidim::Templates::Template" do + trait :proposal_answer do + templatable { organization } + target { :proposal_answer } + field_values { { internal_state: :accepted } } + end + end +end diff --git a/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb b/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb new file mode 100644 index 00000000..cc1156b8 --- /dev/null +++ b/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe CopyProposalAnswerTemplate do + let(:template) { create(:template, :proposal_answer) } + + describe "when the template is invalid" do + before do + template.update(name: nil) + end + + it "broadcasts invalid" do + expect { described_class.call(template) }.to broadcast(:invalid) + end + end + + describe "when the template is valid" do + let(:destination_template) do + events = described_class.call(template) + # events => { :ok => copied_template } + expect(events).to have_key(:ok) + events[:ok] + end + + it "applies template attributes to the questionnaire" do + expect(destination_template.name).to eq(template.name) + expect(destination_template.description).to eq(template.description) + expect(destination_template.field_values).to eq(template.field_values) + expect(destination_template.templatable).to eq(template.templatable) + expect(destination_template.target).to eq(template.target) + end + end + end + end + end +end diff --git a/spec/commands/decidim/templates/admin/destroy_template_spec.rb b/spec/commands/decidim/templates/admin/destroy_template_spec.rb new file mode 100644 index 00000000..59a67f23 --- /dev/null +++ b/spec/commands/decidim/templates/admin/destroy_template_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe DestroyTemplate do + let(:template) { create(:questionnaire_template) } + let(:admin) { create(:user, :admin) } + let!(:templatable) { template.templatable } + + it "destroy the template" do + described_class.call(template, admin) + expect { template.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + end +end diff --git a/spec/factories.rb b/spec/factories.rb index 11e512f6..2d5da853 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -3,4 +3,5 @@ require "decidim/core/test/factories" require "decidim/participatory_processes/test/factories" require "decidim/proposals/test/factories" +require "decidim/templates/test/factories" require "decidim/reporting_proposals/test/factories" diff --git a/spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb b/spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb new file mode 100644 index 00000000..622ad8d4 --- /dev/null +++ b/spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe ProposalAnswerTemplateForm do + subject do + described_class.from_params(attributes).with_context( + current_organization: current_organization + ) + end + + let(:current_organization) { create(:organization) } + + let(:name) do + { + "en" => name_english, + "ca" => "Nom", + "es" => "Nombre" + } + end + + let(:description) do + { + "en" => "

Content

", + "ca" => "

Contingut

", + "es" => "

Contenido

" + } + end + + let(:internal_state) { :accepted } + + let(:name_english) { "Name" } + + let(:attributes) do + { + "name" => name, + "description" => description, + "internal_state" => internal_state + } + end + + context "when everything is OK" do + it { is_expected.to be_valid } + end + + context "when name is not valid" do + let(:name_english) { "" } + + it { is_expected.not_to be_valid } + end + + context "when internal_state is not valid" do + let(:internal_state) { "" } + + it { is_expected.not_to be_valid } + end + end + end + end +end diff --git a/spec/lib/overrides_spec.rb b/spec/lib/overrides_spec.rb index 9ef14ad7..41c86e44 100644 --- a/spec/lib/overrides_spec.rb +++ b/spec/lib/overrides_spec.rb @@ -31,7 +31,8 @@ { package: "decidim-templates", files: { - # "/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb" => "11677320c5263e2fe13371ec2d057480" + "/app/commands/decidim/templates/admin/copy_questionnaire_template.rb" => "4c8a7686cc05ad207bd7c041c99c4ef5", + "/app/commands/decidim/templates/admin/create_questionnaire_template.rb" => "14b1479a6de1153c4396bfbaf2f05703" } }, { diff --git a/spec/system/decidim/templates/admin/admin_manages_proposal_answer_templates_spec.rb b/spec/system/decidim/templates/admin/admin_manages_proposal_answer_templates_spec.rb new file mode 100644 index 00000000..498258b2 --- /dev/null +++ b/spec/system/decidim/templates/admin/admin_manages_proposal_answer_templates_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "spec_helper" +require "decidim/proposals/test/factories" + +describe "Admin manages proposal answer templates", type: :system do + let!(:organization) { create :organization } + let!(:user) { create :user, :admin, :confirmed, organization: organization } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + visit decidim_admin_templates.proposal_answer_templates_path + end + + describe "listing templates" do + let!(:template) { create(:template, :proposal_answer, organization: organization) } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + end + + it "shows a table with the templates info" do + within ".questionnaire-templates" do + expect(page).to have_i18n_content(template.name) + expect(page).to have_i18n_content("Global (available everywhere)") + end + end + + context "when a template is scoped to an invalid resource" do + let!(:template) { create(:template, :proposal_answer, organization: organization, templatable: create(:dummy_resource)) } + + it "shows a table info about the invalid resource" do + within ".questionnaire-templates" do + expect(page).to have_i18n_content(template.name) + expect(page).to have_i18n_content("(missing resource)") + end + end + end + end + + describe "creating a proposal_answer_template" do + let(:participatory_process) { create :participatory_process, title: { en: "A participatory process" }, organization: organization } + let!(:proposals_component) { create :component, manifest_name: :proposals, name: { en: "A component" }, participatory_space: participatory_process } + + before do + within ".layout-content" do + click_link("New") + end + end + + shared_examples "creates a new template with scopes" do |scope_name| + it "creates a new template" do + within ".new_proposal_answer_template" do + fill_in_i18n( + :proposal_answer_template_name, + "#proposal_answer_template-name-tabs", + en: "My template", + es: "Mi plantilla", + ca: "La meva plantilla" + ) + fill_in_i18n_editor( + :proposal_answer_template_description, + "#proposal_answer_template-description-tabs", + en: "Description", + es: "DescripciĆ³n", + ca: "DescripciĆ³" + ) + + choose "Not answered" + select scope_name, from: :proposal_answer_template_scope_for_availability + + page.find("*[type=submit]").click + end + + expect(page).to have_admin_callout("successfully") + expect(page).to have_current_path decidim_admin_templates.proposal_answer_templates_path + within ".questionnaire-templates" do + expect(page).to have_i18n_content(scope_name) + expect(page).to have_content("My template") + end + end + end + + it_behaves_like "creates a new template with scopes", "Global (available everywhere)" + it_behaves_like "creates a new template with scopes", "Participatory process: A participatory process > A component" + end + + describe "updating a template" do + let!(:template) { create(:template, :proposal_answer, organization: organization) } + let(:participatory_process) { create :participatory_process, title: { en: "A participatory process" }, organization: organization } + let!(:proposals_component) { create :component, manifest_name: :proposals, name: { en: "A component" }, participatory_space: participatory_process } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + click_link translated(template.name) + end + + shared_examples "updates a template with scopes" do |scope_name| + it "updates a template" do + fill_in_i18n( + :proposal_answer_template_name, + "#proposal_answer_template-name-tabs", + en: "My new name", + es: "Mi nuevo nombre", + ca: "El meu nou nom" + ) + + select scope_name, from: :proposal_answer_template_scope_for_availability + + within ".edit_proposal_answer_template" do + page.find("*[type=submit]").click + end + + expect(page).to have_admin_callout("successfully") + expect(page).to have_current_path decidim_admin_templates.proposal_answer_templates_path + within ".questionnaire-templates" do + expect(page).to have_i18n_content(scope_name) + expect(page).to have_content("My new name") + end + end + end + + it_behaves_like "updates a template with scopes", "Global (available everywhere)" + it_behaves_like "updates a template with scopes", "Participatory process: A participatory process > A component" + end + + describe "updating a template with invalid values" do + let!(:template) { create(:template, :proposal_answer, organization: organization) } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + click_link translated(template.name) + end + + it "does not update the template" do + fill_in_i18n( + :proposal_answer_template_name, + "#proposal_answer_template-name-tabs", + en: "", + es: "", + ca: "" + ) + + within ".edit_proposal_answer_template" do + find("*[type=submit]").click + end + + expect(page).to have_admin_callout("problem") + end + end + + describe "copying a template" do + let!(:template) { create(:template, :proposal_answer, organization: organization) } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + end + + it "copies the template" do + within find("tr", text: translated(template.name)) do + click_link "Duplicate" + end + + expect(page).to have_admin_callout("successfully") + expect(page).to have_content(template.name["en"], count: 2) + end + end + + describe "destroying a template" do + let!(:template) { create(:template, :proposal_answer, organization: organization) } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + end + + it "destroys the template" do + within find("tr", text: translated(template.name)) do + accept_confirm { click_link "Delete" } + end + + expect(page).to have_admin_callout("successfully") + expect(page).to have_no_i18n_content(template.name) + end + end + + describe "using a proposal_answer_template" do + let(:participatory_process) { create :participatory_process, title: { en: "A participatory process" }, organization: organization } + let!(:component) { create :component, manifest_name: :proposals, name: { en: "A component" }, participatory_space: participatory_process } + + let(:description) { "Some meaningful answer" } + let(:values) do + { internal_state: "rejected" } + end + let!(:template) { create(:template, :proposal_answer, description: { en: description }, field_values: values, organization: organization, templatable: component) } + let!(:proposal) { create(:proposal, component: component) } + + before do + visit Decidim::EngineRouter.admin_proxy(component).root_path + find("a", class: "action-icon--show-proposal").click + end + + it "uses the template" do + within ".edit_proposal_answer" do + select template.name["en"], from: :proposal_answer_template_chooser + expect(page).to have_content(description) + click_button "Answer" + end + + expect(page).to have_admin_callout("Proposal successfully answered") + + within find("tr", text: proposal.title["en"]) do + expect(page).to have_content("Rejected") + end + end + end +end