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..e0d6abff7350 --- /dev/null +++ b/app/components/projects/hover_card_component.html.erb @@ -0,0 +1,65 @@ +<%#-- 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-#{@id}" }) do |flex| + if breadcrumb_items.present? + flex.with_row(mb: 1) do + 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| + truncate.with_item(max_width: 135) { item[:text] } + end + end + end + end + end + end + + flex.with_row(mb: 1) do + render(Primer::Beta::Text.new(tag: :h3, font_size: 5)) { @name } + end + + flex.with_row(flex_layout: true, align_items: :center) do |row| + row.with_column(mr: 2) do + render(Projects::WorkspaceLabelComponent.new(project: @project, data: { test_selector: "workspace-label-#{@id}" })) + end + + if @project.archived? + 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, size: :large, with_icon: true, data: { test_selector: "status-badge-#{@id}" })) + 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..5f0fa143a63e --- /dev/null +++ b/app/components/projects/hover_card_component.rb @@ -0,0 +1,54 @@ +# 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 + @id = project.id + @name = project.name + @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/components/projects/info_line_component.html.erb b/app/components/projects/info_line_component.html.erb index 3996f7534d0a..03ea144c02ff 100644 --- a/app/components/projects/info_line_component.html.erb +++ b/app/components/projects/info_line_component.html.erb @@ -28,24 +28,23 @@ 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 - render(Primer::Beta::Label.new) { t("project.archive.archived") } + if @project.archived? + flex.with_column do + render(Primer::Beta::Label.new(scheme: :secondary)) { t("project.archive.archived") } end 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..19159bc0be78 100644 --- a/app/components/projects/status_badge_component.html.erb +++ b/app/components/projects/status_badge_component.html.erb @@ -1 +1,14 @@ -<%= render(Primer::Beta::Label.new(size: :medium, inline: true, **@system_arguments)) { name } %> +<%= + 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 + 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 new file mode 100644 index 000000000000..1580a5c7e55f --- /dev/null +++ b/app/components/projects/workspace_label_component.html.erb @@ -0,0 +1,11 @@ +<%= + 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 + + container.with_column do + 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 new file mode 100644 index 000000000000..f92fb3297025 --- /dev/null +++ b/app/components/projects/workspace_label_component.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. +#++ + +class Projects::WorkspaceLabelComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(project:, **system_arguments) + super + + @project = project + @system_arguments = system_arguments + 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/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/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 3a11cce6c2c4..0b6a8fae8306 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, 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..2297a82e4b46 --- /dev/null +++ b/lookbook/previews/open_project/projects/hover_card_component_preview.rb @@ -0,0 +1,59 @@ +# 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) + render(::Projects::HoverCardComponent.new(project:, user:)) + end + + def with_hierarchy + user = FactoryBot.build_stubbed(:admin) + 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, child, leaf] + + chain.each_with_index do |project, i| + ancestors = chain.first(i) + SCOPES.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 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..a767f41f070a --- /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:)) + end + end +end 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_component_spec.rb b/spec/components/projects/workspace_label_component_spec.rb new file mode 100644 index 000000000000..54834a3e5942 --- /dev/null +++ b/spec/components/projects/workspace_label_component_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