Skip to content

Commit a0cde77

Browse files
committed
Adds DataTable external sorting mode
Adds sorting: :external, leaving row order to the caller (e.g. SQL ordering). Sortable headers render plain links built by a required sort_href_builder proc, which receives the column id and the direction the link requests next (NONE and DESC cycle to ASC, ASC to DESC). aria-sort renders exactly as in client mode. External tables emit no client sort metadata: no data-sort-strategy on headers, no per-cell sort values, and no toggleSort binding. The data-table element additionally ignores connect and toggleSort when the data-external-sorting attribute is present. Diverges from Primer React, whose externalSorting keeps sort buttons and an onToggleSort callback; links suit server rendering and work without JavaScript.
1 parent dc5f3c4 commit a0cde77

13 files changed

Lines changed: 277 additions & 17 deletions

File tree

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,13 @@
1717
</div>
1818
<% else %>
1919
<%= render(Primer::BaseComponent.new(**@wrapper_arguments)) do %>
20-
<data-table>
20+
<data-table<% if external_sorting? %> data-external-sorting=""<% end %>>
2121
<%= render(Primer::OpenProject::Table.new(**@system_arguments)) do |table| %>
2222
<% table.with_head(classes: "TableHead") do |thead| %>
2323
<% thead.with_row(classes: "TableRow") do |tr| %>
2424
<% headers.each do |header| %>
2525
<% if header.sortable? %>
26-
<% tr.with_header(
27-
component_klass: Primer::OpenProject::DataTable::SortHeader,
28-
align: header.column.align,
29-
direction: header.sort_direction,
30-
data: { sort_strategy: header.sort_strategy }
31-
) { header.column.header.to_s } %>
26+
<% tr.with_header(**sort_header_arguments(header)) { header.column.header.to_s } %>
3227
<% else %>
3328
<% tr.with_header(classes: "TableHeader", align: header.column.align) { header.column.header.to_s } %>
3429
<% end %>

app/components/primer/open_project/data_table.pcss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@
1818
border: 0;
1919
}
2020

