Merge pull request #23175 from opf/feature/73089-restyled-work-package-card-in-backlogs-and-sprints-view

[73968, 73089] Extend and improve design of WorkPackageCardComponent
This commit is contained in:
Henriette Darge
2026-05-19 14:36:33 +02:00
committed by GitHub
19 changed files with 382 additions and 54 deletions
@@ -30,20 +30,43 @@ See COPYRIGHT and LICENSE files for more details.
<%= grid_layout(
"op-work-package-card",
tag: :article,
classes: { "op-work-package-card_with-metric": metric? }
classes: layout_classes
) do |grid| %>
<% grid.with_area(:info_line) do %>
<%# TODO(73089): allow callers to pass arguments through to InfoLineComponent (e.g. status presentation, variants). %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:)) %>
<% end %>
<% if metric? %>
<% grid.with_area(:metric) do %>
<%= metric %>
<% if show_drag_handle %>
<% grid.with_area(:drag_handle, classes: "hide-when-print") do %>
<%= render(Primer::OpenProject::DragHandle.new) %>
<% end %>
<% end %>
<% grid.with_area(:menu) do %>
<% grid.with_area(:info_line) do %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:, status_scheme:)) %>
<% end %>
<% grid.with_area(:actions) do %>
<% if show_assignee && work_package.assigned_to %>
<%= render(
Users::AvatarComponent.new(
user: work_package.assigned_to, size: "mini", link: false, show_name: true,
name_classes: "op-work-package-card--assignee-name"
)
) %>
<% end %>
<% if metric? %>
<span class="op-work-package-card--metric"><%= metric %></span>
<% end %>
<% if show_priority && work_package.priority %>
<%= render(
Primer::Beta::Text.new(
tag: :span,
classes: "__hl_inline_priority_#{work_package.priority.id} __hl_inline__small_dot"
)
) do %>
<span class="op-work-package-card--priority-name"><%= work_package.priority.name %></span>
<% end %>
<% end %>
<% if menu? %>
<%= menu %>
<% else %>
@@ -58,6 +81,39 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
<% grid.with_area(:subject) do %>
<%= render(Primer::Beta::Text.new(font_weight: :semibold)) { work_package.subject } %>
<% if link_subject %>
<%= render(Primer::Beta::Link.new(href: work_package_path(work_package), font_weight: :semibold)) { work_package.subject } %>
<% else %>
<%= render(Primer::Beta::Text.new(font_weight: :semibold)) { work_package.subject } %>
<% end %>
<% end %>
<% if show_footer? %>
<% grid.with_area(:footer) do %>
<% flex_layout do |flex| %>
<% if show_parent? %>
<% flex.with_row(classes: "ellipsis") do %>
<%= render(Primer::Beta::Text.new(color: :muted, font_size: :small)) do %>
<%= "#{t('.parent')}: " %>
<%= render(
Primer::Beta::Link.new(
href: work_package_path(work_package.parent),
underline: false,
color: :default
)
) do %>
<%= work_package.parent.subject %>
<% end %>
<% end %>
<% end %>
<% end %>
<% if additional_details? %>
<% flex.with_row do %>
<%= additional_details %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -43,17 +43,54 @@ module OpenProject
**system_arguments
)
}
renders_one :additional_details, Primer::Content
attr_reader :work_package, :menu_src
attr_reader :work_package, :menu_src, :show_drag_handle, :show_assignee, :show_priority,
:show_parent, :link_subject, :status_scheme
alias_method :show_drag_handle?, :show_drag_handle
# @param work_package [WorkPackage] the work package this card represents.
# @param menu_src [String, NilClass] optional lazy menu source. Prefer the
# `with_menu(src:)` slot for new call sites.
def initialize(work_package:, menu_src: nil)
# @param show_drag_handle [Boolean] whether to show a drag handle icon.
# @param show_assignee [Boolean] whether to show the assignee (icon + name when space allows).
# @param show_priority [Boolean] whether to show the priority badge.
# @param show_parent [Boolean] whether to show a link to the parent work package in row 3.
# Only rendered when the work package actually has a parent.
# @param link_subject [Boolean] whether to link the subject to the WP or render as plain text instead.
# @param status_scheme [Symbol] status label scheme for the info line. One of :default or :secondary.
def initialize(work_package:, menu_src: nil, show_drag_handle: false,
show_assignee: false, show_priority: false, show_parent: false, link_subject: true,
status_scheme: :default)
super()
@work_package = work_package
@menu_src = menu_src
@show_drag_handle = show_drag_handle
@show_assignee = show_assignee
@show_priority = show_priority
@show_parent = show_parent
@link_subject = link_subject
@status_scheme = status_scheme
end
private
def layout_classes
{
"op-work-package-card_with-drag-handle": show_drag_handle?,
"op-work-package-card_with-footer": show_footer?,
"op-work-package-card_with-menu": menu? || menu_src.present?
}
end
def show_parent?
show_parent && work_package.parent&.visible?
end
def show_footer?
additional_details? || show_parent?
end
end
end
@@ -30,24 +30,74 @@
display: grid
grid-template-columns: 1fr auto
grid-template-rows: auto auto
grid-template-areas: "info_line menu" "subject subject"
grid-template-areas: "info_line actions" "subject subject"
align-items: center
margin-top: calc(-1 * var(--base-size-4))
grid-row-gap: var(--base-size-4)
margin-top: var(--base-size-4)
margin-bottom: var(--base-size-4)
container-type: inline-size
.op-work-package-card_with-metric
grid-template-columns: 1fr minmax(2rem, max-content) auto
grid-template-areas: "info_line metric menu" "subject subject subject"
&_with-footer
grid-template-rows: auto auto auto
grid-template-areas: "info_line actions" "subject subject" "footer footer"
.op-work-package-card--metric
margin-left: var(--stack-gap-normal)
font-variant-numeric: tabular-nums
text-align: right
&_with-drag-handle
grid-template-columns: auto 1fr auto
grid-template-areas: "drag_handle info_line actions" ". subject subject"
.op-work-package-card--menu
margin-left: var(--stack-gap-normal)
&.op-work-package-card_with-footer
grid-template-rows: auto auto auto
grid-template-areas: "drag_handle info_line actions" ". subject subject" ". footer footer"
.op-work-package-card--subject
align-self: start // Align to top of second row
word-wrap: break-word
overflow-wrap: break-word
// --------- START: Menu positioning ---------
// This is a hack to avoid that the IconButton of the menu blows up the height of the first row
// resulting in different spacing between the rows.
// By positioning the menu absolutely, we can ensure that the height of the first row is not affected.
&_with-menu
.op-work-package-card--actions
position: relative
// 1rem column gap + 2rem button width - 0.5rem visual gap of the invisible button to it's icon
padding-right: 2.5rem
&--menu
position: absolute
right: 0
// --------- END: Menu positioning ---------
&--drag_handle
align-self: center
padding-right: var(--stack-gap-condensed)
cursor: grab
color: var(--fgColor-muted)
&--actions
display: flex
align-items: center
gap: var(--stack-gap-normal)
color: var(--fgColor-muted)
&--metric
font-variant-numeric: tabular-nums
text-align: right
// parent_link and additional_details auto-place into implicit rows, spanning full width
&--parent_link,
&--additional_details
grid-column: 1 / -1
padding-top: var(--base-size-4)
&_with-drag-handle
.op-work-package-card--parent_link,
.op-work-package-card--additional_details
grid-column: 2 / -1
&--subject
align-self: start
word-wrap: break-word
overflow-wrap: break-word
// < 768px: hide text labels, show only icons
@container (width < 768px)
.op-work-package-card--assignee-name,
.op-work-package-card--priority-name
display: none
@@ -49,6 +49,7 @@ module OpenProject
system_arguments[:anchor_align] ||= :end
system_arguments[:classes] = class_names(
system_arguments[:classes],
"op-work-package-card--menu",
"hide-when-print"
)
@menu = Primer::Alpha::ActionMenu.new(**system_arguments)
@@ -21,7 +21,7 @@
if @show_status
flex.with_column(ml: 2) do
render WorkPackages::StatusBadgeComponent.new(status: @work_package.status)
render WorkPackages::StatusBadgeComponent.new(status: @work_package.status, scheme: @status_scheme)
end
end
@@ -35,6 +35,7 @@ class WorkPackages::InfoLineComponent < ApplicationComponent
show_project: false,
show_subject: false,
show_status: true,
status_scheme: :default,
font_size: :small,
**system_arguments)
super
@@ -44,6 +45,7 @@ class WorkPackages::InfoLineComponent < ApplicationComponent
@show_project = show_project
@show_subject = show_subject
@show_status = show_status
@status_scheme = status_scheme
@system_arguments = system_arguments
end
@@ -35,6 +35,13 @@ class WorkPackages::StatusBadgeComponent < ApplicationComponent
super
@status = status
@system_arguments = system_arguments.merge({ classes: "__hl_background_status_#{@status.id}" })
@system_arguments = system_arguments
if @system_arguments[:scheme].nil? || @system_arguments[:scheme] == :default
@system_arguments.delete(:scheme)
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"__hl_background_status_#{@status.id}"
)
end
end
end
+3
View File
@@ -4937,8 +4937,11 @@ en:
open_project:
common:
work_package_card_component:
drag_handle:
label: "Drag to reorder"
menu:
label_actions: "Work package actions"
parent: "Parent"
permission_add_work_package_comments: "Add comments"
permission_add_work_packages: "Add work packages"
@@ -110,6 +110,11 @@
width: 10px
height: 10px
&.__hl_inline__small_dot:before
width: 6px
height: 6px
vertical-align: 1px
@mixin dot_border_width_style
[class^='__hl_inline_'],
[class*=' __hl_inline_']
@@ -0,0 +1,62 @@
The `WorkPackageCard` is a compact representation of a work package, designed for use in list and board views such as sprint and backlog views.
## Overview
<%= embed OpenProject::Common::WorkPackageCardComponentPreview, :default %>
## Anatomy
The card is structured in up to three rows:
**Row 1 — Metadata**
Contains the info line (type, ID, status), followed by optional elements: assignee, metric (e.g. story points), priority, and the actions menu. When `show_drag_handle: true`, a drag handle icon appears on the far left, spanning all rows, aligned to the top (row 1).
**Row 2 — Subject**
The work package title, always visible.
**Row 3 — Footer** *(only rendered when content is present)*
Shows a link to the parent work package (when `show_parent: true` and a parent exists) and/or the `with_additional_details` slot.
## Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| `work_package` | `WorkPackage` | required | The work package to display |
| `menu_src` | `String` | `nil` | Optional lazy-load URL for the actions menu |
| `show_assignee` | `Boolean` | `false` | Show the assignee icon and name |
| `show_priority` | `Boolean` | `false` | Show a priority badge |
| `show_drag_handle` | `Boolean` | `false` | Show a drag handle icon |
| `show_parent` | `Boolean` | `false` | Show a link to the parent work package in row 3. Only rendered when `work_package.parent` is present. |
| `status_scheme` | `Symbol` | `:default` | Status label style: `:default` (highlighted) or `:secondary` (neutral label) |
## Slots
| Slot | Description |
|---|---|
| `with_metric` | Numeric value shown in row 1 (e.g. story points) |
| `with_menu` | Custom actions menu, replaces the default lazy menu |
| `with_additional_details` | Additional content appended to row 3 |
## Variants
<%= embed OpenProject::Common::WorkPackageCardComponentPreview, :playground, panels: %i[params source] %>
## Code structure
```ruby
render OpenProject::Common::WorkPackageCardComponent.new(
work_package: @work_package,
show_assignee: true,
show_priority: true,
show_parent: true,
status_scheme: :secondary
) do |card|
card.with_metric { @work_package.story_points }
card.with_menu do |menu|
menu.with_item(label: "Open", href: work_package_path(@work_package))
end
card.with_additional_details do
# Whatever else you want to show
end
end
```
@@ -32,37 +32,101 @@ module OpenProject
module Common
# @logical_path OpenProject/Common
class WorkPackageCardComponentPreview < ViewComponent::Preview
# See the [component documentation](/lookbook/pages/components/work_packages/card) for more details.
#
# @param show_assignee toggle
# @param show_priority toggle
# @param show_drag_handle toggle
# @param show_parent toggle
# @param link_subject toggle
# @param show_metric toggle
# @param show_menu toggle
# @param additional_details toggle
# @param status_scheme select [default, secondary]
def playground(show_assignee: false, show_priority: false, show_drag_handle: false,
show_parent: false, link_subject: true, show_metric: false, show_menu: false,
additional_details: false, status_scheme: :default)
work_package = WorkPackage.visible.where.not(parent_id: nil).first || WorkPackage.visible.first
return preview_message("No work packages in the database.") unless work_package
render_with_template(template: "open_project/common/work_package_card_component_preview/playground",
locals: {
work_package:,
show_assignee:,
show_priority:,
show_drag_handle:,
show_parent:,
link_subject:,
show_metric:,
show_menu:,
additional_details:,
status_scheme:
})
end
# Minimal card showing only the info line, subject and actions menu.
def default
work_package = WorkPackage.first
work_package = WorkPackage.visible.first
return preview_message("No work packages in the database.") unless work_package
render OpenProject::Common::WorkPackageCardComponent.new(work_package:)
end
# Card with a numeric metric (e.g. story points) in the top-right area.
def with_metric
work_package = WorkPackage.visible.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 { (work_package.try(:story_points) || 8).to_s }
end
end
# Card with a custom actions menu.
def with_menu
work_package = WorkPackage.visible.first
return preview_message("No work packages in the database.") unless work_package
render OpenProject::Common::WorkPackageCardComponent.new(work_package:) do |card|
card.with_menu do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}")
menu.with_item(label: "Edit", href: "/work_packages/#{work_package.id}/edit")
menu.with_divider
menu.with_item(label: "Delete", scheme: :danger)
end
end
end
# Card with a drag handle icon for reorderable lists.
def with_drag_handle
work_package = WorkPackage.visible.first
return preview_message("No work packages in the database.") unless work_package
render OpenProject::Common::WorkPackageCardComponent.new(
work_package:
work_package:,
show_drag_handle: true
)
end
def with_metric
work_package = WorkPackage.first
return preview_message("No work packages in the database.") unless work_package
# Card with show_parent enabled. Renders a link to the parent work package in row 3.
# Only visible when the work package actually has a parent.
def with_parent
work_package = WorkPackage.visible.where.not(parent_id: nil).first
return preview_message("No work packages with a parent found.") unless work_package
render OpenProject::Common::WorkPackageCardComponent.new(
work_package:
) do |card|
card.with_metric_content(10)
end
work_package:,
show_parent: true
)
end
def with_menu
work_package = WorkPackage.first
# Card with additional content in the bottom slot (row 3), rendered alongside the parent link.
def with_additional_details
work_package = WorkPackage.visible.first
return preview_message("No work packages in the database.") unless work_package
render OpenProject::Common::WorkPackageCardComponent.new(
work_package:
) do |card|
card.with_menu do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}")
end
end
render_with_template(template: "open_project/common/work_package_card_component_preview/with_additional_details",
locals: { work_package: })
end
private
@@ -0,0 +1,26 @@
<%= render OpenProject::Common::WorkPackageCardComponent.new(
work_package:,
show_assignee:,
show_priority:,
show_drag_handle:,
show_parent:,
link_subject:,
status_scheme: status_scheme.to_sym
) do |card|
card.with_metric { (work_package.try(:story_points) || 5).to_s } if show_metric
if show_menu
card.with_menu do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}")
menu.with_item(label: "Edit", href: "/work_packages/#{work_package.id}/edit")
menu.with_divider
menu.with_item(label: "Delete", scheme: :danger)
end
end
if additional_details
card.with_additional_details do
render(Primer::Beta::Text.new(tag: :span, font_size: :small, color: :subtle)) { "Some bottom line" }
end
end
end %>
@@ -0,0 +1,5 @@
<%= render OpenProject::Common::WorkPackageCardComponent.new(work_package:) do |card|
card.with_additional_details do
render(Primer::Beta::Text.new(tag: :span, font_size: :small, color: :subtle)) { "Some bottom line" }
end
end %>
@@ -36,11 +36,13 @@ module OpenProject::WorkPackages
# @param show_subject [Boolean]
# @param show_status [Boolean]
# @param font_size [Symbol] select [small, normal]
def playground(show_project: false, show_subject: false, show_status: true, font_size: :small)
# @param status_scheme select [default, secondary]
def playground(show_project: false, show_subject: false, show_status: true, font_size: :small, status_scheme: :default)
render(WorkPackages::InfoLineComponent.new(work_package: WorkPackage.visible.first,
show_project:,
show_subject:,
show_status:,
status_scheme:,
font_size:))
end
end
@@ -46,7 +46,8 @@ module Backlogs
@project = project
@current_user = current_user
@active_sprint_ids = active_sprint_ids
@work_packages = work_packages || sprint.work_packages_for(project).includes(:status, :type)
@work_packages = work_packages || sprint.work_packages_for(project).includes(:status, :type, :assigned_to, :priority,
:parent)
end
def wrapper_uniq_by
@@ -54,7 +54,14 @@ module Backlogs
private
def card
@card ||= OpenProject::Common::WorkPackageCardComponent.new(work_package:, menu_src:)
@card ||= OpenProject::Common::WorkPackageCardComponent.new(
work_package:,
menu_src:,
show_assignee: true,
show_priority: true,
show_parent: true,
status_scheme: :secondary
)
end
def before_render
@@ -73,7 +73,7 @@ module Backlogs
@work_packages_by_sprint_id = WorkPackage
.where(sprint: @sprints, project: @project)
.includes(:type, :status)
.includes(:type, :status, :assigned_to, :priority, :parent)
.order_by_position
.group_by(&:sprint_id)
@active_sprint_ids = @sprints.select(&:active?).map(&:id)
@@ -43,6 +43,6 @@ class BacklogBucket < ApplicationRecord
validates :name, :project, presence: true
def self.for_project(project)
where(project:).order_alphabetically.includes(:displayed_work_packages)
where(project:).order_alphabetically.includes(displayed_work_packages: %i[assigned_to priority parent])
end
end
@@ -37,7 +37,7 @@ module WorkPackages::Scopes::BacklogsInboxFor
.visible
.with_status_open
.where(project:, sprint_id: nil, backlog_bucket_id: nil)
.includes(:type)
.includes(:type, :assigned_to, :priority, :parent)
.order_by_position
.order(WorkPackage.arel_table[:id].asc)
end