[#73968] Extract Backlogs card box items

Move Backlogs-specific item/card concerns behind Backlogs components
while keeping the common card box API focused on generic item
rendering. The common item keeps generic draggability hooks, while
Backlogs owns story points, menu URLs, and story controller row data.

https://community.openproject.org/wp/73968
This commit is contained in:
Alexander Brandon Coles
2026-05-01 21:27:02 +02:00
parent 15ac6ebcec
commit b6d78999d6
11 changed files with 552 additions and 361 deletions
@@ -86,20 +86,15 @@ module OpenProject
# #
# # @param work_package [WorkPackage] the work package rendered in the row.
# # @param component_klass [Class] row bridge class used instead of the
# # default `Item`. It must accept the arguments documented on
# # box's configured `item_component_klass`. It must accept the
# # arguments documented on
# # `#build_item`, expose `#row_args` with valid
# # `Primer::Beta::BorderBox#with_row` keyword arguments, and expose
# # `#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,
# component_klass: item_component_klass,
# **system_arguments,
# &block
# )
@@ -141,8 +136,7 @@ module OpenProject
:project,
:container,
:drag_and_drop,
:item_menu_src,
:item_metric,
:item_component_klass,
:params,
:current_user
@@ -156,15 +150,10 @@ module OpenProject
# exceeds the derived threshold.
# @param drag_and_drop [Hash, NilClass] optional generic drag-and-drop
# target data. Requires `:target_id` and `:allowed_drag_type` when set.
# @param item_menu_src [Proc, String, NilClass] optional menu source for
# 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 item_component_klass [Class] item class used for automatically
# built work package items.
# @param params [Hash] optional URL params passed to work package items
# when deriving row and menu URLs.
# when deriving row arguments.
# @param current_user [User] passed through to each item for permission
# checks; defaults to `User.current`.
# @param system_arguments [Hash] forwarded to the underlying
@@ -174,8 +163,7 @@ module OpenProject
container:,
work_packages: [],
drag_and_drop: nil,
item_menu_src: nil,
item_metric: nil,
item_component_klass: Item,
params: {},
current_user: User.current,
**system_arguments
@@ -186,8 +174,7 @@ module OpenProject
@project = project
@container = container
@drag_and_drop = drag_and_drop
@item_menu_src = item_menu_src
@item_metric = item_metric
@item_component_klass = item_component_klass
@params = params
@current_user = current_user
@automatic_items = false
@@ -203,8 +190,6 @@ module OpenProject
# Content must be loaded before mode validation and automatic item builds
# 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!
@@ -220,20 +205,13 @@ module OpenProject
# items outside this box, such as in a separately-loaded page.
#
# @param work_package [WorkPackage] the work package rendered in the row.
# @param component_klass [Class] item class used instead of the default
# `Item`. It must accept `work_package:`, `project:`, `container:`,
# `params:`, optional `item_menu_src:`, `current_user:`, and
# `**system_arguments`.
# @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 component_klass [Class] item class used instead of the configured
# default item class. It must accept `work_package:`, `project:`,
# `container:`, `params:`, `current_user:`, and `**system_arguments`.
# @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),
component_klass: item_component_klass,
**system_arguments
)
component_klass.new(
@@ -241,8 +219,6 @@ module OpenProject
project:,
container:,
params:,
item_menu_src:,
item_metric:,
current_user:,
**system_arguments
)
@@ -270,35 +246,6 @@ module OpenProject
@automatic_items
end
def item_menu_src_for(work_package)
return unless item_menu_src
if item_menu_src.is_a?(Proc)
item_menu_src.call(work_package)
else
item_menu_src
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?
@@ -42,8 +42,6 @@ module OpenProject
attr_reader :work_package,
:project,
:container,
:item_menu_src,
:item_metric,
:params,
:current_user
@@ -54,21 +52,15 @@ module OpenProject
project:,
container:,
params: {},
item_menu_src: nil,
item_metric: nil,
current_user: User.current,
**system_arguments
)
super()
validate_item_menu_src!(item_menu_src)
@work_package = work_package
@project = project
@container = container
@params = params
@item_menu_src = item_menu_src
@item_metric = item_metric
@current_user = current_user
@system_arguments = system_arguments
end
@@ -86,9 +78,7 @@ module OpenProject
end
def card
@card ||= WorkPackageCardComponent.new(work_package:, menu_src: item_menu_src).tap do |card|
card.with_metric_content(item_metric) if item_metric
end
@card ||= WorkPackageCardComponent.new(work_package:)
end
def render? = false
@@ -97,43 +87,6 @@ module OpenProject
private
def draggable?
current_user.allowed_in_project?(:manage_sprint_items, project)
end
def split_url
url_helpers.project_backlogs_backlog_details_path(project, work_package, params)
end
def full_url
url_helpers.work_package_path(work_package)
end
# Sprint is the only positive match; bucket and inbox both fall through
# to inbox routes.
def uses_inbox_routes?
!container.is_a?(Sprint)
end
def drop_url
if uses_inbox_routes?
url_helpers.move_project_backlogs_inbox_path(project, work_package, params)
else
url_helpers.move_project_backlogs_work_package_path(
project,
container,
work_package,
params
)
end
end
def validate_item_menu_src!(source)
return if source.nil? || source.is_a?(String)
raise ArgumentError, "item_menu_src must be a String or nil"
end
def row_classes
class_names(
"Box-row--hover-blue",
@@ -143,27 +96,20 @@ module OpenProject
)
end
# `story` data attrs match the live Stimulus controller and Dragula
# drag-type; renaming requires coordinated JS changes (separate PR).
def row_data
base = {
story: true,
controller: "backlogs--story",
backlogs__story_id_value: work_package.id,
backlogs__story_display_id_value: work_package.display_id,
backlogs__story_split_url_value: split_url,
backlogs__story_full_url_value: full_url,
backlogs__story_selected_class: "Box-row--blue",
data = {
test_selector: "work-package-#{work_package.id}"
}
return base unless draggable?
draggable? ? data.merge(draggable_data) : data
end
base.merge(
draggable_id: work_package.id,
draggable_type: "story",
drop_url:
)
def draggable?
false
end
def draggable_data
{}
end
end
end
@@ -37,14 +37,7 @@ See COPYRIGHT and LICENSE files for more details.
target_id: "backlog_bucket:#{backlog_bucket.id}",
allowed_drag_type: "story"
},
item_menu_src: ->(work_package) do
menu_project_backlogs_inbox_path(
project,
work_package,
all_backlogs_params
)
end,
item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) },
item_component_klass: Backlogs::WorkPackageCardBoxItemComponent,
params: all_backlogs_params,
current_user:,
data: { test_selector: "backlog-bucket-#{backlog_bucket.id}" }
@@ -37,14 +37,7 @@ See COPYRIGHT and LICENSE files for more details.
target_id: "inbox",
allowed_drag_type: "story"
},
item_menu_src: ->(work_package) do
menu_project_backlogs_inbox_path(
project,
work_package,
all_backlogs_params
)
end,
item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) },
item_component_klass: Backlogs::WorkPackageCardBoxItemComponent,
params: all_backlogs_params,
current_user:,
data: { test_selector: "backlog-inbox" }
@@ -37,15 +37,7 @@ See COPYRIGHT and LICENSE files for more details.
target_id: "sprint:#{sprint.id}",
allowed_drag_type: "story"
},
item_menu_src: ->(work_package) do
menu_project_backlogs_work_package_path(
project,
sprint,
work_package,
all_backlogs_params
)
end,
item_metric: ->(work_package) { Backlogs::StoryPointsComponent.new(work_package:) },
item_component_klass: Backlogs::WorkPackageCardBoxItemComponent,
params: all_backlogs_params,
current_user:,
data: { test_selector: "sprint-#{sprint.id}" }
@@ -0,0 +1,105 @@
# 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 WorkPackageCardBoxItemComponent < OpenProject::Common::WorkPackageCardBoxComponent::Item
def card
@card ||= WorkPackageCardComponent.new(work_package:, menu_src:)
end
private
def draggable?
current_user.allowed_in_project?(:manage_sprint_items, project)
end
def split_url
url_helpers.project_backlogs_backlog_details_path(project, work_package, params)
end
def full_url
url_helpers.work_package_path(work_package)
end
# Sprint is the only positive match; bucket and inbox both fall through to
# inbox routes.
def uses_inbox_routes?
!container.is_a?(Sprint)
end
def drop_url
if uses_inbox_routes?
url_helpers.move_project_backlogs_inbox_path(project, work_package, params)
else
url_helpers.move_project_backlogs_work_package_path(
project,
container,
work_package,
params
)
end
end
def menu_src
if uses_inbox_routes?
url_helpers.menu_project_backlogs_inbox_path(project, work_package, params)
else
url_helpers.menu_project_backlogs_work_package_path(
project,
container,
work_package,
params
)
end
end
# `story` data attrs match the live Stimulus controller and Dragula
# drag-type; renaming requires coordinated JS changes (separate PR).
def row_data
super.merge(
story: true,
controller: "backlogs--story",
backlogs__story_id_value: work_package.id,
backlogs__story_display_id_value: work_package.display_id,
backlogs__story_split_url_value: split_url,
backlogs__story_full_url_value: full_url,
backlogs__story_selected_class: "Box-row--blue"
)
end
def draggable_data
{
draggable_id: work_package.id,
draggable_type: "story",
drop_url:
}
end
end
end
@@ -0,0 +1,50 @@
# 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 WorkPackageCardComponent < ApplicationComponent
attr_reader :work_package, :menu_src
def initialize(work_package:, menu_src: nil)
super()
@work_package = work_package
@menu_src = menu_src
end
def call
render(OpenProject::Common::WorkPackageCardComponent.new(work_package:, menu_src:)) do |card|
card.with_metric do
render(Backlogs::StoryPointsComponent.new(work_package:))
end
end
end
end
end
@@ -0,0 +1,194 @@
# 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::WorkPackageCardBoxItemComponent, type: :component do
include Rails.application.routes.url_helpers
shared_let(:type_feature) { create(:type_feature) }
shared_let(:default_status) { create(:default_status) }
shared_let(:default_priority) { create(:default_priority) }
shared_let(:user) { create(:admin) }
current_user { user }
shared_let(:project) { create(:project, types: [type_feature]) }
shared_let(:sprint) do
create(:sprint, project:, name: "Sprint 1",
start_date: Date.yesterday, finish_date: Date.tomorrow)
end
shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") }
let(:container) { sprint }
let(:params) { {} }
let(:work_package) do
create(:work_package,
project:,
type: type_feature,
status: default_status,
priority: default_priority,
subject: "Card subject",
story_points: 5,
position: 1,
sprint:)
end
let(:item) do
described_class.new(work_package:, project:, container:, params:, current_user: user)
end
describe "#row_args" do
it "marks the row as clickable and controlled by the Backlogs story controller" do
expect(item.row_args[:classes]).to include(
"Box-row--hover-blue",
"Box-row--focus-gray",
"Box-row--clickable"
)
expect(item.row_args[:data]).to include(
story: true,
controller: "backlogs--story",
backlogs__story_id_value: work_package.id,
backlogs__story_display_id_value: work_package.display_id,
backlogs__story_full_url_value: work_package_path(work_package),
backlogs__story_selected_class: "Box-row--blue",
test_selector: "work-package-#{work_package.id}"
)
end
it "marks the row as draggable for users allowed to manage sprint items" do
expect(item.row_args[:classes]).to include("Box-row--draggable")
expect(item.row_args[:data]).to include(
draggable_id: work_package.id,
draggable_type: "story"
)
end
context "when the user cannot manage sprint items" do
let(:role) { create(:project_role, permissions: %i[view_sprints view_work_packages]) }
let(:limited_user) { create(:user, member_with_roles: { project => role }) }
let(:item) do
described_class.new(work_package:, project:, container:, params:, current_user: limited_user)
end
it "does not mark the row as draggable" do
expect(item.row_args[:classes]).not_to include("Box-row--draggable")
expect(item.row_args[:data]).not_to include(:draggable_id)
expect(item.row_args[:data]).not_to include(:drop_url)
end
end
end
describe "URL derivation by container" do
context "with a sprint container" do
it "uses sprint routes" do
expect(item.row_args.dig(:data, :backlogs__story_split_url_value))
.to end_with(project_backlogs_backlog_details_path(project, work_package))
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_work_package_path(project, sprint, work_package))
end
end
context "with a backlog bucket container" do
let(:container) { backlog_bucket }
it "uses inbox routes" do
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_inbox_path(project, work_package))
end
end
context "with an inbox container id" do
let(:container) { "inbox_project_#{project.id}" }
it "uses inbox routes" do
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_inbox_path(project, work_package))
end
end
context "with params" do
let(:params) { { all: 1 } }
it "passes params into row URLs" do
expect(item.row_args.dig(:data, :backlogs__story_split_url_value)).to match(/all=1/)
expect(item.row_args.dig(:data, :drop_url)).to match(/all=1/)
end
end
end
describe "#card" do
subject(:rendered_card) { render_inline(item.card) }
it "builds a Backlogs card with story points" do
expect(rendered_card).to have_text("5 points", normalize_ws: true)
end
context "with a sprint container" do
it "uses the sprint menu source" do
expect(rendered_card).to have_element(
"include-fragment",
src: menu_project_backlogs_work_package_path(project, sprint, work_package)
)
end
end
context "with an inbox container id" do
let(:container) { "inbox_project_#{project.id}" }
it "uses the inbox menu source" do
expect(rendered_card).to have_element(
"include-fragment",
src: menu_project_backlogs_inbox_path(project, work_package)
)
end
end
context "with a backlog bucket container" do
let(:container) { backlog_bucket }
it "uses the inbox menu source" do
expect(rendered_card).to have_element(
"include-fragment",
src: menu_project_backlogs_inbox_path(project, work_package)
)
end
end
context "with params" do
let(:params) { { all: 1 } }
it "passes params into the menu source" do
expect(rendered_card).to have_element(
"include-fragment",
src: menu_project_backlogs_work_package_path(project, sprint, work_package, all: 1)
)
end
end
end
end
@@ -0,0 +1,63 @@
# 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::WorkPackageCardComponent, type: :component do
shared_let(:type_feature) { create(:type_feature) }
shared_let(:project) { create(:project, types: [type_feature]) }
let(:menu_src) { "/backlogs/work_packages/#{work_package.id}/menu" }
let(:work_package) do
create(:work_package,
project:,
type: type_feature,
story_points: 5,
subject: "Backlogs card")
end
subject(:rendered_component) do
render_inline(described_class.new(work_package:, menu_src:))
end
it "renders the common work package card" do
expect(rendered_component).to have_text("Backlogs card")
expect(rendered_component).to have_text("##{work_package.id}")
end
it "renders story points as the card metric" do
expect(rendered_component).to have_text("5 points", normalize_ws: true)
end
it "passes the menu source to the common card" do
expect(rendered_component).to have_element "include-fragment",
src: menu_src
end
end
@@ -31,8 +31,6 @@
require "rails_helper"
RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :component do
include Rails.application.routes.url_helpers
shared_let(:type_feature) { create(:type_feature) }
shared_let(:default_status) { create(:default_status) }
shared_let(:default_priority) { create(:default_priority) }
@@ -40,13 +38,8 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co
current_user { user }
shared_let(:project) { create(:project, types: [type_feature]) }
shared_let(:sprint) do
create(:sprint, project:, name: "Sprint 1",
start_date: Date.yesterday, finish_date: Date.tomorrow)
end
shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") }
let(:container) { sprint }
let(:container) { project }
let(:params) { {} }
let(:work_package) do
create(:work_package,
@@ -56,12 +49,31 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co
priority: default_priority,
subject: "Card subject",
story_points: 5,
position: 1,
sprint:)
position: 1)
end
let(:item) do
described_class.new(work_package:, project:, container:, params:, current_user: user)
end
let(:draggable_item_class) do
stub_const(
"DraggableWorkPackageCardBoxItem",
Class.new(described_class) do
private
def draggable?
true
end
def draggable_data
{
draggable_id: work_package.id,
draggable_type: "work_package",
drop_url: "/drop"
}
end
end
)
end
describe "#row_args" do
it "can be passed to a BorderBox row" do
@@ -109,55 +121,25 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co
)
end
it "marks the row as draggable for users allowed to manage sprint items" do
expect(item.row_args[:classes]).to include("Box-row--draggable")
expect(item.row_args[:data]).to include(
draggable_id: work_package.id,
draggable_type: "story"
it "does not include Backlogs row wiring" do
expect(item.row_args[:classes]).not_to include("Box-row--draggable")
expect(item.row_args[:data]).not_to include(
:controller,
:draggable_id,
:drop_url,
:backlogs__story_split_url_value
)
end
context "when the user cannot manage sprint items" do
let(:role) { create(:project_role, permissions: %i[view_sprints view_work_packages]) }
let(:limited_user) { create(:user, member_with_roles: { project => role }) }
let(:item) do
described_class.new(work_package:, project:, container:, params:, current_user: limited_user)
end
it "supports generic draggable row data from subclasses" do
item = draggable_item_class.new(work_package:, project:, container:, params:, current_user: user)
it "does not mark the row as draggable" do
expect(item.row_args[:classes]).not_to include("Box-row--draggable")
expect(item.row_args[:data]).not_to include(:draggable_id)
expect(item.row_args[:data]).not_to include(:drop_url)
end
end
end
describe "URL derivation by container" do
context "with a sprint container" do
it "uses sprint routes" do
expect(item.row_args.dig(:data, :backlogs__story_split_url_value))
.to end_with(project_backlogs_backlog_details_path(project, work_package))
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_work_package_path(project, sprint, work_package))
end
end
context "with a backlog bucket container" do
let(:container) { backlog_bucket }
it "uses inbox routes" do
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_inbox_path(project, work_package))
end
end
context "with an inbox container id" do
let(:container) { "inbox_project_#{project.id}" }
it "uses inbox routes" do
expect(item.row_args.dig(:data, :drop_url))
.to end_with(move_project_backlogs_inbox_path(project, work_package))
end
expect(item.row_args[:classes]).to include("Box-row--draggable")
expect(item.row_args[:data]).to include(
draggable_id: work_package.id,
draggable_type: "work_package",
drop_url: "/drop"
)
end
end
@@ -168,15 +150,6 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co
expect(rendered_card).to have_no_element "include-fragment"
end
context "with params" do
let(:params) { { all: 1 } }
it "passes params into row URLs" do
expect(item.row_args.dig(:data, :backlogs__story_split_url_value)).to match(/all=1/)
expect(item.row_args.dig(:data, :drop_url)).to match(/all=1/)
end
end
it "returns the same card instance across calls" do
expect(item.card).to equal(item.card)
end
@@ -186,38 +159,5 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent::Item, type: :co
expect(rendered_card).to have_text("Forwarded metric")
end
context "with a provided menu source" do
let(:item) do
described_class.new(
work_package:,
project:,
container:,
params:,
item_menu_src: "/provided-menu",
current_user: user
)
end
it "uses the provided source" do
expect(rendered_card).to have_element "include-fragment",
src: "/provided-menu"
end
end
context "with an invalid menu source" do
it "raises ArgumentError" do
expect do
described_class.new(
work_package:,
project:,
container:,
params:,
item_menu_src: :provided_menu,
current_user: user
)
end.to raise_error(ArgumentError, /item_menu_src/)
end
end
end
end
@@ -46,14 +46,54 @@ 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(:item_component_klass) { described_class::Item }
let(:params) { {} }
let(:work_packages) { [] }
let(:system_arguments) { {} }
let(:header_arguments) { nil }
let(:footer_content) { nil }
let(:custom_item_component_class) do
stub_const(
"CustomWorkPackageCardBoxItem",
Class.new(ApplicationComponent) do
def initialize(
work_package:,
project:,
container:,
params:,
current_user: User.current,
**system_arguments
)
super()
@work_package = work_package
@params = params
@context = [project, container, current_user]
@system_arguments = system_arguments
end
def row_args
data = @system_arguments.fetch(:data, {}).merge(
params: @params.to_query,
context_size: @context.size
)
@system_arguments.merge(
id: "custom_work_package_#{@work_package.id}",
data:
)
end
def card
CustomWorkPackageCardBoxItemCard.new(subject: @work_package.subject)
end
def render? = false
end
)
end
subject(:rendered_component) do
render_component(work_packages:, container:, drag_and_drop:, system_arguments:)
end
@@ -64,8 +104,7 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen
project:,
container:,
drag_and_drop:,
item_menu_src:,
item_metric:,
item_component_klass:,
params:,
current_user: user,
**system_arguments
@@ -79,6 +118,23 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen
end
end
before do
stub_const(
"CustomWorkPackageCardBoxItemCard",
Class.new(ApplicationComponent) do
def initialize(subject:)
super()
@subject = subject
end
def call
tag.span("custom #{@subject}")
end
end
)
end
describe "Box shell" do
it_behaves_like "rendering Box", row_count: 1, header: false, footer: false
it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty"
@@ -290,39 +346,25 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen
)
end
context "with an item_menu_src proc" do
let(:item_menu_src) { ->(work_package) { "/custom/#{work_package.id}/menu" } }
it "does not include Backlogs row wiring by default" do
expect(rendered_component).to have_css(".Box-row", count: 2)
expect(rendered_component).to have_no_css(".Box-row[data-controller='backlogs--story']")
expect(rendered_component).to have_no_css(".Box-row[data-drop-url]")
expect(rendered_component).to have_no_css(".Box-row[data-backlogs--story-split-url-value]")
end
it "uses the derived menu source for automatically built items" do
expect(rendered_component).to have_element(
"include-fragment",
src: "/custom/#{work_packages.first.id}/menu"
context "with an item_component_klass" do
let(:item_component_klass) { custom_item_component_class }
it "uses the configured item class for automatically built items" do
expect(rendered_component).to have_css(
".Box-row#custom_work_package_#{work_packages.first.id}",
text: "custom WP A"
)
expect(rendered_component).to have_css(
".Box-row#custom_work_package_#{work_packages.second.id}",
text: "custom WP B"
)
end
end
context "with an invalid item_menu_src" do
let(:item_menu_src) { :custom_menu }
it "raises ArgumentError" do
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
@@ -338,69 +380,6 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen
end
let(:params) { { all: 1 } }
let(:slot_work_package) { work_packages.first }
let(:custom_item_component_class) do
stub_const(
"CustomWorkPackageCardBoxItem",
Class.new(ApplicationComponent) do
def initialize(
work_package:,
project:,
container:,
params:,
item_menu_src: nil,
item_metric: nil,
current_user: User.current,
**system_arguments
)
super()
@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
end
def row_args
data = @system_arguments.fetch(:data, {}).merge(
params: @params.to_query,
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}",
data:
)
end
def card
CustomWorkPackageCardBoxItemCard.new(subject: @work_package.subject)
end
def render? = false
end
)
end
before do
stub_const(
"CustomWorkPackageCardBoxItemCard",
Class.new(ApplicationComponent) do
def initialize(subject:)
super()
@subject = subject
end
def call
tag.span("custom #{@subject}")
end
end
)
end
def render_with_manual_item
render_inline(
@@ -436,17 +415,6 @@ RSpec.describe OpenProject::Common::WorkPackageCardBoxComponent, type: :componen
expect(rendered).to have_no_text("WP B")
end
it "uses the provided menu source 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, item_menu_src: "/manual-menu")
end
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)