[#74684] Extract BorderBoxListComponent

Introduces a shared BorderBox-backed list component and moves the
Backlogs-specific work package card list into the Backlogs module.

Wires Backlogs callers to the new component API, keeps specialized
card rendering behind an item factory hook, and replaces old OpPrimer
work-package card list coverage with focused component specs.

https://community.openproject.org/wp/74684
This commit is contained in:
Alexander Brandon Coles
2026-05-14 16:06:00 +02:00
parent a1b7f636bf
commit 8f2cdab609
34 changed files with 1890 additions and 1562 deletions
@@ -0,0 +1,155 @@
# 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 OpenProject
module Common
# @logical_path OpenProject/Common
class BorderBoxListComponentPreview < ViewComponent::Preview
# @label Default
def default
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-preview"
) do |list|
list.with_header(title: "Things we're building", count: true) do |header|
header.with_description { "There's lots to look forward to" }
header.with_action_button(scheme: :invisible) do |button|
button.with_leading_visual_icon(icon: :pencil)
"Edit"
end
header.with_menu(button_aria_label: "List actions") do |menu|
menu.with_item(label: "Configure") do |menu_item|
menu_item.with_leading_visual_icon(icon: :gear)
end
end
end
list.with_item { "Prioritized project launch" }
list.with_item { "Updated status reporting" }
list.with_item { "Shared team calendar" }
list.with_footer { "Next launch window: October" }
end
end
# @label With work package items
def with_work_package_items
work_packages = WorkPackage.includes(:project).limit(2).to_a
return preview_message("No work packages in the database.") if work_packages.empty?
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-work-package-preview"
) do |list|
list.with_header(title: "Work packages", count: true)
work_packages.each do |work_package|
list.with_work_package_item(work_package:) do |item|
item.with_menu(button_aria_label: "Work package actions") do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}") do |menu_item|
menu_item.with_leading_visual_icon(icon: :link)
end
end
end
end
end
end
# @label Playground
# @param title_tag [Symbol] select [h2, h3, h4, h5]
# @param count [Symbol] select [inferred, hidden, explicit, zero]
# @param count_scheme [Symbol] select [primary, secondary]
# @param hide_zero_count toggle
def playground(
title_tag: :h4,
count: :inferred,
count_scheme: :primary,
hide_zero_count: true
)
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-playground-preview"
) do |list|
list.with_header(
title: "Playground list",
title_tag: title_tag.to_sym,
count: preview_count(count),
count_arguments: {
scheme: count_scheme.to_sym,
hide_if_zero: boolean_preview_param(hide_zero_count),
aria: { label: "Visible list item count", live: "polite" }
}
) do |header|
header.with_description { "Advanced header options" }
end
list.with_item { "First item" }
list.with_item { "Second item" }
list.with_footer { "Footer content" }
end
end
# @label Empty state
# List with a header and an empty state (Blankslate), no items.
def empty_state
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-empty-preview"
) do |list|
list.with_header(title: "Empty list", count: 0)
list.with_empty_state(
title: "No items yet",
description: "There is nothing to show."
)
end
end
private
def preview_count(count)
case count.to_sym
when :inferred
true
when :hidden
false
when :explicit
7
when :zero
0
end
end
def boolean_preview_param(value)
ActiveModel::Type::Boolean.new.cast(value)
end
def preview_message(text)
render(Primer::Beta::Blankslate.new) do |blankslate|
blankslate.with_heading(tag: :h4).with_content(text)
end
end
end
end
end
@@ -1,114 +0,0 @@
# 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 OpenProject
module Common
# @logical_path OpenProject/Common
class WorkPackageCardListComponentPreview < ViewComponent::Preview
include ActionView::RecordIdentifier
def sprint_with_cards
sprint = Sprint.first
project = sprint&.project
return preview_message("No sprints in the database.") unless sprint && project
work_packages = sprint.work_packages_for(project).limit(3)
render OpenProject::Common::WorkPackageCardListComponent.new(
work_packages:,
project:,
container: sprint
) do |list|
list.with_header(title: sprint.name, count: work_packages.size) do |header|
points = work_packages.sum { |w| w.story_points || 0 }
header.with_description { "#{points} points" }
end
list.with_empty_state(title: "Sprint is empty", description: "Drag work packages here")
end
end
def empty_sprint
sprint = Sprint.first
project = sprint&.project
return preview_message("No sprints in the database.") unless sprint && project
render OpenProject::Common::WorkPackageCardListComponent.new(
work_packages: [], project:, container: sprint
) do |list|
list.with_header(title: sprint.name, count: 0) do |header|
header.with_description { "0 points" }
end
list.with_empty_state(title: "Sprint is empty", description: "Drag work packages here")
end
end
def inbox
project = Project.first
return preview_message("No project in the database.") unless project
render OpenProject::Common::WorkPackageCardListComponent.new(
work_packages: [],
project:,
container: dom_target(:inbox, project)
) do |list|
list.with_empty_state(title: "Inbox is empty", description: "All caught up",
icon: :"op-backlogs")
end
end
def manual_item
work_package = WorkPackage.first
project = work_package&.project
return preview_message("No work packages in the database.") unless work_package && project
render OpenProject::Common::WorkPackageCardListComponent.new(
project:,
container: :manual_item_demo
) do |list|
list.with_empty_state(title: "No items", description: "Manual items can be added by callers")
list.with_work_package_item(work_package:)
list.with_item(scheme: :neutral) { "Caller-provided item" }
end
end
private
# ViewComponent's `Preview.render_args` expects each preview method to
# return a Hash (it does `result[:template] = …`), so plain string
# returns fail with "no implicit conversion of Symbol into Integer".
# Wrap fallback messages in a Blankslate render so they go through the
# standard hash path.
def preview_message(text)
render(Primer::Beta::Blankslate.new) do |b|
b.with_heading(tag: :h4).with_content(text)
end
end
end
end
end