From 91de3310bd6cd9da8d8c0115d82f6fe853a79c2d Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 12 May 2026 14:16:58 +0200 Subject: [PATCH] Extend the WorkPackageCardComponent with new optionss --- .../work_package_card_component.html.erb | 69 +++++++++++++-- .../common/work_package_card_component.rb | 25 +++++- .../common/work_package_card_component.sass | 65 +++++++++++--- .../work_package_card_component/menu.html.erb | 3 +- .../info_line_component.html.erb | 2 +- .../work_packages/info_line_component.rb | 2 + .../work_packages/status_badge_component.rb | 8 +- config/locales/en.yml | 3 + .../src/global_styles/layout/_colors.sass | 5 ++ .../docs/components/work-packages/card.md.erb | 62 +++++++++++++ .../work_package_card_component_preview.rb | 88 ++++++++++++++++--- .../playground.html.erb | 25 ++++++ .../with_bottom_line.html.erb | 5 ++ .../backlogs/work_package_card_component.rb | 9 +- 14 files changed, 329 insertions(+), 42 deletions(-) create mode 100644 lookbook/docs/components/work-packages/card.md.erb create mode 100644 lookbook/previews/open_project/common/work_package_card_component_preview/playground.html.erb create mode 100644 lookbook/previews/open_project/common/work_package_card_component_preview/with_bottom_line.html.erb diff --git a/app/components/open_project/common/work_package_card_component.html.erb b/app/components/open_project/common/work_package_card_component.html.erb index 0b879b92a1e9..159239e5af0c 100644 --- a/app/components/open_project/common/work_package_card_component.html.erb +++ b/app/components/open_project/common/work_package_card_component.html.erb @@ -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, + "op-work-package-card_with-footer": show_footer? + } ) do |grid| %> + <% if show_drag_handle %> + <% grid.with_area(:drag_handle) do %> + <%= render(Primer::Beta::Octicon.new(icon: :grabber, "aria-label": t(".drag_handle.label"))) %> + <% 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? %> + <%= metric %> + <% 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 %> <% if menu? %> <%= menu %> <% else %> @@ -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 } %> + <% 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 %> + <% end %> + <% end %> + <% end %> + + <% if bottom_line? %> + <% flex.with_row do %> + <%= bottom_line %> + <% end %> + <% end %> + <% end %> + <% end %> <% end %> <% end %> diff --git a/app/components/open_project/common/work_package_card_component.rb b/app/components/open_project/common/work_package_card_component.rb index 224f342a9089..184a0700182f 100644 --- a/app/components/open_project/common/work_package_card_component.rb +++ b/app/components/open_project/common/work_package_card_component.rb @@ -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. + # @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 diff --git a/app/components/open_project/common/work_package_card_component.sass b/app/components/open_project/common/work_package_card_component.sass index 8e79ba50667a..2f3c65dad39d 100644 --- a/app/components/open_project/common/work_package_card_component.sass +++ b/app/components/open_project/common/work_package_card_component.sass @@ -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 diff --git a/app/components/open_project/common/work_package_card_component/menu.html.erb b/app/components/open_project/common/work_package_card_component/menu.html.erb index c2d9fff9061b..ff5e4b866f14 100644 --- a/app/components/open_project/common/work_package_card_component/menu.html.erb +++ b/app/components/open_project/common/work_package_card_component/menu.html.erb @@ -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 ) %> <% end %> diff --git a/app/components/work_packages/info_line_component.html.erb b/app/components/work_packages/info_line_component.html.erb index e4e05ec8bdf6..cf7a6e4ace9c 100644 --- a/app/components/work_packages/info_line_component.html.erb +++ b/app/components/work_packages/info_line_component.html.erb @@ -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 diff --git a/app/components/work_packages/info_line_component.rb b/app/components/work_packages/info_line_component.rb index 9be082a5acc2..e20b50b752fc 100644 --- a/app/components/work_packages/info_line_component.rb +++ b/app/components/work_packages/info_line_component.rb @@ -35,6 +35,7 @@ def initialize(work_package:, show_project: false, show_subject: false, show_status: true, + status_scheme: :default, font_size: :small, **system_arguments) super @@ -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 diff --git a/app/components/work_packages/status_badge_component.rb b/app/components/work_packages/status_badge_component.rb index 18462e3ee904..9d0362e84825 100644 --- a/app/components/work_packages/status_badge_component.rb +++ b/app/components/work_packages/status_badge_component.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index c725f5060d0c..482b3031d11b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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" diff --git a/frontend/src/global_styles/layout/_colors.sass b/frontend/src/global_styles/layout/_colors.sass index bad6dd16ef34..542043d27003 100644 --- a/frontend/src/global_styles/layout/_colors.sass +++ b/frontend/src/global_styles/layout/_colors.sass @@ -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_'] diff --git a/lookbook/docs/components/work-packages/card.md.erb b/lookbook/docs/components/work-packages/card.md.erb new file mode 100644 index 000000000000..0d66dbf41753 --- /dev/null +++ b/lookbook/docs/components/work-packages/card.md.erb @@ -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 +``` diff --git a/lookbook/previews/open_project/common/work_package_card_component_preview.rb b/lookbook/previews/open_project/common/work_package_card_component_preview.rb index 0adc6dd1b50a..201dd0a685dc 100644 --- a/lookbook/previews/open_project/common/work_package_card_component_preview.rb +++ b/lookbook/previews/open_project/common/work_package_card_component_preview.rb @@ -32,39 +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_link toggle + # @param show_metric toggle + # @param show_menu toggle + # @param show_bottom toggle + # @param status_scheme select [default, secondary] + def playground(show_assignee: false, show_priority: false, show_drag_handle: false, + show_parent_link: false, show_metric: false, show_menu: false, + show_bottom: 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:, + show_metric:, + show_menu:, + show_bottom:, + 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: - ) + 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.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: - ) do |card| - card.with_metric_content(10) + 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.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: - ) do |card| + 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:, + show_drag_handle: true + ) + end + + # Card with show_parent_link 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_link + 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:, + show_parent_link: true + ) + end + + # Card with additional content in the bottom slot (row 3), rendered alongside the parent link. + def with_bottom_line + work_package = 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/with_bottom_line", + locals: { work_package: }) + end + private def preview_message(text) diff --git a/lookbook/previews/open_project/common/work_package_card_component_preview/playground.html.erb b/lookbook/previews/open_project/common/work_package_card_component_preview/playground.html.erb new file mode 100644 index 000000000000..3ec2c94bb943 --- /dev/null +++ b/lookbook/previews/open_project/common/work_package_card_component_preview/playground.html.erb @@ -0,0 +1,25 @@ +<%= render OpenProject::Common::WorkPackageCardComponent.new( + work_package:, + show_assignee:, + show_priority:, + show_drag_handle:, + show_parent_link:, + 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 show_bottom + card.with_bottom_line do + render(Primer::Beta::Text.new(tag: :span, font_size: :small, color: :subtle)) { "Some bottom line" } + end + end + end %> diff --git a/lookbook/previews/open_project/common/work_package_card_component_preview/with_bottom_line.html.erb b/lookbook/previews/open_project/common/work_package_card_component_preview/with_bottom_line.html.erb new file mode 100644 index 000000000000..d22923af8955 --- /dev/null +++ b/lookbook/previews/open_project/common/work_package_card_component_preview/with_bottom_line.html.erb @@ -0,0 +1,5 @@ +<%= render OpenProject::Common::WorkPackageCardComponent.new(work_package:) do |card| + card.with_bottom_line do + render(Primer::Beta::Text.new(tag: :span, font_size: :small, color: :subtle)) { "Some bottom line" } + end + end %> diff --git a/modules/backlogs/app/components/backlogs/work_package_card_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_component.rb index f0b69e46108b..6da3a71b1653 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_component.rb @@ -54,7 +54,14 @@ def call 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_link: true, + status_scheme: :secondary + ) end def before_render