ruact 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,338 @@
1
+ "use client";
2
+
3
+ <% unless typescript? -%>
4
+ // Generated with --javascript: this .jsx file forfeits the FR99 typed server
5
+ // boundary and the FR100 call-site contract (both TS-only). Re-run without
6
+ // --javascript for the typed .tsx variant.
7
+ <% end -%>
8
+ <% if shadcn_missing? -%>
9
+ // ⚠️ GENERATED WITH --skip-shadcn-check — shadcn/ui is not fully set up.
10
+ // The @/components/ui/* imports below will NOT resolve until you install them
11
+ // (the Vite dev server fails the build with an import error — loud by design):
12
+ // <%= shadcn_banner_add_command %>
13
+ // (If you have not set up shadcn at all, run `npx shadcn@latest init` first.)
14
+ <% end -%>
15
+ // <%= class_name %> list — a shadcn `table` primitive (a styled HTML table) plus a
16
+ // small GENERATED client-side sort. There is NO table-engine dependency: the
17
+ // scaffold stays dep-free (no react-table runtime), matching the dep-free
18
+ // Form and the native date inputs. The collection arrives as a SERVER-RENDERED
19
+ // prop (`posts`); there is no client query for the initial render. A query only
20
+ // enters when the *client* drives the read: the search box calls
21
+ // useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as you type.
22
+ // Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
23
+ // /<%= plural_name %>/:id via the destroy<%= class_name %> action); on `{ ok: true }` the row is
24
+ // removed from the displayed rows IN PLACE (no reload, no URL built). Column
25
+ // sorting is CLIENT-ONLY — the dataset is the index payload; server-side
26
+ // sort/pagination is Phase-3 territory.
27
+ //
28
+ // The `@/components/ui/table` primitive (and the Badge/Button/DropdownMenu
29
+ // primitives below) is installed by Story 10.5 (`npx shadcn add table`). A
30
+ // freshly scaffolded app will not resolve these until 10.5 lands; that is
31
+ // expected (the end-to-end live demo is Story 10.7).
32
+ import { useState } from "react";
33
+ import { search as <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
34
+ import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
35
+ import { Badge } from "@/components/ui/badge";
36
+ import { Button } from "@/components/ui/button";
37
+ import {
38
+ Table,
39
+ TableBody,
40
+ TableCell,
41
+ TableHead,
42
+ TableHeader,
43
+ TableRow,
44
+ } from "@/components/ui/table";
45
+ import {
46
+ DropdownMenu,
47
+ DropdownMenuContent,
48
+ DropdownMenuItem,
49
+ DropdownMenuTrigger,
50
+ } from "@/components/ui/dropdown-menu";
51
+ <% if typescript? -%>
52
+
53
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
54
+
55
+ // FR100 — opt-in call-site contract: `posts` is required. The index view passes
56
+ // `<<%= class_name %>List posts={rows} />` (satisfied); a call site that omits it
57
+ // fails at preprocess time, not as a silent `undefined` in the browser.
58
+ export const __ruactContract = {
59
+ props: { posts: "required" },
60
+ };
61
+ <% end -%>
62
+
63
+ // Columns whose values are date/datetime strings: the sort compares these by
64
+ // epoch time (`new Date(value).getTime()`) rather than lexically, so they order
65
+ // chronologically. Generated from the model's date/datetime attributes.
66
+ const DATE_KEYS = new Set<% if typescript? %><string><% end %>([<%= scaffold_attributes.select(&:date?).map { |attr| %("#{attr.column_name}") }.join(", ") %>]);
67
+
68
+ // Generated client-side comparator (no table engine). Sorts a COPY of the rows
69
+ // by the active key + direction: `null`/`undefined` always sort LAST (so a blank
70
+ // cell never jumps to the top, regardless of direction); date columns compare by
71
+ // time, numbers numerically, booleans false-before-true, everything else via a
72
+ // locale-aware string compare. The direction flip is applied AFTER the null
73
+ // handling, never to it.
74
+ function compareRows(
75
+ a<% if typescript? %>: <%= class_name %>Row<% end %>,
76
+ b<% if typescript? %>: <%= class_name %>Row<% end %>,
77
+ sort<% if typescript? %>: { key: string; dir: "asc" | "desc" }<% end %>,
78
+ )<% if typescript? %>: number<% end %> {
79
+ const av = a[sort.key<% if typescript? %> as keyof <%= class_name %>Row<% end %>];
80
+ const bv = b[sort.key<% if typescript? %> as keyof <%= class_name %>Row<% end %>];
81
+ if (av == null && bv == null) return 0;
82
+ if (av == null) return 1;
83
+ if (bv == null) return -1;
84
+
85
+ let result<% if typescript? %>: number<% end %>;
86
+ if (DATE_KEYS.has(sort.key)) {
87
+ result = new Date(String(av)).getTime() - new Date(String(bv)).getTime();
88
+ } else if (typeof av === "number" && typeof bv === "number") {
89
+ result = av - bv;
90
+ } else if (typeof av === "boolean" && typeof bv === "boolean") {
91
+ result = Number(av) - Number(bv);
92
+ } else {
93
+ result = String(av).localeCompare(String(bv));
94
+ }
95
+ return sort.dir === "desc" ? -result : result;
96
+ }
97
+
98
+ // Pull a human-readable message out of the failed-delete result. A thrown
99
+ // RuactActionError (non-2xx) carries the parsed structured-error body, whose
100
+ // `message` the gem shapes per env (the exception message in development, the
101
+ // generic server message in production); a resolved soft-failure may carry a
102
+ // top-level `error`/`message`. Returns undefined when none is present, so the
103
+ // dialog can fall back to its own generic copy.
104
+ function deleteErrorMessage(error<% if typescript? %>: unknown<% end %>)<% if typescript? %>: string | undefined<% end %> {
105
+ if (error != null && typeof error === "object") {
106
+ const carrier = error<% if typescript? %> as { body?: unknown; error?: unknown; message?: unknown }<% end %>;
107
+ if (carrier.body != null && typeof carrier.body === "object") {
108
+ const message = (carrier.body<% if typescript? %> as { message?: unknown }<% end %>).message;
109
+ if (typeof message === "string" && message.length > 0) return message;
110
+ }
111
+ if (typeof carrier.error === "string" && carrier.error.length > 0) return carrier.error;
112
+ if (typeof carrier.message === "string" && carrier.message.length > 0) return carrier.message;
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ // A resolved delete is a SUCCESS only on the server-owned success shapes: the
118
+ // in-list default `{ ok: true }`, or the delete-from-show `$redirect` the
119
+ // accessor already followed (which resolves to `null`). Any other resolved shape
120
+ // (e.g. an explicit `{ ok: false }`) is a non-success the dialog surfaces.
121
+ function deleteSucceeded(result<% if typescript? %>: unknown<% end %>)<% if typescript? %>: boolean<% end %> {
122
+ if (result === null) return true;
123
+ return (
124
+ typeof result === "object" &&
125
+ (result<% if typescript? %> as { ok?: unknown }<% end %>).ok === true
126
+ );
127
+ }
128
+
129
+ // Per-row actions (AC5) — Edit + a Delete TRIGGER that opens the controlled
130
+ // AlertDialog. Each row owns its OWN dialog `open` state (one independent
131
+ // useState), so the dialog opens for exactly one row. `onConfirm` calls
132
+ // destroy<%= class_name %> and, on a server-owned success, asks the List to drop the row
133
+ // in place via `onDeleted`. The same component renders BOTH the inline (≥ md) and
134
+ // the overflow-menu (< md) layouts. The trigger is never unmounted, so Radix
135
+ // returns focus to it on close.
136
+ function RowActions({ record, onDeleted }<% if typescript? %>: {
137
+ record: <%= class_name %>Row;
138
+ onDeleted: (id: number) => void;
139
+ }<% end %>) {
140
+ const [open, setOpen] = useState(false);
141
+ // The overflow menu is CONTROLLED so the Delete item can close it explicitly
142
+ // and open the dialog in the same batch — preventing Radix's default
143
+ // close-autofocus from pulling focus back to the trigger as the dialog mounts.
144
+ const [menuOpen, setMenuOpen] = useState(false);
145
+
146
+ async function onConfirm()<% if typescript? %>: Promise<{ ok: boolean; error?: string }><% end %> {
147
+ try {
148
+ const result = await destroy<%= class_name %>({ id: record.id });
149
+ if (deleteSucceeded(result)) {
150
+ onDeleted(record.id);
151
+ setOpen(false);
152
+ return { ok: true };
153
+ }
154
+ // Resolved but not a success shape → keep the dialog open with the message.
155
+ return { ok: false, error: deleteErrorMessage(result) };
156
+ } catch (error) {
157
+ // Failure (non-2xx structured error) → keep the dialog open, message inline.
158
+ return { ok: false, error: deleteErrorMessage(error) };
159
+ }
160
+ }
161
+
162
+ return (
163
+ <div className="flex justify-end">
164
+ {/* ≥ md (768px): inline actions */}
165
+ <div className="hidden items-center gap-2 md:flex">
166
+ <Button variant="ghost" asChild>
167
+ <a href={`/<%= plural_name %>/${record.id}/edit`}>Edit</a>
168
+ </Button>
169
+ <Button variant="ghost" className="text-destructive" onClick={() => setOpen(true)}>
170
+ Delete
171
+ </Button>
172
+ </div>
173
+ {/* < md: collapse into a … overflow menu so actions never break layout */}
174
+ <div className="md:hidden">
175
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
176
+ <DropdownMenuTrigger asChild>
177
+ <Button variant="ghost" aria-label="Open actions menu">…</Button>
178
+ </DropdownMenuTrigger>
179
+ <DropdownMenuContent align="end">
180
+ <DropdownMenuItem asChild>
181
+ <a href={`/<%= plural_name %>/${record.id}/edit`}>Edit</a>
182
+ </DropdownMenuItem>
183
+ {/* Close the menu and open the AlertDialog together: preventDefault
184
+ stops the default close (whose autofocus would fight the dialog),
185
+ then we close the menu and open the dialog deterministically. */}
186
+ <DropdownMenuItem
187
+ className="text-destructive"
188
+ onSelect={(event) => {
189
+ event.preventDefault();
190
+ setMenuOpen(false);
191
+ setOpen(true);
192
+ }}
193
+ >
194
+ Delete
195
+ </DropdownMenuItem>
196
+ </DropdownMenuContent>
197
+ </DropdownMenu>
198
+ </div>
199
+
200
+ <<%= class_name %>DeleteDialog
201
+ open={open}
202
+ onOpenChange={setOpen}
203
+ <%= singular_name %>={record}
204
+ onConfirm={onConfirm}
205
+ />
206
+ </div>
207
+ );
208
+ }
209
+
210
+ export function <%= class_name %>List({
211
+ posts = [],
212
+ emptyLabel = "No <%= plural_name %> yet — create one.",
213
+ }<% if typescript? %>: { posts?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
214
+ const [q, setQ] = useState("");
215
+ const searching = q.trim().length > 0;
216
+
217
+ // Ids deleted IN PLACE — a single tombstone list (not a second row cache)
218
+ // applied to whichever source is displayed, so a delete removes the row
219
+ // immediately whether it happened on the server-rendered list OR on the live
220
+ // search results.
221
+ const [removedIds, setRemovedIds] = useState<% if typescript? %><number[]><% end %>([]);
222
+
223
+ // Sort state (AC2) — the active column key + direction, or null when unsorted
224
+ // (server/insertion order). A new key sorts ascending; clicking the same key
225
+ // again flips asc↔desc.
226
+ const [sort, setSort] = useState<% if typescript? %><{ key: string; dir: "asc" | "desc" } | null><% end %>(null);
227
+
228
+ // Client-driven read (AC5) — only meaningful while searching. When q is blank
229
+ // the box is idle and we fall back to the server-rendered rows.
230
+ const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
231
+
232
+ const source = searching ? searchData ?? [] : posts;
233
+ const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
234
+ // Always sort a COPY — never mutate the prop/source array.
235
+ const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
236
+
237
+ const onDeleted = (id<% if typescript? %>: number<% end %>) =>
238
+ setRemovedIds((current) => (current.includes(id) ? current : [...current, id]));
239
+
240
+ // Set the sort to a new key (ascending), or flip the direction when the active
241
+ // key is clicked again.
242
+ function toggleSort(key<% if typescript? %>: string<% end %>) {
243
+ setSort((current) =>
244
+ current && current.key === key
245
+ ? { key, dir: current.dir === "asc" ? "desc" : "asc" }
246
+ : { key, dir: "asc" },
247
+ );
248
+ }
249
+
250
+ return (
251
+ <section className="mx-auto max-w-4xl px-4 py-8">
252
+ <div className="mb-4 flex items-center justify-between gap-4">
253
+ <h1 className="text-2xl font-semibold tracking-tight"><%= human_name.pluralize %></h1>
254
+ <Button asChild>
255
+ <a href="/<%= plural_name %>/new">+ New <%= human_name.downcase %></a>
256
+ </Button>
257
+ </div>
258
+
259
+ <input
260
+ type="search"
261
+ value={q}
262
+ onChange={(e) => setQ(e.target.value)}
263
+ placeholder="Search <%= plural_name %>…"
264
+ className="mb-4 w-full rounded-md border px-3 py-2 text-sm"
265
+ />
266
+
267
+ {searching && searchLoading && <p className="text-sm text-muted-foreground">Searching…</p>}
268
+ {!searching && rows.length === 0 && <p className="text-sm text-muted-foreground">{emptyLabel}</p>}
269
+ {searching && !searchLoading && rows.length === 0 && (
270
+ <p className="text-sm text-muted-foreground">No <%= plural_name %> match “{q.trim()}”.</p>
271
+ )}
272
+
273
+ {sortedRows.length > 0 && (
274
+ <Table>
275
+ <TableHeader>
276
+ <TableRow>
277
+ <TableHead>
278
+ <Button variant="ghost" onClick={() => toggleSort("id")}>ID</Button>
279
+ </TableHead>
280
+ <% scaffold_attributes.each do |attr| -%>
281
+ <% if attr.column_kind == :numeric -%>
282
+ <TableHead>
283
+ <div className="text-right">
284
+ <Button variant="ghost" onClick={() => toggleSort("<%= attr.column_name %>")}>
285
+ <%= attr.column_name.humanize %>
286
+ </Button>
287
+ </div>
288
+ </TableHead>
289
+ <% else -%>
290
+ <TableHead>
291
+ <Button variant="ghost" onClick={() => toggleSort("<%= attr.column_name %>")}>
292
+ <%= attr.column_name.humanize %>
293
+ </Button>
294
+ </TableHead>
295
+ <% end -%>
296
+ <% end -%>
297
+ <TableHead className="text-right">Actions</TableHead>
298
+ </TableRow>
299
+ </TableHeader>
300
+ <TableBody>
301
+ {sortedRows.map((row) => (
302
+ <TableRow key={row.id}>
303
+ <TableCell>
304
+ <a href={`/<%= plural_name %>/${row.id}`} className="font-medium underline-offset-4 hover:underline">
305
+ {row.id}
306
+ </a>
307
+ </TableCell>
308
+ <% scaffold_attributes.each do |attr| -%>
309
+ <% kind = attr.column_kind -%>
310
+ <% if kind == :numeric -%>
311
+ <TableCell>
312
+ <div className="text-right tabular-nums">{String(row.<%= attr.column_name %> ?? "")}</div>
313
+ </TableCell>
314
+ <% elsif kind == :badge -%>
315
+ <TableCell>
316
+ <Badge variant={row.<%= attr.column_name %> ? "default" : "secondary"}>{row.<%= attr.column_name %> ? "Yes" : "No"}</Badge>
317
+ </TableCell>
318
+ <% elsif kind == :date -%>
319
+ <TableCell>
320
+ <span>{row.<%= attr.column_name %> ? new Date(String(row.<%= attr.column_name %>)).toLocaleString() : ""}</span>
321
+ </TableCell>
322
+ <% else -%>
323
+ <TableCell>
324
+ <span>{String(row.<%= attr.column_name %> ?? "")}</span>
325
+ </TableCell>
326
+ <% end -%>
327
+ <% end -%>
328
+ <TableCell>
329
+ <RowActions record={row} onDeleted={onDeleted} />
330
+ </TableCell>
331
+ </TableRow>
332
+ ))}
333
+ </TableBody>
334
+ </Table>
335
+ )}
336
+ </section>
337
+ );
338
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ <% unless typescript? -%>
4
+ // Generated with --javascript: this .jsx file forfeits the FR99 typed server
5
+ // boundary and the FR100 call-site contract (both TS-only). Re-run without
6
+ // --javascript for the typed .tsx variant.
7
+ <% end -%>
8
+ // <%= class_name %> delete confirmation — a CONTROLLED, DESIGN-SYSTEM-AGNOSTIC
9
+ // dialog (Story 14.4 / FR103) built on the native HTML <dialog> element — NO
10
+ // shadcn/ui, NO Radix, NO Tailwind. The PARENT owns `open` and provides
11
+ // `onConfirm`; this component owns only its local submit/error state and drives
12
+ // the native modal off the `open` prop (`showModal()`/`close()`). `onConfirm`
13
+ // performs the destroy<%= class_name %> call (DELETE /<%= plural_name %>/:id) and reports `{ ok, error? }`:
14
+ //
15
+ // - on success the parent closes the dialog (in-list removal of the row, or the
16
+ // server-driven `$redirect` the runtime follows) — no URL is built here;
17
+ // - on failure the dialog STAYS OPEN and shows the message inline (the gem's
18
+ // structured-error shape: the exception message in development, the generic
19
+ // server message in production).
20
+ //
21
+ // There is no Rails `method=delete` fallback — the destroy<%= class_name %> action is the
22
+ // only path. Cancel closes via `onOpenChange(false)`; Escape fires the native
23
+ // `close` event, which we forward to `onOpenChange(false)` to keep the parent
24
+ // state in sync. (The shadcn AlertDialog styling is the opt-in `--shadcn` path —
25
+ // Story 14.5.)
26
+ import { useEffect, useRef, useState } from "react";
27
+ <% if typescript? -%>
28
+
29
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
30
+
31
+ // FR100 — opt-in call-site contract. INERT for this component: it is rendered
32
+ // only from <%= class_name %>List.tsx (JSX), never from an ERB `<Component>` tag,
33
+ // so the 13.5 preprocess validator never runs against it. Retained for uniformity
34
+ // with the List/Form.
35
+ export const __ruactContract = {
36
+ props: { <%= singular_name %>: "required" },
37
+ };
38
+ <% end -%>
39
+
40
+ export function <%= class_name %>DeleteDialog({
41
+ open,
42
+ onOpenChange,
43
+ <%= singular_name %>,
44
+ onConfirm,
45
+ }<% if typescript? %>: {
46
+ open: boolean;
47
+ onOpenChange: (open: boolean) => void;
48
+ <%= singular_name %>: <%= class_name %>Row;
49
+ onConfirm: () => Promise<{ ok: boolean; error?: string }>;
50
+ }<% end %>) {
51
+ const dialogRef = useRef<% if typescript? %><HTMLDialogElement | null><% end %>(null);
52
+ const [submitting, setSubmitting] = useState(false);
53
+ const [error, setError] = useState<% if typescript? %><string | null><% end %>(null);
54
+
55
+ // Drive the native <dialog> modal state off the controlled `open` prop.
56
+ useEffect(() => {
57
+ const node = dialogRef.current;
58
+ if (!node) return;
59
+ if (open && !node.open) node.showModal();
60
+ if (!open && node.open) node.close();
61
+ }, [open]);
62
+
63
+ async function handleConfirm(event<% if typescript? %>: { preventDefault: () => void }<% end %>) {
64
+ // The PARENT closes the dialog (via onOpenChange) only after a successful
65
+ // delete; a failure leaves it open with the message.
66
+ event.preventDefault();
67
+ setError(null);
68
+ setSubmitting(true);
69
+ const res = await onConfirm();
70
+ setSubmitting(false);
71
+ if (!res.ok) {
72
+ setError(res.error ?? "Could not delete this <%= human_name.downcase %>");
73
+ }
74
+ }
75
+
76
+ return (
77
+ <dialog ref={dialogRef} onClose={() => onOpenChange(false)}>
78
+ <h2>Delete “{String(<%= singular_name %>.<%= display_attribute %> ?? "")}”?</h2>
79
+ <p>This action cannot be undone.</p>
80
+ {error && <p>{error}</p>}
81
+ <p>
82
+ <button type="button" onClick={() => onOpenChange(false)} disabled={submitting}>
83
+ Cancel
84
+ </button>
85
+ {" "}
86
+ <button type="button" onClick={handleConfirm} disabled={submitting}>
87
+ {submitting ? "Deleting…" : "Delete"}
88
+ </button>
89
+ </p>
90
+ </dialog>
91
+ );
92
+ }
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ <% unless typescript? -%>
4
+ // Generated with --javascript: this .jsx file forfeits the FR99 typed server
5
+ // boundary and the FR100 call-site contract (both TS-only). Re-run without
6
+ // --javascript for the typed .tsx variant.
7
+ <% end -%>
8
+ // <%= class_name %> form — shared by `new` (initial == null) and `edit` (initial
9
+ // == the serialized record). DESIGN-SYSTEM-AGNOSTIC (Story 14.4 / FR103): every
10
+ // field renders a plain, native HTML control mapped to its attribute type
11
+ // (input / textarea / checkbox / select), labelled, with the FR98 server error
12
+ // surfaced inline beneath it — NO shadcn/ui, NO Tailwind, browser-default styling.
13
+ // Submits through the v2 action accessors: create<%= class_name %> (POST /<%= plural_name %>) or
14
+ // update<%= class_name %> (PATCH /<%= plural_name %>/:id). On success the controller drives navigation
15
+ // SERVER-SIDE via `redirect_to` (the `$redirect` the runtime follows) — there is
16
+ // no client URL building here. On a validation failure the SAME response carries
17
+ // the FR98 attribute-keyed `errors` map, surfaced inline per field (the client
18
+ // stays on the form). (The richer shadcn controls are the opt-in `--shadcn` path
19
+ // — Story 14.5.)
20
+ //
21
+ // Client-side validation is intentionally NOT here — it is server-only with a
22
+ // full round-trip. The controlled `useState` keeps this dependency-free.
23
+ import { useState<% if typescript? %>, type FormEvent<% end %> } from "react";
24
+ import { create<%= class_name %>, update<%= class_name %> } from "@/.ruact/server-functions";
25
+ <% if typescript? -%>
26
+
27
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
28
+
29
+ // FR100 — opt-in call-site contract: `initial` is OPTIONAL (`new` omits it,
30
+ // `edit` passes the serialized record); each `references` field adds an
31
+ // optional `<name>Options` prop the new/edit views pass. A typo'd prop name
32
+ // fails at preprocess time instead of silently arriving as `undefined`.
33
+ export const __ruactContract = {
34
+ props: <%= form_contract_props %>,
35
+ };
36
+ <% end -%>
37
+
38
+ export function <%= class_name %>Form({ <%= form_props_params.join(", ") %> }<% if typescript? %>: <%= form_props_type %><% end %>) {
39
+ <% scaffold_attributes.each do |attr| -%>
40
+ <% if attr.boolean? -%>
41
+ const [<%= attr.js_var %>, <%= attr.js_setter %>] = useState(Boolean(initial?.<%= attr.column_name %> ?? false));
42
+ <% else -%>
43
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
44
+ // the row as numbers, so coerce here). The controller re-coerces.
45
+ const [<%= attr.js_var %>, <%= attr.js_setter %>] = useState(String(initial?.<%= attr.column_name %> ?? ""));
46
+ <% end -%>
47
+ <% end -%>
48
+ // FR98 — keyed { [attr]: string[] }; `{}` means "no errors" (always present).
49
+ const [errors, setErrors] = useState<% if typescript? %><Record<string, string[]>><% end %>({});
50
+ const [saving, setSaving] = useState(false);
51
+
52
+ async function handleSubmit(event<% if typescript? %>: FormEvent<% end %>) {
53
+ event.preventDefault();
54
+ setSaving(true);
55
+ setErrors({});
56
+
57
+ const payload = {
58
+ <% scaffold_attributes.each do |attr| -%>
59
+ <%= attr.column_name %>: <%= attr.js_var %>,
60
+ <% end -%>
61
+ };
62
+
63
+ // On success the server returns a `$redirect` and the runtime navigates
64
+ // away (the code below does not run); on a validation failure we get the
65
+ // keyed `errors` map back instead.
66
+ const result = (initial != null
67
+ ? await update<%= class_name %>({ id: initial.id, ...payload })
68
+ : await create<%= class_name %>(payload))<% if typescript? %> as { errors?: Record<string, string[]> } | null<% end %>;
69
+
70
+ setSaving(false);
71
+ if (result && result.errors) {
72
+ setErrors(result.errors);
73
+ }
74
+ }
75
+
76
+ const errorsFor = (attr<% if typescript? %>: string<% end %>) => errors[attr] ?? [];
77
+
78
+ return (
79
+ <form onSubmit={handleSubmit}>
80
+ <h1>{initial != null ? `Edit <%= human_name %> #${initial.id}` : "New <%= human_name %>"}</h1>
81
+
82
+ {errorsFor("base").length > 0 && (
83
+ <ul>
84
+ {errorsFor("base").map((message, i) => (
85
+ <li key={i}>{message}</li>
86
+ ))}
87
+ </ul>
88
+ )}
89
+ <% scaffold_attributes.each do |attr| -%>
90
+ <% if attr.reference? -%>
91
+
92
+ <p>
93
+ <label htmlFor="<%= attr.column_name %>"><%= attr.name.humanize %></label>
94
+ {/* Eager native <select> of controller-provided options (ivar → `<%= attr.options_prop %>`
95
+ prop). For a parent set larger than the generator's REFERENCE_OPTIONS_LIMIT,
96
+ swap this for a server-search combobox backed by a parent-options read
97
+ query (opt-in follow-up — that query is out of this scaffold's scope
98
+ since the parent model isn't scaffolded here). */}
99
+ <select
100
+ id="<%= attr.column_name %>"
101
+ value={<%= attr.js_var %>}
102
+ onChange={(e) => <%= attr.js_setter %>(e.target.value)}
103
+ disabled={saving}
104
+ >
105
+ <option value="">Select <%= attr.name.humanize.downcase %></option>
106
+ {<%= attr.options_prop %>.map((option) => (
107
+ <option key={option.id} value={String(option.id)}>
108
+ {option.label}
109
+ </option>
110
+ ))}
111
+ </select>
112
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
113
+ <span key={i}>{message}</span>
114
+ ))}
115
+ </p>
116
+ <% elsif attr.control == :checkbox -%>
117
+
118
+ <p>
119
+ <label htmlFor="<%= attr.column_name %>">
120
+ <input
121
+ id="<%= attr.column_name %>"
122
+ type="checkbox"
123
+ checked={<%= attr.js_var %>}
124
+ onChange={(e) => <%= attr.js_setter %>(e.target.checked)}
125
+ disabled={saving}
126
+ />
127
+ {" "}<%= attr.column_name.humanize %>
128
+ </label>
129
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
130
+ <span key={i}>{message}</span>
131
+ ))}
132
+ </p>
133
+ <% elsif attr.control == :textarea -%>
134
+
135
+ <p>
136
+ <label htmlFor="<%= attr.column_name %>"><%= attr.column_name.humanize %></label>
137
+ <textarea
138
+ id="<%= attr.column_name %>"
139
+ value={<%= attr.js_var %>}
140
+ onChange={(e) => <%= attr.js_setter %>(e.target.value)}
141
+ disabled={saving}
142
+ />
143
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
144
+ <span key={i}>{message}</span>
145
+ ))}
146
+ </p>
147
+ <% else -%>
148
+
149
+ <p>
150
+ <label htmlFor="<%= attr.column_name %>"><%= attr.column_name.humanize %></label>
151
+ <input
152
+ id="<%= attr.column_name %>"
153
+ type="<%= native_input_type(attr) %>"
154
+ value={<%= attr.js_var %>}
155
+ onChange={(e) => <%= attr.js_setter %>(e.target.value)}
156
+ disabled={saving}
157
+ />
158
+ {errorsFor("<%= attr.column_name %>").map((message, i) => (
159
+ <span key={i}>{message}</span>
160
+ ))}
161
+ </p>
162
+ <% end -%>
163
+ <% end -%>
164
+
165
+ <p>
166
+ <button type="submit" disabled={saving}>
167
+ {saving ? "Saving…" : initial != null ? "Update <%= human_name.downcase %>" : "Create <%= human_name.downcase %>"}
168
+ </button>
169
+ {" "}
170
+ <a href={initial != null ? `/<%= plural_name %>/${initial.id}` : "/<%= plural_name %>"}>Cancel</a>
171
+ </p>
172
+ </form>
173
+ );
174
+ }