[64823] Improve usability of Workflow tables (#20581)

* Split workflow tables into multiple tabs

Co-authored-by: Behrokh Satarnejad <b.satarnejad@openproject.com>

* make header and first column sticky in work flow tables

* calculate the height of the table

* Update the tabs individually

* calculate the height of the table

* Add tests for separated Workflow update process

* add a new style sheet for the workflows page and handle vertical and horizontal scroll in it

* set a class for page header in workflows page

* set page header class for other pages like summary and copy as well

* make header and first column sticky in summary page

* make the button sticky while scrolling horizontally

* redirect to the current tab in update method

---------

Co-authored-by: Behrokh Satarnejad <b.satarnejad@openproject.com>
This commit is contained in:
Henriette Darge
2025-10-16 09:55:52 +02:00
committed by GitHub
parent fae9dd0b49
commit 10e45aead5
17 changed files with 443 additions and 103 deletions
@@ -0,0 +1,63 @@
<%#-- 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.
++#%>
<%=
render Primer::OpenProject::PageHeader.new(classes: "workflows-page-header") do |header|
header.with_title { title }
header.with_breadcrumbs(breadcrumb_items)
header.with_action_button(
tag: :a,
mobile_icon: :copy,
mobile_label: t(:button_copy),
size: :medium,
href: copy_workflows_path,
aria: { label: I18n.t(:button_copy) },
title: I18n.t(:button_copy)
) do |button|
button.with_leading_visual_icon(icon: :copy)
t(:button_copy)
end
header.with_action_button(
tag: :a,
mobile_icon: :info,
mobile_label: t(:label_workflow_summary),
size: :medium,
href: workflows_path,
aria: { label: I18n.t(:label_workflow_summary) },
title: I18n.t(:label_workflow_summary)
) do |button|
button.with_leading_visual_icon(icon: :info)
t(:label_workflow_summary)
end
helpers.render_tab_header_nav(header, @tabs)
end
%>
@@ -0,0 +1,51 @@
# 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
class EditPageHeaderComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include ApplicationHelper
def initialize(tabs:)
super
@tabs = tabs
end
def breadcrumb_items
[{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_work_packages_general_path, text: t(:label_work_package_plural) },
title]
end
def title
Workflow.model_name.human
end
end
end
@@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
render Primer::OpenProject::PageHeader.new do |header|
render Primer::OpenProject::PageHeader.new(classes: "workflows-page-header") do |header|
header.with_title { title }
header.with_breadcrumbs(breadcrumb_items)
@@ -39,15 +39,10 @@ module Workflows
end
def breadcrumb_items
base_items = [{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_work_packages_general_path, text: t(:label_work_package_plural) },
title]
if @state == :edit
base_items
else
base_items.insert(2, { href: edit_workflows_path, text: t(:label_workflow) })
end
[{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_work_packages_general_path, text: t(:label_work_package_plural) },
{ href: edit_workflows_path, text: t(:label_workflow) },
title]
end
def title
@@ -56,8 +51,6 @@ module Workflows
t(:label_workflow_summary)
when :copy
t(:label_workflow_copy)
when :edit
Workflow.model_name.human
else
t(:label_workflow_plural)
end
+3 -2
View File
@@ -57,13 +57,14 @@ class WorkflowsController < ApplicationController
end
def update
tab = params[:tab] || "always"
call = Workflows::BulkUpdateService
.new(role: @role, type: @type)
.new(role: @role, type: @type, tab:)
.call(permitted_status_params)
if call.success?
flash[:notice] = I18n.t(:notice_successful_update)
redirect_to action: "edit", role_id: @role, type_id: @type
redirect_to action: "edit", role_id: @role, type_id: @type, tab:
end
end
+54
View File
@@ -0,0 +1,54 @@
# 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 WorkflowHelper
def workflow_tabs
[
{
name: "always",
partial: "workflows/form",
path: edit_workflows_path({ tab: :always }.merge(params.permit(:role_id, :type_id, :used_statuses_only))),
label: I18n.t(:"admin.workflows.tabs.default_transitions")
},
{
name: "author",
partial: "workflows/form",
path: edit_workflows_path({ tab: :author }.merge(params.permit(:role_id, :type_id, :used_statuses_only))),
label: I18n.t(:"admin.workflows.tabs.user_author")
},
{
name: "assignee",
partial: "workflows/form",
path: edit_workflows_path({ tab: :assignee }.merge(params.permit(:role_id, :type_id, :used_statuses_only))),
label: I18n.t(:"admin.workflows.tabs.user_assignee")
}
]
end
end
+17 -6
View File
@@ -29,9 +29,10 @@
#++
class Workflows::BulkUpdateService < BaseServices::Update
def initialize(role:, type:)
def initialize(role:, type:, tab:)
@role = role
@type = type
@tab = tab
end
def call(status_transitions)
@@ -64,8 +65,8 @@ class Workflows::BulkUpdateService < BaseServices::Update
role:,
old_status: status_map[status_id.to_i],
new_status: status_map[new_status_id.to_i],
author: options_include(options, "author"),
assignee: options_include(options, "assignee"))
author: author?,
assignee: assignee?)
end
end
@@ -73,7 +74,13 @@ class Workflows::BulkUpdateService < BaseServices::Update
end
def delete_current
Workflow.where(role_id: role.id, type_id: type.id).delete_all
if author?
Workflow.where(role_id: role.id, type_id: type.id, author: true).delete_all
elsif assignee?
Workflow.where(role_id: role.id, type_id: type.id, assignee: true).delete_all
else
Workflow.where(role_id: role.id, type_id: type.id, assignee: false, author: false).delete_all
end
end
def bulk_insert(workflows)
@@ -89,7 +96,11 @@ class Workflows::BulkUpdateService < BaseServices::Update
@status_map ||= Status.all.group_by(&:id).transform_values(&:first)
end
def options_include(options, string)
options.is_a?(Array) && options.include?(string) && !options.include?("always")
def author?
@tab == "author"
end
def assignee?
@tab == "assignee"
end
end
+14 -2
View File
@@ -26,10 +26,22 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<% name = tab[:name] %>
<% workflows = @workflows[name] %>
<%=
if name == "assignee"
render(Primer::OpenProject::Heading.new(tag: :h3, my: 3)) { t(:label_additional_workflow_transitions_for_assignee) }
elsif name == "author"
render(Primer::OpenProject::Heading.new(tag: :h3, my: 3)) { t(:label_additional_workflow_transitions_for_author) }
end
%>
<div id="workflow_form_<%= name %>" class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table workflow-table transitions-<%= name %>">
<thead>
<thead class="-sticky">
<tr>
<th></th>
<th colspan="<%= @statuses.length %>">
@@ -39,7 +51,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= t(:label_new_statuses_allowed) %>
</span>
<span class="workflow-table--check-all">
(<%= check_all_links "workflow_form_" + name %>)
(<%= check_all_links "workflow_form_#{name}" %>)
</span>
</div>
</div>
+20 -17
View File
@@ -28,9 +28,11 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% html_title t(:label_administration), t(:label_workflow_plural) -%>
<%= render Workflows::PageHeaderComponent.new(state: :edit) %>
<%= render Workflows::EditPageHeaderComponent.new(tabs: workflow_tabs) %>
<%= styled_form_tag({}, method: "get") do %>
<%= hidden_field_tag "tab", params[:tab] || "always" %>
<fieldset class="simple-filters--container">
<legend><%= t(:text_workflow_edit) %></legend>
<ul class="simple-filters--filters">
@@ -57,31 +59,32 @@ See COPYRIGHT and LICENSE files for more details.
</li>
</ul>
<li class="simple-filters--controls">
<%= submit_tag t(:button_edit), name: nil, accesskey: accesskey(:edit), class: "button -small -primary" %>
<%= render(
Primer::Beta::Button.new(
scheme: :primary,
type: :submit,
name: nil,
accesskey: accesskey(:edit),
size: :small
)
) { t(:button_edit) } %>
</li>
</ul>
</fieldset>
<% end %>
<% if @type && @role && @statuses.any? %>
<%= form_tag({ action: :update }, id: "workflow_form", method: :patch) do %>
<%= hidden_field_tag "type_id", @type.id %>
<%= hidden_field_tag "role_id", @role.id %>
<%= hidden_field_tag "tab", params[:tab] || "always" %>
<%= render partial: "form",
locals: { name: "always", workflows: @workflows["always"] } %>
<%= render_tabs workflow_tabs %>
<%= augmented_collapsible_section initiallyExpanded: @workflows["author"].present?,
title: t(:label_additional_workflow_transitions_for_author) do %>
<%= render partial: "form", locals: { name: "author", workflows: @workflows["author"] } %>
<% end %>
<%= augmented_collapsible_section initiallyExpanded: @workflows["assignee"].present?,
title: t(:label_additional_workflow_transitions_for_assignee) do %>
<%= render partial: "form", locals: { name: "assignee", workflows: @workflows["assignee"] } %>
<% end %>
<%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %>
<%=
render Primer::Beta::Button.new(scheme: :primary, type: :submit, id: "work-flow-save-button") do
t(:button_save)
end
%>
<% end %>
<% end %>
<% html_title(Workflow.model_name.human) -%>
+4 -4
View File
@@ -31,10 +31,10 @@ See COPYRIGHT and LICENSE files for more details.
<%= render Workflows::PageHeaderComponent.new(state: :show) %>
<% if @workflow_counts.any? %>
<div class="autoscroll">
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" data-controller="table-highlighting">
<table class="generic-table workflow-table" data-controller="table-highlighting" id="workflow_summary">
<colgroup>
<col data-highlight="false">
<col>
@@ -44,7 +44,7 @@ See COPYRIGHT and LICENSE files for more details.
<col>
<col data-highlight="false">
</colgroup>
<thead>
<thead class="-sticky">
<tr>
<th><div class="generic-table--empty-header"></div></th>
<% @workflow_counts.first.last.each do |role, count| %>
@@ -74,7 +74,7 @@ See COPYRIGHT and LICENSE files for more details.
</div>
</div>
</div>
<% else %>
<%= no_results_box %>
<% end %>
+5
View File
@@ -171,6 +171,11 @@ en:
expired: "Expired on %{date}"
revoked: "Revoked on %{date}"
title: "Access token table"
workflows:
tabs:
default_transitions: "Default transitions"
user_author: "User is author"
user_assignee: "User is assignee"
authentication:
login_and_registration: "Login and registration"
@@ -84,3 +84,4 @@
@import user-content/index
@import bim/index
@import reporting/index
@import "work_packages/workflows"
@@ -55,52 +55,6 @@ table
font-style: normal
font-weight: var(--base-text-weight-bold)
background-color: #EEEEEE
#workflow_form
.generic-table--results-container
position: relative
.workflow-table.generic-table
// Let space for the turned header
margin-left: 30px
width: calc(100% - 30px)
.workflow-table--current-status
font-weight: var(--base-text-weight-bold)
text-transform: uppercase
font-size: 0.875rem
tbody
span.workflow-table--turned-header
white-space: nowrap
transform: rotate(270deg)
position: absolute
top: 235px
left: 0px
transform-origin: 0 0
text-transform: uppercase
font-weight: var(--base-text-weight-bold)
max-width: 220px
@include text-shortener
thead
th
padding: 0 6px
.workflow-table--header
text-align: right
display: flex
span
flex-basis: 50%
.workflow-table--check-all
font-size: 12px
font-style: italic
text-transform: none
a:hover
text-decoration: underline
.generic-table--sort-header-outer:hover
background: none
tr
div.expander
cursor: pointer
@@ -0,0 +1,86 @@
.controller-workflows
#content-body
display: grid
grid-area: auto
padding-top: 0
overflow-x: scroll
.workflows-page-header
padding-top: 1rem
#workflow_form
.generic-table--results-container
position: relative
overflow: visible
.generic-table--container
overflow: visible
.workflow-table.generic-table
// Let space for the turned header
margin-left: 30px
width: calc(100% - 30px)
.workflow-table--current-status
font-weight: var(--base-text-weight-bold)
text-transform: uppercase
font-size: 0.875rem
td:first-child:not(:has(.workflow-table--turned-header)),
th:first-child
position: sticky
left: -1rem
background: var(--body-background)
z-index: 2
box-shadow: 0 2px 4px rgba(0,0,0,0.08)
padding-top: 16px
th:first-child
z-index: 3
tbody
span.workflow-table--turned-header
white-space: nowrap
transform: rotate(270deg)
position: absolute
top: 235px
left: 0px
transform-origin: 0 0
text-transform: uppercase
font-weight: var(--base-text-weight-bold)
max-width: 220px
@include text-shortener
thead
th
padding: 0 6px
.workflow-table--header
text-align: right
display: flex
span
flex-basis: 50%
.workflow-table--check-all
font-size: 12px
font-style: italic
text-transform: none
a:hover
text-decoration: underline
.generic-table--sort-header-outer:hover
background: none
#workflow_summary
td:first-child:not(:has(.workflow-table--turned-header)),
th:first-child
position: sticky
left: -1rem
background: var(--body-background)
z-index: 2
box-shadow: 0 2px 4px rgba(0,0,0,0.08)
th:first-child
z-index: 3
#work-flow-save-button
position: sticky
left: 0
@@ -253,8 +253,8 @@ RSpec.describe WorkflowsController do
allow(Workflows::BulkUpdateService)
.to receive(:new)
.with(role:, type:)
.and_return(service)
.with(role: role, type: type, tab: "always")
.and_return(service)
service
end
@@ -279,7 +279,7 @@ RSpec.describe WorkflowsController do
it "redirects to edit" do
expect(response)
.to redirect_to edit_workflows_path(role_id: role.id, type_id: type.id)
.to redirect_to edit_workflows_path(role_id: role.id, type_id: type.id, tab: "always")
end
end
+92
View File
@@ -77,6 +77,98 @@ RSpec.describe "Workflow edit" do
.to have_field "status_#{statuses[2].id}_#{statuses[0].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[2].id}_#{statuses[1].id}_", checked: false
expect(Workflow.where(type_id: type.id, role_id: role.id).count).to be 2
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[0].id, new_status_id: statuses[1].id).first
assert !w.author
assert !w.assignee
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[1].id, new_status_id: statuses[2].id).first
assert !w.author
assert !w.assignee
end
end
it "allows editing the workflow when the user is author" do
click_link "User is author"
click_button "Edit"
within "#workflow_form_author" do
check "status_#{statuses[2].id}_#{statuses[1].id}_"
end
click_button "Save"
expect_flash(message: "Successful update.")
within "#workflow_form_author" do
expect(page)
.to have_field "status_#{statuses[2].id}_#{statuses[1].id}_", checked: true
expect(page)
.to have_field "status_#{statuses[0].id}_#{statuses[1].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[1].id}_#{statuses[2].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[0].id}_#{statuses[2].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[1].id}_#{statuses[0].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[2].id}_#{statuses[0].id}_", checked: false
expect(Workflow.where(type_id: type.id, role_id: role.id).count).to be 2
# the newly added Workflow
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[2].id, new_status_id: statuses[1].id).first
assert w.author
assert !w.assignee
# The already existing Workflow
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[0].id, new_status_id: statuses[1].id).first
assert !w.author
assert !w.assignee
end
end
it "allows editing the workflow when the user is assignee" do
click_link "User is assignee"
click_button "Edit"
within "#workflow_form_assignee" do
check "status_#{statuses[2].id}_#{statuses[0].id}_"
end
click_button "Save"
expect_flash(message: "Successful update.")
within "#workflow_form_assignee" do
expect(page)
.to have_field "status_#{statuses[2].id}_#{statuses[0].id}_", checked: true
expect(page)
.to have_field "status_#{statuses[0].id}_#{statuses[1].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[1].id}_#{statuses[2].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[0].id}_#{statuses[2].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[1].id}_#{statuses[0].id}_", checked: false
expect(page)
.to have_field "status_#{statuses[2].id}_#{statuses[1].id}_", checked: false
expect(Workflow.where(type_id: type.id, role_id: role.id).count).to be 2
# the newly added Workflow
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[2].id, new_status_id: statuses[0].id).first
assert !w.author
assert w.assignee
# The already existing Workflow
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: statuses[0].id, new_status_id: statuses[1].id).first
assert !w.author
assert !w.assignee
end
end
end
@@ -54,7 +54,7 @@ RSpec.describe Workflows::BulkUpdateService, "integration", type: :model do
end
let(:instance) do
described_class.new(role:, type:)
described_class.new(role:, type:, tab:)
end
describe "#call" do
@@ -64,6 +64,7 @@ RSpec.describe Workflows::BulkUpdateService, "integration", type: :model do
end
context "with status transitions for everybody" do
let(:tab) { "always" }
let(:params) do
{
status4.id => { status5.id => ["always"] },
@@ -88,11 +89,11 @@ RSpec.describe Workflows::BulkUpdateService, "integration", type: :model do
end
end
context "with additional transitions" do
context "with additional author transitions" do
let(:tab) { "author" }
let(:params) do
{
status4.id => { status5.id => ["always"] },
status3.id => { status1.id => ["author"], status2.id => ["assignee"], status4.id => %w(author assignee) }
status3.id => { status1.id => ["author"] }
}
end
@@ -100,24 +101,36 @@ RSpec.describe Workflows::BulkUpdateService, "integration", type: :model do
subject
expect(Workflow.where(type_id: type.id, role_id: role.id).count)
.to be 4
.to be 1
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: status4.id, new_status_id: status5.id).first
assert !w.author
assert !w.assignee
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: status3.id, new_status_id: status1.id).first
assert w.author
assert !w.assignee
end
end
context "with additional assignee transitions" do
let(:tab) { "assignee" }
let(:params) do
{
status3.id => { status2.id => ["assignee"] }
}
end
it "sets the workflows" do
subject
expect(Workflow.where(type_id: type.id, role_id: role.id).count)
.to be 1
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: status3.id, new_status_id: status2.id).first
assert !w.author
assert w.assignee
w = Workflow.where(role_id: role.id, type_id: type.id, old_status_id: status3.id, new_status_id: status4.id).first
assert w.author
assert w.assignee
end
end
context "without transitions" do
let(:tab) { "always" }
let(:params) do
{}
end
@@ -135,6 +148,7 @@ RSpec.describe Workflows::BulkUpdateService, "integration", type: :model do
end
context "with no params" do
let(:tab) { "always" }
let(:params) do
nil
end