Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,44 @@ 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: {
"op-work-package-card_with-drag-handle": show_drag_handle,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: you can alias these methods as predicate in WorkPackageCardComponent:

Suggested change
"op-work-package-card_with-drag-handle": show_drag_handle,
"op-work-package-card_with-drag-handle": show_drag_handle?,
alias_method :show_drag_handle?, show_drag_handle

Example here

"op-work-package-card_with-footer": show_footer?
}
) do |grid| %>
<% if show_drag_handle %>
<% grid.with_area(:drag_handle) do %>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<% grid.with_area(:drag_handle) do %>
<% grid.with_area(:drag_handle, classes: "hide-when-print") do %>

<%= render(Primer::Beta::Octicon.new(icon: :grabber, "aria-label": t(".drag_handle.label"))) %>
Copy link
Copy Markdown
Contributor

@myabc myabc May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Why not use Primer::OpenProject::DragHandle?

This was what was used prior to prior to #22838 being merged (e.g. here)

<% end %>
<% end %>

<% 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:)) %>
<%= render(WorkPackages::InfoLineComponent.new(work_package:, status_scheme:)) %>
<% end %>

<% if metric? %>
<% grid.with_area(:metric) do %>
<%= metric %>
<% 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"
)
) { work_package.priority.name } %>
<% end %>
<% end %>

<% grid.with_area(:menu) do %>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make the as intutitve as possible, I'd like to keep to try to keep the slot names aligned with BorderBoxComponent (#23074). There we have a separate actions and menu slot. If we conflate them, we should do so in both components.

That said, I quite like keeping the menu separate. #23218 now hides the menu slot before generating a drag preview (using a hidden-for-drag-preview class).

hide-when-print might also make sense here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise now that was mixing up slot naming and grid area naming. 🤦🏻 .. However, we should probably still try to keep things aligned if possible.

<% if menu? %>
<%= menu %>
<% else %>
Expand All @@ -58,6 +82,33 @@ 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 } %>
<%= render(Primer::Beta::Link.new(href: work_package_path(work_package), font_weight: :semibold)) { work_package.subject } %>
Copy link
Copy Markdown
Contributor

@myabc myabc May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this link be optional?

I'm not sure what is specified, but for the Backlogs use case (i.e. where the whole card is draggable), I would not want to make the subject a link.

<% end %>

<% if show_footer? %>
<% grid.with_area(:footer) do %>
<% flex_layout do |flex| %>
<% if show_parent_link && work_package.parent.present? %>
<% flex.with_row do %>
<%= render(
Primer::Beta::Link.new(
href: work_package_path(work_package.parent),
underline: false,
color: :default
)
) do %>
<%= render(Primer::Beta::Text.new(color: :subtle)) { "#{t('.parent')}: " } %>
<%= work_package.parent.subject %>
Comment on lines +91 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True (I think).. I missed this one.

<% end %>
Comment on lines +93 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The font size should be :small / 14px here.
  2. The "Parent: " label should be "muted" colour.
  3. I personally think it looks better if we don't link the "Parent: " label.
Suggested change
<%= render(
Primer::Beta::Link.new(
href: work_package_path(work_package.parent),
underline: false,
color: :default
)
) do %>
<%= render(Primer::Beta::Text.new(color: :subtle)) { "#{t('.parent')}: " } %>
<%= work_package.parent.subject %>
<% end %>
<%= 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 %>

e.g.

Image

<% end %>
<% end %>

<% if bottom_line? %>
<% flex.with_row do %>
<%= bottom_line %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
25 changes: 23 additions & 2 deletions app/components/open_project/common/work_package_card_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,38 @@ class WorkPackageCardComponent < ApplicationComponent
**system_arguments
)
}
renders_one :bottom_line, Primer::Content

attr_reader :work_package, :menu_src
attr_reader :work_package, :menu_src, :show_drag_handle, :show_assignee, :show_priority,
:show_parent_link, :status_scheme

# @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_link [Boolean] whether to show a link to the parent work package in row 3.
# Only rendered when the work package actually has a parent.
Comment on lines +57 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: could shorten this parameter to just show_parent (but I don't feel strongly about this either).

# @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_link: false,
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_link = show_parent_link
@status_scheme = status_scheme
end

private

def show_footer?
bottom_line? || (show_parent_link && work_package.parent.present?)
end
end
end
Expand Down
65 changes: 51 additions & 14 deletions app/components/open_project/common/work_package_card_component.sass
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,61 @@
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))
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
&--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 bottom_line auto-place into implicit rows, spanning full width
&--parent_link,
&--bottom_line
grid-column: 1 / -1
padding-top: var(--base-size-4)

&_with-drag-handle
.op-work-package-card--parent_link,
.op-work-package-card--bottom_line
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
display: none

.op-work-package-card--actions .__hl_inline__small_dot
font-size: 0
Comment on lines +89 to +90
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't like this. I'm not 100% sure of what implications are for accessibility, etc. but it feels "hacky". Without a supporting comment it's unclear to other devs what tis code is meant to be do..

Can we not just apply an explicit class for priority-name and use the same mechanism for hiding as op-work-package-card--assignee-name.

Suggested change
.op-work-package-card--actions .__hl_inline__small_dot
font-size: 0
.op-work-package-card--priority-name
display: none

or - for better accessibility support (/cc @bsatarnejad):

Suggested change
.op-work-package-card--actions .__hl_inline__small_dot
font-size: 0
.op-work-package-card--priority-name
@extend .sr-only

(if you do do the latter though, you'll need to make the parent container position: relative to avoid being bitten by the same issue as in #23143)

Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": button_aria_label || t(".label_actions"),
tooltip_direction: :se
tooltip_direction: :se,
size: :small
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not against doing this in principle, but this will break alignment with BorderBoxListComponent headers.. so we should discuss and/or allow callers to customise size.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

) %>
<% end %>
2 changes: 1 addition & 1 deletion app/components/work_packages/info_line_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/components/work_packages/info_line_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def initialize(work_package:,
show_project: false,
show_subject: false,
show_status: true,
status_scheme: :default,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this break existing callers? We should document this option on InfoLineComponent too.

font_size: :small,
**system_arguments)
super
Expand All @@ -44,6 +45,7 @@ def initialize(work_package:,
@show_project = show_project
@show_subject = show_subject
@show_status = show_status
@status_scheme = status_scheme

@system_arguments = system_arguments
end
Expand Down
8 changes: 7 additions & 1 deletion app/components/work_packages/status_badge_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def initialize(status:, **system_arguments)
super

@status = status
@system_arguments = system_arguments.merge({ classes: "__hl_background_status_#{@status.id}" })
@system_arguments = system_arguments
unless @system_arguments[:scheme]
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"__hl_background_status_#{@status.id}"
)
end
end
end
3 changes: 3 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4902,8 +4902,11 @@ en:
open_project:
common:
work_package_card_component:
drag_handle:
label: "Drag to reorder"
menu:
label_actions: "Work package actions"
parent: "Parent"
work_package_card_list_component:
header:
label_actions: "Open menu"
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/global_styles/layout/_colors.sass
Original file line number Diff line number Diff line change
Expand Up @@ -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_']
Expand Down
62 changes: 62 additions & 0 deletions lookbook/docs/components/work-packages/card.md.erb
Original file line number Diff line number Diff line change
@@ -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_link: true` and a parent exists) and/or the `with_bottom_line` 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_link` | `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_bottom_line` | 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_link: 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_bottom_line do
# Whatever else you want to show
end
end
```
Loading
Loading