diff --git a/decidim-proposals/app/views/decidim/proposals/admin/proposal_answers/_form.html.erb b/decidim-proposals/app/views/decidim/proposals/admin/proposal_answers/_form.html.erb index dbd886ed228dc..6c77bda0d4546 100644 --- a/decidim-proposals/app/views/decidim/proposals/admin/proposal_answers/_form.html.erb +++ b/decidim-proposals/app/views/decidim/proposals/admin/proposal_answers/_form.html.erb @@ -5,6 +5,10 @@

<%= t ".title", title: present(proposal).title %>

+ <% if defined?(Decidim::Templates) %> + <%= render "decidim/templates/admin/proposal_answer_templates/template_chooser", form: f %> + <% end %> +
<%= f.collection_radio_buttons :internal_state, [["not_answered", t(".not_answered")], ["accepted", t(".accepted")], ["rejected", t(".rejected")], ["evaluating", t(".evaluating")]], :first, :last, prompt: true do |builder| builder.label { builder.radio_button + builder.text } end %> diff --git a/decidim-templates/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb b/decidim-templates/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb new file mode 100644 index 0000000000000..6cf809d6b7674 --- /dev/null +++ b/decidim-templates/app/commands/decidim/templates/admin/copy_proposal_answer_template.rb @@ -0,0 +1,21 @@ +# 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 < CopyTemplate + 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/decidim-templates/app/commands/decidim/templates/admin/create_proposal_answer_template.rb b/decidim-templates/app/commands/decidim/templates/admin/create_proposal_answer_template.rb new file mode 100644 index 0000000000000..cdd694c62d197 --- /dev/null +++ b/decidim-templates/app/commands/decidim/templates/admin/create_proposal_answer_template.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class CreateProposalAnswerTemplate < Decidim::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 + ) + + @template.update!(templatable: identify_templateable_resource) + + broadcast(:ok, @template) + end + + private + + def identify_templateable_resource + resource = @form.current_organization + if @form.component_constraint.present? + found_component = Decidim::Component.find_by(id: @form.component_constraint, manifest_name: "proposals") + if found_component.present? + resource = found_component&.participatory_space&.decidim_organization_id == @form.current_organization.id ? found_component : nil + end + end + resource + end + end + end + end +end diff --git a/decidim-templates/app/commands/decidim/templates/admin/update_proposal_answer_template.rb b/decidim-templates/app/commands/decidim/templates/admin/update_proposal_answer_template.rb new file mode 100644 index 0000000000000..726254469dcc7 --- /dev/null +++ b/decidim-templates/app/commands/decidim/templates/admin/update_proposal_answer_template.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class UpdateProposalAnswerTemplate < Decidim::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 + ) + + @template.update!(templatable: identify_templateable_resource) + + broadcast(:ok, @template) + end + + private + + def identify_templateable_resource + resource = @form.current_organization + if @form.component_constraint.present? + found_component = Decidim::Component.find_by(id: @form.component_constraint, manifest_name: "proposals") + if found_component.present? + resource = found_component&.participatory_space&.decidim_organization_id == @form.current_organization.id ? found_component : nil + end + end + resource + end + end + end + end +end diff --git a/decidim-templates/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb b/decidim-templates/app/controllers/decidim/templates/admin/proposal_answer_templates_controller.rb new file mode 100644 index 0000000000000..95f6d074826c8 --- /dev/null +++ b/decidim-templates/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:) + @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:) + + 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:) + + 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:) + @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, current_user) 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.to_h do |language, value| + value.gsub!("%{organization}", proposal.organization.name) + value.gsub!("%{name}", author_name(proposal)) + value.gsub!("%{admin}", current_user.name) + + [language, value] + end + end + + def author_name(proposal) + proposal.creator_author.try(:title) || proposal.creator_author.try(:name) + end + + def proposal + @proposal ||= Decidim::Proposals::Proposal.find(params[:proposalId]) + end + + def availability_option_as_text(template) + return unless template.templatable_type + return t("global_scope", scope: "decidim.templates.admin.proposal_answer_templates.index") if template.templatable == current_organization + + avaliablity_options.select { |a| a.last == template.templatable_id }&.flatten&.first || t("templates.missing_resource", scope: "decidim.admin") + end + + def availability_options_for_select + avaliablity_options + end + + def avaliablity_options + @avaliablity_options = [] + @avaliablity_options.push [t("global_scope", scope: "decidim.templates.admin.proposal_answer_templates.index"), 0] + + Decidim::Component.includes(:participatory_space).where(manifest_name: [:proposals]) + .select { |a| a.participatory_space.decidim_organization_id == current_organization.id }.each do |component| + @avaliablity_options.push [formatted_name(component), component.id] + end + + @avaliablity_options + end + + def formatted_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 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/decidim-templates/app/forms/decidim/templates/admin/proposal_answer_template_form.rb b/decidim-templates/app/forms/decidim/templates/admin/proposal_answer_template_form.rb new file mode 100644 index 0000000000000..72c72370e2f06 --- /dev/null +++ b/decidim-templates/app/forms/decidim/templates/admin/proposal_answer_template_form.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Decidim + module Templates + module Admin + class ProposalAnswerTemplateForm < TemplateForm + attribute :internal_state, String + attribute :component_constraint, Integer + + validates :internal_state, presence: true + + def map_model(model) + self.internal_state = model.field_values["internal_state"] + self.component_constraint = if model.templatable_type == "Decidim::Organization" + 0 + else + model.templatable&.id + end + end + end + end + end +end diff --git a/decidim-templates/app/packs/entrypoints/decidim_templates_admin.js b/decidim-templates/app/packs/entrypoints/decidim_templates_admin.js index 69282e7b67bb6..e384c97f4d07b 100644 --- a/decidim-templates/app/packs/entrypoints/decidim_templates_admin.js +++ b/decidim-templates/app/packs/entrypoints/decidim_templates_admin.js @@ -1 +1,2 @@ -import "src/decidim/templates/admin/block_template_chooser" +import "src/decidim/templates/admin/block_user_template_chooser" +import "src/decidim/templates/admin/proposal_answer_template_chooser" diff --git a/decidim-templates/app/packs/src/decidim/templates/admin/block_template_chooser.js b/decidim-templates/app/packs/src/decidim/templates/admin/block_template_chooser.js deleted file mode 100644 index 1ac391f565648..0000000000000 --- a/decidim-templates/app/packs/src/decidim/templates/admin/block_template_chooser.js +++ /dev/null @@ -1,17 +0,0 @@ -// Choose a Block User Message template, get it by AJAX and add the Template in the justification textarea -document.addEventListener("DOMContentLoaded", () => { - document.getElementById("block_template_chooser").addEventListener("change", () => { - const dropdown = document.getElementById("block_template_chooser"); - const url = dropdown.getAttribute("data-url"); - const templateId = dropdown.value; - - if (templateId === "") { - return; - } - fetch(`${new URL(url).pathname}?${new URLSearchParams({ id: templateId })}`). - then((response) => response.json()). - then((data) => { - document.getElementById("block_user_justification").value = data.template; - }) - }); -}); diff --git a/decidim-templates/app/packs/src/decidim/templates/admin/block_user_template_chooser.js b/decidim-templates/app/packs/src/decidim/templates/admin/block_user_template_chooser.js new file mode 100644 index 0000000000000..6fbf484307026 --- /dev/null +++ b/decidim-templates/app/packs/src/decidim/templates/admin/block_user_template_chooser.js @@ -0,0 +1,20 @@ +// Choose a Block User Message template, get it by AJAX and add the Template in the justification textarea +document.addEventListener("DOMContentLoaded", () => { + const blockTemplateChooser = document.getElementById("block_template_chooser"); + if (blockTemplateChooser) { + blockTemplateChooser.addEventListener("change", () => { + const dropdown = document.getElementById("block_template_chooser"); + const url = dropdown.getAttribute("data-url"); + const templateId = dropdown.value; + + if (templateId === "") { + return; + } + fetch(`${new URL(url).pathname}?${new URLSearchParams({ id: templateId })}`). + then((response) => response.json()). + then((data) => { + document.getElementById("block_user_justification").value = data.template; + }) + }); + } +}); diff --git a/decidim-templates/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js b/decidim-templates/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js new file mode 100644 index 0000000000000..6afacf861b5c5 --- /dev/null +++ b/decidim-templates/app/packs/src/decidim/templates/admin/proposal_answer_template_chooser.js @@ -0,0 +1,31 @@ +// Choose a Proposal Answer template, get it by AJAX and add the Template in the Proposal Answer textarea +document.addEventListener("DOMContentLoaded", () => { + const proposalAnswerTemplateChooser = document.getElementById("proposal_answer_template_chooser"); + if (proposalAnswerTemplateChooser) { + + proposalAnswerTemplateChooser.addEventListener("change", () => { + const dropdown = document.getElementById("proposal_answer_template_chooser"); + const url = dropdown.getAttribute("data-url"); + const templateId = dropdown.value; + const proposalId = dropdown.dataset.proposal; + + if (templateId === "") { + return; + } + fetch(`${new URL(url).pathname}?${new URLSearchParams({ id: templateId, proposalId: proposalId })}`). + then((response) => response.json()). + then((data) => { + document.getElementById(`proposal_answer_internal_state_${data.state}`).click(); + + let editorContainer = null; + for (const [key, value] of Object.entries(data.template)) { + editorContainer = document.querySelector(`[name="proposal_answer[answer_${key}]"]`).nextElementSibling; + let editor = editorContainer.querySelector(".ProseMirror").editor; + + editor.commands.setContent(value, true); + } + }) + }); + + } +}); diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb new file mode 100644 index 0000000000000..f0eca2ecb1120 --- /dev/null +++ b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/_form.html.erb @@ -0,0 +1,38 @@ +
+
+
+
+ <%= form.translated :text_field, :name, aria: { label: :name } %> +
+ +
+ <%= form.translated :editor, :description, rows: 3, aria: { label: :description } %> + <%= t(".hint_html") %> +
    +
  • <%= t(".hint1_html") %>
  • +
  • <%= t(".hint2_html") %>
  • +
  • <%= t(".hint3_html") %>
  • +
