[#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
+1 -2
View File
@@ -1,5 +1,6 @@
@import "enterprise_edition/banner_component"
@import "filter/filters_component"
@import "open_project/common/border_box_list_component"
@import "op_primer/border_box_table_component"
@import "op_primer/full_page_prompt_component"
@import "op_primer/form_helpers"
@@ -11,8 +12,6 @@
@import "open_project/common/inplace_edit_fields/index"
@import "open_project/common/submenu_component"
@import "open_project/common/main_menu_toggle_component"
@import "open_project/common/work_package_card_list_component"
@import "open_project/common/work_package_card_list_component/header"
@import "open_project/common/work_package_card_component"
@import "portfolios/details_component"
@import "projects/row_component"
@@ -29,25 +29,25 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %>
<% if header? %>
<% border_box.with_header(id: header_id) do %>
<% border_box.with_header(**header.row_args) do %>
<%= header %>
<% end %>
<% end %>
<% if items.empty? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<%= empty_state %>
<% end %>
<% else %>
<% if items.any? %>
<% items.each do |item| %>
<% border_box.with_row(**item.row_args) do %>
<%= render(item.card) %>
<%= item %>
<% end %>
<% end %>
<% elsif empty_state? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<%= empty_state %>
<% end %>
<% end %>
<% if footer? %>
<% border_box.with_row(scheme: :neutral) do %>
<% border_box.with_footer(**footer.footer_args) do %>
<%= footer %>
<% end %>
<% end %>
@@ -0,0 +1,196 @@
# 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
# A Primer BorderBox-backed list composition with optional header, items,
# empty state, and footer.
#
# Use this component for compact lists that need consistent OpenProject
# header actions, collapsible behavior, and row rendering.
class BorderBoxListComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
attr_reader :container, :current_user, :header_id, :footer_id
# Optional header row.
#
# @!parse
# # Adds the optional header row.
# #
# # @param system_arguments [Hash] forwarded to {Header}. List wiring
# # arguments are supplied internally.
# # @return [ViewComponent::Slot]
# def with_header(**system_arguments, &block)
# end
renders_one :header, ->(**system_arguments) {
system_arguments[:id] = header_id
system_arguments[:list_id] = list_id
Header.new(**system_arguments)
}
# List row content.
#
# Use:
#
# - `item` for generic row content.
# - `work_package_item` for rows backed by a work package card.
#
# @!parse
# # Adds a generic list row.
# #
# # @param system_arguments [Hash] forwarded to Primer's BorderBox row.
# # @return [ViewComponent::Slot]
# def with_item(**system_arguments, &block)
# end
#
# # Adds a work-package list row.
# #
# # @param work_package [WorkPackage] work package rendered by the row.
# # @param project [Project] project context for the work package.
# # @param params [Hash] request params used by specialized item classes.
# # @param component_klass [Class] item component class to instantiate.
# # @param item_arguments [Hash] forwarded to the item component.
# # @return [ViewComponent::Slot]
# def with_work_package_item(
# work_package:,
# project: work_package.project,
# params: {},
# component_klass: WorkPackageItem,
# **item_arguments,
# &block
# )
# end
renders_many :items, types: {
item: {
renders: ->(**system_arguments) {
Item.new(**system_arguments)
},
as: :item
},
work_package_item: {
renders: ->(
work_package:,
project: work_package.project,
params: {},
component_klass: WorkPackageItem,
**item_arguments
) {
component_klass.new(
work_package:,
project:,
params:,
container:,
current_user:,
**item_arguments
)
},
as: :work_package_item
}
}
# Optional empty-state content rendered when no items are present.
#
# @!parse
# # Adds empty-state content.
# #
# # @param title [String] empty-state title.
# # @param description [String, nil] optional supporting text.
# # @param icon [Symbol, nil] optional Primer icon.
# # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
# # @return [ViewComponent::Slot]
# def with_empty_state(title:, description: nil, icon: nil, **system_arguments)
# end
renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) {
EmptyState.new(title:, description:, icon:, **system_arguments)
}
# Optional footer row.
#
# @!parse
# # Adds an optional footer row.
# #
# # @param system_arguments [Hash] forwarded to Primer's BorderBox
# # footer. The `id` is generated internally for collapsible header
# # wiring.
# # @return [ViewComponent::Slot]
# def with_footer(**system_arguments, &block)
# end
renders_one :footer, ->(**system_arguments) {
system_arguments[:id] = footer_id
Footer.new(**system_arguments)
}
# @param container [String, Symbol, Class, Object] value passed to
# `dom_target` to derive DOM ids for the list and related controls.
# @param current_user [User] user context passed to work-package items.
# @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox`.
# Pass `id:` to set the box id; related ids are derived from it.
def initialize(container:, current_user: User.current, **system_arguments)
super()
@container = container
@current_user = current_user
@system_arguments = system_arguments
@system_arguments[:id] ||= dom_target(container)
@system_arguments[:list_id] = dom_target(@system_arguments[:id], :list)
@header_id = dom_target(@system_arguments[:id], :header)
@footer_id = dom_target(@system_arguments[:id], :footer)
end
def before_render
content
configure_header!
end
def render?
header? || items.any? || empty_state? || footer?
end
private
def configure_header!
return unless header?
header.resolve_count!(items.size)
return unless footer?
header.collapsible_id = [list_id, footer_id].compact.join(" ")
end
def list_id
@system_arguments[:list_id]
end
end
end
end
@@ -0,0 +1,22 @@
//-- 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.
//
// See COPYRIGHT and LICENSE files for more details.
//++
.op-border-box-list-header
display: grid
grid-template-columns: 1fr minmax(5rem, max-content) auto
grid-template-areas: "collapsible actions menu"
align-items: center
&--actions,
&--menu
margin-left: var(--stack-gap-normal)
align-self: flex-start
// Unfortunately, the invisible button style bites us here again.
margin-top: -6px
@@ -0,0 +1,74 @@
# 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
class BorderBoxListComponent
# Empty-state content rendered as a Primer Blankslate.
#
# This component is part of {BorderBoxListComponent} and should not be
# used as a standalone component.
#
# The component announces changes politely by default while preserving
# caller-provided aria attributes.
class EmptyState < ApplicationComponent
include Primer::AttributesHelper
# @param title [String] empty-state heading.
# @param description [String, nil] optional supporting text.
# @param icon [Symbol, nil] optional Primer icon.
# @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
def initialize(title:, description: nil, icon: nil, **system_arguments)
super()
@title = title
@description = description
@icon = icon
@system_arguments = system_arguments
@system_arguments[:role] = "status"
@system_arguments[:aria] = merge_aria(
system_arguments,
aria: { live: "polite" }
)
end
def call
blankslate = Primer::Beta::Blankslate.new(**@system_arguments)
blankslate.with_heading(tag: :h4).with_content(@title)
blankslate.with_description { @description } if @description
blankslate.with_visual_icon(icon: @icon) if @icon
render(blankslate)
end
end
end
end
end
@@ -23,28 +23,37 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
# Row bridge for caller-provided empty content.
class EmptyItem < ContentItem
include Primer::AttributesHelper
class BorderBoxListComponent
# Footer row rendered below list items.
#
# This component is part of {BorderBoxListComponent} and should not be
# used as a standalone component.
class Footer < ApplicationComponent
attr_reader :id
def row_args
system_arguments = @system_arguments.deep_dup
system_arguments[:data] = merge_data(
{ data: { empty_list_item: true } },
system_arguments
)
system_arguments
# @param system_arguments [Hash] forwarded to Primer's BorderBox footer.
def initialize(**system_arguments)
super()
@id = system_arguments[:id]
@system_arguments = system_arguments
end
def empty_item? = true
# @return [Hash] arguments forwarded to Primer's BorderBox footer.
def footer_args
@system_arguments.deep_dup
end
def call
content
end
end
end
end
@@ -23,55 +23,60 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
class Header < ApplicationComponent
include OpPrimer::ComponentHelpers
class BorderBoxListComponent
# Adds the standard list action menu slot used by list headers and items.
module HasMenu
extend ActiveSupport::Concern
include Primer::ClassNameHelper
renders_one :description
renders_many :actions, types: {
button: ->(**system_arguments) do
Primer::Beta::Button.new(**system_arguments)
included do
# @!parse
# # Adds a trailing action menu.
# #
# # @param menu_id [String, nil] id prefix for the Primer action menu.
# # @param button_aria_label [String, nil] accessible label for the
# # menu button.
# # @param system_arguments [Hash] forwarded to
# # `Primer::Alpha::ActionMenu`.
# # @return [ViewComponent::Slot]
# def with_menu(menu_id: nil, button_aria_label: nil, **system_arguments, &block)
# end
renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do
build_menu(menu_id:, button_aria_label:, **system_arguments)
end
}
end
renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do
private
def build_menu(menu_id: nil, button_aria_label: nil, **system_arguments)
system_arguments[:classes] = class_names(
system_arguments[:classes],
"hide-when-print"
)
menu = Primer::Alpha::ActionMenu.new(
menu_id: menu_id || dom_target(container, :menu),
menu_id: menu_id || default_menu_id,
anchor_align: :end,
**system_arguments
)
menu.with_show_button(
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": button_aria_label || t(".label_actions"),
"aria-label": button_aria_label || I18n.t(:label_actions),
tooltip_direction: :se
)
menu
end
attr_reader :title, :container, :list_id, :collapsed, :count
def initialize(title:, container:, list_id:, collapsed: false, count: nil)
super()
@title = title
@container = container
@list_id = list_id
@collapsed = collapsed
@count = count
def default_menu_id
self.class.generate_id
end
end
end
@@ -0,0 +1,52 @@
<%# -- 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
See COPYRIGHT and LICENSE files for more details.
++# %>
<%= grid_layout("op-border-box-list-header", tag: :div) do |grid| %>
<% grid.with_area(:collapsible) do %>
<%=
render(
Primer::OpenProject::BorderBox::CollapsibleHeader.new(
collapsible_id:,
collapsed:,
multi_line: true
)
) do |collapsible|
%>
<% collapsible.with_title(tag: title_tag) { title } %>
<% if render_count? %>
<% collapsible.with_count(**counter_arguments) %>
<% end %>
<% if description? %>
<% collapsible.with_description do %>
<%= description %>
<% end %>
<% end %>
<% end %>
<% end %>
<% if actions? %>
<% grid.with_area(:actions) do %>
<% actions.each do |action| %>
<%= action %>
<% end %>
<% end %>
<% end %>
<% if menu? %>
<% grid.with_area(:menu) do %>
<%= menu %>
<% end %>
<% end %>
<% end %>
@@ -0,0 +1,150 @@
# 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
class BorderBoxListComponent
# Structured header for {BorderBoxListComponent}.
#
# This component is part of {BorderBoxListComponent} and should not be
# used as a standalone component.
#
# The header renders through `Primer::Beta::BorderBox#with_header` and
# wraps the supplied title, count, description, actions, and menu in an
# `Primer::OpenProject::BorderBox::CollapsibleHeader`.
class Header < ApplicationComponent
include OpPrimer::ComponentHelpers
include HasMenu
DEFAULT_COUNT_ARGUMENTS = {
scheme: :primary,
round: true,
limit: 1_000,
hide_if_zero: true
}.freeze
# @!parse
# # Adds secondary content below the header title.
# #
# # @return [ViewComponent::Slot]
# def with_description(&block)
# end
renders_one :description
# @!parse
# # Adds a button to the header actions area.
# #
# # @param system_arguments [Hash] forwarded to `Primer::Beta::Button`.
# # @return [ViewComponent::Slot]
# def with_action_button(**system_arguments, &block)
# end
renders_many :actions, types: {
button: ->(**system_arguments) do
Primer::Beta::Button.new(**system_arguments)
end
}
attr_reader :title,
:count,
:count_arguments,
:title_tag,
:list_id,
:collapsed
attr_writer :collapsible_id
# @param title [String] header title.
# @param count [Integer, Boolean, nil] count badge behavior. Pass
# `nil` or `false` to hide it, `true` to infer the rendered item
# count, or an integer to render an explicit value.
# @param count_arguments [Hash] forwarded to `Primer::Beta::Counter`.
# Values are merged over the default counter arguments.
# @param title_tag [Symbol] tag used for the title heading.
# @param list_id [String, nil] id of the collapsible list body.
# @param collapsed [Boolean] whether the collapsible header starts closed.
# @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_header`.
def initialize(
title:,
count: nil,
count_arguments: {},
title_tag: :h4,
list_id: nil,
collapsed: false,
**system_arguments
)
super()
@title = title
@count = count
@count_arguments = count_arguments
@title_tag = title_tag
@list_id = list_id
@collapsible_id = list_id
@collapsed = collapsed
@system_arguments = system_arguments
end
# Resolves inferred counts after the list slots have been captured.
#
# @param item_count [Integer] number of rendered item slots.
# @return [void]
def resolve_count!(item_count)
@count = item_count if count == true
end
# @return [Hash] arguments forwarded to `Primer::Beta::BorderBox#with_header`.
def row_args
@system_arguments.deep_dup
end
# @return [Boolean] whether a counter should be rendered.
def render_count?
!count.nil? && count != false
end
# @return [Hash] merged arguments forwarded to `Primer::Beta::Counter`.
def counter_arguments
DEFAULT_COUNT_ARGUMENTS.merge(count_arguments).merge(count:)
end
# @return [String, nil] ids controlled by the collapsible header.
def collapsible_id
@collapsible_id.presence
end
private
def default_menu_id
list_id ? "#{list_id}_menu" : super
end
end
end
end
end
@@ -23,32 +23,28 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
# Item bridge for caller-provided content.
class ContentItem < ApplicationComponent
class BorderBoxListComponent
# Generic BorderBox list row that renders the slot content directly.
class Item < ApplicationComponent
# @param system_arguments [Hash] forwarded to Primer's BorderBox row.
def initialize(**system_arguments)
super()
@system_arguments = system_arguments
end
# @return [Hash] arguments forwarded to Primer's BorderBox row.
def row_args
@system_arguments.deep_dup
end
def card
self
end
def empty_item? = false
def call
content
end
@@ -23,18 +23,19 @@
#
# 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.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
class WorkPackageCardListComponent
# Internal row bridge between the card list and the visual card. It owns
# the surrounding BorderBox row arguments while `WorkPackageCardComponent`
# renders the card body.
class Item < ApplicationComponent
class BorderBoxListComponent
# BorderBox list row that renders a work package card.
#
# Specialized rows can subclass this component and override `build_card`
# to provide a different card component while keeping row behavior.
class WorkPackageItem < ApplicationComponent
include ActionView::RecordIdentifier
include Primer::ClassNameHelper
include Primer::AttributesHelper
@@ -45,8 +46,16 @@ module OpenProject
:params,
:current_user
delegate :with_metric, to: :card
# Delegates card slots so callers can configure the rendered card from
# `with_work_package_item`.
delegate :with_metric, :with_menu, to: :card
# @param work_package [WorkPackage] work package rendered by the card.
# @param project [Project] project context for row behavior.
# @param container [String, Array<String, Symbol>] parent list container id seed.
# @param params [Hash] request params used by specialized item classes.
# @param current_user [User] user context for specialized item classes.
# @param system_arguments [Hash] forwarded to Primer's BorderBox row.
def initialize(
work_package:,
project:,
@@ -65,11 +74,16 @@ module OpenProject
@system_arguments = system_arguments
end
# @return [Hash] arguments forwarded to Primer's BorderBox row.
def row_args
row_arguments = @system_arguments.deep_dup
row_arguments[:id] ||= dom_id(work_package)
row_arguments[:tabindex] ||= 0
row_arguments[:classes] = class_names(row_classes, row_arguments[:classes])
row_arguments[:test_selector] ||= "work-package-#{work_package.id}"
row_arguments[:classes] = class_names(
row_classes,
row_arguments[:classes]
)
row_arguments[:data] = merge_data(
{ data: row_data },
row_arguments
@@ -77,16 +91,28 @@ module OpenProject
row_arguments
end
def card
@card ||= WorkPackageCardComponent.new(work_package:)
def before_render
content
end
def render? = false
def call
render(card)
end
def empty_item? = false
# @return [ApplicationComponent] card component rendered inside the row.
def card
@card ||= build_card
end
private
# Override in subclasses to render a specialized work-package card.
#
# @return [ApplicationComponent]
def build_card
WorkPackageCardComponent.new(work_package:)
end
def row_classes
class_names(
"Box-row--hover-blue",
@@ -97,11 +123,7 @@ module OpenProject
end
def row_data
data = {
test_selector: "work-package-#{work_package.id}"
}
draggable? ? data.merge(draggable_data) : data
draggable? ? draggable_data : {}
end
def draggable?
@@ -1,309 +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
class WorkPackageCardListComponent < ApplicationComponent
include Primer::AttributesHelper
include OpPrimer::ComponentHelpers
# Renders a `Header` above the card list with the title, count badge, and
# consumer-provided actions/menu/description.
#
# @param title [String] heading text rendered inside the collapsible header.
# @param count [Integer, NilClass] optional count badge displayed alongside
# the title; hidden when zero or nil.
renders_one :header, ->(title:, count: nil) {
Header.new(title:, count:, container:, list_id:, collapsed: folded?)
}
# Renders a `Primer::Beta::Blankslate` when no items are produced — that
# is, when `items.empty?` after slot resolution and automatic item builds.
# The slot is required unless the caller provides manual items, and is
# silently ignored whenever `items` is non-empty.
#
# @param title [String] blankslate heading.
# @param description [String, NilClass] optional secondary text.
# @param icon [Symbol, NilClass] optional Octicon name.
# @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) {
system_arguments[:role] = "status"
system_arguments[:aria] = merge_aria(
system_arguments,
aria: { live: "polite" }
)
blankslate = Primer::Beta::Blankslate.new(**system_arguments)
blankslate.with_heading(tag: :h4).with_content(title)
blankslate.with_description_content(description) if description
blankslate.with_visual_icon(icon:) if icon
blankslate
}
# @!parse
# # Adds a work package item row to the list. When at least one item
# # is added manually, the list does not build rows from
# # `work_packages:`.
# #
# # @param work_package [WorkPackage] the work package rendered in the row.
# # @param component_klass [Class] row bridge class used instead of the
# # default item class. Defaults to the list's configured
# # `item_component_klass`. It must accept the arguments documented on
# # `#build_item`, expose `#row_args` with valid
# # `Primer::Beta::BorderBox#with_row` keyword arguments, and expose
# # `#card` returning a renderable object.
# # @param system_arguments [Hash] forwarded to the item class.
# def with_work_package_item(
# work_package:,
# component_klass: Item,
# **system_arguments,
# &block
# )
# end
# @!parse
# # Adds a custom empty item row to the list. This can be used instead of
# # the `empty_state` slot when the caller owns item iteration. It cannot
# # be combined with `work_packages:`, `with_work_package_item`, or
# # `with_item`.
# #
# # @param system_arguments [Hash] forwarded to
# # `Primer::Beta::BorderBox#with_row`.
# def with_empty_item(**system_arguments, &block)
# end
# @!parse
# # Adds a generic item to the list. When at least one item is added
# # manually, the list does not build rows from `work_packages:`.
# #
# # @param system_arguments [Hash] forwarded to
# # `Primer::Beta::BorderBox#with_row`.
# def with_item(**system_arguments, &block)
# end
renders_many :items, types: {
work_package_item: {
renders: lambda { |work_package:, **system_arguments, &block|
build_item(work_package:, **system_arguments).tap do |item|
capture(item, &block) if block
end
},
as: :work_package_item
},
empty_item: {
renders: lambda { |**system_arguments, &block|
build_content_item(EmptyItem, **system_arguments, &block)
},
as: :empty_item
},
item: {
renders: lambda { |**system_arguments, &block|
build_content_item(ContentItem, **system_arguments, &block)
},
as: :item
}
}
# Renders a free-form footer row below the card list.
renders_one :footer
attr_reader :work_packages,
:project,
:container,
:drag_and_drop,
:item_component_klass,
:params,
:current_user
# @param project [Project] the project this card list is rendered in. May
# differ from individual `work_package.project` values when sprints or
# buckets are shared across projects.
# @param container [Symbol, String, Class, ApplicationRecord] drives the
# list DOM id and related ids via `dom_target`.
# @param work_packages [Enumerable<WorkPackage>] the work packages to render
# as cards.
# @param drag_and_drop [Hash, NilClass] optional generic drag-and-drop
# target data. Requires `:target_id` and `:allowed_drag_type` when set.
# @param item_component_klass [Class] item class used for automatically
# built work package items.
# @param params [Hash] optional URL params passed to work package items
# when deriving row arguments.
# @param current_user [User] passed through to each item for permission
# checks; defaults to `User.current`.
# @param system_arguments [Hash] forwarded to the underlying
# `Primer::Beta::BorderBox`.
def initialize(
project:,
container:,
work_packages: [],
drag_and_drop: nil,
item_component_klass: Item,
params: {},
current_user: User.current,
**system_arguments
)
super()
@work_packages = work_packages
@project = project
@container = container
@drag_and_drop = drag_and_drop
@item_component_klass = item_component_klass
@params = params
@current_user = current_user
@automatic_items = false
@system_arguments = system_arguments
@system_arguments[:id] = container_id
@system_arguments[:list_id] = list_id
@system_arguments[:padding] = :condensed
merge_drag_and_drop_data! if drag_and_drop
end
def before_render
# Content must be loaded before mode validation and automatic item builds
# so slot calls have already populated `items`.
content
validate_item_mode!
build_automatic_items if build_automatic_items?
validate_empty_state!
end
# Builds a new work package item without adding it to the list. Use this
# instead of the `#with_work_package_item` slot when rendering additional
# items outside this list, such as in a separately-loaded page.
#
# @param work_package [WorkPackage] the work package rendered in the row.
# @param component_klass [Class] item class used instead of the configured
# default item class. It must accept `work_package:`, `project:`,
# `container:`, `params:`, `current_user:`, and `**system_arguments`.
# @param system_arguments [Hash] forwarded to the item class.
def build_item(
work_package:,
component_klass: item_component_klass,
**system_arguments
)
component_klass.new(
work_package:,
project:,
container:,
params:,
current_user:,
**system_arguments
)
end
private
def folded?
current_user.pref[:backlogs_versions_default_fold_state] == "closed"
end
def build_automatic_items?
non_empty_items.empty? && work_packages.any?
end
def build_automatic_items
@automatic_items = true
work_packages.each do |work_package|
with_work_package_item(work_package:)
end
end
def build_content_item(item_class, **system_arguments, &block)
item_class.new(**system_arguments).tap do |item|
item.with_content(capture(&block)) if block
end
end
def automatic_items?
@automatic_items
end
def validate_item_mode!
return unless empty_items.any?
if work_packages.any?
raise ArgumentError, "empty_item cannot be combined with work_packages"
end
if non_empty_items.any?
raise ArgumentError, "empty_item cannot be combined with other items"
end
end
def validate_empty_state!
return unless items.empty? && !empty_state?
raise ArgumentError, "empty_state slot is required when no work package items are rendered"
end
def container_id
dom_target(container)
end
def list_id
dom_target(container, :list)
end
def header_id
dom_target(container, :header)
end
def empty_items
items.select { |item| item.respond_to?(:empty_item?) && item.empty_item? }
end
def non_empty_items
items - empty_items
end
def merge_drag_and_drop_data!
@system_arguments[:data] = merge_data(
{
data: drag_and_drop_data
},
@system_arguments
)
end
def drag_and_drop_data
{
# Existing callers share one mirror container target on the page until
# parent-specific DnD handling is extracted in follow-up work.
generic_drag_and_drop_target: "container",
target_container_accessor: ":scope > ul",
target_id: drag_and_drop.fetch(:target_id),
target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type)
}
end
end
end
end
@@ -1,74 +0,0 @@
<%# -- 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.
++# %>
<%= grid_layout("op-work-package-card-list-header", tag: :div) do |grid| %>
<% grid.with_area(:collapsible) do %>
<%=
render(
Primer::OpenProject::BorderBox::CollapsibleHeader.new(
collapsible_id: list_id,
collapsed:,
multi_line: true
)
) do |collapsible|
%>
<% collapsible.with_title(tag: :h4) { title } %>
<% if count %>
<% collapsible.with_count(
scheme: :primary,
count: count,
round: true,
limit: 1_000,
hide_if_zero: true,
aria: {
label: t(".label_work_package_count", count: count),
live: "polite"
}
) %>
<% end %>
<% if description? %>
<% collapsible.with_description do %>
<%= description %>
<% end %>
<% end %>
<% end %>
<% end %>
<% if actions? %>
<% grid.with_area(:actions) do %>
<% actions.each do |action| %>
<%= action %>
<% end %>
<% end %>
<% end %>
<% grid.with_area(:menu) do %>
<%= menu %>
<% end %>
<% end %>
@@ -1,40 +0,0 @@
//-- 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.
//++
.op-work-package-card-list-header
display: grid
grid-template-columns: 1fr minmax(5rem, max-content) auto
grid-template-areas: "collapsible actions menu"
align-items: center
&--actions,
&--menu
margin-left: var(--stack-gap-normal)
align-self: flex-start
// Unfortunately, the invisible button style bites us here again.
margin-top: -6px
-7
View File
@@ -4934,13 +4934,6 @@ en:
work_package_card_component:
menu:
label_actions: "Work package actions"
work_package_card_list_component:
header:
label_actions: "Open menu"
label_work_package_count:
zero: "No work packages"
one: "%{count} work package"
other: "%{count} work packages"
permission_add_work_package_comments: "Add comments"
permission_add_work_packages: "Add work packages"
@@ -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
@@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= component_wrapper(tag: :section) do %>
<%= render(
OpenProject::Common::WorkPackageCardListComponent.new(
Backlogs::WorkPackageCardListComponent.new(
work_packages:,
project:,
container: backlog_bucket,
@@ -37,13 +37,12 @@ See COPYRIGHT and LICENSE files for more details.
target_id: "backlog_bucket:#{backlog_bucket.id}",
allowed_drag_type: "story"
},
item_component_klass: Backlogs::WorkPackageCardListItemComponent,
params: all_backlogs_params,
current_user:,
data: { test_selector: "backlog-bucket-#{backlog_bucket.id}" }
)
) do |list| %>
<% list.with_header(title: backlog_bucket.name, count: work_packages.size) do |header| %>
<% list.with_header(title: backlog_bucket.name) do |header| %>
<% if show_menu? %>
<% header.with_menu(button_aria_label: t(".label_actions")) do |menu| %>
<% with_item_group(menu) do %>
@@ -28,29 +28,34 @@ See COPYRIGHT and LICENSE files for more details.
++# %>
<%= component_wrapper(tag: :section) do %>
<% inbox_container = dom_target(:inbox, project) %>
<%= render(
OpenProject::Common::WorkPackageCardListComponent.new(
project:,
container: dom_target(:inbox, project),
drag_and_drop: {
target_id: "inbox",
allowed_drag_type: "story"
},
item_component_klass: Backlogs::WorkPackageCardListItemComponent,
params: all_backlogs_params,
OpenProject::Common::BorderBoxListComponent.new(
container: inbox_container,
current_user:,
data: { test_selector: "backlog-inbox" }
padding: :condensed,
data: {
generic_drag_and_drop_target: "container",
target_container_accessor: ":scope > ul",
target_id: "inbox",
target_allowed_drag_type: "story",
test_selector: "backlog-inbox"
}
)
) do |list| %>
<% list.with_empty_state(
title: t(".blankslate_title"),
description: t(".blankslate_description"),
icon: :"op-backlogs",
spacious: true
icon: :"op-backlogs"
) %>
<% visible_work_packages.each.with_index do |work_package, index| %>
<% list.with_work_package_item(work_package:) %>
<% list.with_work_package_item(
work_package:,
project:,
params: all_backlogs_params,
component_klass: Backlogs::WorkPackageCardListItemComponent
) %>
<% if truncated? && index == TRUNCATE_MIDDLE - 1 %>
<% list.with_item(
@@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= component_wrapper(tag: :section) do %>
<%= render(
OpenProject::Common::WorkPackageCardListComponent.new(
Backlogs::WorkPackageCardListComponent.new(
work_packages:,
project:,
container: sprint,
@@ -37,13 +37,12 @@ See COPYRIGHT and LICENSE files for more details.
target_id: "sprint:#{sprint.id}",
allowed_drag_type: "story"
},
item_component_klass: Backlogs::WorkPackageCardListItemComponent,
params: all_backlogs_params,
current_user:,
data: { test_selector: "sprint-#{sprint.id}" }
)
) do |list| %>
<% list.with_header(title: sprint.name, count: work_packages.size) do |header| %>
<% list.with_header(title: sprint.name) do |header| %>
<% header.with_description do %>
<%= render(
Primer::Beta::Text.new(
@@ -0,0 +1,145 @@
# 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 Backlogs
class WorkPackageCardListComponent < ApplicationComponent
include Primer::AttributesHelper
include OpPrimer::ComponentHelpers
delegate :with_empty_state, :with_footer, :empty_state?, to: :@list
attr_reader :work_packages,
:project,
:container,
:drag_and_drop,
:params,
:current_user
def initialize(
project:,
container:,
work_packages: nil,
drag_and_drop: nil,
params: {},
current_user: User.current,
**system_arguments
)
super()
@work_packages = work_packages || []
@project = project
@container = container
@drag_and_drop = drag_and_drop
@params = params
@current_user = current_user
@system_arguments = system_arguments
@system_arguments[:padding] = :condensed
merge_drag_and_drop_data! if drag_and_drop
@list = OpenProject::Common::BorderBoxListComponent.new(
container:,
current_user:,
**@system_arguments
)
end
def with_header(title:, **system_arguments, &)
count = work_packages.size
@list.with_header(
title:,
count:,
count_arguments: {
aria: {
label: t(".label_work_package_count", count:),
live: "polite"
}
},
collapsed: folded?,
**system_arguments,
&
)
end
def before_render
content
populate_list!
validate_empty_state!
end
def call
render(@list)
end
private
def merge_drag_and_drop_data!
@system_arguments[:data] = merge_data(
{
data: drag_and_drop_data
},
@system_arguments
)
end
def drag_and_drop_data
{
generic_drag_and_drop_target: "container",
target_container_accessor: ":scope > ul",
target_id: drag_and_drop.fetch(:target_id),
target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type)
}
end
def folded?
current_user.pref[:backlogs_versions_default_fold_state] == "closed"
end
def populate_list!
return if work_packages.empty?
work_packages.each do |work_package|
@list.with_work_package_item(
work_package:,
project:,
params:,
component_klass: Backlogs::WorkPackageCardListItemComponent
)
end
end
def validate_empty_state!
return unless work_packages.empty? && !empty_state?
raise ArgumentError, "empty_state slot is required when no work package items are rendered"
end
end
end
@@ -29,13 +29,13 @@
#++
module Backlogs
class WorkPackageCardListItemComponent < OpenProject::Common::WorkPackageCardListComponent::Item
def card
@card ||= WorkPackageCardComponent.new(work_package:, menu_src:)
end
class WorkPackageCardListItemComponent < OpenProject::Common::BorderBoxListComponent::WorkPackageItem
private
def build_card
WorkPackageCardComponent.new(work_package:, menu_src:)
end
def draggable?
current_user.allowed_in_project?(:manage_sprint_items, project)
end
+6
View File
@@ -202,6 +202,12 @@ en:
blankslate_title: "No burndown data available"
blankslate_description: "Set start and end date for the sprint to generate a burndown chart."
work_package_card_list_component:
label_work_package_count:
zero: "No work packages"
one: "%{count} work package"
other: "%{count} work packages"
burndown:
story_points: "Story points"
story_points_ideal: "Story points (ideal)"
@@ -89,8 +89,7 @@ RSpec.describe Backlogs::BucketComponent, type: :component do
expect(rendered_component).to have_css(
".Counter",
text: "1",
aria: { label: I18n.t("open_project.common.work_package_card_list_component.header.label_work_package_count",
count: 1) }
aria: { label: I18n.t("backlogs.work_package_card_list_component.label_work_package_count", count: 1) }
)
end
@@ -79,6 +79,14 @@ RSpec.describe Backlogs::SprintComponent, type: :component do
expect(rendered_component).to have_text("8 points", normalize_ws: true)
end
it "renders the inferred work-package count in the header" do
expect(rendered_component).to have_css(
".Counter",
text: "2",
aria: { label: I18n.t("backlogs.work_package_card_list_component.label_work_package_count", count: 2) }
)
end
it "renders story points on each work package card" do
expect(rendered_component).to have_css("span", text: "5", aria: { hidden: true })
expect(rendered_component).to have_css(".sr-only", text: "5 story points")
@@ -0,0 +1,313 @@
# 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.
#++
require "rails_helper"
RSpec.describe Backlogs::WorkPackageCardListComponent, type: :component do
include Rails.application.routes.url_helpers
shared_let(:type_feature) { create(:type_feature) }
shared_let(:default_status) { create(:default_status) }
shared_let(:default_priority) { create(:default_priority) }
shared_let(:user) { create(:admin) }
current_user { user }
shared_let(:project) { create(:project, types: [type_feature]) }
shared_let(:sprint) do
create(:sprint, project:, name: "Sprint 1",
start_date: Date.yesterday, finish_date: Date.tomorrow)
end
shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") }
let(:container) { sprint }
let(:drag_and_drop) { nil }
let(:params) { {} }
let(:work_packages) { [] }
let(:header_arguments) { nil }
let(:footer_content) { nil }
subject(:rendered_component) do
render_component(work_packages:, container:, drag_and_drop:)
end
def render_component(work_packages:, container:, drag_and_drop:)
render_inline(
described_class.new(
work_packages:,
project:,
container:,
drag_and_drop:,
params:,
current_user: user
)
) do |box|
box.with_header(**header_arguments) if header_arguments
box.with_empty_state(title: "Sprint 1 is empty", description: "Drag work packages here")
box.with_footer { footer_content } if footer_content
end
end
describe "automatic work_packages iteration" do
let(:work_packages) do
[
create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1),
create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 2)
]
end
it_behaves_like "rendering Box", row_count: 2, header: false, footer: false
it "renders one row per work package" do
expect(rendered_component).to have_text("WP A")
expect(rendered_component).to have_text("WP B")
end
end
describe "hardcoded Backlogs item component" do
let(:work_packages) do
[
create(:work_package, subject: "Story card", project:, type: type_feature,
status: default_status, priority: default_priority,
sprint:, position: 1, story_points: 3)
]
end
it "renders items through Backlogs::WorkPackageCardListItemComponent" do
work_package = work_packages.first
expect(rendered_component).to have_css(
".Box-row#work_package_#{work_package.id}[data-controller='backlogs--story']"
)
end
it "renders Backlogs-specific row data attributes" do
work_package = work_packages.first
expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row|
expect(row["data-story"]).to be_present
expect(row["data-backlogs--story-id-value"]).to eq(work_package.id.to_s)
end
end
it "renders the Backlogs work-package card" do
work_package = work_packages.first
expect(rendered_component).to have_css(
".Box-row#work_package_#{work_package.id} .sr-only",
text: "3 story points"
)
expect(rendered_component).to have_element(
"include-fragment",
src: menu_project_backlogs_work_package_path(project, sprint, work_package)
)
end
end
describe "delegated header with fold-state defaults" do
let(:header_arguments) { { title: "Sprint 1", count: 0 } }
it "renders the header" do
expect(rendered_component).to have_css(".Box-header")
end
it "renders the provided title" do
expect(rendered_component).to have_heading "Sprint 1", level: 4
end
context "when the user prefers closed folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "closed"
end
it "renders the header as collapsed" do
expect(rendered_component).to have_css(
".CollapsibleHeader-triggerArea",
aria: { expanded: "false" }
)
end
end
context "when the user prefers open folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "open"
end
it "renders the header as expanded" do
expect(rendered_component).to have_css(
".CollapsibleHeader-triggerArea",
aria: { expanded: "true" }
)
end
end
context "with work packages" do
let(:header_arguments) { { title: "Sprint 1" } }
let(:work_packages) do
[
create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1),
create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 2)
]
end
it "infers the count from the rendered work packages" do
expect(rendered_component).to have_css(
".Counter",
text: "2",
aria: { label: I18n.t("backlogs.work_package_card_list_component.label_work_package_count", count: 2) }
)
end
end
context "when the count is disabled" do
let(:header_arguments) { { title: "Sprint 1", count: false } }
let(:work_packages) do
[
create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1)
]
end
it "does not render the count" do
expect(rendered_component).to have_no_css(".Counter")
end
end
end
describe "delegated footer" do
let(:footer_content) { "footer-content" }
it "renders the footer when supplied" do
expect(rendered_component).to have_text("footer-content")
end
end
describe "empty_state rendering" do
it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty"
it "renders the blankslate when work_packages is empty" do
expect(rendered_component).to have_text("Sprint 1 is empty")
expect(rendered_component).to have_text("Drag work packages here")
end
context "when work_packages is nil" do
let(:work_packages) { nil }
it "treats nil as an empty collection" do
expect(rendered_component).to have_text("Sprint 1 is empty")
expect(rendered_component).to have_text("Drag work packages here")
end
end
context "when there are work packages" do
let(:work_packages) do
[
create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1)
]
end
it "does not render the blankslate" do
expect(rendered_component).to have_no_css(".blankslate")
end
end
end
describe "empty_state validation" do
it "raises ArgumentError when work_packages is empty and no empty_state given" do
expect do
render_inline(
described_class.new(
work_packages: [],
project:,
container: sprint,
current_user: user
)
) do |box|
box.with_footer { "" }
end
end.to raise_error(ArgumentError, /empty_state slot is required/)
end
end
describe "drag-and-drop data merging" do
context "without drag_and_drop" do
it "does not emit drag-and-drop data" do
expect(rendered_component).to have_no_css(".Box[data-generic-drag-and-drop-target]")
expect(rendered_component).to have_no_css(".Box[data-target-id]")
expect(rendered_component).to have_no_css(".Box[data-target-allowed-drag-type]")
end
end
context "with drag_and_drop configured" do
let(:drag_and_drop) do
{ target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" }
end
it "merges drag-and-drop data attributes onto the box" do
expect(rendered_component).to have_css(".Box") do |box|
expect(box["data-generic-drag-and-drop-target"]).to eq("container")
expect(box["data-target-container-accessor"]).to eq(":scope > ul")
expect(box["data-target-id"]).to eq("sprint:#{sprint.id}")
expect(box["data-target-allowed-drag-type"]).to eq("story")
end
end
end
end
describe "container/list/header DOM IDs" do
context "when container is a Sprint" do
let(:container) { sprint }
it "uses dom_target(sprint) as the box id" do
expect(rendered_component).to have_css(".Box#sprint_#{sprint.id}")
end
it "uses dom_target(sprint, :list) for the list id" do
expect(rendered_component).to have_css("ul#sprint_#{sprint.id}_list")
end
end
context "when container is a BacklogBucket" do
let(:container) { backlog_bucket }
it "uses dom_target(backlog_bucket) as the box id" do
expect(rendered_component).to have_css(".Box#backlog_bucket_#{backlog_bucket.id}")
end
it "uses dom_target(backlog_bucket, :list) for the list id" do
expect(rendered_component).to have_css("ul#backlog_bucket_#{backlog_bucket.id}_list")
end
end
end
end
@@ -76,9 +76,9 @@ RSpec.describe Backlogs::WorkPackageCardListItemComponent, type: :component do
backlogs__story_id_value: work_package.id,
backlogs__story_display_id_value: work_package.display_id,
backlogs__story_full_url_value: work_package_path(work_package),
backlogs__story_selected_class: "Box-row--blue",
test_selector: "work-package-#{work_package.id}"
backlogs__story_selected_class: "Box-row--blue"
)
expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}")
end
it "marks the row as draggable for users allowed to manage sprint items" do
@@ -350,10 +350,7 @@ module Pages
within_backlog_bucket(bucket) do
expect(page).to have_css(
".Counter",
accessible_name: I18n.t(
"open_project.common.work_package_card_list_component.header.label_work_package_count",
count:
)
accessible_name: I18n.t("backlogs.work_package_card_list_component.label_work_package_count", count:)
)
end
end
@@ -470,16 +467,13 @@ module Pages
within(sprint_selector(sprint)) do
expect(page).to have_css(
".Counter",
accessible_name: I18n.t(
"open_project.common.work_package_card_list_component.header.label_work_package_count",
count:
)
accessible_name: I18n.t("backlogs.work_package_card_list_component.label_work_package_count", count:)
)
end
end
def expect_and_dismiss_error(message)
expect(page).to have_content message
expect(page).to have_text message
click_on "Cancel"
end
@@ -0,0 +1,632 @@
# 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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
shared_let(:user) { create(:admin) }
shared_let(:project) { create(:project) }
shared_let(:work_package) { create(:work_package, subject: "Default WP", project:) }
shared_let(:override_work_package) { create(:work_package, subject: "Override WP", project:) }
current_user { user }
let(:default_wp_item_class) do
stub_const(
"TestDefaultWorkPackageItem",
Class.new(ApplicationComponent) do
include ActionView::RecordIdentifier
delegate :with_metric, :with_menu, to: :card
def initialize(work_package:, project:, container:, params: {}, current_user: User.current, **system_arguments) # rubocop:disable Lint/UnusedMethodArgument
super()
@work_package = work_package
@project = project
@container = container
@current_user = current_user
@system_arguments = system_arguments
end
def row_args
@system_arguments.merge(
id: "default_wp_#{@work_package.id}",
data: @system_arguments.fetch(:data, {}).merge(
container: Array(@container).map { |c| c.respond_to?(:id) ? c.id : c }.join("_"),
project: @project&.id,
current_user: @current_user&.id
)
)
end
def card
@card ||= TestWorkPackageCard.new(prefix: "default", subject: @work_package.subject)
end
def before_render
content
end
def call
render(card)
end
end
)
end
let(:override_wp_item_class) do
stub_const(
"TestOverrideWorkPackageItem",
Class.new(default_wp_item_class) do
def row_args
super.merge(id: "override_wp_#{@work_package.id}")
end
def card
@card ||= TestWorkPackageCard.new(prefix: "override", subject: @work_package.subject)
end
end
)
end
before do
stub_const(
"TestWorkPackageCard",
Class.new(ApplicationComponent) do
renders_one :metric
renders_one :menu
def initialize(prefix:, subject:)
super()
@prefix = prefix
@subject = subject
end
def call
safe_join([tag.span("#{@prefix} #{@subject}"), metric, menu].compact)
end
end
)
end
describe "full rendering" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "test-list", current_user: user)
) do |list|
list.with_header(title: "Header title", count: 3)
list.with_item(id: "manual-item") { "Manual item" }
list.with_work_package_item(
work_package:,
component_klass: default_wp_item_class,
data: { source: "slot" }
) do |item|
item.card.with_metric { "Metric content" }
end
list.with_work_package_item(
work_package: override_work_package,
component_klass: override_wp_item_class
) do |item|
item.with_menu { "Menu content" }
end
list.with_footer { "Footer content" }
end
end
it_behaves_like "rendering Box", row_count: 3, header: true, footer: true
it "renders the header with title" do
expect(rendered_component).to have_heading("Header title", level: 4)
end
it "renders the header count badge" do
expect(rendered_component).to have_css(".Counter", text: "3")
end
it "renders generic items as content rows" do
expect(rendered_component).to have_css(".Box-row#manual-item", text: "Manual item")
end
it "renders the footer" do
expect(rendered_component).to have_css(".Box-footer", text: "Footer content")
end
it "renders the default work-package item" do
expect(rendered_component).to have_css(
".Box-row#default_wp_#{work_package.id}",
text: "default Default WP"
)
end
it "renders the overridden work-package item" do
expect(rendered_component).to have_css(
".Box-row#override_wp_#{override_work_package.id}",
text: "override Override WP"
)
end
it "captures work-package item customization blocks" do
expect(rendered_component).to have_text("Metric content")
end
it "delegates menu customization to the card" do
expect(rendered_component).to have_text("Menu content")
end
end
describe "header" do
it "renders a description below the title" do
rendered = render_inline(
described_class.new(container: "hdr-test")
) do |list|
list.with_header(title: "My title") do |header|
header.with_description { "Some description" }
end
list.with_item { "row" }
end
expect(rendered).to have_heading("My title", level: 4)
expect(rendered).to have_text("Some description")
end
it "renders multiple action buttons" do
rendered = render_inline(
described_class.new(container: "hdr-actions")
) do |list|
list.with_header(title: "Actions") do |header|
header.with_action_button(scheme: :primary) { "Add" }
header.with_action_button(scheme: :default) { "Edit" }
end
list.with_item { "row" }
end
expect(rendered).to have_button("Add")
expect(rendered).to have_button("Edit")
end
it "renders a menu in the header" do
rendered = render_inline(
described_class.new(container: "hdr-menu")
) do |list|
list.with_header(title: "With menu") do |header|
header.with_menu do |menu|
menu.with_item(label: "Option A", value: "a")
end
end
list.with_item { "row" }
end
expect(rendered).to have_css(".Box-header")
expect(rendered).to have_css("action-menu")
expect(rendered).to have_css("tool-tip[data-type='label']", text: I18n.t(:label_actions))
end
it "infers the count from rendered items" do
rendered = render_inline(
described_class.new(container: "hdr-inferred-count")
) do |list|
list.with_header(title: "Counted", count: true)
list.with_item { "first row" }
list.with_item { "second row" }
end
expect(rendered).to have_css(".Counter", text: "2")
end
it "does not render a count when count is false" do
rendered = render_inline(
described_class.new(container: "hdr-false-count")
) do |list|
list.with_header(title: "Uncounted", count: false)
list.with_item { "row" }
end
expect(rendered).to have_no_css(".Counter")
end
it "does not render a count when count is nil" do
rendered = render_inline(
described_class.new(container: "hdr-nil-count")
) do |list|
list.with_header(title: "Uncounted", count: nil)
list.with_item { "row" }
end
expect(rendered).to have_no_css(".Counter")
end
it "renders an explicit count" do
rendered = render_inline(
described_class.new(container: "hdr-explicit-count")
) do |list|
list.with_header(title: "Counted", count: 5)
list.with_item { "row" }
end
expect(rendered).to have_css(".Counter", text: "5")
end
it "keeps zero counts hidden by default" do
rendered = render_inline(
described_class.new(container: "hdr-zero-count")
) do |list|
list.with_header(title: "Counted", count: 0)
list.with_item { "row" }
end
expect(rendered).to have_css(".Counter[hidden]", text: "0", visible: :all)
end
it "allows zero counts to be shown through count arguments" do
rendered = render_inline(
described_class.new(container: "hdr-visible-zero-count")
) do |list|
list.with_header(title: "Counted", count: 0, count_arguments: { hide_if_zero: false })
list.with_item { "row" }
end
expect(rendered).to have_css(".Counter:not([hidden])", text: "0")
end
it "merges count aria arguments with count defaults" do
rendered = render_inline(
described_class.new(container: "hdr-count-arguments")
) do |list|
list.with_header(
title: "Counted",
count: 5,
count_arguments: { aria: { label: "5 items", live: "polite" } }
)
list.with_item { "row" }
end
expect(rendered).to have_css(".Counter.Counter--primary[aria-label='5 items'][aria-live='polite']", text: "5")
end
it "allows the title tag to be customized" do
rendered = render_inline(
described_class.new(container: "hdr-title-tag")
) do |list|
list.with_header(title: "Custom title", title_tag: :h3)
list.with_item { "row" }
end
expect(rendered).to have_heading("Custom title", level: 3)
end
end
describe "header collapsible behavior" do
it "sets collapsible_id from list and footer ids" do
rendered = render_inline(
described_class.new(container: "collapse-test")
) do |list|
list.with_header(title: "Collapsible")
list.with_item { "row" }
list.with_footer { "foot" }
end
list_id = "collapse-test_list"
footer_id = "collapse-test_footer"
expect(rendered).to have_css(
"[aria-controls='#{list_id} #{footer_id}']"
)
end
it "sets collapsible_id from list id only when no footer" do
rendered = render_inline(
described_class.new(container: "collapse-no-footer")
) do |list|
list.with_header(title: "No footer")
list.with_item { "row" }
end
expect(rendered).to have_css(
"[aria-controls='collapse-no-footer_list']"
)
end
end
describe "generic items" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "generic-items")
) do |list|
list.with_item(id: "row-1") { "First" }
list.with_item(id: "row-2") { "Second" }
end
end
it "renders content block rows" do
expect(rendered_component).to have_css(".Box-row#row-1", text: "First")
expect(rendered_component).to have_css(".Box-row#row-2", text: "Second")
end
it "renders the expected number of rows" do
expect(rendered_component).to have_css(".Box-row", count: 2)
end
end
describe "work-package items" do
describe "with the default WorkPackageItem" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "wp-default", current_user: user)
) do |list|
list.with_work_package_item(work_package:)
end
end
it "renders the work package row" do
expect(rendered_component).to have_css(
".Box-row#work_package_#{work_package.id}"
)
end
it "applies clickable row classes" do
expect(rendered_component).to have_css(
".Box-row.Box-row--clickable"
)
end
it "sets the test selector" do
item = described_class::WorkPackageItem.new(
work_package:,
project:,
container: "wp-default",
current_user: user
)
expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}")
expect(rendered_component).to have_css(
".Box-row[data-test-selector='work-package-#{work_package.id}']"
)
end
it "delegates metric customization to the work-package card" do
rendered = render_inline(
described_class.new(container: "wp-default-metric", current_user: user)
) do |list|
list.with_work_package_item(work_package:) do |item|
item.with_metric { "Custom metric" }
end
end
expect(rendered).to have_text("Custom metric")
end
end
describe "with an overridden component_klass" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "wp-override", current_user: user)
) do |list|
list.with_work_package_item(
work_package: override_work_package,
component_klass: override_wp_item_class
)
end
end
it "uses the provided component class" do
expect(rendered_component).to have_css(
".Box-row#override_wp_#{override_work_package.id}",
text: "override Override WP"
)
end
end
describe "injected container: and current_user:" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "injection-test", current_user: user)
) do |list|
list.with_work_package_item(
work_package:,
component_klass: default_wp_item_class
)
end
end
it "injects the list container into the item" do
expect(rendered_component).to have_css(
".Box-row[data-container='injection-test']"
)
end
it "injects the list current_user into the item" do
expect(rendered_component).to have_css(
".Box-row[data-current-user='#{user.id}']"
)
end
end
describe "project defaults to work_package.project" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "project-default", current_user: user)
) do |list|
list.with_work_package_item(
work_package:,
component_klass: default_wp_item_class
)
end
end
it "passes the work package's project when project: is omitted" do
expect(rendered_component).to have_css(
".Box-row[data-project='#{work_package.project.id}']"
)
end
end
end
describe "empty state" do
it "renders a Blankslate when no items are present" do
rendered = render_inline(
described_class.new(container: "empty-list")
) do |list|
list.with_empty_state(title: "Nothing here", description: "Add some items", icon: :inbox)
end
expect(rendered).to have_css(".blankslate")
expect(rendered).to have_text("Nothing here")
expect(rendered).to have_text("Add some items")
end
it "does not render the empty state when items are present" do
rendered = render_inline(
described_class.new(container: "non-empty-list")
) do |list|
list.with_empty_state(title: "Nothing here")
list.with_item { "Has content" }
end
expect(rendered).to have_no_css(".blankslate")
expect(rendered).to have_text("Has content")
end
it "sets aria role and live attributes on the empty state" do
rendered = render_inline(
described_class.new(container: "empty-aria")
) do |list|
list.with_empty_state(title: "Empty")
end
expect(rendered).to have_css("[role='status'][aria-live='polite']")
end
end
describe "footer rendering" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "footer-test")
) do |list|
list.with_item { "row" }
list.with_footer(classes: "custom-footer") { "Custom footer" }
end
end
it "renders as a proper BorderBox footer" do
expect(rendered_component).to have_css(".Box-footer", text: "Custom footer")
end
it "auto-derives the footer id from the box id" do
expect(rendered_component).to have_css(".Box-footer#footer-test_footer")
end
end
describe "container-derived DOM IDs" do
context "with a string container" do
subject(:rendered_component) do
render_inline(
described_class.new(container: "my-widget")
) do |list|
list.with_item { "row" }
end
end
it "derives the box id from container" do
expect(rendered_component).to have_css(".Box#my-widget")
end
it "derives the list id from container" do
expect(rendered_component).to have_css("ul#my-widget_list")
end
end
it "derives the header id from the box id" do
rendered = render_inline(
described_class.new(container: "my-widget")
) do |list|
list.with_header(title: "Header")
list.with_item { "row" }
end
expect(rendered).to have_css(".Box-header#my-widget_header")
end
it "derives the list id from the explicit box id" do
rendered = render_inline(
described_class.new(container: "ignored", id: "explicit-box", list_id: "explicit-list")
) do |list|
list.with_item { "row" }
end
expect(rendered).to have_css(".Box#explicit-box")
expect(rendered).to have_css("ul#explicit-box_list")
expect(rendered).to have_no_css("ul#explicit-list")
end
it "derives the footer id from the explicit box id" do
rendered = render_inline(
described_class.new(container: "ignored", id: "explicit-box")
) do |list|
list.with_header(title: "Header")
list.with_item { "row" }
list.with_footer(id: "explicit-footer") { "footer" }
end
expect(rendered).to have_css(".Box-footer#explicit-box_footer")
expect(rendered).to have_no_css(".Box-footer#explicit-footer")
expect(rendered).to have_css("[aria-controls='explicit-box_list explicit-box_footer']")
end
end
describe "system arguments forwarded to BorderBox" do
subject(:rendered_component) do
render_inline(
described_class.new(
container: "sys-args",
classes: "extra-class",
data: { test_selector: "my-box" }
)
) do |list|
list.with_item { "row" }
end
end
it "forwards classes to the underlying BorderBox" do
expect(rendered_component).to have_css(".Box.extra-class")
end
it "forwards data attributes to the underlying BorderBox" do
expect(rendered_component).to have_css(".Box[data-test-selector='my-box']")
end
end
describe "constructor requires container:" do
it "raises ArgumentError when container: is missing" do
expect { described_class.new }.to raise_error(ArgumentError)
end
end
end
@@ -1,55 +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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::WorkPackageCardListComponent::EmptyItem, type: :component do
describe "#row_args" do
it "marks the row as an empty list item by default" do
item = described_class.new
expect(item.row_args[:data]).to include(empty_list_item: true)
end
it "lets caller-supplied data override the default empty item data" do
item = described_class.new(
data: {
empty_list_item: false,
test_selector: "custom-empty-row"
}
)
expect(item.row_args[:data]).to include(
empty_list_item: false,
test_selector: "custom-empty-row"
)
end
end
end
@@ -1,135 +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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::WorkPackageCardListComponent::Header, type: :component do
shared_let(:user) { create(:admin) }
current_user { user }
shared_let(:project) { create(:project) }
shared_let(:sprint) do
create(:sprint, project:, name: "Sprint 1",
start_date: Date.yesterday, finish_date: Date.tomorrow)
end
let(:title) { "Sprint 1" }
let(:container) { sprint }
let(:list_id) { "sprint_1_list" }
let(:count) { 4 }
let(:menu_button_id) { "sprint_#{sprint.id}_menu-button" }
subject(:rendered_component) do
render_component
end
def render_component(&)
render_inline(described_class.new(title:, container:, list_id:, count:), &)
end
describe "kwargs-only render" do
it "renders the title in the collapsible header" do
expect(rendered_component).to have_heading "Sprint 1", level: 4
end
it "renders the count badge" do
expect(rendered_component).to have_css ".Counter", text: "4"
end
it "passes the provided list id to the collapsible trigger" do
expect(rendered_component).to have_css ".CollapsibleHeader-triggerArea", aria: { controls: "sprint_1_list" }
end
it "uses the work-package-count aria label on the count badge" do
expect(rendered_component).to have_css ".Counter", text: "4", aria: { label: "4 work packages" }
end
end
describe ":description slot" do
subject(:rendered_component) do
render_component do |header|
header.with_description { "extra-bit" }
end
end
it "renders inside the description region" do
expect(rendered_component).to have_text("extra-bit")
end
end
describe ":actions slots" do
subject(:rendered_component) do
render_component do |header|
header.with_action_button(id: "start-btn", scheme: :primary) { "Start" }
header.with_action_button(id: "finish-btn", scheme: :invisible) { "Finish" }
end
end
it "renders buttons into the actions grid area" do
expect(rendered_component).to have_button "Start"
expect(rendered_component).to have_button "Finish"
end
end
describe ":menu slot" do
subject(:rendered_component) do
render_component do |header|
header.with_menu(**menu_arguments) { |menu| menu.with_item(label: "Edit", href: "/x") }
end
end
let(:count) { 1 }
let(:menu_arguments) { {} }
it "renders an action-menu" do
expect(rendered_component).to have_element :"action-menu"
end
it "uses the standard kebab accessible label" do
expect(rendered_component).to have_button menu_button_id, accessible_name: "Open menu"
end
it "defaults menu_id to dom_target(container, :menu)" do
expect(rendered_component).to have_button menu_button_id
end
it "applies the hide-when-print class" do
expect(rendered_component).to have_element :"action-menu", class: "hide-when-print"
end
context "when a custom aria label is provided" do
let(:menu_arguments) { { button_aria_label: "Sprint actions" } }
it "uses the custom label" do
expect(rendered_component).to have_button menu_button_id, accessible_name: "Sprint actions"
end
end
end
end
@@ -1,163 +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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::WorkPackageCardListComponent::Item, type: :component do
shared_let(:type_feature) { create(:type_feature) }
shared_let(:default_status) { create(:default_status) }
shared_let(:default_priority) { create(:default_priority) }
shared_let(:user) { create(:admin) }
current_user { user }
shared_let(:project) { create(:project, types: [type_feature]) }
let(:container) { project }
let(:params) { {} }
let(:work_package) do
create(:work_package,
project:,
type: type_feature,
status: default_status,
priority: default_priority,
subject: "Card subject",
story_points: 5,
position: 1)
end
let(:item) do
described_class.new(work_package:, project:, container:, params:, current_user: user)
end
let(:draggable_item_class) do
stub_const(
"DraggableWorkPackageCardListItem",
Class.new(described_class) do
private
def draggable?
true
end
def draggable_data
{
draggable_id: work_package.id,
draggable_type: "work_package",
drop_url: "/drop"
}
end
end
)
end
describe "#row_args" do
it "can be passed to a BorderBox row" do
rendered = render_inline(Primer::Beta::BorderBox.new) do |box|
box.with_row(**item.row_args) do
"row body"
end
end
expect(rendered).to have_css(
".Box-row#work_package_#{work_package.id}",
text: "row body"
)
end
it "supplies the work-package row attributes" do
expect(item.row_args).to include(
id: "work_package_#{work_package.id}",
tabindex: 0
)
expect(item.row_args[:classes]).to include(
"Box-row--hover-blue",
"Box-row--focus-gray",
"Box-row--clickable"
)
expect(item.row_args[:data][:test_selector]).to eq("work-package-#{work_package.id}")
end
it "lets caller-supplied data override default row data" do
item = described_class.new(
work_package:,
project:,
container:,
params:,
current_user: user,
data: {
story: false,
test_selector: "custom-work-package-row"
}
)
expect(item.row_args[:data]).to include(
story: false,
test_selector: "custom-work-package-row"
)
end
it "does not include Backlogs row wiring" do
expect(item.row_args[:classes]).not_to include("Box-row--draggable")
expect(item.row_args[:data]).not_to include(
:controller,
:draggable_id,
:drop_url,
:backlogs__story_split_url_value
)
end
it "supports generic draggable row data from subclasses" do
item = draggable_item_class.new(work_package:, project:, container:, params:, current_user: user)
expect(item.row_args[:classes]).to include("Box-row--draggable")
expect(item.row_args[:data]).to include(
draggable_id: work_package.id,
draggable_type: "work_package",
drop_url: "/drop"
)
end
end
describe "#card" do
subject(:rendered_card) { render_inline(item.card) }
it "builds the visual card without deriving a menu src" do
expect(rendered_card).to have_no_element "include-fragment"
end
it "returns the same card instance across calls" do
expect(item.card).to equal(item.card)
end
it "forwards metric content to the visual card" do
item.with_metric { "Forwarded metric" }
expect(rendered_card).to have_text("Forwarded metric")
end
end
end
@@ -1,555 +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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::WorkPackageCardListComponent, type: :component do
shared_let(:type_feature) { create(:type_feature) }
shared_let(:default_status) { create(:default_status) }
shared_let(:default_priority) { create(:default_priority) }
shared_let(:user) { create(:admin) }
current_user { user }
shared_let(:project) { create(:project, types: [type_feature]) }
shared_let(:sprint) do
create(:sprint, project:, name: "Sprint 1",
start_date: Date.yesterday, finish_date: Date.tomorrow)
end
shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") }
let(:container) { sprint }
let(:drag_and_drop) { nil }
let(:item_component_klass) { described_class::Item }
let(:params) { {} }
let(:work_packages) { [] }
let(:system_arguments) { {} }
let(:header_arguments) { nil }
let(:footer_content) { nil }
let(:custom_item_component_class) do
stub_const(
"CustomWorkPackageCardListItem",
Class.new(ApplicationComponent) do
def initialize(
work_package:,
project:,
container:,
params:,
current_user: User.current,
**system_arguments
)
super()
@work_package = work_package
@params = params
@context = [project, container, current_user]
@system_arguments = system_arguments
end
def row_args
data = @system_arguments.fetch(:data, {}).merge(
params: @params.to_query,
context_size: @context.size
)
@system_arguments.merge(
id: "custom_work_package_#{@work_package.id}",
data:
)
end
def card
CustomWorkPackageCardListItemCard.new(subject: @work_package.subject)
end
def render? = false
end
)
end
subject(:rendered_component) do
render_component(work_packages:, container:, drag_and_drop:, system_arguments:)
end
def render_component(work_packages:, container:, drag_and_drop:, system_arguments:)
component_arguments = {
work_packages:,
project:,
container:,
drag_and_drop:,
item_component_klass:,
params:,
current_user: user,
**system_arguments
}
render_inline(
described_class.new(**component_arguments)
) do |box|
box.with_header(**header_arguments) if header_arguments
box.with_empty_state(title: "Sprint 1 is empty", description: "Drag work packages here")
box.with_footer { footer_content } if footer_content
end
end
before do
stub_const(
"CustomWorkPackageCardListItemCard",
Class.new(ApplicationComponent) do
def initialize(subject:)
super()
@subject = subject
end
def call
tag.span("custom #{@subject}")
end
end
)
end
describe "Box shell" do
it_behaves_like "rendering Box", row_count: 1, header: false, footer: false
it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty"
end
describe "container-derived attributes" do
context "when container is a Sprint" do
let(:container) { sprint }
it "uses dom_target(sprint) as the box id" do
expect(rendered_component).to have_css(".Box#sprint_#{sprint.id}")
end
it "uses dom_target(sprint, :list) for the collapsible BorderBox body" do
expect(rendered_component).to have_css("ul#sprint_#{sprint.id}_list")
end
it "does not emit drag-and-drop data by default" do
expect(rendered_component).to have_no_css(".Box[data-generic-drag-and-drop-target]")
expect(rendered_component).to have_no_css(".Box[data-target-id]")
expect(rendered_component).to have_no_css(".Box[data-target-allowed-drag-type]")
end
context "with drag_and_drop configured" do
let(:drag_and_drop) do
{ target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" }
end
it "uses the configured drag-and-drop data" do
expect(rendered_component).to have_css(".Box") do |box|
expect(box["data-generic-drag-and-drop-target"]).to eq("container")
expect(box["data-target-container-accessor"]).to eq(":scope > ul")
expect(box["data-target-id"]).to eq("sprint:#{sprint.id}")
expect(box["data-target-allowed-drag-type"]).to eq("story")
end
end
end
it "does not emit a default test selector" do
expect(rendered_component).to have_no_css(".Box[data-test-selector]")
end
end
context "when container is a BacklogBucket" do
let(:container) { backlog_bucket }
it "uses dom_target(backlog_bucket) as the box id" do
expect(rendered_component).to have_css(".Box#backlog_bucket_#{backlog_bucket.id}")
end
it "uses dom_target(backlog_bucket, :list) for the collapsible BorderBox body" do
expect(rendered_component).to have_css("ul#backlog_bucket_#{backlog_bucket.id}_list")
end
it "does not emit a default test selector" do
expect(rendered_component).to have_no_css(".Box[data-test-selector]")
end
end
context "when container is a Symbol" do
let(:container) { :inbox }
it "uses dom_target(container) as the box id" do
expect(rendered_component).to have_css(".Box#inbox")
end
it "uses dom_target(container, :list) for the list id" do
expect(rendered_component).to have_css("ul#inbox_list")
end
end
context "when container is a String" do
let(:container) { "custom_box" }
it "uses dom_target(container) as the box id" do
expect(rendered_component).to have_css(".Box#custom_box")
end
it "uses dom_target(container, :list) for the list id" do
expect(rendered_component).to have_css("ul#custom_box_list")
end
end
context "when container is a model class" do
let(:container) { Project }
it "uses dom_target(container) as the box id" do
expect(rendered_component).to have_css(".Box#project")
end
it "uses dom_target(container, :list) for the list id" do
expect(rendered_component).to have_css("ul#project_list")
end
end
context "when data[:test_selector] is provided by the caller" do
let(:system_arguments) { { data: { test_selector: "custom-sprint-box" } } }
it "passes the custom test selector through" do
expect(rendered_component).to have_css(".Box[data-test-selector='custom-sprint-box']")
end
end
end
describe ":header slot" do
context "when no header is supplied" do
it "renders no Box-header" do
expect(rendered_component).to have_no_css(".Box-header")
end
end
context "when a header is supplied" do
let(:header_arguments) { { title: "Sprint 1", count: 0 } }
it_behaves_like "rendering Box", row_count: 1, header: true, footer: false
it "renders the provided title" do
expect(rendered_component).to have_heading "Sprint 1", level: 4
end
it "uses dom_target(container, :header) as the header row id" do
expect(rendered_component).to have_css(".Box-header#sprint_#{sprint.id}_header")
end
end
end
describe "fold state in the rendered header" do
let(:header_arguments) { { title: "Sprint 1", count: 0 } }
context "when the user prefers closed folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "closed"
end
it "renders the header as collapsed" do
expect(rendered_component).to have_css ".CollapsibleHeader-triggerArea", aria: { expanded: "false" }
end
end
context "when the user prefers open folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "open"
end
it "renders the header as expanded" do
expect(rendered_component).to have_css ".CollapsibleHeader-triggerArea", aria: { expanded: "true" }
end
end
end
describe ":empty_state slot" do
it "requires the empty_state slot" do
expect do
render_inline(described_class.new(work_packages: [], project:, container: sprint, current_user: user)) do |box|
box.with_footer { "" }
end
end.to raise_error(ArgumentError, /empty_state slot is required when no work package items are rendered/)
end
it "renders the blankslate when work_packages is empty" do
expect(rendered_component).to have_text("Sprint 1 is empty")
expect(rendered_component).to have_text("Drag work packages here")
end
context "when there are work packages" do
let(:work_packages) do
[
create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1)
]
end
it "does not render the blankslate" do
expect(rendered_component).to have_no_css(".blankslate")
end
end
end
describe ":footer slot" do
let(:footer_content) { "footer-content" }
it "renders the footer row when supplied" do
expect(rendered_component).to have_text("footer-content")
end
end
describe "items collection" do
let(:work_packages) do
[
create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1),
create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 2)
]
end
it_behaves_like "rendering Box", row_count: 2, header: false, footer: false
it "renders one row per work package" do
expect(rendered_component).to have_text("WP A")
expect(rendered_component).to have_text("WP B")
end
it "applies the card row attributes in the rendered HTML" do
work_package = work_packages.first
expect(rendered_component).to have_css(
".Box-row#work_package_#{work_package.id}.Box-row--clickable[data-test-selector='work-package-#{work_package.id}']"
)
end
it "does not include Backlogs row wiring by default" do
expect(rendered_component).to have_css(".Box-row", count: 2)
expect(rendered_component).to have_no_css(".Box-row[data-controller='backlogs--story']")
expect(rendered_component).to have_no_css(".Box-row[data-drop-url]")
expect(rendered_component).to have_no_css(".Box-row[data-backlogs--story-split-url-value]")
end
context "with an item_component_klass" do
let(:item_component_klass) { custom_item_component_class }
it "uses the configured item class for automatically built items" do
expect(rendered_component).to have_css(
".Box-row#custom_work_package_#{work_packages.first.id}",
text: "custom WP A"
)
expect(rendered_component).to have_css(
".Box-row#custom_work_package_#{work_packages.second.id}",
text: "custom WP B"
)
end
end
end
describe ":work_package_item slot" do
let(:work_packages) do
[
create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1),
create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 2)
]
end
let(:params) { { all: 1 } }
let(:slot_work_package) { work_packages.first }
def render_with_manual_item
render_inline(
described_class.new(work_packages: [], project:, container:, params:, current_user: user)
) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_work_package_item(
work_package: slot_work_package,
component_klass: custom_item_component_class,
data: { source: "slot" }
)
end
end
it "builds rows with the configured item component class" do
rendered = render_with_manual_item
expect(rendered).to have_css(".Box-row#custom_work_package_#{work_packages.first.id}", text: "custom WP A")
expect(rendered).to have_css(".Box-row[data-source='slot']")
expect(rendered).to have_css(".Box-row[data-params='all=1']")
end
it "does not also build automatic work package rows" do
rendered = render_inline(
described_class.new(work_packages:, project:, container:, params:, current_user: user)
) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_work_package_item(work_package: slot_work_package)
end
expect(rendered).to have_css(".Box-row", count: 1)
expect(rendered).to have_text("WP A")
expect(rendered).to have_no_text("WP B")
end
it "uses caller-provided metric content for manual work package items" do
rendered = render_inline(
described_class.new(project:, container:, params:, current_user: user)
) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_work_package_item(work_package: slot_work_package) do |item|
item.with_metric { "manual metric" }
end
end
expect(rendered).to have_text("manual metric")
end
it "exposes build_item for building an item without adding it to the box" do
component = described_class.new(work_packages: [], project:, container:, params:, current_user: user)
item = component.build_item(
work_package: slot_work_package,
component_klass: custom_item_component_class,
data: { source: "builder" }
)
expect(item.row_args).to include(
id: "custom_work_package_#{slot_work_package.id}",
data: { params: "all=1", context_size: 3, source: "builder" }
)
end
end
describe ":empty_item slot" do
it "renders a caller-provided empty item row" do
rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box|
box.with_empty_item(data: { test_selector: "manual-empty-item" }) do
"Nothing to show"
end
end
expect(rendered).to have_css(
".Box-row[data-empty-list-item='true'][data-test-selector='manual-empty-item']",
text: "Nothing to show"
)
end
it "raises when combined with automatic work packages" do
work_package = create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1)
expect do
render_inline(
described_class.new(work_packages: [work_package], project:, container:, current_user: user)
) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_empty_item { "Nothing to show" }
end
end.to raise_error(ArgumentError, /empty_item cannot be combined with work_packages/)
end
it "raises when combined with manual work package items" do
work_package = create(:work_package, project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1)
expect do
render_inline(described_class.new(project:, container:, current_user: user)) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_work_package_item(work_package:)
box.with_empty_item { "Nothing to show" }
end
end.to raise_error(ArgumentError, /empty_item cannot be combined with other items/)
end
it "raises when combined with generic items" do
expect do
render_inline(described_class.new(project:, container:, current_user: user)) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_item { "Manual item" }
box.with_empty_item { "Nothing to show" }
end
end.to raise_error(ArgumentError, /empty_item cannot be combined with other items/)
end
end
describe ":item slot" do
let(:work_packages) do
[
create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 1),
create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status,
priority: default_priority, sprint:, position: 2)
]
end
it "renders caller-provided content with caller-provided item arguments" do
rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_item(
id: "manual-item",
scheme: :neutral,
data: { test_selector: "manual-item" }
) do
"Manual item"
end
end
expect(rendered).to have_css(
".Box-row#manual-item[data-test-selector='manual-item']",
text: "Manual item"
)
end
it "can be interleaved with work package item rows" do
rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_work_package_item(work_package: work_packages.first)
box.with_item(id: "manual-item") { "Manual item" }
box.with_work_package_item(work_package: work_packages.second)
end
expect(rendered).to have_css(".Box-row", count: 3)
expect(rendered).to have_css("li.Box-row:nth-child(1)", text: "WP A")
expect(rendered).to have_css("li.Box-row:nth-child(2)#manual-item", text: "Manual item")
expect(rendered).to have_css("li.Box-row:nth-child(3)", text: "WP B")
end
it "does not build automatic work package rows when manual rows are supplied" do
rendered = render_inline(
described_class.new(work_packages:, project:, container:, current_user: user)
) do |box|
box.with_empty_state(title: "empty", description: "drag here")
box.with_item(id: "manual-item") { "Manual item" }
end
expect(rendered).to have_css(".Box-row", count: 1)
expect(rendered).to have_text("Manual item")
expect(rendered).to have_no_text("WP A")
expect(rendered).to have_no_text("WP B")
end
end
end