From 3834c447bacebfdc5a31600e79df4ccecc6ef7fa Mon Sep 17 00:00:00 2001 From: Anna Topalidi <60363870+antopalidi@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:12:21 +0100 Subject: [PATCH] Add recipient selection and preview for Newsletters (#13680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ivan Vergés --- .../admin/multi_select_picker/show.erb | 10 + .../decidim/admin/multi_select_picker_cell.rb | 38 +++ .../decidim/admin/newsletters_controller.rb | 57 +++- .../admin/selective_newsletter_form.rb | 57 +++- .../decidim/admin/newsletters_helper.rb | 84 ++++-- .../app/jobs/decidim/admin/newsletter_job.rb | 4 +- .../packs/src/decidim/admin/newsletters.js | 246 ++++++++++++------ .../decidim/admin/_select_picker.scss | 20 ++ .../decidim/admin/application.scss | 1 + .../decidim/admin/newsletter_recipients.rb | 64 ++++- .../newsletters/confirm_recipients.html.erb | 64 +++++ .../select_recipients_to_deliver.html.erb | 64 +++-- decidim-admin/config/locales/en.yml | 24 +- decidim-admin/config/routes.rb | 1 + .../admin/multi_select_picker_cell_spec.rb | 91 +++++++ .../decidim/admin/deliver_newsletter_spec.rb | 121 +++++++-- .../newsletters_controller_spec.rb | 141 ++++++++++ .../forms/selective_newsletter_form_spec.rb | 51 ++-- .../spec/jobs/newsletter_job_spec.rb | 12 +- .../queries/newsletter_recipients_spec.rb | 81 +++--- .../system/admin_manages_newsletters_spec.rb | 246 +++++++++++++++--- decidim-core/app/models/decidim/newsletter.rb | 18 +- .../participatory_space_private_user.rb | 4 + 23 files changed, 1196 insertions(+), 303 deletions(-) create mode 100644 decidim-admin/app/cells/decidim/admin/multi_select_picker/show.erb create mode 100644 decidim-admin/app/cells/decidim/admin/multi_select_picker_cell.rb create mode 100644 decidim-admin/app/packs/stylesheets/decidim/admin/_select_picker.scss create mode 100644 decidim-admin/app/views/decidim/admin/newsletters/confirm_recipients.html.erb create mode 100644 decidim-admin/spec/cells/decidim/admin/multi_select_picker_cell_spec.rb create mode 100644 decidim-admin/spec/controllers/newsletters_controller_spec.rb diff --git a/decidim-admin/app/cells/decidim/admin/multi_select_picker/show.erb b/decidim-admin/app/cells/decidim/admin/multi_select_picker/show.erb new file mode 100644 index 0000000000000..16051b87bf23c --- /dev/null +++ b/decidim-admin/app/cells/decidim/admin/multi_select_picker/show.erb @@ -0,0 +1,10 @@ +<%= select_tag field_name, + options_for_select( + cell_options_for_select.map { |option| [option[0], option[1]] }, + selected_values + ), + id: select_id, + class: css_classes, + placeholder:, + multiple: true, + data: { multiselect: true } %> diff --git a/decidim-admin/app/cells/decidim/admin/multi_select_picker_cell.rb b/decidim-admin/app/cells/decidim/admin/multi_select_picker_cell.rb new file mode 100644 index 0000000000000..88eb277621a5c --- /dev/null +++ b/decidim-admin/app/cells/decidim/admin/multi_select_picker_cell.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # Universal cell for rendering multi-select pickers + class MultiSelectPickerCell < Decidim::ViewModel + include ActionView::Helpers::FormOptionsHelper + + def show + render :show + end + + def cell_options_for_select + context[:options_for_select] || [] + end + + def selected_values + context[:selected_values] || [] + end + + def select_id + context[:select_id] + end + + def field_name + context[:field_name] + end + + def placeholder + context[:placeholder] || "" + end + + def css_classes + context[:class] || "" + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/newsletters_controller.rb b/decidim-admin/app/controllers/decidim/admin/newsletters_controller.rb index c85d0e79ec6f3..a673e1fd9bd32 100644 --- a/decidim-admin/app/controllers/decidim/admin/newsletters_controller.rb +++ b/decidim-admin/app/controllers/decidim/admin/newsletters_controller.rb @@ -7,7 +7,7 @@ class NewslettersController < Decidim::Admin::ApplicationController include Decidim::NewslettersHelper include Decidim::Admin::NewslettersHelper include Paginable - helper_method :newsletter, :recipients_count_query, :content_block + helper_method :newsletter, :recipients_count_query, :content_block, :selected_options, :newsletter_params def index enforce_permission_to :index, :newsletter @@ -103,20 +103,22 @@ def destroy def select_recipients_to_deliver enforce_permission_to(:update, :newsletter, newsletter:) - @form = form(SelectiveNewsletterForm).from_model(newsletter) - @form.send_to_all_users = current_user.admin? + + @form = if newsletter_params.present? + form(SelectiveNewsletterForm).from_params(newsletter_params) + else + form(SelectiveNewsletterForm).from_model(newsletter) + end end def recipients_count - data = params.permit(newsletter: {}).to_h[:newsletter] - - @form = form(SelectiveNewsletterForm).from_params(data) + @form = form(SelectiveNewsletterForm).from_params(newsletter_params) render plain: recipients_count_query end def deliver enforce_permission_to(:update, :newsletter, newsletter:) - @form = form(SelectiveNewsletterForm).from_params(params) + @form = form(SelectiveNewsletterForm).from_params(newsletter_params) DeliverNewsletter.call(newsletter, @form) do on(:ok) do @@ -136,8 +138,49 @@ def deliver end end + def confirm_recipients + enforce_permission_to(:update, :newsletter, newsletter:) + @form = form(SelectiveNewsletterForm).from_params(newsletter_params) + @recipients = NewsletterRecipients.for(@form).order(:email) + @recipients = paginate(@recipients) + + render :confirm_recipients + end + private + def newsletter_params + params.fetch(:newsletter, {}).permit( + :send_to_all_users, + :send_to_verified_users, + :send_to_followers, + :send_to_participants, + :send_to_private_members, + verification_types: [], + participatory_space_types: { + assemblies: [:manifest_name, { ids: [] }], + conferences: [:manifest_name, { ids: [] }], + initiatives: [:manifest_name, { ids: [] }], + participatory_processes: [:manifest_name, { ids: [] }] + } + ) + end + + def selected_options(key) + @selected_options ||= {} + @selected_options[key] ||= extract_selected_ids(params[:newsletter], key) + end + + def extract_selected_ids(newsletter_params, key) + return {} unless newsletter_params.present? && newsletter_params[key].present? + + if newsletter_params[key].is_a?(Array) + { key => newsletter_params[key].map(&:to_s) } + else + (newsletter_params[key] || {}).transform_values { |space| space["ids"] || [] } + end + end + def collection @collection ||= Newsletter.where(organization: current_organization) end diff --git a/decidim-admin/app/forms/decidim/admin/selective_newsletter_form.rb b/decidim-admin/app/forms/decidim/admin/selective_newsletter_form.rb index 0c0cfa1a28ad3..d9a99fde04571 100644 --- a/decidim-admin/app/forms/decidim/admin/selective_newsletter_form.rb +++ b/decidim-admin/app/forms/decidim/admin/selective_newsletter_form.rb @@ -7,33 +7,33 @@ class SelectiveNewsletterForm < Decidim::Form mimic :newsletter attribute :participatory_space_types, Array[SelectiveNewsletterParticipatorySpaceTypeForm] - attribute :scope_ids, Array + attribute :verification_types, Array[String] attribute :send_to_all_users, Boolean + attribute :send_to_verified_users, Boolean attribute :send_to_participants, Boolean attribute :send_to_followers, Boolean + attribute :send_to_private_members, Boolean - validates :send_to_all_users, presence: true, unless: ->(form) { form.send_to_participants.present? || form.send_to_followers.present? } - validates :send_to_followers, presence: true, if: ->(form) { form.send_to_all_users.blank? && form.send_to_participants.blank? } - validates :send_to_participants, presence: true, if: ->(form) { form.send_to_all_users.blank? && form.send_to_followers.blank? } + validates :send_to_all_users, presence: true, unless: :other_groups_selected_for_all_users? + validates :send_to_verified_users, presence: true, unless: :other_groups_selected_for_verified_users? + validates :send_to_followers, presence: true, if: :only_followers_selected? + validates :send_to_participants, presence: true, if: :only_participants_selected? + validates :send_to_private_members, presence: true, if: :only_private_members_selected? validate :at_least_one_participatory_space_selected - def map_model(_newsletter) + def map_model(newsletter) self.participatory_space_types = Decidim.participatory_space_manifests.map do |manifest| SelectiveNewsletterParticipatorySpaceTypeForm.from_model(manifest:) end - end - # Make sure the empty scope is not passed because then some logic could - # assume erroneously that some scope is selected. - def scope_ids - super.select(&:presence) + self.verification_types = newsletter.organization.available_authorizations end private def at_least_one_participatory_space_selected - return if send_to_all_users && current_user.admin? + return if (send_to_all_users || send_to_verified_users) && current_user.admin? errors.add(:base, :at_least_one_space) if spaces_selected.blank? end @@ -44,6 +44,41 @@ def spaces_selected [type.manifest_name, spaces] if spaces.present? end.compact end + + def other_groups_selected_for_all_users? + send_to_verified_users.present? || + send_to_participants.present? || + send_to_followers.present? || + send_to_private_members.present? + end + + def other_groups_selected_for_verified_users? + send_to_all_users.present? || + send_to_participants.present? || + send_to_followers.present? || + send_to_private_members.present? + end + + def only_followers_selected? + send_to_all_users.blank? && + send_to_participants.blank? && + send_to_private_members.blank? && + send_to_verified_users.blank? + end + + def only_participants_selected? + send_to_all_users.blank? && + send_to_followers.blank? && + send_to_private_members.blank? && + send_to_verified_users.blank? + end + + def only_private_members_selected? + send_to_all_users.blank? && + send_to_followers.blank? && + send_to_participants.blank? && + send_to_verified_users.blank? + end end end end diff --git a/decidim-admin/app/helpers/decidim/admin/newsletters_helper.rb b/decidim-admin/app/helpers/decidim/admin/newsletters_helper.rb index f65ab7702f61d..a20a3902078e6 100644 --- a/decidim-admin/app/helpers/decidim/admin/newsletters_helper.rb +++ b/decidim-admin/app/helpers/decidim/admin/newsletters_helper.rb @@ -4,6 +4,13 @@ module Decidim module Admin # This module includes helpers to manage newsletters in admin layout module NewslettersHelper + def find_verification_types_for_select(organization) + available_verifications = organization.available_authorizations + available_verifications.map do |verification_type| + [t("decidim.authorization_handlers.#{verification_type}.name"), verification_type] + end + end + def participatory_spaces_for_select(form_object) content_tag :div do @form.participatory_space_types.each do |space_type| @@ -17,22 +24,32 @@ def participatory_space_types_form_object(form_object, space_type) html = "" form_object.fields_for "participatory_space_types[#{space_type.manifest_name}]", space_type do |ff| + html += participatory_space_title(space_type) html += ff.hidden_field :manifest_name, value: space_type.manifest_name html += select_tag_participatory_spaces(space_type.manifest_name, spaces_for_select(space_type.manifest_name.to_sym), ff) end html.html_safe end + def participatory_space_title(space_type) + return unless space_type + + content_tag :h4 do + t("activerecord.models.decidim/#{space_type.manifest_name.singularize}.other") + end + end + def select_tag_participatory_spaces(manifest_name, spaces, child_form) return unless spaces - content_tag :div, class: "#{manifest_name}-block spaces-block-tag cell small-12 medium-6" do - child_form.select :ids, options_for_select(spaces), - { prompt: t("select_recipients_to_deliver.none", scope: "decidim.admin.newsletters"), - label: t("activerecord.models.decidim/#{manifest_name.singularize}.other"), - include_hidden: false }, - multiple: true, size: [spaces.size, 10].min, class: "chosen-select" - end + raw(cell("decidim/admin/multi_select_picker", nil, context: { + select_id: "#{manifest_name}-spaces-select", + field_name: "#{child_form.object_name}[ids][]", + options_for_select: spaces, + selected_values: selected_options(:participatory_space_types)[manifest_name] || [], + placeholder: t("select_recipients_to_deliver.select_#{manifest_name}", scope: "decidim.admin.newsletters"), + class: "mb-2" + })) end def spaces_for_select(manifest_name) @@ -45,21 +62,35 @@ def spaces_for_select(manifest_name) def selective_newsletter_to(newsletter) return content_tag(:strong, t("index.not_sent", scope: "decidim.admin.newsletters"), class: "text-warning") unless newsletter.sent? return content_tag(:strong, t("index.all_users", scope: "decidim.admin.newsletters"), class: "text-success") if newsletter.sent? && newsletter.extended_data.blank? + return sent_to_verified_users(newsletter) if newsletter.sent_to_verified_users? content_tag :div do concat sent_to_users newsletter concat sent_to_spaces newsletter - concat sent_to_scopes newsletter end end def sent_to_users(newsletter) content_tag :p, style: "margin-bottom:0;" do concat content_tag(:strong, t("index.has_been_sent_to", scope: "decidim.admin.newsletters"), class: "text-success") - concat content_tag(:strong, t("index.all_users", scope: "decidim.admin.newsletters")) if newsletter.sended_to_all_users? - concat content_tag(:strong, t("index.followers", scope: "decidim.admin.newsletters")) if newsletter.sended_to_followers? - concat t("index.and", scope: "decidim.admin.newsletters") if newsletter.sended_to_followers? && newsletter.sended_to_participants? - concat content_tag(:strong, t("index.participants", scope: "decidim.admin.newsletters")) if newsletter.sended_to_participants? + + recipients = [] + + recipients << content_tag(:strong, t("index.all_users", scope: "decidim.admin.newsletters")) if newsletter.sent_to_all_users? + recipients << content_tag(:strong, t("index.verified_users", scope: "decidim.admin.newsletters")) if newsletter.sent_to_verified_users? + recipients << content_tag(:strong, t("index.followers", scope: "decidim.admin.newsletters")) if newsletter.sent_to_followers? + recipients << content_tag(:strong, t("index.participants", scope: "decidim.admin.newsletters")) if newsletter.sent_to_participants? + recipients << content_tag(:strong, t("index.private_members", scope: "decidim.admin.newsletters")) if newsletter.sent_to_private_members? + + concat recipients.join(t("index.and", scope: "decidim.admin.newsletters")).html_safe + end + end + + def sent_to_verified_users(newsletter) + content_tag :p, style: "margin-bottom:0;" do + concat content_tag(:strong, t("index.has_been_sent_to", scope: "decidim.admin.newsletters"), class: "text-success") + concat content_tag(:strong, t("index.verified_users", scope: "decidim.admin.newsletters")) + concat content_tag(:p, t("index.verification_types", scope: "decidim.admin.newsletters", types: selected_verification_types(newsletter))) end end @@ -68,12 +99,14 @@ def sent_to_spaces(newsletter) newsletter.sent_to_participatory_spaces.try(:each) do |type| next if type["ids"].blank? + ids = parse_ids(type["ids"]) + html += t("index.segmented_to", scope: "decidim.admin.newsletters", subject: t("activerecord.models.decidim/#{type["manifest_name"].singularize}.other")) - if type["ids"].include?("all") + if ids.include?("all") html += " #{t("index.all", scope: "decidim.admin.newsletters")} " else Decidim.find_participatory_space_manifest(type["manifest_name"].to_sym) - .participatory_spaces.call(current_organization).where(id: type["ids"]).each do |space| + .participatory_spaces.call(current_organization).where(id: ids).each do |space| html += "#{decidim_escape_translated(space.title)}" end end @@ -83,19 +116,6 @@ def sent_to_spaces(newsletter) html.html_safe end - def sent_to_scopes(newsletter) - content_tag :p, style: "margin-bottom:0;" do - concat t("index.segmented_to", scope: "decidim.admin.newsletters", subject: nil) - if newsletter.sent_scopes.any? - newsletter.sent_scopes.each do |scope| - concat content_tag(:strong, decidim_escape_translated(scope.name).to_s) - end - else - concat content_tag(:strong, t("index.no_scopes", scope: "decidim.admin.newsletters")) - end - end - end - def organization_participatory_space(manifest_name) @organization_participatory_spaces ||= {} @organization_participatory_spaces[manifest_name] ||= Decidim @@ -144,6 +164,16 @@ def newsletter_recipients_count_callout_announcement body: } end + + def parse_ids(ids) + ids.size == 1 && ids.first.is_a?(String) ? ids.first.split.map(&:strip) : ids + end + + def selected_verification_types(newsletter) + newsletter.sent_to_users_with_verification_types&.map do |type| + I18n.t("decidim.authorization_handlers.#{type}.name") + end&.join(", ") + end end end end diff --git a/decidim-admin/app/jobs/decidim/admin/newsletter_job.rb b/decidim-admin/app/jobs/decidim/admin/newsletter_job.rb index 7ebf6c39efd45..c4c01e88369b2 100644 --- a/decidim-admin/app/jobs/decidim/admin/newsletter_job.rb +++ b/decidim-admin/app/jobs/decidim/admin/newsletter_job.rb @@ -33,10 +33,12 @@ def perform(newsletter, form, recipients_ids) def extended_data { send_to_all_users: @form["send_to_all_users"], + send_to_verified_users: @form["send_to_verified_users"], send_to_followers: @form["send_to_followers"], send_to_participants: @form["send_to_participants"], + send_to_private_members: @form["send_to_private_members"], participatory_space_types: @form["participatory_space_types"], - scope_ids: @form["scope_ids"] + verification_types: @form["verification_types"] } end diff --git a/decidim-admin/app/packs/src/decidim/admin/newsletters.js b/decidim-admin/app/packs/src/decidim/admin/newsletters.js index 72efb746aa587..c62ecb150a9f1 100644 --- a/decidim-admin/app/packs/src/decidim/admin/newsletters.js +++ b/decidim-admin/app/packs/src/decidim/admin/newsletters.js @@ -1,91 +1,173 @@ -$(() => { - const $form = $(".form.newsletter_deliver"); - - if ($form.length > 0) { - const $sendNewsletterToAllUsers = $form.find("#send_newsletter_to_all_users"); - const $sendNewsletterToFollowers = $form.find("#send_newsletter_to_followers"); - const $sendNewsletterToParticipants = $form.find("#send_newsletter_to_participants"); - const $participatorySpacesForSelect = $form.find("#participatory_spaces_for_select"); - - const checkSelectiveNewsletterFollowers = $sendNewsletterToFollowers.find("input[type='checkbox']").prop("checked"); - const checkSelectiveNewsletterParticipants = $sendNewsletterToParticipants.find("input[type='checkbox']").prop("checked"); - - $sendNewsletterToAllUsers.on("change", (event) => { - const checked = event.target.checked; - if (checked) { - $sendNewsletterToFollowers.find("input[type='checkbox']").prop("checked", !checked); - $sendNewsletterToParticipants.find("input[type='checkbox']").prop("checked", !checked); - $participatorySpacesForSelect.hide(); +import TomSelect from "tom-select/dist/cjs/tom-select.popular"; + +document.addEventListener("DOMContentLoaded", () => { + const isOnSelectRecipientsPage = window.location.pathname.includes("/select_recipients_to_deliver"); + + const selectors = { + form: document.querySelector(".form.newsletter_deliver"), + sendToAllUsers: document.querySelector("#newsletter_send_to_all_users"), + sendToVerifiedUsers: document.querySelector("#newsletter_send_to_verified_users"), + sendToParticipants: document.querySelector("#newsletter_send_to_participants"), + sendToFollowers: document.querySelector("#newsletter_send_to_followers"), + sendToPrivateMembers: document.querySelector("#newsletter_send_to_private_members"), + verificationTypesSelect: document.querySelector("#verification_types_for_select"), + participatorySpacesForSelect: document.querySelector("#participatory_spaces_for_select"), + deliverButton: document.querySelector("#deliver-button"), + confirmRecipientsLink: document.querySelector("#confirm-recipients-link"), + recipientsCount: document.querySelector("#recipients_count"), + recipientsCountSpinner: document.querySelector("#recipients_count_spinner"), + csrfToken: document.querySelector('meta[name="csrf-token"]') + }; + + const inputs = { + radioButtons: [selectors.sendToAllUsers, selectors.sendToVerifiedUsers].filter(Boolean), + checkboxes: [ + selectors.sendToParticipants, + selectors.sendToFollowers, + selectors.sendToPrivateMembers + ].filter(Boolean) + }; + + const toggleVisibility = (element, condition) => element?.classList.toggle("hidden", !condition); + + const updateDeliverButtonVisibility = () => { + const sendToAllUsersChecked = selectors.form?.elements["newsletter[send_to_all_users]"]?.value === "1"; + + toggleVisibility(selectors.deliverButton, sendToAllUsersChecked); + toggleVisibility(selectors.confirmRecipientsLink, !sendToAllUsersChecked); + }; + + const updateHiddenField = (input) => { + const hiddenInput = selectors.form?.elements[input.name]; + if (hiddenInput) { + hiddenInput.value = input.checked + ? "1" + : "0";} + }; + + const ensureAtLeastOneOptionSelected = () => { + if (![...inputs.radioButtons, ...inputs.checkboxes].some((input) => input?.checked)) { + selectors.sendToAllUsers.checked = true; + updateHiddenField(selectors.sendToAllUsers); + } + }; + + const updateConfirmRecipientsLink = () => { + if (!selectors.confirmRecipientsLink) { + return; + } + const params = new URLSearchParams(new FormData(selectors.form)); + selectors.confirmRecipientsLink.setAttribute( + "href", + `${selectors.confirmRecipientsLink.dataset.baseUrl}?${params.toString()}` + ); + }; + + const updateRecipientsCount = async () => { + const url = selectors.form?.dataset?.recipientsCountNewsletterPath; + if (!url) { + return; + } + + selectors.recipientsCountSpinner?.classList.remove("hide"); + try { + const response = await fetch(url, { + method: "POST", + headers: { "X-CSRF-Token": selectors.csrfToken?.content }, + body: new FormData(selectors.form) + }); + selectors.recipientsCount.textContent = await response.text(); + } catch (error) { + console.error("Error fetching recipients count:", error); + } finally { + selectors.recipientsCountSpinner?.classList.add("hide"); + } + }; + + const resetIdsForParticipatorySpaces = () => { + document.querySelectorAll('.form.newsletter_deliver select[name$="[ids][]"]').forEach((select) => { + if (select.tomselect) { + select.tomselect.clear(); } else { - $sendNewsletterToFollowers.find("input[type='checkbox']").prop("checked", !checked); - $sendNewsletterToParticipants.find("input[type='checkbox']").prop("checked", !checked); - $participatorySpacesForSelect.show(); - } - }) - - $sendNewsletterToFollowers.on("change", (event) => { - const checked = event.target.checked; - const selectiveNewsletterParticipants = $sendNewsletterToParticipants.find("input[type='checkbox']").prop("checked"); - - if (checked) { - $sendNewsletterToAllUsers.find("input[type='checkbox']").prop("checked", !checked); - $participatorySpacesForSelect.show(); - } else if (!selectiveNewsletterParticipants) { - $sendNewsletterToAllUsers.find("input[type='checkbox']").prop("checked", true); - $participatorySpacesForSelect.hide(); + select.value = []; } - }) - - $sendNewsletterToParticipants.on("change", (event) => { - const checked = event.target.checked; - const selectiveNewsletterFollowers = $sendNewsletterToFollowers.find("input[type='checkbox']").prop("checked"); - if (checked) { - $sendNewsletterToAllUsers.find("input[type='checkbox']").prop("checked", !checked); - $participatorySpacesForSelect.show(); - } else if (!selectiveNewsletterFollowers) { - $sendNewsletterToAllUsers.find("input[type='checkbox']").prop("checked", true); - $participatorySpacesForSelect.hide(); - } - }) + }); + }; - if (checkSelectiveNewsletterFollowers || checkSelectiveNewsletterParticipants) { - $participatorySpacesForSelect.show(); - } else { - $participatorySpacesForSelect.hide(); + const resetVerificationTypes = () => { + const select = document.querySelector("#verification-types-select"); + select?.tomselect?.clear(); + const hiddenInput = selectors.form?.elements["newsletter[verification_types]"]; + if (hiddenInput) { + hiddenInput.value = ""; } + }; - $(".form .spaces-block-tag").each(function (_i, blockTag) { - const selectTag = $(blockTag).find(".chosen-select") - selectTag.change(function () { - let optionSelected = selectTag.find("option:selected").val() - if (optionSelected === "all") { - selectTag.find("option").not(":first").prop("selected", true); - selectTag.find("option[value='all']").prop("selected", false); - } else if (optionSelected === "") { - selectTag.find("option").not(":first").prop("selected", false); - } + const updateFormState = () => { + [...inputs.radioButtons, ...inputs.checkboxes].forEach(updateHiddenField); + const isAllUsersChecked = selectors.sendToAllUsers?.checked; + const isAnyChecked = [...inputs.radioButtons, ...inputs.checkboxes].some((input) => input?.checked); + + toggleVisibility(selectors.deliverButton, isAllUsersChecked); + toggleVisibility(selectors.confirmRecipientsLink, !isAllUsersChecked && isAnyChecked); + toggleVisibility( + selectors.participatorySpacesForSelect, + inputs.checkboxes.some((input) => input.checked) && !selectors.sendToVerifiedUsers?.checked + ); + toggleVisibility(selectors.verificationTypesSelect, selectors.sendToVerifiedUsers?.checked); + ensureAtLeastOneOptionSelected(); + updateConfirmRecipientsLink(); + updateRecipientsCount(); + }; + + const handleRadioChange = (radio) => { + inputs.radioButtons.forEach((rb) => (rb.checked = rb === radio)); + inputs.checkboxes.forEach((checkbox) => (checkbox.checked = false)); + resetVerificationTypes(); + resetIdsForParticipatorySpaces(); + updateFormState(); + }; + + const handleCheckboxChange = () => { + inputs.radioButtons.forEach((radio) => (radio.checked = false)); + resetVerificationTypes(); + resetIdsForParticipatorySpaces(); + updateFormState(); + }; + + const attachEventListeners = () => { + inputs.radioButtons.forEach((radio) => + radio.addEventListener("change", () => handleRadioChange(radio)) + ); + + inputs.checkboxes.forEach((checkbox) => + checkbox.addEventListener("change", handleCheckboxChange) + ); + + selectors.form?.addEventListener("change", updateFormState); + }; + + const initializeTomSelect = () => { + document.querySelectorAll("[data-multiselect='true']").forEach((select) => { + const tomSelect = new TomSelect(select, { + plugins: ["remove_button", "dropdown_input"], + allowEmptyOption: true }); - }) - - $form.on("change", function(event) { - let formData = new FormData(event.target.closest("form")); - let url = $form.data("recipients-count-newsletter-path"); - const $modal = $("#recipients_count_spinner"); - $modal.removeClass("hide"); - - const xhr = new XMLHttpRequest(); - xhr.open("POST", url, true); - xhr.onload = function() { - if (xhr.status === 200) { - $("#recipients_count").text(xhr.responseText); + + tomSelect.on("change", () => { + const selectedOptions = tomSelect.getValue(); + + if (selectedOptions.includes("all") && selectedOptions.length > 1) { + tomSelect.setValue(["all"]); } - $modal.addClass("hide"); - }; - xhr.onerror = function() { - $modal.addClass("hide"); - }; - // Send the form data - xhr.send(formData); - }) + }); + }); + }; + + if (isOnSelectRecipientsPage) { + attachEventListeners(); + initializeTomSelect(); + updateFormState(); + updateDeliverButtonVisibility(); } }); diff --git a/decidim-admin/app/packs/stylesheets/decidim/admin/_select_picker.scss b/decidim-admin/app/packs/stylesheets/decidim/admin/_select_picker.scss new file mode 100644 index 0000000000000..4b3703fb27d75 --- /dev/null +++ b/decidim-admin/app/packs/stylesheets/decidim/admin/_select_picker.scss @@ -0,0 +1,20 @@ +@import "tom-select/dist/scss/tom-select"; + +/* overwrite tom-select defaults */ +.ts { + &-control { + @apply border-gray text-md min-h-[40px]; + + input { + @apply font-normal text-black text-md; + } + } + + &-dropdown { + @apply text-md text-gray-2 font-normal; + + .active { + @apply text-white bg-secondary; + } + } +} diff --git a/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss b/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss index ff8d63b8cb319..0b326d1f5395b 100755 --- a/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss +++ b/decidim-admin/app/packs/stylesheets/decidim/admin/application.scss @@ -31,6 +31,7 @@ @import "stylesheets/decidim/admin/_tabs.scss"; @import "stylesheets/decidim/admin/_component-show.scss"; @import "stylesheets/decidim/admin/_moderations.scss"; +@import "stylesheets/decidim/admin/_select_picker.scss"; @import "stylesheets/decidim/admin/_show_email.scss"; @import "stylesheets/decidim/admin/_bulk_actions.scss"; @import "stylesheets/decidim/admin/_data_picker.scss"; diff --git a/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb b/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb index 572e7b332e27f..697ce9f9bf04c 100644 --- a/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb +++ b/decidim-admin/app/queries/decidim/admin/newsletter_recipients.rb @@ -22,15 +22,15 @@ def initialize(form) def query recipients = recipients_base_query - recipients = recipients.interested_in_scopes(@form.scope_ids) if @form.scope_ids.present? + return recipients if @form.send_to_all_users + return verified_users if @form.send_to_verified_users - followers = recipients.where(id: user_id_of_followers) if @form.send_to_followers + if filters_present? + filtered_recipients = apply_filters(recipients) + return recipients.none if filtered_recipients.empty? - participants = recipients.where(id: participant_ids) if @form.send_to_participants - - recipients = participants if @form.send_to_participants - recipients = followers if @form.send_to_followers - recipients = (followers + participants).uniq if @form.send_to_followers && @form.send_to_participants + return filtered_recipients + end recipients end @@ -45,26 +45,49 @@ def recipients_base_query .confirmed end + def filters_present? + @form.send_to_followers || @form.send_to_participants || @form.send_to_private_members + end + + def apply_filters(recipients) + filters = [ + user_id_of_followers, + participant_ids, + private_member_ids + ].compact.flatten.uniq + + filters.empty? ? recipients.none : recipients.where(id: filters) + end + # Return the ids of the ParticipatorySpace selected # in form, grouped by type # This will be used to take followers and # participants of each ParticipatorySpace def spaces - return if @form.participatory_space_types.blank? + return [] if @form.participatory_space_types.blank? @spaces ||= @form.participatory_space_types.map do |type| next if type.ids.blank? + ids = type.ids + object_class = Decidim.participatory_space_registry.find(type.manifest_name).model_class_name.constantize - if type.ids.include?("all") - object_class.where(organization: @organization) - else - object_class.where(id: type.ids.compact_blank) - end + ids.include?("all") ? object_class.where(organization: @form.organization) : object_class.where(id: ids.compact_blank) end.flatten.compact end + def verified_users + users = recipients_base_query + + verified_users = Decidim::Authorization.select(:decidim_user_id) + .where(decidim_user_id: users.select(:id)) + .where.not(granted_at: nil) + .where(name: @form.verification_types) + .distinct + users.where(id: verified_users) + end + # Return the ids of Users that are following # the spaces selected in form def user_id_of_followers @@ -74,7 +97,7 @@ def user_id_of_followers Decidim::Follow.user_follower_ids_for_participatory_spaces(spaces) end - # Return the ids of Users that have participate + # Return the ids of Users that have participated in # the spaces selected in form def participant_ids return if spaces.blank? @@ -95,6 +118,19 @@ def participant_ids participant_ids.flatten.compact.uniq end + + def private_spaces + return [] if spaces.blank? + + spaces.select { |space| space.try(:private_space?) } + end + + def private_member_ids + return unless @form.send_to_private_members + return [] if private_spaces.blank? + + Decidim::ParticipatorySpacePrivateUser.private_user_ids_for_participatory_spaces(private_spaces) + end end end end diff --git a/decidim-admin/app/views/decidim/admin/newsletters/confirm_recipients.html.erb b/decidim-admin/app/views/decidim/admin/newsletters/confirm_recipients.html.erb new file mode 100644 index 0000000000000..51c8ba0f991df --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/newsletters/confirm_recipients.html.erb @@ -0,0 +1,64 @@ +<% add_decidim_page_title(t(".title")) %> +
+

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

+
+ <%= link_to t("actions.edit", scope: "decidim.admin"), + select_recipients_to_deliver_newsletter_path(@newsletter, newsletter: newsletter_params), + class: "button button__sm button__secondary" %> +
+
+
+
+
+ + + + + + + + + <% @recipients.each do |user| %> + + + + + <% end %> + +
<%= t(".name") %><%= t(".email") %>
<%= user.name %>
<%= user.email %>
+
+
+
+ <% unless @newsletter.sent? %> + <%= form_with url: deliver_newsletter_path(@newsletter), method: :post, html: { class: "form form-defaults newsletter_deliver" } do |f| %> + <%= hidden_field_tag "newsletter[send_to_all_users]", params.dig(:newsletter, :send_to_all_users) || "0" %> + <%= hidden_field_tag "newsletter[send_to_verified_users]", params.dig(:newsletter, :send_to_verified_users) %> + <%= hidden_field_tag "newsletter[send_to_participants]", params.dig(:newsletter, :send_to_participants) %> + <%= hidden_field_tag "newsletter[send_to_followers]", params.dig(:newsletter, :send_to_followers) %> + <%= hidden_field_tag "newsletter[send_to_private_members]", params.dig(:newsletter, :send_to_private_members) %> + + <% @form.participatory_space_types.each do |space_type| %> + <%= f.fields_for "newsletter[participatory_space_types][#{space_type.manifest_name}]", space_type do |ff| %> + <%= ff.hidden_field :manifest_name, value: space_type.manifest_name %> + <% space_type.ids.each do |id| %> + <%= hidden_field_tag "newsletter[participatory_space_types][#{space_type.manifest_name}][ids][]", id %> + <% end %> + <% end %> + <% end %> + + <% @form.verification_types.each do |verification_type| %> + <%= hidden_field_tag "newsletter[verification_types][]", verification_type, id: "verification_types_#{verification_type}" %> + <% end %> + + <%= submit_tag t("select_recipients_to_deliver.deliver", scope: "decidim.admin.newsletters"), + id: "deliver-button", class: "button button__sm button__secondary", + data: { confirm: t("select_recipients_to_deliver.confirm_deliver", scope: "decidim.admin.newsletters") } %> + <% end %> + <% end %> +
+
+
+
+<%= decidim_paginate @recipients %> diff --git a/decidim-admin/app/views/decidim/admin/newsletters/select_recipients_to_deliver.html.erb b/decidim-admin/app/views/decidim/admin/newsletters/select_recipients_to_deliver.html.erb index 22634d12a63f9..83d60ac285c98 100644 --- a/decidim-admin/app/views/decidim/admin/newsletters/select_recipients_to_deliver.html.erb +++ b/decidim-admin/app/views/decidim/admin/newsletters/select_recipients_to_deliver.html.erb @@ -16,19 +16,49 @@ <%= cell("decidim/announcement", newsletter_recipients_count_callout_announcement, callout_class: "warning") %>
-
-

<%= t ".select_users" %>

-
<% if current_user.admin? %> -
+
+

<%= t ".select_users_general" %>

+
+
- <%= f.check_box :send_to_all_users, help_text: t(".all_users_help") %> + <%= label_tag "newsletter_send_to_all_users" do %> + <%= radio_button_tag "newsletter[send_to_all_users]", "1", @form.send_to_all_users == true, id: "newsletter_send_to_all_users" %> + <%= t(".send_to_all_users") %> + <% end %> + <%= t(".all_users_help", default: "Sends a newsletter to all users.") %> +
+
+ <%= label_tag "newsletter_send_to_verified_users" do %> + <%= radio_button_tag "newsletter[send_to_verified_users]", "1", @form.send_to_verified_users == true, id: "newsletter_send_to_verified_users" %> + <%= t(".send_to_verified_users") %> + <% end %> + <%= t(".verified_users_help", default: "Sends a newsletter to all confirmed and verified users via any selected verification method.") %> +
+

<% end %> +
+

<%= t ".select_users_for_participatory_space" %>

+
<%= f.check_box :send_to_participants, help_text: t(".participants_help") %> @@ -37,6 +67,10 @@
<%= f.check_box :send_to_followers, help_text: t(".followers_help") %>
+ +
+ <%= f.check_box :send_to_private_members, help_text: t(".private_members_help") %> +
@@ -52,25 +86,15 @@
- - <% if current_user.admin? %> -
-
-

<%= t ".select_scopes" %>

-
-
-
- <%= scopes_select_field f, :scope_ids, options: { include_blank: false }, html_options: { class: "chosen-select", multiple: true, size: 5 } %> - <%= t(".scopes_help") %> -
-
-
- <% end %>
<% unless @newsletter.sent? %> - <%= f.submit t(".deliver"), class: "button button__sm button__secondary", data: { confirm: t(".confirm_deliver") } %> + <%= f.submit t(".deliver"), id: "deliver-button", class: "button button__sm button__secondary", data: { confirm: t(".confirm_deliver") } %> + <%= link_to t(".confirm_recipients"), "#", + id: "confirm-recipients-link", + class: "button button__sm button__secondary hidden", + data: { base_url: confirm_recipients_newsletter_path(@newsletter) } %> <% end %>
diff --git a/decidim-admin/config/locales/en.yml b/decidim-admin/config/locales/en.yml index 0a1fef33efc98..ef08fef494234 100644 --- a/decidim-admin/config/locales/en.yml +++ b/decidim-admin/config/locales/en.yml @@ -756,6 +756,10 @@ en: preview: 'Preview template: %{template_name}' use_template: Use this template newsletters: + confirm_recipients: + email: Email + name: Name + title: Confirm recipients create: error: There was a problem creating this newsletter. success: Newsletter created successfully. Please review it before sending. @@ -775,12 +779,14 @@ en: confirm_delete: Are you sure you want to delete this newsletter? followers: 'followers ' has_been_sent_to: 'Has been sent to: ' - no_scopes: No scopes not_sent: Not sent participants: 'participants ' + private_members: 'private members ' segmented_to: 'Segmented to %{subject}: ' subscribed_count: 'Subscribed:' title: Newsletters + verification_types: 'Verification types: %{types}' + verified_users: Verified users new: save: Save title: New newsletter @@ -788,16 +794,24 @@ en: all_spaces: All all_users_help: Sends newsletter to all confirmed users. confirm_deliver: Are you sure you want to deliver this newsletter? This action cannot be undone. + confirm_recipients: Confirm recipients deliver: Deliver newsletter followers_help: Sends newsletter to all confirmed users that follow any selected participatory spaces in the list. - none: None participants_help: Sends newsletter to all confirmed users that have participated in any of the selected participatory spaces in the list. + private_members_help: Sends a newsletter to all confirmed users that have been added to the selected private participatory spaces. recipients_count: This newsletter will be send to %{count} users. - scopes_help: Sends newsletter to users that have any of the selected scope activated in their account's "My Interests" settings. - select_scopes: Filter for users having activated any selected scope in their account's My Interests settings. + select_assemblies: Choose assemblies + select_conferences: Choose conferences + select_initiatives: Choose initiatives + select_participatory_processes: Choose participatory processes select_spaces: Select spaces to segment the newsletter - select_users: Select which users you want to send the newsletter + select_users_for_participatory_space: Send newsletter to one or more participatory spaces + select_users_general: Send general newsletter + select_verification_types: Choose verification methods + send_to_all_users: Send to all users + send_to_verified_users: Send to verified users title: Select recipients to deliver + verified_users_help: Sends a newsletter to all confirmed and verified users via any selected verification method. warning: "Attention: This newsletter will only be send to users that have activated I want to receive newsletters in their notifications settings." send: no_recipients: No recipients for this selection. diff --git a/decidim-admin/config/routes.rb b/decidim-admin/config/routes.rb index 0fa8857cc1ca7..9db2a0c2940b7 100644 --- a/decidim-admin/config/routes.rb +++ b/decidim-admin/config/routes.rb @@ -89,6 +89,7 @@ get :preview get :select_recipients_to_deliver post :deliver + get :confirm_recipients end end diff --git a/decidim-admin/spec/cells/decidim/admin/multi_select_picker_cell_spec.rb b/decidim-admin/spec/cells/decidim/admin/multi_select_picker_cell_spec.rb new file mode 100644 index 0000000000000..19795c916f783 --- /dev/null +++ b/decidim-admin/spec/cells/decidim/admin/multi_select_picker_cell_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Admin::MultiSelectPickerCell, type: :cell do + subject { cell_html.to_s } + + controller Decidim::Admin::NewslettersController + + let(:context) do + { + select_id: "test-select", + field_name: "test_field", + placeholder: "Choose an option", + class: "custom-class", + options_for_select: [["Option 1", 1], ["Option 2", 2]], + selected_values: [1] + } + end + + let(:my_cell) { cell("decidim/admin/multi_select_picker", nil, context:) } + let(:cell_html) { my_cell.call } + + it "renders a select element with the correct attributes" do + expect(subject).to include("Option 1}) + expect(subject).to match(%r{}) + end + + context "when no options are provided" do + let(:context) do + { + select_id: "test-select", + field_name: "test_field", + placeholder: "Choose an option", + class: "custom-class", + options_for_select: [] + } + end + + it "renders an empty select element" do + expect(subject).to include("Option 1}) + expect(subject).to match(%r{}) + expect(subject).to match(%r{}) + end + end + + context "when a placeholder is provided" do + let(:context) do + { + select_id: "test-select", + field_name: "test_field", + placeholder: "Choose an option", + class: "custom-class", + options_for_select: [] + } + end + + it "renders the placeholder in the select element" do + expect(subject).to include('placeholder="Choose an option"') + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb b/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb index 721e0f1b6cc97..69ae26b33b841 100644 --- a/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb +++ b/decidim-admin/spec/commands/decidim/admin/deliver_newsletter_spec.rb @@ -12,23 +12,24 @@ module Decidim::Admin body: Decidim::Faker::Localized.sentence(word_count: 3)) end let(:current_user) { create(:user, :admin, :confirmed, organization:) } - let(:scopes) do - create_list(:scope, rand(2..9), organization:) - end let(:participatory_processes) { create_list(:participatory_process, rand(2..9), organization:) } let(:selected_participatory_processes) { [participatory_processes.first.id.to_s] } let(:send_to_all_users) { false } + let(:send_to_verified_users) { false } + let(:verification_types) { [] } let(:send_to_followers) { false } let(:send_to_participants) { false } + let(:send_to_private_members) { false } let(:participatory_space_types) { [] } - let(:scope_ids) { [] } let(:form_params) do { send_to_all_users:, + send_to_verified_users:, + verification_types:, send_to_followers:, send_to_participants:, - participatory_space_types:, - scope_ids: + send_to_private_members:, + participatory_space_types: } end let(:form) do @@ -100,32 +101,64 @@ def user_localized_body(user) it_behaves_like "selective newsletter" end + end - context "with scopes segment" do - let(:scope_ids) { [scopes.first.id] } + context "when sending to verified users" do + let(:send_to_verified_users) { true } - context "when interests match the selected scopes" do - let!(:deliverable_users) do - create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current, extended_data: { interested_scopes: scopes.first.id }) - end + context "when no verification types selected" do + it "is not valid" do + expect { command.call }.to broadcast(:no_recipients) + end + end + + context "with a single verification type is selected" do + let(:verification_types) { ["id_documents"] } + + let!(:deliverable_users) do + create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) + end + + let!(:undeliverable_users) do + create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) + end + + let!(:unconfirmed_users) do + create_list(:user, rand(2..9), organization:, newsletter_notifications_at: Time.current) + end - let!(:undeliverable_users) do - create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current, extended_data: { interested_scopes: scopes.last.id }) + before do + deliverable_users.each do |user| + create(:authorization, user:, name: "id_documents", granted_at: Time.current) end + end + + it_behaves_like "selective newsletter" + end + + context "with multiple verification types selected" do + let(:verification_types) { %w(id_documents postal_letter) } + let!(:deliverable_users) { users_with_id_documents + users_with_postal_letter } - it_behaves_like "selective newsletter" + let!(:users_with_id_documents) do + create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) + end + + let!(:users_with_postal_letter) do + create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) end - context "when interest do not match the selected scopes" do - let(:user_interest) { create(:scope, organization:) } - let!(:deliverable_users) do - create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current, extended_data: { interested_scopes: user_interest.id }) + before do + users_with_id_documents.each do |user| + create(:authorization, user:, name: "id_documents", granted_at: Time.current) end - it "is not valid" do - expect { command.call }.to broadcast(:no_recipients) + users_with_postal_letter.each do |user| + create(:authorization, user:, name: "postal_letter", granted_at: Time.current) end end + + it_behaves_like "selective newsletter" end end @@ -270,6 +303,52 @@ def user_localized_body(user) it_behaves_like "selective newsletter" end + context "when sending to private members" do + let(:send_to_private_members) { true } + + context "when no spaces selected" do + it "is not valid" do + expect { command.call }.to broadcast(:invalid) + end + end + + context "when spaces selected" do + let!(:participatory_process) { create(:participatory_process, organization:, private_space: true) } + let!(:component) { create(:dummy_component, organization:, participatory_space: participatory_process) } + let!(:private_users) do + create_list(:participatory_space_private_user, 30) do |private_user| + private_user.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) + private_user.privatable_to = participatory_process + private_user.save! + end + end + let(:participatory_space_types) do + [ + { "id" => nil, + "manifest_name" => "participatory_processes", + "ids" => [participatory_process.id.to_s] }, + { "id" => nil, + "manifest_name" => "assemblies", + "ids" => [] }, + { "id" => nil, + "manifest_name" => "conferences", + "ids" => [] }, + { "id" => nil, + "manifest_name" => "initiatives", + "ids" => [] } + ] + end + + let!(:deliverable_users) { Decidim::User.where(id: private_users.map(&:decidim_user_id)) } + + let!(:undeliverable_users) do + create_list(:user, rand(2..9), :confirmed, organization:, newsletter_notifications_at: Time.current) + end + + it_behaves_like "selective newsletter" + end + end + context "when the user is a space admin" do let(:user) { create(:user, organization:) } let(:component) { create(:dummy_component, organization:) } diff --git a/decidim-admin/spec/controllers/newsletters_controller_spec.rb b/decidim-admin/spec/controllers/newsletters_controller_spec.rb new file mode 100644 index 0000000000000..471fcbbf3260f --- /dev/null +++ b/decidim-admin/spec/controllers/newsletters_controller_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Admin + describe NewslettersController do + routes { Decidim::Admin::Engine.routes } + + let(:organization) { create(:organization) } + let(:current_user) { create(:user, :admin, :confirmed, organization:) } + let(:newsletter) { create(:newsletter, organization:) } + + before do + request.env["decidim.current_organization"] = organization + sign_in current_user, scope: :user + end + + describe "POST deliver" do + let(:params) do + { + id: newsletter.id, + newsletter: { send_to_all_users: "1" } + } + end + + context "when delivery is successful" do + before do + allow(Decidim::Admin::NewsletterRecipients).to receive(:for).and_return([current_user]) + end + + it "sets a flash notice and redirects to index" do + post(:deliver, params:) + + expect(flash[:notice]).to eq(I18n.t("newsletters.deliver.success", scope: "decidim.admin")) + expect(response).to redirect_to(action: :index) + end + end + + context "when there are no recipients" do + before do + allow(Decidim::Admin::NewsletterRecipients).to receive(:for).and_return([]) + end + + it "sets a flash error and renders the select_recipients_to_deliver template" do + post(:deliver, params:) + + expect(flash.now[:error]).to eq(I18n.t("newsletters.send.no_recipients", scope: "decidim.admin")) + expect(response).to render_template(:select_recipients_to_deliver) + end + end + + context "when the delivery is invalid" do + let(:form_instance) { Decidim::Admin::SelectiveNewsletterForm.new } + + before do + allow(Decidim::Admin::SelectiveNewsletterForm).to receive(:from_params).and_return(form_instance) + allow(form_instance).to receive(:valid?).and_return(false) + end + + it "sets a flash error and renders the select_recipients_to_deliver template" do + post(:deliver, params:) + + expect(flash.now[:error]).to eq(I18n.t("newsletters.deliver.error", scope: "decidim.admin")) + expect(response).to render_template(:select_recipients_to_deliver) + end + end + end + + describe "GET confirm_recipients" do + let!(:recipients) { create_list(:user, 3, organization:) } + + before do + allow(Decidim::Admin::NewsletterRecipients).to receive(:for).and_return(User.where(id: recipients.map(&:id))) + allow(controller).to receive(:paginate).and_call_original + end + + it "assigns the recipients and paginates them" do + get :confirm_recipients, params: { id: newsletter.id, newsletter: { send_to_all_users: "1" } } + + expect(assigns(:recipients)).to match_array(recipients) + expect(response).to render_template(:confirm_recipients) + end + end + + describe "GET select_recipients_to_deliver" do + context "when newsletter_params are present" do + let(:params) do + { + id: newsletter.id, + newsletter: { + send_to_all_users: "1", + send_to_participants: "0", + participatory_space_types: { + participatory_processes: { manifest_name: "participatory_processes" }, + assemblies: { manifest_name: "assemblies" } + } + } + } + end + + it "assigns the form with the given params" do + get(:select_recipients_to_deliver, params:) + + assigned_form = assigns(:form) + expect(assigned_form).to be_a(Decidim::Admin::SelectiveNewsletterForm) + expect(assigned_form.send_to_all_users).to be true + expect(assigned_form.send_to_participants).to be false + expect(response).to render_template(:select_recipients_to_deliver) + end + end + + context "when newsletter_params are not present" do + it "assigns the form from the newsletter model" do + get :select_recipients_to_deliver, params: { id: newsletter.id } + + assigned_form = assigns(:form) + expect(assigned_form).to be_a(Decidim::Admin::SelectiveNewsletterForm) + expect(assigned_form.participatory_space_types).to be_present + expect(response).to render_template(:select_recipients_to_deliver) + end + end + end + + describe "GET recipients_count" do + let(:params) { { id: newsletter.id, newsletter: { send_to_all_users: "1" } } } + + before do + allow(controller).to receive(:recipients_count_query).and_return(5) + end + + it "renders the recipients count as plain text" do + get(:recipients_count, params:) + + expect(response.body).to eq("5") + expect(response.content_type).to eq("text/plain; charset=utf-8") + end + end + end + end +end diff --git a/decidim-admin/spec/forms/selective_newsletter_form_spec.rb b/decidim-admin/spec/forms/selective_newsletter_form_spec.rb index 3796635798b89..12f76b00e252e 100644 --- a/decidim-admin/spec/forms/selective_newsletter_form_spec.rb +++ b/decidim-admin/spec/forms/selective_newsletter_form_spec.rb @@ -14,25 +14,26 @@ module Admin let(:organization) { create(:organization) } let!(:user) { create(:user, :confirmed, :admin, organization:) } - let(:scopes) do - create_list(:scope, 5, organization:) - end let(:participatory_processes) { create_list(:participatory_process, rand(1..9), organization:) } let(:selected_participatory_processes) { [participatory_processes.first.id.to_s] } let(:send_to_all_users) { user.admin? } + let(:send_to_verified_users) { false } + let(:send_to_private_members) { false } let(:send_to_participants) { false } let(:send_to_followers) { false } let(:participatory_space_types) { [] } - let(:scope_ids) { [] } + let(:verification_types) { [] } let(:attributes) do { "newsletter" => { "send_to_all_users" => send_to_all_users, + "send_to_verified_users" => send_to_verified_users, + "send_to_private_members" => send_to_private_members, "send_to_participants" => send_to_participants, "send_to_followers" => send_to_followers, - "participatory_space_types" => participatory_space_types, - "scope_ids" => scope_ids + "verification_types" => verification_types, + "participatory_space_types" => participatory_space_types } } end @@ -84,6 +85,25 @@ module Admin it_behaves_like "selective newsletter form" end + + context "when send_to_verified_users is true" do + let(:send_to_verified_users) { true } + let(:verification_types) { ["example"] } + + it { is_expected.to be_valid } + end + + context "when verification_types is empty" do + let(:verification_types) { [] } + + it { is_expected.to be_invalid } + end + + context "when send_to_private_members is true" do + let(:send_to_private_members) { true } + + it_behaves_like "selective newsletter form" + end end context "when the user is a space admin" do @@ -108,25 +128,6 @@ module Admin it { is_expected.to be_invalid } end end - - describe "#scope_ids" do - context "when the scope IDs contain an empty value" do - # When the scope is selected from a dropdown and no value is selected - # this is what will be sent by the form - # (). - let(:scope_ids) { [""] } - - it "returns an empty array" do - expect(subject.scope_ids.empty?).to be(true) - end - end - end - - context "when a scope is selectable", type: :b do - let(:scope) { create(:scope, organization:) } - - it { is_expected.to be_valid } - end end end end diff --git a/decidim-admin/spec/jobs/newsletter_job_spec.rb b/decidim-admin/spec/jobs/newsletter_job_spec.rb index a71de71fa08a3..23d73d3fae342 100644 --- a/decidim-admin/spec/jobs/newsletter_job_spec.rb +++ b/decidim-admin/spec/jobs/newsletter_job_spec.rb @@ -14,18 +14,22 @@ module Admin let!(:non_deliverable_user) { create(:user, :confirmed, newsletter_notifications_at: nil, organization:) } let!(:deleted_user) { create(:user, :confirmed, :deleted, newsletter_notifications_at: Time.current, organization:) } let(:send_to_all_users) { true } + let(:send_to_verified_users) { false } let(:send_to_followers) { false } let(:send_to_participants) { false } + let(:send_to_private_members) { false } let(:participatory_space_types) { [] } - let(:scope_ids) { [] } + let(:verification_types) { [] } let(:form_params) do { send_to_all_users:, + send_to_verified_users:, send_to_followers:, send_to_participants:, participatory_space_types:, - scope_ids: + send_to_private_members:, + verification_types: } end @@ -63,7 +67,9 @@ module Admin "send_to_followers" => false, "send_to_participants" => false, "participatory_space_types" => [], - "scope_ids" => [] + "verification_types" => [], + "send_to_private_members" => false, + "send_to_verified_users" => false ) end end diff --git a/decidim-admin/spec/queries/newsletter_recipients_spec.rb b/decidim-admin/spec/queries/newsletter_recipients_spec.rb index 49a47fac8215d..ac961dbc01f21 100644 --- a/decidim-admin/spec/queries/newsletter_recipients_spec.rb +++ b/decidim-admin/spec/queries/newsletter_recipients_spec.rb @@ -11,16 +11,20 @@ module Decidim::Admin let(:send_to_all_users) { true } let(:send_to_followers) { false } let(:send_to_participants) { false } + let(:send_to_verified_users) { false } + let(:send_to_private_members) { false } let(:participatory_space_types) { [] } - let(:scope_ids) { [] } + let(:verification_types) { [] } let(:form_params) do { send_to_all_users:, send_to_followers:, send_to_participants:, + send_to_verified_users:, + send_to_private_members:, participatory_space_types:, - scope_ids: + verification_types: } end @@ -117,7 +121,7 @@ module Decidim::Admin ] end - context "when recipients participate to the participatory space" do + context "when recipients participate in the participatory space" do let!(:authors) do create_list(:user, 3, :confirmed, organization:, newsletter_notifications_at: Time.current) end @@ -132,55 +136,50 @@ module Decidim::Admin expect(subject.query).to match_array authors expect(authors.count).to eq 3 end - - context "and other comment in other participatory spaces" do - # non participant commentator (comments into other spaces) - let!(:non_participant) { create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) } - let!(:component_out_of_newsletter) { create(:dummy_component, organization:) } - let!(:resource_out_of_newsletter) { create(:dummy_resource, :published, author: non_participant, component: component_out_of_newsletter) } - let!(:outlier_comment) { create(:comment, author: non_participant, commentable: resource_out_of_newsletter) } - # participant commentator - let!(:commentator_participant) { create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) } - let!(:resource_in_newsletter) { create(:dummy_resource, :published, author: authors.first, component:) } - let!(:comment_in_newsletter) { create(:comment, author: commentator_participant, commentable: resource_in_newsletter) } - - let(:recipients) { authors + [commentator_participant] } - - it "returns only commenters in the selected spaces" do - expect(subject.query).to match_array(recipients) - expect(recipients.count).to eq 4 - end - end end end - context "with scopes segment" do - let(:scopes) do - create_list(:scope, 5, organization:) - end - let(:scope_ids) { scopes.pluck(:id) } + context "when sending to verified users" do + let(:send_to_all_users) { false } + let(:send_to_verified_users) { true } + let!(:verified_users) { create_list(:user, 3, :confirmed, organization:, newsletter_notifications_at: Time.current) } + let(:verification_types) { ["example"] } - context "when recipients interested in scopes" do - let!(:recipients) do - create_list(:user, 3, :confirmed, organization:, newsletter_notifications_at: Time.current, extended_data: { "interested_scopes" => scopes.first.id }) + before do + verified_users.each do |user| + create(:authorization, name: "example", granted_at: Time.current, user:) end + end - it "returns all users" do - expect(subject.query).to match_array recipients - expect(recipients.count).to eq 3 - end + it "returns verified users only" do + expect(subject.query).to match_array verified_users + expect(verified_users.count).to eq 3 end + end - context "when interest not match the selected scopes" do - let(:user_interset) { create(:scope, organization:) } - let!(:recipients) do - create_list(:user, 3, :confirmed, organization:, newsletter_notifications_at: Time.current, extended_data: { "interested_scopes" => user_interset.id }) - end + context "when sending to private members" do + let(:send_to_all_users) { false } + let(:send_to_private_members) { true } + let!(:recipients) { create_list(:user, 3, :confirmed, newsletter_notifications_at: Time.current, organization:) } + let(:participatory_process) { create(:participatory_process, organization:, private_space: true) } + let(:participatory_space_types) do + [ + { "id" => nil, + "manifest_name" => "participatory_processes", + "ids" => [participatory_process.id.to_s] } + ] + end - it "do not return recipients" do - expect(subject.query).to be_empty + before do + recipients.each do |member| + create(:participatory_space_private_user, privatable_to: participatory_process, user: member) end end + + it "returns private members only" do + expect(subject.query).to match_array recipients + expect(recipients.count).to eq 3 + end end end end diff --git a/decidim-admin/spec/system/admin_manages_newsletters_spec.rb b/decidim-admin/spec/system/admin_manages_newsletters_spec.rb index 06bc7a684ee82..6f26f58b4f375 100644 --- a/decidim-admin/spec/system/admin_manages_newsletters_spec.rb +++ b/decidim-admin/spec/system/admin_manages_newsletters_spec.rb @@ -173,10 +173,11 @@ end describe "select newsletter recipients" do - let!(:participatory_process) { create(:participatory_process, organization:) } - let!(:assembly) { create(:assembly, organization:) } - let!(:conference) { create(:conference, organization:) } - let!(:initiative) { create(:initiative, organization:) } + let!(:participatory_process) { create(:participatory_process, organization:, skip_injection: true) } + let!(:assembly) { create(:assembly, organization:, skip_injection: true) } + let!(:conference) { create(:conference, organization:, skip_injection: true) } + let!(:initiative) { create(:initiative, organization:, skip_injection: true) } + let!(:newsletter) { create(:newsletter, organization:) } let(:spaces) { [participatory_process, assembly, conference, initiative] } let!(:component) { create(:dummy_component, participatory_space: participatory_process) } @@ -184,12 +185,23 @@ def select_all spaces.each do |space| plural_name = space.model_name.route_key - within ".#{plural_name}-block" do - select translated(space.title), from: "newsletter_participatory_space_types_#{plural_name}__ids" - end + select_id = "##{plural_name}-spaces-select" + + next unless has_css?(select_id) + + tom_select(select_id, option_id: "all") end end + def select_verification_type(types) + select_id = "#verification-types-select" + + return unless has_css?(select_id) + + option_ids = Array(types) + tom_select(select_id, option_id: option_ids) + end + context "when all users are selected" do let(:recipients_count) { deliverable_users.size } @@ -197,7 +209,7 @@ def select_all visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) perform_enqueued_jobs do within(".newsletter_deliver") do - find_by_id("newsletter_send_to_all_users").set(true) + choose("Send to all users") end within "#recipients_count" do @@ -219,6 +231,89 @@ def select_all end end + context "when verified users selected" do + let!(:verification_type_first) { create(:authorization, :granted, user: deliverable_users.first, organization:, name: "id_documents") } + let!(:verification_type_last) { create(:authorization, :granted, user: deliverable_users.last, organization:, name: "postal_letter") } + + before do + organization.update!( + available_authorizations: [verification_type_first.name, verification_type_last.name] + ) + end + + context "with one verification type" do + let(:recipients_count) { 1 } + + it "sends newsletter to users verified with one type", :slow do + visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) + + within(".newsletter_deliver") do + choose("Send to verified users") + select_verification_type(verification_type_first.name) # Одна авторизация передается как строка + end + + within "#recipients_count" do + expect(page).to have_content(recipients_count) + end + + click_on("Confirm recipients") + + expect(page).to have_content(deliverable_users.first.name) + expect(page).to have_no_content(deliverable_users.last.name) + expect(page).to have_content(deliverable_users.first.email) + expect(page).to have_no_content(deliverable_users.last.email) + + perform_enqueued_jobs do + accept_confirm { click_on("Deliver newsletter") } + + expect(page).to have_content("Newsletters") + expect(page).to have_admin_callout("successfully") + end + + within "tbody" do + expect(page).to have_content("Has been sent to: Verified users") + expect(page).to have_content("1 / 1") + end + end + end + + context "with two verification types" do + let(:recipients_count) { 2 } + + it "sends newsletter to users verified with both types", :slow do + visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) + + within(".newsletter_deliver") do + choose("Send to verified users") + select_verification_type([verification_type_first.name, verification_type_last.name]) # Несколько авторизаций передаются как массив + end + + within "#recipients_count" do + expect(page).to have_content(recipients_count) + end + + click_on("Confirm recipients") + + expect(page).to have_content(deliverable_users.first.name) + expect(page).to have_content(deliverable_users.last.name) + expect(page).to have_content(deliverable_users.first.email) + expect(page).to have_content(deliverable_users.last.email) + + perform_enqueued_jobs do + accept_confirm { click_on("Deliver newsletter") } + + expect(page).to have_content("Newsletters") + expect(page).to have_admin_callout("successfully") + end + + within "tbody" do + expect(page).to have_content("Has been sent to: Verified users") + expect(page).to have_content("2 / 2") + end + end + end + end + context "when followers are selected" do let!(:followers) do deliverable_users.each do |follower| @@ -231,9 +326,7 @@ def select_all visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) perform_enqueued_jobs do within(".newsletter_deliver") do - uncheck("Send to all users") check("Send to followers") - uncheck("Send to participants") select_all end @@ -241,10 +334,15 @@ def select_all expect(page).to have_content(recipients_count) end - within "form.newsletter_deliver .item__edit-sticky" do - accept_confirm { click_on("Deliver newsletter") } + click_on("Confirm recipients") + + deliverable_users.each do |user| + expect(page).to have_content(user.name) + expect(page).to have_content(user.email) end + accept_confirm { click_on("Deliver newsletter") } + expect(page).to have_content("Newsletters") expect(page).to have_admin_callout("successfully") end @@ -264,8 +362,6 @@ def select_all it "has a working user counter" do visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) expect(page).to have_content("This newsletter will be send to 5 users.") - uncheck("Send to all users") - uncheck("Send to participants") check("Send to followers") select_all expect(page).to have_content("This newsletter will be send to 3 users.") @@ -285,35 +381,35 @@ def select_all it "has a working user counter" do visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) expect(page).to have_content("This newsletter will be send to 5 users.") - uncheck("Send to all users") - uncheck("Send to followers") check("Send to participants") - plural_name = assembly.model_name.route_key - within ".#{plural_name}-block" do - select translated(assembly.title), from: "newsletter_participatory_space_types_#{plural_name}__ids" - end + expect(find("input[name='newsletter[send_to_participants]']")).to be_checked + plural_name = assembly.model_name.route_key + select_id = "##{plural_name}-spaces-select" + tom_select(select_id, option_id: translated(assembly.title)) expect(page).to have_content("This newsletter will be send to 0 users.") end it "sends to participants", :slow do visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) - perform_enqueued_jobs do - within(".newsletter_deliver") do - uncheck("Send to all users") - uncheck("Send to followers") - check("Send to participants") - select_all - end + check("Send to participants") - within "#recipients_count" do - expect(page).to have_content(recipients_count) - end + expect(find("input[name='newsletter[send_to_participants]']")).to be_checked - within "form.newsletter_deliver .item__edit-sticky" do - accept_confirm { click_on("Deliver newsletter") } - end + select_all + + expect(page).to have_content("This newsletter will be send to 5 users.") + + click_on("Confirm recipients") + + deliverable_users.each do |user| + expect(page).to have_content(user.name) + expect(page).to have_content(user.email) + end + + perform_enqueued_jobs do + accept_confirm { click_on("Deliver newsletter") } expect(page).to have_content("Newsletters") expect(page).to have_admin_callout("successfully") @@ -343,28 +439,92 @@ def select_all it "sends to followers and participants", :slow do visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) + check("Send to participants") + check("Send to followers") + select_all + + within "#recipients_count" do + expect(page).to have_content(recipients_count) + end + + click_on("Confirm recipients") + perform_enqueued_jobs do - within(".newsletter_deliver") do - uncheck("Send to all users") - check("Send to followers") - check("Send to participants") - select_all + accept_confirm { click_on("Deliver newsletter") } + expect(page).to have_content("Newsletters") + expect(page).to have_admin_callout("successfully") + end + + within "tbody" do + expect(page).to have_content("10 / 10") + end + end + end + + context "when private members are selected" do + context "with private members" do + let!(:participatory_process) { create(:participatory_process, organization:, skip_injection: true, private_space: true) } + let!(:private_users) do + create_list(:participatory_space_private_user, 30) do |private_user| + private_user.user = create(:user, :confirmed, newsletter_notifications_at: Time.current, organization:) + private_user.privatable_to = participatory_process + private_user.save! end + end + + let(:recipients_count) { private_users.size } + + it "sends to private members", :slow do + visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) + check("Send to private members") + + expect(find("input[name='newsletter[send_to_private_members]']")).to be_checked + + select_all within "#recipients_count" do expect(page).to have_content(recipients_count) end - within "form.newsletter_deliver .item__edit-sticky" do + click_on("Confirm recipients") + + # The users are paginated + expect(page).to have_content("Results per page") + expect(page).to have_content("Next") + + perform_enqueued_jobs do accept_confirm { click_on("Deliver newsletter") } + expect(page).to have_content("Newsletters") + expect(page).to have_admin_callout("successfully") end - expect(page).to have_content("Newsletters") - expect(page).to have_admin_callout("successfully") + within "tbody" do + expect(page).to have_content("30 / 30") + end end + end - within "tbody" do - expect(page).to have_content("10 / 10") + context "when the private members count is 0" do + it "does not display any recipients", :slow do + visit decidim_admin.select_recipients_to_deliver_newsletter_path(newsletter) + check("Send to private members") + + expect(find("input[name='newsletter[send_to_private_members]']")).to be_checked + + select_all + + within "#recipients_count" do + expect(page).to have_content("0") + end + + click_on("Confirm recipients") + + # Check that no users are displayed + expect(page).to have_no_content("Results per page") + expect(page).to have_no_content("Next") + within "tbody" do + expect(page).to have_no_css("tr") + end end end end diff --git a/decidim-core/app/models/decidim/newsletter.rb b/decidim-core/app/models/decidim/newsletter.rb index b497f027f49a3..da4e2a88683e5 100644 --- a/decidim-core/app/models/decidim/newsletter.rb +++ b/decidim-core/app/models/decidim/newsletter.rb @@ -34,15 +34,23 @@ def sent_scopes @sent_scopes ||= organization.scopes.where(id: sent_scopes_ids) end - def sended_to_all_users? + def sent_to_all_users? extended_data["send_to_all_users"] end - def sended_to_followers? + def sent_to_verified_users? + extended_data["send_to_verified_users"] + end + + def sent_to_users_with_verification_types + extended_data["verification_types"] + end + + def sent_to_followers? extended_data["send_to_followers"] end - def sended_to_participants? + def sent_to_participants? extended_data["send_to_participants"] end @@ -50,6 +58,10 @@ def sent_to_participatory_spaces extended_data["participatory_space_types"] end + def sent_to_private_members? + extended_data["send_to_private_members"] + end + def template @template ||= Decidim::ContentBlock .for_scope(:newsletter_template, organization:) diff --git a/decidim-core/app/models/decidim/participatory_space_private_user.rb b/decidim-core/app/models/decidim/participatory_space_private_user.rb index a08c3bc107c5b..cd8676f037dd2 100644 --- a/decidim-core/app/models/decidim/participatory_space_private_user.rb +++ b/decidim-core/app/models/decidim/participatory_space_private_user.rb @@ -20,6 +20,10 @@ def self.user_collection(user) where(decidim_user_id: user.id) end + def self.private_user_ids_for_participatory_spaces(spaces) + joins(:user).where(privatable_to: spaces).distinct.pluck(:decidim_user_id) + end + def self.export_serializer Decidim::DownloadYourDataSerializers::DownloadYourDataParticipatorySpacePrivateUserSerializer end