[#73968] Move card metrics to Backlogs

Remove the story-points display from the shared work package card and
expose a generic metric slot instead. Backlogs now opts into that slot
with a small story-points component when rendering sprint, bucket, and
inbox cards.

https://community.openproject.org/wp/73968
This commit is contained in:
Alexander Brandon Coles
2026-05-01 19:48:20 +02:00
parent 60bb8e1ea9
commit 35d3fc8e8b
19 changed files with 276 additions and 22 deletions
@@ -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?
@@ -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
@@ -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 %>
<span class="op-work-package-card-points-label"> <%= t(:"backlogs.points_label", count: story_points) %></span>
<% if metric? %>
<% grid.with_area(:metric) do %>
<%= metric %>
<% end %>
<% end %>
@@ -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
@@ -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
@@ -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 }
@@ -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)
@@ -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}" }
@@ -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" }
@@ -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}" }
@@ -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 %>
<span class="op-work-package-card-metric-label"> <%= t(:"backlogs.points_label", count: story_points) %></span>
<% end %>
@@ -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
@@ -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")
@@ -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
@@ -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)
@@ -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
@@ -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(
@@ -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)
@@ -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