diff --git a/app/components/open_project/common/work_package_card_box_component.rb b/app/components/open_project/common/work_package_card_box_component.rb index 6183b08c373..244443d25b2 100644 --- a/app/components/open_project/common/work_package_card_box_component.rb +++ b/app/components/open_project/common/work_package_card_box_component.rb @@ -92,11 +92,14 @@ module OpenProject # # `#card` returning a renderable object. # # @param item_menu_src [String, NilClass] optional menu source for the # # item's `WorkPackageCardComponent`. + # # @param item_metric [Object, NilClass] optional metric content for the + # # item's `WorkPackageCardComponent`. # # @param system_arguments [Hash] forwarded to the item class. # def with_work_package_item( # work_package:, # component_klass: Item, # item_menu_src: nil, + # item_metric: nil, # **system_arguments, # &block # ) @@ -114,8 +117,10 @@ module OpenProject # end renders_many :items, types: { work_package_item: { - renders: lambda { |work_package:, **system_arguments| - build_item(work_package:, **system_arguments) + renders: lambda { |work_package:, **system_arguments, &block| + build_item(work_package:, **system_arguments).tap do |item| + capture(item, &block) if block + end }, as: :work_package_item }, @@ -137,6 +142,7 @@ module OpenProject :container, :drag_and_drop, :item_menu_src, + :item_metric, :params, :current_user @@ -154,6 +160,9 @@ module OpenProject # automatically built items. Procs receive the work package. When set, # callers are responsible for including any URL params they want in the # returned source. + # @param item_metric [Proc, NilClass] optional metric content for + # automatically built items. Procs receive the work package and return + # renderable content. # @param params [Hash] optional URL params passed to work package items # when deriving row and menu URLs. # @param current_user [User] passed through to each item for permission @@ -166,6 +175,7 @@ module OpenProject work_packages: [], drag_and_drop: nil, item_menu_src: nil, + item_metric: nil, params: {}, current_user: User.current, **system_arguments @@ -177,6 +187,7 @@ module OpenProject @container = container @drag_and_drop = drag_and_drop @item_menu_src = item_menu_src + @item_metric = item_metric @params = params @current_user = current_user @automatic_items = false @@ -193,6 +204,7 @@ module OpenProject # so slot calls have already populated `items`. content validate_item_menu_src! + validate_item_metric! validate_item_mode! build_automatic_items if build_automatic_items? validate_empty_state! @@ -215,11 +227,13 @@ module OpenProject # @param item_menu_src [String, NilClass] optional item menu source # override. When set, callers are responsible for including any URL # params they want in the source. + # @param item_metric [Object, NilClass] optional item metric content. # @param system_arguments [Hash] forwarded to the item class. def build_item( work_package:, component_klass: Item, item_menu_src: item_menu_src_for(work_package), + item_metric: item_metric_for(work_package), **system_arguments ) component_klass.new( @@ -228,6 +242,7 @@ module OpenProject container:, params:, item_menu_src:, + item_metric:, current_user:, **system_arguments ) @@ -265,12 +280,25 @@ module OpenProject end end + def item_metric_for(work_package) + return unless item_metric + + metric = item_metric.call(work_package) + metric.respond_to?(:render_in) ? render(metric) : metric + end + def validate_item_menu_src! return if item_menu_src.nil? || item_menu_src.is_a?(Proc) || item_menu_src.is_a?(String) raise ArgumentError, "item_menu_src must be a Proc, String, or nil" end + def validate_item_metric! + return if item_metric.nil? || item_metric.is_a?(Proc) + + raise ArgumentError, "item_metric must be a Proc or nil" + end + def validate_item_mode! return unless empty_items.any? diff --git a/app/components/open_project/common/work_package_card_box_component/item.rb b/app/components/open_project/common/work_package_card_box_component/item.rb index f177257f3ad..b028f449dd3 100644 --- a/app/components/open_project/common/work_package_card_box_component/item.rb +++ b/app/components/open_project/common/work_package_card_box_component/item.rb @@ -43,15 +43,19 @@ module OpenProject :project, :container, :item_menu_src, + :item_metric, :params, :current_user + delegate :with_metric, to: :card + def initialize( work_package:, project:, container:, params: {}, item_menu_src: nil, + item_metric: nil, current_user: User.current, **system_arguments ) @@ -64,6 +68,7 @@ module OpenProject @container = container @params = params @item_menu_src = item_menu_src + @item_metric = item_metric @current_user = current_user @system_arguments = system_arguments end @@ -81,7 +86,9 @@ module OpenProject end def card - @card ||= WorkPackageCardComponent.new(work_package:, menu_src: item_menu_src) + @card ||= WorkPackageCardComponent.new(work_package:, menu_src: item_menu_src).tap do |card| + card.with_metric_content(item_metric) if item_metric + end end def render? = false diff --git a/app/components/open_project/common/work_package_card_component.html.erb b/app/components/open_project/common/work_package_card_component.html.erb index fc374aa78f3..cb2bcf0b5ba 100644 --- a/app/components/open_project/common/work_package_card_component.html.erb +++ b/app/components/open_project/common/work_package_card_component.html.erb @@ -27,15 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++# %> -<%= grid_layout("op-work-package-card", tag: :article) do |grid| %> +<%= grid_layout(card_classes, tag: :article) do |grid| %> <% grid.with_area(:info_line) do %> <%= render(WorkPackages::InfoLineComponent.new(work_package:)) %> <% end %> - <% grid.with_area(:points) do %> - <%= render(Primer::Beta::Text.new(color: :subtle)) do %> - <%= story_points %> - <%= t(:"backlogs.points_label", count: story_points) %> + <% if metric? %> + <% grid.with_area(:metric) do %> + <%= metric %> <% end %> <% end %> diff --git a/app/components/open_project/common/work_package_card_component.rb b/app/components/open_project/common/work_package_card_component.rb index eef7e67c720..bcbea5c8e56 100644 --- a/app/components/open_project/common/work_package_card_component.rb +++ b/app/components/open_project/common/work_package_card_component.rb @@ -31,8 +31,11 @@ module OpenProject module Common class WorkPackageCardComponent < ApplicationComponent + include Primer::ClassNameHelper include OpPrimer::ComponentHelpers + renders_one :metric, Primer::Content + attr_reader :work_package, :menu_src # @param work_package [WorkPackage] the work package this card represents. @@ -44,10 +47,11 @@ module OpenProject @menu_src = menu_src end - private - - def story_points - work_package.story_points || 0 + def card_classes + class_names( + "op-work-package-card", + "op-work-package-card_with-metric": metric? + ) end end end diff --git a/app/components/open_project/common/work_package_card_component.sass b/app/components/open_project/common/work_package_card_component.sass index 79624a91b3a..6a1a4a72d15 100644 --- a/app/components/open_project/common/work_package_card_component.sass +++ b/app/components/open_project/common/work_package_card_component.sass @@ -28,14 +28,18 @@ .op-work-package-card display: grid - grid-template-columns: 1fr minmax(5rem, max-content) auto + grid-template-columns: 1fr auto grid-template-rows: auto auto - grid-template-areas: "info_line points menu" "subject subject subject" + grid-template-areas: "info_line menu" "subject subject" align-items: center margin-top: calc(-1 * var(--base-size-4)) margin-bottom: var(--base-size-4) -.op-work-package-card--points +.op-work-package-card_with-metric + grid-template-columns: 1fr minmax(5rem, max-content) auto + grid-template-areas: "info_line metric menu" "subject subject subject" + +.op-work-package-card--metric margin-left: var(--stack-gap-normal) font-variant-numeric: tabular-nums @@ -51,8 +55,8 @@ // `backlogsListsContainer` named container is established on // `.op-backlogs-page`; outside of it these rules are inert. @container backlogsListsContainer (max-width: 654px) - .op-work-package-card-points-label + .op-work-package-card-metric-label display: none - .op-work-package-card + .op-work-package-card_with-metric grid-template-columns: 1fr minmax(2rem, max-content) auto diff --git a/lookbook/previews/open_project/common/work_package_card_box_component_preview.rb b/lookbook/previews/open_project/common/work_package_card_box_component_preview.rb index ab7ad4e1fff..d32a65f2d0b 100644 --- a/lookbook/previews/open_project/common/work_package_card_box_component_preview.rb +++ b/lookbook/previews/open_project/common/work_package_card_box_component_preview.rb @@ -41,7 +41,9 @@ module OpenProject work_packages = sprint.work_packages_for(project).limit(3) render OpenProject::Common::WorkPackageCardBoxComponent.new( - work_packages:, project:, container: sprint + work_packages:, + project:, + container: sprint ) do |box| box.with_header(title: sprint.name, count: work_packages.size) do |header| points = work_packages.sum { |w| w.story_points || 0 } diff --git a/lookbook/previews/open_project/common/work_package_card_component_preview.rb b/lookbook/previews/open_project/common/work_package_card_component_preview.rb index 17ab09b5727..023d64a9667 100644 --- a/lookbook/previews/open_project/common/work_package_card_component_preview.rb +++ b/lookbook/previews/open_project/common/work_package_card_component_preview.rb @@ -41,6 +41,19 @@ module OpenProject ) end + def with_metric + work_package = WorkPackage.first + return preview_message("No work packages in the database.") unless work_package + + render OpenProject::Common::WorkPackageCardComponent.new( + work_package: + ) do |card| + card.with_metric do + render Backlogs::StoryPointsComponent.new(work_package:) + end + end + end + private def preview_message(text) diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index b29e029c8fb..c8c0f26870d 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -44,6 +44,7 @@ See COPYRIGHT and LICENSE files for more details. all_backlogs_params ) end, + item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) }, params: all_backlogs_params, current_user:, data: { test_selector: "backlog-bucket-#{backlog_bucket.id}" } diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index b7123131212..733fce566ad 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -44,6 +44,7 @@ See COPYRIGHT and LICENSE files for more details. all_backlogs_params ) end, + item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) }, params: all_backlogs_params, current_user:, data: { test_selector: "backlog-inbox" } diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index bb95943d9b9..7ea388165d3 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -45,6 +45,7 @@ See COPYRIGHT and LICENSE files for more details. all_backlogs_params ) end, + item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) }, params: all_backlogs_params, current_user:, data: { test_selector: "sprint-#{sprint.id}" } diff --git a/modules/backlogs/app/components/backlogs/story_points_component.html.erb b/modules/backlogs/app/components/backlogs/story_points_component.html.erb new file mode 100644 index 00000000000..842c8ab06e2 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/story_points_component.html.erb @@ -0,0 +1,33 @@ +<%# -- 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. + +++%> + +<%= render(Primer::Beta::Text.new(color: :subtle)) do %> + <%= story_points %> + <%= t(:"backlogs.points_label", count: story_points) %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/story_points_component.rb b/modules/backlogs/app/components/backlogs/story_points_component.rb new file mode 100644 index 00000000000..cdf919691c4 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/story_points_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, 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 Backlogs + class StoryPointsComponent < ApplicationComponent + attr_reader :work_package + + def initialize(work_package:) + super() + + @work_package = work_package + end + + private + + def story_points + work_package.story_points || 0 + end + end +end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 70cc1a8c35d..273e4007a8f 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -112,6 +112,10 @@ RSpec.describe Backlogs::BucketComponent, type: :component do expect(rendered_component).to have_text("##{work_package.id}") end + it "renders story points on the work package card" do + expect(rendered_component).to have_text("3 points", normalize_ws: true) + end + it "wires the bucket drop-target data on the box" do expect(rendered_component).to have_css(".Box") do |box| expect(box["data-generic-drag-and-drop-target"]).to eq("container mirrorContainer") diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 45e28a3e731..2d8d8196d1e 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -83,8 +83,8 @@ RSpec.describe Backlogs::InboxComponent, type: :component do describe "with work packages" do let(:work_packages) do [ - create(:work_package, subject: "First item", project:, position: 1), - create(:work_package, subject: "Second item", project:, position: 2) + create(:work_package, subject: "First item", project:, story_points: 2, position: 1), + create(:work_package, subject: "Second item", project:, story_points: 4, position: 2) ] end @@ -98,6 +98,11 @@ RSpec.describe Backlogs::InboxComponent, type: :component do # does not show the blankslate expect(page).to have_no_css("h4", text: "Backlog inbox is empty") end + + it "renders story points on each work package card" do + expect(page).to have_text("2 points", normalize_ws: true) + expect(page).to have_text("4 points", normalize_ws: true) + end end describe "pagination" do diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index 94d1cab83c2..424af198c0d 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -79,6 +79,11 @@ RSpec.describe Backlogs::SprintComponent, type: :component do expect(rendered_component).to have_text("8 points", normalize_ws: true) end + it "renders story points on each work package card" do + expect(rendered_component).to have_text("5 points", normalize_ws: true) + expect(rendered_component).to have_text("3 points", normalize_ws: true) + end + it "renders one Box-row per work package" do expect(rendered_component).to have_css(".Box-row", count: 2) expect(rendered_component).to have_text(work_package1.subject) diff --git a/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb b/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb new file mode 100644 index 00000000000..cd6676601df --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/story_points_component_spec.rb @@ -0,0 +1,51 @@ +# 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 Backlogs::StoryPointsComponent, type: :component do + shared_let(:project) { create(:project) } + + it "renders the work package story points" do + work_package = create(:work_package, project:, story_points: 5) + + render_inline(described_class.new(work_package:)) + + expect(page).to have_text("5 points", normalize_ws: true) + end + + it "renders zero when story points are unset" do + work_package = create(:work_package, project:, story_points: nil) + + render_inline(described_class.new(work_package:)) + + expect(page).to have_text("0 points", normalize_ws: true) + end +end diff --git a/spec/components/open_project/common/work_package_card_box_component/item_spec.rb b/spec/components/open_project/common/work_package_card_box_component/item_spec.rb index 5c19fefec9a..6488a501b35 100644 --- a/spec/components/open_project/common/work_package_card_box_component/item_spec.rb +++ b/spec/components/open_project/common/work_package_card_box_component/item_spec.rb @@ -181,6 +181,12 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co expect(item.card).to equal(item.card) end + it "forwards metric content to the visual card" do + item.with_metric { "Forwarded metric" } + + expect(rendered_card).to have_text("Forwarded metric") + end + context "with a provided menu source" do let(:item) do described_class.new( diff --git a/spec/components/open_project/common/work_package_card_box_component_spec.rb b/spec/components/open_project/common/work_package_card_box_component_spec.rb index fbc35655124..2720acb07da 100644 --- a/spec/components/open_project/common/work_package_card_box_component_spec.rb +++ b/spec/components/open_project/common/work_package_card_box_component_spec.rb @@ -47,6 +47,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen let(:container) { sprint } let(:drag_and_drop) { nil } let(:item_menu_src) { nil } + let(:item_metric) { nil } let(:params) { {} } let(:work_packages) { [] } let(:system_arguments) { {} } @@ -64,6 +65,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen container:, drag_and_drop:, item_menu_src:, + item_metric:, params:, current_user: user, **system_arguments @@ -306,6 +308,23 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen expect { rendered_component }.to raise_error(ArgumentError, /item_menu_src/) end end + + context "with an item_metric proc" do + let(:item_metric) { ->(work_package) { "metric #{work_package.id}" } } + + it "uses the derived metric for automatically built items" do + expect(rendered_component).to have_text("metric #{work_packages.first.id}") + expect(rendered_component).to have_text("metric #{work_packages.second.id}") + end + end + + context "with an invalid item_metric" do + let(:item_metric) { "static metric" } + + it "raises ArgumentError" do + expect { rendered_component }.to raise_error(ArgumentError, /item_metric/) + end + end end describe ":work_package_item slot" do @@ -329,6 +348,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen container:, params:, item_menu_src: nil, + item_metric: nil, current_user: User.current, **system_arguments ) @@ -336,6 +356,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen @work_package = work_package @item_menu_src = item_menu_src + @item_metric = item_metric @params = params @context = [project, container, current_user] @system_arguments = system_arguments @@ -347,6 +368,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen context_size: @context.size ) data[:item_menu_src] = @item_menu_src if @item_menu_src + data[:item_metric] = @item_metric if @item_metric @system_arguments.merge( id: "custom_work_package_#{@work_package.id}", @@ -425,6 +447,19 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen expect(rendered).to have_element("include-fragment", src: "/manual-menu") end + it "uses caller-provided metric content for manual work package items" do + rendered = render_inline( + described_class.new(project:, container:, params:, current_user: user) + ) do |box| + box.with_empty_state(title: "empty", description: "drag here") + box.with_work_package_item(work_package: slot_work_package) do |item| + item.with_metric { "manual metric" } + end + end + + expect(rendered).to have_text("manual metric") + end + it "exposes build_item for building an item without adding it to the box" do component = described_class.new(work_packages: [], project:, container:, params:, current_user: user) diff --git a/spec/components/open_project/common/work_package_card_component_spec.rb b/spec/components/open_project/common/work_package_card_component_spec.rb index 35e42e4a24e..d9034a3ea84 100644 --- a/spec/components/open_project/common/work_package_card_component_spec.rb +++ b/spec/components/open_project/common/work_package_card_component_spec.rb @@ -69,8 +69,16 @@ RSpec.describe OpenProject::Common::WorkPackageCardComponent, type: :component d expect(rendered_component).to have_text("Card subject") end - it "renders the story points label" do - expect(rendered_component).to have_text("5 points", normalize_ws: true) + it "does not render story points by default" do + expect(rendered_component).to have_no_text("5 points", normalize_ws: true) + end + + it "renders the metric slot when provided" do + rendered = render_inline(component) do |card| + card.with_metric { "Custom metric" } + end + + expect(rendered).to have_text("Custom metric") end it "renders a WorkPackageCardComponent::Menu kebab" do