21+
/* External sorting renders the header as a plain link */
22+
.TableSortLink {
23+
color: inherit;
24+
25+
&:hover,
26+
&:focus {
27+
color: inherit;
28+
text-decoration: none;
29+
}
30+
}
31+
2132
/* Container ---------------------------------------------------------------- */
2233
.TableContainer {
2334
display: grid;

app/components/primer/open_project/data_table.rb

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ def sort_data
3333
CELL_PADDING_DEFAULT = :normal
3434
CELL_PADDING_OPTIONS = [:condensed, CELL_PADDING_DEFAULT, :spacious].freeze
3535

36+
SORTING_DEFAULT = :client
37+
SORTING_OPTIONS = [SORTING_DEFAULT, :external].freeze
38+
3639
TITLE_TAG_DEFAULT = :h2
3740

3841
SUBTITLE_TAG_DEFAULT = :div
@@ -109,6 +112,17 @@ def sort_data
109112
# @param initial_sort_direction [Symbol, nil]
110113
# Sort direction for the initially sorted column.
111114
# Options: :ASC, :DESC
115+
# @param sorting [Symbol]
116+
# How sorting is performed. `:client` sorts rows in Ruby for the initial
117+
# render and re-sorts in the browser. `:external` leaves row order to the
118+
# caller (e.g. SQL ordering): sortable headers become links built by
119+
# `sort_href_builder`, and `initial_sort_column`/`initial_sort_direction`
120+
# only drive the rendered sort state.
121+
# Options: :client, :external
122+
# @param sort_href_builder [Proc, nil]
123+
# `->(column_id, direction)` returning the URL a sortable header links to,
124+
# where `direction` is the direction the link requests next. Required when
125+
# `sorting: :external` and any column is sortable.
112126
# @param divider [Boolean]
113127
# Whether to render a presentational divider line below the title row,
114128
# ported from Primer React's `Table.Divider`
@@ -130,6 +144,8 @@ def initialize(
130144
cell_padding: CELL_PADDING_DEFAULT,
131145
initial_sort_column: nil,
132146
initial_sort_direction: nil,
147+
sorting: SORTING_DEFAULT,
148+
sort_href_builder: nil,
133149
divider: false,
134150
row_id: nil,
135151
row_dom_id: false,
@@ -140,6 +156,8 @@ def initialize(
140156
@cell_padding = fetch_or_fallback(CELL_PADDING_OPTIONS, cell_padding, CELL_PADDING_DEFAULT)
141157
@initial_sort_column = initial_sort_column
142158
@initial_sort_direction = initial_sort_direction
159+
@sorting = fetch_or_fallback(SORTING_OPTIONS, sorting, SORTING_DEFAULT)
160+
@sort_href_builder = sort_href_builder
143161
@divider = fetch_or_fallback_boolean(divider, false)
144162
@row_id_proc = row_id
145163
@row_dom_id = fetch_or_fallback_boolean(row_dom_id, false)
@@ -169,6 +187,10 @@ def render?
169187
def before_render
170188
return unless render?
171189

190+
if external_sorting? && @sort_href_builder.nil? && columns.any?(&:sortable?)
191+
raise ArgumentError, "`sorting: :external` requires a `sort_href_builder` when columns are sortable"
192+
end
193+
172194
@initial_sort_state = build_initial_sort_state
173195
@headers = build_headers
174196
@rows = sorted_rows
@@ -249,6 +271,7 @@ def build_headers
249271
end
250272

251273
def sorted_rows
274+
return @rows if external_sorting?
252275
return @rows unless @initial_sort_state
253276

254277
Sorting.sort_rows(
@@ -258,6 +281,30 @@ def sorted_rows
258281
)
259282
end
260283

284+
def external_sorting?
285+
@sorting == :external
286+
end
287+
288+
def next_direction_for(header)
289+
header.sort_direction == :ASC ? :DESC : :ASC
290+
end
291+
292+
def sort_header_arguments(header)
293+
arguments = {
294+
component_klass: Primer::OpenProject::DataTable::SortHeader,
295+
align: header.column.align,
296+
direction: header.sort_direction
297+
}
298+
299+
if external_sorting?
300+
arguments[:href] = @sort_href_builder.call(header.id, next_direction_for(header))
301+
else
302+
arguments[:data] = { sort_strategy: header.sort_strategy }
303+
end
304+
305+
arguments
306+
end
307+
261308
def grid_template_from_columns(columns)
262309
columns.map do |column|
263310
column_width = column.width || :grow
@@ -321,7 +368,7 @@ def cell_content(cell, row)
321368

322369
def cells_for(row)
323370
headers.map do |header|
324-
sort_metadata = header.sortable? ? header.column.sort_metadata(row) : {}
371+
sort_metadata = header.sortable? && !external_sorting? ? header.column.sort_metadata(row) : {}
325372

326373
Cell.new(
327374
column: header.column,

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
<%= render(Primer::OpenProject::Table::Header.new(**@system_arguments)) do %>
2-
<%=
3-
render(
4-
Primer::Beta::BaseButton.new(
5-
classes: "TableSortButton",
6-
data: { action: "click:data-table#toggleSort" }
7-
)
8-
) do
9-
%>
2+
<%= render(sort_control) do %>
103
<%= content %>
114
<%=
125
render(

app/components/primer/open_project/data_table/sort_header.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ class SortHeader < Primer::Component
1515

1616
# @param direction [Symbol] select [:ASC, :DESC, :NONE]
1717
# Specify the sort direction for the header.
18+
# @param href [String, nil]
19+
# When given, the header renders a plain sort link to this URL instead
20+
# of the client-side sort button (used for external sorting).
1821
# @param system_arguments [Hash]
1922
# System arguments passed to the root element
20-
def initialize(direction: DEFAULT_DIRECTION, **system_arguments)
23+
def initialize(direction: DEFAULT_DIRECTION, href: nil, **system_arguments)
2124
@direction = fetch_or_fallback(DIRECTION_OPTIONS, direction, DEFAULT_DIRECTION)
25+
@href = href
2226
aria_sort = ARIA_SORT_OPTIONS.fetch(@direction, nil)
2327

2428
@system_arguments = system_arguments
@@ -30,6 +34,23 @@ def initialize(direction: DEFAULT_DIRECTION, **system_arguments)
3034
@system_arguments, { aria: { sort: aria_sort } }
3135
)
3236
end
37+
38+
private
39+
40+
def sort_control
41+
if @href
42+
Primer::Beta::Link.new(
43+
href: @href,
44+
muted: true,
45+
classes: "TableSortButton TableSortLink"
46+
)
47+
else
48+
Primer::Beta::BaseButton.new(
49+
classes: "TableSortButton",
50+
data: { action: "click:data-table#toggleSort" }
51+
)
52+
end
53+
end
3354
end
3455
end
3556
end

app/components/primer/open_project/data_table_element.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ const DEFAULT_SORT_DIRECTION = SortDirection.ASC
1616
@controller('data-table')
1717
export class DataTableElement extends HTMLElement {
1818
connectedCallback() {
19+
if (this.externalSorting) return
20+
1921
sortTableByAriaSort(this.table)
2022
updateSortIcons(this.table)
2123
}
2224

2325
toggleSort(event: MouseEvent) {
26+
if (this.externalSorting) return
27+
2428
const header = (event.target as Element).closest('th')!
2529
const direction = getSortDirection(header)
2630
const nextDirection =
@@ -45,6 +49,10 @@ export class DataTableElement extends HTMLElement {
4549
get table(): HTMLTableElement {
4650
return this.querySelector('table')!
4751
}
52+
53+
get externalSorting(): boolean {
54+
return this.hasAttribute('data-external-sorting')
55+
}
4856
}
4957

5058
function resetSort(th: HTMLElement) {

previews/primer/open_project/data_table_preview.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,22 @@ def with_cell_placeholder
7676
render_with_template(locals: { rows: rows })
7777
end
7878

79+
# @label With External Sorting
80+
# @snapshot
81+
# @param sort_column [Symbol] select [name, status_code, created_at]
82+
# @param sort_direction [Symbol] select [ASC, DESC]
83+
def with_external_sorting(sort_column: :name, sort_direction: :ASC)
84+
column = sort_column.to_sym
85+
direction = sort_direction.to_s.upcase == "DESC" ? :DESC : :ASC
86+
87+
rows = sample_rows.sort_by { |row| row.public_send(column).to_s } # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
88+
rows.reverse! if direction == :DESC
89+
90+
render_with_template(
91+
locals: { rows: rows, sort_column: column, sort_direction: direction }
92+
)
93+
end
94+
7995
# @label With Pagination
8096
# @snapshot
8197
# @param page [Integer] number
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<%=
2+
render(
3+
Primer::OpenProject::DataTable.new(
4+
rows,
5+
sorting: :external,
6+
initial_sort_column: sort_column,
7+
initial_sort_direction: sort_direction,
8+
sort_href_builder: ->(column_id, direction) { "?sort_column=#{column_id}&sort_direction=#{direction}" }
9+
)
10+
) do |data_table|
11+
data_table.with_title(tag: :h2) { "Projects" }
12+
data_table.with_subtitle { "The server owns the sort order; headers are plain links (Preview-only: links round-trip via preview params)." }
13+
14+
data_table.with_column(field: :name, header: "Name", row_header: true, sort_by: true)
15+
data_table.with_column(field: :status_code, header: "Status", sort_by: true)
16+
data_table.with_column(field: :created_at, header: "Created", align: :end, sort_by: true) do |column|
17+
column.with_cell { |row| row.created_at.strftime("%Y-%m-%d") }
18+
end
19+
end
20+
%>

static/arguments.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6088,6 +6088,18 @@
60886088
"default": "`nil`",
60896089
"description": "Sort direction for the initially sorted column. Options: :ASC, :DESC"
60906090
},
6091+
{
6092+
"name": "sorting",
6093+
"type": "Symbol",
6094+
"default": "`:client`",
6095+
"description": "How sorting is performed. `:client` sorts rows in Ruby for the initial render and re-sorts in the browser. `:external` leaves row order to the caller (e.g. SQL ordering): sortable headers become links built by `sort_href_builder`, and `initial_sort_column`/`initial_sort_direction` only drive the rendered sort state. Options: :client, :external"
6096+
},
6097+
{
6098+
"name": "sort_href_builder",
6099+
"type": "Proc, nil",
6100+
"default": "`nil`",
6101+
"description": "`->(column_id, direction)` returning the URL a sortable header links to, where `direction` is the direction the link requests next. Required when `sorting: :external` and any column is sortable."
6102+
},
60916103
{
60926104
"name": "divider",
60936105
"type": "Boolean",
@@ -6306,6 +6318,12 @@
63066318
"default": "`:ASC`",
63076319
"description": "select [:ASC, :DESC, :NONE] Specify the sort direction for the header."
63086320
},
6321+
{
6322+
"name": "href",
6323+
"type": "String, nil",
6324+
"default": "`nil`",
6325+
"description": "When given, the header renders a plain sort link to this URL instead of the client-side sort button (used for external sorting)."
6326+
},
63096327
{
63106328
"name": "system_arguments",
63116329
"type": "Hash",

static/constants.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,11 @@
17801780
"GeneratedSlotMethods": "Primer::OpenProject::DataTable::GeneratedSlotMethods",
17811781
"Header": "Primer::OpenProject::DataTable::Header",
17821782
"PaginationFooter": "Primer::OpenProject::DataTable::PaginationFooter",
1783+
"SORTING_DEFAULT": "client",
1784+
"SORTING_OPTIONS": [
1785+
"client",
1786+
"external"
1787+
],
17831788
"SUBTITLE_TAG_DEFAULT": "div",
17841789
"SUBTITLE_TAG_OPTIONS": [
17851790
"div",

0 commit comments

Comments
 (0)