mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge branch 'release/17.0' into dev
This commit is contained in:
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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.
|
||||
<table
|
||||
class="generic-table"
|
||||
data-controller="checkable table-highlighting">
|
||||
<caption class="sr-only">
|
||||
<%= t(".matrix_caption", module: module_title) %>
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col data-highlight="false">
|
||||
<% @roles.size.times do |role| %>
|
||||
<col>
|
||||
<% end %>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -87,22 +94,29 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
</th>
|
||||
<% @roles.each do |role| %>
|
||||
<th>
|
||||
<div class="generic-table--sort-header-outer">
|
||||
<div class="generic-table--sort-header-outer p-0">
|
||||
<div class="generic-table--sort-header">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,9 +139,22 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
</thead>
|
||||
<tbody>
|
||||
<% mod_permissions.each do |permission| %>
|
||||
<% humanized_permission_name = l_or_humanize(permission.name, prefix: "permission_") %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %>
|
||||
<th scope="row" class="-min-300">
|
||||
<%=
|
||||
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 %>
|
||||
</td>
|
||||
<% @roles.each do |role| %>
|
||||
</th>
|
||||
<% @roles.each.with_index do |role, i| %>
|
||||
<td>
|
||||
<% 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 %>
|
||||
</td>
|
||||
@@ -177,5 +231,5 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<p><%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %></p>
|
||||
<%= render Roles::Save.new(f) %>
|
||||
<% end %>
|
||||
|
||||
@@ -42,57 +42,63 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<div class="generic-table--results-container">
|
||||
<table
|
||||
class="generic-table workflow-table transitions-<%= name %>"
|
||||
data-controller="checkable">
|
||||
data-controller="checkable table-highlighting">
|
||||
<caption class="sr-only">
|
||||
<%= t(".matrix_caption_#{name}", default: t(".matrix_caption")) %>
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col data-highlight="false">
|
||||
<% @statuses.size.times do |role| %>
|
||||
<col>
|
||||
<% end %>
|
||||
</colgroup>
|
||||
<thead class="-sticky">
|
||||
<tr>
|
||||
<th></th>
|
||||
<th colspan="<%= @statuses.length %>">
|
||||
<th colspan="<%= @statuses.size %>" class="-max">
|
||||
<div class="generic-table--sort-header-outer -no-border">
|
||||
<div class="generic-table--sort-header workflow-table--header">
|
||||
<span>
|
||||
<%= t(:label_new_statuses_allowed) %>
|
||||
</span>
|
||||
<span class="workflow-table--check-all">
|
||||
<%= check_all_links "workflow_form_#{name}" %>
|
||||
</span>
|
||||
<div class="generic-table--sort-header">
|
||||
<%=
|
||||
render(
|
||||
Primer::BaseComponent.new(
|
||||
tag: :div,
|
||||
display: :flex,
|
||||
align_items: :center,
|
||||
classes: "gap-2",
|
||||
mx: 1
|
||||
)
|
||||
) do
|
||||
%>
|
||||
<span class="flex-1">
|
||||
<%= t(:label_new_statuses_allowed) %>
|
||||
</span>
|
||||
<%=
|
||||
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 %>
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="-table-border-bottom -table-border-right"></th>
|
||||
<% for new_status in @statuses %>
|
||||
<th class="workflow-table--current-status -table-border-top -table-border-bottom">
|
||||
<%= 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 %>
|
||||
</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for old_status in @statuses %>
|
||||
<tr class="-table-border-left">
|
||||
<td class="workflow-table--current-status -table-border-right">
|
||||
<%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, align_items: :center)) do %>
|
||||
<% @statuses.each do |new_status| %>
|
||||
<th class="workflow-table--current-status -table-border-top -table-border-bottom">
|
||||
<%=
|
||||
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 %>
|
||||
</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @statuses.each do |old_status| %>
|
||||
<tr class="-table-border-left">
|
||||
<th scope="row" class="workflow-table--current-status -table-border-right">
|
||||
<%=
|
||||
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 %>
|
||||
</td>
|
||||
<% for new_status in @statuses -%>
|
||||
</th>
|
||||
<% @statuses.each do |new_status| -%>
|
||||
<td>
|
||||
<%=
|
||||
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
|
||||
%>
|
||||
</td>
|
||||
<% end -%>
|
||||
|
||||
@@ -37,17 +37,14 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
<table class="generic-table workflow-table" data-controller="table-highlighting" id="workflow_summary">
|
||||
<colgroup>
|
||||
<col data-highlight="false">
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col>
|
||||
<col data-highlight="false">
|
||||
<% @roles.size.times do |role| %>
|
||||
<col>
|
||||
<% end %>
|
||||
</colgroup>
|
||||
<thead class="-sticky">
|
||||
<tr>
|
||||
<th><div class="generic-table--empty-header"></div></th>
|
||||
<% @workflow_counts.first.last.each do |role, count| %>
|
||||
<% @roles.each do |role| %>
|
||||
<th>
|
||||
<div class="generic-table--sort-header-outer">
|
||||
<div class="generic-table--sort-header">
|
||||
|
||||
@@ -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 <em>%{permission}</em> permission for all roles"
|
||||
matrix_check_uncheck_all_in_col_label_html: "Toggle all %{module} permissions for <em>%{role}</em> 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 <em>%{old_status}</em> to all new statuses"
|
||||
matrix_check_uncheck_all_in_col_label_html: "Toggle transitions from all old statuses to <em>%{new_status}</em>"
|
||||
|
||||
work_flows:
|
||||
index:
|
||||
no_results_title_text: There are currently no workflows.
|
||||
|
||||
@@ -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."
|
||||
|
||||
+17
-17
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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<string, unknown> = {}):void {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<HTMLTableElement> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
|
||||
This is a very long text that should be truncated when it exceeds the container width
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<HTMLButtonElement>('[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<HTMLElement>('[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<HTMLButtonElement>('[data-truncation-target="expander"] button')!;
|
||||
const truncateEl = document.querySelector<HTMLElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLElement>('[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 = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 500px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
|
||||
Short text
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
appendTemplate(shortTextTemplate);
|
||||
await waitForResize();
|
||||
|
||||
const expander = document.querySelector<HTMLElement>('[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 = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 50px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap; width: 300px;">
|
||||
This is a very long text that should definitely be truncated
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
appendTemplate(longTextTemplate);
|
||||
await waitForResize();
|
||||
|
||||
const expander = document.querySelector<HTMLElement>('[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 = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
|
||||
Test text
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<any>(controller, 'update').and.callThrough();
|
||||
|
||||
controller.resize();
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('updates expander visibility when content dimensions change', async () => {
|
||||
const template = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 100px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
|
||||
Test
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
appendTemplate(template);
|
||||
await nextFrame();
|
||||
|
||||
const controller:any = Stimulus.getControllerForElementAndIdentifier(
|
||||
document.querySelector('[data-controller="truncation"]')!,
|
||||
'truncation',
|
||||
);
|
||||
const expander = document.querySelector<HTMLElement>('[data-truncation-target="expander"]')!;
|
||||
const truncateText = document.querySelector<HTMLElement>('.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 = `
|
||||
<div data-controller="truncation" data-truncation-expanded-value="false">
|
||||
<div data-truncation-target="truncate" style="width: 200px; overflow: hidden;">
|
||||
<span class="Truncate-text" style="display: inline-block; white-space: nowrap;">
|
||||
Short
|
||||
</span>
|
||||
</div>
|
||||
<div data-truncation-target="expander">
|
||||
<button type="button">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
appendTemplate(template);
|
||||
await nextFrame();
|
||||
|
||||
const controller:any = Stimulus.getControllerForElementAndIdentifier(
|
||||
document.querySelector('[data-controller="truncation"]')!,
|
||||
'truncation',
|
||||
);
|
||||
const expander = document.querySelector<HTMLElement>('[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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement> {
|
||||
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<HTMLElement>('.Truncate-text')!;
|
||||
}
|
||||
|
||||
get expanderButton():HTMLButtonElement {
|
||||
return this.expanderTarget.querySelector<HTMLButtonElement>('button')!;
|
||||
}
|
||||
|
||||
private update() {
|
||||
const truncated = this.truncateText.scrollWidth > this.truncateText.clientWidth;
|
||||
this.expanderTarget.hidden = !truncated && !this.expandedValue;
|
||||
}
|
||||
|
||||
private expanderClicked() {
|
||||
this.expandedValue = !this.expandedValue;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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**" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user