Skip to content

Commit 74870c5

Browse files
committed
[DREAM-590] Add DataTable pagination footer
Adds a server-side pagination footer to DataTable, ported from Primer React's Table.Pagination. Introduces DataTable::PaginationFooter, exposed through a `pagination` slot, which renders an optional range summary ("1 - 10 of 95") alongside the existing Primer::OpenProject::Pagination. Pagination gains a `tag` option so it can be embedded inside the footer's single nav landmark without nesting a second nav. Adds With Pagination and With Pagination Using Default Page Index previews, component tests, and a system spec exercising page navigation and the range summary. https://community.openproject.org/wp/DREAM-590
1 parent 9856e92 commit 74870c5

19 files changed

Lines changed: 552 additions & 45 deletions

File tree

app/components/primer/open_project/data_table.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@
3838
<% end %>
3939
</data-table>
4040
<% end %>
41+
<%= pagination %>
4142
<% end %>

app/components/primer/open_project/data_table.pcss

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
grid-template-areas:
2525
'title'
2626
'subtitle'
27-
'table';
27+
'table'
28+
'footer';
2829
}
2930

3031
/* TableTitle */
@@ -209,6 +210,34 @@
209210
align-items: center;
210211
}
211212

213+
/* Pagination footer --------------------------------------------------------- */
214+
.TablePagination {
215+
display: flex;
216+
align-items: center;
217+
justify-content: space-between;
218+
column-gap: var(--base-size-16);
219+
width: 100%;
220+
grid-area: footer;
221+
padding: var(--base-size-8) var(--base-size-16);
222+
border: var(--borderWidth-thin) solid var(--borderColor-default);
223+
border-top-width: 0;
224+
border-end-start-radius: var(--borderRadius-medium);
225+
border-end-end-radius: var(--borderRadius-medium);
226+
}
227+
228+
.TablePaginationRange {
229+
margin: 0;
230+
color: var(--fgColor-muted);
231+
font-size: var(--text-body-size-small);
232+
}
233+
234+
/* The embedded pagination drops its standalone vertical margin and sits at the
235+
inline end of the footer (after the range summary). */
236+
.TablePagination .PaginationContainer {
237+
margin: 0;
238+
margin-inline-start: auto;
239+
}
240+
212241
/* Grid layout */
213242
.TableHead,
214243
.TableBody,

app/components/primer/open_project/data_table.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def sort_data
6363
column
6464
}
6565

66+
renders_one :pagination, Primer::OpenProject::DataTable::PaginationFooter
67+
6668
# @param data [Array, ActiveRecord::Relation]
6769
# A collection of rows that will be rendered inside the table
6870
# @param cell_padding [Symbol]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<nav class="TablePagination" aria-label="<%= aria_label %>">
2+
<% if show_range? %>
3+
<p class="TablePaginationRange">
4+
<%= range_start %><span class="sr-only"> through </span><span aria-hidden="true"></span><%= range_end %> of <%= @total_count %>
5+
</p>
6+
<% end %>
7+
<%= render(Primer::OpenProject::Pagination.new(**@pagination_arguments)) %>
8+
</nav>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
module Primer
4+
module OpenProject
5+
class DataTable
6+
# This component is part of `Primer::OpenProject::DataTable` and should
7+
# not be used as a standalone component.
8+
#
9+
# It renders the table's pagination footer: an optional "range" summary
10+
# (e.g. "1 ‒ 10 of 95") alongside the paginated navigation provided by
11+
# <%= link_to_component(Primer::OpenProject::Pagination) %>.
12+
class PaginationFooter < Primer::Component
13+
status :open_project
14+
15+
# @param current_page [Integer]
16+
# The page that is currently being viewed (1-based).
17+
# @param total_count [Integer, nil]
18+
# The total number of items across all pages. Required (together with
19+
# `page_size`) to render the range summary.
20+
# @param page_size [Integer, nil]
21+
# The number of items rendered per page. Required (together with
22+
# `total_count`) to render the range summary.
23+
# @param aria_label [String, nil]
24+
# Accessible name for the pagination navigation landmark.
25+
# @param system_arguments [Hash]
26+
# System arguments passed to the root element. All remaining arguments
27+
# are forwarded to <%= link_to_component(Primer::OpenProject::Pagination) %>.
28+
def initialize(current_page:, total_count: nil, page_size: nil, aria_label: nil, **system_arguments)
29+
@current_page = current_page
30+
@total_count = total_count
31+
@page_size = page_size
32+
@aria_label = aria_label || I18n.t("pagination.label")
33+
@pagination_arguments = system_arguments.merge(current_page: current_page, tag: :div)
34+
end
35+
36+
attr_reader :aria_label
37+
38+
def render?
39+
@pagination_arguments.key?(:page_count)
40+
end
41+
42+
def show_range?
43+
@total_count.present? && @page_size.present? && @current_page.present?
44+
end
45+
46+
def range_start
47+
((@current_page - 1) * @page_size) + 1
48+
end
49+
50+
def range_end
51+
[@current_page * @page_size, @total_count].min
52+
end
53+
end
54+
end
55+
end
56+
end

