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..7dbf1e2fbba2 --- /dev/null +++ b/app/components/open_project/common/border_box_list_component.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + # A Primer BorderBox-backed list composition with optional header, items, + # empty state, and footer. + # + # Use this component for compact lists that need consistent OpenProject + # header actions, collapsible behavior, and row rendering. + class BorderBoxListComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :container, :collapsible, :current_user, :header_id, :footer_id + + alias_method :collapsible?, :collapsible + + # Optional header row. + # + # @!parse + # # Adds the optional header row. + # # + # # @param system_arguments [Hash] forwarded to {Header}. List wiring + # # arguments are supplied internally. + # # @return [ViewComponent::Slot] + # def with_header(**system_arguments, &block) + # end + renders_one :header, ->(**system_arguments) { + system_arguments = system_arguments.except(:id, :list_id) + system_arguments[:id] = header_id + system_arguments[:list_id] = list_id + system_arguments[:interactive] = interactive? + system_arguments[:collapsible] = collapsible? + + Header.new(**system_arguments) + } + + # List row content. + # + # Use: + # + # - `item` for generic row content. + # - `work_package_item` for rows backed by a work package card. + # + # @!parse + # # Adds a generic list row. + # # + # # @param system_arguments [Hash] forwarded to Primer's BorderBox row. + # # @return [ViewComponent::Slot] + # def with_item(**system_arguments, &block) + # end + # + # # Adds a work-package list row. + # # + # # @param work_package [WorkPackage] work package rendered by the row. + # # @param project [Project] project context for the work package. + # # @param params [Hash] request params used by specialized item classes. + # # @param component_klass [Class] item component class to instantiate. + # # @param item_arguments [Hash] forwarded to the item component. + # # @return [ViewComponent::Slot] + # def with_work_package_item( + # work_package:, + # project: work_package.project, + # params: {}, + # component_klass: WorkPackageItem, + # **item_arguments, + # &block + # ) + # end + renders_many :items, types: { + item: { + renders: ->(**system_arguments) { + Item.new(**system_arguments) + }, + as: :item + }, + work_package_item: { + renders: ->( + work_package:, + project: work_package.project, + params: {}, + component_klass: WorkPackageItem, + **item_arguments + ) { + component_klass.new( + work_package:, + project:, + params:, + container:, + current_user:, + **item_arguments + ) + }, + as: :work_package_item + } + } + + # Optional empty-state content rendered when no items are present. + # + # @!parse + # # Adds empty-state content. + # # + # # @param title [String] empty-state title. + # # @param description [String, nil] optional supporting text. + # # @param icon [Symbol, nil] optional Primer icon. + # # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`. + # # @return [ViewComponent::Slot] + # def with_empty_state(title:, description: nil, icon: nil, **system_arguments) + # end + renders_one :empty_state, ->(title:, description: nil, icon: nil, **system_arguments) { + EmptyState.new(title:, description:, icon:, interactive: interactive?, **system_arguments) + } + + # Optional footer row. + # + # @!parse + # # Adds an optional footer row. + # # + # # @param system_arguments [Hash] forwarded to Primer's BorderBox + # # footer. The `id` is generated internally for collapsible header + # # wiring. + # # @return [ViewComponent::Slot] + # def with_footer(**system_arguments, &block) + # end + renders_one :footer, ->(**system_arguments) { + system_arguments = system_arguments.except(:id) + system_arguments[:id] = footer_id + + Footer.new(**system_arguments) + } + + # @param container [String, Symbol, Class, Object] value passed to + # `dom_target` to derive DOM ids for the list and related controls. + # @param interactive [Boolean] whether dynamic list updates should be + # announced politely to assistive technology. + # @param collapsible [Boolean] whether the header renders a collapsible + # toggle. Defaults to `false`. + # @param current_user [User] user context passed to work-package items. + # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox`. + # Pass `id:` to set the box id; related ids are derived from it. + def initialize( + container:, + interactive: false, + collapsible: false, + current_user: User.current, + **system_arguments + ) + super() + + @container = container + @interactive = interactive + @collapsible = collapsible + @current_user = current_user + @system_arguments = system_arguments.except(:list_id) + + @system_arguments[:id] ||= dom_target(container) + @system_arguments[:list_id] = dom_target(@system_arguments[:id], :list) + @header_id = dom_target(@system_arguments[:id], :header) + @footer_id = dom_target(@system_arguments[:id], :footer) + end + + def before_render + content + configure_header! + end + + def render? + header? || items.any? || empty_state? || footer? + end + + private + + def interactive? + @interactive == true + end + + def configure_header! + return unless header? + + header.resolve_count!(items.size) + return unless collapsible? && footer? + + header.collapsible_id = [list_id, footer_id].compact.join(" ") + end + + def list_id + @system_arguments[:list_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/app/components/open_project/common/border_box_list_component/empty_state.rb b/app/components/open_project/common/border_box_list_component/empty_state.rb new file mode 100644 index 000000000000..225c08b0d32a --- /dev/null +++ b/app/components/open_project/common/border_box_list_component/empty_state.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + class BorderBoxListComponent + # Empty-state content rendered as a Primer Blankslate. + # + # This component is part of {BorderBoxListComponent} and should not be + # used as a standalone component. + # + class EmptyState < ApplicationComponent + include Primer::AttributesHelper + + # @param title [String] empty-state heading. + # @param description [String, nil] optional supporting text. + # @param icon [Symbol, nil] optional Primer icon. + # @param interactive [Boolean] whether empty-state updates should be + # announced politely to assistive technology. + # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`. + def initialize(title:, description: nil, icon: nil, interactive: false, **system_arguments) + super() + + @title = title + @description = description + @icon = icon + + @system_arguments = system_arguments + return unless interactive + + @system_arguments[:role] ||= "status" + @system_arguments[:aria] = merge_aria( + { aria: { live: "polite" } }, + @system_arguments + ) + end + + def call + 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: @icon) if @icon + + 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 64% 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..e4912c095518 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,37 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Row bridge for caller-provided empty content. - class EmptyItem < ContentItem - include Primer::AttributesHelper + class BorderBoxListComponent + # Footer row rendered below list items. + # + # This component is part of {BorderBoxListComponent} and should not be + # used as a standalone component. + class Footer < ApplicationComponent + attr_reader :id - def row_args - system_arguments = @system_arguments.deep_dup - system_arguments[:data] = merge_data( - { data: { empty_list_item: true } }, - system_arguments - ) - system_arguments + # @param system_arguments [Hash] forwarded to Primer's BorderBox footer. + def initialize(**system_arguments) + super() + + @id = system_arguments[:id] + @system_arguments = system_arguments + end + + # @return [Hash] arguments forwarded to Primer's BorderBox footer. + 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 58% 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..60beea91b76e 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,60 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - class Header < ApplicationComponent - include OpPrimer::ComponentHelpers + class BorderBoxListComponent + # Adds the standard list action menu slot used by list headers and items. + module HasMenu + extend ActiveSupport::Concern + include Primer::ClassNameHelper - renders_one :description - - renders_many :actions, types: { - button: ->(**system_arguments) do - Primer::Beta::Button.new(**system_arguments) + included do + # @!parse + # # Adds a trailing action menu. + # # + # # @param menu_id [String, nil] id prefix for the Primer action menu. + # # @param button_aria_label [String, nil] accessible label for the + # # menu button. + # # @param system_arguments [Hash] forwarded to + # # `Primer::Alpha::ActionMenu`. + # # @return [ViewComponent::Slot] + # def with_menu(menu_id: nil, button_aria_label: nil, **system_arguments, &block) + # end + renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do + build_menu(menu_id:, button_aria_label:, **system_arguments) end - } + end + + 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..3ef158da2d25 --- /dev/null +++ b/app/components/open_project/common/border_box_list_component/header.html.erb @@ -0,0 +1,82 @@ +<%# -- 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-border-box-list-header", tag: :div) do |grid| %> + <% grid.with_area(:collapsible) do %> + <% if collapsible? %> + <%= + render( + Primer::OpenProject::BorderBox::CollapsibleHeader.new( + collapsible_id:, + collapsed:, + multi_line: true + ) + ) do |collapsible| + %> + <% collapsible.with_title(tag: title_tag) { title } %> + <% if render_count? %> + <% collapsible.with_count(**counter_arguments) %> + <% end %> + <% if description? %> + <% collapsible.with_description do %> + <%= description %> + <% end %> + <% end %> + <% end %> + <% else %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader CollapsibleHeader--multi-line")) do %> + <%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader-title-line")) do %> + <%= render(Primer::Beta::Truncate.new(tag: title_tag, classes: "CollapsibleHeader-title Box-title")) { title } %> + <% if render_count? %> + <%= render(Primer::Beta::Counter.new(**counter_arguments, classes: class_names(counter_arguments[:classes], "CollapsibleHeader-count"))) %> + <% end %> + <% end %> + <% if description? %> + <%= render(Primer::Beta::Text.new(color: :subtle, trim: true, classes: "CollapsibleHeader-description")) do %> + <%= description %> + <% end %> + <% 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..6d5a516b77be --- /dev/null +++ b/app/components/open_project/common/border_box_list_component/header.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + class BorderBoxListComponent + # Structured header for {BorderBoxListComponent}. + # + # This component is part of {BorderBoxListComponent} and should not be + # used as a standalone component. + # + # The header renders through `Primer::Beta::BorderBox#with_header` and + # wraps the supplied title, count, description, actions, and menu in an + # `Primer::OpenProject::BorderBox::CollapsibleHeader`. + class Header < ApplicationComponent + include OpPrimer::ComponentHelpers + include Primer::AttributesHelper + include HasMenu + + DEFAULT_ACTION_SCHEME = :default + + DEFAULT_COUNT_ARGUMENTS = { + scheme: :primary, + round: true, + limit: 1_000, + hide_if_zero: true + }.freeze + + # @!parse + # # Adds secondary content below the header title. + # # + # # @return [ViewComponent::Slot] + # def with_description(&block) + # end + renders_one :description + + # @!parse + # # Adds a button to the header actions area. + # # + # # @param system_arguments [Hash] forwarded to `Primer::Beta::Button`. + # # @return [ViewComponent::Slot] + # def with_action_button(**system_arguments, &block) + # end + renders_many :actions, types: { + button: ->(scheme: DEFAULT_ACTION_SCHEME, **system_arguments) do + Primer::Beta::Button.new(scheme:, **system_arguments) + end + } + + attr_reader :title, + :count, + :count_label, + :count_arguments, + :title_tag, + :list_id, + :interactive, + :collapsed, + :collapsible + + attr_writer :collapsible_id + + # @param title [String] header title. + # @param count [Integer, Boolean, nil] count badge behavior. Pass + # `nil` or `false` to hide it, `true` to infer the rendered item + # count, or an integer to render an explicit value. + # @param count_label [String, nil] accessible label for the counter + # badge. Defaults to `I18n.t(:label_x_items, count:)` when a count + # is rendered. Pass an explicit string to override. + # @param count_arguments [Hash] forwarded to `Primer::Beta::Counter`. + # Values are merged over the default counter arguments. + # @param title_tag [Symbol] tag used for the title heading. + # @param list_id [String, nil] id of the collapsible list body. + # @param interactive [Boolean] whether counter updates should be + # announced politely to assistive technology. + # @param collapsed [Boolean] whether the collapsible header starts closed. + # @param collapsible [Boolean] whether the header renders a collapsible + # toggle. Defaults to `true`. Pass `false` to render a plain title + # without a toggle button. + # @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_header`. + def initialize( + title:, + count: nil, + count_label: nil, + count_arguments: {}, + title_tag: :h4, + list_id: nil, + interactive: false, + collapsed: false, + collapsible: true, + **system_arguments + ) + super() + + @title = title + @count = count + @count_label = count_label + @count_arguments = count_arguments + @title_tag = title_tag + @list_id = list_id + @interactive = interactive + @collapsible_id = list_id + @collapsed = collapsed + @collapsible = collapsible + @system_arguments = system_arguments + end + + # @return [Boolean] whether a collapsible toggle should be rendered. + def collapsible? + collapsible + end + + # Resolves inferred counts after the list slots have been captured. + # + # @param item_count [Integer] number of rendered item slots. + # @return [void] + def resolve_count!(item_count) + @count = item_count if count == true + @count_label ||= I18n.t(:label_x_items, count: @count) if render_count? + end + + # @return [Hash] arguments forwarded to `Primer::Beta::BorderBox#with_header`. + def row_args + @system_arguments.deep_dup + end + + # @return [Boolean] whether a counter should be rendered. + def render_count? + !count.nil? && count != false + end + + # @return [Hash] merged arguments forwarded to `Primer::Beta::Counter`. + def counter_arguments + merged = DEFAULT_COUNT_ARGUMENTS.merge(count_arguments).merge(count:) + default_aria = { label: count_label } + default_aria[:live] = "polite" if interactive + merged[:aria] = merge_aria( + { aria: default_aria }, + merged + ) + merged + end + + # @return [String, nil] ids controlled by the collapsible header. + def collapsible_id + @collapsible_id.presence + end + + private + + def default_menu_id + list_id ? "#{list_id}_menu" : super + end + end + end + end +end 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 82% 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..dcd8079c7c90 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,32 +23,28 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Item bridge for caller-provided content. - class ContentItem < ApplicationComponent + class BorderBoxListComponent + # Generic BorderBox list row that renders the slot content directly. + class Item < ApplicationComponent + # @param system_arguments [Hash] forwarded to Primer's BorderBox row. def initialize(**system_arguments) super() @system_arguments = system_arguments end + # @return [Hash] arguments forwarded to Primer's BorderBox row. def row_args @system_arguments.deep_dup end - def card - self - end - - def empty_item? = false - def call content end 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 61% 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..32537fd9ca22 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,19 @@ # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. #++ module OpenProject module Common - class WorkPackageCardListComponent - # Internal row bridge between the card list and the visual card. It owns - # the surrounding BorderBox row arguments while `WorkPackageCardComponent` - # renders the card body. - class Item < ApplicationComponent + class BorderBoxListComponent + # BorderBox list row that renders a work package card. + # + # Specialized rows can subclass this component and override `build_card` + # to provide a different card component while keeping row behavior. + class WorkPackageItem < ApplicationComponent include ActionView::RecordIdentifier include Primer::ClassNameHelper include Primer::AttributesHelper @@ -45,8 +46,16 @@ class Item < ApplicationComponent :params, :current_user - delegate :with_metric, to: :card + # Delegates card slots so callers can configure the rendered card from + # `with_work_package_item`. + delegate :with_metric, :with_menu, to: :card + # @param work_package [WorkPackage] work package rendered by the card. + # @param project [Project] project context for row behavior. + # @param container [String, Array] parent list container id seed. + # @param params [Hash] request params used by specialized item classes. + # @param current_user [User] user context for specialized item classes. + # @param system_arguments [Hash] forwarded to Primer's BorderBox row. def initialize( work_package:, project:, @@ -65,11 +74,16 @@ def initialize( @system_arguments = system_arguments end + # @return [Hash] arguments forwarded to Primer's BorderBox row. def row_args row_arguments = @system_arguments.deep_dup row_arguments[:id] ||= dom_id(work_package) row_arguments[:tabindex] ||= 0 - row_arguments[:classes] = class_names(row_classes, row_arguments[:classes]) + row_arguments[:test_selector] ||= "work-package-#{work_package.id}" + row_arguments[:classes] = class_names( + row_classes, + row_arguments[:classes] + ) row_arguments[:data] = merge_data( { data: row_data }, row_arguments @@ -77,16 +91,28 @@ def row_args row_arguments end - def card - @card ||= WorkPackageCardComponent.new(work_package:) + def before_render + content end - def render? = false + def call + render(card) + end - def empty_item? = false + # @return [ApplicationComponent] card component rendered inside the row. + def card + @card ||= build_card + end private + # Override in subclasses to render a specialized work-package card. + # + # @return [ApplicationComponent] + def build_card + WorkPackageCardComponent.new(work_package:) + end + def row_classes class_names( "Box-row--hover-blue", @@ -97,11 +123,7 @@ def row_classes end def row_data - data = { - test_selector: "work-package-#{work_package.id}" - } - - draggable? ? data.merge(draggable_data) : data + draggable? ? draggable_data : {} end def draggable? 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/config/locales/en.yml b/config/locales/en.yml index 46c674d24539..d22edc243f73 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4592,6 +4592,10 @@ en: one: "1 comment" other: "%{count} comments" zero: "no comments" + label_x_items: + one: "1 item" + other: "%{count} items" + zero: "no items" label_x_open_work_packages_abbr: one: "1 open" other: "%{count} open" @@ -4934,13 +4938,6 @@ en: work_package_card_component: menu: label_actions: "Work package actions" - work_package_card_list_component: - header: - label_actions: "Open menu" - label_work_package_count: - zero: "No work packages" - one: "%{count} work package" - other: "%{count} work packages" permission_add_work_package_comments: "Add comments" permission_add_work_packages: "Add work packages" diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass index 0b6eb6746fb6..2e2eeea80abb 100644 --- a/frontend/src/global_styles/openproject.sass +++ b/frontend/src/global_styles/openproject.sass @@ -20,6 +20,7 @@ // Module specific Styles @import "../../../modules/auth_saml/app/components/_index.sass" +@import "../../../modules/backlogs/app/components/_index.sass" @import "../../../modules/costs/app/components/_index.sass" @import "../../../modules/documents/app/assets/stylesheets/_index.sass" @import "../../../modules/documents/app/components/_index.sass" 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..4a406b870556 --- /dev/null +++ b/lookbook/previews/open_project/common/border_box_list_component_preview.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module OpenProject + module Common + # @logical_path OpenProject/Common + class BorderBoxListComponentPreview < ViewComponent::Preview + # @label Default + # @param interactive toggle + # @param collapsible toggle + def default(interactive: false, collapsible: false) + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-preview", + interactive: boolean_preview_param(interactive), + collapsible: boolean_preview_param(collapsible) + ) do |list| + list.with_header(title: "Things we're building", count: true) do |header| + header.with_description { "There's lots to look forward to" } + header.with_action_button do |button| + button.with_leading_visual_icon(icon: :pencil) + "Edit" + end + header.with_menu(button_aria_label: "List actions") do |menu| + menu.with_item(label: "Configure") do |menu_item| + menu_item.with_leading_visual_icon(icon: :gear) + end + end + end + + list.with_item { "Prioritized project launch" } + list.with_item { "Updated status reporting" } + list.with_item { "Shared team calendar" } + list.with_footer { "Next launch window: October" } + end + end + + # @label With work package items + # @param interactive toggle + # @param collapsible toggle + def with_work_package_items(interactive: false, collapsible: false) + work_packages = WorkPackage.includes(:project).limit(2).to_a + return preview_message("No work packages in the database.") if work_packages.empty? + + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-work-package-preview", + interactive: boolean_preview_param(interactive), + collapsible: boolean_preview_param(collapsible) + ) do |list| + list.with_header(title: "Work packages", count: true) + render_work_package_items(list, work_packages) + end + end + + # @label Playground + # @param collapsible toggle + # @param title_tag [Symbol] select [h2, h3, h4, h5] + # @param count [Symbol] select [inferred, hidden, explicit, zero] + # @param count_scheme [Symbol] select [primary, secondary] + # @param hide_zero_count toggle + # @param interactive toggle + def playground( + collapsible: false, + title_tag: :h4, + count: :inferred, + count_scheme: :primary, + hide_zero_count: true, + interactive: false + ) + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-playground-preview", + interactive: boolean_preview_param(interactive), + collapsible: boolean_preview_param(collapsible) + ) do |list| + list.with_header( + title: "Playground list", + title_tag: title_tag.to_sym, + count: preview_count(count), + count_arguments: { + scheme: count_scheme.to_sym, + hide_if_zero: boolean_preview_param(hide_zero_count), + aria: { label: "Visible list item count" } + } + ) do |header| + header.with_description { "Advanced header options" } + end + + list.with_item { "First item" } + list.with_item { "Second item" } + list.with_footer { "Footer content" } + end + end + + # @label Empty state + # List with a header and an empty state (Blankslate), no items. + # @param interactive toggle + # @param collapsible toggle + def empty_state(interactive: false, collapsible: false) + render OpenProject::Common::BorderBoxListComponent.new( + container: "border-box-list-empty-preview", + interactive: boolean_preview_param(interactive), + collapsible: boolean_preview_param(collapsible) + ) do |list| + list.with_header(title: "Empty list", count: 0) + list.with_empty_state( + title: "No items yet", + description: "There is nothing to show." + ) + end + end + + private + + def preview_count(count) + case count.to_sym + when :inferred + true + when :hidden + false + when :explicit + 7 + when :zero + 0 + end + end + + def boolean_preview_param(value) + ActiveModel::Type::Boolean.new.cast(value) + end + + def preview_message(text) + render(Primer::Beta::Blankslate.new) do |blankslate| + blankslate.with_heading(tag: :h4).with_content(text) + end + end + + def render_work_package_items(list, work_packages) + work_packages.each do |work_package| + list.with_work_package_item(work_package:) do |item| + item.with_menu(button_aria_label: "Work package actions") do |menu| + menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}") do |menu_item| + menu_item.with_leading_visual_icon(icon: :link) + end + end + end + end + end + end + 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/_index.sass b/modules/backlogs/app/components/_index.sass new file mode 100644 index 000000000000..f28a7206a853 --- /dev/null +++ b/modules/backlogs/app/components/_index.sass @@ -0,0 +1 @@ +@import "./backlogs/work_package_card_list_component.sass" diff --git a/modules/backlogs/app/components/backlogs/bucket_component.html.erb b/modules/backlogs/app/components/backlogs/bucket_component.html.erb index 30f81119db2d..e9829d3d0e7c 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,13 +37,12 @@ See COPYRIGHT and LICENSE files for more details. target_id: "backlog_bucket:#{backlog_bucket.id}", allowed_drag_type: "story" }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, params: all_backlogs_params, current_user:, - data: { test_selector: "backlog-bucket-#{backlog_bucket.id}" } + test_selector: "backlog-bucket-#{backlog_bucket.id}" ) ) do |list| %> - <% list.with_header(title: backlog_bucket.name, count: work_packages.size) do |header| %> + <% list.with_header(title: backlog_bucket.name) do |header| %> <% if show_menu? %> <% header.with_menu(button_aria_label: t(".label_actions")) do |menu| %> <% with_item_group(menu) do %> diff --git a/modules/backlogs/app/components/backlogs/inbox_component.html.erb b/modules/backlogs/app/components/backlogs/inbox_component.html.erb index f8a6e7eff2ad..e2f05287ec0d 100644 --- a/modules/backlogs/app/components/backlogs/inbox_component.html.erb +++ b/modules/backlogs/app/components/backlogs/inbox_component.html.erb @@ -28,18 +28,20 @@ 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" } + interactive: true, + padding: :condensed, + test_selector: "backlog-inbox", + data: { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", + target_id: "inbox", + target_allowed_drag_type: "story" + } ) ) do |list| %> <% list.with_empty_state( @@ -50,7 +52,12 @@ See COPYRIGHT and LICENSE files for more details. ) %> <% 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..c798db47e126 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,13 +37,12 @@ See COPYRIGHT and LICENSE files for more details. target_id: "sprint:#{sprint.id}", allowed_drag_type: "story" }, - item_component_klass: Backlogs::WorkPackageCardListItemComponent, params: all_backlogs_params, current_user:, - data: { test_selector: "sprint-#{sprint.id}" } + test_selector: "sprint-#{sprint.id}" ) ) do |list| %> - <% list.with_header(title: sprint.name, count: work_packages.size) do |header| %> + <% list.with_header(title: sprint.name) do |header| %> <% header.with_description do %> <%= render( Primer::Beta::Text.new( diff --git a/modules/backlogs/app/components/backlogs/sprint_component.rb b/modules/backlogs/app/components/backlogs/sprint_component.rb index 2f89621b86db..033112f12148 100644 --- a/modules/backlogs/app/components/backlogs/sprint_component.rb +++ b/modules/backlogs/app/components/backlogs/sprint_component.rb @@ -68,7 +68,7 @@ def disable_start_sprint_action? end def start_sprint_button_arguments - base_arguments = { id: dom_target(sprint, :start_button), scheme: :invisible } + base_arguments = { id: dom_target(sprint, :start_button) } if disable_start_sprint_action? base_arguments.merge(tag: :button, inactive: true, aria: { disabled: true }) @@ -84,7 +84,6 @@ def start_sprint_button_arguments def finish_sprint_button_arguments { id: dom_target(sprint, :finish_button), - scheme: :invisible, tag: :a, href: finish_project_backlogs_sprint_path(project, sprint, all_backlogs_params), data: { turbo_method: :post } 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..5122de033ed0 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Backlogs + class WorkPackageCardListComponent < ApplicationComponent + include Primer::AttributesHelper + include OpPrimer::ComponentHelpers + + delegate :with_empty_state, :with_footer, :empty_state?, to: :@list + + attr_reader :work_packages, + :project, + :container, + :drag_and_drop, + :params, + :current_user + + def initialize( + project:, + container:, + work_packages: nil, + drag_and_drop: nil, + params: {}, + current_user: User.current, + **system_arguments + ) + super() + + @work_packages = work_packages || [] + @project = project + @container = container + @drag_and_drop = drag_and_drop + @params = params + @current_user = current_user + + @system_arguments = system_arguments + @system_arguments[:padding] = :condensed + merge_drag_and_drop_data! if drag_and_drop + + @list = OpenProject::Common::BorderBoxListComponent.new( + container:, + current_user:, + interactive: true, + **@system_arguments + ) + end + + def with_header( + title:, + count: work_packages.size, + count_label: default_count_label(count), + **system_arguments, + & + ) + @list.with_header( + title:, + count:, + count_label:, + **system_arguments, + & + ) + end + + def before_render + content + populate_list! + validate_empty_state! + end + + def call + render(@list) + end + + private + + def merge_drag_and_drop_data! + @system_arguments[:data] = merge_data( + { + data: drag_and_drop_data + }, + @system_arguments + ) + end + + def drag_and_drop_data + { + generic_drag_and_drop_target: "container", + target_container_accessor: ":scope > ul", + target_id: drag_and_drop.fetch(:target_id), + target_allowed_drag_type: drag_and_drop.fetch(:allowed_drag_type) + } + end + + def default_count_label(count) + return unless count + + I18n.t(:label_x_work_packages, count: count == true ? work_packages.size : count) + end + + def populate_list! + return if work_packages.empty? + + work_packages.each do |work_package| + @list.with_work_package_item( + work_package:, + project:, + params:, + component_klass: Backlogs::WorkPackageCardListItemComponent + ) + end + end + + def validate_empty_state! + return unless work_packages.empty? && !empty_state? + + raise ArgumentError, "empty_state slot is required when no work package items are rendered" + end + end +end 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..3197d1f1c5df 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,13 +29,13 @@ #++ module Backlogs - class WorkPackageCardListItemComponent < OpenProject::Common::WorkPackageCardListComponent::Item - def card - @card ||= WorkPackageCardComponent.new(work_package:, menu_src:) - end - + class WorkPackageCardListItemComponent < OpenProject::Common::BorderBoxListComponent::WorkPackageItem private + def build_card + WorkPackageCardComponent.new(work_package:, menu_src:) + end + def draggable? current_user.allowed_in_project?(:manage_sprint_items, project) end diff --git a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb index 29e14317e1cd..31999c1b46fb 100644 --- a/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/bucket_component_spec.rb @@ -89,8 +89,7 @@ def render_component expect(rendered_component).to have_css( ".Counter", text: "1", - aria: { label: I18n.t("open_project.common.work_package_card_list_component.header.label_work_package_count", - count: 1) } + aria: { label: I18n.t(:label_x_work_packages, count: 1), live: "polite" } ) end diff --git a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb index 0e362579e57d..09c17f7c472b 100644 --- a/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/inbox_component_spec.rb @@ -69,6 +69,10 @@ def render_component expect(box["data-target-allowed-drag-type"]).to eq("story") end end + + it "announces dynamic empty-state updates" do + expect(page).to have_role(:status, aria: { live: "polite" }) + end end describe "empty state" do diff --git a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb index d35f6ddea962..9fafa5ff4e7a 100644 --- a/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/sprint_component_spec.rb @@ -79,6 +79,14 @@ def menu_items expect(rendered_component).to have_text("8 points", normalize_ws: true) end + it "renders the inferred work-package count in the header" do + expect(rendered_component).to have_css( + ".Counter", + text: "2", + aria: { label: I18n.t(:label_x_work_packages, count: 2), live: "polite" } + ) + end + it "renders story points on each work package card" do expect(rendered_component).to have_css("span", text: "5", aria: { hidden: true }) expect(rendered_component).to have_css(".sr-only", text: "5 story points") 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..d9246595ff8a --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Backlogs::WorkPackageCardListComponent, type: :component do + include Rails.application.routes.url_helpers + + shared_let(:type_feature) { create(:type_feature) } + shared_let(:default_status) { create(:default_status) } + shared_let(:default_priority) { create(:default_priority) } + shared_let(:user) { create(:admin) } + current_user { user } + + shared_let(:project) { create(:project, types: [type_feature]) } + shared_let(:sprint) do + create(:sprint, project:, name: "Sprint 1", + start_date: Date.yesterday, finish_date: Date.tomorrow) + end + shared_let(:backlog_bucket) { create(:backlog_bucket, project:, name: "Bucket A") } + + let(:container) { sprint } + let(:drag_and_drop) { nil } + let(:params) { {} } + let(:work_packages) { [] } + let(:header_arguments) { nil } + let(:footer_content) { nil } + + subject(:rendered_component) do + render_component(work_packages:, container:, drag_and_drop:) + end + + def render_component(work_packages:, container:, drag_and_drop:) + render_inline( + described_class.new( + work_packages:, + project:, + container:, + drag_and_drop:, + params:, + current_user: user + ) + ) do |box| + box.with_header(**header_arguments) if header_arguments + box.with_empty_state(title: "Sprint 1 is empty", description: "Drag work packages here") + box.with_footer { footer_content } if footer_content + end + end + + describe "automatic work_packages iteration" do + let(:work_packages) do + [ + create(:work_package, subject: "WP A", project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 1), + create(:work_package, subject: "WP B", project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 2) + ] + end + + it_behaves_like "rendering Box", row_count: 2, header: false, footer: false + + it "renders one row per work package" do + expect(rendered_component).to have_text("WP A") + expect(rendered_component).to have_text("WP B") + end + end + + describe "hardcoded Backlogs item component" do + let(:work_packages) do + [ + create(:work_package, subject: "Story card", project:, type: type_feature, + status: default_status, priority: default_priority, + sprint:, position: 1, story_points: 3) + ] + end + + it "renders items through Backlogs::WorkPackageCardListItemComponent" do + work_package = work_packages.first + + expect(rendered_component).to have_css( + ".Box-row#work_package_#{work_package.id}[data-controller='backlogs--story']" + ) + end + + it "renders Backlogs-specific row data attributes" do + work_package = work_packages.first + + expect(rendered_component).to have_css(".Box-row#work_package_#{work_package.id}") do |row| + expect(row["data-story"]).to be_present + expect(row["data-backlogs--story-id-value"]).to eq(work_package.id.to_s) + end + end + + it "renders the Backlogs work-package card" do + work_package = work_packages.first + + expect(rendered_component).to have_css( + ".Box-row#work_package_#{work_package.id} .sr-only", + text: "3 story points" + ) + expect(rendered_component).to have_element( + "include-fragment", + src: menu_project_backlogs_work_package_path(project, sprint, work_package) + ) + end + end + + describe "delegated header with fold-state defaults" do + let(:header_arguments) { { title: "Sprint 1", count: 0 } } + + it "renders the header" do + expect(rendered_component).to have_css(".Box-header") + end + + it "renders the provided title" do + expect(rendered_component).to have_heading "Sprint 1", level: 4 + end + + it "announces dynamic counter updates" do + expect(rendered_component).to have_css( + ".Counter", + aria: { label: I18n.t(:label_x_work_packages, count: 0), live: "polite" }, + visible: :all + ) + end + + context "with work packages" do + let(:header_arguments) { { title: "Sprint 1" } } + let(:work_packages) do + [ + create(:work_package, project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 1), + create(:work_package, project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 2) + ] + end + + it "infers the count from the rendered work packages" do + expect(rendered_component).to have_css( + ".Counter", + text: "2", + aria: { label: I18n.t(:label_x_work_packages, count: 2) } + ) + end + end + + context "when the count is disabled" do + let(:header_arguments) { { title: "Sprint 1", count: false } } + let(:work_packages) do + [ + create(:work_package, project:, type: type_feature, status: default_status, + priority: default_priority, sprint:, position: 1) + ] + end + + it "does not render the count" do + expect(rendered_component).to have_no_css(".Counter") + end + end + + context "when the count label is overridden" do + let(:header_arguments) do + { title: "Sprint 1", count: 7, count_label: "7 backlog stories" } + end + + it "renders the provided count and accessible label" do + expect(rendered_component).to have_css( + ".Counter", + text: "7", + aria: { label: "7 backlog stories" } + ) + 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 + + it "announces dynamic empty-state updates" do + expect(rendered_component).to have_role(:status, aria: { live: "polite" }) + 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/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 2e9558c57833..ba0f64609bd5 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -76,9 +76,9 @@ backlogs__story_id_value: work_package.id, backlogs__story_display_id_value: work_package.display_id, backlogs__story_full_url_value: work_package_path(work_package), - backlogs__story_selected_class: "Box-row--blue", - test_selector: "work-package-#{work_package.id}" + backlogs__story_selected_class: "Box-row--blue" ) + expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}") end it "marks the row as draggable for users allowed to manage sprint items" do diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 0ad8734100fa..294a0f702c06 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -350,10 +350,7 @@ def expect_backlog_bucket_work_package_count(bucket, count) within_backlog_bucket(bucket) do expect(page).to have_css( ".Counter", - accessible_name: I18n.t( - "open_project.common.work_package_card_list_component.header.label_work_package_count", - count: - ) + accessible_name: I18n.t(:label_x_work_packages, count:) ) end end @@ -470,16 +467,13 @@ def expect_sprint_story_count(sprint, count) within(sprint_selector(sprint)) do expect(page).to have_css( ".Counter", - accessible_name: I18n.t( - "open_project.common.work_package_card_list_component.header.label_work_package_count", - count: - ) + accessible_name: I18n.t(:label_x_work_packages, count:) ) end end def expect_and_dismiss_error(message) - expect(page).to have_content message + expect(page).to have_text message click_on "Cancel" end 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..c072dc586cf6 --- /dev/null +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -0,0 +1,770 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do + shared_let(:user) { create(:admin) } + shared_let(:project) { create(:project) } + shared_let(:work_package) { create(:work_package, subject: "Default WP", project:) } + shared_let(:override_work_package) { create(:work_package, subject: "Override WP", project:) } + + current_user { user } + + let(:default_wp_item_class) do + stub_const( + "TestDefaultWorkPackageItem", + Class.new(ApplicationComponent) do + include ActionView::RecordIdentifier + + delegate :with_metric, :with_menu, to: :card + + def initialize(work_package:, project:, container:, params: {}, current_user: User.current, **system_arguments) # rubocop:disable Lint/UnusedMethodArgument + super() + + @work_package = work_package + @project = project + @container = container + @current_user = current_user + @system_arguments = system_arguments + end + + def row_args + @system_arguments.merge( + id: "default_wp_#{@work_package.id}", + data: @system_arguments.fetch(:data, {}).merge( + container: Array(@container).map { |c| c.respond_to?(:id) ? c.id : c }.join("_"), + project: @project&.id, + current_user: @current_user&.id + ) + ) + end + + def card + @card ||= TestWorkPackageCard.new(prefix: "default", subject: @work_package.subject) + end + + def before_render + content + end + + def call + render(card) + end + end + ) + end + + let(:override_wp_item_class) do + stub_const( + "TestOverrideWorkPackageItem", + Class.new(default_wp_item_class) do + def row_args + super.merge(id: "override_wp_#{@work_package.id}") + end + + def card + @card ||= TestWorkPackageCard.new(prefix: "override", subject: @work_package.subject) + end + end + ) + end + + before do + stub_const( + "TestWorkPackageCard", + Class.new(ApplicationComponent) do + renders_one :metric + renders_one :menu + + def initialize(prefix:, subject:) + super() + + @prefix = prefix + @subject = subject + end + + def call + safe_join([tag.span("#{@prefix} #{@subject}"), metric, menu].compact) + end + end + ) + end + + describe "full rendering" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "test-list", current_user: user) + ) do |list| + list.with_header(title: "Header title", count: 3) + list.with_item(id: "manual-item") { "Manual item" } + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class, + data: { source: "slot" } + ) do |item| + item.card.with_metric { "Metric content" } + end + list.with_work_package_item( + work_package: override_work_package, + component_klass: override_wp_item_class + ) do |item| + item.with_menu { "Menu content" } + end + list.with_footer { "Footer content" } + end + end + + it_behaves_like "rendering Box", row_count: 3, header: true, footer: true + + it "renders the header with title" do + expect(rendered_component).to have_heading("Header title", level: 4) + end + + it "renders the header count badge" do + expect(rendered_component).to have_css(".Counter", text: "3") + end + + it "renders generic items as content rows" do + expect(rendered_component).to have_css(".Box-row#manual-item", text: "Manual item") + end + + it "renders the footer" do + expect(rendered_component).to have_css(".Box-footer", text: "Footer content") + end + + it "renders the default work-package item" do + expect(rendered_component).to have_css( + ".Box-row#default_wp_#{work_package.id}", + text: "default Default WP" + ) + end + + it "renders the overridden work-package item" do + expect(rendered_component).to have_css( + ".Box-row#override_wp_#{override_work_package.id}", + text: "override Override WP" + ) + end + + it "captures work-package item customization blocks" do + expect(rendered_component).to have_text("Metric content") + end + + it "delegates menu customization to the card" do + expect(rendered_component).to have_text("Menu content") + end + end + + describe "header" do + it "renders a description below the title" do + rendered = render_inline( + described_class.new(container: "hdr-test") + ) do |list| + list.with_header(title: "My title") do |header| + header.with_description { "Some description" } + end + list.with_item { "row" } + end + + expect(rendered).to have_heading("My title", level: 4) + expect(rendered).to have_text("Some description") + end + + it "renders multiple action buttons" do + rendered = render_inline( + described_class.new(container: "hdr-actions") + ) do |list| + list.with_header(title: "Actions") do |header| + header.with_action_button(scheme: :primary) { "Add" } + header.with_action_button(scheme: :default) { "Edit" } + end + list.with_item { "row" } + end + + expect(rendered).to have_button("Add") + expect(rendered).to have_button("Edit") + end + + it "renders a menu in the header" do + rendered = render_inline( + described_class.new(container: "hdr-menu") + ) do |list| + list.with_header(title: "With menu") do |header| + header.with_menu do |menu| + menu.with_item(label: "Option A", value: "a") + end + end + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box-header") + expect(rendered).to have_css("action-menu") + expect(rendered).to have_css("tool-tip[data-type='label']", text: I18n.t(:label_actions)) + end + + it "infers the count from rendered items" do + rendered = render_inline( + described_class.new(container: "hdr-inferred-count") + ) do |list| + list.with_header(title: "Counted", count: true) + list.with_item { "first row" } + list.with_item { "second row" } + end + + expect(rendered).to have_css(".Counter", text: "2") + end + + it "does not render a count when count is false" do + rendered = render_inline( + described_class.new(container: "hdr-false-count") + ) do |list| + list.with_header(title: "Uncounted", count: false) + list.with_item { "row" } + end + + expect(rendered).to have_no_css(".Counter") + end + + it "does not render a count when count is nil" do + rendered = render_inline( + described_class.new(container: "hdr-nil-count") + ) do |list| + list.with_header(title: "Uncounted", count: nil) + list.with_item { "row" } + end + + expect(rendered).to have_no_css(".Counter") + end + + it "renders an explicit count" do + rendered = render_inline( + described_class.new(container: "hdr-explicit-count") + ) do |list| + list.with_header(title: "Counted", count: 5) + list.with_item { "row" } + end + + expect(rendered).to have_css(".Counter", text: "5") + end + + it "keeps zero counts hidden by default" do + rendered = render_inline( + described_class.new(container: "hdr-zero-count") + ) do |list| + list.with_header(title: "Counted", count: 0) + list.with_item { "row" } + end + + expect(rendered).to have_css(".Counter[hidden]", text: "0", visible: :all) + end + + it "allows zero counts to be shown through count arguments" do + rendered = render_inline( + described_class.new(container: "hdr-visible-zero-count") + ) do |list| + list.with_header(title: "Counted", count: 0, count_arguments: { hide_if_zero: false }) + list.with_item { "row" } + end + + expect(rendered).to have_css(".Counter:not([hidden])", text: "0") + end + + it "sets a default aria-label on the counter" do + rendered = render_inline( + described_class.new(container: "hdr-default-aria") + ) do |list| + list.with_header(title: "Counted", count: 5) + list.with_item { "row" } + end + + expect(rendered).to have_css( + ".Counter", + text: "5", + aria: { label: I18n.t(:label_x_items, count: 5), live: nil } + ) + end + + it "adds aria-live to the counter when the list is interactive" do + rendered = render_inline( + described_class.new(container: "hdr-interactive-aria", interactive: true) + ) do |list| + list.with_header(title: "Counted", count: 5) + list.with_item { "row" } + end + + expect(rendered).to have_css( + ".Counter", + text: "5", + aria: { label: I18n.t(:label_x_items, count: 5), live: "polite" } + ) + end + + it "preserves caller-provided counter aria attributes" do + rendered = render_inline( + described_class.new(container: "hdr-custom-counter-aria", interactive: true) + ) do |list| + list.with_header( + title: "Counted", + count: 5, + count_arguments: { aria: { describedby: "counter-help", live: "assertive" } } + ) + list.with_item { "row" } + end + + expect(rendered).to have_css( + ".Counter", + text: "5", + aria: { + label: I18n.t(:label_x_items, count: 5), + describedby: "counter-help", + live: "assertive" + } + ) + end + + it "uses the default aria-label when count is inferred" do + rendered = render_inline( + described_class.new(container: "hdr-inferred-aria") + ) do |list| + list.with_header(title: "Counted", count: true) + list.with_item { "one" } + list.with_item { "two" } + end + + expect(rendered).to have_css( + ".Counter", + text: "2", + aria: { label: I18n.t(:label_x_items, count: 2) } + ) + end + + it "allows count_label to override the default aria-label" do + rendered = render_inline( + described_class.new(container: "hdr-custom-label") + ) do |list| + list.with_header(title: "Counted", count: 3, count_label: "3 work packages") + list.with_item { "row" } + end + + expect(rendered).to have_css( + ".Counter", + text: "3", + aria: { label: "3 work packages" } + ) + end + + it "allows the title tag to be customized" do + rendered = render_inline( + described_class.new(container: "hdr-title-tag") + ) do |list| + list.with_header(title: "Custom title", title_tag: :h3) + list.with_item { "row" } + end + + expect(rendered).to have_heading("Custom title", level: 3) + end + end + + describe "header collapsible behavior" do + it "sets collapsible_id from list and footer ids" do + rendered = render_inline( + described_class.new(container: "collapse-test", collapsible: true) + ) do |list| + list.with_header(title: "Collapsible") + list.with_item { "row" } + list.with_footer { "foot" } + end + + list_id = "collapse-test_list" + footer_id = "collapse-test_footer" + + expect(rendered).to have_css( + "[aria-controls='#{list_id} #{footer_id}']" + ) + end + + it "sets collapsible_id from list id only when no footer" do + rendered = render_inline( + described_class.new(container: "collapse-no-footer", collapsible: true) + ) do |list| + list.with_header(title: "No footer") + list.with_item { "row" } + end + + expect(rendered).to have_css( + "[aria-controls='collapse-no-footer_list']" + ) + end + end + + describe "generic items" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "generic-items") + ) do |list| + list.with_item(id: "row-1") { "First" } + list.with_item(id: "row-2") { "Second" } + end + end + + it "renders content block rows" do + expect(rendered_component).to have_css(".Box-row#row-1", text: "First") + expect(rendered_component).to have_css(".Box-row#row-2", text: "Second") + end + + it "renders the expected number of rows" do + expect(rendered_component).to have_css(".Box-row", count: 2) + end + end + + describe "work-package items" do + describe "with the default WorkPackageItem" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "wp-default", current_user: user) + ) do |list| + list.with_work_package_item(work_package:) + end + end + + it "renders the work package row" do + expect(rendered_component).to have_css( + ".Box-row#work_package_#{work_package.id}" + ) + end + + it "applies clickable row classes" do + expect(rendered_component).to have_css( + ".Box-row.Box-row--clickable" + ) + end + + it "sets the test selector" do + item = described_class::WorkPackageItem.new( + work_package:, + project:, + container: "wp-default", + current_user: user + ) + + expect(item.row_args[:test_selector]).to eq("work-package-#{work_package.id}") + expect(rendered_component).to have_css( + ".Box-row[data-test-selector='work-package-#{work_package.id}']" + ) + end + + it "delegates metric customization to the work-package card" do + rendered = render_inline( + described_class.new(container: "wp-default-metric", current_user: user) + ) do |list| + list.with_work_package_item(work_package:) do |item| + item.with_metric { "Custom metric" } + end + end + + expect(rendered).to have_text("Custom metric") + end + end + + describe "with an overridden component_klass" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "wp-override", current_user: user) + ) do |list| + list.with_work_package_item( + work_package: override_work_package, + component_klass: override_wp_item_class + ) + end + end + + it "uses the provided component class" do + expect(rendered_component).to have_css( + ".Box-row#override_wp_#{override_work_package.id}", + text: "override Override WP" + ) + end + end + + describe "injected container: and current_user:" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "injection-test", current_user: user) + ) do |list| + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class + ) + end + end + + it "injects the list container into the item" do + expect(rendered_component).to have_css( + ".Box-row[data-container='injection-test']" + ) + end + + it "injects the list current_user into the item" do + expect(rendered_component).to have_css( + ".Box-row[data-current-user='#{user.id}']" + ) + end + end + + describe "project defaults to work_package.project" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "project-default", current_user: user) + ) do |list| + list.with_work_package_item( + work_package:, + component_klass: default_wp_item_class + ) + end + end + + it "passes the work package's project when project: is omitted" do + expect(rendered_component).to have_css( + ".Box-row[data-project='#{work_package.project.id}']" + ) + end + end + end + + describe "empty state" do + it "renders a Blankslate when no items are present" do + rendered = render_inline( + described_class.new(container: "empty-list") + ) do |list| + list.with_empty_state(title: "Nothing here", description: "Add some items", icon: :inbox) + end + + expect(rendered).to have_css(".blankslate") + expect(rendered).to have_text("Nothing here") + expect(rendered).to have_text("Add some items") + end + + it "does not render the empty state when items are present" do + rendered = render_inline( + described_class.new(container: "non-empty-list") + ) do |list| + list.with_empty_state(title: "Nothing here") + list.with_item { "Has content" } + end + + expect(rendered).to have_no_css(".blankslate") + expect(rendered).to have_text("Has content") + end + + it "does not set aria role and live attributes on the empty state by default" do + rendered = render_inline( + described_class.new(container: "empty-aria") + ) do |list| + list.with_empty_state(title: "Empty") + end + + expect(rendered).to have_no_role(:status) + expect(rendered).to have_css(".blankslate", aria: { live: nil }) + end + + it "sets aria role and live attributes on the empty state when the list is interactive" do + rendered = render_inline( + described_class.new(container: "empty-interactive-aria", interactive: true) + ) do |list| + list.with_empty_state(title: "Empty") + end + + expect(rendered).to have_role(:status, aria: { live: "polite" }) + end + + it "preserves caller-provided empty state aria attributes" do + rendered = render_inline( + described_class.new(container: "empty-custom-aria", interactive: true) + ) do |list| + list.with_empty_state( + title: "Empty", + role: "alert", + aria: { live: "assertive", describedby: "empty-help" } + ) + end + + expect(rendered).to have_alert(aria: { live: "assertive", describedby: "empty-help" }) + end + end + + describe "footer rendering" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "footer-test") + ) do |list| + list.with_item { "row" } + list.with_footer(classes: "custom-footer") { "Custom footer" } + end + end + + it "renders as a proper BorderBox footer" do + expect(rendered_component).to have_css(".Box-footer", text: "Custom footer") + end + + it "auto-derives the footer id from the box id" do + expect(rendered_component).to have_css(".Box-footer#footer-test_footer") + end + end + + describe "container-derived DOM IDs" do + context "with a string container" do + subject(:rendered_component) do + render_inline( + described_class.new(container: "my-widget") + ) do |list| + list.with_item { "row" } + end + end + + it "derives the box id from container" do + expect(rendered_component).to have_css(".Box#my-widget") + end + + it "derives the list id from container" do + expect(rendered_component).to have_css("ul#my-widget_list") + end + end + + it "derives the header id from the box id" do + rendered = render_inline( + described_class.new(container: "my-widget") + ) do |list| + list.with_header(title: "Header") + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box-header#my-widget_header") + end + + it "derives the header ids when explicit slot ids are provided" do + rendered = render_inline( + described_class.new(container: "ignored", id: "explicit-box", collapsible: true) + ) do |list| + list.with_header(title: "Header", id: "explicit-header", list_id: "explicit-list") + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box-header#explicit-box_header") + expect(rendered).to have_element(aria: { controls: "explicit-box_list" }) + expect(rendered).to have_no_css(".Box-header#explicit-header") + expect(rendered).to have_no_element(aria: { controls: "explicit-list" }) + end + + it "derives the list id from the explicit box id" do + rendered = render_inline( + described_class.new(container: "ignored", id: "explicit-box", list_id: "explicit-list") + ) do |list| + list.with_item { "row" } + end + + expect(rendered).to have_css(".Box#explicit-box") + expect(rendered).to have_css("ul#explicit-box_list") + expect(rendered).to have_no_css("ul#explicit-list") + end + + it "derives the footer id from the explicit box id" do + rendered = render_inline( + described_class.new(container: "ignored", id: "explicit-box", collapsible: true) + ) do |list| + list.with_header(title: "Header") + list.with_item { "row" } + list.with_footer(id: "explicit-footer") { "footer" } + end + + expect(rendered).to have_css(".Box-footer#explicit-box_footer") + expect(rendered).to have_no_css(".Box-footer#explicit-footer") + expect(rendered).to have_element(aria: { controls: "explicit-box_list explicit-box_footer" }) + end + end + + describe "system arguments forwarded to BorderBox" do + subject(:rendered_component) do + render_inline( + described_class.new( + container: "sys-args", + classes: "extra-class", + data: { test_selector: "my-box" } + ) + ) do |list| + list.with_item { "row" } + end + end + + it "forwards classes to the underlying BorderBox" do + expect(rendered_component).to have_css(".Box.extra-class") + end + + it "forwards data attributes to the underlying BorderBox" do + expect(rendered_component).to have_css(".Box[data-test-selector='my-box']") + end + end + + describe "constructor requires container:" do + it "raises ArgumentError when container: is missing" do + expect { described_class.new }.to raise_error(ArgumentError) + end + end + + describe "collapsible" do + it "renders a non-collapsible header by default" do + rendered = render_inline( + described_class.new(container: "no-collapse") + ) do |list| + list.with_header(title: "Non-collapsible header", count: 3) do |header| + header.with_description { "Description text" } + end + list.with_item { "row" } + end + + expect(rendered).to have_heading("Non-collapsible header", level: 4) + expect(rendered).to have_css(".Counter", text: "3") + expect(rendered).to have_text("Description text") + expect(rendered).to have_no_css("collapsible-header") + expect(rendered).to have_no_css("[aria-controls]") + end + + it "renders a collapsible header when collapsible is true" do + rendered = render_inline( + described_class.new(container: "explicit-collapse", collapsible: true) + ) do |list| + list.with_header(title: "Collapsible header") + list.with_item { "row" } + end + + expect(rendered).to have_css("collapsible-header") + end + end +end diff --git a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb b/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb deleted file mode 100644 index 0995b5d41a6a..000000000000 --- a/spec/components/open_project/common/work_package_card_list_component/empty_item_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -require "rails_helper" - -RSpec.describe OpenProject::Common::WorkPackageCardListComponent::EmptyItem, type: :component do - describe "#row_args" do - it "marks the row as an empty list item by default" do - item = described_class.new - - expect(item.row_args[:data]).to include(empty_list_item: true) - end - - it "lets caller-supplied data override the default empty item data" do - item = described_class.new( - data: { - empty_list_item: false, - test_selector: "custom-empty-row" - } - ) - - expect(item.row_args[:data]).to include( - empty_list_item: false, - test_selector: "custom-empty-row" - ) - end - end -end 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