From 65bc873600a8e689c38b86f1d4c0ce2ed9e646db Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 11:55:33 +0200 Subject: [PATCH 1/8] [DREAM-869] Project hover card https://community.openproject.org/wp/DREAM-869 From 535814f30dcc7d1ec98aa3c83b24f2fe777f4576 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 15:50:47 +0200 Subject: [PATCH 2/8] [DREAM-869] Initial component Adds the barebones start of a component, with lookbook preview. --- .../projects/hover_card_component.html.erb | 49 +++++++++++++++++ .../projects/hover_card_component.rb | 52 +++++++++++++++++++ app/views/projects/hover_card/show.html.erb | 3 ++ .../projects/hover_card_component_preview.rb | 32 ++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 app/components/projects/hover_card_component.html.erb create mode 100644 app/components/projects/hover_card_component.rb create mode 100644 app/views/projects/hover_card/show.html.erb create mode 100644 lookbook/previews/open_project/projects/hover_card_component_preview.rb diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb new file mode 100644 index 000000000000..31d11920b3fd --- /dev/null +++ b/app/components/projects/hover_card_component.html.erb @@ -0,0 +1,49 @@ +<%#-- 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, see . + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= + flex_layout(classes: "op-project-hover-card", data: { test_selector: "project-hover-card-#{@project.id}" }) do |flex| + if breadcrumb_items.present? + flex.with_row do + render(Primer::Beta::Breadcrumbs.new) do |breadcrumbs| + breadcrumb_items.each do |item| + breadcrumbs.with_item(href: item[:href]) do + render(Primer::Beta::Truncate.new) do |truncate| + truncate.with_item(max_width: 135) { item[:text] } + end + end + end + end + end + + flex.with_row do + + end + end + end +%> diff --git a/app/components/projects/hover_card_component.rb b/app/components/projects/hover_card_component.rb new file mode 100644 index 000000000000..639eee3704bf --- /dev/null +++ b/app/components/projects/hover_card_component.rb @@ -0,0 +1,52 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + class HoverCardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :project + + def initialize(project:, user:) + super + + @project = project + @user = user + end + + def breadcrumb_items + ancestors = project.ancestors.visible.active.reorder(:lft) + return nil if ancestors.empty? + + (ancestors + [project]).map do |item| + { href: project_overview_path(item), text: item.name, selected: item == project } + end + end + end +end diff --git a/app/views/projects/hover_card/show.html.erb b/app/views/projects/hover_card/show.html.erb new file mode 100644 index 000000000000..a5a7cc1d1d16 --- /dev/null +++ b/app/views/projects/hover_card/show.html.erb @@ -0,0 +1,3 @@ + + <%= render Projects::HoverCardComponent.new(id: @id) %> + diff --git a/lookbook/previews/open_project/projects/hover_card_component_preview.rb b/lookbook/previews/open_project/projects/hover_card_component_preview.rb new file mode 100644 index 000000000000..d1d15edc838a --- /dev/null +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -0,0 +1,32 @@ +module OpenProject::Projects + # @logical_path OpenProject/Projects + class HoverCardComponentPreview < ViewComponent::Preview + # !! Currently nothing happens when changing the status by + # !! clicking the options in the dropdown!! + # @param status select [open, in_progress, completed] + def playground(status: :open) + user = FactoryBot.build_stubbed(:admin) + project = FactoryBot.build_stubbed(:project) + render(::Projects::HoverCardComponent.new(project:, user:)) + end + + def deep_hierarchy + user = FactoryBot.build_stubbed(:admin) + root = FactoryBot.build_stubbed(:project, name: "Company") + parent = FactoryBot.build_stubbed(:project, name: "Department", parent: root) + child = FactoryBot.build_stubbed(:project, name: "Team", parent: parent) + leaf = FactoryBot.build_stubbed(:project, name: "Sprint", parent: child) + + chain = [root, parent, child, leaf] + + chain.each_with_index do |project, i| + ancestors = chain.first(i) + %i[visible active].each { |scope| ancestors.define_singleton_method(scope) { |*| self } } + ancestors.define_singleton_method(:reorder) { |*| self } + project.define_singleton_method(:ancestors) { ancestors } + end + + render(::Projects::HoverCardComponent.new(project: leaf, user:)) + end + end +end From ff0c9f414d48a22735d471ff324d434960bf4097 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Wed, 21 Oct 2026 17:40:42 +0200 Subject: [PATCH 3/8] [DREAM-869] Adds content, and barebones new components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the remaining content to the card, as shown in the designs. Still some open questions about which sub-components will need to be built new, and which are simply components I didn’t find yet. Adds a project-workspace-label-component. --- .../projects/hover_card_component.html.erb | 20 ++++++- .../projects/hover_card_component.rb | 2 + .../workspace_label_component.html.erb | 11 ++++ .../projects/workspace_label_component.rb | 53 +++++++++++++++++++ .../projects/hover_card_component_preview.rb | 19 +++---- 5 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 app/components/projects/workspace_label_component.html.erb create mode 100644 app/components/projects/workspace_label_component.rb diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb index 31d11920b3fd..7e7b20f37101 100644 --- a/app/components/projects/hover_card_component.html.erb +++ b/app/components/projects/hover_card_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - flex_layout(classes: "op-project-hover-card", data: { test_selector: "project-hover-card-#{@project.id}" }) do |flex| + flex_layout(classes: "op-project-hover-card", data: { test_selector: "project-hover-card-#{@id}" }) do |flex| if breadcrumb_items.present? flex.with_row do render(Primer::Beta::Breadcrumbs.new) do |breadcrumbs| @@ -40,9 +40,25 @@ See COPYRIGHT and LICENSE files for more details. end end end + end - flex.with_row do + flex.with_row do + render(Primer::Beta::Text.new(tag: :h3)) { @name } + end + + flex.with_row(flex_layout: true) do |row| + row.with_column(mr: 2) do + render(Projects::WorkspaceLabelComponent.new(project: @project)) + end + + if @project.archived? + row.with_column(mr: 1) do + render(Primer::Beta::Label.new(scheme: :secondary)) { t('project.archive.archived') } + end + end + row.with_column do + render(Projects::StatusBadgeComponent.new(project: @project)) end end end diff --git a/app/components/projects/hover_card_component.rb b/app/components/projects/hover_card_component.rb index 639eee3704bf..5f0fa143a63e 100644 --- a/app/components/projects/hover_card_component.rb +++ b/app/components/projects/hover_card_component.rb @@ -37,6 +37,8 @@ def initialize(project:, user:) super @project = project + @id = project.id + @name = project.name @user = user end diff --git a/app/components/projects/workspace_label_component.html.erb b/app/components/projects/workspace_label_component.html.erb new file mode 100644 index 000000000000..0927e5381dc1 --- /dev/null +++ b/app/components/projects/workspace_label_component.html.erb @@ -0,0 +1,11 @@ +<%= + flex_layout do |container| + container.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: icon_name)) + end + + container.with_column do + render(Primer::Beta::Text.new()) {@label} + end + end +%> diff --git a/app/components/projects/workspace_label_component.rb b/app/components/projects/workspace_label_component.rb new file mode 100644 index 000000000000..e28d785bdbdd --- /dev/null +++ b/app/components/projects/workspace_label_component.rb @@ -0,0 +1,53 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Projects::WorkspaceLabelComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + attr_reader :project + + def initialize(project:) + super + + @project = project + @label = project.workspace_type + end + + def icon_name + case project.workspace_type + when "program" + :version + when "portfolio" + :briefcase + else + :project + end + end +end diff --git a/lookbook/previews/open_project/projects/hover_card_component_preview.rb b/lookbook/previews/open_project/projects/hover_card_component_preview.rb index d1d15edc838a..3eddac3028ec 100644 --- a/lookbook/previews/open_project/projects/hover_card_component_preview.rb +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -1,23 +1,20 @@ module OpenProject::Projects # @logical_path OpenProject/Projects class HoverCardComponentPreview < ViewComponent::Preview - # !! Currently nothing happens when changing the status by - # !! clicking the options in the dropdown!! - # @param status select [open, in_progress, completed] - def playground(status: :open) + def playground user = FactoryBot.build_stubbed(:admin) - project = FactoryBot.build_stubbed(:project) + project = FactoryBot.build_stubbed(:project, :with_status) render(::Projects::HoverCardComponent.new(project:, user:)) end - def deep_hierarchy + def with_hierarchy user = FactoryBot.build_stubbed(:admin) - root = FactoryBot.build_stubbed(:project, name: "Company") - parent = FactoryBot.build_stubbed(:project, name: "Department", parent: root) - child = FactoryBot.build_stubbed(:project, name: "Team", parent: parent) - leaf = FactoryBot.build_stubbed(:project, name: "Sprint", parent: child) + status = FactoryBot.build_stubbed(:status) + root = FactoryBot.build_stubbed(:project, name: "Arcanos Flight Planning") + child = FactoryBot.build_stubbed(:project, name: "Mobile app", parent: root) + leaf = FactoryBot.build_stubbed(:project, active: false, name: "Data sources", parent: child, status_code: 1) - chain = [root, parent, child, leaf] + chain = [root, child, leaf] chain.each_with_index do |project, i| ancestors = chain.first(i) From 352229b2f3e4b25fabc430f4f514bd368ea0cfda Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Thu, 22 Oct 2026 14:57:32 +0200 Subject: [PATCH 4/8] [DREAM-869] Adjusts layout & i18n Adjusts spacing, font-sizes, icon-sizes etc, to match designs Uses the workspace-label-component in the new InfoLine component Localises the workspace type text --- .../projects/hover_card_component.html.erb | 14 +++--- .../projects/info_line_component.html.erb | 17 ++++--- .../projects/info_line_component.rb | 8 ---- .../projects/status_badge_component.html.erb | 13 ++++- .../projects/status_badge_component.rb | 3 +- .../workspace_label_component.html.erb | 6 +-- .../projects/workspace_label_component.rb | 21 +++------ .../src/global_styles/primer/_overrides.sass | 4 +- .../status_badge_component_preview.rb | 47 +++++++++++++++++++ 9 files changed, 88 insertions(+), 45 deletions(-) create mode 100644 lookbook/previews/open_project/projects/status_badge_component_preview.rb diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb index 7e7b20f37101..06809da2dce9 100644 --- a/app/components/projects/hover_card_component.html.erb +++ b/app/components/projects/hover_card_component.html.erb @@ -29,10 +29,10 @@ See COPYRIGHT and LICENSE files for more details. <%= flex_layout(classes: "op-project-hover-card", data: { test_selector: "project-hover-card-#{@id}" }) do |flex| if breadcrumb_items.present? - flex.with_row do + flex.with_row(mb: 1) do render(Primer::Beta::Breadcrumbs.new) do |breadcrumbs| - breadcrumb_items.each do |item| - breadcrumbs.with_item(href: item[:href]) do + breadcrumb_items.each_with_index do |item, i| + breadcrumbs.with_item(href: item[:href], font_weight: i == breadcrumb_items.size - 1 ? :bold : nil) do render(Primer::Beta::Truncate.new) do |truncate| truncate.with_item(max_width: 135) { item[:text] } end @@ -42,23 +42,23 @@ See COPYRIGHT and LICENSE files for more details. end end - flex.with_row do + flex.with_row(mb: 1) do render(Primer::Beta::Text.new(tag: :h3)) { @name } end - flex.with_row(flex_layout: true) do |row| + flex.with_row(flex_layout: true, align_items: :center) do |row| row.with_column(mr: 2) do render(Projects::WorkspaceLabelComponent.new(project: @project)) end if @project.archived? - row.with_column(mr: 1) do + row.with_column(mr: 2) do render(Primer::Beta::Label.new(scheme: :secondary)) { t('project.archive.archived') } end end row.with_column do - render(Projects::StatusBadgeComponent.new(project: @project)) + render(Projects::StatusBadgeComponent.new(project: @project, size: :large, with_icon: true)) end end end diff --git a/app/components/projects/info_line_component.html.erb b/app/components/projects/info_line_component.html.erb index 3996f7534d0a..c92ac67cf58b 100644 --- a/app/components/projects/info_line_component.html.erb +++ b/app/components/projects/info_line_component.html.erb @@ -28,23 +28,22 @@ See COPYRIGHT and LICENSE files for more details. <%= flex_layout(**@system_arguments) do |flex| - flex.with_column do - if project.archived? - render(Primer::Beta::Text.new(font_weight: :bold)) { project.name } + flex.with_column(mr: 2) do + if @project.archived? + render(Primer::Beta::Text.new(font_weight: :bold)) { @project.name } else - render(Primer::Beta::Link.new(href: project_path(project), font_weight: :bold)) { project.name } + render(Primer::Beta::Link.new(href: project_path(@project), font_weight: :bold)) { @project.name } end end if @show_workspace_type - flex.with_column(ml: 2, display: :flex, align_items: :center, test_selector: "project-info-line-workspace-type") do - render(Primer::Beta::Octicon.new(icon: workspace_type_icon, size: :xsmall, color: :muted, mr: 1)) + - render(Primer::Beta::Text.new(color: :muted, font_size: :small)) { workspace_type_label } + flex.with_column(mr: 2) do + render(Projects::WorkspaceLabelComponent.new(project: @project)) end end - if project.archived? - flex.with_column(ml: 2) do + if @project.archived? + flex.with_column do render(Primer::Beta::Label.new) { t("project.archive.archived") } end end diff --git a/app/components/projects/info_line_component.rb b/app/components/projects/info_line_component.rb index 3a27b77726e4..c8049ebeb9ca 100644 --- a/app/components/projects/info_line_component.rb +++ b/app/components/projects/info_line_component.rb @@ -40,12 +40,4 @@ def initialize(project:, show_workspace_type: false, **system_arguments) @system_arguments[:align_items] ||= :center @system_arguments[:flex_wrap] ||= :wrap end - - private - - attr_reader :project - - def workspace_type_label = I18n.t(:"label_#{project.workspace_type}") - - def workspace_type_icon = helpers.workspace_icon(project.workspace_type) end diff --git a/app/components/projects/status_badge_component.html.erb b/app/components/projects/status_badge_component.html.erb index de00db3a79a7..fa20e3fa5269 100644 --- a/app/components/projects/status_badge_component.html.erb +++ b/app/components/projects/status_badge_component.html.erb @@ -1 +1,12 @@ -<%= render(Primer::Beta::Label.new(size: :medium, inline: true, **@system_arguments)) { name } %> +<%= render(Primer::Beta::Label.new(size: :medium, **@system_arguments)) { + render(Primer::OpenProject::FlexLayout.new) do |flex| + if @with_icon + flex.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: @status.icon)) + end + end + flex.with_column do + name + end + end +} %> diff --git a/app/components/projects/status_badge_component.rb b/app/components/projects/status_badge_component.rb index bc28eecb3737..1ff45c4361f9 100644 --- a/app/components/projects/status_badge_component.rb +++ b/app/components/projects/status_badge_component.rb @@ -31,10 +31,11 @@ class Projects::StatusBadgeComponent < ApplicationComponent include OpPrimer::ComponentHelpers include Primer::ClassNameHelper - def initialize(project:, **system_arguments) + def initialize(project:, with_icon: false, **system_arguments) super @project = project + @with_icon = with_icon @status = find_status(project.status_code) @system_arguments = system_arguments end diff --git a/app/components/projects/workspace_label_component.html.erb b/app/components/projects/workspace_label_component.html.erb index 0927e5381dc1..0ed4017df720 100644 --- a/app/components/projects/workspace_label_component.html.erb +++ b/app/components/projects/workspace_label_component.html.erb @@ -1,11 +1,11 @@ <%= - flex_layout do |container| + flex_layout(align_items: :center) do |container| container.with_column(mr: 1) do - render(Primer::Beta::Octicon.new(icon: icon_name)) + render(Primer::Beta::Octicon.new(icon: icon_name, color: :muted)) end container.with_column do - render(Primer::Beta::Text.new()) {@label} + render(Primer::Beta::Text.new(color: :muted, font_size: :small)) { label } end end %> diff --git a/app/components/projects/workspace_label_component.rb b/app/components/projects/workspace_label_component.rb index e28d785bdbdd..09d90d2716a5 100644 --- a/app/components/projects/workspace_label_component.rb +++ b/app/components/projects/workspace_label_component.rb @@ -28,26 +28,19 @@ #++ class Projects::WorkspaceLabelComponent < ApplicationComponent - include ApplicationHelper include OpPrimer::ComponentHelpers - attr_reader :project - def initialize(project:) super @project = project - @label = project.workspace_type end - def icon_name - case project.workspace_type - when "program" - :version - when "portfolio" - :briefcase - else - :project - end - end + private + + attr_reader :project + + def label = I18n.t(:"label_#{project.workspace_type}") + + def icon_name = helpers.workspace_icon(project.workspace_type) end diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 3a11cce6c2c4..04bb4d5bc187 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -52,6 +52,7 @@ page-header, sub-header, .op-work-package-details-tab-component, +.op-project-hover-card, .UnderlineNav, .tabnav, .Box-header, @@ -165,7 +166,7 @@ ul.SegmentedControl, .Banner @container (max-width: 543.98px) - grid-template-areas: 'visual message close' '. actions actions' + grid-template-areas: 'visual message close' '.actions actions' grid-template-columns: min-content 1fr min-content grid-template-rows: min-content min-content @@ -213,7 +214,6 @@ ul.SegmentedControl, body.-browser-windows.-browser-firefox & @include keep-project-select-actions-clear-of-scrollbar - .op-sidemenu-filterable-tree .TreeViewItemContentText, .TreeViewItemToggle, diff --git a/lookbook/previews/open_project/projects/status_badge_component_preview.rb b/lookbook/previews/open_project/projects/status_badge_component_preview.rb new file mode 100644 index 000000000000..636fe0ab55df --- /dev/null +++ b/lookbook/previews/open_project/projects/status_badge_component_preview.rb @@ -0,0 +1,47 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject::Projects + # @logical_path OpenProject/Projects + class StatusBadgeComponentPreview < ViewComponent::Preview + # @label Playground + # @param with_icon [boolean] + def playground(with_icon: false) + project = FactoryBot.build_stubbed(:project, :with_status) + + render(Projects::StatusBadgeComponent.new(project:, with_icon:)) + end + + def with_icon(with_icon: true) + project = FactoryBot.build_stubbed(:project, :with_status) + + render(Projects::StatusBadgeComponent.new(project:, with_icon: true)) + end + end +end From a945a0c7b40fd37823502d56a6d14c6f7b29e31f Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 10:21:45 +0100 Subject: [PATCH 5/8] [DREAM-869] Typography Adjusts the font-sizes and weights of all the text in the component to match designs. Note that this includes overrideing font-sizes on the other components like labels and breadcrumbs. Adjusts icons sizes --- app/components/projects/hover_card_component.html.erb | 4 ++-- app/components/projects/info_line_component.html.erb | 2 +- app/components/projects/status_badge_component.html.erb | 4 ++-- .../open_project/projects/hover_card_component_preview.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb index 06809da2dce9..3adf318d1afd 100644 --- a/app/components/projects/hover_card_component.html.erb +++ b/app/components/projects/hover_card_component.html.erb @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details. flex.with_row(mb: 1) do render(Primer::Beta::Breadcrumbs.new) do |breadcrumbs| breadcrumb_items.each_with_index do |item, i| - breadcrumbs.with_item(href: item[:href], font_weight: i == breadcrumb_items.size - 1 ? :bold : nil) do + breadcrumbs.with_item(href: item[:href], font_size: :small, font_weight: i == breadcrumb_items.size - 1 ? :bold : nil) do render(Primer::Beta::Truncate.new) do |truncate| truncate.with_item(max_width: 135) { item[:text] } end @@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details. end flex.with_row(mb: 1) do - render(Primer::Beta::Text.new(tag: :h3)) { @name } + render(Primer::Beta::Text.new(tag: :h3, font_size: 5)) { @name } end flex.with_row(flex_layout: true, align_items: :center) do |row| diff --git a/app/components/projects/info_line_component.html.erb b/app/components/projects/info_line_component.html.erb index c92ac67cf58b..03ea144c02ff 100644 --- a/app/components/projects/info_line_component.html.erb +++ b/app/components/projects/info_line_component.html.erb @@ -44,7 +44,7 @@ See COPYRIGHT and LICENSE files for more details. if @project.archived? flex.with_column do - render(Primer::Beta::Label.new) { t("project.archive.archived") } + render(Primer::Beta::Label.new(scheme: :secondary)) { t("project.archive.archived") } end end end diff --git a/app/components/projects/status_badge_component.html.erb b/app/components/projects/status_badge_component.html.erb index fa20e3fa5269..ca473c952087 100644 --- a/app/components/projects/status_badge_component.html.erb +++ b/app/components/projects/status_badge_component.html.erb @@ -1,12 +1,12 @@ <%= render(Primer::Beta::Label.new(size: :medium, **@system_arguments)) { - render(Primer::OpenProject::FlexLayout.new) do |flex| + render(Primer::OpenProject::FlexLayout.new(align_items: :center)) do |flex| if @with_icon flex.with_column(mr: 1) do render(Primer::Beta::Octicon.new(icon: @status.icon)) end end flex.with_column do - name + render(Primer::Beta::Text.new(font_size: 5, font_weight: :bold)) { name } end end } %> diff --git a/lookbook/previews/open_project/projects/hover_card_component_preview.rb b/lookbook/previews/open_project/projects/hover_card_component_preview.rb index 3eddac3028ec..2a27dbac357c 100644 --- a/lookbook/previews/open_project/projects/hover_card_component_preview.rb +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -1,7 +1,7 @@ module OpenProject::Projects # @logical_path OpenProject/Projects class HoverCardComponentPreview < ViewComponent::Preview - def playground + def no_hierarchy user = FactoryBot.build_stubbed(:admin) project = FactoryBot.build_stubbed(:project, :with_status) render(::Projects::HoverCardComponent.new(project:, user:)) From 661748ce8d8b6484a1993653251fbdcf13337646 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 10:33:45 +0100 Subject: [PATCH 6/8] [DREAM-869] Linting --- .../projects/hover_card_component.html.erb | 2 +- .../projects/status_badge_component.html.erb | 20 ++++++++++--------- .../workspace_label_component.html.erb | 2 +- .../src/global_styles/primer/_overrides.sass | 3 ++- .../projects/hover_card_component_preview.rb | 1 - 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb index 3adf318d1afd..4a1785e7b8ac 100644 --- a/app/components/projects/hover_card_component.html.erb +++ b/app/components/projects/hover_card_component.html.erb @@ -53,7 +53,7 @@ See COPYRIGHT and LICENSE files for more details. if @project.archived? row.with_column(mr: 2) do - render(Primer::Beta::Label.new(scheme: :secondary)) { t('project.archive.archived') } + render(Primer::Beta::Label.new(scheme: :secondary)) { t("project.archive.archived") } end end diff --git a/app/components/projects/status_badge_component.html.erb b/app/components/projects/status_badge_component.html.erb index ca473c952087..19159bc0be78 100644 --- a/app/components/projects/status_badge_component.html.erb +++ b/app/components/projects/status_badge_component.html.erb @@ -1,12 +1,14 @@ -<%= render(Primer::Beta::Label.new(size: :medium, **@system_arguments)) { - render(Primer::OpenProject::FlexLayout.new(align_items: :center)) do |flex| - if @with_icon - flex.with_column(mr: 1) do - render(Primer::Beta::Octicon.new(icon: @status.icon)) +<%= + render(Primer::Beta::Label.new(size: :medium, **@system_arguments)) do + render(Primer::OpenProject::FlexLayout.new(align_items: :center)) do |flex| + if @with_icon + flex.with_column(mr: 1) do + render(Primer::Beta::Octicon.new(icon: @status.icon)) + end + end + flex.with_column do + render(Primer::Beta::Text.new(font_size: 5, font_weight: :bold)) { name } end - end - flex.with_column do - render(Primer::Beta::Text.new(font_size: 5, font_weight: :bold)) { name } end end -} %> +%> diff --git a/app/components/projects/workspace_label_component.html.erb b/app/components/projects/workspace_label_component.html.erb index 0ed4017df720..a40b4520b7a5 100644 --- a/app/components/projects/workspace_label_component.html.erb +++ b/app/components/projects/workspace_label_component.html.erb @@ -7,5 +7,5 @@ container.with_column do render(Primer::Beta::Text.new(color: :muted, font_size: :small)) { label } end - end + end %> diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 04bb4d5bc187..0b6a8fae8306 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -166,7 +166,7 @@ ul.SegmentedControl, .Banner @container (max-width: 543.98px) - grid-template-areas: 'visual message close' '.actions actions' + grid-template-areas: 'visual message close' '. actions actions' grid-template-columns: min-content 1fr min-content grid-template-rows: min-content min-content @@ -214,6 +214,7 @@ ul.SegmentedControl, body.-browser-windows.-browser-firefox & @include keep-project-select-actions-clear-of-scrollbar + .op-sidemenu-filterable-tree .TreeViewItemContentText, .TreeViewItemToggle, diff --git a/lookbook/previews/open_project/projects/hover_card_component_preview.rb b/lookbook/previews/open_project/projects/hover_card_component_preview.rb index 2a27dbac357c..0c859feb304f 100644 --- a/lookbook/previews/open_project/projects/hover_card_component_preview.rb +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -9,7 +9,6 @@ def no_hierarchy def with_hierarchy user = FactoryBot.build_stubbed(:admin) - status = FactoryBot.build_stubbed(:status) root = FactoryBot.build_stubbed(:project, name: "Arcanos Flight Planning") child = FactoryBot.build_stubbed(:project, name: "Mobile app", parent: root) leaf = FactoryBot.build_stubbed(:project, active: false, name: "Data sources", parent: child, status_code: 1) From 42befc67e590e5ef24e4f0bc9c384d6eaa18b028 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 11:40:45 +0100 Subject: [PATCH 7/8] [DREAM-869] Adding tests Adds tests for the new components, including for the new with_icon functionality on status-badge. Passes `system_arguments` around the new components, so we get data-test-ids. --- .../projects/hover_card_component.html.erb | 6 +- .../workspace_label_component.html.erb | 2 +- .../projects/workspace_label_component.rb | 3 +- .../projects/hover_card_component_spec.rb | 176 ++++++++++++++++++ .../projects/status_badge_component_spec.rb | 28 +++ .../workspace_label_somponent_spec.rb | 81 ++++++++ 6 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 spec/components/projects/hover_card_component_spec.rb create mode 100644 spec/components/projects/workspace_label_somponent_spec.rb diff --git a/app/components/projects/hover_card_component.html.erb b/app/components/projects/hover_card_component.html.erb index 4a1785e7b8ac..e0d6abff7350 100644 --- a/app/components/projects/hover_card_component.html.erb +++ b/app/components/projects/hover_card_component.html.erb @@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details. flex_layout(classes: "op-project-hover-card", data: { test_selector: "project-hover-card-#{@id}" }) do |flex| if breadcrumb_items.present? flex.with_row(mb: 1) do - render(Primer::Beta::Breadcrumbs.new) do |breadcrumbs| + render(Primer::Beta::Breadcrumbs.new(data: { test_selector: "hierarchy-#{@id}" })) do |breadcrumbs| breadcrumb_items.each_with_index do |item, i| breadcrumbs.with_item(href: item[:href], font_size: :small, font_weight: i == breadcrumb_items.size - 1 ? :bold : nil) do render(Primer::Beta::Truncate.new) do |truncate| @@ -48,7 +48,7 @@ See COPYRIGHT and LICENSE files for more details. flex.with_row(flex_layout: true, align_items: :center) do |row| row.with_column(mr: 2) do - render(Projects::WorkspaceLabelComponent.new(project: @project)) + render(Projects::WorkspaceLabelComponent.new(project: @project, data: { test_selector: "workspace-label-#{@id}" })) end if @project.archived? @@ -58,7 +58,7 @@ See COPYRIGHT and LICENSE files for more details. end row.with_column do - render(Projects::StatusBadgeComponent.new(project: @project, size: :large, with_icon: true)) + render(Projects::StatusBadgeComponent.new(project: @project, size: :large, with_icon: true, data: { test_selector: "status-badge-#{@id}" })) end end end diff --git a/app/components/projects/workspace_label_component.html.erb b/app/components/projects/workspace_label_component.html.erb index a40b4520b7a5..1580a5c7e55f 100644 --- a/app/components/projects/workspace_label_component.html.erb +++ b/app/components/projects/workspace_label_component.html.erb @@ -1,5 +1,5 @@ <%= - flex_layout(align_items: :center) do |container| + flex_layout(align_items: :center, **@system_arguments) do |container| container.with_column(mr: 1) do render(Primer::Beta::Octicon.new(icon: icon_name, color: :muted)) end diff --git a/app/components/projects/workspace_label_component.rb b/app/components/projects/workspace_label_component.rb index 09d90d2716a5..f92fb3297025 100644 --- a/app/components/projects/workspace_label_component.rb +++ b/app/components/projects/workspace_label_component.rb @@ -30,10 +30,11 @@ class Projects::WorkspaceLabelComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(project:) + def initialize(project:, **system_arguments) super @project = project + @system_arguments = system_arguments end private diff --git a/spec/components/projects/hover_card_component_spec.rb b/spec/components/projects/hover_card_component_spec.rb new file mode 100644 index 000000000000..a0e096f0dbbe --- /dev/null +++ b/spec/components/projects/hover_card_component_spec.rb @@ -0,0 +1,176 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Projects::HoverCardComponent, type: :component do + include Rails.application.routes.url_helpers + + shared_let(:grandparent) { create(:project, name: "Grandparent") } + shared_let(:parent) { create(:project, name: "Parent", parent: grandparent) } + shared_let(:project) { create(:project, name: "Leaf project", parent:) } + shared_let(:standalone_project) { create(:project, name: "Standalone project") } + + let(:current_user) do + create(:user, + member_with_permissions: { + grandparent => [:view_project], + parent => [:view_project], + project => [:view_project], + standalone_project => [:view_project] + }) + end + let(:displayed_project) { project } + + subject { described_class.new(project: displayed_project, user: current_user) } + + before do + login_as(current_user) + render_inline(subject) + end + + it "renders successfully" do + expect(page).to have_test_selector("project-hover-card-#{project.id}") + expect(page).to have_css(".op-project-hover-card") + end + + it "renders the project name as a heading" do + expect(page).to have_css("h3", text: "Leaf project") + end + + it "renders the workspace label" do + expect(page).to have_test_selector("workspace-label-#{project.id}") + end + + context "when displaying the status badge" do + let(:displayed_project) { create(:project, status_code: "on_track") } + + it "renders the project status badge" do + expect(page).to have_test_selector("status-badge-#{displayed_project.id}") + end + end + + context "when the project is active" do + it "does not show the archived label" do + expect(page).to have_no_text(I18n.t("project.archive.archived")) + end + end + + context "when the project is archived" do + let(:displayed_project) { create(:project, active: false) } + + it "shows the archived label" do + expect(page).to have_text(I18n.t("project.archive.archived")) + end + end + + context "when displaying the hierarchy" do + context "when the project has no ancestors" do + let(:displayed_project) { standalone_project } + + it "renders no breadcrumbs" do + expect(page).to have_no_test_selector("hierarchy-#{project.id}") + end + end + + context "when the project has visible ancestors" do + it "renders the ancestors followed by the project, in hierarchy order" do + hierarchy = find_test_selector("hierarchy-#{project.id}") + + expect(hierarchy).to have_text("Grandparent") + expect(hierarchy).to have_text("Parent") + expect(hierarchy).to have_text("Leaf project") + end + + it "links each item to the project overview" do + expect(page).to have_link("Grandparent", href: project_overview_path(grandparent)) + expect(page).to have_link("Parent", href: project_overview_path(parent)) + expect(page).to have_link("Leaf project", href: project_overview_path(project)) + end + end + + context "when an ancestor is not visible to the user" do + let(:current_user) do + create(:user, member_with_permissions: { project => [:view_project], parent => [:view_project] }) + end + + it "omits the hidden ancestor" do + expect(page).to have_no_text("Grandparent") + expect(page).to have_text("Parent") + end + end + + context "when no ancestor is visible to the user" do + let(:current_user) do + create(:user, member_with_permissions: { project => [:view_project] }) + end + + it "renders no breadcrumbs and does not leak ancestor names" do + expect(page).to have_no_css(".breadcrumb-item") + expect(page).to have_no_text("Parent") + expect(page).to have_no_text("Grandparent") + end + end + + context "when an ancestor is archived" do + before do + parent.update_column(:active, false) + render_inline(described_class.new(project:, user: current_user)) + end + + it "omits the archived ancestor" do + expect(page).to have_no_text("Parent") + end + end + end + + describe "#breadcrumb_items" do + let(:items) { subject.breadcrumb_items } + + context "without ancestors" do + let(:displayed_project) { standalone_project } + + it "returns nil" do + expect(items).to be_nil + end + end + + context "with visible ancestors" do + it "returns hashes with href, text and selected" do + expect(items).to eq( + [ + { href: project_overview_path(grandparent), text: "Grandparent", selected: false }, + { href: project_overview_path(parent), text: "Parent", selected: false }, + { href: project_overview_path(project), text: "Leaf project", selected: true } + ] + ) + end + end + end +end diff --git a/spec/components/projects/status_badge_component_spec.rb b/spec/components/projects/status_badge_component_spec.rb index a64595f1a0eb..2c11432bfa0f 100644 --- a/spec/components/projects/status_badge_component_spec.rb +++ b/spec/components/projects/status_badge_component_spec.rb @@ -45,6 +45,16 @@ def render_component(...) it "renders nothing" do expect(rendered_component.to_s).to be_empty end + + context "and with_icon is true" do + subject(:rendered_component) do + render_component(project:, with_icon: true) + end + + it "still renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end end context "when status code is set" do @@ -53,5 +63,23 @@ def render_component(...) it "renders a label" do expect(rendered_component).to have_primer_label "Off track" end + + it "does not render an icon by default" do + expect(rendered_component).to have_no_css "svg.octicon" + end + + context "with with_icon: true" do + subject(:rendered_component) do + render_component(project:, with_icon: true) + end + + it "renders the label text" do + expect(rendered_component).to have_primer_label "Off track" + end + + it "renders an icon inside the label" do + expect(rendered_component).to have_css ".Label svg.octicon" + end + end end end diff --git a/spec/components/projects/workspace_label_somponent_spec.rb b/spec/components/projects/workspace_label_somponent_spec.rb new file mode 100644 index 000000000000..54834a3e5942 --- /dev/null +++ b/spec/components/projects/workspace_label_somponent_spec.rb @@ -0,0 +1,81 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Projects::WorkspaceLabelComponent, type: :component do + def render_component(...) + render_inline(described_class.new(...)) + end + + let(:project) { build_stubbed(:project, workspace_type:) } + let(:workspace_type) { :project } + + subject(:rendered_component) do + render_component(project:) + end + + context "when the workspace is a project" do + let(:workspace_type) { :project } + + it "renders the project label" do + expect(rendered_component).to have_text "Project" + end + end + + context "when the workspace is a program" do + let(:workspace_type) { :program } + + it "renders the program label" do + expect(rendered_component).to have_text "Program" + end + end + + context "when the workspace is a portfolio" do + let(:workspace_type) { :portfolio } + + it "renders the portfolio label" do + expect(rendered_component).to have_text "Portfolio" + end + end + + it "renders an icon" do + expect(rendered_component).to have_css "svg.octicon" + end + + context "with system arguments" do + subject(:rendered_component) do + render_component(project:, classes: "my-custom-class") + end + + it "forwards them to the wrapper" do + expect(rendered_component).to have_css ".my-custom-class" + end + end +end From 6eafa8c22cb05c941e5809465e7d48dcabd1ba14 Mon Sep 17 00:00:00 2001 From: Darren Cadwallader Date: Mon, 26 Oct 2026 14:32:21 +0100 Subject: [PATCH 8/8] [DREAM-869] Linting --- .../projects/hover_card_component_preview.rb | 33 ++++++++++++++++++- .../status_badge_component_preview.rb | 2 +- ...c.rb => workspace_label_component_spec.rb} | 0 3 files changed, 33 insertions(+), 2 deletions(-) rename spec/components/projects/{workspace_label_somponent_spec.rb => workspace_label_component_spec.rb} (100%) diff --git a/lookbook/previews/open_project/projects/hover_card_component_preview.rb b/lookbook/previews/open_project/projects/hover_card_component_preview.rb index 0c859feb304f..2297a82e4b46 100644 --- a/lookbook/previews/open_project/projects/hover_card_component_preview.rb +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -1,6 +1,37 @@ +# 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, see . +# +# See COPYRIGHT and LICENSE files for more details. +#++ + module OpenProject::Projects # @logical_path OpenProject/Projects class HoverCardComponentPreview < ViewComponent::Preview + SCOPES = %i[visible active].freeze + def no_hierarchy user = FactoryBot.build_stubbed(:admin) project = FactoryBot.build_stubbed(:project, :with_status) @@ -17,7 +48,7 @@ def with_hierarchy chain.each_with_index do |project, i| ancestors = chain.first(i) - %i[visible active].each { |scope| ancestors.define_singleton_method(scope) { |*| self } } + SCOPES.each { |scope| ancestors.define_singleton_method(scope) { |*| self } } ancestors.define_singleton_method(:reorder) { |*| self } project.define_singleton_method(:ancestors) { ancestors } end diff --git a/lookbook/previews/open_project/projects/status_badge_component_preview.rb b/lookbook/previews/open_project/projects/status_badge_component_preview.rb index 636fe0ab55df..a767f41f070a 100644 --- a/lookbook/previews/open_project/projects/status_badge_component_preview.rb +++ b/lookbook/previews/open_project/projects/status_badge_component_preview.rb @@ -41,7 +41,7 @@ def playground(with_icon: false) def with_icon(with_icon: true) project = FactoryBot.build_stubbed(:project, :with_status) - render(Projects::StatusBadgeComponent.new(project:, with_icon: true)) + render(Projects::StatusBadgeComponent.new(project:, with_icon:)) end end end diff --git a/spec/components/projects/workspace_label_somponent_spec.rb b/spec/components/projects/workspace_label_component_spec.rb similarity index 100% rename from spec/components/projects/workspace_label_somponent_spec.rb rename to spec/components/projects/workspace_label_component_spec.rb