app/components/primer/open_project/pagination.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def initialize(
3030
show_pages: true,
3131
surrounding_page_count: DEFAULT_SURROUNDING_PAGE_COUNT,
3232
link_arguments: {},
33+
tag: :nav,
3334
**system_arguments
3435
)
3536
@page_count = cast_integer!(page_count, "page_count")
@@ -41,12 +42,15 @@ def initialize(
4142
@link_arguments = link_arguments
4243
@system_arguments = system_arguments
4344

44-
@system_arguments[:tag] = :nav
45+
@system_arguments[:tag] = tag
4546
@system_arguments[:classes] = class_names(
4647
"PaginationContainer",
4748
@system_arguments[:classes]
4849
)
49-
@system_arguments["aria-label"] = I18n.t("pagination.label")
50+
# Only the <nav> variant acts as a navigation landmark and needs a label.
51+
# When embedded (e.g. inside the DataTable pagination footer) the wrapping
52+
# element provides the landmark, so we avoid a duplicate accessible name.
53+
@system_arguments["aria-label"] = I18n.t("pagination.label") if tag == :nav
5054

5155
validate_arguments!
5256
end

previews/primer/open_project/data_table_preview.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,52 @@ def playground(
5050
)
5151
end
5252

53+
# @label With Pagination
54+
# @snapshot
55+
# @param page [Integer] number
56+
def with_pagination(page: 1)
57+
render_pagination_example(page: page, total_count: 95, page_size: 10, default_page: 1)
58+
end
59+
60+
# @label With Pagination Using Default Page Index
61+
# @snapshot
62+
# @param page [Integer] number
63+
def with_pagination_using_default_page_index(page: nil)
64+
render_pagination_example(page: page, total_count: 1000, page_size: 10, default_page: 50)
65+
end
66+
5367
private
5468

69+
def render_pagination_example(page:, total_count:, page_size:, default_page:)
70+
page_count = (total_count.to_f / page_size).ceil
71+
current_page = (page.presence || default_page).to_i.clamp(1, page_count)
72+
offset = (current_page - 1) * page_size
73+
74+
render_with_template(
75+
template: "primer/open_project/data_table_preview/pagination_example",
76+
locals: {
77+
rows: paginated_sample_rows(total_count)[offset, page_size],
78+
current_page: current_page,
79+
page_count: page_count,
80+
page_size: page_size,
81+
total_count: total_count
82+
}
83+
)
84+
end
85+
86+
def paginated_sample_rows(count)
87+
now = Time.now
88+
89+
count.times.map do |i|
90+
DemoProject.new(
91+
id: i + 1,
92+
name: "Project #{i + 1}",
93+
status_code: %w[active on_track at_risk off_track].fetch(i % 4),
94+
created_at: now - (i * 86_400) # i days ago
95+
)
96+
end
97+
end
98+
5599
def sample_rows
56100
now = Time.now
57101

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<%=
2+
render(
3+
Primer::OpenProject::DataTable.new(data: rows)
4+
) do |data_table|
5+
data_table.with_title(tag: :h2) { "Projects" }
6+
data_table.with_subtitle { "A subtitle could appear here to give extra context to the data." }
7+
8+
data_table.with_column(header: "Name", field: :name, row_header: true, width: :grow_collapse)
9+
10+
data_table.with_column(header: "Status", field: :status_code) do |column|
11+
column.with_cell do |row|
12+
render(Primer::Beta::Label.new) { row.status_code }
13+
end
14+
end
15+
16+
data_table.with_column(header: "Created", field: :created_at, align: :end) do |column|
17+
column.with_cell do |row|
18+
row.created_at.strftime("%Y-%m-%d")
19+
end
20+
end
21+
22+
data_table.with_pagination(
23+
current_page: current_page,
24+
page_count: page_count,
25+
page_size: page_size,
26+
total_count: total_count,
27+
href_builder: ->(page) { "?page=#{page}" }
28+
)
29+
end
30+
%>

static/arguments.json

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6130,9 +6130,9 @@
61306130
},
61316131
{
61326132
"name": "sort_by",
6133-
"type": "String, Symbol, nil",
6133+
"type": "Boolean, Symbol, nil",
61346134
"default": "`nil`",
6135-
"description": "Sort key used for sorting"
6135+
"description": "Whether this column is sortable, or the sort strategy to use"
61366136
},
61376137
{
61386138
"name": "row_header",
@@ -6154,6 +6154,46 @@
61546154
}
61556155
]
61566156
},
6157+
{
6158+
"component": "OpenProject::DataTable::PaginationFooter",
6159+
"status": "open_project",
6160+
"a11y_reviewed": false,
6161+
"short_name": "OpenProjectDataTablePaginationFooter",
6162+
"source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/data_table/pagination_footer.rb",
6163+
"lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/data_table/pagination_footer/default/",
6164+
"parameters": [
6165+
{
6166+
"name": "current_page",
6167+
"type": "Integer",
6168+
"default": "N/A",
6169+
"description": "The page that is currently being viewed (1-based)."
6170+
},
6171+
{
6172+
"name": "total_count",
6173+
"type": "Integer, nil",
6174+
"default": "`nil`",
6175+
"description": "The total number of items across all pages. Required (together with `page_size`) to render the range summary."
6176+
},
6177+
{
6178+
"name": "page_size",
6179+
"type": "Integer, nil",
6180+
"default": "`nil`",
6181+
"description": "The number of items rendered per page. Required (together with `total_count`) to render the range summary."
6182+
},
6183+
{
6184+
"name": "aria_label",
6185+
"type": "String, nil",
6186+
"default": "`nil`",
6187+
"description": "Accessible name for the pagination navigation landmark."
6188+
},
6189+
{
6190+
"name": "system_arguments",
6191+
"type": "Hash",
6192+
"default": "N/A",
6193+
"description": "System arguments passed to the root element. All remaining arguments are forwarded to [OpenProject::Pagination](/components/openprojectpagination)."
6194+
}
6195+
]
6196+
},
61576197
{
61586198
"component": "OpenProject::DataTable::SortHeader",
61596199
"status": "open_project",
@@ -6320,25 +6360,25 @@
63206360
{
63216361
"name": "filter_input_arguments",
63226362
"type": "Hash",
6323-
"default": "`DEFAULT_FILTER_INPUT_ARGUMENTS.dup`",
6363+
"default": "`{}`",
63246364
"description": "Arguments that will be passed to the [TextField](/components/alpha/textfield) component."
63256365
},
63266366
{
63276367
"name": "filter_mode_control_arguments",
63286368
"type": "Hash",
6329-
"default": "`DEFAULT_FILTER_MODE_CONTROL_ARGUMENTS.dup`",
6369+
"default": "`{}`",
63306370
"description": "Arguments that will be passed to the [SegmentedControl](/components/alpha/segmentedcontrol) component."
63316371
},
63326372
{
63336373
"name": "include_sub_items_check_box_arguments",
63346374
"type": "Hash",
6335-
"default": "`DEFAULT_INCLUDE_SUB_ITEMS_CHECK_BOX_ARGUMENTS.dup`",
6375+
"default": "`{}`",
63366376
"description": "Arguments that will be passed to the [CheckBox](/components/alpha/checkbox) component."
63376377
},
63386378
{
63396379
"name": "no_results_node_arguments",
63406380
"type": "Hash",
6341-
"default": "`DEFAULT_NO_RESULTS_NODE_ARGUMENTS.dup`",
6381+
"default": "`{}`",
63426382
"description": "Arguments that will be passed to a [TreeView::LeafNode](/components/alpha/treeviewleafnode) component that appears when no items match the filter criteria."
63436383
}
63446384
]
@@ -6732,12 +6772,12 @@
67326772
]
67336773
},
67346774
{
6735-
"component": "OpenProject::SubHeader::QuickActionComponent",
6775+
"component": "OpenProject::SubHeader::QuickAction",
67366776
"status": "open_project",
67376777
"a11y_reviewed": false,
6738-
"short_name": "OpenProjectSubHeaderQuickActionComponent",
6739-
"source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/sub_header/quick_filter.rb",
6740-
"lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/sub_header/quick_filter/default/",
6778+
"short_name": "OpenProjectSubHeaderQuickAction",
6779+
"source": "https://github.com/primer/view_components/tree/main/app/components/primer/open_project/sub_header/quick_action_component.rb",
6780+
"lookbook": "https://primer.style/view-components/lookbook/inspect/primer/open_project/sub_header/quick_action/default/",
67416781
"parameters": []
67426782
},
67436783
{

static/audited_at.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
"Primer::OpenProject::DangerDialog::FormWrapper": "",
152152
"Primer::OpenProject::DataTable": "",
153153
"Primer::OpenProject::DataTable::Column": "",
154+
"Primer::OpenProject::DataTable::PaginationFooter": "",
154155
"Primer::OpenProject::DataTable::SortHeader": "",
155156
"Primer::OpenProject::DragHandle": "",
156157
"Primer::OpenProject::FeedbackDialog": "",

0 commit comments

Comments
 (0)