diff --git a/app/components/open_project/common/check_all_component.html.erb b/app/components/open_project/common/check_all_component.html.erb index 0f1428ed0c5..e5d68bf217f 100644 --- a/app/components/open_project/common/check_all_component.html.erb +++ b/app/components/open_project/common/check_all_component.html.erb @@ -1,13 +1,13 @@ -<% unless check_all? %> - <% with_check_all # set the default %> +<% unless check_all_button? %> + <% with_check_all_button # set the default %> <% end %> -<% unless uncheck_all? %> - <% with_uncheck_all # set the default %> +<% unless uncheck_all_button? %> + <% with_uncheck_all_button # set the default %> <% end %> <%= render(Primer::BaseComponent.new(**@system_arguments)) do %> - <%= check_all %> + <%= check_all_button %> | - <%= uncheck_all %> + <%= uncheck_all_button %> <% end %> diff --git a/app/components/open_project/common/check_all_component.rb b/app/components/open_project/common/check_all_component.rb index 751afd1ea00..bfc85f475f7 100644 --- a/app/components/open_project/common/check_all_component.rb +++ b/app/components/open_project/common/check_all_component.rb @@ -33,14 +33,15 @@ module OpenProject class CheckAllComponent < ApplicationComponent include Primer::AttributesHelper - attr_reader :checkable_id + attr_reader :checkable_id, :base_id CHECKABLE_CONTROLLER_SELECTOR = "[data-controller~='checkable']" - renders_one :check_all, ->(text: I18n.t(:button_check_all), **system_arguments) { + renders_one :check_all_button, ->(text: I18n.t(:button_check_all), **system_arguments) { action = use_outlet? ? "check-all#checkAll:stop" : "checkable#checkAll:stop" controls = checkable_id if use_outlet? + system_arguments[:id] = "#{base_id}-check-all" system_arguments[:data] = merge_data( system_arguments, { data: { action: } @@ -53,10 +54,11 @@ module OpenProject Primer::Beta::Button.new(scheme: :link, **system_arguments).with_content(text) } - renders_one :uncheck_all, ->(text: I18n.t(:button_uncheck_all), **system_arguments) { + renders_one :uncheck_all_button, ->(text: I18n.t(:button_uncheck_all), **system_arguments) { action = use_outlet? ? "check-all#uncheckAll:stop" : "checkable#uncheckAll:stop" controls = checkable_id if use_outlet? + system_arguments[:id] = "#{base_id}-uncheck-all" system_arguments[:data] = merge_data( system_arguments, { data: { action: } @@ -90,6 +92,7 @@ module OpenProject super() @checkable_id = checkable_id + @base_id = checkable_id || self.class.generate_id @system_arguments = system_arguments @system_arguments[:tag] ||= :span diff --git a/app/controllers/workflows_controller.rb b/app/controllers/workflows_controller.rb index 178900db361..f1b63f77967 100644 --- a/app/controllers/workflows_controller.rb +++ b/app/controllers/workflows_controller.rb @@ -44,6 +44,7 @@ class WorkflowsController < ApplicationController def show @workflow_counts = Workflow.count_by_type_and_role + @roles = @workflow_counts.first&.last&.map(&:first) end def edit diff --git a/app/forms/roles/save.rb b/app/forms/roles/save.rb new file mode 100644 index 00000000000..bc2573c2647 --- /dev/null +++ b/app/forms/roles/save.rb @@ -0,0 +1,43 @@ +# 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 Roles + class Save < ApplicationForm + extend Dry::Initializer + + option :scheme, default: -> { :primary } + option :name, default: -> { :save } + option :label, default: -> { I18n.t(:button_save) } + + form do |f| + f.submit(scheme:, name:, label:) + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c1487289a78..10d6b126d3a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -362,8 +362,8 @@ module ApplicationHelper back_url end - def check_all_links(form_id = nil) - render(OpenProject::Common::CheckAllComponent.new(checkable_id: form_id)) + def check_all_links(form_id = nil, &) + render(OpenProject::Common::CheckAllComponent.new(checkable_id: form_id), &) end def current_layout diff --git a/app/models/exports/pdf/common/macro.rb b/app/models/exports/pdf/common/macro.rb index cb0fce390ef..41e6b7e2622 100644 --- a/app/models/exports/pdf/common/macro.rb +++ b/app/models/exports/pdf/common/macro.rb @@ -102,12 +102,10 @@ module Exports::PDF::Common::Macro end def replace_macro(text, macro, in_html, context) - new_text = text.dup - text.to_enum(:scan, macro.regexp).each do |matched_string| + text.gsub(macro.regexp) do |matched_string| match = Regexp.last_match - new_text[match.begin(0)...match.end(0)] = replace_macro_match(match, matched_string, macro, in_html, context) + replace_macro_match(match, matched_string, macro, in_html, context) end - new_text end def replace_macro_match(match, matched_string, macro, in_html, context) diff --git a/app/views/roles/_permissions.html.erb b/app/views/roles/_permissions.html.erb index b94b8ce1997..e66f3ec9982 100644 --- a/app/views/roles/_permissions.html.erb +++ b/app/views/roles/_permissions.html.erb @@ -27,17 +27,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% permissions.each do |mod, mod_permissions| %> - <% global_prefix = show_global_role ? "fieldset--global--" : "fieldset--" %> - <% module_name = mod.blank? ? "fieldset--global--#{Project.model_name.human.downcase.tr(' ', '_')}" : "#{global_prefix}#{l_or_humanize(mod, prefix: 'project_module_').downcase.tr(' ', '_')}" %> - <% module_id = module_name.parameterize %> + <% module_name = mod.blank? ? Project.model_name.param_key : l_or_humanize(mod, prefix: "project_module_").downcase.tr(" ", "_") %> + <% module_title = show_global_role && mod.blank? ? t(:label_global) : permission_header_for_project_module(mod) %> + <% section_id = "#{'global-' if show_global_role}#{module_name.parameterize}-section" %> - <%= render Primer::OpenProject::CollapsibleSection.new(id: module_id, display: :block, mb: 3) do |section| %> + <%= render Primer::OpenProject::CollapsibleSection.new(id: section_id, display: :block, mb: 3) do |section| %> <% section.with_title(tag: :h3) do %> - <% if show_global_role && mod.blank? %> - <%= t(:label_global) %> - <% else %> - <%= permission_header_for_project_module(mod) %> - <% end %> + <%= module_title %> <% end %> <% if I18n.exists?("permission_header_explanation", scope: mod) %> @@ -47,7 +43,12 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% section.with_additional_information do %> - <%= check_all_links module_id %> + <%= + check_all_links section_id do |links| + links.with_check_all_button.with_tooltip(text: t(".section_check_all_label", module: module_title)) + links.with_uncheck_all_button.with_tooltip(text: t(".section_uncheck_all_label", module: module_title)) + end + %> <% end %> <% section.with_collapsible_content(classes: "-columns-2", data: { controller: "checkable" }) do %> diff --git a/app/views/roles/report.html.erb b/app/views/roles/report.html.erb index ee7414a38b0..0a17fb6f41d 100644 --- a/app/views/roles/report.html.erb +++ b/app/views/roles/report.html.erb @@ -44,20 +44,26 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for(call.result) %> <% end %> -<%= form_tag(roles_path, method: :put, id: "permissions_form") do %> +<%= primer_form_with(url: roles_path, method: :put, id: "permissions_form") do |f| %> <%= hidden_field_tag "permissions[0]", "", id: nil %> <% group_permissions_by_module(@permissions).each do |mod, mod_permissions| %> - <% module_name = mod.blank? ? "form--#{I18n.t('attributes.project')}" : "form--#{l_or_humanize(mod, prefix: 'project_module_').tr(' ', '_')}" %> - <% escaped_name = module_name.parameterize %> + <% module_name = mod.blank? ? Project.model_name.param_key : l_or_humanize(mod, prefix: "project_module_").tr(" ", "_") %> + <% module_title = permission_header_for_project_module(mod) %> + <% section_id = "#{module_name.parameterize}-section" %> - <%= render Primer::OpenProject::CollapsibleSection.new(id: escaped_name, display: :block, mb: 3) do |section| %> + <%= render Primer::OpenProject::CollapsibleSection.new(id: section_id, display: :block, mb: 3) do |section| %> <% section.with_title do %> - <%= permission_header_for_project_module(mod) %> + <%= module_title %> <% end %> <% section.with_additional_information do %> - <%= check_all_links escaped_name %> + <%= + check_all_links section_id do |links| + links.with_check_all_button.with_tooltip(text: t(".matrix_check_all_label", module: module_title)) + links.with_uncheck_all_button.with_tooltip(text: t(".matrix_uncheck_all_label", module: module_title)) + end + %> <% end %> <% section.with_collapsible_content do %> @@ -66,13 +72,14 @@ See COPYRIGHT and LICENSE files for more details. + - - - - - - + + <% @roles.size.times do |role| %> + + <% end %> @@ -87,22 +94,29 @@ See COPYRIGHT and LICENSE files for more details. <% @roles.each do |role| %> <% mod_permissions.each do |permission| %> + <% humanized_permission_name = l_or_humanize(permission.name, prefix: "permission_") %> - + <% @roles.each.with_index do |role, i| %> @@ -177,5 +231,5 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% end %> -

