Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#58160] project stage columns on project list #17400

Draft
wants to merge 16 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app/components/projects/row_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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
Expand All @@ -94,6 +96,18 @@ def custom_field_column(column)
end
end

def life_cycle_step_column(column)
return nil unless user_can_view_project?

ls = Project::LifeCycleStep
.where(active: true)
.find_by(definition_id: column.life_cycle.id, project:)

return nil if ls.blank?

fmt_date_or_range(ls.start_date, ls.end_date)
end

def created_at
helpers.format_date(project.created_at)
end
Expand Down Expand Up @@ -374,8 +388,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
2 changes: 1 addition & 1 deletion app/components/projects/table_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details.
</th>
<% 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 %>
<th>
<div class="generic-table--sort-header-outer">
Expand Down
3 changes: 1 addition & 2 deletions app/helpers/projects_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 23 additions & 12 deletions app/helpers/sort_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -389,35 +392,43 @@ 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`.
# It is important for keeping the current state in the GET parameters of each link used in
# 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))
elsif column.respond_to?(:action_menu_header)
column.action_menu_header(button)
else
button.with_trailing_action_icon(icon: :"triangle-down")

Expand Down
5 changes: 5 additions & 0 deletions app/models/project/life_cycle_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions app/models/project/life_cycle_step_definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ def initialize(*args)

super
end

def column_name
"lcsd_#{id}"
end
end
1 change: 1 addition & 0 deletions app/models/queries/projects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions app/models/queries/projects/selects/life_cycle_step.rb
Original file line number Diff line number Diff line change
@@ -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.name
end

def life_cycle
return @life_cycle if defined?(@life_cycle)

@life_cycle = Project::LifeCycleStepDefinition
.find_by(id: attribute[KEY, 1])
end

def available?
life_cycle.present?
end

def action_menu_header(button)
# Show the proper icon for the definition with the correct color.
icon = case life_cycle
when Project::StageDefinition
:"git-commit"
when Project::GateDefinition
:diamond
else
raise "Unknown life cycle definition for: #{life_cycle}"
end

classes = helpers.hl_inline_class("life_cycle_step_definition", life_cycle)
button.with_leading_visual_icon(icon:, classes:)

# As all other action menu headers, we will show an action icon and the caption:
button.with_trailing_action_icon(icon: :"triangle-down")

caption.to_s
end

def action_menu_classes
"leading-visual-icon-header"
end

private

def helpers
@helpers ||= Object.new.extend(ColorsHelper)
end
end
16 changes: 0 additions & 16 deletions frontend/src/global_styles/content/_projects_list.sass
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions frontend/src/global_styles/content/_table.sass
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ $input-elements: input, 'input.form--text-field', select, 'select.form--select',
&.ng-leave
@include animation(0.5s fade-out)


table.generic-table
border-collapse: collapse
width: 100%
Expand Down Expand Up @@ -381,3 +382,8 @@ thead.-sticky th

p:last-child
margin-bottom: 0

.generic-table
&--action-menu-button.leading-visual-icon-header
position: relative
top: 2px
71 changes: 71 additions & 0 deletions spec/features/projects/projects_index_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,77 @@ 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) }

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

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
Expand Down
Loading
Loading