diff --git a/app/components/confirm_modal_component.rb b/app/components/confirm_modal_component.rb new file mode 100644 index 0000000000..5df7d9ba54 --- /dev/null +++ b/app/components/confirm_modal_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ConfirmModalComponent < ModalComponent + def initialize(id:, confirm_actions: nil, controllers: nil) + super(id: id, close_button: true) + @confirm_actions = confirm_actions + @controllers = controllers + end + + private + + def close_button_class + "secondary" + end +end diff --git a/app/components/confirm_modal_component/confirm_modal_component.html.haml b/app/components/confirm_modal_component/confirm_modal_component.html.haml new file mode 100644 index 0000000000..58feccb1ec --- /dev/null +++ b/app/components/confirm_modal_component/confirm_modal_component.html.haml @@ -0,0 +1,8 @@ +%div{ id: @id, "data-controller": "modal #{@controllers}", "data-action": "keyup@document->modal#closeIfEscapeKey" } + .reveal-modal-bg.fade{ "data-modal-target": "background", "data-action": "click->modal#close" } + .reveal-modal.fade.tiny.help-modal{ "data-modal-target": "modal" } + = content + + .modal-actions + %input{ class: "button icon-plus #{close_button_class}", type: 'button', value: t('js.admin.modals.cancel'), "data-action": "click->modal#close" } + %input{ class: "button icon-plus primary", type: 'button', value: t('js.admin.modals.confirm'), "data-action": @confirm_actions } diff --git a/app/components/confirm_modal_component/confirm_modal_component.scss b/app/components/confirm_modal_component/confirm_modal_component.scss new file mode 100644 index 0000000000..ccbf18169c --- /dev/null +++ b/app/components/confirm_modal_component/confirm_modal_component.scss @@ -0,0 +1,4 @@ +.modal-actions { + display: flex; + justify-content: space-around; +} diff --git a/app/components/help_modal_component.rb b/app/components/help_modal_component.rb index 9e4d1ca6bd..4af82634c4 100644 --- a/app/components/help_modal_component.rb +++ b/app/components/help_modal_component.rb @@ -1,26 +1,7 @@ # frozen_string_literal: true -class HelpModalComponent < ViewComponent::Base +class HelpModalComponent < ModalComponent def initialize(id:, close_button: true) - @id = id - @close_button = close_button - end - - private - - def close_button_class - if namespace == "admin" - "red" - else - "primary" - end - end - - def close_button? - !!@close_button - end - - def namespace - helpers.controller_path.split("/").first + super(id: id, close_button: close_button) end end diff --git a/app/components/modal_component.rb b/app/components/modal_component.rb new file mode 100644 index 0000000000..70226295c7 --- /dev/null +++ b/app/components/modal_component.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ModalComponent < ViewComponent::Base + def initialize(id:, close_button: true) + @id = id + @close_button = close_button + end + + private + + def close_button_class + if namespace == "admin" + "red" + else + "primary" + end + end + + def close_button? + !!@close_button + end + + def namespace + helpers.controller_path.split("/").first + end +end diff --git a/app/reflexes/application_reflex.rb b/app/reflexes/application_reflex.rb index 387bee1745..7fc8b6cc3d 100644 --- a/app/reflexes/application_reflex.rb +++ b/app/reflexes/application_reflex.rb @@ -16,4 +16,11 @@ class ApplicationReflex < StimulusReflex::Reflex # # For code examples, considerations and caveats, see: # https://docs.stimulusreflex.com/rtfm/patterns#internationalization + include CanCan::ControllerAdditions + + delegate :current_user, to: :connection + + def current_ability + Spree::Ability.new(current_user) + end end diff --git a/app/reflexes/resend_confirmation_email_reflex.rb b/app/reflexes/resend_confirmation_email_reflex.rb new file mode 100644 index 0000000000..d43ef5f05a --- /dev/null +++ b/app/reflexes/resend_confirmation_email_reflex.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ResendConfirmationEmailReflex < ApplicationReflex + def confirm(order_ids) + Spree::Order.where(id: order_ids).find_each do |o| + Spree::OrderMailer.confirm_email_for_customer(o.id, true).deliver_later if can? :resend, o + end + + flash[:success] = I18n.t("admin.resend_confirmation_emails_feedback", count: order_ids.count) + cable_ready.dispatch_event(name: "modal:close") + morph "#flashes", render(partial: "shared/flashes", locals: { flashes: flash }) + end +end diff --git a/app/views/shared/_flashes.html.haml b/app/views/shared/_flashes.html.haml index 1cd0bd5615..be6c23dc5d 100644 --- a/app/views/shared/_flashes.html.haml +++ b/app/views/shared/_flashes.html.haml @@ -2,6 +2,6 @@ - if defined? flashes - flashes.each do |type, msg| %alert.animate-show{"data-controller": "flash"} - %div{type: "#{type}", class: "alert-box #{type == 'error' ? 'alert' : type}"} + .flash{type: "#{type}", class: "alert-box #{type == 'error' ? 'alert' : type}"} %span= msg %a.small.close{"data-action": "click->flash#close"} × diff --git a/app/views/spree/admin/orders/index.html.haml b/app/views/spree/admin/orders/index.html.haml index 1ca1753f19..4326c2065e 100644 --- a/app/views/spree/admin/orders/index.html.haml +++ b/app/views/spree/admin/orders/index.html.haml @@ -30,13 +30,15 @@ ="#{t('admin.actions')}".html_safe %span{ 'ng-class' => "expanded && 'icon-caret-up' || !expanded && 'icon-caret-down'" } %div.menu{ 'ng-show' => "expanded" } + %div.menu_item + %span.name{ "data-controller": "modal-link", "data-action": "click->modal-link#open", "data-modal-link-target-value": "resend_confirmation" } + = t('.resend_confirmation') %div.menu_item %span.name.invoices-modal{'ng-controller' => 'bulkInvoiceCtrl', 'ng-click' => 'createBulkInvoice()' } = t('.print_invoices') %div.menu_item %span.name{'ng-controller' => 'bulkCancelCtrl', 'ng-click' => 'cancelSelectedOrders()' } = t('.cancel_orders') - = render partial: 'per_page_controls', locals: { position: "right" } @@ -63,7 +65,7 @@ %tbody %tr{ng: {repeat: 'order in orders track by order.id', class: {even: "'even'", odd: "'odd'"}}, 'ng-class' => "{'state-{{order.state}}': true, 'row-loading': rowStatus[order.id] == 'loading'}"} %td.align-center - %input{type: 'checkbox', 'ng-model' => 'checkboxes[order.id]', 'ng-change' => 'toggleSelection(order.id)'} + %input{type: 'checkbox', 'ng-model' => 'checkboxes[order.id]', 'ng-change' => 'toggleSelection(order.id)', value: '{{order.id}}', name: 'order_ids[]'} %td.align-center {{order.distributor_name}} %td.align-center @@ -118,3 +120,7 @@ = t('.no_orders_found') = render 'spree/admin/shared/custom-confirm' + += render ConfirmModalComponent.new(id: "resend_confirmation", confirm_actions: "click->resend-confirmation-email#confirm", controllers: "resend-confirmation-email") do + .margin-bottom-30 + = t('.resend_confirmation_confirm_html') diff --git a/app/views/spree/layouts/_admin_body.html.haml b/app/views/spree/layouts/_admin_body.html.haml index 056b027fba..b70d808729 100644 --- a/app/views/spree/layouts/_admin_body.html.haml +++ b/app/views/spree/layouts/_admin_body.html.haml @@ -11,6 +11,8 @@ - if flash[:success] .flash.success= flash[:success] + = render partial: "shared/flashes" + = render partial: "spree/layouts/admin/progress_spinner" %header#header{"data-hook" => ""} diff --git a/app/webpacker/controllers/help_modal_controller.js b/app/webpacker/controllers/help_modal_controller.js index 36322ea285..1cf387fc1e 100644 --- a/app/webpacker/controllers/help_modal_controller.js +++ b/app/webpacker/controllers/help_modal_controller.js @@ -1,33 +1,10 @@ import { Controller } from "stimulus"; +import { useOpenAndCloseAsAModal } from "./mixins/useOpenAndCloseAsAModal"; export default class extends Controller { static targets = ["background", "modal"]; - open() { - this.backgroundTarget.style.display = "block"; - this.modalTarget.style.display = "block"; - - setTimeout(() => { - this.modalTarget.classList.add("in"); - this.backgroundTarget.classList.add("in"); - document.querySelector("body").classList.add("modal-open"); - }); - } - - close() { - this.modalTarget.classList.remove("in"); - this.backgroundTarget.classList.remove("in"); - document.querySelector("body").classList.remove("modal-open"); - - setTimeout(() => { - this.backgroundTarget.style.display = "none"; - this.modalTarget.style.display = "none"; - }, 200); - } - - closeIfEscapeKey(e) { - if (e.code == "Escape") { - this.close(); - } + connect() { + useOpenAndCloseAsAModal(this); } } diff --git a/app/webpacker/controllers/help_modal_link_controller.js b/app/webpacker/controllers/help_modal_link_controller.js index 8d3bc973fa..631e24863f 100644 --- a/app/webpacker/controllers/help_modal_link_controller.js +++ b/app/webpacker/controllers/help_modal_link_controller.js @@ -1,15 +1,7 @@ -import { Controller } from "stimulus"; +import ModalLinkController from "./modal_link_controller"; -export default class extends Controller { - static values = { target: String }; - - open() { - let helpModal = document.getElementById(this.targetValue); - let helpModalController = - this.application.getControllerForElementAndIdentifier( - helpModal, - "help-modal" - ); - helpModalController.open(); +export default class extends ModalLinkController { + getIdentifier() { + return "help-modal"; } } diff --git a/app/webpacker/controllers/mixins/useOpenAndCloseAsAModal.js b/app/webpacker/controllers/mixins/useOpenAndCloseAsAModal.js new file mode 100644 index 0000000000..0a4987806b --- /dev/null +++ b/app/webpacker/controllers/mixins/useOpenAndCloseAsAModal.js @@ -0,0 +1,31 @@ +export const useOpenAndCloseAsAModal = (controller) => { + Object.assign(controller, { + open: function () { + this.backgroundTarget.style.display = "block"; + this.modalTarget.style.display = "block"; + + setTimeout(() => { + this.modalTarget.classList.add("in"); + this.backgroundTarget.classList.add("in"); + document.querySelector("body").classList.add("modal-open"); + }); + }.bind(controller), + + close: function () { + this.modalTarget.classList.remove("in"); + this.backgroundTarget.classList.remove("in"); + document.querySelector("body").classList.remove("modal-open"); + + setTimeout(() => { + this.backgroundTarget.style.display = "none"; + this.modalTarget.style.display = "none"; + }, 200); + }.bind(controller), + + closeIfEscapeKey: function (e) { + if (e.code == "Escape") { + this.close(); + } + }.bind(controller), + }); +}; diff --git a/app/webpacker/controllers/modal_controller.js b/app/webpacker/controllers/modal_controller.js new file mode 100644 index 0000000000..bc8c0fcff2 --- /dev/null +++ b/app/webpacker/controllers/modal_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "stimulus"; +import { useOpenAndCloseAsAModal } from "./mixins/useOpenAndCloseAsAModal"; + +export default class extends Controller { + static targets = ["background", "modal"]; + + connect() { + useOpenAndCloseAsAModal(this); + window.addEventListener("modal:close", this.close.bind(this)); + } + + disconnect() { + window.removeEventListener("modal:close", this.close); + } +} diff --git a/app/webpacker/controllers/modal_link_controller.js b/app/webpacker/controllers/modal_link_controller.js new file mode 100644 index 0000000000..d4f58fb546 --- /dev/null +++ b/app/webpacker/controllers/modal_link_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static values = { target: String }; + + open() { + let modal = document.getElementById(this.targetValue); + let modalController = this.application.getControllerForElementAndIdentifier( + modal, + this.getIdentifier() + ); + modalController.open(); + } + + getIdentifier() { + return "modal"; + } +} diff --git a/app/webpacker/controllers/resend_confirmation_email_controller.js b/app/webpacker/controllers/resend_confirmation_email_controller.js new file mode 100644 index 0000000000..d375e6a0cf --- /dev/null +++ b/app/webpacker/controllers/resend_confirmation_email_controller.js @@ -0,0 +1,18 @@ +import ApplicationController from "./application_controller"; + +export default class extends ApplicationController { + connect() { + super.connect(); + } + + confirm() { + const order_ids = []; + document + .querySelectorAll("#listing_orders input[name='order_ids[]']:checked") + .forEach((checkbox) => { + order_ids.push(checkbox.value); + }); + + this.stimulate("ResendConfirmationEmailReflex#confirm", order_ids); + } +} diff --git a/app/webpacker/css/admin/all.scss b/app/webpacker/css/admin/all.scss index f0de698f7b..7ee28e2e08 100644 --- a/app/webpacker/css/admin/all.scss +++ b/app/webpacker/css/admin/all.scss @@ -127,3 +127,4 @@ @import "app/components/pagination_component/pagination_component"; @import "app/components/table_header_component/table_header_component"; @import "app/components/search_input_component/search_input_component"; +@import 'app/components/confirm_modal_component/confirm_modal_component'; diff --git a/app/webpacker/css/admin/components/messages.scss b/app/webpacker/css/admin/components/messages.scss index 34970ce4c7..57b89ea3ba 100644 --- a/app/webpacker/css/admin/components/messages.scss +++ b/app/webpacker/css/admin/components/messages.scss @@ -38,6 +38,7 @@ font-size: 120%; color: $color-1; font-weight: 600; + margin-top: 0; &.notice { background-color: rgba($color-notice, 0.8) } &.success { background-color: rgba($color-success, 0.8) } diff --git a/app/webpacker/css/darkswarm/all.scss b/app/webpacker/css/darkswarm/all.scss index 3738d945c6..d57b326c58 100644 --- a/app/webpacker/css/darkswarm/all.scss +++ b/app/webpacker/css/darkswarm/all.scss @@ -77,3 +77,4 @@ ofn-modal { @import '../admin/shared/scroll_bar'; @import 'app/components/help_modal_component/help_modal_component'; +@import 'app/components/confirm_modal_component/confirm_modal_component'; diff --git a/config/environments/production.rb b/config/environments/production.rb index a097aefd29..6fde13c85e 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -48,6 +48,9 @@ Openfoodnetwork::Application.configure do reconnect_attempts: 1 } + config.action_cable.url = "#{ENV['OFN_URL']}/cable" + config.action_cable.allowed_request_origins = [/http:\/\/#{ENV['OFN_URL']}\/*/, /https:\/\/#{ENV['OFN_URL']}\/*/] + # Enable serving of images, stylesheets, and JavaScripts from an asset server # config.action_controller.asset_host = "http://assets.example.com" diff --git a/config/environments/staging.rb b/config/environments/staging.rb index a097aefd29..6fde13c85e 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -48,6 +48,9 @@ Openfoodnetwork::Application.configure do reconnect_attempts: 1 } + config.action_cable.url = "#{ENV['OFN_URL']}/cable" + config.action_cable.allowed_request_origins = [/http:\/\/#{ENV['OFN_URL']}\/*/, /https:\/\/#{ENV['OFN_URL']}\/*/] + # Enable serving of images, stylesheets, and JavaScripts from an asset server # config.action_controller.asset_host = "http://assets.example.com" diff --git a/config/locales/en.yml b/config/locales/en.yml index de5802b846..493ae5ad51 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1566,6 +1566,10 @@ en: stripe_connect_settings: resource: Stripe Connect configuration + resend_confirmation_emails_feedback: + one: "Confirmation email sent for 1 order." + other: "Confirmation emails sent for %{count} orders." + # API # api: @@ -3036,8 +3040,10 @@ See the %{link} to find out more about %{sitename}'s features and to start using deleting_item_will_cancel_order: "This operation will result in one or more empty orders, which will be cancelled. Do you wish to proceed?" modals: got_it: "Got it" + confirm: "Confirm" close: "Close" continue: "Continue" + cancel: "Cancel" invite: "Invite" invite_title: "Invite an unregistered user" tag_rule_help: @@ -3905,6 +3911,8 @@ See the %{link} to find out more about %{sitename}'s features and to start using viewing: "Viewing %{start} to %{end}." print_invoices: "Print Invoices" cancel_orders: "Cancel Orders" + resend_confirmation: "Resend Confirmation" + resend_confirmation_confirm_html: "This will resend the confirmation email to the customer.
Are you sure you want to proceed?" selected: zero: "No order selected" one: "1 order selected" diff --git a/spec/system/admin/orders_spec.rb b/spec/system/admin/orders_spec.rb index 723c0df433..ce4795b9ae 100644 --- a/spec/system/admin/orders_spec.rb +++ b/spec/system/admin/orders_spec.rb @@ -122,6 +122,62 @@ describe ' end end + context "bulk actions" do + context "resend confirmation email" do + it "can bulk send email to 2 orders" do + login_as_admin_and_visit spree.admin_orders_path + + page.find("#listing_orders tbody tr:nth-child(1) input[name='order_ids[]']").click + page.find("#listing_orders tbody tr:nth-child(2) input[name='order_ids[]']").click + + page.find("span.icon-reorder", text: "ACTIONS").click + within ".ofn-drop-down-with-prepend .menu" do + page.find("span", text: "Resend Confirmation").click + end + + expect(page).to have_content "Are you sure you want to proceed?" + + within ".reveal-modal" do + expect { + find_button("Confirm").click + }.to enqueue_job(ActionMailer::MailDeliveryJob).exactly(:twice) + end + + expect(page).to have_content "Confirmation emails sent for 2 orders." + end + + context "for a hub manager" do + before do + login_to_admin_as user + visit spree.admin_orders_path + end + + it "cannnot send emails to orders if permission have been revoked in the meantime" do + page.find("#listing_orders tbody tr:nth-child(1) input[name='order_ids[]']").click + + # Find the clicked order + order = Spree::Order.find_by(id: page.find("#listing_orders tbody tr:nth-child(1) input[name='order_ids[]']").value) + # Revoke permission for the current user on that specific order by changing its owners + order.update_attribute(:created_by, create(:user)) + order.update_attribute(:distributor, create(:distributor_enterprise)) + + page.find("span.icon-reorder", text: "ACTIONS").click + within ".ofn-drop-down-with-prepend .menu" do + page.find("span", text: "Resend Confirmation").click + end + + expect(page).to have_content "Are you sure you want to proceed?" + + within ".reveal-modal" do + expect { + find_button("Confirm").click + }.to_not enqueue_job(ActionMailer::MailDeliveryJob) + end + end + end + end + end + context "with a capturable order" do before do order.finalize! # ensure order has a payment to capture