Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/components/_index.sass
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
196 changes: 196 additions & 0 deletions app/components/open_project/common/border_box_list_component.rb
Original file line number Diff line number Diff line change
@@ -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

Comment thread
myabc marked this conversation as resolved.
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@system_arguments[:list_id] = dom_target(...) unconditionally overwrites any caller-provided list_id:. The spec ("derives the list id from the explicit box id") even asserts that an explicit list_id: is ignored. Same for footer id: at line 168 — the Footer stores the original in attr_reader :id but the rendered id is always the derived one. If these aren't meant to be caller-controlled, consider filtering them out or documenting they're reserved, rather than silently discarding them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deny_single_argument would make this easy to implement, but we would have to inherit from Primer::Component instead.

@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
22 changes: 22 additions & 0 deletions app/components/open_project/common/border_box_list_component.sass
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should every empty state be rendered as role="status" with aria-live="polite" by default?

It might be more announcement-heavy than needed. Maybe this should be configurable, with the current behavior as an option?

@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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with_description { @description } works but the old code used with_description_content(description), which is the Primer documented API for passing a plain string. The block form is fine but slightly inconsistent with the chaining style used for with_heading two lines above.

blankslate.with_visual_icon(icon: @icon) if @icon

render(blankslate)
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,37 @@
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OpenProject
module Common
class WorkPackageCardListComponent
# Row bridge for caller-provided empty content.
class EmptyItem < ContentItem
include Primer::AttributesHelper
class BorderBoxListComponent
# Footer row rendered below list items.
#
# This component is part of {BorderBoxListComponent} and should not be
# used as a standalone component.
class Footer < ApplicationComponent
attr_reader :id

def row_args
system_arguments = @system_arguments.deep_dup
system_arguments[:data] = merge_data(
{ data: { empty_list_item: true } },
system_arguments
)
system_arguments
# @param system_arguments [Hash] forwarded to Primer's BorderBox footer.
def initialize(**system_arguments)
super()

@id = system_arguments[:id]
@system_arguments = system_arguments
end

# @return [Hash] arguments forwarded to Primer's BorderBox footer.
def footer_args
@system_arguments.deep_dup
end

def empty_item? = true
def call
content
end
end
end
end
Expand Down
Loading
Loading