diff --git a/.gitignore b/.gitignore index 0a27bf8e565e..a677a999f576 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,8 @@ structure.sql lefthook-local.yml .rubocop-local.yml +/.lefthook-local/ + frontend/package-lock.json # Testing and nextcloud infrastructure diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 3f540eac0ecd..67cb4292198f 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -29,8 +29,9 @@ #++ module Projects class RowComponent < ::RowComponent - delegate :favored_project_ids, to: :table delegate :identifier, to: :project + delegate :favored_project_ids, to: :table + delegate :project_life_cycle_step_by_definition, to: :table def project model.first @@ -69,6 +70,8 @@ def currently_favored? def column_value(column) if custom_field_column?(column) custom_field_column(column) + elsif life_cycle_step_column?(column) + life_cycle_step_column(column) else send(column.attribute) end @@ -94,6 +97,16 @@ def custom_field_column(column) end end + def life_cycle_step_column(column) + return nil unless user_can_view_project? + + life_cycle_step = project_life_cycle_step_by_definition(column.life_cycle_step_definition, project) + + return nil if life_cycle_step.blank? + + fmt_date_or_range(life_cycle_step.start_date, life_cycle_step.end_date) + end + def created_at helpers.format_date(project.created_at) end @@ -374,8 +387,29 @@ def custom_field_column?(column) column.is_a?(::Queries::Projects::Selects::CustomField) end + def life_cycle_step_column?(column) + column.is_a?(::Queries::Projects::Selects::LifeCycleStep) + end + def current_page table.model.current_page.to_s end + + private + + # If only the `start_date` is given, will return a formatted version of that date as string. + # When `end_date` is given as well, will return a representation of the date range from start to end. + # @example + # fmt_date_or_range(Date.new(2024, 12, 4)) + # "04/12/2024" + # + # fmt_date_or_range(Date.new(2024, 12, 4), Date.new(2024, 12, 10)) + # "04/12/2024 - 10/12/2024" + def fmt_date_or_range(start_date, end_date = nil) + [start_date, end_date] + .compact + .map { |d| helpers.format_date(d) } + .join(" - ") + end end end diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index a114faeff841..9f33feb4aeea 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details. <% else %> <% if use_quick_action_table_headers? %> - <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> + <%= quick_action_table_header column, order_options(column, turbo: true) %> <% else %>
diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 7958c81df2ad..e7d8d3da22b8 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -195,6 +195,14 @@ def favored_project_ids @favored_project_ids ||= Favorite.where(user: current_user, favored_type: "Project").pluck(:favored_id) end + def project_life_cycle_step_by_definition(definition, project) + @project_life_cycle_steps_by_definition ||= Project::LifeCycleStep + .where(active: true) + .index_by { |s| [s.definition_id, s.project_id] } + + @project_life_cycle_steps_by_definition[[definition.id, project.id]] + end + def sorted_by_lft? query.orders.first&.attribute == :lft end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c01a2c1bc15c..2a733586906b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -73,8 +73,7 @@ def projects_columns_options def selected_projects_columns_options Setting .enabled_projects_columns - .map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } - .compact + .filter_map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } end def protected_projects_columns_options diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 3ec0eadd99e7..8a5bc7dfafc3 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -331,8 +331,8 @@ def sort_header_tag(column, allowed_params: nil, **options) # This is a more specific version of #sort_header_tag. # For "filter by" to work properly, you must pass a Hash for `filter_column_mapping`. def sort_header_with_action_menu(column, all_columns, filter_column_mapping = {}, allowed_params: nil, **options) - with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_order, **opts| - action_menu(col, all_columns, cap, default_order, filter_column_mapping, **opts) + with_sort_header_options(column.attribute, allowed_params:, **options) do |_col, cap, default_order, **opts| + action_menu(column, all_columns, cap, default_order, filter_column_mapping, **opts) end end @@ -381,6 +381,9 @@ def build_columns_link(columns, allowed_params: nil, **html_options) def find_filter_for_column(column, filter_mapping) col = column.to_s + # Temporarily disabled filters for stages and gates columns for now. Remove this line for #59183 + return nil if column.start_with?("lcsd_") + filter_mapping.fetch(col, col) end @@ -389,9 +392,10 @@ def find_filter_for_column(column, filter_mapping) # Some of the method arguments are only needed for specific actions. def action_menu(column, table_columns, caption, default_order, filter_column_mapping = {}, allowed_params: nil, **html_options) - caption ||= column.to_s.humanize + attribute = column.attribute + caption ||= attribute.to_s.humanize - filter = find_filter_for_column(column, filter_column_mapping) + filter = find_filter_for_column(attribute, filter_column_mapping) sortable = html_options.delete(:sortable) # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. @@ -399,26 +403,32 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map # the action menu. content_args = html_options.merge(rel: :nofollow, param: nil) - render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| - action_button(menu, caption, favorite: column == :favored) + render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{attribute}") do |menu| + action_button(menu, column, caption, favorite: column == :favored) # Some columns are not sortable or do not offer a suitable filter. Omit those actions for them. - sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) if sortable - filter_action(menu, column, filter, content_args:) if filter + sort_actions(menu, attribute, default_order, content_args:, allowed_params:, **html_options) if sortable + filter_action(menu, attribute, filter, content_args:) if filter - move_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) - add_and_remove_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) + move_column_actions(menu, attribute, table_columns, content_args:, allowed_params:, **html_options) + add_and_remove_column_actions(menu, attribute, table_columns, content_args:, allowed_params:, **html_options) end end - def action_button(menu, caption, favorite: false) + def action_button(menu, column, caption, favorite: false) + additional_menu_classes = ["generic-table--action-menu-button", + column.respond_to?(:action_menu_classes) ? column.action_menu_classes : nil] + .compact + .join(" ") + menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, underline: false, display: :inline_flex, - classes: "generic-table--action-menu-button") do |button| + classes: additional_menu_classes) do |button| if favorite # This column only shows an icon, no text. render Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite)) else + button.with_leading_visual_icon(**column.visual_icon) if column.respond_to?(:visual_icon) button.with_trailing_action_icon(icon: :"triangle-down") h(caption).to_s diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index dfa4b11efda9..d4ffcf438427 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -46,4 +46,9 @@ def initialize(*args) super end + + def column_name + # The id of the associated definition is relevant for displaying the correct column headers + "lcsd_#{definition_id}" + end end diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index 48e8e00d94bf..5f1181f326d0 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -55,4 +55,8 @@ def initialize(*args) def step_class raise NotImplementedError end + + def column_name + "lcsd_#{id}" + end end diff --git a/app/models/queries/projects.rb b/app/models/queries/projects.rb index bb25098c5322..64b721b7596a 100644 --- a/app/models/queries/projects.rb +++ b/app/models/queries/projects.rb @@ -62,6 +62,7 @@ module Queries::Projects select Selects::CustomField select Selects::Default select Selects::LatestActivityAt + select Selects::LifeCycleStep select Selects::RequiredDiskSpace select Selects::Status select Selects::Favored diff --git a/app/models/queries/projects/selects/life_cycle_step.rb b/app/models/queries/projects/selects/life_cycle_step.rb new file mode 100644 index 000000000000..cd591ed06209 --- /dev/null +++ b/app/models/queries/projects/selects/life_cycle_step.rb @@ -0,0 +1,90 @@ +# 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. +# ++ + +class Queries::Projects::Selects::LifeCycleStep < Queries::Selects::Base + KEY = /\Alcsd_(\d+)\z/ + + def self.key + KEY + end + + def self.all_available + return [] unless available? + + Project::LifeCycleStepDefinition + .pluck(:id) + .map { |id| new(:"lcsd_#{id}") } + end + + def caption + life_cycle_step_definition.name + end + + def life_cycle_step_definition + return @life_cycle_step_definition if defined?(@life_cycle_step_definition) + + @life_cycle_step_definition = Project::LifeCycleStepDefinition + .find_by(id: attribute[KEY, 1]) + end + + def self.available? + OpenProject::FeatureDecisions.stages_and_gates_active? + end + + def available? + life_cycle_step_definition.present? + end + + def visual_icon + # Show the proper icon for the definition with the correct color. + icon = case life_cycle_step_definition + when Project::StageDefinition + :"git-commit" + when Project::GateDefinition + :diamond + else + raise "Unknown life cycle definition for: #{life_cycle_step_definition}" + end + + classes = helpers.hl_inline_class("life_cycle_step_definition", life_cycle_step_definition) + + { icon:, classes: } + end + + def action_menu_classes + "leading-visual-icon-header" + end + + private + + def helpers + @helpers ||= Object.new.extend(ColorsHelper) + end +end diff --git a/frontend/src/global_styles/content/_projects_list.sass b/frontend/src/global_styles/content/_projects_list.sass index 0260b2f463c1..93fa5bc3a729 100644 --- a/frontend/src/global_styles/content/_projects_list.sass +++ b/frontend/src/global_styles/content/_projects_list.sass @@ -104,19 +104,3 @@ $content-padding: 10px padding-left: 1em li list-style-type: none - -// Firefox does not support the popover attribute yet. Thus, the Primer action menus create an ugly scroll inside the table -// Follow https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover#browser_compatibility to see when this can be removed -body:not(.-browser-firefox) - .project-list-page - display: flex - flex-direction: column - max-height: calc(100vh - #{var(--header-height)} - #{$content-padding} - #{$content-padding}) - - &--table - display: flex - flex-grow: 1 - overflow: auto - - .generic-table--results-container - overflow-x: hidden diff --git a/frontend/src/global_styles/content/_table.sass b/frontend/src/global_styles/content/_table.sass index aab02d6834d1..9b964a9f9cee 100644 --- a/frontend/src/global_styles/content/_table.sass +++ b/frontend/src/global_styles/content/_table.sass @@ -381,3 +381,8 @@ thead.-sticky th p:last-child margin-bottom: 0 + +.generic-table + &--action-menu-button.leading-visual-icon-header + position: relative + top: 2px diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index 5e375ac252b8..ea4ba90c2d87 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -329,6 +329,95 @@ def load_and_open_filters(user) end end + context "with life cycle columns" do + shared_let(:life_cycle_gate) { create(:project_gate, date: Date.new(2024, 12, 13)) } + shared_let(:life_cycle_stage) do + create(:project_stage, + start_date: Date.new(2024, 12, 1), + end_date: Date.new(2024, 12, 13)) + end + shared_let(:inactive_life_cycle_gate) { create(:project_gate, active: false) } + shared_let(:inactive_life_cycle_stage) { create(:project_stage, active: false) } + + context "with the feature flag disabled", with_flag: { stages_and_gates: false } do + specify "life cycle columns cannot be configured to show up" do + login_as(admin) + projects_page.visit! + + element_selector = "#columns-select_autocompleter ng-select.op-draggable-autocomplete--input" + results_selector = "#columns-select_autocompleter ng-dropdown-panel .ng-dropdown-panel-items" + projects_page.expect_no_config_columns(life_cycle_gate.definition.name, + life_cycle_stage.definition.name, + inactive_life_cycle_gate.definition.name, + inactive_life_cycle_stage.definition.name, + element_selector:, + results_selector:) + end + end + + context "with the feature flag enabled", with_flag: { stages_and_gates: true } do + specify "life cycle columns do not show up by default" do + login_as(admin) + projects_page.visit! + + expect(page).to have_no_text(life_cycle_gate.definition.name.upcase) + expect(page).to have_no_text(life_cycle_stage.definition.name.upcase) + expect(page).to have_no_text(inactive_life_cycle_gate.definition.name.upcase) + expect(page).to have_no_text(inactive_life_cycle_stage.definition.name.upcase) + end + + specify "life cycle columns show up when configured to do so" do + login_as(admin) + projects_page.visit! + + projects_page.expect_columns("Name") + projects_page.set_columns(life_cycle_gate.definition.name) + + expect(page).to have_text(life_cycle_gate.definition.name.upcase) + end + + specify "inactive life cycle columns have no cell content" do + login_as(admin) + projects_page.visit! + + projects_page.expect_columns("Name") + + col_names = [life_cycle_gate, life_cycle_stage, + inactive_life_cycle_gate, + inactive_life_cycle_stage].map { |l| l.definition.name } + + projects_page.set_columns(*col_names) + # Inactive columns are still displayed in the header: + projects_page.expect_columns("Name", *col_names) + + gate_project = life_cycle_gate.project + projects_page.within_row(gate_project) do + expect(page).to have_css(".name", text: gate_project.name) + expect(page).to have_css(".lcsd_#{life_cycle_gate.definition.id}", text: "12/13/2024") + # life cycle assigned to other project, no text here + expect(page).to have_css(".lcsd_#{life_cycle_stage.definition.id}", text: "") + # inactive life cycles, no text here + expect(page).to have_css(".lcsd_#{inactive_life_cycle_stage.definition.id}", text: "") + expect(page).to have_css(".lcsd_#{inactive_life_cycle_gate.definition.id}", text: "") + end + + stage_project = life_cycle_stage.project + projects_page.within_row(stage_project) do + expect(page).to have_css(".name", text: stage_project.name) + expect(page).to have_css(".lcsd_#{life_cycle_stage.definition.id}", text: "12/01/2024 - 12/13/2024") + # life cycle assigned to other project, no text here + expect(page).to have_css(".lcsd_#{life_cycle_gate.definition.id}", text: "") + end + + # Inactive life cycle steps never show their date values + other_proj = inactive_life_cycle_stage.project + projects_page.within_row(other_proj) do + expect(page).to have_css(".lcsd_#{inactive_life_cycle_stage.definition.id}", text: "") + end + end + end + end + context "with valid Enterprise token" do shared_let(:long_text_custom_field) { create(:text_project_custom_field) } specify "CF columns and filters are not visible by default" do diff --git a/spec/helpers/sort_helper_spec.rb b/spec/helpers/sort_helper_spec.rb index 1c4e9b2ff5f7..149da1f15bab 100644 --- a/spec/helpers/sort_helper_spec.rb +++ b/spec/helpers/sort_helper_spec.rb @@ -281,11 +281,7 @@ def session; @session ||= {}; end end describe "#sort_header_with_action_menu" do - subject(:output) do - helper.sort_header_with_action_menu("id", - %w[name id description], {}, **options) - end - + let(:id_column) { Queries::Projects::Selects::Default.new "id" } let(:options) { { param: :json, sortable: true } } let(:sort_criteria) { SortHelper::SortCriteria.new } @@ -295,6 +291,11 @@ def session; @session ||= {}; end Nokogiri::HTML(output).at_css("th .generic-table--sort-header action-menu") end + subject(:output) do + helper.sort_header_with_action_menu(id_column, + %w[name id description], {}, **options) + end + before do # helper relies on this instance var @sort_criteria = sort_criteria @@ -309,6 +310,10 @@ def session; @session ||= {}; end expect(action_menu.at_css("button#menu-id-button .Button-content .Button-label").text).to eq("Id") end + it "does not render an icon by default" do + expect(action_menu.at_css(".generic-table--action-menu-button .Button-leadingVisual")).to be_blank + end + it "shows sorting actions in the action-menu" do sort_desc = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-desc']") expect(sort_desc.at_css(".ActionListItem-label").text.strip).to eq("Sort descending") @@ -343,7 +348,7 @@ def session; @session ||= {}; end context "with the current column being the leftmost one" do subject(:output) do - helper.sort_header_with_action_menu("id", + helper.sort_header_with_action_menu(id_column, %w[id name description], {}, **options) end @@ -359,7 +364,7 @@ def session; @session ||= {}; end context "with the current column being the rightmost one" do subject(:output) do - helper.sort_header_with_action_menu("id", + helper.sort_header_with_action_menu(id_column, %w[name description id], {}, **options) end @@ -397,7 +402,7 @@ def session; @session ||= {}; end context "with a filter mapping for the column" do subject(:output) do - helper.sort_header_with_action_menu("id", + helper.sort_header_with_action_menu(id_column, %w[name id description], { "id" => "id_code" }, **options) end @@ -413,7 +418,7 @@ def session; @session ||= {}; end context "with the filter mapping specifying there is no filter for the column" do subject(:output) do # With the filter name mapped to nil, we expect no filter action to be present. - helper.sort_header_with_action_menu("id", + helper.sort_header_with_action_menu(id_column, %w[name id description], { "id" => nil }, **options) end @@ -422,5 +427,44 @@ def session; @session ||= {}; end expect(filter_by).to be_nil end end + + context "with a life cycle gate column" do + let(:life_cycle_step) { create(:project_gate_definition) } + let(:life_cycle_column) { Queries::Projects::Selects::LifeCycleStep.new("lcsd_#{life_cycle_step.id}") } + + let(:options) { { caption: life_cycle_step.name } } + + subject(:output) do + # Not setting any filter column mappings here, so for other column types, this should use the default filter + helper.sort_header_with_action_menu(life_cycle_column, + %W[name lcsd_#{life_cycle_step.id}], {}, **options) + end + + it "never offers a filter by action" do + # But a life cycle column never offers a filter (until #59183 is implemented) + filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']") + expect(filter_by).to be_nil + end + + it "shows a diamond icon in the header for gates" do + icon = action_menu.at_css(".generic-table--action-menu-button .Button-leadingVisual .octicon-diamond") + expect(icon).to be_present + + header_text = action_menu.at_css(".generic-table--action-menu-button .Button-label").text.strip + expect(header_text).to eq(life_cycle_column.caption) + end + + context "with a life cycle stage column" do + let(:life_cycle_step) { create(:project_stage_definition) } + + it "shows a commit icon in the header for gates" do + icon = action_menu.at_css(".generic-table--action-menu-button .Button-leadingVisual .octicon-git-commit") + expect(icon).to be_present + + header_text = action_menu.at_css(".generic-table--action-menu-button .Button-label").text.strip + expect(header_text).to eq(life_cycle_column.caption) + end + end + end end end diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index 54f3111765f0..69034ae8c4b4 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -398,13 +398,14 @@ def set_columns(*columns) wait_for_network_idle end - def expect_no_config_columns(*columns) + def expect_no_config_columns(*columns, element_selector: ".op-draggable-autocomplete--input", + results_selector: ".ng-dropdown-panel-items") open_configure_view columns.each do |column| - expect_no_ng_option find(".op-draggable-autocomplete--input"), + expect_no_ng_option find(element_selector), column, - results_selector: ".ng-dropdown-panel-items" + results_selector: end within "dialog" do