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..ce8ac93262c0 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,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| %> + <% if show_drag_handle %> + <% grid.with_area(:drag_handle, classes: "hide-when-print") do %> + <%= render(Primer::OpenProject::DragHandle.new) %> + <% 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" + ) + ) do %> + <%= work_package.priority.name %> + <% end %> <% end %> - <% end %> - <% grid.with_area(:menu) do %> <% 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 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 %> 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..ac7f4af02147 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,54 @@ class WorkPackageCardComponent < ApplicationComponent **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 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..e468cbe08644 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,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 diff --git a/app/components/open_project/common/work_package_card_component/menu.rb b/app/components/open_project/common/work_package_card_component/menu.rb index 75568601d01b..faadab7e33cd 100644 --- a/app/components/open_project/common/work_package_card_component/menu.rb +++ b/app/components/open_project/common/work_package_card_component/menu.rb @@ -49,6 +49,7 @@ def initialize(work_package:, src: nil, button_aria_label: nil, **system_argumen 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) 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..c388f8d03724 100644 --- a/app/components/work_packages/status_badge_component.rb +++ b/app/components/work_packages/status_badge_component.rb @@ -35,6 +35,13 @@ def initialize(status:, **system_arguments) 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 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..77d68cd5f118 --- /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: 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 +``` 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..261e3b647db5 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,103 @@ 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: - ) + 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 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:, + show_parent: true + ) + end + + # 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_with_template(template: "open_project/common/work_package_card_component_preview/with_additional_details", + 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..d255d25d7143 --- /dev/null +++ b/lookbook/previews/open_project/common/work_package_card_component_preview/playground.html.erb @@ -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 %> diff --git a/lookbook/previews/open_project/common/work_package_card_component_preview/with_additional_details.html.erb b/lookbook/previews/open_project/common/work_package_card_component_preview/with_additional_details.html.erb new file mode 100644 index 000000000000..cf567d83bc55 --- /dev/null +++ b/lookbook/previews/open_project/common/work_package_card_component_preview/with_additional_details.html.erb @@ -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 %> diff --git a/lookbook/previews/open_project/work_packages/info_line_component_preview.rb b/lookbook/previews/open_project/work_packages/info_line_component_preview.rb index 8331d308dccc..e579905ef6de 100644 --- a/lookbook/previews/open_project/work_packages/info_line_component_preview.rb +++ b/lookbook/previews/open_project/work_packages/info_line_component_preview.rb @@ -36,11 +36,13 @@ class InfoLineComponentPreview < ViewComponent::Preview # @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 diff --git a/modules/backlogs/app/components/backlogs/sprint_component.rb b/modules/backlogs/app/components/backlogs/sprint_component.rb index 2f89621b86db..6fe512292e12 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_component.rb @@ -46,7 +46,8 @@ def initialize(sprint:, project:, work_packages: nil, current_user: User.current @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 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..16268a7a0563 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: true, + status_scheme: :secondary + ) end def before_render diff --git a/modules/backlogs/app/controllers/backlogs/backlog_controller.rb b/modules/backlogs/app/controllers/backlogs/backlog_controller.rb index 76e2183dd81c..e75051756138 100644 --- a/modules/backlogs/app/controllers/backlogs/backlog_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/backlog_controller.rb @@ -73,7 +73,7 @@ def load_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) diff --git a/modules/backlogs/app/models/backlog_bucket.rb b/modules/backlogs/app/models/backlog_bucket.rb index 3454697d0c61..0e8c7c003b18 100644 --- a/modules/backlogs/app/models/backlog_bucket.rb +++ b/modules/backlogs/app/models/backlog_bucket.rb @@ -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 diff --git a/modules/backlogs/app/models/work_packages/scopes/backlogs_inbox_for.rb b/modules/backlogs/app/models/work_packages/scopes/backlogs_inbox_for.rb index 9a3aa68d9dee..925cb4fb23a0 100644 --- a/modules/backlogs/app/models/work_packages/scopes/backlogs_inbox_for.rb +++ b/modules/backlogs/app/models/work_packages/scopes/backlogs_inbox_for.rb @@ -37,7 +37,7 @@ def backlogs_inbox_for(project:) .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