Skip to content

Commit e9679c1

Browse files
authored
feat(data_table): Hotwire-first DataTable component family (#353)
* feat(data_table): add 14 components + Stimulus controllers Ports the complete DataTable component family from the web repo: - DataTable (Turbo Frame wrapper + data-controller) - DataTableBulkActions, DataTableToolbar, DataTablePaginationBar - DataTableForm (CSRF-aware form wrapper) - DataTableSearch (debounced GET form via Stimulus) - DataTablePerPageSelect (requestSubmit on change) - DataTableSelectAllCheckbox, DataTableRowCheckbox - DataTableSelectionSummary - DataTableSortHead (server-side sort links with inline SVG icons) - DataTableColumnToggle (client-side visibility via DropdownMenu) - DataTableExpandToggle (accessible row expand button) - DataTablePagination (windowed page links + adapter resolution) Three Stimulus controllers: ruby-ui--data-table (selection + expand delegate), ruby-ui--data-table-column-visibility, ruby-ui--data-table-search (debounced input with focus-restore across Turbo Frame swaps). Inline SVGs replace view_context.lucide_icon calls (chevron-up/down, chevrons-up-down, chevron-right, chevron-down). CGI::escape replaces ActiveSupport's to_query and Hash#except replaces reject-based exclusion. * feat(data_table): add pagination adapters (Manual, Pagy, Kaminari) Three duck-typed adapters exposing current_page, total_pages, total_count, and per_page. No runtime gem dependencies: Manual takes raw integers, Pagy wraps a pagy object (pages/items), Kaminari wraps a collection (total_pages/limit_value). A custom adapter (with:) is also supported. * feat(data_table): add docs view with examples Covers: server-driven sort/search/pagination, selection + bulk actions, column visibility toggle, and expandable rows. Uses static sample rows (no backend dependency) following the accordion_docs pattern. * test(data_table): component + adapter tests (ComponentTest + Phlex::Kit) Converted from ActiveSupport::TestCase to ComponentTest: test blocks to def test_* methods, assert_no_match to refute_match (no ActiveSupport), plain "TEXT" to "TEXT" string literals in Phlex blocks, render_component to phlex { RubyUI.X(...) }. Adapter tests require their subjects explicitly since the test_helper glob only autoloads top-level RubyUI:: constants. 157 runs, 656 assertions, 0 failures, 0 errors. * chore(generators): register data_table in dependencies.yml Lists component dependencies: Table, Checkbox, NativeSelect, Pagination, DropdownMenu, Input, Button.
1 parent e143b5e commit e9679c1

39 files changed

Lines changed: 1409 additions & 0 deletions

lib/generators/ruby_ui/dependencies.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ accordion:
22
js_packages:
33
- "motion"
44

5+
data_table:
6+
components:
7+
- "Table"
8+
- "Checkbox"
9+
- "NativeSelect"
10+
- "Pagination"
11+
- "DropdownMenu"
12+
- "Input"
13+
- "Button"
14+
515
alert_dialog:
616
components:
717
- "Button"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTable < Base
5+
register_element :turbo_frame, tag: "turbo-frame"
6+
7+
def initialize(id:, **attrs)
8+
@id = id
9+
super(**attrs)
10+
end
11+
12+
def view_template(&block)
13+
turbo_frame(id: @id, target: "_top") do
14+
div(**attrs) do
15+
yield if block
16+
end
17+
end
18+
end
19+
20+
private
21+
22+
def default_attrs
23+
{
24+
class: "w-full space-y-4",
25+
data: {controller: "ruby-ui--data-table"}
26+
}
27+
end
28+
end
29+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableBulkActions < Base
5+
def view_template(&)
6+
div(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{
13+
class: "hidden items-center gap-2",
14+
data: {"ruby-ui--data-table-target": "bulkActions"}
15+
}
16+
end
17+
end
18+
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableColumnToggle < Base
5+
def initialize(columns:, **attrs)
6+
@columns = columns
7+
super(**attrs)
8+
end
9+
10+
def view_template
11+
div(**attrs) do
12+
render RubyUI::DropdownMenu.new do
13+
render RubyUI::DropdownMenuTrigger.new do
14+
render RubyUI::Button.new(variant: :outline, size: :sm) do
15+
plain "Columns"
16+
# inline chevron-down SVG (lucide 24px, 1px stroke)
17+
svg(
18+
xmlns: "http://www.w3.org/2000/svg",
19+
width: "16",
20+
height: "16",
21+
viewBox: "0 0 24 24",
22+
fill: "none",
23+
stroke: "currentColor",
24+
stroke_width: "2",
25+
stroke_linecap: "round",
26+
stroke_linejoin: "round",
27+
class: "w-4 h-4 ml-1"
28+
) do |s|
29+
s.polyline(points: "6 9 12 15 18 9")
30+
end
31+
end
32+
end
33+
render RubyUI::DropdownMenuContent.new do
34+
@columns.each do |col|
35+
label(class: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent") do
36+
input(
37+
type: "checkbox",
38+
checked: true,
39+
class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer",
40+
data: {
41+
column_key: col[:key].to_s,
42+
action: "change->ruby-ui--data-table-column-visibility#toggle"
43+
}
44+
)
45+
span { plain col[:label] }
46+
end
47+
end
48+
end
49+
end
50+
end
51+
end
52+
53+
private
54+
55+
def default_attrs
56+
{
57+
class: "relative",
58+
data: {controller: "ruby-ui--data-table-column-visibility"}
59+
}
60+
end
61+
end
62+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js
2+
import { Controller } from "@hotwired/stimulus";
3+
4+
export default class extends Controller {
5+
toggle(event) {
6+
const key = event.target.dataset.columnKey;
7+
const visible = event.target.checked;
8+
const root = this.element.closest('[data-controller~="ruby-ui--data-table"]');
9+
if (!root) return;
10+
root
11+
.querySelectorAll(`[data-column="${key}"]`)
12+
.forEach((el) => el.classList.toggle("hidden", !visible));
13+
}
14+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// app/javascript/controllers/ruby_ui/data_table_controller.js
2+
import { Controller } from "@hotwired/stimulus";
3+
4+
export default class extends Controller {
5+
static targets = [
6+
"selectAll",
7+
"rowCheckbox",
8+
"selectionSummary",
9+
"selectionBar",
10+
"bulkActions",
11+
];
12+
13+
connect() {
14+
this.updateState();
15+
}
16+
17+
toggleAll(event) {
18+
const checked = event.target.checked;
19+
this.rowCheckboxTargets.forEach((cb) => {
20+
cb.checked = checked;
21+
});
22+
this.updateState();
23+
}
24+
25+
toggleRow() {
26+
this.updateState();
27+
}
28+
29+
toggleRowDetail(event) {
30+
const button = event.currentTarget;
31+
const id = button.getAttribute("aria-controls");
32+
if (!id) return;
33+
const target = document.getElementById(id);
34+
if (!target) return;
35+
const expanded = button.getAttribute("aria-expanded") === "true";
36+
button.setAttribute("aria-expanded", String(!expanded));
37+
target.classList.toggle("hidden", expanded);
38+
}
39+
40+
updateState() {
41+
const total = this.rowCheckboxTargets.length;
42+
const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
43+
44+
if (this.hasSelectAllTarget) {
45+
this.selectAllTarget.checked = total > 0 && selected === total;
46+
this.selectAllTarget.indeterminate = selected > 0 && selected < total;
47+
}
48+
49+
if (this.hasSelectionSummaryTarget) {
50+
this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
51+
}
52+
53+
if (this.hasBulkActionsTarget) {
54+
this.bulkActionsTarget.classList.toggle("hidden", selected === 0);
55+
}
56+
}
57+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# frozen_string_literal: true
2+
3+
class Views::Docs::DataTable < Views::Base
4+
Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)
5+
6+
SAMPLE_ROWS = [
7+
Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"),
8+
Row.new(id: 2, name: "Bob", email: "bob@example.com", salary: 75_000, status: "Inactive"),
9+
Row.new(id: 3, name: "Carol", email: "carol@example.com", salary: 85_000, status: "Active")
10+
].freeze
11+
12+
def view_template
13+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
14+
component = "DataTable"
15+
render Docs::Header.new(
16+
title: component,
17+
description: "A Hotwire-first data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission."
18+
)
19+
20+
Heading(level: 2) { "Usage" }
21+
22+
render Docs::VisualCodeExample.new(title: "Server-driven table", context: self) do
23+
@@code = <<~RUBY
24+
DataTable(id: "employees") do
25+
DataTableToolbar do
26+
DataTableSearch(path: employees_path, value: @search)
27+
DataTablePerPageSelect(path: employees_path, value: @per_page)
28+
end
29+
30+
div(class: "rounded-md border") do
31+
Table do
32+
TableHeader do
33+
TableRow do
34+
TableHead { "Name" }
35+
DataTableSortHead(column_key: :email, label: "Email",
36+
sort: @sort, direction: @direction,
37+
path: employees_path)
38+
TableHead(class: "text-right") { "Salary" }
39+
end
40+
end
41+
TableBody do
42+
@rows.each do |r|
43+
TableRow do
44+
TableCell { r.name }
45+
TableCell { r.email }
46+
TableCell(class: "text-right") { r.salary }
47+
end
48+
end
49+
end
50+
end
51+
end
52+
53+
DataTablePaginationBar do
54+
DataTableSelectionSummary(total_on_page: @rows.size)
55+
DataTablePagination(page: @page, per_page: @per_page,
56+
total_count: @total_count, path: employees_path)
57+
end
58+
end
59+
RUBY
60+
end
61+
62+
render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do
63+
@@code = <<~RUBY
64+
FORM_ID = "employees_form"
65+
66+
DataTable(id: "employees_select") do
67+
DataTableToolbar do
68+
DataTableSearch(path: employees_path, value: @search)
69+
DataTableBulkActions do
70+
Button(type: "submit", form: FORM_ID,
71+
formaction: bulk_delete_employees_path,
72+
formmethod: "post",
73+
variant: :destructive, size: :sm) { "Delete" }
74+
end
75+
end
76+
77+
DataTableForm(id: FORM_ID, action: "") do
78+
div(class: "rounded-md border") do
79+
Table do
80+
TableHeader do
81+
TableRow do
82+
TableHead(class: "w-10") { DataTableSelectAllCheckbox() }
83+
TableHead { "Name" }
84+
TableHead { "Email" }
85+
end
86+
end
87+
TableBody do
88+
@rows.each do |r|
89+
TableRow do
90+
TableCell { DataTableRowCheckbox(value: r.id) }
91+
TableCell { r.name }
92+
TableCell { r.email }
93+
end
94+
end
95+
end
96+
end
97+
end
98+
end
99+
100+
DataTablePaginationBar do
101+
DataTableSelectionSummary(total_on_page: @rows.size)
102+
DataTablePagination(page: @page, per_page: @per_page,
103+
total_count: @total_count, path: employees_path)
104+
end
105+
end
106+
RUBY
107+
end
108+
109+
render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do
110+
@@code = <<~RUBY
111+
DataTable(id: "employees_cols") do
112+
DataTableToolbar do
113+
DataTableColumnToggle(columns: [
114+
{key: :email, label: "Email"},
115+
{key: :salary, label: "Salary"}
116+
])
117+
end
118+
119+
Table do
120+
TableHeader do
121+
TableRow do
122+
TableHead { "Name" }
123+
TableHead(data: {column: "email"}) { "Email" }
124+
TableHead(data: {column: "salary"}) { "Salary" }
125+
end
126+
end
127+
TableBody do
128+
@rows.each do |r|
129+
TableRow do
130+
TableCell { r.name }
131+
TableCell(data: {column: "email"}) { r.email }
132+
TableCell(data: {column: "salary"}) { r.salary }
133+
end
134+
end
135+
end
136+
end
137+
end
138+
RUBY
139+
end
140+
141+
render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do
142+
@@code = <<~RUBY
143+
DataTable(id: "employees_expand") do
144+
Table do
145+
TableHeader do
146+
TableRow do
147+
TableHead(class: "w-10") { }
148+
TableHead { "Name" }
149+
TableHead { "Email" }
150+
end
151+
end
152+
TableBody do
153+
@rows.each do |r|
154+
detail_id = "row-\#{r.id}-detail"
155+
TableRow do
156+
TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle \#{r.name}") }
157+
TableCell { r.name }
158+
TableCell { r.email }
159+
end
160+
TableRow(id: detail_id, class: "hidden", role: "region") do
161+
TableCell(colspan: 3, class: "bg-muted/40") do
162+
div(class: "p-4") do
163+
p { "Salary: $\#{r.salary}" }
164+
p { "Status: \#{r.status}" }
165+
end
166+
end
167+
end
168+
end
169+
end
170+
end
171+
end
172+
RUBY
173+
end
174+
175+
render Components::ComponentSetup::Tabs.new(component_name: component)
176+
177+
render Docs::ComponentsTable.new(component_files(component))
178+
end
179+
end
180+
end

0 commit comments

Comments
 (0)