diff --git a/app/components/workflows/blankslate_component.html.erb b/app/components/workflows/blankslate_component.html.erb index da7a8b2ae1c..6947ea1984f 100644 --- a/app/components/workflows/blankslate_component.html.erb +++ b/app/components/workflows/blankslate_component.html.erb @@ -31,8 +31,9 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::Beta::Blankslate.new(border: true)) do |blankslate| blankslate.with_heading(tag: :h2).with_content(t("admin.workflows.blankslate.title")) blankslate.with_description_content(t("admin.workflows.blankslate.description")) + # TODO: pass all roles once BlankslateComponent accepts roles: and StatusDialogComponent supports multi-role natively. blankslate.with_primary_action( - href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_id: @role.id), + href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_ids: [@role.id]), scheme: :secondary, data: { controller: "async-dialog" } ) do |button| diff --git a/app/components/workflows/status_form_component.html.erb b/app/components/workflows/status_form_component.html.erb index d9bf8ef27b7..59394c0a330 100644 --- a/app/components/workflows/status_form_component.html.erb +++ b/app/components/workflows/status_form_component.html.erb @@ -28,8 +28,9 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= + # TODO: pass all roles once StatusFormComponent and confirm_statuses support multi-role natively. primer_form_with( - url: helpers.confirm_statuses_workflow_tab_path(@type, @tab, role_id: @role.id), + url: helpers.confirm_statuses_workflow_tab_path(@type, @tab, role_ids: [@role.id]), method: :post, id: FORM_ID, data: { turbo_frame: "workflow-table" } diff --git a/app/components/workflows/status_matrix_form_component.html.erb b/app/components/workflows/status_matrix_form_component.html.erb index 93522276a2f..c6aa018b334 100644 --- a/app/components/workflows/status_matrix_form_component.html.erb +++ b/app/components/workflows/status_matrix_form_component.html.erb @@ -32,19 +32,29 @@ See COPYRIGHT and LICENSE files for more details. render Primer::OpenProject::SubHeader.new do |subheader| if @type && @available_roles.any? subheader.with_filter_component do - render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu| - menu.with_show_button(scheme: :secondary) do |button| + render( + Primer::Alpha::SelectPanel.new( + select_variant: :multiple, + fetch_strategy: :local, + title: t("admin.workflows.role_selector.title"), + data: data_attributes + ) + ) do |panel| + panel.with_show_button(scheme: :secondary) do |button| button.with_trailing_visual_icon(icon: :"triangle-down") - @role ? t("admin.workflows.role_selector.label", role: @role.name) : t("admin.workflows.role_selector.no_role") + if @roles.many? + t("admin.workflows.role_selector.roles", count: @roles.size) + elsif @role + t("admin.workflows.role_selector.label", role: @role.name) + else + t("admin.workflows.role_selector.no_role") + end end @available_roles.each do |available_role| - menu.with_item( + panel.with_item( label: available_role.name, - active: available_role == @role, - tag: :a, - href: helpers.edit_workflow_tab_path(@type, @tab, role_id: available_role.id), - content_arguments: { data: { "admin--workflow-checkbox-state-confirmation-trigger": "click", - turbo_action: "advance" } } + active: @roles.include?(available_role), + item_id: available_role.id ) end end @@ -56,7 +66,9 @@ See COPYRIGHT and LICENSE files for more details. scheme: :secondary, leading_icon: :plus, label: t("admin.workflows.status_button"), - href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_id: @role&.id, status_ids: @statuses.pluck(:id).presence), + # TODO: status_dialog and StatusDialogComponent currently work with a single role (@role = @roles.first); + # update when they support multi-role natively. + href: helpers.status_dialog_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id), status_ids: @statuses.pluck(:id).presence), data: { controller: "async-dialog" } ) do t("admin.workflows.status_button") @@ -76,7 +88,9 @@ See COPYRIGHT and LICENSE files for more details. } ) do %> <%= hidden_field_tag "type_id", @type.id %> - <%= hidden_field_tag "role_id", @role.id %> + <% @roles.each do |role| %> + <%= hidden_field_tag "role_ids[]", role.id %> + <% end %> <%= hidden_field_tag "tab", @tab %> <%= helpers.render_tabs helpers.workflow_tabs(@type) %> @@ -94,7 +108,7 @@ See COPYRIGHT and LICENSE files for more details. Primer::OpenProject::FeedbackDialog.new( title: t("admin.workflows.leave_confirmation.title"), data: { - "admin--workflow-checkbox-state-target": "confirmationDialog", + "admin--workflow-checkbox-state-target": "confirmationDialog" } ) ) do |dialog| diff --git a/app/components/workflows/status_matrix_form_component.rb b/app/components/workflows/status_matrix_form_component.rb index 14c3ade5fdb..bc3365aaff1 100644 --- a/app/components/workflows/status_matrix_form_component.rb +++ b/app/components/workflows/status_matrix_form_component.rb @@ -33,14 +33,26 @@ module Workflows include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(tab:, role:, type:, available_roles:, statuses:, has_status_changes:) + def initialize(tab:, roles:, type:, available_roles:, statuses:, has_status_changes:) super @tab = tab - @role = role + @roles = roles + @role = @roles.first @type = type @available_roles = available_roles @statuses = statuses @has_status_changes = has_status_changes end + + private + + def data_attributes + { + controller: "admin--workflow-role-select", + "admin--workflow-role-select-base-url-value": helpers.edit_workflow_path(@type), + "admin--workflow-role-select-current-role-ids-value": @roles.map(&:id).join(","), + "admin--workflow-role-select-admin--workflow-checkbox-state-outlet": "#workflow_form" + } + end end end diff --git a/app/components/workflows/status_removal_danger_dialog_component.html.erb b/app/components/workflows/status_removal_danger_dialog_component.html.erb index c2b020ff5b7..8a8ccd8e578 100644 --- a/app/components/workflows/status_removal_danger_dialog_component.html.erb +++ b/app/components/workflows/status_removal_danger_dialog_component.html.erb @@ -46,7 +46,8 @@ See COPYRIGHT and LICENSE files for more details. # The reason this is done here is because the submit is not a DELETE, and GET form submissions # strip url params dialog.with_additional_details do - concat(hidden_field_tag(:role_id, @role.id)) + # TODO: pass all roles once StatusRemovalDangerDialogComponent supports multi-role natively. + concat(hidden_field_tag("role_ids[]", @role.id)) @status_ids.each { |id| concat(hidden_field_tag("status_ids[]", id)) } end end diff --git a/app/controllers/workflows/tabs_controller.rb b/app/controllers/workflows/tabs_controller.rb index 94c9805c22a..6c476c890b5 100644 --- a/app/controllers/workflows/tabs_controller.rb +++ b/app/controllers/workflows/tabs_controller.rb @@ -38,11 +38,11 @@ class Workflows::TabsController < ApplicationController before_action :set_type before_action :set_tab before_action :set_eligible_roles - before_action :set_role + before_action :set_roles def edit unless turbo_frame_request? - redirect_to edit_workflow_path(@type, role_id: params[:role_id], tab: @tab) + redirect_to edit_workflow_path(@type, role_ids: params[:role_ids], tab: @tab) return end @@ -53,12 +53,19 @@ class Workflows::TabsController < ApplicationController end end - def update - call = Workflows::BulkUpdateService - .new(role: @role, type: @type, tab: @tab) - .call(permitted_status_params) + def update # rubocop:disable Metrics/AbcSize + success = false + Workflow.transaction do + success = true + @roles.each do |role| + result = Workflows::BulkUpdateService.new(role:, type: @type, tab: @tab) + .call(permitted_status_params) + success = false unless result.success? + end + raise ActiveRecord::Rollback unless success + end - if call.success? + if success render_flash_message_via_turbo_stream( message: I18n.t(:notice_successful_update), scheme: :success @@ -69,7 +76,7 @@ class Workflows::TabsController < ApplicationController update_via_turbo_stream( component: Workflows::StatusMatrixFormComponent.new( tab: @tab, - role: @role, + roles: @roles, type: @type, available_roles: @eligible_roles, statuses:, @@ -125,7 +132,7 @@ class Workflows::TabsController < ApplicationController update_via_turbo_stream( component: Workflows::StatusMatrixFormComponent.new( tab: @tab, - role: @role, + roles: @roles, type: @type, available_roles: @eligible_roles, statuses:, @@ -150,8 +157,11 @@ class Workflows::TabsController < ApplicationController @eligible_roles = Workflow.eligible_roles.order(:builtin, :position) end - def set_role - @role = @eligible_roles.find(params[:role_id]) + def set_roles + @roles = @eligible_roles.where(id: params[:role_ids]) + # TODO: remove @role once the matrix form and all dependent components + # (dialogs, status selectors, page headers) work natively with @roles (multi-role). + @role = @roles.first end def statuses_for_form diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb index 82b03b15118..fb40fead193 100644 --- a/app/controllers/workflows_controller.rb +++ b/app/controllers/workflows_controller.rb @@ -38,7 +38,7 @@ class WorkflowsController < ApplicationController before_action :find_types, only: %i[index] before_action :find_type, only: %i[edit] - before_action :find_optional_role, only: %i[edit] + before_action :find_optional_roles, only: %i[edit] def index; end @@ -64,8 +64,13 @@ class WorkflowsController < ApplicationController @type = ::Type.find(params[:type_id]) end - def find_optional_role - @role = eligible_roles.find_by(id: params[:role_id]) || eligible_roles.order(:builtin, :position).first + def find_optional_roles + ordered = eligible_roles.order(:builtin, :position) + @roles = ordered.where(id: params[:role_ids]) + @roles = [ordered.first] if @roles.empty? + # TODO: remove @role once the matrix form and all dependent components + # (dialogs, status selectors, page headers) work natively with @roles (multi-role). + @role = @roles.first end def eligible_roles diff --git a/app/helpers/workflow_helper.rb b/app/helpers/workflow_helper.rb index a56b291fce3..61c7007adb0 100644 --- a/app/helpers/workflow_helper.rb +++ b/app/helpers/workflow_helper.rb @@ -37,7 +37,7 @@ module WorkflowHelper ].map do |tab| tab.merge( partial: "workflows/form", - path: edit_workflow_tab_path(type, tab[:name], params.permit(:role_id)), + path: edit_workflow_tab_path(type, tab[:name], params.permit(role_ids: [])), data: { "admin--workflow-checkbox-state-confirmation-trigger": "click", turbo_frame: "workflow-table", turbo_action: "advance" } diff --git a/app/views/workflows/edit.html.erb b/app/views/workflows/edit.html.erb index 49db69fe7cc..26a2de62642 100644 --- a/app/views/workflows/edit.html.erb +++ b/app/views/workflows/edit.html.erb @@ -31,6 +31,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render Workflows::PageHeaders::EditComponent.new(@type, role: @role, tabs: workflow_tabs(@type)) %> <% end %> -<% if @type && @role %> - <%= turbo_frame_tag "workflow-table", src: edit_workflow_tab_path(@type, @current_tab, role_id: @role.id, status_ids: params[:status_ids]) %> +<% if @type && @roles.any? %> + <%= turbo_frame_tag "workflow-table", src: edit_workflow_tab_path(@type, @current_tab, role_ids: @roles.map(&:id), status_ids: params[:status_ids]) %> <% end %> diff --git a/app/views/workflows/summaries/show.html.erb b/app/views/workflows/summaries/show.html.erb index 034123f66d9..f5ed8bcff9e 100644 --- a/app/views/workflows/summaries/show.html.erb +++ b/app/views/workflows/summaries/show.html.erb @@ -61,7 +61,7 @@ See COPYRIGHT and LICENSE files for more details. <%= h type %> <% roles.each do |role, count| -%> - <%= link_to((count > 0 ? count : content_tag(:span, "", class: "icon-close icon-context icon-button")), edit_workflow_path(type, role_id: role), title: t(:button_edit)) %> + <%= link_to((count > 0 ? count : content_tag(:span, "", class: "icon-close icon-context icon-button")), edit_workflow_path(type, role_ids: [role]), title: t(:button_edit)) %> <% end -%> diff --git a/app/views/workflows/tabs/edit.html.erb b/app/views/workflows/tabs/edit.html.erb index f665ac5cd3c..61af4af9c6f 100644 --- a/app/views/workflows/tabs/edit.html.erb +++ b/app/views/workflows/tabs/edit.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= turbo_frame_tag "workflow-table", data: { turbo_cache: false } do %> - <%= render Workflows::StatusMatrixFormComponent.new(tab: @tab, role: @role, type: @type, available_roles: @eligible_roles, statuses: @statuses, has_status_changes: @has_status_changes) %> + <%= render Workflows::StatusMatrixFormComponent.new(tab: @tab, roles: @roles, type: @type, available_roles: @eligible_roles, statuses: @statuses, has_status_changes: @has_status_changes) %> <%= turbo_stream.replace(Workflows::PageHeaders::EditComponent.wrapper_key) do %> <%= render Workflows::PageHeaders::EditComponent.new(@type, role: @role, tabs: workflow_tabs(@type)) %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 797f428cbfe..dd1b14da815 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -461,8 +461,12 @@ en: ignore: "Ignore changes" save: "Save changes and continue" role_selector: + title: "Select roles" label: "Role: %{role}" no_role: "Select role" + roles: + one: "%{count} role selected" + other: "%{count} roles selected" blankslate: title: "No status transitions configured" description: "Add statuses to start configuring workflows for this role" diff --git a/frontend/src/stimulus/controllers/dynamic/admin/workflow-checkbox-state.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/workflow-checkbox-state.controller.ts index 3dd327642bf..a88031e47c5 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/workflow-checkbox-state.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/workflow-checkbox-state.controller.ts @@ -113,13 +113,21 @@ export default class WorkflowCheckboxStateController extends Controller(`input[name="${name}"]`)!.value; } + private formValues(name:string):string[] { + return Array.from( + this.element.querySelectorAll(`input[name="${name}"]`), + ).map((el) => el.value); + } + private pushState(key:string, state:CheckboxesState) { const savedState:SavedState = { formKey: this.formKey, checkboxes: state }; sessionStorage.setItem(key, JSON.stringify(savedState)); @@ -155,7 +163,7 @@ export default class WorkflowCheckboxStateController extends Controller { - const onIgnoreCallback = this.onIgnoreChanges(target, event); - this.ignoreButtonTarget.addEventListener('click', onIgnoreCallback); - - const onSaveCallback = this.onSaveChanges(target, event); - this.saveButtonTarget.addEventListener('click', onSaveCallback); - + private openConfirmationDialog(onIgnore:() => void, onSave:() => void) { + this.ignoreButtonTarget.addEventListener('click', onIgnore); + this.saveButtonTarget.addEventListener('click', onSave); this.confirmationDialogTarget.addEventListener('close', () => { - this.ignoreButtonTarget.removeEventListener('click', onIgnoreCallback); - this.saveButtonTarget.removeEventListener('click', onSaveCallback); + this.ignoreButtonTarget.removeEventListener('click', onIgnore); + this.saveButtonTarget.removeEventListener('click', onSave); }); - this.confirmationDialogTarget.showModal(); + } + + private confirmThenResubmit = (target:HTMLElement, event:Event) => { + this.openConfirmationDialog( + this.onIgnoreChanges(target, event), + this.onSaveChanges(target, event), + ); }; private onIgnoreChanges = (originalTarget:HTMLElement, originalEvent:Event) => { @@ -193,7 +203,7 @@ export default class WorkflowCheckboxStateController extends Controller params.append('role_ids[]', id)); url.search = params.toString(); turboFrame.setAttribute('src', url.toString()); @@ -273,4 +283,33 @@ export default class WorkflowCheckboxStateController extends Controller { + this.hasCheckboxChangesValue = false; + this.hasStatusChangesValue = false; + this.confirmationDialogTarget.close(); + setTimeout(() => { Turbo.visit(url); }, 0); + }, + () => { + this.element.requestSubmit(); + this.confirmationDialogTarget.close(); + // Delay to allow the flash message from the form submission to appear. + setTimeout(() => { Turbo.visit(url); }, 1000); + }, + ); + } } diff --git a/frontend/src/stimulus/controllers/dynamic/admin/workflow-role-select.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/workflow-role-select.controller.ts new file mode 100644 index 00000000000..11c66b3d383 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/workflow-role-select.controller.ts @@ -0,0 +1,78 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import type { SelectPanelElement } from '@primer/view-components/app/components/primer/alpha/select_panel_element'; +import WorkflowCheckboxStateController from './workflow-checkbox-state.controller'; + +/** + * Mounted on the element for role selection in the workflow quick filters. + * When the panel closes, it navigates to the workflow edit page with the selected role IDs. + * Delegates dirty-state confirmation to the workflow-checkbox-state controller via an outlet. + */ +export default class WorkflowRoleSelectController extends Controller { + static outlets = ['admin--workflow-checkbox-state']; + static values = { baseUrl: String, currentRoleIds: String }; + + declare readonly adminWorkflowCheckboxStateOutlet:WorkflowCheckboxStateController; + declare readonly hasAdminWorkflowCheckboxStateOutlet:boolean; + declare baseUrlValue:string; + declare currentRoleIdsValue:string; + + connect() { + this.element.addEventListener('panelClosed', this.handlePanelClosed); + } + + disconnect() { + this.element.removeEventListener('panelClosed', this.handlePanelClosed); + } + + private handlePanelClosed = () => { + const panel = this.element as HTMLElement as SelectPanelElement; + const selectedIds = panel.items + .filter((item) => panel.isItemChecked(item)) + .map((item) => item.getAttribute('data-item-id')) + .filter(Boolean); + + if (!selectedIds.length) return; + + const currentIds = this.currentRoleIdsValue.split(',').filter(Boolean); + if (selectedIds.slice().sort().join(',') === currentIds.slice().sort().join(',')) return; + + const url = new URL(this.baseUrlValue, window.location.origin); + selectedIds.forEach((id) => url.searchParams.append('role_ids[]', id!)); + + if (this.hasAdminWorkflowCheckboxStateOutlet) { + this.adminWorkflowCheckboxStateOutlet.navigateTo(url.toString()); + } else { + Turbo.visit(url.toString()); + } + }; +}