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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +54 -2
- data/.rubocop_todo.yml +3 -115
- data/CHANGELOG.md +68 -17
- 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 +44 -47
- data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
- data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
- data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
- data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
- 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,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
|
+
}
|