+
+ +
+ <%= form.label :internal_state %> +
+ <%= form.collection_radio_buttons :internal_state, + Decidim::Proposals::Proposal::STATES.keys - [:withdrawn], + :to_s, + ->(mode) { t(mode, scope: "decidim.proposals.admin.proposal_answers.form") }, + prompt: true do |builder| + builder.label { builder.radio_button + builder.text } + end %> +
+
+ +
+ <%= form.select :component_constraint, availability_options_for_select, + sselected: form.object.component_constraint, + help_text: t(".component_constraint_help") %> +
+
+
+
diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/_template_chooser.html.erb new file mode 100644 index 0000000000000..28c12d21df683 --- /dev/null +++ b/decidim-templates/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? %> +
+ <%= append_javascript_pack_tag "decidim_templates_admin" %> + + +
+<% end %> diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb new file mode 100644 index 0000000000000..061ebe17fc378 --- /dev/null +++ b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/edit.html.erb @@ -0,0 +1,18 @@ +<% add_decidim_page_title(t(".title")) %> +
+

+ <%= t(".title") %> +

+
+
+
+ <%= decidim_form_for(@form, url: proposal_answer_template_path, html: { class: "form-defaults form edit_proposal_answer_template" }) do |f| %> + <%= render partial: "form", object: f %> +
+
+ <%= f.submit t("save", scope: "decidim.templates.admin.proposal_answer_templates.form"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
+
diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb new file mode 100644 index 0000000000000..55d82fb92ea13 --- /dev/null +++ b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/index.html.erb @@ -0,0 +1,51 @@ +<% add_decidim_page_title(t(".title")) %> + +
+
+

+ <%= t ".title" %> + <% if allowed_to?(:create, :template) %> + <%= link_to t("actions.new_template", scope: "decidim.admin.templates"), [:new, :proposal_answer_template], class: "button button__sm button__secondary new" %> + <% end %> +

+
+ <% if @templates.any? %> +
+ + + + + + + + + + + + <% @templates.each do |template| %> + + + + + + + + + <% end %> + +
<%= t("template.name", scope: "decidim.models") %><%= t(".internal_state") %><%= t(".component_constraint") %><%= t("template.fields.created_at", scope: "decidim.models") %>
<%= link_to_if allowed_to?(:update, :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:) %> + <%= icon_link_to "pencil-line", edit_proposal_answer_template_path(template), t("actions.edit", scope: "decidim.admin"), class: "edit" %> + <% end %> + <% if allowed_to?(:copy, :template, template:) %> + <%= icon_link_to "file-copy-line", copy_proposal_answer_template_path(template), t("actions.duplicate", scope: "decidim.admin"), method: :post %> + <% end %> + <% if allowed_to?(:destroy, :template, template:) %> + <%= icon_link_to "delete-bin-line", proposal_answer_template_path(template), t("actions.destroy", scope: "decidim.admin"), method: :delete, data: { confirm: t(".confirm_delete") }, class: "action-icon--remove" %> + <% end %> +
+
+ <% else %> + <%= t("templates.empty", scope: "decidim.admin") %> + <% end %> +
diff --git a/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb new file mode 100644 index 0000000000000..041e9e71e76ea --- /dev/null +++ b/decidim-templates/app/views/decidim/templates/admin/proposal_answer_templates/new.html.erb @@ -0,0 +1,18 @@ +<% add_decidim_page_title(t(".title")) %> +
+

+ <%= t(".title") %> +

+
+
+
+ <%= decidim_form_for(@form, url: proposal_answer_templates_path, html: { class: "form-defaults form new_proposal_answer_template" }) do |f| %> + <%= render partial: "form", object: f %> +
+
+ <%= f.submit t("save", scope: "decidim.templates.admin.proposal_answer_templates.form"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
+
diff --git a/decidim-templates/config/locales/en.yml b/decidim-templates/config/locales/en.yml index 853a17d69c6be..d8c72ba254e8b 100644 --- a/decidim-templates/config/locales/en.yml +++ b/decidim-templates/config/locales/en.yml @@ -27,6 +27,7 @@ en: destroy: success: Template deleted successfully. empty: There are no templates yet. + missing_resource: "(missing resource)" update: error: There was a problem updating this template. success: Template updated successfully. @@ -57,6 +58,26 @@ en: title: New block user message template template_chooser: select_template: Select a template answer + proposal_answer_templates: + edit: + title: Edit proposal answer template + form: + component_constraint_help: Note that only participatory spaces having components of the type "proposals" will be listed. + hint1_html: "%{organization} will be replaced by the organization's name" + hint2_html: "%{name} will be replaced by the author's name" + hint3_html: "%{admin} will be replaced by the admin's name (the one answering the proposal)" + hint_html: "Hint: You can use these variables anywhere on the answer template that will be replaced when using the template" + save: Save + index: + component_constraint: Add constraint + confirm_delete: Are you sure you want to delete this template? + global_scope: Global (available everywhere) + internal_state: Internal state + title: Proposal answers + new: + title: New proposal answer template + template_chooser: + select_template: Select a template answer questionnaire_templates: choose: create_from_template: Create from template @@ -88,4 +109,5 @@ en: update: "%{user_name} updated the %{resource_name} questionnaire template" template_types: block_user: Block user messages + proposal_answer_templates: Proposal answers questionnaires: Questionnaires diff --git a/decidim-templates/lib/decidim/templates/admin_engine.rb b/decidim-templates/lib/decidim/templates/admin_engine.rb index 5c24a2452b02e..84e1cab6bb7f8 100644 --- a/decidim-templates/lib/decidim/templates/admin_engine.rb +++ b/decidim-templates/lib/decidim/templates/admin_engine.rb @@ -10,6 +10,15 @@ class AdminEngine < ::Rails::Engine paths["lib/tasks"] = nil routes do + resources :proposal_answer_templates do + member do + post :copy + end + collection do + get :fetch + end + end + ## Routes for Questionnaire Templates resources :questionnaire_templates do member do @@ -48,7 +57,8 @@ class AdminEngine < ::Rails::Engine active: ( is_active_link?(decidim_admin_templates.questionnaire_templates_path) || is_active_link?(decidim_admin_templates.root_path) - ) && !is_active_link?(decidim_admin_templates.block_user_templates_path) + ) && !is_active_link?(decidim_admin_templates.block_user_templates_path) && + !is_active_link?(decidim_admin_templates.proposal_answer_templates_path) menu.add_item :user_reports, I18n.t("template_types.block_user", scope: "decidim.templates"), @@ -56,6 +66,13 @@ class AdminEngine < ::Rails::Engine icon_name: "user-forbid-line", if: allowed_to?(:index, :templates), active: is_active_link?(decidim_admin_templates.block_user_templates_path) + + menu.add_item :proposal_answers, + I18n.t("template_types.proposal_answer_templates", scope: "decidim.templates"), + decidim_admin_templates.proposal_answer_templates_path, + icon_name: "file-copy-line", + if: allowed_to?(:index, :templates), + active: is_active_link?(decidim_admin_templates.proposal_answer_templates_path) end end diff --git a/decidim-templates/lib/decidim/templates/test/factories.rb b/decidim-templates/lib/decidim/templates/test/factories.rb index a5fcf410d25c0..941e65686a48e 100644 --- a/decidim-templates/lib/decidim/templates/test/factories.rb +++ b/decidim-templates/lib/decidim/templates/test/factories.rb @@ -16,6 +16,12 @@ target { :user_block } end + trait :proposal_answer do + templatable { organization } + target { :proposal_answer } + field_values { { internal_state: :accepted } } + end + ## Questionnaire templates factory :questionnaire_template do target { "questionnaire" } diff --git a/decidim-templates/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb b/decidim-templates/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb new file mode 100644 index 0000000000000..a57f011caa0c0 --- /dev/null +++ b/decidim-templates/spec/commands/decidim/templates/admin/copy_proposal_answer_template_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe CopyProposalAnswerTemplate do + let(:template) { create(:template, :proposal_answer) } + + let(:user) { create(:user, :admin, organization: template.organization) } + + describe "when the template is invalid" do + before do + template.update(name: nil) + end + + it "broadcasts invalid" do + expect { described_class.call(template, user) }.to broadcast(:invalid) + end + end + + describe "when the template is valid" do + let(:destination_template) do + events = described_class.call(template, user) + 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 + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:perform_action!) + .with("duplicate", Decidim::Templates::Template, user) + .and_call_original + + expect { described_class.call(template, user) }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end + end + end + end +end diff --git a/decidim-templates/spec/commands/decidim/templates/admin/create_proposal_answer_template_spec.rb b/decidim-templates/spec/commands/decidim/templates/admin/create_proposal_answer_template_spec.rb new file mode 100644 index 0000000000000..ddee346788de4 --- /dev/null +++ b/decidim-templates/spec/commands/decidim/templates/admin/create_proposal_answer_template_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe CreateProposalAnswerTemplate do + subject { described_class.new(form) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:component_constraint) { 0 } + let(:name) { { "en" => "name" } } + let(:description) { { "en" => "description" } } + let(:internal_state) { "accepted" } + + let(:form) do + instance_double( + ProposalAnswerTemplateForm, + invalid?: invalid, + valid?: !invalid, + current_user: user, + current_organization: organization, + name:, + description:, + internal_state:, + component_constraint: + ) + end + + let(:invalid) { false } + + context "when the form is not valid" do + let(:invalid) { true } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the form is valid" do + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok) + end + + it "creates a new template for the organization" do + expect { subject.call }.to change(Decidim::Templates::Template, :count).by(1) + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:perform_action!) + .with(:create, Decidim::Templates::Template, user, {}) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + + it "creates the second resource" do + expect(Decidim::Templates::Template.where(target: :proposal_answer).count).to eq(0) + expect { subject.call }.to broadcast(:ok) + expect(Decidim::Templates::Template.where(target: :proposal_answer).count).to eq(1) + end + + context "when changing the internal state" do + context "with rejected" do + let(:internal_state) { "rejected" } + + it "saves the internal state" do + subject.call + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with accepted" do + let(:internal_state) { "accepted" } + + it "saves the internal state" do + subject.call + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with evaluating" do + let(:internal_state) { "evaluating" } + + it "saves the internal state" do + subject.call + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with not_answered" do + let(:internal_state) { "not_answered" } + + it "saves the internal state" do + subject.call + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.field_values["internal_state"]).to eq(internal_state) + end + end + end + + context "when the form has a component constraint" do + context "and templatable is Organization" do + it "creates the second resource" do + expect(Decidim::Templates::Template.where(target: :proposal_answer).count).to eq(0) + expect { subject.call }.to broadcast(:ok) + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.templatable).to eq(organization) + end + end + + context "and templatable is Proposal Component" do + let(:component) { create(:proposal_component, organization:) } + let(:component_constraint) { component.id } + + it "creates the second resource" do + expect(Decidim::Templates::Template.where(target: :proposal_answer).count).to eq(0) + expect { subject.call }.to broadcast(:ok) + expect(Decidim::Templates::Template.where(target: :proposal_answer).last.templatable).to eq(component) + end + end + end + end + end + end + end +end diff --git a/decidim-templates/spec/commands/decidim/templates/admin/update_proposal_answer_template_spec.rb b/decidim-templates/spec/commands/decidim/templates/admin/update_proposal_answer_template_spec.rb new file mode 100644 index 0000000000000..2cb1c03342036 --- /dev/null +++ b/decidim-templates/spec/commands/decidim/templates/admin/update_proposal_answer_template_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Templates + module Admin + describe UpdateProposalAnswerTemplate do + subject { described_class.new(template, form, user) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:template) { create(:template, :proposal_answer, organization:) } + let!(:component_constraint) { 0 } + let(:name) { { "en" => "name" } } + let(:description) { { "en" => "description" } } + let(:internal_state) { "accepted" } + + let(:form) do + instance_double( + ProposalAnswerTemplateForm, + invalid?: invalid, + valid?: !invalid, + current_user: user, + current_organization: organization, + name:, + description:, + internal_state:, + component_constraint: + ) + end + + let(:invalid) { false } + + context "when the form is not valid" do + let(:invalid) { true } + + it "is not valid" do + expect { subject.call }.to broadcast(:invalid) + end + end + + context "when the form is valid" do + it "broadcasts ok" do + expect { subject.call }.to broadcast(:ok) + end + + it "updates the template" do + subject.call + expect(template.name).to eq(name) + expect(template.description).to eq(description) + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:perform_action!) + .with(:update, template, user, {}) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + + context "when changing the internal state" do + context "with rejected" do + let(:internal_state) { "rejected" } + + it "saves the internal state" do + subject.call + expect(template.reload.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with accepted" do + let(:internal_state) { "accepted" } + + it "saves the internal state" do + subject.call + expect(template.reload.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with evaluating" do + let(:internal_state) { "evaluating" } + + it "saves the internal state" do + subject.call + expect(template.reload.field_values["internal_state"]).to eq(internal_state) + end + end + + context "with not_answered" do + let(:internal_state) { "not_answered" } + + it "saves the internal state" do + subject.call + expect(template.reload.field_values["internal_state"]).to eq(internal_state) + end + end + end + + context "when the form has a component constraint" do + context "and templatable is Organization" do + it "creates the second resource" do + expect { subject.call }.to broadcast(:ok) + expect(template.reload.templatable).to eq(organization) + end + end + + context "and templatable is Proposal Component" do + let(:component) { create(:component, manifest_name: :proposals, organization:) } + let(:component_constraint) { component.id } + + it "creates the second resource" do + expect(template.reload.templatable).to eq(organization) + expect { subject.call }.to broadcast(:ok) + expect(template.reload.templatable).to eq(component) + end + end + end + end + end + end + end +end diff --git a/decidim-templates/spec/factories.rb b/decidim-templates/spec/factories.rb index 02f6849bb118b..c223a6a8c6f68 100644 --- a/decidim-templates/spec/factories.rb +++ b/decidim-templates/spec/factories.rb @@ -3,3 +3,4 @@ require "decidim/core/test/factories" require "decidim/forms/test/factories" require "decidim/templates/test/factories" +require "decidim/proposals/test/factories" diff --git a/decidim-templates/spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb b/decidim-templates/spec/forms/decidim/templates/admin/proposal_answer_template_form_spec.rb new file mode 100644 index 0000000000000..a01b1910ea84d --- /dev/null +++ b/decidim-templates/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: + ) + 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/decidim-templates/spec/system/admin/admin_manages_proposal_answer_templates_spec.rb b/decidim-templates/spec/system/admin/admin_manages_proposal_answer_templates_spec.rb new file mode 100644 index 0000000000000..a7fe53be324e9 --- /dev/null +++ b/decidim-templates/spec/system/admin/admin_manages_proposal_answer_templates_spec.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages proposal answer templates" do + let!(:organization) { create(:organization) } + let!(:user) { create(:user, :admin, :confirmed, 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:) } + + before do + visit decidim_admin_templates.proposal_answer_templates_path + end + + it "shows a table with the templates info" do + within ".table-list" 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:, templatable: create(:dummy_resource)) } + + it "shows a table info about the invalid resource" do + within ".table-list" 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:) } + 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_component_constraint + + 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 ".table-list" 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:) } + let(:participatory_process) { create(:participatory_process, title: { en: "A participatory process" }, 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_component_constraint + + 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 ".table-list" 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:) } + + 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:) } + + 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:) } + + 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:) } + let!(:component) { create(:component, manifest_name: :proposals, name: { en: "A component" }, participatory_space: participatory_process) } + + let(:description) { "Some meaningful answer" } + let(:field_values) { { internal_state: "rejected" } } + let!(:template) { create(:template, :proposal_answer, description: { en: description }, field_values:, organization:, templatable: component) } + let!(:proposal) { create(:proposal, 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 + expect(proposal.reload.internal_state).to eq("not_answered") + 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 + expect(proposal.reload.internal_state).to eq("rejected") + end + + context "when there are no templates" do + before do + template.destroy! + visit Decidim::EngineRouter.admin_proxy(component).root_path + find("a", class: "action-icon--show-proposal").click + end + + it "hides the template selector in the proposal answer page" do + expect(page).not_to have_select(:proposal_answer_template_chooser) + end + end + + context "when displaying current component and organization templates" do + let!(:other_component) { create(:component, manifest_name: :proposals, name: { en: "Another component" }, participatory_space: participatory_process) } + let!(:other_component_template) { create(:template, :proposal_answer, description: { en: "Foo bar" }, field_values: { internal_state: "evaluating" }, organization:, templatable: other_component) } + let!(:proposal) { create(:proposal, component:) } + + before do + visit Decidim::EngineRouter.admin_proxy(component).root_path + find("a", class: "action-icon--show-proposal").click + end + + it "displays the global template in dropdown" do + expect(page).to have_select(:proposal_answer_template_chooser, with_options: [translated(template.name)]) + end + + it "hides templates scoped for other components" do + expect(proposal.reload.internal_state).to eq("not_answered") + expect(page).not_to have_select(:proposal_answer_template_chooser, with_options: [translated(other_component_template.name)]) + end + end + + context "when selecting a global template" do + let!(:global_template) { create(:template, :proposal_answer, description: { en: "Foo bar" }, field_values: { internal_state: "evaluating" }, organization:, templatable: organization) } + let!(:proposal) { create(:proposal, component:) } + + before do + visit Decidim::EngineRouter.admin_proxy(component).root_path + find("a", class: "action-icon--show-proposal").click + end + + it "all the fields are properly changed" do + expect(proposal.reload.internal_state).to eq("not_answered") + expect(page).to have_select(:proposal_answer_template_chooser, with_options: [translated(template.name), translated(global_template.name)]) + within ".edit_proposal_answer" do + select translated(global_template.name), from: :proposal_answer_template_chooser + expect(page).to have_content("Foo bar") + click_button "Answer" + end + + expect(page).to have_admin_callout("Proposal successfully answered") + + within find("tr", text: translated(proposal.title)) do + expect(page).to have_content("Evaluating") + end + expect(proposal.reload.internal_state).to eq("evaluating") + end + end + + context "when the template uses interpolations" do + context "with the organization variable" do + let(:description) { "Some meaningful answer with the %{organization}" } + + it "changes it with the organization name" do + within ".edit_proposal_answer" do + select template.name["en"], from: :proposal_answer_template_chooser + expect(page).to have_content("Some meaningful answer with the #{organization.name}") + end + end + end + + context "with the admin variable" do + let(:description) { "Some meaningful answer with the %{admin}" } + + it "changes it with the admin's user name" do + within ".edit_proposal_answer" do + select template.name["en"], from: :proposal_answer_template_chooser + expect(page).to have_content("Some meaningful answer with the #{user.name}") + end + end + end + + context "with the user variable" do + let(:description) { "Some meaningful answer with the %{name}" } + + it "changes it with the author's user name" do + within ".edit_proposal_answer" do + select template.name["en"], from: :proposal_answer_template_chooser + expect(page).to have_content("Some meaningful answer with the #{proposal.creator_author.name}") + end + end + end + end + end +end