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