diff --git a/app/components/_index.sass b/app/components/_index.sass index cd21a4285f84..db76bc961b5e 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 "open_project/common/border_box_list_component" @import "op_primer/border_box_table_component" @import "op_primer/full_page_prompt_component" @import "op_primer/form_helpers" @@ -11,8 +12,6 @@ @import "open_project/common/inplace_edit_fields/index" @import "open_project/common/submenu_component" @import "open_project/common/main_menu_toggle_component" -@import "open_project/common/work_package_card_list_component" -@import "open_project/common/work_package_card_list_component/header" @import "open_project/common/work_package_card_component" @import "portfolios/details_component" @import "projects/row_component" diff --git a/app/components/open_project/common/work_package_card_list_component.html.erb b/app/components/open_project/common/border_box_list_component.html.erb similarity index 89% rename from app/components/open_project/common/work_package_card_list_component.html.erb rename to app/components/open_project/common/border_box_list_component.html.erb index 7aa5bdb62fe5..416d6220be8c 100644 --- a/app/components/open_project/common/work_package_card_list_component.html.erb +++ b/app/components/open_project/common/border_box_list_component.html.erb @@ -29,25 +29,25 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %> <% if header? %> - <% border_box.with_header(id: header_id) do %> + <% border_box.with_header(**header.row_args) do %> <%= header %> <% end %> <% end %> - <% if items.empty? %> - <% border_box.with_row(data: { empty_list_item: true }) do %> - <%= empty_state %> - <% end %> - <% else %> + <% if items.any? %> <% items.each do |item| %> <% border_box.with_row(**item.row_args) do %> - <%= render(item.card) %> + <%= item %> <% end %> <% end %> + <% elsif empty_state? %> + <% border_box.with_row(data: { empty_list_item: true }) do %> + <%= empty_state %> + <% end %> <% end %> <% if footer? %> - <% border_box.with_row(scheme: :neutral) do %> + <% border_box.with_footer(**footer.footer_args) do %> <%= footer %> <% end %> <% end %> 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..6fa44de136db --- /dev/null +++ b/app/components/open_project/common/border_box_list_component.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + class BorderBoxListComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + renders_one :header, ->(**system_arguments) { + system_arguments[:list_id] ||= list_id + + Header.new(**system_arguments) + } + + renders_many :items, types: { + item: { + renders: ->(**system_arguments) { + Item.new(**system_arguments) + }, + as: :item + }, + work_package_item: { + renders: ->(work_package:, project: nil, params: {}, component_klass: WorkPackageItem, **item_arguments) { + project ||= work_package.project + item_arguments[:container] = container unless item_arguments.key?(:container) + item_arguments[:current_user] = current_user unless item_arguments.key?(:current_user) + + component_klass.new( + work_package:, + project:, + params:, + **item_arguments + ) + }, + as: :work_package_item + } + } + + renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) { + EmptyState.new(title:, description:, icon:, **system_arguments) + } + + renders_one :footer, ->(**system_arguments) { + system_arguments[:id] ||= dom_target(list_id, :footer) if list_id + + Footer.new(**system_arguments) + } + + attr_reader :container, :current_user + + def initialize(container:, current_user: User.current, **system_arguments) + super() + + @container = container + @current_user = current_user + @system_arguments = system_arguments + + apply_container_defaults! + end + + def before_render + content + apply_header_defaults! + end + + def render? + header? || items.any? || empty_state? || footer? + end + + private + + def apply_container_defaults! + @system_arguments[:id] ||= dom_target(*Array(container)) + @system_arguments[:list_id] ||= dom_target(*Array(container), :list) + end + + 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 +end diff --git a/app/components/open_project/common/border_box_list_component.sass b/app/components/open_project/common/border_box_list_component.sass new file mode 100644 index 000000000000..11a8520a0327 --- /dev/null +++ b/app/components/open_project/common/border_box_list_component.sass @@ -0,0 +1,22 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +.op-border-box-list-header + display: grid + grid-template-columns: 1fr minmax(5rem, max-content) auto + grid-template-areas: "collapsible actions menu" + align-items: center + + &--actions, + &--menu + margin-left: var(--stack-gap-normal) + align-self: flex-start + // Unfortunately, the invisible button style bites us here again. + margin-top: -6px diff --git a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb b/app/components/open_project/common/border_box_list_component/empty_state.rb similarity index 55% rename from spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb rename to app/components/open_project/common/border_box_list_component/empty_state.rb index 0995b5d41a6a..d08058a87869 100644 --- a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb +++ b/app/components/open_project/common/border_box_list_component/empty_state.rb @@ -23,33 +23,42 @@ # # 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 OpenProject + module Common + class BorderBoxListComponent + class EmptyState < ApplicationComponent + include Primer::AttributesHelper -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(title:, description: nil, icon: nil, **system_arguments) + super() - expect(item.row_args[:data]).to include(empty_list_item: true) - end + @title = title + @description = description + @icon = icon + @system_arguments = system_arguments + end + + def call + system_arguments = @system_arguments.deep_dup + system_arguments[:role] = "status" + system_arguments[:aria] = merge_aria( + system_arguments, + aria: { live: "polite" } + ) - 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" - } - ) + blankslate = Primer::Beta::Blankslate.new(**system_arguments) + blankslate.with_heading(tag: :h4).with_content(@title) + blankslate.with_description { @description } if @description + blankslate.with_visual_icon(icon: @icon) if @icon - expect(item.row_args[:data]).to include( - empty_list_item: false, - test_selector: "custom-empty-row" - ) + render(blankslate) + end + end end end end diff --git a/app/components/open_project/common/work_package_card_list_component/empty_item.rb b/app/components/open_project/common/border_box_list_component/footer.rb similarity index 73% rename from app/components/open_project/common/work_package_card_list_component/empty_item.rb rename to app/components/open_project/common/border_box_list_component/footer.rb index c68491095325..d4b98ab7706a 100644 --- a/app/components/open_project/common/work_package_card_list_component/empty_item.rb +++ b/app/components/open_project/common/border_box_list_component/footer.rb @@ -23,28 +23,31 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Row bridge for caller-provided empty content. - class EmptyItem < ContentItem - include Primer::AttributesHelper + class BorderBoxListComponent + class Footer < ApplicationComponent + attr_reader :id - def row_args - system_arguments = @system_arguments.deep_dup - system_arguments[:data] = merge_data( - { data: { empty_list_item: true } }, - system_arguments - ) - system_arguments + def initialize(**system_arguments) + super() + + @id = system_arguments[:id] + @system_arguments = system_arguments + end + + def footer_args + @system_arguments.deep_dup end - def empty_item? = true + def call + content + end end end end diff --git a/app/components/open_project/common/work_package_card_list_component/header.rb b/app/components/open_project/common/border_box_list_component/has_menu.rb similarity index 59% rename from app/components/open_project/common/work_package_card_list_component/header.rb rename to app/components/open_project/common/border_box_list_component/has_menu.rb index 3bb6f63f823a..7194dc57cb05 100644 --- a/app/components/open_project/common/work_package_card_list_component/header.rb +++ b/app/components/open_project/common/border_box_list_component/has_menu.rb @@ -23,55 +23,58 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - class Header < ApplicationComponent - include OpPrimer::ComponentHelpers + class BorderBoxListComponent + # Adds the standard list action menu slot used by list headers and items. + module HasMenu + extend ActiveSupport::Concern + include Primer::ClassNameHelper - renders_one :description - - renders_many :actions, types: { - button: ->(**system_arguments) do - Primer::Beta::Button.new(**system_arguments) + included do + # @!parse + # # Adds a trailing action menu. + # # + # # @param menu_id [String, nil] id prefix for the Primer action menu. + # # @param button_aria_label [String, nil] accessible label for the menu button. + # # @param system_arguments [Hash] forwarded to `Primer::Alpha::ActionMenu`. + # # @return [ViewComponent::Slot] + # def with_menu(menu_id: nil, button_aria_label: nil, **system_arguments, &block) + # end + renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do + build_menu(menu_id:, button_aria_label:, **system_arguments) end - } + end + + private - renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do + def build_menu(menu_id: nil, button_aria_label: nil, **system_arguments) system_arguments[:classes] = class_names( system_arguments[:classes], "hide-when-print" ) menu = Primer::Alpha::ActionMenu.new( - menu_id: menu_id || dom_target(container, :menu), + menu_id: menu_id || default_menu_id, anchor_align: :end, **system_arguments ) menu.with_show_button( scheme: :invisible, icon: :"kebab-horizontal", - "aria-label": button_aria_label || t(".label_actions"), + "aria-label": button_aria_label || I18n.t(:label_actions), tooltip_direction: :se ) menu end - attr_reader :title, :container, :list_id, :collapsed, :count - - def initialize(title:, container:, list_id:, collapsed: false, count: nil) - super() - - @title = title - @container = container - @list_id = list_id - @collapsed = collapsed - @count = count + def default_menu_id + self.class.generate_id end end end diff --git a/app/components/open_project/common/border_box_list_component/header.html.erb b/app/components/open_project/common/border_box_list_component/header.html.erb new file mode 100644 index 000000000000..b963397ddfcd --- /dev/null +++ b/app/components/open_project/common/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/open_project/common/border_box_list_component/header.rb b/app/components/open_project/common/border_box_list_component/header.rb new file mode 100644 index 000000000000..ab00f2301f6c --- /dev/null +++ b/app/components/open_project/common/border_box_list_component/header.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + class BorderBoxListComponent + # Structured header for {BorderBoxListComponent}. + # + # 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 +end diff --git a/app/components/open_project/common/work_package_card_list_component/content_item.rb b/app/components/open_project/common/border_box_list_component/item.rb similarity index 86% rename from app/components/open_project/common/work_package_card_list_component/content_item.rb rename to app/components/open_project/common/border_box_list_component/item.rb index a6b27d116d8b..c8e6cc4d4d54 100644 --- a/app/components/open_project/common/work_package_card_list_component/content_item.rb +++ b/app/components/open_project/common/border_box_list_component/item.rb @@ -23,16 +23,15 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Item bridge for caller-provided content. - class ContentItem < ApplicationComponent + class BorderBoxListComponent + class Item < ApplicationComponent def initialize(**system_arguments) super() @@ -43,12 +42,6 @@ def row_args @system_arguments.deep_dup end - def card - self - end - - def empty_item? = false - def call content end diff --git a/app/components/open_project/common/work_package_card_list_component/item.rb b/app/components/open_project/common/border_box_list_component/work_package_item.rb similarity index 88% rename from app/components/open_project/common/work_package_card_list_component/item.rb rename to app/components/open_project/common/border_box_list_component/work_package_item.rb index ca5c6a661311..c280acf4e453 100644 --- a/app/components/open_project/common/work_package_card_list_component/item.rb +++ b/app/components/open_project/common/border_box_list_component/work_package_item.rb @@ -23,18 +23,15 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Internal row bridge between the card list and the visual card. It owns - # the surrounding BorderBox row arguments while `WorkPackageCardComponent` - # renders the card body. - class Item < ApplicationComponent + class BorderBoxListComponent + class WorkPackageItem < ApplicationComponent include ActionView::RecordIdentifier include Primer::ClassNameHelper include Primer::AttributesHelper @@ -45,7 +42,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 +78,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/app/components/open_project/common/work_package_card_list_component.rb b/app/components/open_project/common/work_package_card_list_component.rb deleted file mode 100644 index 0f29b85c2681..000000000000 --- a/app/components/open_project/common/work_package_card_list_component.rb +++ /dev/null @@ -1,309 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module OpenProject - module Common - class WorkPackageCardListComponent < ApplicationComponent - include Primer::AttributesHelper - include OpPrimer::ComponentHelpers - - # Renders a `Header` above the card list with the title, count badge, and - # consumer-provided actions/menu/description. - # - # @param title [String] heading text rendered inside the collapsible header. - # @param count [Integer, NilClass] optional count badge displayed alongside - # the title; hidden when zero or nil. - renders_one :header, ->(title:, count: nil) { - Header.new(title:, count:, container:, list_id:, collapsed: folded?) - } - - # Renders a `Primer::Beta::Blankslate` when no items are produced — that - # is, when `items.empty?` after slot resolution and automatic item builds. - # The slot is required unless the caller provides manual items, and is - # silently ignored whenever `items` is non-empty. - # - # @param title [String] blankslate heading. - # @param description [String, NilClass] optional secondary text. - # @param icon [Symbol, NilClass] optional Octicon name. - # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`. - renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) { - system_arguments[:role] = "status" - system_arguments[:aria] = merge_aria( - system_arguments, - aria: { live: "polite" } - ) - - blankslate = Primer::Beta::Blankslate.new(**system_arguments) - blankslate.with_heading(tag: :h4).with_content(title) - blankslate.with_description_content(description) if description - blankslate.with_visual_icon(icon:) if icon - blankslate - } - - # @!parse - # # Adds a work package item row to the list. When at least one item - # # is added manually, the list does not build rows from - # # `work_packages:`. - # # - # # @param work_package [WorkPackage] the work package rendered in the row. - # # @param component_klass [Class] row bridge class used instead of the - # # default item class. Defaults to the list's configured - # # `item_component_klass`. It must accept the arguments documented on - # # `#build_item`, expose `#row_args` with valid - # # `Primer::Beta::BorderBox#with_row` keyword arguments, and expose - # # `#card` returning a renderable object. - # # @param system_arguments [Hash] forwarded to the item class. - # def with_work_package_item( - # work_package:, - # component_klass: Item, - # **system_arguments, - # &block - # ) - # end - - # @!parse - # # Adds a custom empty item row to the list. This can be used instead of - # # the `empty_state` slot when the caller owns item iteration. It cannot - # # be combined with `work_packages:`, `with_work_package_item`, or - # # `with_item`. - # # - # # @param system_arguments [Hash] forwarded to - # # `Primer::Beta::BorderBox#with_row`. - # def with_empty_item(**system_arguments, &block) - # end - - # @!parse - # # Adds a generic item to the list. When at least one item is added - # # manually, the list does not build rows from `work_packages:`. - # # - # # @param system_arguments [Hash] forwarded to - # # `Primer::Beta::BorderBox#with_row`. - # def with_item(**system_arguments, &block) - # end - renders_many :items, types: { - work_package_item: { - renders: lambda { |work_package:, **system_arguments, &block| - build_item(work_package:, **system_arguments).tap do |item| - capture(item, &block) if block - end - }, - as: :work_package_item - }, - empty_item: { - renders: lambda { |**system_arguments, &block| - build_content_item(EmptyItem, **system_arguments, &block) - }, - as: :empty_item - }, - item: { - renders: lambda { |**system_arguments, &block| - build_content_item(ContentItem, **system_arguments, &block) - }, - as: :item - } - } - - # Renders a free-form footer row below the card list. - renders_one :footer - - attr_reader :work_packages, - :project, - :container, - :drag_and_drop, - :item_component_klass, - :params, - :current_user - - # @param project [Project] the project this card list is rendered in. May - # differ from individual `work_package.project` values when sprints or - # buckets are shared across projects. - # @param container [Symbol, String, Class, ApplicationRecord] drives the - # list DOM id and related ids via `dom_target`. - # @param work_packages [Enumerable] the work packages to render - # as cards. - # @param drag_and_drop [Hash, NilClass] optional generic drag-and-drop - # target data. Requires `:target_id` and `:allowed_drag_type` when set. - # @param item_component_klass [Class] item class used for automatically - # built work package items. - # @param params [Hash] optional URL params passed to work package items - # when deriving row arguments. - # @param current_user [User] passed through to each item for permission - # checks; defaults to `User.current`. - # @param system_arguments [Hash] forwarded to the underlying - # `Primer::Beta::BorderBox`. - def initialize( - project:, - container:, - work_packages: [], - drag_and_drop: nil, - item_component_klass: Item, - params: {}, - current_user: User.current, - **system_arguments - ) - super() - - @work_packages = work_packages - @project = project - @container = container - @drag_and_drop = drag_and_drop - @item_component_klass = item_component_klass - @params = params - @current_user = current_user - @automatic_items = false - - @system_arguments = system_arguments - @system_arguments[:id] = container_id - @system_arguments[:list_id] = list_id - @system_arguments[:padding] = :condensed - merge_drag_and_drop_data! if drag_and_drop - end - - def before_render - # Content must be loaded before mode validation and automatic item builds - # so slot calls have already populated `items`. - content - validate_item_mode! - build_automatic_items if build_automatic_items? - validate_empty_state! - end - - # Builds a new work package item without adding it to the list. Use this - # instead of the `#with_work_package_item` slot when rendering additional - # items outside this list, such as in a separately-loaded page. - # - # @param work_package [WorkPackage] the work package rendered in the row. - # @param component_klass [Class] item class used instead of the configured - # default item class. It must accept `work_package:`, `project:`, - # `container:`, `params:`, `current_user:`, and `**system_arguments`. - # @param system_arguments [Hash] forwarded to the item class. - def build_item( - work_package:, - component_klass: item_component_klass, - **system_arguments - ) - component_klass.new( - work_package:, - project:, - container:, - params:, - current_user:, - **system_arguments - ) - end - - private - - def folded? - current_user.pref[:backlogs_versions_default_fold_state] == "closed" - end - - def build_automatic_items? - non_empty_items.empty? && work_packages.any? - end - - def build_automatic_items - @automatic_items = true - - work_packages.each do |work_package| - with_work_package_item(work_package:) - end - end - - def build_content_item(item_class, **system_arguments, &block) - item_class.new(**system_arguments).tap do |item| - item.with_content(capture(&block)) if block - end - end - - def automatic_items? - @automatic_items - end - - def validate_item_mode! - return unless empty_items.any? - - if work_packages.any? - raise ArgumentError, "empty_item cannot be combined with work_packages" - end - - if non_empty_items.any? - raise ArgumentError, "empty_item cannot be combined with other items" - end - end - - def validate_empty_state! - return unless items.empty? && !empty_state? - - raise ArgumentError, "empty_state slot is required when no work package items are rendered" - end - - def container_id - dom_target(container) - end - - def list_id - dom_target(container, :list) - end - - def header_id - dom_target(container, :header) - end - - def empty_items - items.select { |item| item.respond_to?(:empty_item?) && item.empty_item? } - end - - def non_empty_items - items - empty_items - end - - def merge_drag_and_drop_data! - @system_arguments[:data] = merge_data( - { - data: drag_and_drop_data - }, - @system_arguments - ) - end - - def drag_and_drop_data - { - # Existing callers share one mirror container target on the page until - # parent-specific DnD handling is extracted in follow-up work. - generic_drag_and_drop_target: "container", - target_container_accessor: ":scope > ul", - target_id: drag_and_drop.fetch(:target_id), - target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type) - } - end - end - end -end 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.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/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..65448200cc13 --- /dev/null +++ b/lookbook/previews/open_project/common/border_box_list_component_preview.rb @@ -0,0 +1,120 @@ +# 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 + # Full list demonstrating header (with title, count, description, action + # button, and menu), generic items, work-package items rendered with the + # default `WorkPackageItem`, and a footer. + def default # rubocop:disable Metrics/AbcSize + work_packages = WorkPackage.includes(:project).limit(2).to_a + return preview_message("No work packages in the database.") if work_packages.empty? + + project = work_packages.first.project + + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-preview" + ) do |list| + list.with_header( + title: "Things we're building", + count: work_packages.size + 2, + count_aria_label: "#{work_packages.size + 2} list items" + ) do |header| + header.with_description { "There's lots to look forward to" } + header.with_action_button(scheme: :invisible, size: :small) 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 { "A plain generic item" } + list.with_item { "Another generic item" } + + work_packages.each do |work_package| + list.with_work_package_item(work_package:, project:) 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 + + list.with_footer { "Next launch window: October" } + end + end + + # List with a header and an empty state (Blankslate), no items. + def empty_state + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-empty-preview" + ) do |list| + list.with_header(title: "Empty list", count: 0) + list.with_empty_state( + title: "No items yet", + description: "There is nothing to show." + ) + end + end + + # List populated exclusively with generic (non-work-package) items. + def generic_items_only + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-generic-preview" + ) do |list| + list.with_header( + title: "Generic items", + count: 3, + count_aria_label: "3 generic items" + ) + + list.with_item { "AI Copilot for Support Teams" } + list.with_item { "\"Just One More Feature\" Initiative" } + list.with_item { "Automated Release Pipeline" } + 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 deleted file mode 100644 index fa9bb524768f..000000000000 --- a/lookbook/previews/open_project/common/work_package_card_list_component_preview.rb +++ /dev/null @@ -1,114 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module OpenProject - module Common - # @logical_path OpenProject/Common - class WorkPackageCardListComponentPreview < ViewComponent::Preview - include ActionView::RecordIdentifier - - def sprint_with_cards - sprint = Sprint.first - project = sprint&.project - return preview_message("No sprints in the database.") unless sprint && project - - work_packages = sprint.work_packages_for(project).limit(3) - render OpenProject::Common::WorkPackageCardListComponent.new( - work_packages:, - project:, - container: sprint - ) do |list| - list.with_header(title: sprint.name, count: work_packages.size) do |header| - points = work_packages.sum { |w| w.story_points || 0 } - header.with_description { "#{points} points" } - end - list.with_empty_state(title: "Sprint is empty", description: "Drag work packages here") - end - end - - def empty_sprint - sprint = Sprint.first - project = sprint&.project - return preview_message("No sprints in the database.") unless sprint && project - - render OpenProject::Common::WorkPackageCardListComponent.new( - work_packages: [], project:, container: sprint - ) do |list| - list.with_header(title: sprint.name, count: 0) do |header| - header.with_description { "0 points" } - end - list.with_empty_state(title: "Sprint is empty", description: "Drag work packages here") - end - end - - def inbox - project = Project.first - return preview_message("No project in the database.") unless project - - render OpenProject::Common::WorkPackageCardListComponent.new( - work_packages: [], - project:, - container: dom_target(:inbox, project) - ) do |list| - list.with_empty_state(title: "Inbox is empty", description: "All caught up", - icon: :"op-backlogs") - end - end - - def manual_item - work_package = WorkPackage.first - project = work_package&.project - return preview_message("No work packages in the database.") unless work_package && project - - render OpenProject::Common::WorkPackageCardListComponent.new( - project:, - container: :manual_item_demo - ) do |list| - list.with_empty_state(title: "No items", description: "Manual items can be added by callers") - list.with_work_package_item(work_package:) - list.with_item(scheme: :neutral) { "Caller-provided item" } - end - end - - private - - # ViewComponent's `Preview.render_args` expects each preview method to - # return a Hash (it does `result[:template] = …`), so plain string - # returns fail with "no implicit conversion of Symbol into Integer". - # Wrap fallback messages in a Blankslate render so they go through the - # standard hash path. - def preview_message(text) - render(Primer::Beta::Blankslate.new) do |b| - b.with_heading(tag: :h4).with_content(text) - end - end - end - end -end diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index 30f81119db2d..64a241cda646 100644 --- a/modules/backlogs/app/components/backlogs/bucket_component.html.erb +++ b/modules/backlogs/app/components/backlogs/bucket_component.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: :section) do %> <%= render( - OpenProject::Common::WorkPackageCardListComponent.new( + Backlogs::WorkPackageCardListComponent.new( work_packages:, project:, container: backlog_bucket, @@ -37,7 +37,6 @@ See COPYRIGHT and LICENSE files for more details. target_id: "backlog_bucket:#{backlog_bucket.id}", allowed_drag_type: "story" }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, params: all_backlogs_params, current_user:, data: { test_selector: "backlog-bucket-#{backlog_bucket.id}" } diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index f8a6e7eff2ad..f6f0afa36a1b 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -28,29 +28,34 @@ See COPYRIGHT and LICENSE files for more details. ++# %> <%= component_wrapper(tag: :section) do %> + <% inbox_container = dom_target(:inbox, project) %> <%= render( - OpenProject::Common::WorkPackageCardListComponent.new( - project:, - container: dom_target(:inbox, project), - drag_and_drop: { - target_id: "inbox", - allowed_drag_type: "story" - }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, - params: all_backlogs_params, + OpenProject::Common::BorderBoxListComponent.new( + container: inbox_container, current_user:, - data: { test_selector: "backlog-inbox" } + padding: :condensed, + data: { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", + target_id: "inbox", + target_allowed_drag_type: "story", + test_selector: "backlog-inbox" + } ) ) do |list| %> <% list.with_empty_state( title: t(".blankslate_title"), description: t(".blankslate_description"), - icon: :"op-backlogs", - spacious: true + icon: :"op-backlogs" ) %> <% visible_work_packages.each.with_index do |work_package, index| %> - <% list.with_work_package_item(work_package:) %> + <% list.with_work_package_item( + work_package:, + project:, + params: all_backlogs_params, + component_klass: Backlogs::WorkPackageCardListItemComponent + ) %> <% if truncated? && index == TRUNCATE_MIDDLE - 1 %> <% list.with_item( diff --git a/modules/backlogs/app/components/backlogs/sprint_component.html.erb b/modules/backlogs/app/components/backlogs/sprint_component.html.erb index c4053ca23e94..dbb212268381 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.html.erb +++ b/modules/backlogs/app/components/backlogs/sprint_component.html.erb @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= component_wrapper(tag: :section) do %> <%= render( - OpenProject::Common::WorkPackageCardListComponent.new( + Backlogs::WorkPackageCardListComponent.new( work_packages:, project:, container: sprint, @@ -37,7 +37,6 @@ See COPYRIGHT and LICENSE files for more details. target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, params: all_backlogs_params, current_user:, data: { test_selector: "sprint-#{sprint.id}" } diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb new file mode 100644 index 000000000000..2ea7ea922665 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Backlogs + class WorkPackageCardListComponent < ApplicationComponent + include Primer::AttributesHelper + include OpPrimer::ComponentHelpers + + renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) { + @list.with_empty_state(title:, description:, icon:, **system_arguments) + } + + delegate :with_footer, to: :@list + + attr_reader :work_packages, + :project, + :container, + :drag_and_drop, + :params, + :current_user + + def initialize( + project:, + container:, + work_packages: [], + drag_and_drop: nil, + params: {}, + current_user: User.current, + **system_arguments + ) + super() + + @work_packages = work_packages || [] + @project = project + @container = container + @drag_and_drop = drag_and_drop + @params = params + @current_user = current_user + + @system_arguments = system_arguments + @system_arguments[:padding] = :condensed + merge_drag_and_drop_data! if drag_and_drop + + @list = OpenProject::Common::BorderBoxListComponent.new( + container:, + current_user:, + **@system_arguments + ) + end + + def with_header(title:, count: nil, &) + count_aria_label = if count + t("open_project.common.work_package_card_list_component.header.label_work_package_count", + count:) + end + + @list.with_header( + title:, + count:, + collapsed: folded?, + count_aria_label:, + id: header_id, + & + ) + end + + def before_render + content + populate_list! + validate_empty_state! + end + + def call + render(@list) + end + + private + + def populate_list! + if work_packages.empty? + # empty_state is declared via the slot and delegated to @list + else + work_packages.each do |work_package| + @list.with_work_package_item( + work_package:, + project:, + params:, + component_klass: Backlogs::WorkPackageCardListItemComponent + ) + end + end + end + + def folded? + current_user.pref[:backlogs_versions_default_fold_state] == "closed" + end + + def validate_empty_state! + return unless work_packages.empty? && !empty_state? + + raise ArgumentError, "empty_state slot is required when no work package items are rendered" + end + + def header_id + dom_target(container, :header) + end + + def merge_drag_and_drop_data! + @system_arguments[:data] = merge_data( + { + data: drag_and_drop_data + }, + @system_arguments + ) + end + + def drag_and_drop_data + { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", + target_id: drag_and_drop.fetch(:target_id), + target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type) + } + end + end +end diff --git a/app/components/open_project/common/work_package_card_list_component.sass b/modules/backlogs/app/components/backlogs/work_package_card_list_component.sass similarity index 100% rename from app/components/open_project/common/work_package_card_list_component.sass rename to modules/backlogs/app/components/backlogs/work_package_card_list_component.sass diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index 2686487887a8..ed48622787fb 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -29,7 +29,7 @@ #++ module Backlogs - class WorkPackageCardListItemComponent < OpenProject::Common::WorkPackageCardListComponent::Item + class WorkPackageCardListItemComponent < OpenProject::Common::BorderBoxListComponent::WorkPackageItem def card @card ||= WorkPackageCardComponent.new(work_package:, menu_src:) end diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb new file mode 100644 index 000000000000..212335935ab4 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Backlogs::WorkPackageCardListComponent, type: :component do + shared_let(:type_feature) { create(:type_feature) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + shared_let(:project) { create(:project, types: [type_feature]) } + shared_let(:sprint) do + create(:sprint, project:, name: "Sprint 1", + start_date: Date.yesterday, finish_date: Date.tomorrow) + end + shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") } + + let(:container) { sprint } + let(:drag_and_drop) { nil } + let(:params) { {} } + let(:work_packages) { [] } + let(:header_arguments) { nil } + let(:footer_content) { nil } + + subject(:rendered_component) do + render_component(work_packages:, container:, drag_and_drop:) + end + + def render_component(work_packages:, container:, drag_and_drop:) + render_inline( + described_class.new( + work_packages:, + project:, + container:, + drag_and_drop:, + params:, + current_user: user + ) + ) do |box| + box.with_header(**header_arguments) if header_arguments + box.with_empty_state(title: "Sprint 1 is empty", description: "Drag work packages here") + box.with_footer { footer_content } if footer_content + end + end + + describe "automatic work_packages iteration" do + let(:work_packages) do + [ + create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 1), + create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 2) + ] + end + + it_behaves_like "rendering Box", row_count: 2, header: false, footer: false + + it "renders one row per work package" do + expect(rendered_component).to have_text("WP A") + expect(rendered_component).to have_text("WP B") + end + end + + describe "hardcoded Backlogs item component" do + let(:work_packages) do + [ + create(:work_package, subject: "Story card", project:, type: type_feature, + status: default_status, priority: default_priority, + sprint:, position: 1, story_points: 3) + ] + end + + it "renders items through Backlogs::WorkPackageCardListItemComponent" do + work_package = work_packages.first + + expect(rendered_component).to have_css( + ".Box-row#work_package_#{work_package.id}[data-controller='backlogs--story']" + ) + end + + it "renders Backlogs-specific row data attributes" do + work_package = work_packages.first + + expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| + expect(row["data-story"]).to be_present + expect(row["data-backlogs--story-id-value"]).to eq(work_package.id.to_s) + end + end + end + + describe "delegated header with fold-state defaults" do + let(:header_arguments) { { title: "Sprint 1", count: 0 } } + + it "renders the header" do + expect(rendered_component).to have_css(".Box-header") + end + + it "renders the provided title" do + expect(rendered_component).to have_heading "Sprint 1", level: 4 + end + + it "uses dom_target(container, :header) as the header id" do + expect(rendered_component).to have_css(".Box-header#sprint_#{sprint.id}_header") + end + + context "when the user prefers closed folds" do + before do + user.pref[:backlogs_versions_default_fold_state] = "closed" + end + + it "renders the header as collapsed" do + expect(rendered_component).to have_css( + ".CollapsibleHeader-triggerArea", + aria: { expanded: "false" } + ) + end + end + + context "when the user prefers open folds" do + before do + user.pref[:backlogs_versions_default_fold_state] = "open" + end + + it "renders the header as expanded" do + expect(rendered_component).to have_css( + ".CollapsibleHeader-triggerArea", + aria: { expanded: "true" } + ) + end + end + end + + describe "delegated footer" do + let(:footer_content) { "footer-content" } + + it "renders the footer when supplied" do + expect(rendered_component).to have_text("footer-content") + end + end + + describe "empty_state rendering" do + it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty" + + it "renders the blankslate when work_packages is empty" do + expect(rendered_component).to have_text("Sprint 1 is empty") + expect(rendered_component).to have_text("Drag work packages here") + end + + context "when work_packages is nil" do + let(:work_packages) { nil } + + it "treats nil as an empty collection" do + expect(rendered_component).to have_text("Sprint 1 is empty") + expect(rendered_component).to have_text("Drag work packages here") + end + end + + context "when there are work packages" do + let(:work_packages) do + [ + create(:work_package, project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 1) + ] + end + + it "does not render the blankslate" do + expect(rendered_component).to have_no_css(".blankslate") + end + end + end + + describe "empty_state validation" do + it "raises ArgumentError when work_packages is empty and no empty_state given" do + expect do + render_inline( + described_class.new( + work_packages: [], + project:, + container: sprint, + current_user: user + ) + ) do |box| + box.with_footer { "" } + end + end.to raise_error(ArgumentError, /empty_state slot is required/) + end + end + + describe "drag-and-drop data merging" do + context "without drag_and_drop" do + it "does not emit drag-and-drop data" do + expect(rendered_component).to have_no_css(".Box[data-generic-drag-and-drop-target]") + expect(rendered_component).to have_no_css(".Box[data-target-id]") + expect(rendered_component).to have_no_css(".Box[data-target-allowed-drag-type]") + end + end + + context "with drag_and_drop configured" do + let(:drag_and_drop) do + { target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" } + end + + it "merges drag-and-drop data attributes onto the box" do + expect(rendered_component).to have_css(".Box") do |box| + expect(box["data-generic-drag-and-drop-target"]).to eq("container") + expect(box["data-target-container-accessor"]).to eq(":scope > ul") + expect(box["data-target-id"]).to eq("sprint:#{sprint.id}") + expect(box["data-target-allowed-drag-type"]).to eq("story") + end + end + end + end + + describe "container/list/header DOM IDs" do + context "when container is a Sprint" do + let(:container) { sprint } + + it "uses dom_target(sprint) as the box id" do + expect(rendered_component).to have_css(".Box#sprint_#{sprint.id}") + end + + it "uses dom_target(sprint, :list) for the list id" do + expect(rendered_component).to have_css("ul#sprint_#{sprint.id}_list") + end + end + + context "when container is a BacklogBucket" do + let(:container) { backlog_bucket } + + it "uses dom_target(backlog_bucket) as the box id" do + expect(rendered_component).to have_css(".Box#backlog_bucket_#{backlog_bucket.id}") + end + + it "uses dom_target(backlog_bucket, :list) for the list id" do + expect(rendered_component).to have_css("ul#backlog_bucket_#{backlog_bucket.id}_list") + end + end + end +end 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..4005dd15aa60 --- /dev/null +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -0,0 +1,522 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do + shared_let(:user) { create(:admin) } + shared_let(:project) { create(:project) } + shared_let(:work_package) { create(:work_package, subject: "Default WP", project:) } + shared_let(:override_work_package) { create(:work_package, subject: "Override WP", project:) } + + current_user { user } + + let(:default_wp_item_class) do + stub_const( + "TestDefaultWorkPackageItem", + Class.new(ApplicationComponent) do + include ActionView::RecordIdentifier + + delegate :with_metric, :with_menu, to: :card + + def initialize(work_package:, project:, container:, params: {}, current_user: User.current, **system_arguments) # rubocop:disable Lint/UnusedMethodArgument + super() + + @work_package = work_package + @project = project + @container = container + @current_user = current_user + @system_arguments = system_arguments + end + + def row_args + @system_arguments.merge( + id: "default_wp_#{@work_package.id}", + data: @system_arguments.fetch(:data, {}).merge( + container: Array(@container).map { |c| c.respond_to?(:id) ? c.id : c }.join("_"), + project: @project&.id, + current_user: @current_user&.id + ) + ) + end + + def card + @card ||= TestWorkPackageCard.new(prefix: "default", subject: @work_package.subject) + end + + def before_render + content + end + + def call + render(card) + end + end + ) + end + + let(:override_wp_item_class) do + stub_const( + "TestOverrideWorkPackageItem", + Class.new(default_wp_item_class) do + def row_args + super.merge(id: "override_wp_#{@work_package.id}") + end + + def card + @card ||= TestWorkPackageCard.new(prefix: "override", subject: @work_package.subject) + end + end + ) + end + + before do + stub_const( + "TestWorkPackageCard", + Class.new(ApplicationComponent) do + renders_one :metric + renders_one :menu + + def initialize(prefix:, subject:) + super() + + @prefix = prefix + @subject = subject + end + + def call + safe_join([tag.span("#{@prefix} #{@subject}"), metric, menu].compact) + end + end + ) + end + + describe "full rendering" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "test-list", current_user: user) + ) do |list| + list.with_header(title: "Header title", count: 3) + list.with_item(id: "manual-item") { "Manual item" } + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class, + data: { source: "slot" } + ) do |item| + item.card.with_metric { "Metric content" } + end + list.with_work_package_item( + work_package: override_work_package, + component_klass: override_wp_item_class + ) do |item| + item.with_menu { "Menu content" } + end + list.with_footer { "Footer content" } + end + end + + it_behaves_like "rendering Box", row_count: 3, header: true, footer: true + + it "renders the header with title" do + expect(rendered_component).to have_heading("Header title", level: 4) + end + + it "renders the header count badge" do + expect(rendered_component).to have_css(".Counter", text: "3") + end + + it "renders generic items as content rows" do + expect(rendered_component).to have_css(".Box-row#manual-item", text: "Manual item") + end + + it "renders the footer" do + expect(rendered_component).to have_css(".Box-footer", text: "Footer content") + end + + it "renders the default work-package item" do + expect(rendered_component).to have_css( + ".Box-row#default_wp_#{work_package.id}", + text: "default Default WP" + ) + end + + it "renders the overridden work-package item" do + expect(rendered_component).to have_css( + ".Box-row#override_wp_#{override_work_package.id}", + text: "override Override WP" + ) + end + + it "captures work-package item customization blocks" do + expect(rendered_component).to have_text("Metric content") + end + + it "delegates menu customization to the card" do + expect(rendered_component).to have_text("Menu content") + end + end + + describe "header" do + it "renders a description below the title" do + rendered = render_inline( + described_class.new(container: "hdr-test") + ) do |list| + list.with_header(title: "My title") do |header| + header.with_description { "Some description" } + end + list.with_item { "row" } + end + + expect(rendered).to have_heading("My title", level: 4) + expect(rendered).to have_text("Some description") + end + + it "renders multiple action buttons" do + rendered = render_inline( + described_class.new(container: "hdr-actions") + ) do |list| + list.with_header(title: "Actions") do |header| + header.with_action_button(scheme: :primary) { "Add" } + header.with_action_button(scheme: :default) { "Edit" } + end + list.with_item { "row" } + end + + expect(rendered).to have_button("Add") + expect(rendered).to have_button("Edit") + end + + it "renders a menu in the header" do + rendered = render_inline( + described_class.new(container: "hdr-menu") + ) do |list| + list.with_header(title: "With menu") do |header| + header.with_menu do |menu| + menu.with_item(label: "Option A", value: "a") + end + end + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box-header") + expect(rendered).to have_css("action-menu") + expect(rendered).to have_css("tool-tip[data-type='label']", text: I18n.t(:label_actions)) + end + + it "renders a count aria label" do + rendered = render_inline( + described_class.new(container: "hdr-aria") + ) do |list| + list.with_header(title: "Counted", count: 5, count_aria_label: "5 items") + list.with_item { "row" } + end + + expect(rendered).to have_css(".Counter[aria-label='5 items']", text: "5") + end + end + + describe "header collapsible behavior" do + it "sets collapsible_id from list and footer ids" do + rendered = render_inline( + described_class.new(container: "collapse-test") + ) do |list| + list.with_header(title: "Collapsible") + list.with_item { "row" } + list.with_footer { "foot" } + end + + list_id = "collapse-test_list" + footer_id = "collapse-test_list_footer" + + expect(rendered).to have_css( + "[aria-controls='#{list_id} #{footer_id}']" + ) + end + + it "sets collapsible_id from list id only when no footer" do + rendered = render_inline( + described_class.new(container: "collapse-no-footer") + ) do |list| + list.with_header(title: "No footer") + list.with_item { "row" } + end + + expect(rendered).to have_css( + "[aria-controls='collapse-no-footer_list']" + ) + end + end + + describe "generic items" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "generic-items") + ) do |list| + list.with_item(id: "row-1") { "First" } + list.with_item(id: "row-2") { "Second" } + end + end + + it "renders content block rows" do + expect(rendered_component).to have_css(".Box-row#row-1", text: "First") + expect(rendered_component).to have_css(".Box-row#row-2", text: "Second") + end + + it "renders the expected number of rows" do + expect(rendered_component).to have_css(".Box-row", count: 2) + end + end + + describe "work-package items" do + describe "with the default WorkPackageItem" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "wp-default", current_user: user) + ) do |list| + list.with_work_package_item(work_package:) + end + end + + it "renders the work package row" do + expect(rendered_component).to have_css( + ".Box-row#work_package_#{work_package.id}" + ) + end + + it "applies clickable row classes" do + expect(rendered_component).to have_css( + ".Box-row.Box-row--clickable" + ) + end + + it "sets the test selector" do + expect(rendered_component).to have_css( + ".Box-row[data-test-selector='work-package-#{work_package.id}']" + ) + end + end + + describe "with an overridden component_klass" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "wp-override", current_user: user) + ) do |list| + list.with_work_package_item( + work_package: override_work_package, + component_klass: override_wp_item_class + ) + end + end + + it "uses the provided component class" do + expect(rendered_component).to have_css( + ".Box-row#override_wp_#{override_work_package.id}", + text: "override Override WP" + ) + end + end + + describe "injected container: and current_user:" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "injection-test", current_user: user) + ) do |list| + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class + ) + end + end + + it "injects the list container into the item" do + expect(rendered_component).to have_css( + ".Box-row[data-container='injection-test']" + ) + end + + it "injects the list current_user into the item" do + expect(rendered_component).to have_css( + ".Box-row[data-current-user='#{user.id}']" + ) + end + end + + describe "project defaults to work_package.project" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "project-default", current_user: user) + ) do |list| + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class + ) + end + end + + it "passes the work package's project when project: is omitted" do + expect(rendered_component).to have_css( + ".Box-row[data-project='#{work_package.project.id}']" + ) + end + end + end + + describe "empty state" do + it "renders a Blankslate when no items are present" do + rendered = render_inline( + described_class.new(container: "empty-list") + ) do |list| + list.with_empty_state(title: "Nothing here", description: "Add some items", icon: :inbox) + end + + expect(rendered).to have_css(".blankslate") + expect(rendered).to have_text("Nothing here") + expect(rendered).to have_text("Add some items") + end + + it "does not render the empty state when items are present" do + rendered = render_inline( + described_class.new(container: "non-empty-list") + ) do |list| + list.with_empty_state(title: "Nothing here") + list.with_item { "Has content" } + end + + expect(rendered).to have_no_css(".blankslate") + expect(rendered).to have_text("Has content") + end + + it "sets aria role and live attributes on the empty state" do + rendered = render_inline( + described_class.new(container: "empty-aria") + ) do |list| + list.with_empty_state(title: "Empty") + end + + expect(rendered).to have_css("[role='status'][aria-live='polite']") + end + end + + describe "footer rendering" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "footer-test") + ) do |list| + list.with_item { "row" } + list.with_footer(classes: "custom-footer") { "Custom footer" } + end + end + + it "renders as a proper BorderBox footer" do + expect(rendered_component).to have_css(".Box-footer", text: "Custom footer") + end + + it "auto-derives the footer id from list_id" do + expect(rendered_component).to have_css(".Box-footer#footer-test_list_footer") + end + end + + describe "container-derived DOM IDs" do + context "with a string container" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "my-widget") + ) do |list| + list.with_item { "row" } + end + end + + it "derives the box id from container" do + expect(rendered_component).to have_css(".Box#my-widget") + end + + it "derives the list id from container" do + expect(rendered_component).to have_css("ul#my-widget_list") + end + end + + context "with an array container" do + subject(:rendered_component) do + render_inline( + described_class.new(container: %i[inbox sprint]) + ) do |list| + list.with_item { "row" } + end + end + + it "derives the box id from all container segments" do + expect(rendered_component).to have_css(".Box#inbox_sprint") + end + + it "derives the list id from all container segments" do + expect(rendered_component).to have_css("ul#inbox_sprint_list") + end + end + + it "allows explicit id and list_id overrides" do + rendered = render_inline( + described_class.new(container: "ignored", id: "explicit-box", list_id: "explicit-list") + ) do |list| + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box#explicit-box") + expect(rendered).to have_css("ul#explicit-list") + end + end + + describe "system arguments forwarded to BorderBox" do + subject(:rendered_component) do + render_inline( + described_class.new( + container: "sys-args", + classes: "extra-class", + data: { test_selector: "my-box" } + ) + ) do |list| + list.with_item { "row" } + end + end + + it "forwards classes to the underlying BorderBox" do + expect(rendered_component).to have_css(".Box.extra-class") + end + + it "forwards data attributes to the underlying BorderBox" do + expect(rendered_component).to have_css(".Box[data-test-selector='my-box']") + end + end + + describe "constructor requires container:" do + it "raises ArgumentError when container: is missing" do + expect { described_class.new }.to raise_error(ArgumentError) + end + end +end 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/item_spec.rb b/spec/components/open_project/common/work_package_card_list_component/item_spec.rb deleted file mode 100644 index 7d3ec596698b..000000000000 --- a/spec/components/open_project/common/work_package_card_list_component/item_spec.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "rails_helper" - -RSpec.describe OpenProject::Common::WorkPackageCardListComponent::Item, type: :component do - shared_let(:type_feature) { create(:type_feature) } - shared_let(:default_status) { create(:default_status) } - shared_let(:default_priority) { create(:default_priority) } - shared_let(:user) { create(:admin) } - current_user { user } - - shared_let(:project) { create(:project, types: [type_feature]) } - - let(:container) { project } - let(:params) { {} } - let(:work_package) do - create(:work_package, - project:, - type: type_feature, - status: default_status, - priority: default_priority, - subject: "Card subject", - story_points: 5, - position: 1) - end - let(:item) do - described_class.new(work_package:, project:, container:, params:, current_user: user) - end - let(:draggable_item_class) do - stub_const( - "DraggableWorkPackageCardListItem", - Class.new(described_class) do - private - - def draggable? - true - end - - def draggable_data - { - draggable_id: work_package.id, - draggable_type: "work_package", - drop_url: "/drop" - } - end - end - ) - end - - describe "#row_args" do - it "can be passed to a BorderBox row" do - rendered = render_inline(Primer::Beta::BorderBox.new) do |box| - box.with_row(**item.row_args) do - "row body" - end - end - - expect(rendered).to have_css( - ".Box-row#work_package_#{work_package.id}", - text: "row body" - ) - end - - it "supplies the work-package row attributes" do - expect(item.row_args).to include( - id: "work_package_#{work_package.id}", - tabindex: 0 - ) - expect(item.row_args[:classes]).to include( - "Box-row--hover-blue", - "Box-row--focus-gray", - "Box-row--clickable" - ) - expect(item.row_args[:data][:test_selector]).to eq("work-package-#{work_package.id}") - end - - it "lets caller-supplied data override default row data" do - item = described_class.new( - work_package:, - project:, - container:, - params:, - current_user: user, - data: { - story: false, - test_selector: "custom-work-package-row" - } - ) - - expect(item.row_args[:data]).to include( - story: false, - test_selector: "custom-work-package-row" - ) - end - - it "does not include Backlogs row wiring" do - expect(item.row_args[:classes]).not_to include("Box-row--draggable") - expect(item.row_args[:data]).not_to include( - :controller, - :draggable_id, - :drop_url, - :backlogs__story_split_url_value - ) - end - - it "supports generic draggable row data from subclasses" do - item = draggable_item_class.new(work_package:, project:, container:, params:, current_user: user) - - expect(item.row_args[:classes]).to include("Box-row--draggable") - expect(item.row_args[:data]).to include( - draggable_id: work_package.id, - draggable_type: "work_package", - drop_url: "/drop" - ) - end - end - - describe "#card" do - subject(:rendered_card) { render_inline(item.card) } - - it "builds the visual card without deriving a menu src" do - expect(rendered_card).to have_no_element "include-fragment" - end - - it "returns the same card instance across calls" do - expect(item.card).to equal(item.card) - end - - it "forwards metric content to the visual card" do - item.with_metric { "Forwarded metric" } - - expect(rendered_card).to have_text("Forwarded metric") - end - end -end 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 deleted file mode 100644 index d9b9dbfb9e4d..000000000000 --- a/spec/components/open_project/common/work_package_card_list_component_spec.rb +++ /dev/null @@ -1,555 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "rails_helper" - -RSpec.describe OpenProject::Common::WorkPackageCardListComponent, type: :component do - shared_let(:type_feature) { create(:type_feature) } - shared_let(:default_status) { create(:default_status) } - shared_let(:default_priority) { create(:default_priority) } - shared_let(:user) { create(:admin) } - current_user { user } - - shared_let(:project) { create(:project, types: [type_feature]) } - shared_let(:sprint) do - create(:sprint, project:, name: "Sprint 1", - start_date: Date.yesterday, finish_date: Date.tomorrow) - end - shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") } - - let(:container) { sprint } - let(:drag_and_drop) { nil } - let(:item_component_klass) { described_class::Item } - let(:params) { {} } - let(:work_packages) { [] } - let(:system_arguments) { {} } - let(:header_arguments) { nil } - let(:footer_content) { nil } - - let(:custom_item_component_class) do - stub_const( - "CustomWorkPackageCardListItem", - Class.new(ApplicationComponent) do - def initialize( - work_package:, - project:, - container:, - params:, - current_user: User.current, - **system_arguments - ) - super() - - @work_package = work_package - @params = params - @context = [project, container, current_user] - @system_arguments = system_arguments - end - - def row_args - data = @system_arguments.fetch(:data, {}).merge( - params: @params.to_query, - context_size: @context.size - ) - - @system_arguments.merge( - id: "custom_work_package_#{@work_package.id}", - data: - ) - end - - def card - CustomWorkPackageCardListItemCard.new(subject: @work_package.subject) - end - - def render? = false - end - ) - end - - subject(:rendered_component) do - render_component(work_packages:, container:, drag_and_drop:, system_arguments:) - end - - def render_component(work_packages:, container:, drag_and_drop:, system_arguments:) - component_arguments = { - work_packages:, - project:, - container:, - drag_and_drop:, - item_component_klass:, - params:, - current_user: user, - **system_arguments - } - render_inline( - described_class.new(**component_arguments) - ) do |box| - box.with_header(**header_arguments) if header_arguments - box.with_empty_state(title: "Sprint 1 is empty", description: "Drag work packages here") - box.with_footer { footer_content } if footer_content - end - end - - before do - stub_const( - "CustomWorkPackageCardListItemCard", - Class.new(ApplicationComponent) do - def initialize(subject:) - super() - - @subject = subject - end - - def call - tag.span("custom #{@subject}") - end - end - ) - end - - describe "Box shell" do - it_behaves_like "rendering Box", row_count: 1, header: false, footer: false - it_behaves_like "rendering Blank Slate", heading: "Sprint 1 is empty" - end - - describe "container-derived attributes" do - context "when container is a Sprint" do - let(:container) { sprint } - - it "uses dom_target(sprint) as the box id" do - expect(rendered_component).to have_css(".Box#sprint_#{sprint.id}") - end - - it "uses dom_target(sprint, :list) for the collapsible BorderBox body" do - expect(rendered_component).to have_css("ul#sprint_#{sprint.id}_list") - end - - it "does not emit drag-and-drop data by default" do - expect(rendered_component).to have_no_css(".Box[data-generic-drag-and-drop-target]") - expect(rendered_component).to have_no_css(".Box[data-target-id]") - expect(rendered_component).to have_no_css(".Box[data-target-allowed-drag-type]") - end - - context "with drag_and_drop configured" do - let(:drag_and_drop) do - { target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" } - end - - it "uses the configured drag-and-drop data" do - expect(rendered_component).to have_css(".Box") do |box| - expect(box["data-generic-drag-and-drop-target"]).to eq("container") - expect(box["data-target-container-accessor"]).to eq(":scope > ul") - expect(box["data-target-id"]).to eq("sprint:#{sprint.id}") - expect(box["data-target-allowed-drag-type"]).to eq("story") - end - end - end - - it "does not emit a default test selector" do - expect(rendered_component).to have_no_css(".Box[data-test-selector]") - end - end - - context "when container is a BacklogBucket" do - let(:container) { backlog_bucket } - - it "uses dom_target(backlog_bucket) as the box id" do - expect(rendered_component).to have_css(".Box#backlog_bucket_#{backlog_bucket.id}") - end - - it "uses dom_target(backlog_bucket, :list) for the collapsible BorderBox body" do - expect(rendered_component).to have_css("ul#backlog_bucket_#{backlog_bucket.id}_list") - end - - it "does not emit a default test selector" do - expect(rendered_component).to have_no_css(".Box[data-test-selector]") - end - end - - context "when container is a Symbol" do - let(:container) { :inbox } - - it "uses dom_target(container) as the box id" do - expect(rendered_component).to have_css(".Box#inbox") - end - - it "uses dom_target(container, :list) for the list id" do - expect(rendered_component).to have_css("ul#inbox_list") - end - end - - context "when container is a String" do - let(:container) { "custom_box" } - - it "uses dom_target(container) as the box id" do - expect(rendered_component).to have_css(".Box#custom_box") - end - - it "uses dom_target(container, :list) for the list id" do - expect(rendered_component).to have_css("ul#custom_box_list") - end - end - - context "when container is a model class" do - let(:container) { Project } - - it "uses dom_target(container) as the box id" do - expect(rendered_component).to have_css(".Box#project") - end - - it "uses dom_target(container, :list) for the list id" do - expect(rendered_component).to have_css("ul#project_list") - end - end - - context "when data[:test_selector] is provided by the caller" do - let(:system_arguments) { { data: { test_selector: "custom-sprint-box" } } } - - it "passes the custom test selector through" do - expect(rendered_component).to have_css(".Box[data-test-selector='custom-sprint-box']") - end - end - end - - describe ":header slot" do - context "when no header is supplied" do - it "renders no Box-header" do - expect(rendered_component).to have_no_css(".Box-header") - end - end - - context "when a header is supplied" do - let(:header_arguments) { { title: "Sprint 1", count: 0 } } - - it_behaves_like "rendering Box", row_count: 1, header: true, footer: false - - it "renders the provided title" do - expect(rendered_component).to have_heading "Sprint 1", level: 4 - end - - it "uses dom_target(container, :header) as the header row id" do - expect(rendered_component).to have_css(".Box-header#sprint_#{sprint.id}_header") - end - end - end - - describe "fold state in the rendered header" do - let(:header_arguments) { { title: "Sprint 1", count: 0 } } - - context "when the user prefers closed folds" do - before do - user.pref[:backlogs_versions_default_fold_state] = "closed" - end - - it "renders the header as collapsed" do - expect(rendered_component).to have_css ".CollapsibleHeader-triggerArea", aria: { expanded: "false" } - end - end - - context "when the user prefers open folds" do - before do - user.pref[:backlogs_versions_default_fold_state] = "open" - end - - it "renders the header as expanded" do - expect(rendered_component).to have_css ".CollapsibleHeader-triggerArea", aria: { expanded: "true" } - end - end - end - - describe ":empty_state slot" do - it "requires the empty_state slot" do - expect do - render_inline(described_class.new(work_packages: [], project:, container: sprint, current_user: user)) do |box| - box.with_footer { "" } - end - end.to raise_error(ArgumentError, /empty_state slot is required when no work package items are rendered/) - end - - it "renders the blankslate when work_packages is empty" do - expect(rendered_component).to have_text("Sprint 1 is empty") - expect(rendered_component).to have_text("Drag work packages here") - end - - context "when there are work packages" do - let(:work_packages) do - [ - create(:work_package, project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1) - ] - end - - it "does not render the blankslate" do - expect(rendered_component).to have_no_css(".blankslate") - end - end - end - - describe ":footer slot" do - let(:footer_content) { "footer-content" } - - it "renders the footer row when supplied" do - expect(rendered_component).to have_text("footer-content") - end - end - - describe "items collection" do - let(:work_packages) do - [ - create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1), - create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 2) - ] - end - - it_behaves_like "rendering Box", row_count: 2, header: false, footer: false - - it "renders one row per work package" do - expect(rendered_component).to have_text("WP A") - expect(rendered_component).to have_text("WP B") - end - - it "applies the card row attributes in the rendered HTML" do - work_package = work_packages.first - - expect(rendered_component).to have_css( - ".Box-row#work_package_#{work_package.id}.Box-row--clickable[data-test-selector='work-package-#{work_package.id}']" - ) - end - - it "does not include Backlogs row wiring by default" do - expect(rendered_component).to have_css(".Box-row", count: 2) - expect(rendered_component).to have_no_css(".Box-row[data-controller='backlogs--story']") - expect(rendered_component).to have_no_css(".Box-row[data-drop-url]") - expect(rendered_component).to have_no_css(".Box-row[data-backlogs--story-split-url-value]") - end - - context "with an item_component_klass" do - let(:item_component_klass) { custom_item_component_class } - - it "uses the configured item class for automatically built items" do - expect(rendered_component).to have_css( - ".Box-row#custom_work_package_#{work_packages.first.id}", - text: "custom WP A" - ) - expect(rendered_component).to have_css( - ".Box-row#custom_work_package_#{work_packages.second.id}", - text: "custom WP B" - ) - end - end - end - - describe ":work_package_item slot" do - let(:work_packages) do - [ - create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1), - create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 2) - ] - end - let(:params) { { all: 1 } } - let(:slot_work_package) { work_packages.first } - - def render_with_manual_item - render_inline( - described_class.new(work_packages: [], project:, container:, params:, current_user: user) - ) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_work_package_item( - work_package: slot_work_package, - component_klass: custom_item_component_class, - data: { source: "slot" } - ) - end - end - - it "builds rows with the configured item component class" do - rendered = render_with_manual_item - - expect(rendered).to have_css(".Box-row#custom_work_package_#{work_packages.first.id}", text: "custom WP A") - expect(rendered).to have_css(".Box-row[data-source='slot']") - expect(rendered).to have_css(".Box-row[data-params='all=1']") - end - - it "does not also build automatic work package rows" do - rendered = render_inline( - described_class.new(work_packages:, project:, container:, params:, current_user: user) - ) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_work_package_item(work_package: slot_work_package) - end - - expect(rendered).to have_css(".Box-row", count: 1) - expect(rendered).to have_text("WP A") - expect(rendered).to have_no_text("WP B") - end - - it "uses caller-provided metric content for manual work package items" do - rendered = render_inline( - described_class.new(project:, container:, params:, current_user: user) - ) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_work_package_item(work_package: slot_work_package) do |item| - item.with_metric { "manual metric" } - end - end - - expect(rendered).to have_text("manual metric") - end - - it "exposes build_item for building an item without adding it to the box" do - component = described_class.new(work_packages: [], project:, container:, params:, current_user: user) - - item = component.build_item( - work_package: slot_work_package, - component_klass: custom_item_component_class, - data: { source: "builder" } - ) - - expect(item.row_args).to include( - id: "custom_work_package_#{slot_work_package.id}", - data: { params: "all=1", context_size: 3, source: "builder" } - ) - end - end - - describe ":empty_item slot" do - it "renders a caller-provided empty item row" do - rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box| - box.with_empty_item(data: { test_selector: "manual-empty-item" }) do - "Nothing to show" - end - end - - expect(rendered).to have_css( - ".Box-row[data-empty-list-item='true'][data-test-selector='manual-empty-item']", - text: "Nothing to show" - ) - end - - it "raises when combined with automatic work packages" do - work_package = create(:work_package, project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1) - - expect do - render_inline( - described_class.new(work_packages: [work_package], project:, container:, current_user: user) - ) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_empty_item { "Nothing to show" } - end - end.to raise_error(ArgumentError, /empty_item cannot be combined with work_packages/) - end - - it "raises when combined with manual work package items" do - work_package = create(:work_package, project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1) - - expect do - render_inline(described_class.new(project:, container:, current_user: user)) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_work_package_item(work_package:) - box.with_empty_item { "Nothing to show" } - end - end.to raise_error(ArgumentError, /empty_item cannot be combined with other items/) - end - - it "raises when combined with generic items" do - expect do - render_inline(described_class.new(project:, container:, current_user: user)) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_item { "Manual item" } - box.with_empty_item { "Nothing to show" } - end - end.to raise_error(ArgumentError, /empty_item cannot be combined with other items/) - end - end - - describe ":item slot" do - let(:work_packages) do - [ - create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 1), - create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status, - priority: default_priority, sprint:, position: 2) - ] - end - - it "renders caller-provided content with caller-provided item arguments" do - rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_item( - id: "manual-item", - scheme: :neutral, - data: { test_selector: "manual-item" } - ) do - "Manual item" - end - end - - expect(rendered).to have_css( - ".Box-row#manual-item[data-test-selector='manual-item']", - text: "Manual item" - ) - end - - it "can be interleaved with work package item rows" do - rendered = render_inline(described_class.new(project:, container:, current_user: user)) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_work_package_item(work_package: work_packages.first) - box.with_item(id: "manual-item") { "Manual item" } - box.with_work_package_item(work_package: work_packages.second) - end - - expect(rendered).to have_css(".Box-row", count: 3) - expect(rendered).to have_css("li.Box-row:nth-child(1)", text: "WP A") - expect(rendered).to have_css("li.Box-row:nth-child(2)#manual-item", text: "Manual item") - expect(rendered).to have_css("li.Box-row:nth-child(3)", text: "WP B") - end - - it "does not build automatic work package rows when manual rows are supplied" do - rendered = render_inline( - described_class.new(work_packages:, project:, container:, current_user: user) - ) do |box| - box.with_empty_state(title: "empty", description: "drag here") - box.with_item(id: "manual-item") { "Manual item" } - end - - expect(rendered).to have_css(".Box-row", count: 1) - expect(rendered).to have_text("Manual item") - expect(rendered).to have_no_text("WP A") - expect(rendered).to have_no_text("WP B") - end - end -end