<%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %>

+ <%= render Roles::Save.new(f) %> <% end %> diff --git a/app/views/workflows/_form.html.erb b/app/views/workflows/_form.html.erb index b60d1afe6cc..003552d9504 100644 --- a/app/views/workflows/_form.html.erb +++ b/app/views/workflows/_form.html.erb @@ -42,57 +42,63 @@ See COPYRIGHT and LICENSE files for more details.
+ <%= t(".matrix_caption", module: module_title) %> +
-
+
- <%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %> - <%= render(Primer::Beta::Text.new(mr: 1)) do %> - <%= role.name %> - <%= render(Primer::Beta::Label.new(scheme: :secondary)) { t(:label_builtin) } if role.builtin? %> - <% end %> + <%= + render( + Primer::BaseComponent.new( + tag: :div, + display: :flex, + align_items: :center, + classes: "gap-1", + ml: 1, + mr: 2 + ) + ) do + %> <%= render( Primer::Beta::IconButton.new( scheme: :invisible, size: :small, icon: :check, - tooltip_direction: :se, + tooltip_direction: :sw, aria: { - label: t(:label_check_uncheck_all_in_column) + label: t(".matrix_check_uncheck_all_in_col_label_html", module: module_title, role: role.name) }, data: { action: "checkable#toggleSelection", @@ -112,6 +126,10 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> + <%= render(Primer::Beta::Truncate.new(flex: 1)) do %> + <%= role.name %> + <%= render(Primer::Beta::Label.new(scheme: :secondary)) { t(:label_builtin) } if role.builtin? %> + <% end %> <% end %>
@@ -121,9 +139,22 @@ See COPYRIGHT and LICENSE files for more details.
- <%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %> + + <%= + render( + Primer::BaseComponent.new( + tag: :div, + display: :flex, + align_items: :center, + data: { controller: "truncation" }, + classes: "gap-1", + ml: 1, + mr: 3 + ) + ) do + %> <%= render( Primer::Beta::IconButton.new( @@ -132,7 +163,7 @@ See COPYRIGHT and LICENSE files for more details. icon: :check, tooltip_direction: :sw, aria: { - label: t(:label_check_uncheck_all_in_row) + label: t(".matrix_check_uncheck_all_in_row_label_html", permission: humanized_permission_name) }, data: { action: "checkable#toggleSelection", @@ -142,26 +173,49 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> - <%= render(Primer::Beta::Text.new(ml: 1)) do %> - <%= l_or_humanize(permission.name, prefix: "permission_") %> - <% end %> + <%= + render( + Primer::Beta::Truncate.new( + data: { truncation_target: "truncate" }, + flex: 1 + ) + ) do + humanized_permission_name + end + %> + <%= + render( + Primer::Alpha::HiddenTextExpander.new( + hidden: true, + aria: { label: t(:"js.label_expand_text") }, + data: { truncation_target: "expander" } + ) + ) + %> <% end %> - - <% @roles.each do |role| %> + <% if setable_permissions(role).include?(permission) %> <%= - check_box_tag( - "permissions[#{role.id}][]", - permission.name, - role.permissions.include?(permission.name), - id: nil, - data: { - checkable_target: "checkbox", - role: role.id, - permission: permission.name - } - ) + render(Primer::BaseComponent.new(tag: :div, display: :flex, align_items: :center, mx: 1)) do + render( + Primer::Alpha::CheckBox.new( + scheme: :array, + name: "permissions[#{role.id}]", + id: "permissions_#{role.id}_#{permission.name}", # See BUG https://github.com/primer/view_components/issues/3811 + value: permission.name, + checked: role.permissions.include?(permission.name), + label: t(".matrix_checkbox_label", permission: humanized_permission_name, role: role.name), + visually_hide_label: true, + data: { + checkable_target: "checkbox", + role: role.id, + permission: permission.name + } + ) + ) + end %> <% end %>
+ data-controller="checkable table-highlighting"> + + + + <% @statuses.size.times do |role| %> + + <% end %> + - - <% for new_status in @statuses %> - - <% end %> - - - - <% for old_status in @statuses %> - - + <% end %> + + + + <% @statuses.each do |old_status| %> + + + <% @statuses.each do |new_status| -%> <% end -%> diff --git a/app/views/workflows/show.html.erb b/app/views/workflows/show.html.erb index 95493e1dddc..25cb49ac354 100644 --- a/app/views/workflows/show.html.erb +++ b/app/views/workflows/show.html.erb @@ -37,17 +37,14 @@ See COPYRIGHT and LICENSE files for more details.
+ <%= t(".matrix_caption_#{name}", default: t(".matrix_caption")) %> +
+
-
- - <%= t(:label_new_statuses_allowed) %> - - - <%= check_all_links "workflow_form_#{name}" %> - +
+ <%= + render( + Primer::BaseComponent.new( + tag: :div, + display: :flex, + align_items: :center, + classes: "gap-2", + mx: 1 + ) + ) do + %> + + <%= t(:label_new_statuses_allowed) %> + + <%= + check_all_links "workflow_form_#{name}" do |links| + links.with_check_all_button.with_tooltip(text: t(".matrix_check_all_label")) + links.with_uncheck_all_button.with_tooltip(text: t(".matrix_uncheck_all_label")) + end + %> + <% end %>
- <%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %> - <%= - render( - Primer::Beta::IconButton.new( - scheme: :invisible, - size: :small, - icon: :check, - tooltip_direction: :sw, - aria: { - label: t(:label_check_uncheck_all_in_column) - }, - data: { - action: "checkable#toggleSelection", - checkable_key_param: "newStatus", - checkable_value_param: new_status.id - } - ) - ) - %> - <%= render(Primer::Beta::Text.new(ml: 1)) { new_status.name } %> - <% end %> -
- <%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %> + <% @statuses.each do |new_status| %> + + <%= + render( + Primer::BaseComponent.new( + tag: :div, + display: :flex, + align_items: :center, + classes: "gap-1", + ml: 1, + mr: 2 + ) + ) do + %> <%= render( Primer::Beta::IconButton.new( @@ -101,7 +107,48 @@ See COPYRIGHT and LICENSE files for more details. icon: :check, tooltip_direction: :sw, aria: { - label: t(:label_check_uncheck_all_in_row) + label: t(".matrix_check_uncheck_all_in_col_label_html", new_status: new_status.name) + }, + data: { + action: "checkable#toggleSelection", + checkable_key_param: "newStatus", + checkable_value_param: new_status.id + } + ) + ) + %> + <%= render(Primer::Beta::Truncate.new(flex: 1)) { new_status.name } %> + <% end %> +
+ <%= + render( + Primer::BaseComponent.new( + tag: :div, + display: :flex, + align_items: :center, + data: { controller: "truncation" }, + classes: "gap-1", + ml: 1, + mr: 3 + ) + ) do + %> + <%= + render( + Primer::Beta::IconButton.new( + scheme: :invisible, + size: :small, + icon: :check, + tooltip_direction: :sw, + aria: { + label: t(".matrix_check_uncheck_all_in_row_label_html", old_status: old_status.name) }, data: { action: "checkable#toggleSelection", @@ -111,22 +158,48 @@ See COPYRIGHT and LICENSE files for more details. ) ) %> - <%= render(Primer::Beta::Text.new(ml: 1)) { old_status.name } %> + <%= + render( + Primer::Beta::Truncate.new( + data: { truncation_target: "truncate" }, + flex: 1 + ) + ) do + old_status.name + end + %> + <%= + render( + Primer::Alpha::HiddenTextExpander.new( + hidden: true, + aria: { label: t(:"js.label_expand_text") }, + data: { truncation_target: "expander" } + ) + ) + %> <% end %> - - <% for new_status in @statuses -%> + <%= - check_box_tag( - "status[#{old_status.id}][#{new_status.id}][]", - name, - workflows.detect { |w| w.old_status_id == old_status.id && w.new_status_id == new_status.id }, - data: { - checkable_target: "checkbox", - old_status: old_status.id, - new_status: new_status.id - } - ) + render(Primer::BaseComponent.new(tag: :div, display: :flex, align_items: :center, mx: 1)) do + render( + Primer::Alpha::CheckBox.new( + scheme: :array, + 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: workflows.any? { it.old_status_id == old_status.id && it.new_status_id == new_status.id }, + 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 + } + ) + ) + end %>
- - - - - - + <% @roles.size.times do |role| %> + + <% end %> - <% @workflow_counts.first.last.each do |role, count| %> + <% @roles.each do |role| %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 100ac6b0e6b..11bd317a4f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -771,6 +771,18 @@ en: no_results_title_text: There is currently no news to report. no_results_content_text: Add a news item + roles: + permissions: + section_check_all_label: "Assign all %{module} permissions" + section_uncheck_all_label: "Unassign all %{module} permissions" + report: + matrix_caption: "Permissions matrix for %{module} module" + matrix_checkbox_label: "Assign %{permission} permission to %{role} role" + matrix_check_all_label: "Assign all %{module} permissions to all roles" + matrix_uncheck_all_label: "Unassign all %{module} permissions from all roles" + matrix_check_uncheck_all_in_row_label_html: "Toggle %{permission} permission for all roles" + matrix_check_uncheck_all_in_col_label_html: "Toggle all %{module} permissions for %{role} role" + users: autologins: prompt: "Stay logged in for %{num_days}" @@ -985,6 +997,17 @@ en: index: no_results_content_text: Add a new wiki page + workflows: + form: + matrix_caption: "Workflow matrix" + matrix_caption_assignee: "Workflow matrix for assignee" + matrix_caption_author: "Workflow matrix for author" + matrix_checkbox_label: "Allow transition from %{old_status} to %{new_status}" + matrix_check_all_label: "Allow all transitions" + matrix_uncheck_all_label: "Disallow all transitions" + matrix_check_uncheck_all_in_row_label_html: "Toggle transitions from %{old_status} to all new statuses" + matrix_check_uncheck_all_in_col_label_html: "Toggle transitions from all old statuses to %{new_status}" + work_flows: index: no_results_title_text: There are currently no workflows. diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 00c6af78a90..8794ba76e4f 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -385,6 +385,7 @@ en: label_collapse: "Collapse" label_collapsed: "collapsed" label_collapse_all: "Collapse all" + label_collapse_text: "Collapse text" label_comment: "Comment" label_committed_at: "%{committed_revision_link} at %{date}" label_committed_link: "committed revision %{revision_identifier}" @@ -397,6 +398,7 @@ en: label_expand: "Expand" label_expanded: "expanded" label_expand_all: "Expand all" + label_expand_text: "Show full text" label_expand_project_menu: "Expand project menu" label_export: "Export" label_export_preparing: "The export is being prepared and will be downloaded shortly." diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index fd059ad53b0..4c0ab445b3d 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - EventEmitter, + EventEmitter, inject, Input, OnInit, Output, @@ -43,6 +43,9 @@ import { BaselineMode, getBaselineState } from 'core-app/features/work-packages/ import { CombinedDateDisplayField, } from 'core-app/shared/components/fields/display/field-types/combined-date-display.field'; +import { + KeepTabService +} from 'core-app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service'; @Component({ selector: 'wp-single-card', @@ -92,6 +95,18 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen @Output() cardContextMenu = new EventEmitter<{ workPackageId:string, event:MouseEvent }>(); + readonly pathHelper = inject(PathHelperService); + readonly I18n = inject(I18nService); + readonly $state = inject(StateService); + readonly uiRouterGlobals = inject(UIRouterGlobals); + readonly wpTableSelection = inject(WorkPackageViewSelectionService); + readonly wpTableFocus = inject(WorkPackageViewFocusService); + readonly cardView = inject(WorkPackageCardViewService); + readonly cdRef = inject(ChangeDetectorRef); + readonly timezoneService = inject(TimezoneService); + readonly schemaCache = inject(SchemaCacheService); + readonly keepTabService = inject(KeepTabService); + public uiStateLinkClass:string = uiStateLinkClass; public selected = false; @@ -114,21 +129,6 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen combinedDateDisplayField = CombinedDateDisplayField; - constructor( - readonly pathHelper:PathHelperService, - readonly I18n:I18nService, - readonly $state:StateService, - readonly uiRouterGlobals:UIRouterGlobals, - readonly wpTableSelection:WorkPackageViewSelectionService, - readonly wpTableFocus:WorkPackageViewFocusService, - readonly cardView:WorkPackageCardViewService, - readonly cdRef:ChangeDetectorRef, - readonly timezoneService:TimezoneService, - readonly schemaCache:SchemaCacheService, - ) { - super(); - } - ngOnInit():void { // Update selection state combineLatest([ @@ -207,7 +207,7 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen } public fullWorkPackageLink(wp:WorkPackageResource):string { - return this.$state.href('work-packages.show', { workPackageId: wp.id }); + return this.keepTabService.currentShowHref(wp.id!); } public cardHighlightingClass(wp:WorkPackageResource):string { diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts index 61869ae7a6f..d86cf6c42ae 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.ts @@ -73,8 +73,12 @@ export class KeepTabService { } public goCurrentShowState(workPackageId:string):void { + window.location.href = this.currentShowHref(workPackageId); + } + + public currentShowHref(workPackageId:string):string { const projectIdentifier = this.currentProject.identifier; - window.location.href = this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId, this.currentShowTab) + window.location.search; + return this.pathHelper.genericWorkPackagePath(projectIdentifier, workPackageId, this.currentShowTab) + window.location.search; } public goCurrentDetailsState(params:Record = {}):void { diff --git a/frontend/src/global_styles/content/_table.sass b/frontend/src/global_styles/content/_table.sass index 5a8ee2a1dd8..f44c8a9fd39 100644 --- a/frontend/src/global_styles/content/_table.sass +++ b/frontend/src/global_styles/content/_table.sass @@ -150,6 +150,7 @@ table.generic-table border-bottom: 1px solid var(--borderColor-default) @media screen + th[scope="row"]:not(.-no-ellipsis), td:not(.-no-ellipsis) @include text-shortener @@ -162,6 +163,10 @@ table.generic-table position: relative top: 1px + th[scope="row"] + font-weight: var(--base-text-weight-bold) + + th[scope="row"], td max-width: 300px text-align: left @@ -172,6 +177,12 @@ table.generic-table &.form--td vertical-align: middle + &.-centered + text-align: center + + &.-right + text-align: right + // Center input fields and select boxes vertically in tables .form--field margin: 0px @@ -180,7 +191,9 @@ table.generic-table #{$inputElement}~.form-label vertical-align: middle - input[type="checkbox"], input[type="radio"] + // N.B. we only adjust margin on non-Primerized controls + input[type="checkbox"]:not(.FormControl-checkbox), + input[type="radio"]:not(.FormControl-radio) margin-top: -0.25rem // In the interactive table the behaviour is like this: @@ -200,9 +213,10 @@ table.generic-table width: 100% max-width: 500px - &.-min-200 - @media screen - min-width: 200px + @each $size in (200, 300) + &.-min-#{$size} + @media screen + min-width: #{$size}px // The avatar image is not taken into the width calculation of the table cell by the browser. // That is why we add the space manually. diff --git a/frontend/src/global_styles/content/work_packages/_workflows.sass b/frontend/src/global_styles/content/work_packages/_workflows.sass index 1ca0b121457..c9791286de5 100644 --- a/frontend/src/global_styles/content/work_packages/_workflows.sass +++ b/frontend/src/global_styles/content/work_packages/_workflows.sass @@ -51,21 +51,6 @@ 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 @@ -73,7 +58,7 @@ td:first-child:not(:has(.workflow-table--turned-header)), th:first-child position: sticky - left: -1rem + left: 0 background: var(--body-background) z-index: 2 box-shadow: 0 2px 4px rgba(0,0,0,0.08) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 51b5aa0421c..a463e38c056 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -136,3 +136,15 @@ ul.SegmentedControl, // we override ordering to display help text after the asterisk > .op-attribute-help-text order: 1 + +.Truncate + &.Truncate--expanded + max-width: none + min-width: 0 + overflow: visible + white-space: normal + + .Truncate-text + white-space: normal + overflow: visible + text-overflow: clip diff --git a/frontend/src/stimulus/controllers/table-highlighting.controller.ts b/frontend/src/stimulus/controllers/table-highlighting.controller.ts index 4702a8aa0ca..6177bf48ec7 100644 --- a/frontend/src/stimulus/controllers/table-highlighting.controller.ts +++ b/frontend/src/stimulus/controllers/table-highlighting.controller.ts @@ -28,65 +28,66 @@ * ++ */ -import { ApplicationController } from 'stimulus-use'; +import { Controller } from '@hotwired/stimulus'; -export default class TableHighlightingController extends ApplicationController { - private thead:HTMLElement; - private colgroup:HTMLElement; +export default class TableHighlightingController extends Controller { + private thead:HTMLTableSectionElement|null = null; + private colgroup:HTMLTableColElement|null = null; + private abortController:AbortController|null = null; - connect() { - const thead = this.element.querySelector('thead'); - const colgroup = this.element.querySelector('colgroup'); + connect():void { + this.thead = this.element.tHead; + this.colgroup = this.element.querySelector(':scope > colgroup'); - if (thead && colgroup) { - this.thead = thead; - this.colgroup = colgroup; - - this.thead.addEventListener('mouseover', this.hover); - this.thead.addEventListener('mouseout', this.unhover); + if (!this.thead || !this.colgroup) { + return; } + + this.abortController = new AbortController(); + const { signal } = this.abortController; + + // N.B. Using capture phase to enable event delegation on th elements + this.thead.addEventListener('mouseenter', this.onEnter, { capture: true, signal }); + this.thead.addEventListener('mouseleave', this.onLeave, { capture: true, signal }); } - disconnect() { - super.disconnect(); + disconnect():void { + this.abortController?.abort(); - if (this.thead && this.colgroup) { - this.thead.removeEventListener('mouseover', this.hover); - this.thead.removeEventListener('mouseout', this.unhover); - } + this.thead = null; + this.colgroup = null; } - private hover = (evt:MouseEvent) => { - const col = this.getColumn(evt.target as HTMLElement); + private onEnter = ({ target }:Event):void => { + const col = this.resolveColumn(target); col?.classList.add('hover'); }; - private unhover = (evt:MouseEvent) => { - const col = this.getColumn(evt.target as HTMLElement); + private onLeave = ({ target }:Event):void => { + const col = this.resolveColumn(target); col?.classList.remove('hover'); }; - private getColumn(target:HTMLElement):HTMLElement|null { - const th = target.closest('th') as HTMLElement; - const index = this.parentIndex(th); - - if (index === null) { + private resolveColumn(target:EventTarget|null):HTMLTableColElement|null { + if (!(target instanceof HTMLElement)) { return null; } - const col = this.colgroup.children.item(index) as HTMLElement|null; + const th = target.closest('th'); + if (!th || !this.colgroup) { + return null; + } + + const index = th.cellIndex; + if (index < 0) { + return null; + } + + const col = this.colgroup.children.item(index) as HTMLTableColElement|null; if (!col || col.dataset.highlight === 'false') { return null; } return col; } - - private parentIndex(element:HTMLElement):number|null { - if (element.parentElement) { - return Array.from(element.parentElement.children).indexOf(element); - } - - return null; - } } diff --git a/frontend/src/stimulus/controllers/truncation.controller.spec.ts b/frontend/src/stimulus/controllers/truncation.controller.spec.ts new file mode 100644 index 00000000000..11082b349b5 --- /dev/null +++ b/frontend/src/stimulus/controllers/truncation.controller.spec.ts @@ -0,0 +1,404 @@ +/* + * -- 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. + * ++ + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Application } from '@hotwired/stimulus'; +import TruncationController from './truncation.controller'; + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +describe('TruncationController', () => { + let Stimulus:Application; + let fixturesElement:HTMLElement; + let originalI18n:any; + + beforeEach(() => { + fixturesElement = document.createElement('div'); + document.body.appendChild(fixturesElement); + + // Save original I18n and configure translations + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + originalI18n = (window as any).I18n; + if (originalI18n && typeof originalI18n.store === 'function') { + originalI18n.store({ + en: { + js: { + label_expand_text: 'Expand text', + label_collapse_text: 'Collapse text', + }, + }, + }); + } + }); + + beforeEach(async () => { + Stimulus = Application.start(); + Stimulus.handleError = (error, message, detail) => { + console.error(error, message, detail); + }; + Stimulus.register('truncation', TruncationController); + await nextFrame(); + }); + + const truncationTemplate = ` +
+
+ + This is a very long text that should be truncated when it exceeds the container width + +
+
+ +
+
+ `; + + function appendTemplate(html:string) { + const template = document.createElement('template'); + template.innerHTML = html.trim(); + fixturesElement.appendChild(template.content.cloneNode(true)); + } + + describe('initialization', () => { + beforeEach(async () => { + appendTemplate(truncationTemplate); + await nextFrame(); + }); + + it('connects successfully', () => { + const controller = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + expect(controller).toBeDefined(); + }); + + it('sets initial aria attributes on expander button', () => { + const button = document.querySelector('[data-truncation-target="expander"] button')!; + + expect(button.getAttribute('aria-label')).toBe('Expand text'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + }); + + it('adds Truncate--expanded class when expanded value is true', async () => { + const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + controller.expandedValue = true; + await nextFrame(); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + }); + }); + + describe('expander button click', () => { + beforeEach(async () => { + appendTemplate(truncationTemplate); + await nextFrame(); + }); + + it('toggles expanded state', async () => { + const button = document.querySelector('[data-truncation-target="expander"] button')!; + const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(button.getAttribute('aria-expanded')).toBe('false'); + + button.click(); + await nextFrame(); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + expect(button.getAttribute('aria-expanded')).toBe('true'); + expect(button.getAttribute('aria-label')).toBe('Collapse text'); + + button.click(); + await nextFrame(); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(button.getAttribute('aria-label')).toBe('Expand text'); + }); + }); + + describe('expandedValue changes', () => { + beforeEach(async () => { + appendTemplate(truncationTemplate); + await nextFrame(); + }); + + it('updates aria-label when expanded', async () => { + const button = document.querySelector('[data-truncation-target="expander"] button')!; + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + expect(button.getAttribute('aria-label')).toBe('Expand text'); + + controller.expandedValue = true; + await nextFrame(); + + expect(button.getAttribute('aria-label')).toBe('Collapse text'); + }); + + it('updates aria-expanded attribute', async () => { + const button = document.querySelector('[data-truncation-target="expander"] button')!; + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + expect(button.getAttribute('aria-expanded')).toBe('false'); + + controller.expandedValue = true; + await nextFrame(); + + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('toggles Truncate--expanded class', async () => { + const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + + controller.expandedValue = true; + await nextFrame(); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + + controller.expandedValue = false; + await nextFrame(); + + expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + }); + }); + + describe('expander visibility', () => { + // Helper to wait for ResizeObserver to process updates + const waitForResize = async () => { + // Wait multiple frames to ensure ResizeObserver has fired + await nextFrame(); + await nextFrame(); + }; + + it('hides expander when content is not truncated', async () => { + const shortTextTemplate = ` +
+
+ + Short text + +
+
+ +
+
+ `; + + appendTemplate(shortTextTemplate); + await waitForResize(); + + const expander = document.querySelector('[data-truncation-target="expander"]')!; + + // When content is not truncated, expander should be hidden + expect(expander.hidden).toBeTrue(); + }); + + it('shows expander when content is truncated', async () => { + const longTextTemplate = ` +
+
+ + This is a very long text that should definitely be truncated + +
+
+ +
+
+ `; + + appendTemplate(longTextTemplate); + await waitForResize(); + + const expander = document.querySelector('[data-truncation-target="expander"]')!; + + // When content is truncated, expander should be visible + expect(expander.hidden).toBeFalse(); + }); + }); + + describe('resize() method', () => { + it('calls update() when resize is triggered', async () => { + const template = ` +
+
+ + Test text + +
+
+ +
+
+ `; + + appendTemplate(template); + await nextFrame(); + + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + + // Spy on the private update method to verify resize() calls it + const updateSpy = spyOn(controller, 'update').and.callThrough(); + + controller.resize(); + + expect(updateSpy).toHaveBeenCalledWith(); + }); + + it('updates expander visibility when content dimensions change', async () => { + const template = ` +
+
+ + Test + +
+
+ +
+
+ `; + + appendTemplate(template); + await nextFrame(); + + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + const expander = document.querySelector('[data-truncation-target="expander"]')!; + const truncateText = document.querySelector('.Truncate-text')!; + + // Mock scrollWidth and clientWidth to simulate truncation state + const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth'); + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + + // Simulate not truncated: scrollWidth === clientWidth + Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 100 }); + Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); + controller.resize(); + + expect(expander.hidden).toBeTrue(); + + // Simulate truncated: scrollWidth > clientWidth + Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 200 }); + Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); + controller.resize(); + + expect(expander.hidden).toBeFalse(); + + // Simulate not truncated again + Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 50 }); + Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 50 }); + controller.resize(); + + expect(expander.hidden).toBeTrue(); + + // Restore original descriptors + if (originalScrollWidth) { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', originalScrollWidth); + } + if (originalClientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth); + } + }); + + it('keeps expander visible when expanded even if not truncated', async () => { + const template = ` +
+
+ + Short + +
+
+ +
+
+ `; + + appendTemplate(template); + await nextFrame(); + + const controller:any = Stimulus.getControllerForElementAndIdentifier( + document.querySelector('[data-controller="truncation"]')!, + 'truncation', + ); + const expander = document.querySelector('[data-truncation-target="expander"]')!; + + // Initially short text, expander should be hidden + controller.resize(); + + expect(expander.hidden).toBeTrue(); + + // Expand the text + controller.expandedValue = true; + await nextFrame(); + + // When expanded, expander should remain visible even if not truncated + expect(expander.hidden).toBeFalse(); + }); + }); + + afterEach(() => { + fixturesElement.remove(); + Stimulus.stop(); + // Restore original I18n + if (originalI18n) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + (window as any).I18n = originalI18n; + } + }); +}); diff --git a/frontend/src/stimulus/controllers/truncation.controller.ts b/frontend/src/stimulus/controllers/truncation.controller.ts new file mode 100644 index 00000000000..8be1475c4f8 --- /dev/null +++ b/frontend/src/stimulus/controllers/truncation.controller.ts @@ -0,0 +1,88 @@ +/* + * -- 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 { useResize } from 'stimulus-use'; + +export default class TruncationController extends Controller { + static targets = ['truncate', 'expander']; + static values = { expanded: Boolean }; + + declare readonly truncateTarget:HTMLElement; + declare readonly expanderTarget:HTMLElement; + declare expandedValue:boolean; + declare readonly expandLabelValue:string; + declare readonly collapseLabelValue:string; + + private abortController:AbortController|null = null; + + connect() { + useResize(this, { element: this.truncateTarget }); + this.update(); + } + + resize() { + this.update(); + } + + expanderTargetConnected(_target:HTMLElement) { + this.abortController = new AbortController(); + const { signal } = this.abortController; + this.expanderButton.addEventListener('click', () => this.expanderClicked(), { signal }); + } + + expanderTargetDisconnected(_target:HTMLElement) { + this.abortController?.abort(); + } + + expandedValueChanged(value:boolean) { + this.expanderButton.setAttribute('aria-label', value ? I18n.t('js.label_collapse_text') : I18n.t('js.label_expand_text')); + this.expanderButton.setAttribute('aria-expanded', String(value)); + this.truncateTarget.classList.toggle('Truncate--expanded', value); + this.update(); // Redundant call to ensure state consistency; the resize observer will likely trigger this anyway. + } + + get truncateText():HTMLElement { + return this.truncateTarget.querySelector('.Truncate-text')!; + } + + get expanderButton():HTMLButtonElement { + return this.expanderTarget.querySelector('button')!; + } + + private update() { + const truncated = this.truncateText.scrollWidth > this.truncateText.clientWidth; + this.expanderTarget.hidden = !truncated && !this.expandedValue; + } + + private expanderClicked() { + this.expandedValue = !this.expandedValue; + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 0955d67c814..adf2a099326 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -39,6 +39,7 @@ import SelectAutosizeController from 'core-stimulus/controllers/select-autosize. import OpZenModeController from 'core-stimulus/controllers/zen-mode.controller'; import CheckAllController from 'core-stimulus/controllers/check-all.controller'; import CheckableController from 'core-stimulus/controllers/checkable.controller'; +import TruncationController from 'core-stimulus/controllers/truncation.controller'; declare global { interface Window { @@ -83,6 +84,7 @@ OpenProjectStimulusApplication.preregister('select-autosize', SelectAutosizeCont OpenProjectStimulusApplication.preregister('editable-page-header-title', EditablePageHeaderTitleController); OpenProjectStimulusApplication.preregister('check-all', CheckAllController); OpenProjectStimulusApplication.preregister('checkable', CheckableController); +OpenProjectStimulusApplication.preregister('truncation', TruncationController); const instance = OpenProjectStimulusApplication.start(); window.Stimulus = instance; diff --git a/modules/reporting/lib/widget/filters/user.rb b/modules/reporting/lib/widget/filters/user.rb index 73101ddc4c7..34348623e93 100644 --- a/modules/reporting/lib/widget/filters/user.rb +++ b/modules/reporting/lib/widget/filters/user.rb @@ -39,6 +39,7 @@ class Widget::Filters::User < Widget::Filters::Base box = angular_component_tag "opce-user-autocompleter", inputs: { + appendTo: "body", InputName: "values[#{filter_class.underscore_name}]", hiddenFieldAction: "change->reporting--page#selectValueChanged", multiple: true, diff --git a/modules/reporting/spec/features/me_value_spec.rb b/modules/reporting/spec/features/me_value_spec.rb index d335bee08ce..26987567bc3 100644 --- a/modules/reporting/spec/features/me_value_spec.rb +++ b/modules/reporting/spec/features/me_value_spec.rb @@ -38,7 +38,7 @@ RSpec.describe "Cost report showing my own times", :js do user_autocompleter = find("opce-user-autocompleter##{filter_selector}") ng_select_clear(user_autocompleter, raise_on_missing: false) - select_autocomplete(user_autocompleter, query: "me") + select_autocomplete(user_autocompleter, query: "me", results_selector: "body") click_on "Save" fill_in "query_name", with: "Query ME value" diff --git a/spec/components/open_project/common/check_all_component_spec.rb b/spec/components/open_project/common/check_all_component_spec.rb index 7b4f6fa454f..a4543558489 100644 --- a/spec/components/open_project/common/check_all_component_spec.rb +++ b/spec/components/open_project/common/check_all_component_spec.rb @@ -78,6 +78,14 @@ RSpec.describe OpenProject::Common::CheckAllComponent, type: :component do it "sets aria-controls attribute on 'Uncheck all'" do expect(rendered_component).to have_button "Uncheck all", aria: { controls: "foo" } end + + it "applies an ID to 'Check all'" do + expect(subject).to have_button id: "foo-check-all" + end + + it "applies an ID to 'Uncheck all'" do + expect(subject).to have_button id: "foo-uncheck-all" + end end context "when :checkable_id is nil" do @@ -114,5 +122,13 @@ RSpec.describe OpenProject::Common::CheckAllComponent, type: :component do expect(button["aria-controls"]).to be_nil end end + + it "applies an ID to 'Check all'" do + expect(subject).to have_button id: /check-all-component-([\w-]+)-check-all/ + end + + it "applies an ID to 'Uncheck all'" do + expect(subject).to have_button id: /check-all-component-([\w-]+)-uncheck-all/ + end end end diff --git a/spec/controllers/workflows_controller_spec.rb b/spec/controllers/workflows_controller_spec.rb index b408ccacaea..4a2009207f7 100644 --- a/spec/controllers/workflows_controller_spec.rb +++ b/spec/controllers/workflows_controller_spec.rb @@ -79,7 +79,7 @@ RSpec.describe WorkflowsController do current_user { build_stubbed(:admin) } describe "#index" do - let(:counts) { instance_double(Hash) } + let(:counts) { [] } before do allow(Workflow) @@ -94,9 +94,29 @@ RSpec.describe WorkflowsController do .to be_successful end - it "assigns the workflows by type and role" do - expect(assigns[:workflow_counts]) - .to eql counts + context "when counts is empty" do + it "assigns the workflows by type and role" do + expect(assigns[:workflow_counts]).to eql counts + end + + it "assigns roles" do + expect(assigns[:roles]).to be_nil + end + end + + context "when counts is present" do + let(:type) { build_stubbed(:type) } + let(:project_role) { build_stubbed(:project_role) } + let(:global_role) { build_stubbed(:global_role) } + let(:counts) { [[type, [[project_role, 25], [global_role, 0]]]] } + + it "assigns the workflows by type and role" do + expect(assigns[:workflow_counts]).to eql counts + end + + it "assigns roles" do + expect(assigns[:roles]).to contain_exactly(project_role, global_role) + end end end diff --git a/spec/features/roles/create_spec.rb b/spec/features/roles/create_spec.rb index d6742f11796..adbfb2e7e9a 100644 --- a/spec/features/roles/create_spec.rb +++ b/spec/features/roles/create_spec.rb @@ -112,9 +112,9 @@ RSpec.describe "Role creation", :js do select type.name, from: "Type" click_button "Edit" - from_id = existing_workflow.old_status_id - to_id = existing_workflow.new_status_id + old_status = existing_workflow.old_status.name + new_status = existing_workflow.new_status.name - expect(page).to have_field("status_#{from_id}_#{to_id}_", checked: true) + expect(page).to have_checked_field("Allow transition from #{old_status} to #{new_status}") end end diff --git a/spec/features/roles/report_spec.rb b/spec/features/roles/report_spec.rb new file mode 100644 index 00000000000..b23d55c8184 --- /dev/null +++ b/spec/features/roles/report_spec.rb @@ -0,0 +1,146 @@ +# 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 "rails_helper" + +RSpec.describe "Roles report", :js, :selenium do + shared_let(:admin) { create(:admin) } + let(:project) { create(:project, name: "Project 1", identifier: "project1") } + let(:permissions) { %i[view_project permission1 permission2] } + let!(:role1) { create(:global_role, permissions:, name: "Global IT MGMT") } + let!(:role2) { create(:global_role, permissions:, name: "Unsure Off-Shore") } + + current_user { admin } + + before do + visit report_roles_path + end + + it "allows checking and unchecking by row" do + expect(page).to have_heading "Permissions report" + expect(page).to be_axe_clean + .within("#content") + .skipping("nested-interactive") # TODO: fix Collapsible Sections + + expect(page).to have_region "Project" + + within_region "Project" do + expect(page).to have_selector :table, "Permissions matrix for Project module" + + expect(page).to have_unchecked_field "Assign Create projects permission to Global IT MGMT role" + + check "Assign Create projects permission to Global IT MGMT role" + + # mixed state + expect(page).to have_checked_field "Assign Create projects permission to Global IT MGMT role" + + row = find(:row, "Create projects") + row.click_on accessible_name: "Toggle Create projects permission for all roles" + # stays checked + expect(page).to have_checked_field "Assign Create projects permission to Global IT MGMT role" + # mixed -> all checked + expect(row.all(:checkbox, minimum: 1)).to all(match_selector(:checkbox, checked: true)) + + row.click_on accessible_name: "Toggle Create projects permission for all roles" + # all checked -> all unchecked + expect(page).to have_unchecked_field "Assign Create projects permission to Global IT MGMT role" + expect(row.all(:checkbox, minimum: 1)).to all(match_selector(:checkbox, unchecked: true)) + end + + click_on "Save" + + expect_and_dismiss_flash type: :success, message: "Successful update." + end + + it "allows checking and unchecking by column" do + expect(page).to have_heading "Permissions report" + expect(page).to be_axe_clean + .within("#content") + .skipping("nested-interactive") # TODO: fix Collapsible Sections + + expect(page).to have_region "Project" + + within_region "Project" do + expect(page).to have_selector :table, "Permissions matrix for Project module" + + expect(page).to have_unchecked_field "Assign Create projects permission to Global IT MGMT role" + + check "Assign Create projects permission to Global IT MGMT role" + + # mixed state + expect(page).to have_checked_field "Assign Create projects permission to Global IT MGMT role" + + col_header = find("th", text: "GLOBAL IT MGMT") + col_header.click_on accessible_name: "Toggle all Project permissions for Global IT MGMT role" + + # stays checked + expect(page).to have_checked_field "Assign Create projects permission to Global IT MGMT role" + # mixed -> all checked + col_index = col_header.all(:xpath, "preceding-sibling::th").size + 1 + all_checkboxes = all("tbody tr td:nth-child(#{col_index})").flat_map { it.all(:checkbox, wait: 0) } + expect(all_checkboxes).to all(match_selector(:checkbox, checked: true)) + + col_header.click_on accessible_name: "Toggle all Project permissions for Global IT MGMT role" + # all checked -> all unchecked + expect(page).to have_unchecked_field "Assign Create projects permission to Global IT MGMT role" + all_checkboxes = all("tbody tr td:nth-child(#{col_index})").flat_map { it.all(:checkbox, wait: 0) } + expect(all_checkboxes).to all(match_selector(:checkbox, unchecked: true)) + end + + click_on "Save" + + expect_and_dismiss_flash type: :success, message: "Successful update." + end + + it "allows checking and unchecking all" do + expect(page).to have_heading "Permissions report" + expect(page).to be_axe_clean + .within("#content") + .skipping("nested-interactive") # TODO: fix Collapsible Sections + + within("#project-section") do # FIXME: collapsible section semantics + expect(page).to have_unchecked_field "Create projects" + + click_on "Check all" + + expect(page).to have_checked_field "Create projects" + expect(all(:checkbox)).to all(match_selector(:checkbox, checked: true)) + + click_on "Uncheck all" + + expect(page).to have_unchecked_field "Create projects" + expect(all(:checkbox)).to all(match_selector(:checkbox, unchecked: true)) + end + + click_on "Save" + + expect_and_dismiss_flash type: :success, message: "Successful update." + end +end diff --git a/spec/features/workflows/edit_spec.rb b/spec/features/workflows/edit_spec.rb index 9c32a20f34a..548fac07333 100644 --- a/spec/features/workflows/edit_spec.rb +++ b/spec/features/workflows/edit_spec.rb @@ -56,7 +56,7 @@ RSpec.describe "Workflow edit" do click_button "Edit" within "#workflow_form_always" do - check "status_#{statuses[1].id}_#{statuses[2].id}_" + check "status_#{statuses[1].id}_#{statuses[2].id}" end click_button "Save" @@ -65,18 +65,18 @@ RSpec.describe "Workflow edit" do within "#workflow_form_always" do expect(page) - .to have_field "status_#{statuses[0].id}_#{statuses[1].id}_", checked: true + .to have_field "status_#{statuses[0].id}_#{statuses[1].id}", checked: true expect(page) - .to have_field "status_#{statuses[1].id}_#{statuses[2].id}_", checked: true + .to have_field "status_#{statuses[1].id}_#{statuses[2].id}", checked: true expect(page) - .to have_field "status_#{statuses[0].id}_#{statuses[2].id}_", checked: false + .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 + .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 + .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 + .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 @@ -95,7 +95,7 @@ RSpec.describe "Workflow edit" do click_button "Edit" within "#workflow_form_author" do - check "status_#{statuses[2].id}_#{statuses[1].id}_" + check "status_#{statuses[2].id}_#{statuses[1].id}" end click_button "Save" @@ -104,18 +104,18 @@ RSpec.describe "Workflow edit" do within "#workflow_form_author" do expect(page) - .to have_field "status_#{statuses[2].id}_#{statuses[1].id}_", checked: true + .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 + .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 + .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 + .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 + .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 + .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 @@ -136,7 +136,7 @@ RSpec.describe "Workflow edit" do click_button "Edit" within "#workflow_form_assignee" do - check "status_#{statuses[2].id}_#{statuses[0].id}_" + check "status_#{statuses[2].id}_#{statuses[0].id}" end click_button "Save" @@ -145,18 +145,18 @@ RSpec.describe "Workflow edit" do within "#workflow_form_assignee" do expect(page) - .to have_field "status_#{statuses[2].id}_#{statuses[0].id}_", checked: true + .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 + .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 + .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 + .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 + .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 + .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 diff --git a/spec/models/exports/pdf/common/macro_spec.rb b/spec/models/exports/pdf/common/macro_spec.rb index 8511fae3d3a..2472ac2ad04 100644 --- a/spec/models/exports/pdf/common/macro_spec.rb +++ b/spec/models/exports/pdf/common/macro_spec.rb @@ -381,6 +381,14 @@ RSpec.describe Exports::PDF::Common::Macro do end end + describe "with two macros in a single line" do + let(:markdown) { 'workPackageValue:"Custom Field 1" workPackageValue:subject' } + + it "renders both macro values" do + expect(formatted).to eq("Custom value 1 Work package 1") + end + end + describe "with markdown formatting" do let(:markdown) { "**workPackageValue:subject**" }