diff --git a/app/components/workflows/blankslate_component.html.erb b/app/components/workflows/blankslate_component.html.erb index da7a8b2ae1cb..48aedf4b9bc5 100644 --- a/app/components/workflows/blankslate_component.html.erb +++ b/app/components/workflows/blankslate_component.html.erb @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details. blankslate.with_heading(tag: :h2).with_content(t("admin.workflows.blankslate.title")) blankslate.with_description_content(t("admin.workflows.blankslate.description")) 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: @roles.map(&:id)), scheme: :secondary, data: { controller: "async-dialog" } ) do |button| diff --git a/app/components/workflows/blankslate_component.rb b/app/components/workflows/blankslate_component.rb index ed4c14ef797c..0f92b6ea2f4c 100644 --- a/app/components/workflows/blankslate_component.rb +++ b/app/components/workflows/blankslate_component.rb @@ -32,9 +32,9 @@ module Workflows class BlankslateComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(role:, type:, tab:) + def initialize(roles:, type:, tab:) super - @role = role + @roles = roles @type = type @tab = tab end diff --git a/app/components/workflows/page_headers/edit_component.rb b/app/components/workflows/page_headers/edit_component.rb index fac6786a992c..794dbed58e8e 100644 --- a/app/components/workflows/page_headers/edit_component.rb +++ b/app/components/workflows/page_headers/edit_component.rb @@ -30,7 +30,7 @@ module Workflows::PageHeaders class EditComponent < BaseComponent - options :tabs, :role + options :tabs, :roles def type = model @@ -49,7 +49,7 @@ def add_action_buttons(header) mobile_icon: :copy, mobile_label: t(:button_copy), size: :medium, - href: new_workflow_copy_path(type, source_role_id: role&.id), + href: new_workflow_copy_path(type, source_role_id: roles&.first&.id), aria: { label: helpers.t(:button_copy) }, title: helpers.t(:button_copy) ) do |button| diff --git a/app/components/workflows/status_dialog_component.html.erb b/app/components/workflows/status_dialog_component.html.erb index 3727681676f1..d9fe0c120653 100644 --- a/app/components/workflows/status_dialog_component.html.erb +++ b/app/components/workflows/status_dialog_component.html.erb @@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. Workflows::StatusFormComponent.new( all_statuses: @all_statuses, current_statuses: @current_statuses, - role: @role, + roles: @roles, type: @type, tab: @tab ) diff --git a/app/components/workflows/status_dialog_component.rb b/app/components/workflows/status_dialog_component.rb index 0f0ccde45601..2c724a580024 100644 --- a/app/components/workflows/status_dialog_component.rb +++ b/app/components/workflows/status_dialog_component.rb @@ -35,11 +35,11 @@ class StatusDialogComponent < ApplicationComponent DIALOG_ID = "workflows-status-dialog" - def initialize(all_statuses:, current_statuses:, role:, type:, tab:) + def initialize(all_statuses:, current_statuses:, roles:, type:, tab:) super @all_statuses = all_statuses @current_statuses = current_statuses - @role = role + @roles = roles @type = type @tab = tab end diff --git a/app/components/workflows/status_form_component.html.erb b/app/components/workflows/status_form_component.html.erb index d9bf8ef27b7c..c41534cb44f5 100644 --- a/app/components/workflows/status_form_component.html.erb +++ b/app/components/workflows/status_form_component.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= 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: @roles.map(&:id)), method: :post, id: FORM_ID, data: { turbo_frame: "workflow-table" } @@ -39,7 +39,6 @@ See COPYRIGHT and LICENSE files for more details. f, all_statuses: @all_statuses, current_statuses: @current_statuses, - role: @role, type: @type, tab: @tab, dialog_id: diff --git a/app/components/workflows/status_form_component.rb b/app/components/workflows/status_form_component.rb index 114351c64b86..8b99cfc125e6 100644 --- a/app/components/workflows/status_form_component.rb +++ b/app/components/workflows/status_form_component.rb @@ -32,11 +32,11 @@ module Workflows class StatusFormComponent < ApplicationComponent FORM_ID = "status-selection-form" - def initialize(all_statuses:, current_statuses:, role:, type:, tab:) + def initialize(all_statuses:, current_statuses:, roles:, type:, tab:) super @all_statuses = all_statuses @current_statuses = current_statuses - @role = role + @roles = roles @type = type @tab = tab end diff --git a/app/components/workflows/status_matrix_form_component.html.erb b/app/components/workflows/status_matrix_form_component.html.erb index 93522276a2fb..68ca0b219a19 100644 --- a/app/components/workflows/status_matrix_form_component.html.erb +++ b/app/components/workflows/status_matrix_form_component.html.erb @@ -32,21 +32,39 @@ 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 @roles.one? + t("admin.workflows.role_selector.label", role: @roles.first.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 + panel.with_footer(show_divider: true) do + render( + Primer::Beta::Button.new( + scheme: :primary, + data: { action: "click->admin--workflow-role-select#apply" } + ) + ) { t(:button_save) } + end end end end @@ -56,7 +74,7 @@ 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), + 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") @@ -67,7 +85,7 @@ See COPYRIGHT and LICENSE files for more details. <% if @statuses.any? %> <%= form_tag( workflow_tab_path(@type), - id: "workflow_form", + id: form_id, method: :patch, autocomplete: "off", data: { @@ -76,7 +94,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 +114,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| @@ -118,6 +138,6 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> <% else %> - <%= render Workflows::BlankslateComponent.new(role: @role, type: @type, tab: @tab) %> + <%= render Workflows::BlankslateComponent.new(roles: @roles, type: @type, tab: @tab) %> <% end %> <% end %> diff --git a/app/components/workflows/status_matrix_form_component.rb b/app/components/workflows/status_matrix_form_component.rb index 14c3ade5fdbe..5af6570d32ff 100644 --- a/app/components/workflows/status_matrix_form_component.rb +++ b/app/components/workflows/status_matrix_form_component.rb @@ -33,14 +33,29 @@ class StatusMatrixFormComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(tab:, role:, type:, available_roles:, statuses:, has_status_changes:) + FORM_ID = "workflow_form" + + def initialize(tab:, roles:, type:, available_roles:, statuses:, has_status_changes:) super @tab = tab - @role = role + @roles = roles @type = type @available_roles = available_roles @statuses = statuses @has_status_changes = has_status_changes end + + private + + def form_id = FORM_ID + + def data_attributes + { + controller: "admin--workflow-role-select", + "admin--workflow-role-select-base-url-value": helpers.edit_workflow_tab_path(@type, @tab), + "admin--workflow-role-select-current-role-ids-value": @roles.map(&:id), + "admin--workflow-role-select-admin--workflow-checkbox-state-outlet": "##{form_id}" + } + 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 c2b020ff5b72..9846295eb11e 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,7 @@ 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)) + @roles.each { |role| 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/components/workflows/status_removal_danger_dialog_component.rb b/app/components/workflows/status_removal_danger_dialog_component.rb index 1753687632a2..d3f926f24c36 100644 --- a/app/components/workflows/status_removal_danger_dialog_component.rb +++ b/app/components/workflows/status_removal_danger_dialog_component.rb @@ -35,9 +35,9 @@ class StatusRemovalDangerDialogComponent < ApplicationComponent DIALOG_ID = "workflows-status-removal-dialog" - def initialize(role:, type:, tab:, status_ids:, removed_count:) + def initialize(roles:, type:, tab:, status_ids:, removed_count:) super - @role = role + @roles = roles @type = type @tab = tab @status_ids = Array(status_ids).flatten.map(&:to_i) diff --git a/app/controllers/workflows/tabs_controller.rb b/app/controllers/workflows/tabs_controller.rb index 94c9805c22a8..a6c2c1757870 100644 --- a/app/controllers/workflows/tabs_controller.rb +++ b/app/controllers/workflows/tabs_controller.rb @@ -38,27 +38,37 @@ 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 statuses_for_form - if @type && @role && @statuses.any? + if @type && @roles.any? && @statuses.any? workflows_for_form 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 + base_params = permitted_status_params + indeterminate = permitted_indeterminate_params + @roles.each do |role| + role_params = indeterminate.empty? ? base_params : role_specific_params(base_params, indeterminate, role) + result = Workflows::BulkUpdateService.new(role:, type: @type, tab: @tab) + .call(role_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 +79,7 @@ def update update_via_turbo_stream( component: Workflows::StatusMatrixFormComponent.new( tab: @tab, - role: @role, + roles: @roles, type: @type, available_roles: @eligible_roles, statuses:, @@ -92,8 +102,8 @@ def status_dialog all_statuses = Status.order(:position) current_statuses = if params[:status_ids].present? Status.where(id: params[:status_ids].map(&:to_i)).order(:position) - elsif @type && @role - statuses_for_role_and_type + elsif @type && @roles.any? + statuses_for_roles_and_type else Status.none end @@ -101,7 +111,7 @@ def status_dialog respond_with_dialog Workflows::StatusDialogComponent.new( all_statuses:, current_statuses:, - role: @role, + roles: @roles, type: @type, tab: @tab ) @@ -114,7 +124,7 @@ def confirm_statuses # rubocop:disable Metrics/AbcSize if removed_count > 0 respond_with_dialog Workflows::StatusRemovalDangerDialogComponent.new( - role: @role, + roles: @roles, type: @type, tab: @tab, status_ids: current_status_ids, @@ -125,7 +135,7 @@ def confirm_statuses # rubocop:disable Metrics/AbcSize update_via_turbo_stream( component: Workflows::StatusMatrixFormComponent.new( tab: @tab, - role: @role, + roles: @roles, type: @type, available_roles: @eligible_roles, statuses:, @@ -150,8 +160,9 @@ def set_eligible_roles @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]) + @roles = [@eligible_roles.first] if @roles.empty? end def statuses_for_form @@ -159,8 +170,8 @@ def statuses_for_form @has_status_changes = false @statuses = if @type && params[:status_ids].present? statuses_from_params - elsif @type && @role - statuses_for_role_and_type + elsif @type && @roles.any? + statuses_for_roles_and_type elsif @type @type.statuses else @@ -170,18 +181,19 @@ def statuses_for_form def statuses_from_params status_ids = params[:status_ids].map(&:to_i) - saved_ids = statuses_for_role_and_type.pluck(:id) + saved_ids = statuses_for_roles_and_type.pluck(:id) @added_status_ids = status_ids - saved_ids @has_status_changes = @added_status_ids.any? || (saved_ids - status_ids).any? Status.where(id: status_ids).order(:position) end - def statuses_for_role_and_type - @type.statuses(role: @role, tab: @tab) + def statuses_for_roles_and_type + status_ids = @roles.map { |role| @type.statuses(role:, tab: @tab).pluck(:id) }.flatten.uniq + Status.where(id: status_ids) end def workflows_for_form - workflows = Workflow.where(role_id: @role.id, type_id: @type.id) + workflows = Workflow.where(role_id: @roles.map(&:id), type_id: @type.id) @workflows = {} @workflows["always"] = workflows.select { |w| !w.author && !w.assignee } @workflows["author"] = workflows.select(&:author) @@ -189,10 +201,40 @@ def workflows_for_form end def permitted_status_params - return {} if params["status"].blank? + status_params("status") + end + + def permitted_indeterminate_params + status_params("indeterminate_status") + end + + def status_params(key) + return {} if params[key].blank? - params["status"] + params[key] .to_unsafe_h - .select { |key, value| /\A\d+\z/.match?(key) && value.keys.all? { /\A\d+\z/.match?(it) } } + .select { |k, value| /\A\d+\z/.match?(k) && value.keys.all? { /\A\d+\z/.match?(it) } } + end + + def role_specific_params(base_params, indeterminate, role) + params = base_params.deep_dup + indeterminate.each do |old_id, new_ids| + new_ids.each_key do |new_id| + # Restore from DB so that it isn't overwritten by indeterminate state (unchecked) + had_transition = Workflow.exists?( + role_id: role.id, + type_id: @type.id, + old_status_id: old_id.to_i, + new_status_id: new_id.to_i, + author: @tab == "author", + assignee: @tab == "assignee" + ) + if had_transition + params[old_id] ||= {} + params[old_id][new_id] = "1" + end + end + end + params end end diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb index 82b03b151186..3de82fd8d54e 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 @@ -56,16 +56,14 @@ def find_types @types = ::Type.order(:position) end - def find_role - @role = eligible_roles.find(params[:role_id]) - end - def find_type @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? end def eligible_roles diff --git a/app/forms/workflows/status_select_form.rb b/app/forms/workflows/status_select_form.rb index cb6c4cf106f7..f650969ea3e4 100644 --- a/app/forms/workflows/status_select_form.rb +++ b/app/forms/workflows/status_select_form.rb @@ -30,18 +30,16 @@ module Workflows class StatusSelectForm < ApplicationForm - def initialize(all_statuses:, current_statuses:, role:, type:, tab:, dialog_id:) + def initialize(all_statuses:, current_statuses:, type:, tab:, dialog_id:) super() @all_statuses = all_statuses @current_statuses = current_statuses - @role = role @type = type @tab = tab @dialog_id = dialog_id end form do |f| - f.hidden(name: :role_id, value: @role.id) f.hidden(name: :type_id, value: @type.id) f.hidden(name: :tab, value: @tab || "always") @current_statuses.each { |status| f.hidden(name: "original_status_ids[]", value: status.id) } diff --git a/app/helpers/workflow_helper.rb b/app/helpers/workflow_helper.rb index a56b291fce3d..61c7007adb09 100644 --- a/app/helpers/workflow_helper.rb +++ b/app/helpers/workflow_helper.rb @@ -37,7 +37,7 @@ def workflow_tabs(type) ].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/_form.html.erb b/app/views/workflows/_form.html.erb index 95e3a7469a40..11cbe41d9449 100644 --- a/app/views/workflows/_form.html.erb +++ b/app/views/workflows/_form.html.erb @@ -187,9 +187,11 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% @statuses.each do |new_status| -%> - <% transition_saved = workflows.any? { it.old_status_id == old_status.id && it.new_status_id == new_status.id } - newly_added_status = @added_status_ids.include?(old_status.id) || @added_status_ids.include?(new_status.id) %> + <% transition_role_ids = workflows.select { it.old_status_id == old_status.id && it.new_status_id == new_status.id }.map(&:role_id).uniq + newly_added_status = @added_status_ids.include?(old_status.id) || @added_status_ids.include?(new_status.id) + some_roles = !transition_role_ids.empty? && transition_role_ids.size < @roles.size && !newly_added_status %> + <%= hidden_field_tag "indeterminate_status[#{old_status.id}][#{new_status.id}]", "1" if some_roles %> <%= render(Primer::BaseComponent.new(tag: :div, display: :flex, align_items: :center, mx: 1)) do render( @@ -198,13 +200,14 @@ See COPYRIGHT and LICENSE files for more details. name: "status[#{old_status.id}][#{new_status.id}]", id: "status_#{old_status.id}_#{new_status.id}", # See BUG https://github.com/primer/view_components/issues/3811 value: name, - checked: transition_saved || newly_added_status, + checked: !some_roles && (transition_role_ids.any? || newly_added_status), label: t(".matrix_checkbox_label", old_status: old_status.name, new_status: new_status.name), visually_hide_label: true, data: { checkable_target: "checkbox", old_status: old_status.id, - new_status: new_status.id + new_status: new_status.id, + indeterminate: (true if some_roles) } ) ) diff --git a/app/views/workflows/edit.html.erb b/app/views/workflows/edit.html.erb index 49db69fe7cce..b7bdacd0a47e 100644 --- a/app/views/workflows/edit.html.erb +++ b/app/views/workflows/edit.html.erb @@ -28,9 +28,9 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_administration), t(:label_workflow_plural), @type.name -%> <% content_for :content_header do %> - <%= render Workflows::PageHeaders::EditComponent.new(@type, role: @role, tabs: workflow_tabs(@type)) %> + <%= render Workflows::PageHeaders::EditComponent.new(@type, roles: @roles, 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 034123f66d9f..f5ed8bcff9e4 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 f665ac5cd3ca..cc5624dbda93 100644 --- a/app/views/workflows/tabs/edit.html.erb +++ b/app/views/workflows/tabs/edit.html.erb @@ -28,8 +28,8 @@ 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)) %> + <%= render Workflows::PageHeaders::EditComponent.new(@type, roles: @roles, tabs: workflow_tabs(@type)) %> <% end %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 797f428cbfee..dd1b14da815a 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/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index a34390c13e36..f9f3869f6c03 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -208,3 +208,13 @@ ul.SegmentedControl, & .Banner-actions margin: var(--base-size-8) 0 0 var(--base-size-8) + +// Bug in @openproject/primer-view-components: .FormControl-checkbox:indeterminate +// has no background rule, and :indeterminate:before doesn't cancel checkmarkOut. +.FormControl-checkbox:indeterminate + background: var(--control-checked-bgColor-rest, var(--color-accent-fg)) + border-color: var(--control-checked-borderColor-rest, var(--color-accent-fg)) + + &:before + animation: none + clip-path: inset(0) 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 3dd327642bf2..e40218e878a9 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 @@ -82,7 +82,10 @@ 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 +166,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 +206,7 @@ export default class WorkflowCheckboxStateController extends Controller params.append('role_ids[]', id)); url.search = params.toString(); turboFrame.setAttribute('src', url.toString()); @@ -251,12 +264,24 @@ export default class WorkflowCheckboxStateController extends Controller { + private onCheckboxChange = (event:Event) => { + this.removeIndeterminateMarker(event.target as HTMLInputElement); + this.recomputeDirtyFlag(); + }; + + private recomputeDirtyFlag() { const current = this.captureState(); const hasChanges = Object.keys(current).some((key) => current[key] !== this.initialCheckboxState[key]); this.hasCheckboxChangesValue = hasChanges; - }; + } + + private removeIndeterminateMarker(checkbox:HTMLInputElement):void { + const { oldStatus, newStatus } = checkbox.dataset; + this.element.querySelector( + `input[name="indeterminate_status[${oldStatus}][${newStatus}]"]`, + )?.remove(); + } private captureState():Record { const checkboxes:Record = {}; @@ -273,4 +298,52 @@ export default class WorkflowCheckboxStateController extends Controller('input[type="checkbox"][data-indeterminate="true"]').forEach((cb) => { + cb.indeterminate = true; + }); + } + + // + // Trigger navigation with dirty-state confirmation. + // + + navigateTo(url:string) { + if (this.isDirtyValue) { + this.confirmThenNavigate(url); + } else { + this.frameNavigateTo(url); + } + } + + private confirmThenNavigate(url:string) { + this.openConfirmationDialog( + () => { + this.hasCheckboxChangesValue = false; + this.hasStatusChangesValue = false; + this.confirmationDialogTarget.close(); + setTimeout(() => { this.frameNavigateTo(url); }, 0); + }, + () => { + this.element.requestSubmit(); + this.confirmationDialogTarget.close(); + // Delay to allow the flash message from the form submission to appear. + setTimeout(() => { this.frameNavigateTo(url); }, 1000); + }, + ); + } + + // This keeps the url in the /tabs/:tab/edit format consistently, + // rather than doing a Turbo.visit which changes the format. + // It also keeps history usable, similar to data-turbo-action="advance". + private frameNavigateTo(url:string) { + const turboFrame = this.element.closest('turbo-frame') as HTMLElement | null; + if (turboFrame) { + turboFrame.setAttribute('src', url); + history.pushState({}, '', url); + } else { + Turbo.visit(url); + } + } } 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 000000000000..d81f1ff94b1b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/workflow-role-select.controller.ts @@ -0,0 +1,79 @@ +/* + * -- 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'; + +/** + * 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: Array }; + + declare readonly adminWorkflowCheckboxStateOutlet:WorkflowCheckboxStateController; + declare readonly hasAdminWorkflowCheckboxStateOutlet:boolean; + declare baseUrlValue:string; + declare currentRoleIdsValue:unknown[]; + + apply() { + 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); + + // For when all roles are deselected + if (!selectedIds.length) { + this.navigateTo(this.buildUrl([])); + return; + } + + if (selectedIds.slice().sort().join(',') === this.currentRoleIdsValue.slice().sort().join(',')) return; + + this.navigateTo(this.buildUrl(selectedIds as string[])); + } + + private buildUrl(roleIds:string[]):string { + const url = new URL(this.baseUrlValue, window.location.origin); + roleIds.forEach((id) => url.searchParams.append('role_ids[]', id)); + return url.toString(); + } + + private navigateTo(url:string) { + if (this.hasAdminWorkflowCheckboxStateOutlet) { + this.adminWorkflowCheckboxStateOutlet.navigateTo(url); + } else { + Turbo.visit(url); + } + } +} diff --git a/spec/controllers/workflows/tabs_controller_spec.rb b/spec/controllers/workflows/tabs_controller_spec.rb index c870c3bd936d..483c1a44baed 100644 --- a/spec/controllers/workflows/tabs_controller_spec.rb +++ b/spec/controllers/workflows/tabs_controller_spec.rb @@ -48,6 +48,11 @@ .with(role.id.to_s) .and_return(role) + allow(role_scope) + .to receive(:where) + .with(id: [role.id.to_s]) + .and_return([role]) + role_scope end @@ -68,32 +73,58 @@ describe "#edit" do context "when not a turbo frame request" do - it "redirects to the parent workflow edit path" do - get :edit, - params: { - role_id: role.id.to_s, - workflow_type_id: type.id.to_s, - tab: "always" - } - - expect(response).to redirect_to( - edit_workflow_path(type, role_id: role.id.to_s, tab: "always") - ) + context "with a single role" do + it "redirects to the parent workflow edit path" do + get :edit, + params: { + role_ids: [role.id.to_s], + workflow_type_id: type.id.to_s, + tab: "always" + } + + expect(response).to redirect_to( + edit_workflow_path(type, role_ids: [role.id.to_s], tab: "always") + ) + end + + it "does not forward status_ids to the redirect" do + get :edit, + params: { + role_ids: [role.id.to_s], + workflow_type_id: type.id.to_s, + tab: "always", + status_ids: ["1", "2"] + } + + expect(response).to redirect_to( + edit_workflow_path(type, role_ids: [role.id.to_s], tab: "always") + ) + expect(response.location).not_to include("status_ids") + end end - it "does not forward status_ids to the redirect" do - get :edit, - params: { - role_id: role.id.to_s, - workflow_type_id: type.id.to_s, - tab: "always", - status_ids: ["1", "2"] - } - - expect(response).to redirect_to( - edit_workflow_path(type, role_id: role.id.to_s, tab: "always") - ) - expect(response.location).not_to include("status_ids") + context "with multiple roles" do + let(:role2) { build_stubbed(:project_role) } + + before do + allow(role_scope) + .to receive(:where) + .with(id: [role.id.to_s, role2.id.to_s]) + .and_return([role, role2]) + end + + it "redirects preserving all role ids" do + get :edit, + params: { + role_ids: [role.id.to_s, role2.id.to_s], + workflow_type_id: type.id.to_s, + tab: "always" + } + + expect(response).to redirect_to( + edit_workflow_path(type, role_ids: [role.id.to_s, role2.id.to_s], tab: "always") + ) + end end end end @@ -114,7 +145,7 @@ post :confirm_statuses, params: { - role_id: role.id.to_s, + role_ids: [role.id.to_s], workflow_type_id: type.id.to_s, status_ids: ["1", "2"], original_status_ids: ["1", "2"], @@ -133,7 +164,7 @@ before do post :confirm_statuses, params: { - role_id: role.id.to_s, + role_ids: [role.id.to_s], workflow_type_id: type.id.to_s, status_ids: ["1"], original_status_ids: ["1", "2"], @@ -152,33 +183,74 @@ describe "#update" do let(:status_params) { { "1" => { "2" => ["always"] } } } - let(:service) do - instance_double(Workflows::BulkUpdateService).tap do |dbl| - allow(Workflows::BulkUpdateService) - .to receive(:new) - .with(role: role, type: type, tab: "always") - .and_return(dbl) - end - end let(:call_result) { ServiceResult.success } - let(:params) do - { - role_id: role.id, - workflow_type_id: type.id, - tab: "always", - status: status_params - } - end - before do - allow(service).to receive(:call).with(status_params).and_return(call_result) - allow(controller).to receive(:statuses_for_form).and_return([build_stubbed(:status)]) - post :update, params:, format: :turbo_stream + context "with a single role" do + let(:service) do + instance_double(Workflows::BulkUpdateService).tap do |dbl| + allow(Workflows::BulkUpdateService) + .to receive(:new) + .with(role: role, type: type, tab: "always") + .and_return(dbl) + end + end + + before do + allow(service).to receive(:call).with(status_params).and_return(call_result) + allow(controller).to receive(:statuses_for_form).and_return([build_stubbed(:status)]) + post :update, + params: { role_ids: [role.id.to_s], workflow_type_id: type.id, tab: "always", status: status_params }, + format: :turbo_stream + end + + it "calls the service and renders a flash turbo stream" do + expect(service).to have_received(:call).with(status_params) + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end end - it "renders a flash turbo stream" do - expect(service).to have_received(:call).with(status_params) - expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + context "with multiple roles" do + let(:role2) { build_stubbed(:project_role) } + let(:service1) do + instance_double(Workflows::BulkUpdateService).tap do |dbl| + allow(Workflows::BulkUpdateService) + .to receive(:new) + .with(role: role, type: type, tab: "always") + .and_return(dbl) + end + end + let(:service2) do + instance_double(Workflows::BulkUpdateService).tap do |dbl| + allow(Workflows::BulkUpdateService) + .to receive(:new) + .with(role: role2, type: type, tab: "always") + .and_return(dbl) + end + end + + before do + allow(role_scope) + .to receive(:where) + .with(id: [role.id.to_s, role2.id.to_s]) + .and_return([role, role2]) + allow(service1).to receive(:call).with(status_params).and_return(call_result) + allow(service2).to receive(:call).with(status_params).and_return(call_result) + allow(controller).to receive(:statuses_for_form).and_return([build_stubbed(:status)]) + post :update, + params: { + role_ids: [role.id.to_s, role2.id.to_s], + workflow_type_id: type.id, + tab: "always", + status: status_params + }, + format: :turbo_stream + end + + it "calls the service for each role and renders a flash turbo stream" do + expect(service1).to have_received(:call).with(status_params) + expect(service2).to have_received(:call).with(status_params) + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + end end end end diff --git a/spec/controllers/workflows_controller_spec.rb b/spec/controllers/workflows_controller_spec.rb index 1bfd390f410b..09696f87db46 100644 --- a/spec/controllers/workflows_controller_spec.rb +++ b/spec/controllers/workflows_controller_spec.rb @@ -40,7 +40,7 @@ .and_return(role_scope) allow(role_scope) - .to receive_messages(order: role_scope, find_by: nil) + .to receive_messages(order: role_scope, find_by: nil, first: role) allow(role_scope) .to receive(:find) @@ -52,6 +52,11 @@ .with(id: role.id.to_s) .and_return(role) + allow(role_scope) + .to receive(:where) + .with(id: nil) + .and_return([]) + role_scope end @@ -90,10 +95,6 @@ allow(Status) .to receive(:all) .and_return [type_status, non_type_status] - - allow(role_scope) - .to receive(:order) - .and_return([role]) end context "without parameters" do @@ -111,9 +112,8 @@ .to render_template :edit end - it "assigns the first role" do - expect(assigns[:role]) - .to eq role + it "assigns @roles as the canonical collection" do + expect(assigns[:roles]).to contain_exactly(role) end it "does assign type" do @@ -122,40 +122,59 @@ end end - context "with role and type params" do + context "with a single role param" do before do - get :edit, params: { role_id: role.id.to_s, type_id: type.id.to_s } + allow(role_scope) + .to receive(:where) + .with(id: [role.id.to_s]) + .and_return([role]) + + get :edit, params: { role_ids: [role.id.to_s], type_id: type.id.to_s } end - it "responds with the role type statuses", :aggregate_failures do - expect(response) - .to have_http_status(:ok) - expect(response) - .to render_template :edit - expect(assigns[:role]) - .to eq role - expect(assigns[:type]) - .to eq type + it "is successful" do + expect(response).to have_http_status(:ok) + end + + it "renders the edit template" do + expect(response).to render_template :edit + end + + it "assigns the selected role" do + expect(assigns[:roles]).to contain_exactly(role) + end + + it "assigns type" do + expect(assigns[:type]).to eq type + end + end + + context "with multiple role params" do + let(:role2) { build_stubbed(:project_role) } + + before do + allow(role_scope) + .to receive(:where) + .with(id: [role.id.to_s, role2.id.to_s]) + .and_return([role, role2]) + + get :edit, params: { role_ids: [role.id.to_s, role2.id.to_s], type_id: type.id.to_s } end it "is successful" do - expect(response) - .to have_http_status(:ok) + expect(response).to have_http_status(:ok) end it "renders the edit template" do - expect(response) - .to render_template :edit + expect(response).to render_template :edit end - it "assigns role" do - expect(assigns[:role]) - .to eq role + it "assigns all selected roles" do + expect(assigns[:roles]).to contain_exactly(role, role2) end - it "assign type" do - expect(assigns[:type]) - .to eq type + it "assigns type" do + expect(assigns[:type]).to eq type end end end diff --git a/spec/features/roles/create_spec.rb b/spec/features/roles/create_spec.rb index fab54f84acc4..f2a09d7cc31b 100644 --- a/spec/features/roles/create_spec.rb +++ b/spec/features/roles/create_spec.rb @@ -111,8 +111,11 @@ click_link type.name end + new_role = Role.find_by!(name: "New role name") click_button existing_role.name - click_link "New role name" + find("[data-item-id='#{new_role.id}']").click + find("[data-item-id='#{existing_role.id}']").click + page.send_keys :escape old_status = existing_workflow.old_status.name new_status = existing_workflow.new_status.name diff --git a/spec/features/workflows/edit_multi_role_spec.rb b/spec/features/workflows/edit_multi_role_spec.rb new file mode 100644 index 000000000000..19f7871aa820 --- /dev/null +++ b/spec/features/workflows/edit_multi_role_spec.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +# -- 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. +# ++ + +require "spec_helper" + +RSpec.describe "Workflow edit with multiple roles", :js do + include Toasts::Expectations + include Workflows::EditHelpers + + let(:role) { create(:project_role) } + let(:role2) { create(:project_role) } + let(:type) { create(:type) } + let(:admin) { create(:admin) } + let(:statuses) { (1..3).map { create(:status) } } + + # workflow for 0 -> 1 for 'role' + let!(:role_workflow) do + create(:workflow, role_id: role.id, type_id: type.id, + old_status_id: statuses[0].id, new_status_id: statuses[1].id, + author: false, assignee: false) + end + + current_user { admin } + + context "when displaying checkboxes" do + context "when all selected roles have a transition" do + let!(:role2_workflow) do + create(:workflow, role_id: role2.id, type_id: type.id, + old_status_id: statuses[0].id, new_status_id: statuses[1].id, + author: false, assignee: false) + end + + before { visit_workflow_edit(roles: [role, role2]) } + + it "shows the checkbox as checked" do + expect(page).to have_field workflow_checkbox(0, 1), checked: true + expect(indeterminate?(workflow_checkbox(0, 1))).to be false + end + end + + context "when no selected roles have a transition" do + before { visit_workflow_edit(roles: [role, role2]) } + + it "shows the checkbox as unchecked" do + expect(page).to have_field workflow_checkbox(1, 0), checked: false + expect(indeterminate?(workflow_checkbox(1, 0))).to be false + end + end + + context "when only some selected roles have a transition" do + before { visit_workflow_edit(roles: [role, role2]) } + + it "shows the checkbox as indeterminate" do + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + end + + it "the checkbox is visible as indeterminate" do + expect(page).to have_field workflow_checkbox(0, 1), checked: false + + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + expect(indeterminate_visible?(workflow_checkbox(0, 1))).to be true + end + end + + context "when roles have different statuses in their workflows" do + let!(:role2_workflow) do + create(:workflow, role_id: role2.id, type_id: type.id, + old_status_id: statuses[1].id, new_status_id: statuses[2].id, + author: false, assignee: false) + end + + before { visit_workflow_edit(roles: [role, role2]) } + + it "shows the union of statuses from all selected roles" do + expect(page).to have_field workflow_checkbox(0, 2) + expect(page).to have_field workflow_checkbox(1, 2) + end + + it "pre-selects the union of statuses from all selected roles in the status dialog" do + within "#workflow-table" do + click_link "Status" + end + + expect(page).to have_dialog("Statuses") + within_dialog "Statuses" do + expect(page).to have_css(".ng-value-label", text: statuses[0].name) + expect(page).to have_css(".ng-value-label", text: statuses[1].name) + expect(page).to have_css(".ng-value-label", text: statuses[2].name) + end + end + end + + context "with a single role selected" do + before { visit_workflow_edit(roles: [role]) } + + it "does not show indeterminate checkboxes" do + expect(page).to have_field workflow_checkbox(0, 1), checked: true + expect(indeterminate?(workflow_checkbox(0, 1))).to be false + end + end + end + + context "when saving" do + before { visit_workflow_edit(roles: [role, role2]) } + + it "adds the transition for all roles when checking an unchecked checkbox" do + expect_transition(role, 1, 0, exist: false) + expect_transition(role2, 1, 0, exist: false) + + check workflow_checkbox(1, 0) + click_button "Save" + expect_flash(message: "Successful update.") + + expect_transition(role, 1, 0, exist: true) + expect_transition(role2, 1, 0, exist: true) + end + + it "preserves state for each role when saving an untouched indeterminate checkbox" do + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: false) + + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + + click_button "Save" + expect_flash(message: "Successful update.") + + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: false) + + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + end + + it "adds the transition for all roles when checking an indeterminate checkbox" do + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: false) + + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + + check workflow_checkbox(0, 1) + click_button "Save" + expect_flash(message: "Successful update.") + + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: true) + + expect(indeterminate?(workflow_checkbox(0, 1))).to be false + end + + it "removes the transition from all roles when unchecking an indeterminate checkbox" do + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: false) + + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + + check workflow_checkbox(0, 1) + uncheck workflow_checkbox(0, 1) + click_button "Save" + expect_flash(message: "Successful update.") + + expect_transition(role, 0, 1, exist: false) + expect_transition(role2, 0, 1, exist: false) + + expect(indeterminate?(workflow_checkbox(0, 1))).to be false + end + + context "when all roles have the transition" do + let!(:role2_workflow) do + create(:workflow, role_id: role2.id, type_id: type.id, + old_status_id: statuses[0].id, new_status_id: statuses[1].id, + author: false, assignee: false) + end + + before { visit_workflow_edit(roles: [role, role2]) } + + it "removes the transition from all roles when unchecking a fully checked checkbox" do + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: true) + + uncheck workflow_checkbox(0, 1) + click_button "Save" + expect_flash(message: "Successful update.") + + expect_transition(role, 0, 1, exist: false) + expect_transition(role2, 0, 1, exist: false) + end + end + + context "with multiple indeterminate checkboxes" do + let!(:role_workflow2) do + create(:workflow, role_id: role.id, type_id: type.id, + old_status_id: statuses[0].id, new_status_id: statuses[2].id, + author: false, assignee: false) + end + + before { visit_workflow_edit(roles: [role, role2]) } + + it "handles touched and untouched indeterminate checkboxes independently" do + # Both 0 -> 1 and 0 -> 2 are indeterminate + expect_transition(role, 0, 1, exist: true) + expect_transition(role2, 0, 1, exist: false) + expect_transition(role, 0, 2, exist: true) + expect_transition(role2, 0, 2, exist: false) + + check workflow_checkbox(0, 1) # explicitly check for all roles + + click_button "Save" + expect_flash(message: "Successful update.") + + # role2 now has this workflow + expect_transition(role2, 0, 1, exist: true) + + # 0 -> 2 stays indeterminate + expect_transition(role, 0, 2, exist: true) + expect_transition(role2, 0, 2, exist: false) + end + end + + it "marks the form dirty when interacting with an indeterminate checkbox" do + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + + check workflow_checkbox(0, 1) + + click_link "User is author" + expect(page).to have_dialog("Save changes before continuing?") + end + + it "succeeds when saving with no changes to indeterminate checkboxes" do + expect(page).to have_field workflow_checkbox(0, 1), checked: false + expect(indeterminate?(workflow_checkbox(0, 1))).to be true + + click_button "Save" + expect_flash(message: "Successful update.") + end + end + + context "when deselecting all roles in the select panel" do + before { visit_workflow_edit(roles: [role, role2]) } + + it "falls back to the first eligible role instead of leaving the page stuck" do + click_button "2 roles selected" + find("[data-item-id='#{role.id}']").click + find("[data-item-id='#{role2.id}']").click + within("select-panel") { click_button "Save" } + + expect(page).to have_no_text("2 roles selected") + expect(page).to have_button(role.name) + end + end + + context "when modifying statuses" do + before { visit_workflow_edit(roles: [role, role2]) } + + it "preserves all selected roles after adding a status" do + add_status_via_dialog(statuses[2]) + + expect(page).to have_text("2 roles selected") + end + + it "preserves all selected roles after removing a status via the danger dialog" do + remove_status_via_dialog(statuses[1]) + + within_dialog("Remove statuses") { click_button "Remove" } + + expect(page).to have_text("2 roles selected") + end + + it "checks all new status checkboxes as fully checked, not indeterminate, when adding a status" do + add_status_via_dialog(statuses[2]) + + [workflow_checkbox(2, 0), workflow_checkbox(0, 2), workflow_checkbox(2, 1), + workflow_checkbox(1, 2), workflow_checkbox(2, 2)].each do |cb| + expect(page).to have_field cb, checked: true + expect(indeterminate?(cb)).to be false + end + end + + context "when no statuses are configured" do + let(:empty_role1) { create(:project_role) } + let(:empty_role2) { create(:project_role) } + + before { visit_workflow_edit(roles: [empty_role1, empty_role2]) } + + it "shows a blankslate" do + expect(page).to have_text("No status transitions configured") + end + + it "preserves all selected roles when adding statuses from the blankslate" do + within "#workflow-table" do + all(:link, "Status").last.click + end + within_dialog "Statuses" do + find(".ng-arrow-wrapper").click + find(".ng-option", text: statuses[0].name).click + click_button "Apply" + end + + expect(page).to have_text("2 roles selected") + end + end + end +end diff --git a/spec/features/workflows/edit_spec.rb b/spec/features/workflows/edit_spec.rb index e214e19b4693..1845346ce6ff 100644 --- a/spec/features/workflows/edit_spec.rb +++ b/spec/features/workflows/edit_spec.rb @@ -32,6 +32,7 @@ RSpec.describe "Workflow edit", :js do include Toasts::Expectations + include Workflows::EditHelpers let(:role) { create(:project_role) } let(:type) { create(:type) } @@ -48,44 +49,12 @@ current_user { admin } - def workflow_checkbox(from_index, to_index) - "status_#{statuses[from_index].id}_#{statuses[to_index].id}" - end - - def visit_workflow_edit(role: nil, tab: nil) - params = { controller: "/workflows", action: :edit, type_id: type.id } - params[:role_id] = role.id if role - params[:tab] = tab if tab - visit url_for(params) - end - - def add_status_via_dialog(status) - within "#workflow-table" do # Otherwise, click on "Statuses" menu item - click_link "Status" - end - within_dialog "Statuses" do - find(".ng-arrow-wrapper").click - find(".ng-option", text: status.name).click - click_button "Apply" - end - end - - def remove_status_via_dialog(status) - within "#workflow-table" do # Otherwise, click on "Statuses" menu item - click_link "Status" - end - within_dialog "Statuses" do - find(".ng-value", text: status.name).find(".ng-value-icon").click - click_button "Apply" - end - end - before do visit_workflow_edit end it "allows adding another workflow" do - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) check workflow_checkbox(1, 0) @@ -114,7 +83,7 @@ def remove_status_via_dialog(status) old_status_id: statuses[0].id, new_status_id: statuses[1].id, author: true, assignee: false) - visit_workflow_edit(role:, tab: "author") + visit_workflow_edit(roles: [role], tab: "author") within "#workflow_form_author" do check workflow_checkbox(1, 0) @@ -150,7 +119,7 @@ def remove_status_via_dialog(status) old_status_id: statuses[0].id, new_status_id: statuses[1].id, author: false, assignee: true) - visit_workflow_edit(role:, tab: "assignee") + visit_workflow_edit(roles: [role], tab: "assignee") within "#workflow_form_assignee" do check workflow_checkbox(1, 0) @@ -194,7 +163,7 @@ def remove_status_via_dialog(status) end before do - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) end it "shows the always tab by default" do @@ -254,9 +223,7 @@ def remove_status_via_dialog(status) expect(page).to have_css("#workflow_form_author") - expect(Workflow.exists?(role_id: role.id, type_id: type.id, - old_status_id: statuses[1].id, new_status_id: statuses[0].id, - author: false, assignee: false)).to be true + expect_transition(role, 1, 0, exist: true) end it "keeps unsaved changes and stays on the same tab when closing the dialog via 'X'" do @@ -311,7 +278,7 @@ def remove_status_via_dialog(status) end before do - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) end it "shows the matrix for the first role" do @@ -322,8 +289,7 @@ def remove_status_via_dialog(status) end it "loads the matrix for a different role after switching" do - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) within "#workflow_form_always" do expect(page).to have_field workflow_checkbox(1, 2) @@ -336,8 +302,7 @@ def remove_status_via_dialog(status) check workflow_checkbox(1, 0) end - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) within_dialog "Save changes before continuing?" do click_button "Ignore changes" @@ -347,8 +312,7 @@ def remove_status_via_dialog(status) expect(page).to have_field workflow_checkbox(1, 2) end - click_button other_role.name - click_link role.name + switch_role_via_panel(other_role, role) within "#workflow_form_always" do expect(page).to have_field workflow_checkbox(1, 0), checked: false @@ -360,8 +324,7 @@ def remove_status_via_dialog(status) check workflow_checkbox(1, 0) end - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) within_dialog "Save changes before continuing?" do click_button "Save changes and continue" @@ -373,9 +336,7 @@ def remove_status_via_dialog(status) expect(page).to have_field workflow_checkbox(1, 2) end - expect(Workflow.exists?(role_id: role.id, type_id: type.id, - old_status_id: statuses[1].id, new_status_id: statuses[0].id, - author: false, assignee: false)).to be true + expect_transition(role, 1, 0, exist: true) end it "keeps unsaved changes and stays on the same role when closing the dialog via 'X'" do @@ -383,8 +344,7 @@ def remove_status_via_dialog(status) check workflow_checkbox(1, 0) end - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) within_dialog "Save changes before continuing?" do find(".close-button").click @@ -402,8 +362,7 @@ def remove_status_via_dialog(status) add_status_via_dialog(statuses[2]) expect(page).to have_field workflow_checkbox(0, 2) - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) expect(page).to have_dialog("Save changes before continuing?") end @@ -417,8 +376,7 @@ def remove_status_via_dialog(status) expect(page).to have_no_field workflow_checkbox(0, 1) - click_button role.name - click_link other_role.name + switch_role_via_panel(role, other_role) expect(page).to have_dialog("Save changes before continuing?") end @@ -426,7 +384,7 @@ def remove_status_via_dialog(status) context "when reloading the page with unsaved changes", :js do before do - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) end it "shows a browser confirmation when reloading with unsaved checkbox changes" do @@ -466,7 +424,7 @@ def remove_status_via_dialog(status) context "with status dialog", :js do before do - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) end it "shows only role-specific statuses in the matrix by default" do @@ -474,7 +432,7 @@ def remove_status_via_dialog(status) create(:workflow, role_id: other_role.id, type_id: type.id, old_status_id: statuses[0].id, new_status_id: statuses[2].id) - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) expect(page).to have_field workflow_checkbox(0, 1) expect(page).to have_no_field workflow_checkbox(2, 0) @@ -573,8 +531,7 @@ def remove_status_via_dialog(status) expect_flash(message: "Successful update.") - expect(Workflow.exists?(role_id: role.id, type_id: type.id, - old_status_id: statuses[0].id, new_status_id: statuses[2].id)).to be true + expect_transition(role, 0, 2, exist: true) expect(page).to have_field workflow_checkbox(0, 2) @@ -590,7 +547,7 @@ def remove_status_via_dialog(status) expect(page).to have_field workflow_checkbox(2, 0) - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) expect(page).to have_no_field workflow_checkbox(2, 0) end @@ -637,7 +594,7 @@ def remove_status_via_dialog(status) expect_flash(message: "Successful update.") - visit_workflow_edit(role:) + visit_workflow_edit(roles: [role]) expect(page).to have_no_field workflow_checkbox(2, 0) expect(page).to have_no_field workflow_checkbox(0, 2) @@ -698,9 +655,7 @@ def remove_status_via_dialog(status) expect_flash(message: "Successful update.") - expect(Workflow.exists?(role_id: role.id, type_id: type.id, - old_status_id: statuses[1].id, new_status_id: statuses[0].id, - author: false, assignee: false)).to be true + expect_transition(role, 1, 0, exist: true) expect(page).to have_dialog "Copy workflow" end diff --git a/spec/support/workflows/edit_helpers.rb b/spec/support/workflows/edit_helpers.rb new file mode 100644 index 000000000000..7b30cd14bc4b --- /dev/null +++ b/spec/support/workflows/edit_helpers.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +# -- 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. +# ++ + +module Workflows + module EditHelpers + def workflow_checkbox(from_index, to_index) + "status_#{statuses[from_index].id}_#{statuses[to_index].id}" + end + + def visit_workflow_edit(roles: [], tab: nil) + params = {} + params[:role_ids] = roles.map(&:id) if roles.any? + params[:tab] = tab if tab + visit edit_workflow_path(type, **params) + end + + def switch_role_via_panel(from_role, to_role) + click_button from_role.name + find("[data-item-id='#{to_role.id}']").click + find("[data-item-id='#{from_role.id}']").click + within("select-panel") { click_button "Save" } + end + + def add_status_via_dialog(status) + within "#workflow-table" do # Otherwise, click on "Statuses" menu item + click_link "Status" + end + within_dialog "Statuses" do + find(".ng-arrow-wrapper").click + find(".ng-option", text: status.name).click + click_button "Apply" + end + end + + def remove_status_via_dialog(status) + within "#workflow-table" do # Otherwise, click on "Statuses" menu item + click_link "Status" + end + within_dialog "Statuses" do + find(".ng-value", text: status.name).find(".ng-value-icon").click + click_button "Apply" + end + end + + def indeterminate?(checkbox_id) + page.evaluate_script("document.getElementById('#{checkbox_id}')?.indeterminate ?? false") + end + + def indeterminate_visible?(checkbox_id) + page.evaluate_script(<<~JS) + (() => { + const el = document.getElementById('#{checkbox_id}'); + const bg = window.getComputedStyle(el).backgroundColor; + return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'rgb(255, 255, 255)'; + })() + JS + end + + def expect_transition(role, from_index, to_index, exist:, author: false, assignee: false) + expect(Workflow.exists?(role_id: role.id, type_id: type.id, + old_status_id: statuses[from_index].id, + new_status_id: statuses[to_index].id, + author:, assignee:)).to be exist + end + end +end