Replace ActionMenu with multi select SelectPanel for role selector

This commit is contained in:
Mir Bhatia
2026-04-23 11:17:07 +02:00
parent fb31d1da98
commit df5eca254e
14 changed files with 214 additions and 49 deletions
@@ -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|
@@ -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" }
@@ -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|
@@ -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
@@ -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
+21 -11
View File
@@ -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
+8 -3
View File
@@ -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
+1 -1
View File
@@ -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" }
+2 -2
View File
@@ -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 %>
+1 -1
View File
@@ -61,7 +61,7 @@ See COPYRIGHT and LICENSE files for more details.
<td><%= h type %></td>
<% roles.each do |role, count| -%>
<td>
<%= 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)) %>
</td>
<% end -%>
</tr>
+1 -1
View File
@@ -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 %>
+4
View File
@@ -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"
@@ -113,13 +113,21 @@ export default class WorkflowCheckboxStateController extends Controller<HTMLForm
};
private get formKey():string {
return `${this.formValue('type_id')}-${this.formValue('role_id')}`;
const typeId = this.formValue('type_id');
const roleIds = this.formValues('role_ids[]').sort().join(',');
return `${typeId}-${roleIds}`;
}
private formValue(name:string):string {
return this.element.querySelector<HTMLInputElement>(`input[name="${name}"]`)!.value;
}
private formValues(name:string):string[] {
return Array.from(
this.element.querySelectorAll<HTMLInputElement>(`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<HTMLForm
event.preventDefault();
event.stopImmediatePropagation();
this.showDialog(target, event);
this.confirmThenResubmit(target, event);
}
else {
// Reset confirmation status for next time
@@ -167,19 +175,21 @@ export default class WorkflowCheckboxStateController extends Controller<HTMLForm
}
};
private showDialog = (target:HTMLElement, event:Event) => {
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<HTMLForm
const url = new URL(src);
// Reload only with original params
const params = new URLSearchParams();
params.set('role_id', url.searchParams.get('role_id') ?? '');
url.searchParams.getAll('role_ids[]').forEach((id) => params.append('role_ids[]', id));
url.search = params.toString();
turboFrame.setAttribute('src', url.toString());
@@ -273,4 +283,33 @@ export default class WorkflowCheckboxStateController extends Controller<HTMLForm
cb.checked = checkboxes[key] ?? defaultValue ?? true;
});
}
//
// Trigger navigation with dirty-state confirmation.
//
navigateTo(url:string) {
if (this.isDirtyValue) {
this.confirmThenNavigate(url);
} else {
Turbo.visit(url);
}
}
private confirmThenNavigate(url:string) {
this.openConfirmationDialog(
() => {
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);
},
);
}
}
@@ -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 <select-panel> 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());
}
};
}