mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Replace ActionMenu with multi select SelectPanel for role selector
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+52
-13
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user