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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +67 -18
- data/bench/server_functions_dispatch_bench.rb +109 -142
- data/bench/server_functions_dispatch_bench.results.md +29 -0
- data/docs/internal/decisions/server-functions-api.md +402 -0
- data/lib/generators/ruact/install/install_generator.rb +310 -25
- data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
- data/lib/generators/ruact/install/templates/dev.tt +16 -0
- data/lib/generators/ruact/install/templates/package.json.tt +17 -0
- data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
- data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
- data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
- data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
- data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
- data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
- data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
- data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
- data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
- data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
- data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
- data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
- data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
- data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
- data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
- data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
- data/lib/ruact/client_manifest.rb +37 -36
- data/lib/ruact/component_contract.rb +115 -0
- data/lib/ruact/configuration.rb +80 -28
- data/lib/ruact/controller.rb +69 -421
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +65 -12
- data/lib/ruact/erb_preprocessor_hook.rb +4 -1
- data/lib/ruact/errors.rb +30 -44
- data/lib/ruact/html_converter.rb +22 -1
- data/lib/ruact/railtie.rb +56 -200
- data/lib/ruact/server.rb +28 -6
- data/lib/ruact/server_functions/codegen.rb +49 -188
- data/lib/ruact/server_functions/codegen_v2.rb +23 -6
- data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
- data/lib/ruact/server_functions/error_payload.rb +1 -1
- data/lib/ruact/server_functions/error_rendering.rb +22 -29
- data/lib/ruact/server_functions/name_bridge.rb +14 -16
- data/lib/ruact/server_functions/query_source.rb +35 -8
- data/lib/ruact/server_functions/route_source.rb +3 -4
- data/lib/ruact/server_functions/snapshot.rb +12 -139
- data/lib/ruact/server_functions/validation_errors.rb +70 -0
- data/lib/ruact/server_functions.rb +21 -25
- data/lib/ruact/signed_references.rb +162 -0
- data/lib/ruact/string_distance.rb +72 -0
- data/lib/ruact/validation_errors_collector.rb +139 -0
- data/lib/ruact/version.rb +1 -1
- data/lib/ruact/view_helper.rb +102 -0
- data/lib/ruact.rb +19 -19
- data/lib/tasks/ruact.rake +10 -53
- data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
- data/spec/ruact/client_manifest_spec.rb +36 -0
- data/spec/ruact/component_contract_spec.rb +119 -0
- data/spec/ruact/configuration_spec.rb +51 -34
- data/spec/ruact/controller_request_spec.rb +264 -0
- data/spec/ruact/controller_spec.rb +63 -326
- data/spec/ruact/doctor_spec.rb +201 -0
- data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
- data/spec/ruact/erb_preprocessor_spec.rb +127 -0
- data/spec/ruact/errors_spec.rb +0 -45
- data/spec/ruact/html_converter_spec.rb +50 -0
- data/spec/ruact/install_generator_spec.rb +591 -4
- data/spec/ruact/query_request_spec.rb +109 -1
- data/spec/ruact/scaffold_generator_spec.rb +1835 -0
- data/spec/ruact/server_bucket_request_spec.rb +142 -0
- data/spec/ruact/server_function_name_spec.rb +1 -1
- data/spec/ruact/server_functions/codegen_spec.rb +158 -269
- data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
- data/spec/ruact/server_functions/query_source_spec.rb +51 -0
- data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
- data/spec/ruact/server_functions/rake_spec.rb +29 -29
- data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
- data/spec/ruact/server_rescue_request_spec.rb +6 -6
- data/spec/ruact/server_spec.rb +8 -9
- data/spec/ruact/signed_references_spec.rb +164 -0
- data/spec/ruact/string_distance_spec.rb +38 -0
- data/spec/ruact/validation_errors_spec.rb +116 -0
- data/spec/ruact/view_helper_spec.rb +79 -0
- data/spec/spec_helper.rb +0 -5
- data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +40 -45
- data/vendor/javascript/ruact-server-functions-runtime/index.js +42 -98
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +1 -1
- data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
- data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
- data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
- data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
- data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
- data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
- data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
- data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
- data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
- metadata +55 -15
- data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
- data/lib/ruact/server_action.rb +0 -131
- data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
- data/lib/ruact/server_functions/registry.rb +0 -148
- data/lib/ruact/server_functions/registry_entry.rb +0 -26
- data/lib/ruact/server_functions/standalone_context.rb +0 -103
- data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
- data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
- data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
- data/spec/ruact/server_functions/registry_spec.rb +0 -199
- data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
- data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
- 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>
|