From 39b51741d0369a3e2caa38227432e65792b9d723 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 10 May 2026 20:58:23 +0200 Subject: [PATCH] [#74684] Extract BorderBoxListComponent Add the generic OpPrimer BorderBox list shell and the OpenProject::Common wrapper for work-package card rows. Move shared header, menu, item, empty-state, footer, and container-derived id handling into the list components, keep work-package-specific row support out of OpPrimer, and update backlogs callers and component specs. --- app/components/_index.sass | 2 +- .../border_box_list_component.html.erb} | 16 +- .../op_primer/border_box_list_component.rb | 146 +++++++++++ .../op_primer/border_box_list_component.sass | 34 +++ .../border_box_list_component}/empty_item.rb | 35 +-- .../border_box_list_component/footer.rb} | 36 ++- .../border_box_list_component/has_menu.rb | 80 ++++++ .../border_box_list_component/header.html.erb | 62 +++++ .../border_box_list_component/header.rb | 112 +++++++++ .../border_box_list_component/item.html.erb | 27 +++ .../border_box_list_component/item.rb | 32 +-- .../common/border_box_list_component.rb | 141 +++++++++++ .../work_package_card_list_component.rb | 195 ++++----------- .../header.html.erb | 74 ------ .../header.rb | 79 ------ .../header.sass | 40 --- .../work_package_card_list_component/item.rb | 10 +- .../border_box_list_component_preview.rb | 83 +++++++ .../empty.html.erb | 9 + .../border_box_list_component_preview.rb | 76 ++++++ ...ork_package_card_list_component_preview.rb | 17 +- .../backlogs/inbox_component.html.erb | 51 ++-- .../border_box_list_component/header_spec.rb | 155 ++++++++++++ .../border_box_list_component_spec.rb | 228 ++++++++++++++++++ .../common/border_box_list_component_spec.rb | 203 ++++++++++++++++ .../header_spec.rb | 135 ----------- .../work_package_card_list_component_spec.rb | 207 ++-------------- 27 files changed, 1517 insertions(+), 768 deletions(-) rename app/components/{open_project/common/work_package_card_list_component.html.erb => op_primer/border_box_list_component.html.erb} (79%) create mode 100644 app/components/op_primer/border_box_list_component.rb create mode 100644 app/components/op_primer/border_box_list_component.sass rename app/components/{open_project/common/work_package_card_list_component => op_primer/border_box_list_component}/empty_item.rb (71%) rename app/components/{open_project/common/work_package_card_list_component/content_item.rb => op_primer/border_box_list_component/footer.rb} (71%) create mode 100644 app/components/op_primer/border_box_list_component/has_menu.rb create mode 100644 app/components/op_primer/border_box_list_component/header.html.erb create mode 100644 app/components/op_primer/border_box_list_component/header.rb create mode 100644 app/components/op_primer/border_box_list_component/item.html.erb rename spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb => app/components/op_primer/border_box_list_component/item.rb (64%) create mode 100644 app/components/open_project/common/border_box_list_component.rb delete mode 100644 app/components/open_project/common/work_package_card_list_component/header.html.erb delete mode 100644 app/components/open_project/common/work_package_card_list_component/header.rb delete mode 100644 app/components/open_project/common/work_package_card_list_component/header.sass create mode 100644 lookbook/previews/op_primer/border_box_list_component_preview.rb create mode 100644 lookbook/previews/op_primer/border_box_list_component_preview/empty.html.erb create mode 100644 lookbook/previews/open_project/common/border_box_list_component_preview.rb create mode 100644 spec/components/op_primer/border_box_list_component/header_spec.rb create mode 100644 spec/components/op_primer/border_box_list_component_spec.rb create mode 100644 spec/components/open_project/common/border_box_list_component_spec.rb delete mode 100644 spec/components/open_project/common/work_package_card_list_component/header_spec.rb diff --git a/app/components/_index.sass b/app/components/_index.sass index cd21a4285f84..321450d8b674 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,5 +1,6 @@ @import "enterprise_edition/banner_component" @import "filter/filters_component" +@import "op_primer/border_box_list_component" @import "op_primer/border_box_table_component" @import "op_primer/full_page_prompt_component" @import "op_primer/form_helpers" @@ -12,7 +13,6 @@ @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" diff --git a/app/components/open_project/common/work_package_card_list_component.html.erb b/app/components/op_primer/border_box_list_component.html.erb similarity index 79% rename from app/components/open_project/common/work_package_card_list_component.html.erb rename to app/components/op_primer/border_box_list_component.html.erb index 7aa5bdb62fe5..896fd86b0053 100644 --- a/app/components/open_project/common/work_package_card_list_component.html.erb +++ b/app/components/op_primer/border_box_list_component.html.erb @@ -29,25 +29,19 @@ 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 %> - <% items.each do |item| %> - <% border_box.with_row(**item.row_args) do %> - <%= render(item.card) %> - <% end %> + <% items.each do |item| %> + <% border_box.with_row(**item.row_args) do %> + <%= item %> <% end %> <% end %> <% if footer? %> - <% border_box.with_row(scheme: :neutral) do %> + <% border_box.with_footer(**footer.footer_args) do %> <%= footer %> <% end %> <% end %> diff --git a/app/components/op_primer/border_box_list_component.rb b/app/components/op_primer/border_box_list_component.rb new file mode 100644 index 000000000000..5deb3a958ada --- /dev/null +++ b/app/components/op_primer/border_box_list_component.rb @@ -0,0 +1,146 @@ +# 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 OpPrimer + # Static BorderBox-backed list shell for callers that need ordered header, + # item, empty-item, and footer content without list behavior such as sorting + # or reordering. + class BorderBoxListComponent < ApplicationComponent + # @!parse + # # Adds a structured header rendered through the underlying BorderBox + # # header slot, with optional count, description, actions, and menu. + # # + # # @param title [String] header title. + # # @param count [Integer, nil] optional count badge. + # # @param list_id [String, nil] id of the collapsible BorderBox list body. + # # @param collapsed [Boolean] whether the collapsible header starts closed. + # # @param count_aria_label [String, nil] accessible label for the count badge. + # # @param system_arguments [Hash] forwarded to `Header`, whose `row_args` + # # are forwarded to `Primer::Beta::BorderBox#with_header`. + # def with_header(title:, **system_arguments, &block) + # end + renders_one :header, ->(**system_arguments) { + system_arguments[:list_id] ||= list_id + + Header.new(**system_arguments) + } + + # @!parse + # # Adds an arbitrary content row. + # # + # # @param component_klass [Class] class to use instead of the default + # # `Item`. The class must implement `row_args` for BorderBox row + # # arguments and render its own content. + # # @param system_arguments [Hash] forwarded to `component_klass` or + # # `Primer::Beta::BorderBox#with_row`. + # def with_item(component_klass: Item, **system_arguments, &block) + # end + # + # @!parse + # # Adds an empty-state content row with `data-empty-list-item="true"`. + # # + # # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_row`. + # def with_empty_item(**system_arguments, &block) + # end + renders_many :items, types: { + item: { + renders: ->(component_klass: Item, **system_arguments) { + component_klass.new(**system_arguments) + }, + as: :item + }, + empty_item: { + renders: ->(**system_arguments) { + EmptyItem.new(**system_arguments) + }, + as: :empty_item + } + } + + # @!parse + # # Gets the configured list items. + # # + # # This is intentionally public so wrapper components can delegate item + # # access while keeping underlying BorderBox rows as an internal detail. + # # + # # @return [Array] + # def items + # end + + # @!parse + # # Adds a footer below the list body. + # # + # # When the parent BorderBox has a `list_id`, the footer receives a + # # stable default id derived from that list id unless an explicit id is + # # provided. + # # + # # @param system_arguments [Hash] forwarded to `Footer`, whose + # # `footer_args` are forwarded to `Primer::Beta::BorderBox#with_footer`. + # def with_footer(**system_arguments, &block) + # end + renders_one :footer, ->(**system_arguments) { + system_arguments[:id] ||= dom_target(list_id, :footer) if list_id + + Footer.new(**system_arguments) + } + + # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox`. + def initialize(**system_arguments) + super() + + @system_arguments = system_arguments + end + + def before_render + content + apply_header_defaults! + end + + def render? + header? || items.any? || footer? + end + + private + + def apply_header_defaults! + return unless header? + + header.collapsible_id = [list_id, footer_id].compact.join(" ") + end + + def list_id + @system_arguments[:list_id] + end + + def footer_id + footer&.id + end + end +end diff --git a/app/components/op_primer/border_box_list_component.sass b/app/components/op_primer/border_box_list_component.sass new file mode 100644 index 000000000000..f69cd28a7f78 --- /dev/null +++ b/app/components/op_primer/border_box_list_component.sass @@ -0,0 +1,34 @@ +//-- 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 + +.op-border-box-list-item + display: grid + grid-template-columns: minmax(0, 1fr) auto + align-items: start + gap: var(--stack-gap-normal) + + &--content + min-width: 0 + + &--menu + margin-top: -6px diff --git a/app/components/open_project/common/work_package_card_list_component/empty_item.rb b/app/components/op_primer/border_box_list_component/empty_item.rb similarity index 71% rename from app/components/open_project/common/work_package_card_list_component/empty_item.rb rename to app/components/op_primer/border_box_list_component/empty_item.rb index c68491095325..8a04863b7e49 100644 --- a/app/components/open_project/common/work_package_card_list_component/empty_item.rb +++ b/app/components/op_primer/border_box_list_component/empty_item.rb @@ -23,28 +23,33 @@ # # 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 +module OpPrimer + class BorderBoxListComponent + class EmptyItem < ApplicationComponent + include Primer::AttributesHelper - def row_args - system_arguments = @system_arguments.deep_dup - system_arguments[:data] = merge_data( - { data: { empty_list_item: true } }, - system_arguments - ) + def initialize(**system_arguments) + super() + + @system_arguments = system_arguments + end + + def row_args + system_arguments = @system_arguments.deep_dup + system_arguments[:data] = merge_data( + { data: { empty_list_item: true } }, system_arguments - end + ) + system_arguments + end - def empty_item? = true + def call + content end end end diff --git a/app/components/open_project/common/work_package_card_list_component/content_item.rb b/app/components/op_primer/border_box_list_component/footer.rb similarity index 71% rename from app/components/open_project/common/work_package_card_list_component/content_item.rb rename to app/components/op_primer/border_box_list_component/footer.rb index a6b27d116d8b..cf085786a589 100644 --- a/app/components/open_project/common/work_package_card_list_component/content_item.rb +++ b/app/components/op_primer/border_box_list_component/footer.rb @@ -23,35 +23,29 @@ # # 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 - def initialize(**system_arguments) - super() +module OpPrimer + class BorderBoxListComponent + class Footer < ApplicationComponent + attr_reader :id - @system_arguments = system_arguments - end + def initialize(**system_arguments) + super() - def row_args - @system_arguments.deep_dup - end - - def card - self - end + @id = system_arguments[:id] + @system_arguments = system_arguments + end - def empty_item? = false + def footer_args + @system_arguments.deep_dup + end - def call - content - end + def call + content end end end diff --git a/app/components/op_primer/border_box_list_component/has_menu.rb b/app/components/op_primer/border_box_list_component/has_menu.rb new file mode 100644 index 000000000000..6adff9f4a168 --- /dev/null +++ b/app/components/op_primer/border_box_list_component/has_menu.rb @@ -0,0 +1,80 @@ +# 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 OpPrimer + class BorderBoxListComponent + # Adds the standard list action menu slot used by list headers and items. + module HasMenu + extend ActiveSupport::Concern + include Primer::ClassNameHelper + + 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 + + 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 || default_menu_id, + anchor_align: :end, + **system_arguments + ) + menu.with_show_button( + scheme: :invisible, + icon: :"kebab-horizontal", + "aria-label": button_aria_label || I18n.t(:label_actions), + tooltip_direction: :se + ) + menu + end + + def default_menu_id + self.class.generate_id + end + end + end +end diff --git a/app/components/op_primer/border_box_list_component/header.html.erb b/app/components/op_primer/border_box_list_component/header.html.erb new file mode 100644 index 000000000000..b963397ddfcd --- /dev/null +++ b/app/components/op_primer/border_box_list_component/header.html.erb @@ -0,0 +1,62 @@ +<%# -- 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: :h4) { title } %> + <% if count %> + <% count_arguments = { + scheme: :primary, + count: count, + round: true, + limit: 1_000, + hide_if_zero: true + } %> + <% if count_aria_label %> + <% count_arguments[:aria] = { label: count_aria_label, live: "polite" } %> + <% end %> + <% collapsible.with_count(**count_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 %> diff --git a/app/components/op_primer/border_box_list_component/header.rb b/app/components/op_primer/border_box_list_component/header.rb new file mode 100644 index 000000000000..9378801dac56 --- /dev/null +++ b/app/components/op_primer/border_box_list_component/header.rb @@ -0,0 +1,112 @@ +# 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 OpPrimer + class BorderBoxListComponent + # Structured header for {BorderBoxListComponent}. + # + # The header renders through `Primer::Beta::BorderBox#with_header` and + # wraps the supplied title, count, description, actions, and menu in an + # `OpPrimer::CollapsibleHeaderComponent`. + class Header < ApplicationComponent + include OpPrimer::ComponentHelpers + include HasMenu + + # @!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, + :list_id, + :collapsed, + :count_aria_label + + attr_writer :collapsible_id + + # @param title [String] header title. + # @param count [Integer, nil] optional count badge. + # @param list_id [String, nil] id of the collapsible list body. + # @param collapsed [Boolean] whether the collapsible header starts closed. + # @param count_aria_label [String, nil] accessible label for the count badge. + # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_header`. + def initialize( + title:, + count: nil, + list_id: nil, + collapsed: false, + count_aria_label: nil, + **system_arguments + ) + super() + + @title = title + @count = count + @list_id = list_id + @collapsible_id = list_id + @collapsed = collapsed + @count_aria_label = count_aria_label + @system_arguments = system_arguments + end + + # @return [Hash] arguments forwarded to `Primer::Beta::BorderBox#with_header`. + def row_args + @system_arguments.deep_dup + end + + def collapsible_id + @collapsible_id.presence + end + + private + + def default_menu_id + list_id ? "#{list_id}_menu" : super + end + end + end +end diff --git a/app/components/op_primer/border_box_list_component/item.html.erb b/app/components/op_primer/border_box_list_component/item.html.erb new file mode 100644 index 000000000000..09574aa8cd5f --- /dev/null +++ b/app/components/op_primer/border_box_list_component/item.html.erb @@ -0,0 +1,27 @@ +<%# -- 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. + +++ %> + +<% if menu? %> +
+
+ <%= content %> +
+
+ <%= menu %> +
+
+<% else %> + <%= content %> +<% end %> diff --git a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb b/app/components/op_primer/border_box_list_component/item.rb similarity index 64% rename from spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb rename to app/components/op_primer/border_box_list_component/item.rb index 0995b5d41a6a..e8819f6123a2 100644 --- a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb +++ b/app/components/op_primer/border_box_list_component/item.rb @@ -23,33 +23,25 @@ # # 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. #++ -require "rails_helper" +module OpPrimer + class BorderBoxListComponent + class Item < ApplicationComponent + include HasMenu -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 + def initialize(**system_arguments) + super() - 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" - } - ) + @system_arguments = system_arguments + end - expect(item.row_args[:data]).to include( - empty_list_item: false, - test_selector: "custom-empty-row" - ) + def row_args + @system_arguments.deep_dup + end end end end diff --git a/app/components/open_project/common/border_box_list_component.rb b/app/components/open_project/common/border_box_list_component.rb new file mode 100644 index 000000000000..955e54e24d48 --- /dev/null +++ b/app/components/open_project/common/border_box_list_component.rb @@ -0,0 +1,141 @@ +# 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 + # OpenProject-specialized BorderBox list wrapper for manual item + # composition. Generic slots are delegated to the underlying OpPrimer list; + # domain-specific item slots live here. + class BorderBoxListComponent < ApplicationComponent + # @!parse + # # Adds a structured header delegated to `OpPrimer::BorderBoxListComponent`. + # # + # # Header arguments are consumed by `OpPrimer::BorderBoxListComponent::Header`, + # # whose row arguments are forwarded to `Primer::Beta::BorderBox#with_header`. + # def with_header(title:, **system_arguments, &block) + # end + # + # # Adds an arbitrary row delegated to `OpPrimer::BorderBoxListComponent`. + # # + # # @param component_klass [Class] component used for the row content. + # # @param system_arguments [Hash] forwarded to the item component or + # # `Primer::Beta::BorderBox#with_row`. + # def with_item(component_klass: OpPrimer::BorderBoxListComponent::Item, **system_arguments, &block) + # end + # + # # Adds an empty-state row delegated to `OpPrimer::BorderBoxListComponent`. + # # + # # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_row`. + # def with_empty_item(**system_arguments, &block) + # end + # + # # Adds a footer delegated to `OpPrimer::BorderBoxListComponent`. + # # + # # @param system_arguments [Hash] forwarded to + # # `Primer::Beta::BorderBox#with_footer`. + # def with_footer(**system_arguments, &block) + # end + # + # # Returns the delegated OpPrimer list items. + # # + # # @return [Array] + # def items + # end + attr_reader :container, + :list, + :work_package_item_component_klass + + delegate :with_header, + :with_item, + :with_empty_item, + :with_footer, + :items, + to: :@list + + # @param container [Symbol, String, Class, ApplicationRecord, nil] value + # used with `dom_target` to derive the BorderBox id, list id, and + # default `container:` passed to work-package items. Pass explicit + # `id:` or `list_id:` to override the derived ids. + # @param work_package_item_component_klass [Class] default component used + # by `with_work_package_item`. + # @param system_arguments [Hash] forwarded to `OpPrimer::BorderBoxListComponent`. + def initialize(container: nil, work_package_item_component_klass: WorkPackageCardListComponent::Item, **system_arguments) + super() + + if system_arguments.key?(:work_packages) + raise ArgumentError, "`work_packages:` is not supported by OpenProject::Common::BorderBoxListComponent" + end + + @container = container + apply_container_defaults!(system_arguments) if container + @work_package_item_component_klass = work_package_item_component_klass + @list = OpPrimer::BorderBoxListComponent.new(**system_arguments) + end + + # Adds a work-package row to the delegated OpPrimer list. + # + # The row uses `work_package_item_component_klass:` from the constructor by + # default. Pass `component_klass:` to override the component for a single + # item. The list `container:` and `work_package.project` are supplied as + # default item context unless overridden per item. + # + # @param work_package [WorkPackage] work package rendered by the item component. + # @param component_klass [Class, nil] optional per-item component override. + # @param item_arguments [Hash] forwarded to the work-package item component. + # @return [ViewComponent::Slot] + def with_work_package_item(work_package:, component_klass: nil, **item_arguments, &) + item_arguments[:container] = container unless item_arguments.key?(:container) + item_arguments[:project] = work_package.project unless item_arguments.key?(:project) + + list.with_item( + component_klass: component_klass || work_package_item_component_klass, + work_package:, + **item_arguments, + & + ) + end + + def call + render(list) + end + + private + + def apply_container_defaults!(system_arguments) + system_arguments[:id] ||= dom_target(container) + system_arguments[:list_id] ||= dom_target(container, :list) + end + + def before_render + content + end + end + end +end diff --git a/app/components/open_project/common/work_package_card_list_component.rb b/app/components/open_project/common/work_package_card_list_component.rb index 0f29b85c2681..f3f089230119 100644 --- a/app/components/open_project/common/work_package_card_list_component.rb +++ b/app/components/open_project/common/work_package_card_list_component.rb @@ -34,20 +34,9 @@ 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. + # Renders a `Primer::Beta::Blankslate` when `work_packages` is empty. The + # slot is required for empty collections and is ignored whenever work + # packages are present. # # @param title [String] blankslate heading. # @param description [String, NilClass] optional secondary text. @@ -62,76 +51,20 @@ class WorkPackageCardListComponent < ApplicationComponent blankslate = Primer::Beta::Blankslate.new(**system_arguments) blankslate.with_heading(tag: :h4).with_content(title) - blankslate.with_description_content(description) if description + blankslate.with_description { 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:`. + # # Adds a footer rendered through the underlying + # # `OpenProject::Common::BorderBoxListComponent`. # # # # @param system_arguments [Hash] forwarded to - # # `Primer::Beta::BorderBox#with_row`. - # def with_item(**system_arguments, &block) + # # `Primer::Beta::BorderBox#with_footer`. + # def with_footer(**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 + delegate :with_footer, to: :@list attr_reader :work_packages, :project, @@ -157,7 +90,7 @@ class WorkPackageCardListComponent < ApplicationComponent # @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`. + # `OpenProject::Common::BorderBoxListComponent`. def initialize( project:, container:, @@ -170,97 +103,73 @@ def initialize( ) super() - @work_packages = work_packages + @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! + @list = OpenProject::Common::BorderBoxListComponent.new( + container:, + work_package_item_component_klass: item_component_klass, + **@system_arguments + ) 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. + # Adds a structured header with injected list-level defaults for + # collapsible state, count accessibility, and header DOM id. # - # @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 + # @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. + def with_header(title:, count: nil, &) + @list.with_header( + title:, + count:, + collapsed: folded?, + count_aria_label: count ? t(".header.label_work_package_count", count:) : nil, + id: header_id, + & ) end - private - - def folded? - current_user.pref[:backlogs_versions_default_fold_state] == "closed" + def before_render + content + populate_list! + validate_empty_state! end - def build_automatic_items? - non_empty_items.empty? && work_packages.any? + def call + render(@list) end - def build_automatic_items - @automatic_items = true - - work_packages.each do |work_package| - with_work_package_item(work_package:) - end - end + private - def build_content_item(item_class, **system_arguments, &block) - item_class.new(**system_arguments).tap do |item| - item.with_content(capture(&block)) if block + def populate_list! + if work_packages.empty? + rendered_empty = render(empty_state) if empty_state? + @list.with_empty_item { rendered_empty } if rendered_empty + else + work_packages.each do |work_package| + @list.with_work_package_item(work_package:, project:, params:, current_user:) + end 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 + def folded? + current_user.pref[:backlogs_versions_default_fold_state] == "closed" end def validate_empty_state! - return unless items.empty? && !empty_state? + return unless work_packages.empty? && !empty_state? raise ArgumentError, "empty_state slot is required when no work package items are rendered" end @@ -277,14 +186,6 @@ 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( { @@ -296,8 +197,6 @@ def merge_drag_and_drop_data! 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), diff --git a/app/components/open_project/common/work_package_card_list_component/header.html.erb b/app/components/open_project/common/work_package_card_list_component/header.html.erb deleted file mode 100644 index d57862a113ed..000000000000 --- a/app/components/open_project/common/work_package_card_list_component/header.html.erb +++ /dev/null @@ -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 %> diff --git a/app/components/open_project/common/work_package_card_list_component/header.rb b/app/components/open_project/common/work_package_card_list_component/header.rb deleted file mode 100644 index 3bb6f63f823a..000000000000 --- a/app/components/open_project/common/work_package_card_list_component/header.rb +++ /dev/null @@ -1,79 +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 - class Header < ApplicationComponent - include OpPrimer::ComponentHelpers - - renders_one :description - - renders_many :actions, types: { - button: ->(**system_arguments) do - Primer::Beta::Button.new(**system_arguments) - end - } - - renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do - system_arguments[:classes] = class_names( - system_arguments[:classes], - "hide-when-print" - ) - - menu = Primer::Alpha::ActionMenu.new( - menu_id: menu_id || dom_target(container, :menu), - anchor_align: :end, - **system_arguments - ) - menu.with_show_button( - scheme: :invisible, - icon: :"kebab-horizontal", - "aria-label": button_aria_label || 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 - end - end - end - end -end diff --git a/app/components/open_project/common/work_package_card_list_component/header.sass b/app/components/open_project/common/work_package_card_list_component/header.sass deleted file mode 100644 index 0e618e18a1af..000000000000 --- a/app/components/open_project/common/work_package_card_list_component/header.sass +++ /dev/null @@ -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 diff --git a/app/components/open_project/common/work_package_card_list_component/item.rb b/app/components/open_project/common/work_package_card_list_component/item.rb index ca5c6a661311..271d8519004b 100644 --- a/app/components/open_project/common/work_package_card_list_component/item.rb +++ b/app/components/open_project/common/work_package_card_list_component/item.rb @@ -45,7 +45,7 @@ class Item < ApplicationComponent :params, :current_user - delegate :with_metric, to: :card + delegate :with_metric, :with_menu, to: :card def initialize( work_package:, @@ -81,9 +81,13 @@ def card @card ||= WorkPackageCardComponent.new(work_package:) end - def render? = false + def before_render + content + end - def empty_item? = false + def call + render(card) + end private diff --git a/lookbook/previews/op_primer/border_box_list_component_preview.rb b/lookbook/previews/op_primer/border_box_list_component_preview.rb new file mode 100644 index 000000000000..70cfa628c5a7 --- /dev/null +++ b/lookbook/previews/op_primer/border_box_list_component_preview.rb @@ -0,0 +1,83 @@ +# 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 OpPrimer + # @logical_path OpenProject/Primer + class BorderBoxListComponentPreview < ViewComponent::Preview + def default # rubocop:disable Metrics/AbcSize + render OpPrimer::BorderBoxListComponent.new( + id: "border-box-list-preview", + list_id: "border-box-list-preview-list" + ) do |list| + list.with_header( + title: "Things we're building", + count: 3, + count_aria_label: "3 list items" + ) do |header| + header.with_description { "There's lots to look forward to" } + header.with_action_button 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 do |item| + item.with_menu(button_aria_label: "Item actions") do |menu| + menu.with_item(label: "Archive") do |menu_item| + menu_item.with_leading_visual_icon(icon: :archive) + end + end + + "AI Copilot for Support Teams" + end + list.with_item do |item| + item.with_menu(button_aria_label: "Item actions") do |menu| + menu.with_item(label: "Archive") do |menu_item| + menu_item.with_leading_visual_icon(icon: :archive) + end + end + + "“Just One More Feature” Initiative" + end + + list.with_footer { "Next launch window: October" } + end + end + + def empty + render_with_template + end + end +end diff --git a/lookbook/previews/op_primer/border_box_list_component_preview/empty.html.erb b/lookbook/previews/op_primer/border_box_list_component_preview/empty.html.erb new file mode 100644 index 000000000000..3485b4948aa4 --- /dev/null +++ b/lookbook/previews/op_primer/border_box_list_component_preview/empty.html.erb @@ -0,0 +1,9 @@ +<%= render(OpPrimer::BorderBoxListComponent.new(id: "empty-border-box-list-preview")) do |list| %> + <% list.with_header(title: "Empty list") %> + <% list.with_empty_item do %> + <%= render(Primer::Beta::Blankslate.new) do |blankslate| %> + <% blankslate.with_heading(tag: :h4).with_content("No items") %> + <% blankslate.with_description { "There is nothing to show yet." } %> + <% end %> + <% end %> +<% end %> diff --git a/lookbook/previews/open_project/common/border_box_list_component_preview.rb b/lookbook/previews/open_project/common/border_box_list_component_preview.rb new file mode 100644 index 000000000000..0eb4b77e91fd --- /dev/null +++ b/lookbook/previews/open_project/common/border_box_list_component_preview.rb @@ -0,0 +1,76 @@ +# 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 + def work_package_item # rubocop:disable Metrics/AbcSize + work_packages = WorkPackage.includes(:project).limit(3).to_a + return preview_message("No work packages in the database.") if work_packages.empty? + + render OpenProject::Common::BorderBoxListComponent.new( + container: "open-project-border-box-list-preview", + work_package_item_component_klass: OpenProject::Common::WorkPackageCardListComponent::Item + ) do |list| + list.with_header( + title: "Things we're building", + count: work_packages.size, + count_aria_label: "#{work_packages.size} work packages" + ) do |header| + 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 + + 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 + + private + + def preview_message(text) + render(Primer::Beta::Blankslate.new) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(text) + end + end + end + end +end diff --git a/lookbook/previews/open_project/common/work_package_card_list_component_preview.rb b/lookbook/previews/open_project/common/work_package_card_list_component_preview.rb index fa9bb524768f..d1e7317c039b 100644 --- a/lookbook/previews/open_project/common/work_package_card_list_component_preview.rb +++ b/lookbook/previews/open_project/common/work_package_card_list_component_preview.rb @@ -78,22 +78,7 @@ def inbox 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" } + icon: :"op-backlogs") end end diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index f8a6e7eff2ad..239702043b43 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -28,29 +28,46 @@ 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: { + OpenProject::Common::BorderBoxListComponent.new( + container: inbox_container, + padding: :condensed, + work_package_item_component_klass: Backlogs::WorkPackageCardListItemComponent, + data: { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", target_id: "inbox", - allowed_drag_type: "story" - }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, - params: all_backlogs_params, - current_user:, - data: { test_selector: "backlog-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 - ) %> + <% if visible_work_packages.empty? %> + <% list.with_empty_item do %> + <%= + render( + Primer::Beta::Blankslate.new( + role: "status", + aria: { live: "polite" }, + spacious: true + ) + ) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(t(".blankslate_title")) + blankslate.with_description_content(t(".blankslate_description")) + blankslate.with_visual_icon(icon: :"op-backlogs") + end + %> + <% end %> + <% end %> <% 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, + current_user: + ) %> <% if truncated? && index == TRUNCATE_MIDDLE - 1 %> <% list.with_item( diff --git a/spec/components/op_primer/border_box_list_component/header_spec.rb b/spec/components/op_primer/border_box_list_component/header_spec.rb new file mode 100644 index 000000000000..d4622907af4e --- /dev/null +++ b/spec/components/op_primer/border_box_list_component/header_spec.rb @@ -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. +#++ + +require "rails_helper" + +RSpec.describe OpPrimer::BorderBoxListComponent::Header, type: :component do + let(:title) { "Backlog" } + let(:list_id) { "backlog-list" } + let(:count) { nil } + let(:count_aria_label) { nil } + let(:collapsed) { false } + let(:system_arguments) { {} } + + subject(:rendered_component) do + render_inline( + described_class.new( + title:, + count:, + count_aria_label:, + list_id:, + collapsed:, + **system_arguments + ) + ) + end + + it "renders a title-only collapsible header" do + expect(rendered_component).to have_heading("Backlog", level: 4) + expect(rendered_component).to have_css(".CollapsibleHeader-triggerArea", aria: { controls: "backlog-list" }) + expect(rendered_component).to have_no_css(".Counter") + expect(rendered_component).to have_no_css(".CollapsibleHeader-description") + expect(rendered_component).to have_no_css(".op-border-box-list-header--actions") + expect(rendered_component).to have_no_element(:"action-menu") + end + + context "with a count" do + let(:count) { 4 } + + it "renders a count badge without requiring an aria label" do + expect(rendered_component).to have_css(".Counter", text: "4") + expect(rendered_component).to have_no_css(".Counter[aria-label]") + end + + context "with a count aria label" do + let(:count_aria_label) { "4 items" } + + it "applies the count aria label" do + expect(rendered_component).to have_css(".Counter[aria-label='4 items']", text: "4") + end + end + end + + context "when collapsed" do + let(:collapsed) { true } + + it "renders the collapsed state" do + expect(rendered_component).to have_css(".CollapsibleHeader.CollapsibleHeader--collapsed") + expect(rendered_component).to have_css(".CollapsibleHeader-triggerArea", aria: { expanded: "false" }) + end + end + + context "with a description" do + subject(:rendered_component) do + render_inline(described_class.new(title:, list_id:)) do |header| + header.with_description { "List description" } + end + end + + it "renders the description without requiring actions or a menu" do + expect(rendered_component).to have_css(".CollapsibleHeader-description", text: "List description") + expect(rendered_component).to have_no_css(".op-border-box-list-header--actions") + expect(rendered_component).to have_no_element(:"action-menu") + end + end + + context "with action buttons" do + subject(:rendered_component) do + render_inline(described_class.new(title:, list_id:)) do |header| + header.with_action_button(id: "primary-action", scheme: :primary) { "Start" } + header.with_action_button(id: "secondary-action", scheme: :invisible) { "Finish" } + end + end + + it "renders actions without requiring a description or menu" do + expect(rendered_component).to have_css(".op-border-box-list-header--actions") + expect(rendered_component).to have_button("Start") + expect(rendered_component).to have_button("Finish") + expect(rendered_component).to have_no_css(".CollapsibleHeader-description") + expect(rendered_component).to have_no_element(:"action-menu") + end + end + + context "with a menu" do + let(:menu_arguments) { {} } + + subject(:rendered_component) do + render_inline(described_class.new(title:, list_id:)) do |header| + header.with_menu(**menu_arguments) { |menu| menu.with_item(label: "Edit", href: "/edit") } + end + end + + it "renders a hide-when-print action menu with the generic label" do + expect(rendered_component).to have_element(:"action-menu", class: "hide-when-print") + expect(rendered_component).to have_css("tool-tip", text: I18n.t(:label_actions), visible: :all) + expect(rendered_component).to have_link("Edit", href: "/edit") + end + + context "with a custom button label" do + let(:menu_arguments) { { button_aria_label: "Backlog actions" } } + + it "uses the custom label" do + expect(rendered_component).to have_css("tool-tip", text: "Backlog actions", visible: :all) + end + end + end + + context "with a list_id" do + let(:list_id) { "backlog-list" } + + it "derives a stable menu id from the list_id" do + rendered = render_inline(described_class.new(title: "Backlog", list_id:)) do |header| + header.with_menu { |menu| menu.with_item(label: "Edit", href: "/edit") } + end + + expect(rendered).to have_css("#backlog-list_menu-list", visible: :all) + end + end +end diff --git a/spec/components/op_primer/border_box_list_component_spec.rb b/spec/components/op_primer/border_box_list_component_spec.rb new file mode 100644 index 000000000000..126b577e03f3 --- /dev/null +++ b/spec/components/op_primer/border_box_list_component_spec.rb @@ -0,0 +1,228 @@ +# 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 OpPrimer::BorderBoxListComponent, type: :component do + it_behaves_like "rendering Box", row_count: 1, header: true, footer: true do + subject(:rendered_component) do + render_inline( + described_class.new( + id: "generic-list", + list_id: "generic-list-body", + padding: :condensed, + classes: "custom-list", + data: { test_selector: "generic-list" } + ) + ) do |list| + list.with_header( + title: "List title", + count: 2, + count_aria_label: "2 list items", + list_id: "generic-list-body", + id: "generic-list-header" + ) do |header| + header.with_description { "List description" } + header.with_action_button(size: :small) { "Add item" } + header.with_menu(button_aria_label: "List actions") do |menu| + menu.with_item(label: "Configure") + end + end + list.with_item(id: "plain-item", data: { test_selector: "plain-item" }) { "Plain item" } + list.with_footer { "Footer content" } + end + end + + it "forwards system arguments to the underlying BorderBox" do + expect(rendered_component).to have_css( + ".Box.Box--condensed.custom-list#generic-list[data-test-selector='generic-list']" + ) + expect(rendered_component).to have_css("ul#generic-list-body") + end + + it "renders the structured header with caller-provided arguments" do + expect(rendered_component).to have_css(".Box-header#generic-list-header") + expect(rendered_component).to have_heading("List title", level: 4) + expect(rendered_component).to have_text("List description") + expect(rendered_component).to have_button("Add item") + expect(rendered_component).to have_css("[aria-label='2 list items']") + expect(rendered_component).to have_element(:"action-menu", class: "hide-when-print") + expect(rendered_component).to have_css("tool-tip", text: "List actions", visible: :all) + end + + it "renders arbitrary item content with caller-provided arguments" do + expect(rendered_component).to have_css( + ".Box-row#plain-item[data-test-selector='plain-item']", + text: "Plain item" + ) + expect(rendered_component).to have_no_css("#plain-item .op-border-box-list-item") + end + + it "renders footer content" do + expect(rendered_component).to have_css(".Box-footer", text: "Footer content") + end + + it "derives a stable footer id from the list id" do + expect(rendered_component).to have_css(".Box-footer#generic-list-body_footer") + end + + it "controls both the list body and footer from the collapsible header" do + expect(rendered_component).to have_css( + ".CollapsibleHeader-triggerArea[aria-controls='generic-list-body generic-list-body_footer']" + ) + end + end + + it "renders an item menu" do + rendered = render_inline(described_class.new) do |list| + list.with_item do |item| + item.with_menu(button_aria_label: "Item actions") do |menu| + menu.with_item(label: "Archive") + end + + "Item with actions" + end + end + + expect(rendered).to have_css(".Box-row", text: "Item with actions") + expect(rendered).to have_css(".op-border-box-list-item") + expect(rendered).to have_css(".op-border-box-list-item--content", text: "Item with actions") + expect(rendered).to have_no_css(".op-border-box-list-item--content action-menu") + expect(rendered).to have_css(".op-border-box-list-item--menu action-menu") + expect(rendered).to have_css("tool-tip", text: "Item actions", visible: :all) + end + + it "does not render an empty BorderBox when no slots are provided" do + rendered = render_inline(described_class.new) + + expect(rendered).to have_no_css(".Box") + end + + it "renders an item with a custom component_klass" do + stub_const( + "CustomBorderBoxListItem", + Class.new(ApplicationComponent) do + def initialize(**system_arguments) + super() + @system_arguments = system_arguments + end + + def row_args + @system_arguments.merge(id: "custom-item") + end + + def call + tag.span("Custom rendered item") + end + end + ) + + rendered = render_inline(described_class.new) do |list| + list.with_item(component_klass: CustomBorderBoxListItem) { "ignored" } + end + + expect(rendered).to have_css(".Box-row#custom-item", text: "Custom rendered item") + end + + it "merges empty item data into caller-provided row arguments" do + rendered = render_inline(described_class.new) do |list| + list.with_empty_item(data: { test_selector: "empty-item" }) { "No items" } + end + + expect(rendered).to have_css( + ".Box-row[data-empty-list-item='true'][data-test-selector='empty-item']", + text: "No items" + ) + end + + it "does not expose menu slots on empty items" do + empty_item = nil + + rendered = render_inline(described_class.new) do |list| + list.with_empty_item do |item| + empty_item = item + "No items" + end + end + + expect(rendered).to have_css(".Box-row", text: "No items") + expect(empty_item).not_to respond_to(:with_menu) + end + + it "captures rendered component content in empty items" do + blankslate = render_inline(Primer::Beta::Blankslate.new) do |component| + component.with_heading(tag: :h4).with_content("No items") + component.with_description { "There is nothing to show yet." } + end.to_html + + rendered = render_inline(described_class.new) do |list| + list.with_empty_item do + ActiveSupport::SafeBuffer.new(blankslate) + end + end + + expect(rendered).to have_css(".blankslate h4", text: "No items") + expect(rendered).to have_text("There is nothing to show yet.") + end + + it "renders a footer with caller-provided arguments" do + rendered = render_inline(described_class.new) do |list| + list.with_footer(id: "list-footer", data: { test_selector: "custom-footer" }) { "Footer content" } + end + + expect(rendered).to have_css( + ".Box-footer#list-footer[data-test-selector='custom-footer']", + text: "Footer content" + ) + end + + it "keeps an explicit footer id" do + rendered = render_inline(described_class.new(list_id: "custom-list")) do |list| + list.with_header(title: "Header") + list.with_footer(id: "explicit-footer") { "Footer content" } + end + + expect(rendered).to have_css(".Box-footer#explicit-footer") + expect(rendered).to have_css( + ".CollapsibleHeader-triggerArea[aria-controls='custom-list explicit-footer']" + ) + end + + it "controls only the list body when no footer is present" do + rendered = render_inline(described_class.new(list_id: "list-without-footer")) do |list| + list.with_header(title: "Header") + list.with_item { "Item content" } + end + + expect(rendered).to have_css( + ".CollapsibleHeader-triggerArea[aria-controls='list-without-footer']" + ) + end +end diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb new file mode 100644 index 000000000000..334886fd8441 --- /dev/null +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -0,0 +1,203 @@ +# 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(: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:) } + + let(:default_item_component_class) do + stub_const( + "DefaultBorderBoxListWorkPackageItem", + Class.new(ApplicationComponent) do + delegate :with_metric, :with_menu, to: :card + + def initialize(work_package:, project: nil, container: nil, marker: nil, **system_arguments) + super() + + @work_package = work_package + @project = project + @container = container + @marker = marker + @system_arguments = system_arguments + end + + def row_args + @system_arguments.merge( + id: "default_work_package_#{@work_package.id}", + data: @system_arguments.fetch(:data, {}).merge( + container: @container, + marker: @marker, + project: @project&.id + ) + ) + end + + def card + @card ||= BorderBoxListWorkPackageCard.new(prefix: "default", subject: @work_package.subject) + end + + def before_render + content + end + + def call + render(card) + end + end + ) + end + + let(:override_item_component_class) do + stub_const( + "OverrideBorderBoxListWorkPackageItem", + Class.new(default_item_component_class) do + def row_args + super.merge(id: "override_work_package_#{@work_package.id}") + end + + def card + @card ||= BorderBoxListWorkPackageCard.new(prefix: "override", subject: @work_package.subject) + end + end + ) + end + + before do + stub_const( + "BorderBoxListWorkPackageCard", + 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 + + it_behaves_like "rendering Box", row_count: 3, header: true, footer: true do + subject(:rendered_component) do + render_inline( + described_class.new( + id: "open-project-list", + list_id: "open-project-list-body", + container: "default-container", + work_package_item_component_klass: default_item_component_class + ) + ) do |list| + list.with_header(title: "Header title", id: "open-project-list-header") + list.with_item(id: "manual-item") { "Manual item" } + list.with_work_package_item( + work_package:, + marker: "default", + 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_item_component_class, + container: "override-container", + marker: "override" + ) do |item| + item.with_menu { "Menu content" } + end + list.with_footer { "Footer content" } + end + end + + it "renders generic items through delegated slots" do + expect(rendered_component).to have_css(".Box-header#open-project-list-header", text: "Header title") + expect(rendered_component).to have_css(".Box-row#manual-item", text: "Manual item") + expect(rendered_component).to have_css(".Box-footer", text: "Footer content") + end + + it "uses the constructor-level work package item component by default" do + expect(rendered_component).to have_css( + ".Box-row#default_work_package_#{work_package.id}[data-marker='default'][data-source='slot']", + text: "default Default WP" + ) + end + + it "passes constructor-level container and default project to work package items" do + expect(rendered_component).to have_css( + ".Box-row#default_work_package_#{work_package.id}" \ + "[data-container='default-container'][data-project='#{work_package.project.id}']" + ) + end + + it "allows per-item work package component overrides" do + expect(rendered_component).to have_css( + ".Box-row#override_work_package_#{override_work_package.id}" \ + "[data-container='override-container'][data-marker='override']", + text: "override Override WP" + ) + end + + it "delegates work package item menu customization to the card" do + expect(rendered_component).to have_text("Menu content") + end + + it "captures work package item customization blocks" do + expect(rendered_component).to have_text("Metric content") + end + end + + it "rejects automatic work package collections" do + expect do + described_class.new(work_packages: [work_package]) + end.to raise_error(ArgumentError, /work_packages/) + end + + it "derives BorderBox ids from container when ids are not provided" do + rendered = render_inline( + described_class.new( + container: "custom-list", + work_package_item_component_klass: default_item_component_class + ) + ) do |list| + list.with_item { "Manual item" } + end + + expect(rendered).to have_css(".Box#custom-list") + expect(rendered).to have_css("ul#custom-list_list") + end +end diff --git a/spec/components/open_project/common/work_package_card_list_component/header_spec.rb b/spec/components/open_project/common/work_package_card_list_component/header_spec.rb deleted file mode 100644 index c00b7d0b957d..000000000000 --- a/spec/components/open_project/common/work_package_card_list_component/header_spec.rb +++ /dev/null @@ -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 diff --git a/spec/components/open_project/common/work_package_card_list_component_spec.rb b/spec/components/open_project/common/work_package_card_list_component_spec.rb index d9b9dbfb9e4d..96f48f8d5e73 100644 --- a/spec/components/open_project/common/work_package_card_list_component_spec.rb +++ b/spec/components/open_project/common/work_package_card_list_component_spec.rb @@ -23,7 +23,7 @@ # # 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. #++ @@ -86,10 +86,16 @@ def row_args end def card - CustomWorkPackageCardListItemCard.new(subject: @work_package.subject) + @card ||= CustomWorkPackageCardListItemCard.new(subject: @work_package.subject) end - def render? = false + def before_render + content + end + + def call + render(card) + end end ) end @@ -299,6 +305,15 @@ def call 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 [ @@ -316,7 +331,7 @@ def call describe ":footer slot" do let(:footer_content) { "footer-content" } - it "renders the footer row when supplied" do + it "renders the footer when supplied" do expect(rendered_component).to have_text("footer-content") end end @@ -368,188 +383,4 @@ def call 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