ruact 0.0.5 → 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 (131) 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 +67 -18
  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 +40 -45
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
  93. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  94. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  95. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  96. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  97. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  98. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  99. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  102. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  104. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  107. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  118. metadata +55 -15
  119. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  120. data/lib/ruact/server_action.rb +0 -131
  121. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  122. data/lib/ruact/server_functions/registry.rb +0 -148
  123. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  124. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  125. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  126. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  127. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  128. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  129. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  130. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  131. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,253 @@
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 %> list — a DESIGN-SYSTEM-AGNOSTIC table (Story 14.4 / FR103). The
9
+ // default scaffold ships plain, native HTML elements styled by the browser /
10
+ // Rails-default CSS — NO shadcn/ui, NO Tailwind, NO table-engine dependency. The
11
+ // collection arrives as a SERVER-RENDERED prop (`posts`); there is no client query
12
+ // for the initial render. A query only enters when the *client* drives the read:
13
+ // the search box calls useQuery(<%= js_search_alias %>, { q }) and swaps in filtered rows as
14
+ // you type. Per-row delete drives a controlled <%= class_name %>DeleteDialog (DELETE
15
+ // /<%= plural_name %>/:id via the destroy<%= class_name %> action); on `{ ok: true }` the row is
16
+ // removed from the displayed rows IN PLACE (no reload, no URL built). Column
17
+ // sorting is CLIENT-ONLY — the dataset is the index payload; server-side
18
+ // sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
19
+ // the opt-in `--shadcn` path — Story 14.5.)
20
+ import { useState } from "react";
21
+ import { search as <%= js_search_alias %>, destroy<%= class_name %>, useQuery } from "@/.ruact/server-functions";
22
+ import { <%= class_name %>DeleteDialog } from "./<%= class_name %>DeleteDialog";
23
+ <% if typescript? -%>
24
+
25
+ type <%= class_name %>Row = { <%= ts_row_fields %> };
26
+
27
+ // FR100 — opt-in call-site contract: `posts` is required. The index view passes
28
+ // `<<%= class_name %>List posts={rows} />` (satisfied); a call site that omits it
29
+ // fails at preprocess time, not as a silent `undefined` in the browser.
30
+ export const __ruactContract = {
31
+ props: { posts: "required" },
32
+ };
33
+ <% end -%>
34
+
35
+ // Columns whose values are date/datetime strings: the sort compares these by
36
+ // epoch time (`new Date(value).getTime()`) rather than lexically, so they order
37
+ // chronologically. Generated from the model's date/datetime attributes.
38
+ const DATE_KEYS = new Set<% if typescript? %><string><% end %>([<%= scaffold_attributes.select(&:date?).map { |attr| %("#{attr.column_name}") }.join(", ") %>]);
39
+
40
+ // Generated client-side comparator (no table engine). Sorts a COPY of the rows
41
+ // by the active key + direction: `null`/`undefined` always sort LAST (so a blank
42
+ // cell never jumps to the top, regardless of direction); date columns compare by
43
+ // time, numbers numerically, booleans false-before-true, everything else via a
44
+ // locale-aware string compare. The direction flip is applied AFTER the null
45
+ // handling, never to it.
46
+ function compareRows(
47
+ a<% if typescript? %>: <%= class_name %>Row<% end %>,
48
+ b<% if typescript? %>: <%= class_name %>Row<% end %>,
49
+ sort<% if typescript? %>: { key: string; dir: "asc" | "desc" }<% end %>,
50
+ )<% if typescript? %>: number<% end %> {
51
+ const av = a[sort.key<% if typescript? %> as keyof <%= class_name %>Row<% end %>];
52
+ const bv = b[sort.key<% if typescript? %> as keyof <%= class_name %>Row<% end %>];
53
+ if (av == null && bv == null) return 0;
54
+ if (av == null) return 1;
55
+ if (bv == null) return -1;
56
+
57
+ let result<% if typescript? %>: number<% end %>;
58
+ if (DATE_KEYS.has(sort.key)) {
59
+ result = new Date(String(av)).getTime() - new Date(String(bv)).getTime();
60
+ } else if (typeof av === "number" && typeof bv === "number") {
61
+ result = av - bv;
62
+ } else if (typeof av === "boolean" && typeof bv === "boolean") {
63
+ result = Number(av) - Number(bv);
64
+ } else {
65
+ result = String(av).localeCompare(String(bv));
66
+ }
67
+ return sort.dir === "desc" ? -result : result;
68
+ }
69
+
70
+ // Pull a human-readable message out of the failed-delete result. A thrown
71
+ // RuactActionError (non-2xx) carries the parsed structured-error body, whose
72
+ // `message` the gem shapes per env (the exception message in development, the
73
+ // generic server message in production); a resolved soft-failure may carry a
74
+ // top-level `error`/`message`. Returns undefined when none is present, so the
75
+ // dialog can fall back to its own generic copy.
76
+ function deleteErrorMessage(error<% if typescript? %>: unknown<% end %>)<% if typescript? %>: string | undefined<% end %> {
77
+ if (error != null && typeof error === "object") {
78
+ const carrier = error<% if typescript? %> as { body?: unknown; error?: unknown; message?: unknown }<% end %>;
79
+ if (carrier.body != null && typeof carrier.body === "object") {
80
+ const message = (carrier.body<% if typescript? %> as { message?: unknown }<% end %>).message;
81
+ if (typeof message === "string" && message.length > 0) return message;
82
+ }
83
+ if (typeof carrier.error === "string" && carrier.error.length > 0) return carrier.error;
84
+ if (typeof carrier.message === "string" && carrier.message.length > 0) return carrier.message;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ // A resolved delete is a SUCCESS only on the server-owned success shapes: the
90
+ // in-list default `{ ok: true }`, or the delete-from-show `$redirect` the
91
+ // accessor already followed (which resolves to `null`). Any other resolved shape
92
+ // (e.g. an explicit `{ ok: false }`) is a non-success the dialog surfaces.
93
+ function deleteSucceeded(result<% if typescript? %>: unknown<% end %>)<% if typescript? %>: boolean<% end %> {
94
+ if (result === null) return true;
95
+ return (
96
+ typeof result === "object" &&
97
+ (result<% if typescript? %> as { ok?: unknown }<% end %>).ok === true
98
+ );
99
+ }
100
+
101
+ // Per-row actions (AC2) — an Edit link + a Delete TRIGGER that opens the
102
+ // controlled dialog. Each row owns its OWN dialog `open` state (one independent
103
+ // useState), so the dialog opens for exactly one row. `onConfirm` calls
104
+ // destroy<%= class_name %> and, on a server-owned success, asks the List to drop the row
105
+ // in place via `onDeleted`. The agnostic default keeps a single inline actions
106
+ // cell (the responsive overflow menu is a shadcn refinement deferred to the
107
+ // `--shadcn` path — Story 14.5).
108
+ function RowActions({ record, onDeleted }<% if typescript? %>: {
109
+ record: <%= class_name %>Row;
110
+ onDeleted: (id: number) => void;
111
+ }<% end %>) {
112
+ const [open, setOpen] = useState(false);
113
+
114
+ async function onConfirm()<% if typescript? %>: Promise<{ ok: boolean; error?: string }><% end %> {
115
+ try {
116
+ const result = await destroy<%= class_name %>({ id: record.id });
117
+ if (deleteSucceeded(result)) {
118
+ onDeleted(record.id);
119
+ setOpen(false);
120
+ return { ok: true };
121
+ }
122
+ // Resolved but not a success shape → keep the dialog open with the message.
123
+ return { ok: false, error: deleteErrorMessage(result) };
124
+ } catch (error) {
125
+ // Failure (non-2xx structured error) → keep the dialog open, message inline.
126
+ return { ok: false, error: deleteErrorMessage(error) };
127
+ }
128
+ }
129
+
130
+ return (
131
+ <span>
132
+ <a href={`/<%= plural_name %>/${record.id}/edit`}>Edit</a>
133
+ {" "}
134
+ <button type="button" onClick={() => setOpen(true)}>
135
+ Delete
136
+ </button>
137
+
138
+ <<%= class_name %>DeleteDialog
139
+ open={open}
140
+ onOpenChange={setOpen}
141
+ <%= singular_name %>={record}
142
+ onConfirm={onConfirm}
143
+ />
144
+ </span>
145
+ );
146
+ }
147
+
148
+ export function <%= class_name %>List({
149
+ posts = [],
150
+ emptyLabel = "No <%= plural_name %> yet — create one.",
151
+ }<% if typescript? %>: { posts?: <%= class_name %>Row[]; emptyLabel?: string }<% end %>) {
152
+ const [q, setQ] = useState("");
153
+ const searching = q.trim().length > 0;
154
+
155
+ // Ids deleted IN PLACE — a single tombstone list (not a second row cache)
156
+ // applied to whichever source is displayed, so a delete removes the row
157
+ // immediately whether it happened on the server-rendered list OR on the live
158
+ // search results.
159
+ const [removedIds, setRemovedIds] = useState<% if typescript? %><number[]><% end %>([]);
160
+
161
+ // Sort state (AC2) — the active column key + direction, or null when unsorted
162
+ // (server/insertion order). A new key sorts ascending; clicking the same key
163
+ // again flips asc↔desc.
164
+ const [sort, setSort] = useState<% if typescript? %><{ key: string; dir: "asc" | "desc" } | null><% end %>(null);
165
+
166
+ // Client-driven read (AC5) — only meaningful while searching. When q is blank
167
+ // the box is idle and we fall back to the server-rendered rows.
168
+ const { data: searchData, loading: searchLoading } = useQuery<% if typescript? %><<%= class_name %>Row[]><% end %>(<%= js_search_alias %>, { q: q.trim() });
169
+
170
+ const source = searching ? searchData ?? [] : posts;
171
+ const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
172
+ // Always sort a COPY — never mutate the prop/source array.
173
+ const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
174
+
175
+ const onDeleted = (id<% if typescript? %>: number<% end %>) =>
176
+ setRemovedIds((current) => (current.includes(id) ? current : [...current, id]));
177
+
178
+ // Set the sort to a new key (ascending), or flip the direction when the active
179
+ // key is clicked again.
180
+ function toggleSort(key<% if typescript? %>: string<% end %>) {
181
+ setSort((current) =>
182
+ current && current.key === key
183
+ ? { key, dir: current.dir === "asc" ? "desc" : "asc" }
184
+ : { key, dir: "asc" },
185
+ );
186
+ }
187
+
188
+ return (
189
+ <section>
190
+ <h1><%= human_name.pluralize %></h1>
191
+ <p>
192
+ <a href="/<%= plural_name %>/new">+ New <%= human_name.downcase %></a>
193
+ </p>
194
+
195
+ <input
196
+ type="search"
197
+ value={q}
198
+ onChange={(e) => setQ(e.target.value)}
199
+ placeholder="Search <%= plural_name %>…"
200
+ />
201
+
202
+ {searching && searchLoading && <p>Searching…</p>}
203
+ {!searching && rows.length === 0 && <p>{emptyLabel}</p>}
204
+ {searching && !searchLoading && rows.length === 0 && (
205
+ <p>No <%= plural_name %> match “{q.trim()}”.</p>
206
+ )}
207
+
208
+ {sortedRows.length > 0 && (
209
+ <table>
210
+ <thead>
211
+ <tr>
212
+ <th>
213
+ <button type="button" onClick={() => toggleSort("id")}>ID</button>
214
+ </th>
215
+ <% scaffold_attributes.each do |attr| -%>
216
+ <th>
217
+ <button type="button" onClick={() => toggleSort("<%= attr.column_name %>")}>
218
+ <%= attr.column_name.humanize %>
219
+ </button>
220
+ </th>
221
+ <% end -%>
222
+ <th>Actions</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ {sortedRows.map((row) => (
227
+ <tr key={row.id}>
228
+ <td>
229
+ <a href={`/<%= plural_name %>/${row.id}`}>{row.id}</a>
230
+ </td>
231
+ <% scaffold_attributes.each do |attr| -%>
232
+ <% kind = attr.column_kind -%>
233
+ <% if kind == :numeric -%>
234
+ <td>{String(row.<%= attr.column_name %> ?? "")}</td>
235
+ <% elsif kind == :badge -%>
236
+ <td>{row.<%= attr.column_name %> ? "Yes" : "No"}</td>
237
+ <% elsif kind == :date -%>
238
+ <td>{row.<%= attr.column_name %> ? new Date(String(row.<%= attr.column_name %>)).toLocaleString() : ""}</td>
239
+ <% else -%>
240
+ <td>{String(row.<%= attr.column_name %> ?? "")}</td>
241
+ <% end -%>
242
+ <% end -%>
243
+ <td>
244
+ <RowActions record={row} onDeleted={onDeleted} />
245
+ </td>
246
+ </tr>
247
+ ))}
248
+ </tbody>
249
+ </table>
250
+ )}
251
+ </section>
252
+ );
253
+ }
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= controller_class_name %>Controller — a complete CRUD on the v2 route-driven contract.
4
+ #
5
+ # `include Ruact::Server` (sibling of the Ruact::Controller already on
6
+ # ApplicationController via `ruact:install`) gives this controller two
7
+ # behaviours at once:
8
+ #
9
+ # - GET actions (index/show/new/edit) need NO explicit `ruact_render` —
10
+ # Ruact::Controller#default_render activates the RSC pipeline implicitly
11
+ # whenever a matching .html.erb exists. The actions just set ivars.
12
+ #
13
+ # - NON-GET actions (create/update/destroy) are the v2 SERVER FUNCTIONS:
14
+ # exposed because the route is non-GET on a Ruact::Server controller. The
15
+ # route-driven codegen emits their JS accessors (create<%= class_name %> /
16
+ # update<%= class_name %> / destroy<%= class_name %>). On success they
17
+ # drive navigation server-side via `redirect_to` (the override emits
18
+ # `{ "$redirect": "<path>" }`, which the runtime follows); on a validation
19
+ # failure they surface an always-present, attribute-keyed `errors` map via
20
+ # `ruact_errors` (FR98). The navigation decision lives HERE, not in React.
21
+ #
22
+ # NOTE: `include Ruact::Server` must come AFTER `protect_from_forgery` (inherited
23
+ # from ApplicationController) so the CSRF check precedes these actions.
24
+ class <%= controller_class_name %>Controller < ApplicationController
25
+ include Ruact::Server
26
+
27
+ # --- GET pages (rendered as RSC) -----------------------------------------
28
+
29
+ def index
30
+ @<%= plural_name %> = <%= class_name %>.all
31
+ end
32
+
33
+ def show
34
+ @<%= singular_name %> = <%= class_name %>.find(params[:id])
35
+ # FR96 opt-in (tamper-proof reference): resolve from a signed, purpose-scoped
36
+ # token instead of a raw, forgeable :id. Mint the token in create/update below.
37
+ # @<%= singular_name %> = Ruact.locate_signed(params[:<%= singular_name %>_ref], for: :<%= singular_name %>_ref)
38
+ end
39
+
40
+ def new
41
+ <% reference_attributes.each do |ref| -%>
42
+ # Story 10.3 (AC6) — parent options for the <%= ref.name %> <Select> (ivar → prop,
43
+ # capped at <%= reference_options_limit %>+1). Labels: name → title → to_s.
44
+ @<%= ref.options_ivar %> = <%= reference_options_expr(ref) %>
45
+ <% end -%>
46
+ end
47
+
48
+ def edit
49
+ @<%= singular_name %> = <%= class_name %>.find(params[:id])
50
+ <% reference_attributes.each do |ref| -%>
51
+ @<%= ref.options_ivar %> = <%= reference_options_expr(ref) %>
52
+ <% end -%>
53
+ end
54
+
55
+ # --- Writes (v2 actions → Bucket 2 JSON / $redirect) ---------------------
56
+
57
+ def create
58
+ <%= singular_name %> = <%= class_name %>.new(<%= singular_name %>_params)
59
+ if <%= singular_name %>.save
60
+ # FR96 opt-in: hand the client a signed, scoped, expiring handle to the
61
+ # saved record instead of a raw id (rides the Bucket-2 JSON as a String).
62
+ # @<%= singular_name %>_ref = Ruact.signed_global_id(<%= singular_name %>, for: :<%= singular_name %>_ref, expires_in: 1.hour)
63
+ redirect_to <%= singular_name %>
64
+ else
65
+ # FR98 — the always-present, attribute-keyed `errors` map. The form
66
+ # surfaces it inline (no redirect — the client stays on the form).
67
+ ruact_errors(<%= singular_name %>)
68
+ @ok = false
69
+ end
70
+ end
71
+
72
+ def update
73
+ <%= singular_name %> = <%= class_name %>.find(params[:id])
74
+ if <%= singular_name %>.update(<%= singular_name %>_params)
75
+ # @<%= singular_name %>_ref = Ruact.signed_global_id(<%= singular_name %>, for: :<%= singular_name %>_ref, expires_in: 1.hour)
76
+ redirect_to <%= singular_name %>
77
+ else
78
+ ruact_errors(<%= singular_name %>)
79
+ @ok = false
80
+ end
81
+ end
82
+
83
+ def destroy
84
+ <%= singular_name %> = <%= class_name %>.find(params[:id])
85
+ # `destroy!` (bang) so a destroy aborted by a callback RAISES
86
+ # `ActiveRecord::RecordNotDestroyed` instead of silently returning false —
87
+ # otherwise `@ok = true` below would lie. A `restrict_with_exception`
88
+ # foreign key raises `ActiveRecord::DeleteRestrictionError` either way. Both
89
+ # are shaped by the gem's structured-error middleware into the dev/prod
90
+ # failure response the dialog surfaces inline — do NOT catch them here.
91
+ <%= singular_name %>.destroy!
92
+ # DataTable in-list default (golden): NO redirect — the List removes the row
93
+ # in place from this `{ ok: true }` Bucket-2 JSON.
94
+ @ok = true
95
+ @id = <%= singular_name %>.id
96
+ # Delete-from-show alternative — land back on the index instead (the
97
+ # destroy<%= class_name %> accessor follows the emitted `$redirect` automatically):
98
+ # redirect_to <%= plural_name %>_url
99
+ end
100
+
101
+ # --- FR96 opt-in: a reference addressed ONLY by a signed token -------------
102
+ # Uncomment this action AND a matching `collection { post :publish }` route to
103
+ # expose an endpoint that takes NO raw :id — the record is recovered purely
104
+ # from the tamper-proof, purpose-scoped, expiring token minted in create/update
105
+ # above. A forged/expired/wrong-purpose token raises
106
+ # Ruact::InvalidSignedGlobalIDError → a clean structured 400 (no raw-id trust).
107
+ #
108
+ # def publish
109
+ # <%= singular_name %> = Ruact.locate_signed(params[:<%= singular_name %>_ref], for: :<%= singular_name %>_ref)
110
+ # <%= singular_name %>.update(published: true)
111
+ # @ok = true
112
+ # @id = <%= singular_name %>.id
113
+ # end
114
+
115
+ private
116
+
117
+ # Strong-params-style reader: each attribute is read explicitly.<% if scaffold_attributes.any?(&:boolean?) %> A boolean
118
+ # field is coerced to a real boolean (it may arrive as "true"/"on" over the wire).<% end %>
119
+ def <%= singular_name %>_params
120
+ {
121
+ <% scaffold_attributes.each do |attr| -%>
122
+ <% if attr.boolean? -%>
123
+ <%= attr.column_name %>: ActiveModel::Type::Boolean.new.cast(params[:<%= attr.column_name %>]),
124
+ <% else -%>
125
+ <%= attr.column_name %>: params[:<%= attr.column_name %>],
126
+ <% end -%>
127
+ <% end -%>
128
+ }
129
+ end
130
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for the app's v2 server QUERIES (Story 9.4 contract). Mirrors the
4
+ # app/controllers/application_controller.rb convention — a thin host base every
5
+ # concrete query inherits. The context accessors (current_user, params, request,
6
+ # session) come from Ruact::Query and are inherited, so they never appear in
7
+ # public_instance_methods(false) and are NOT mounted as routes.
8
+ #
9
+ # Created by `ruact:scaffold` the first time a resource introduces a query
10
+ # (`ruact:install` does not ship it). A second scaffold never clobbers it, so
11
+ # customise this base freely.
12
+ class ApplicationQuery < Ruact::Query
13
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Read side of the CRUD. Each public instance method becomes one named GET
4
+ # route when mounted with `ruact_queries <%= query_class_name %>`:
5
+ #
6
+ # GET /q/search → <%= query_class_name %>#search(q:) → useQuery(<%= js_search_alias %>, { q }) (list search box)
7
+ #
8
+ # NOTE: there is deliberately NO whole-list query — the index list is
9
+ # server-rendered as props (see <%= controller_class_name %>Controller#index). A query is only the
10
+ # right tool when the *client* drives the read; here that's the search box.
11
+ class <%= query_class_name %> < ApplicationQuery
12
+ # Client-driven search (FR88 kwargs). A blank/nil `q` returns the whole list;
13
+ # otherwise a case-insensitive match over the model's string/text columns. THIS
14
+ # is what justifies a query — the result depends on live client input, not on
15
+ # what the server already had. Rows are the SAME shape the index serializes, so
16
+ # the search results and the server-rendered props are interchangeable.
17
+ def search(q:)
18
+ term = q.to_s.strip
19
+ scope =
20
+ <% if searchable_attributes.empty? -%>
21
+ # No string/text columns to match on — a text search can only match
22
+ # nothing, so a non-blank query returns an empty set (a blank box is idle).
23
+ term.empty? ? <%= class_name %>.all : <%= class_name %>.none
24
+ <% else -%>
25
+ if term.empty?
26
+ <%= class_name %>.all
27
+ else
28
+ # `sanitize_sql_like` escapes user-supplied `%`/`_` (with `!`) so they
29
+ # match literally; the value is bound (`:needle`), never interpolated.
30
+ # `ESCAPE '!'` is explicit so the escape is honored on SQLite too (it has
31
+ # no default LIKE escape char, unlike Postgres/MySQL). Column names are
32
+ # quoted per-adapter so a column that is a SQL reserved word (`order`,
33
+ # `select`, …) does not break the query.
34
+ needle = "%#{<%= class_name %>.sanitize_sql_like(term.downcase, "!")}%"
35
+ columns = %w[<%= searchable_attributes.map(&:column_name).join(' ') %>]
36
+ clause = columns.map do |column|
37
+ "LOWER(#{<%= class_name %>.connection.quote_column_name(column)}) LIKE :needle ESCAPE '!'"
38
+ end.join(" OR ")
39
+ <%= class_name %>.where(clause, needle: needle)
40
+ end
41
+ <% end -%>
42
+ # Order by `id` (always present) — newest-first without assuming timestamps.
43
+ scope.order(id: :desc).map { |record| as_row(record) }
44
+ end
45
+
46
+ private
47
+
48
+ # The wire row — must match <%= controller_class_name %>Controller#index's serialization
49
+ # (the `serialized_row` grain) so query results and server-rendered props are
50
+ # interchangeable in <%= class_name %>List.
51
+ def as_row(record)
52
+ {
53
+ id: record.id,
54
+ <% scaffold_attributes.each do |attr| -%>
55
+ <%= attr.column_name %>: <%= row_value_expr('record', attr) %><%= attr == scaffold_attributes.last ? '' : ',' %>
56
+ <% end -%>
57
+ }
58
+ end
59
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Smoke spec for the generated <%= class_name %>Controller (Story 10.1 skeleton).
4
+ #
5
+ # PREREQUISITES (the model + migration are now generated for you — ruact:scaffold
6
+ # delegates them to Rails' own `resource` generator (FR102), so after
7
+ # `rails db:migrate` this resource is ready, with no manual model step):
8
+ # 1. rspec-rails (`rails_helper`).
9
+ # 2. a built client manifest (`react-client-manifest.json`) so the RSC GET
10
+ # index can resolve `<<%= class_name %>List />` — run the Vite build / dev
11
+ # server (or your test bootstrap) before the suite.
12
+ <% if scaffold_attributes.any?(&:reference?) -%>
13
+ # 3. this scaffold has a `references` column — adjust `valid_attributes` below
14
+ # to a REAL associated record id (the placeholder `1` assumes one exists;
15
+ # a required `belongs_to` will reject a dangling id).
16
+ <% end -%>
17
+ #
18
+ # With those in place this passes unmodified. It is a LIGHT smoke of the v2
19
+ # contract — not full coverage: RSC GET index, the Bucket-2 JSON create
20
+ # happy-path (server-driven `$redirect`), the FR98 keyed `errors` channel, and
21
+ # destroy. Add real coverage as the resource grows.
22
+ require "rails_helper"
23
+
24
+ RSpec.describe "<%= class_name.pluralize %>", type: :request do
25
+ # Every imperative accessor call (create<%= class_name %>/update<%= class_name %>/destroy<%= class_name %>) sends
26
+ # this exact header — it is what routes the action into the Bucket-2 JSON path.
27
+ let(:bucket_two) { { "Accept" => "application/json" } }
28
+
29
+ let(:valid_attributes) do
30
+ {
31
+ <% scaffold_attributes.each do |attr| -%>
32
+ <%= attr.column_name %>: <%= sample_value(attr) %>,
33
+ <% end -%>
34
+ }
35
+ end
36
+
37
+ describe "GET /<%= plural_name %>" do
38
+ it "renders the index (200)" do
39
+ get "/<%= plural_name %>"
40
+ expect(response).to have_http_status(:ok)
41
+ end
42
+ end
43
+
44
+ describe "POST /<%= plural_name %> (Bucket 2)" do
45
+ it "creates a <%= singular_name %> and drives a server-side redirect" do
46
+ expect do
47
+ post "/<%= plural_name %>", params: valid_attributes, headers: bucket_two
48
+ end.to change(<%= class_name %>, :count).by(1)
49
+
50
+ # Success path is a server-driven redirect the runtime follows ($redirect).
51
+ expect(response.parsed_body).to be_a(Hash).and(have_key("$redirect"))
52
+ end
53
+
54
+ it "rides the FR98 keyed `errors` contract" do
55
+ # Empty params. If the model declares a validation it fails → an
56
+ # attribute-keyed `errors` map; if it does not, the create succeeds and
57
+ # the server redirects. Either branch upholds the v2 contract.
58
+ post "/<%= plural_name %>", params: {}, headers: bucket_two
59
+ body = response.parsed_body
60
+ expect(body).to be_a(Hash)
61
+ if body.key?("errors")
62
+ expect(body["errors"]).to be_a(Hash)
63
+ else
64
+ expect(body).to have_key("$redirect")
65
+ end
66
+ end
67
+ end
68
+
69
+ describe "DELETE /<%= plural_name %>/:id (Bucket 2)" do
70
+ it "destroys the <%= singular_name %>" do
71
+ <%= singular_name %> = <%= class_name %>.create!(valid_attributes)
72
+ expect do
73
+ delete "/<%= plural_name %>/#{<%= singular_name %>.id}", headers: bucket_two
74
+ end.to change(<%= class_name %>, :count).by(-1)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,7 @@
1
+ <%%# <%= class_name %> edit — the shared form with the record serialized to a plain
2
+ row hash (initial != null = edit mode). No as_json on the model. %>
3
+ <%% initial = <%= serialized_row("@#{singular_name}") %> %>
4
+ <% reference_attributes.each do |ref| -%>
5
+ <%% <%= ref.options_ivar %> = @<%= ref.options_ivar %> %>
6
+ <% end -%>
7
+ <<%= class_name %>Form initial={initial}<% reference_attributes.each do |ref| %> <%= ref.options_prop %>={<%= ref.options_ivar %>}<% end %> />
@@ -0,0 +1,6 @@
1
+ <%%# <%= class_name %> index — the list is SERVER-RENDERED and handed to React as
2
+ props. The data is already in hand at render time, so it ships inline in the
3
+ Flight payload: no client query, no loading flash. The ivar is serialized to
4
+ plain row hashes here in the view (no as_json on the model). %>
5
+ <%% rows = @<%= plural_name %>.map { |<%= singular_name %>| <%= serialized_row(singular_name) %> } %>
6
+ <<%= class_name %>List posts={rows} />
@@ -0,0 +1,5 @@
1
+ <%%# <%= class_name %> new — the shared form in create mode (no initial data). %>
2
+ <% reference_attributes.each do |ref| -%>
3
+ <%% <%= ref.options_ivar %> = @<%= ref.options_ivar %> %>
4
+ <% end -%>
5
+ <<%= class_name %>Form<% reference_attributes.each do |ref| %> <%= ref.options_prop %>={<%= ref.options_ivar %>}<% end %> />
@@ -0,0 +1,16 @@
1
+ <%%# <%= class_name %> show — a PLAIN Rails view (no React component). It still goes
2
+ through the RSC pipeline — the HTML becomes host React elements in the Flight
3
+ tree — so "just Rails" and React components sit in one React application. %>
4
+ <article style="font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;padding:0 1rem">
5
+ <p style="margin-bottom:4px"><a href="/<%= plural_name %>">← All <%= plural_name %></a></p>
6
+
7
+ <h1 style="margin-bottom:16px"><%= human_name %> #<%%= @<%= singular_name %>.id %></h1>
8
+
9
+ <% scaffold_attributes.each do |attr| -%>
10
+ <p style="margin:4px 0"><strong><%= attr.column_name.humanize %>:</strong> <%%= @<%= singular_name %>.<%= attr.column_name %> %></p>
11
+ <% end -%>
12
+
13
+ <p style="margin-top:24px">
14
+ <a href="/<%= plural_name %>/<%%= @<%= singular_name %>.id %>/edit">Edit</a>
15
+ </p>
16
+ </article>