diff --git a/app/components/_index.sass b/app/components/_index.sass index 5ea38dfa192d..a15d95529d92 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -20,6 +20,7 @@ @import "open_project/common/submenu_component" @import "filter/filters_component" @import "projects/row_component" +@import "projects/phases/hover_card_component" @import "op_primer/border_box_table_component" @import "op_primer/form_helpers" @import "work_packages/exports/modal_dialog_component" diff --git a/app/components/projects/phase_component.html.erb b/app/components/projects/phase_component.html.erb index 1079ff7a7afb..2ad6467291a9 100644 --- a/app/components/projects/phase_component.html.erb +++ b/app/components/projects/phase_component.html.erb @@ -1,7 +1,10 @@ <%= flex_layout(align_items: :center, classes: "gap-2") do |type_container| if display_start_gate? - type_container.with_column(classes: icon_color_class) do + type_container.with_column( + classes: icon_color_class, + data: hover_card_data_args(gate: :start) + ) do render Primer::Beta::Octicon.new(icon: gate_icon) end end @@ -15,7 +18,10 @@ render(Primer::Beta::Text.new) { finish_date } end if display_finish_gate? - type_container.with_column(classes: icon_color_class) do + type_container.with_column( + classes: icon_color_class, + data: hover_card_data_args(gate: :finish) + ) do render Primer::Beta::Octicon.new(icon: gate_icon) end end diff --git a/app/components/projects/phase_component.rb b/app/components/projects/phase_component.rb index 93f56f23aa2f..c48f4ae25395 100644 --- a/app/components/projects/phase_component.rb +++ b/app/components/projects/phase_component.rb @@ -30,6 +30,7 @@ module Projects class PhaseComponent < ApplicationComponent include OpPrimer::ComponentHelpers + include Projects::Phases::Shared def initialize(phase:, **) @phase = phase @@ -61,12 +62,14 @@ def display_finish_gate? phase.finish_gate? && phase.finish_date.present? end - def gate_icon - :"op-gate" - end + def hover_card_data_args(gate:) + raise ArgumentError, "gate must be either :start or :finish" unless %i[start finish].include?(gate) - def icon_color_class - helpers.hl_inline_class("project_phase_definition", phase.definition_id) + { + hover_card_trigger_target: "trigger", + hover_card_url: hover_card_project_phase_path(phase, gate:), + test_selector: "phase-#{phase.id}-#{gate}-gate" + } end private diff --git a/app/components/projects/phases/hover_card_component.html.erb b/app/components/projects/phases/hover_card_component.html.erb new file mode 100644 index 000000000000..b5cc2c84a3d5 --- /dev/null +++ b/app/components/projects/phases/hover_card_component.html.erb @@ -0,0 +1,58 @@ +<%#-- 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. + +++#%> + +<%= + flex_layout(classes: "op-phase-gate-hover-card", data: { test_selector: "phase-gate-hover-card-#{phase.id}" }) do |flex| + flex.with_row do + flex_layout(classes: "op-phase-gate-hover-card--info", justify_content: :space_between) do |f| + f.with_column(classes: "op-phase-gate-hover-card--name flex-self-start") do + flex_layout do |fl| + fl.with_column(classes: icon_color_class, mr: 1) do + render Primer::Beta::Octicon.new(icon: gate_icon) + end + fl.with_column do + render(Primer::Beta::Text.new(data: { test_selector: "phase-gate-hover-card-name" })) { phase_gate_name } + end + end + end + + f.with_column(classes: "op-phase-gate-hover-card--date flex-self-end") do + flex_layout do |fl| + fl.with_column(mr: 1) do + render Primer::Beta::Octicon.new(icon: :calendar, color: :muted) + end + fl.with_column do + render(Primer::Beta::Text.new(data: { test_selector: "phase-gate-hover-card-date" })) { phase_gate_date } + end + end + end + end + end + end +%> diff --git a/app/components/projects/phases/hover_card_component.rb b/app/components/projects/phases/hover_card_component.rb new file mode 100644 index 000000000000..5c1fbbdd650b --- /dev/null +++ b/app/components/projects/phases/hover_card_component.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + module Phases + class HoverCardComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include Projects::Phases::Shared + + attr_reader :phase + + def initialize(phase:, gate:) + raise ArgumentError, "gate must be either 'start' or 'finish'" unless %w[start finish].include?(gate) + + super + + @phase = phase + @gate = gate.to_sym + end + + def phase_gate_name + case @gate + when :start + @phase.start_gate? ? @phase.start_gate_name : nil + else + @phase.finish_gate? ? @phase.finish_gate_name : nil + end + end + + def phase_gate_date + date = case @gate + when :start + @phase.start_date + else + @phase.finish_date + end + + helpers.format_date(date) + end + end + end +end diff --git a/app/components/projects/phases/hover_card_component.sass b/app/components/projects/phases/hover_card_component.sass new file mode 100644 index 000000000000..7a16d26fc66a --- /dev/null +++ b/app/components/projects/phases/hover_card_component.sass @@ -0,0 +1,6 @@ +.op-hover-card:has(.op-phase-gate-hover-card) + max-width: 380px + +.op-phase-gate-hover-card + &--name + @include text-shortener() diff --git a/app/components/projects/phases/shared.rb b/app/components/projects/phases/shared.rb new file mode 100644 index 000000000000..ff17be388e7f --- /dev/null +++ b/app/components/projects/phases/shared.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects + module Phases + module Shared + def gate_icon = :"op-gate" + + def icon_color_class + helpers.hl_inline_class("project_phase_definition", phase.definition_id) + end + end + end +end diff --git a/app/controllers/project_phases/hover_card_controller.rb b/app/controllers/project_phases/hover_card_controller.rb new file mode 100644 index 000000000000..0e5bbc827de6 --- /dev/null +++ b/app/controllers/project_phases/hover_card_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ProjectPhases + class HoverCardController < ApplicationController + before_action :authorize + before_action :check_feature_flag + before_action :assign_gate + before_action :find_phase + before_action :check_access + + layout false + + def show; end + + private + + def check_feature_flag + return if OpenProject::FeatureDecisions.stages_and_gates_active? + + render json: { error: "Not found" }, status: :not_found + end + + def check_access + return if User.current.allowed_in_project?(:view_project_phases, @phase.project) + + render json: { error: "Forbidden" }, status: :forbidden + end + + def assign_gate + @gate = params[:gate] + return if @gate.in?(%w[start finish]) + + render json: { error: "Invalid gate parameter" }, status: :unprocessable_entity + end + + def find_phase + @phase = Project::Phase.where(active: true).eager_load(:definition).find_by(id: params[:id]) + return if @phase + + render json: { error: "Invalid id parameter" }, status: :unprocessable_entity + end + end +end diff --git a/app/views/project_phases/hover_card/show.html.erb b/app/views/project_phases/hover_card/show.html.erb new file mode 100644 index 000000000000..5edea1318e2c --- /dev/null +++ b/app/views/project_phases/hover_card/show.html.erb @@ -0,0 +1,3 @@ + + <%= render Projects::Phases::HoverCardComponent.new(phase: @phase, gate: @gate) %> + diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 48f0996b6110..21a8db5a60d1 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -135,7 +135,9 @@ require: :member map.permission :view_project_phases, - {}, + { + "project_phases/hover_card": :show + }, permissible_on: :project, dependencies: :view_project, visible: -> { OpenProject::FeatureDecisions.stages_and_gates_active? } diff --git a/config/routes.rb b/config/routes.rb index d20489056254..50e612259052 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -449,6 +449,12 @@ end end + resources :project_phases, only: [] do + member do + get "/hover_card" => "project_phases/hover_card#show", as: "hover_card" + end + end + resources :admin, controller: :admin, only: :index do collection do get :plugins diff --git a/frontend/src/stimulus/controllers/hover-card-trigger.controller.ts b/frontend/src/stimulus/controllers/hover-card-trigger.controller.ts index 8cea1823c525..dd4fe4afbca4 100644 --- a/frontend/src/stimulus/controllers/hover-card-trigger.controller.ts +++ b/frontend/src/stimulus/controllers/hover-card-trigger.controller.ts @@ -41,6 +41,8 @@ import { computePosition, flip, limitShift, shift } from '@floating-ui/dom'; export default class HoverCardTriggerController extends ApplicationController { static targets = ['trigger', 'card']; + private readonly triggerTargets:HTMLElement[]; + private mouseInModal = false; private hoverTimeout:number|null = null; private closeTimeout:number|null = null; @@ -112,10 +114,19 @@ export default class HoverCardTriggerController extends ApplicationController { e.preventDefault(); e.stopPropagation(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const el = e.target as HTMLElement; + let el = e.target as HTMLElement; if (!el) { return; } + // If the trigger contains other elements, one of them might have triggered the event. We want to only refer to + // the original trigger as this makes event and state handling easier. Find the correct target element: + if (!this.triggerTargets.some((trigger) => trigger === el)) { + // If the element is not a trigger itself, one of its parents must be. Find the correct one. + const trigger = el.closest('[data-hover-card-trigger-target="trigger"]') as HTMLElement; + if (!trigger) { return; } + + el = trigger; + } + if (this.previousTarget && this.previousTarget === el) { // Re-entering the trigger counts as hovering over the card: this.mouseInModal = true; diff --git a/lookbook/docs/components/hover-cards.md.erb b/lookbook/docs/components/hover-cards.md.erb index 172298e5ba4d..b0b5417e0b6f 100644 --- a/lookbook/docs/components/hover-cards.md.erb +++ b/lookbook/docs/components/hover-cards.md.erb @@ -24,6 +24,7 @@ The HoverCard always consists of two basic parts: - WorkPackage preview when linking via `#ID` - User preview on usernames and avatars +- Phase gate details on the phase gate icon ## Technical notes diff --git a/spec/components/projects/phases/hover_card_component_spec.rb b/spec/components/projects/phases/hover_card_component_spec.rb new file mode 100644 index 000000000000..117d67da0f7a --- /dev/null +++ b/spec/components/projects/phases/hover_card_component_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Projects::Phases::HoverCardComponent, type: :component do + include Rails.application.routes.url_helpers + + let(:phase) { build_stubbed(:project_phase, :with_gated_definition) } + let(:gate) { "start" } + + subject { described_class.new(phase:, gate:) } + + before do + render_inline(subject) + page.extend TestSelectorFinders + end + + context "for start" do + it "renders successfully" do + page.find_test_selector("phase-gate-hover-card-name", text: phase.start_gate_name) + page.find_test_selector("phase-gate-hover-card-date", text: phase.start_date.strftime("%m/%d/%Y")) + end + + context "without a definition" do + let(:phase) { create(:project_phase) } + + it "renders, but has no content" do + page.find_test_selector("phase-gate-hover-card-name", text: "") + page.find_test_selector("phase-gate-hover-card-date", text: "") + end + end + end + + context "for finish" do + let(:gate) { "finish" } + + it "renders successfully" do + page.find_test_selector("phase-gate-hover-card-name", text: phase.finish_gate_name) + page.find_test_selector("phase-gate-hover-card-date", text: phase.finish_date.strftime("%m/%d/%Y")) + end + + context "without a definition" do + let(:phase) { create(:project_phase) } + + it "renders, but has no content" do + page.find_test_selector("phase-gate-hover-card-name", text: "") + page.find_test_selector("phase-gate-hover-card-date", text: "") + end + end + end +end diff --git a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb index 5c47083dc774..009e410cb34a 100644 --- a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb @@ -92,6 +92,22 @@ end end + it "shows a hover card when you hover over a gate" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + page.find_test_selector("phase-#{life_cycle_planning.id}-start-gate").hover + end + + expect(page).to have_test_selector("phase-gate-hover-card-name", text: life_cycle_planning.start_gate_name) + + overview_page.within_life_cycles_sidebar do + page.find_test_selector("phase-#{life_cycle_planning.id}-finish-gate").hover + end + + expect(page).to have_test_selector("phase-gate-hover-card-name", text: life_cycle_planning.finish_gate_name) + end + it "does not show phases not enabled for this project in a sidebar" do life_cycle_executing.toggle!(:active)