ruact 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +54 -2
  3. data/.rubocop_todo.yml +3 -115
  4. data/CHANGELOG.md +68 -17
  5. data/bench/server_functions_dispatch_bench.rb +109 -142
  6. data/bench/server_functions_dispatch_bench.results.md +29 -0
  7. data/docs/internal/decisions/server-functions-api.md +402 -0
  8. data/lib/generators/ruact/install/install_generator.rb +310 -25
  9. data/lib/generators/ruact/install/templates/Procfile.dev.tt +2 -0
  10. data/lib/generators/ruact/install/templates/dev.tt +16 -0
  11. data/lib/generators/ruact/install/templates/package.json.tt +17 -0
  12. data/lib/generators/ruact/install/templates/vite.config.js.tt +3 -1
  13. data/lib/generators/ruact/scaffold/scaffold_attribute.rb +114 -0
  14. data/lib/generators/ruact/scaffold/scaffold_form_helpers.rb +114 -0
  15. data/lib/generators/ruact/scaffold/scaffold_generator.rb +491 -0
  16. data/lib/generators/ruact/scaffold/scaffold_shadcn_preflight.rb +229 -0
  17. data/lib/generators/ruact/scaffold/templates/components/DeleteDialog.tsx.tt +104 -0
  18. data/lib/generators/ruact/scaffold/templates/components/Form.tsx.tt +212 -0
  19. data/lib/generators/ruact/scaffold/templates/components/List.tsx.tt +338 -0
  20. data/lib/generators/ruact/scaffold/templates/components/agnostic/DeleteDialog.tsx.tt +92 -0
  21. data/lib/generators/ruact/scaffold/templates/components/agnostic/Form.tsx.tt +174 -0
  22. data/lib/generators/ruact/scaffold/templates/components/agnostic/List.tsx.tt +253 -0
  23. data/lib/generators/ruact/scaffold/templates/controller.rb.tt +130 -0
  24. data/lib/generators/ruact/scaffold/templates/queries/application_query.rb.tt +13 -0
  25. data/lib/generators/ruact/scaffold/templates/queries/query.rb.tt +59 -0
  26. data/lib/generators/ruact/scaffold/templates/request_spec.rb.tt +77 -0
  27. data/lib/generators/ruact/scaffold/templates/views/edit.html.erb.tt +7 -0
  28. data/lib/generators/ruact/scaffold/templates/views/index.html.erb.tt +6 -0
  29. data/lib/generators/ruact/scaffold/templates/views/new.html.erb.tt +5 -0
  30. data/lib/generators/ruact/scaffold/templates/views/show.html.erb.tt +16 -0
  31. data/lib/ruact/client_manifest.rb +37 -36
  32. data/lib/ruact/component_contract.rb +115 -0
  33. data/lib/ruact/configuration.rb +80 -28
  34. data/lib/ruact/controller.rb +69 -421
  35. data/lib/ruact/doctor.rb +133 -4
  36. data/lib/ruact/erb_preprocessor.rb +65 -12
  37. data/lib/ruact/erb_preprocessor_hook.rb +4 -1
  38. data/lib/ruact/errors.rb +30 -44
  39. data/lib/ruact/html_converter.rb +22 -1
  40. data/lib/ruact/railtie.rb +56 -200
  41. data/lib/ruact/server.rb +28 -6
  42. data/lib/ruact/server_functions/codegen.rb +49 -188
  43. data/lib/ruact/server_functions/codegen_v2.rb +23 -6
  44. data/lib/ruact/server_functions/codegen_v2_query_params.rb +140 -0
  45. data/lib/ruact/server_functions/error_payload.rb +1 -1
  46. data/lib/ruact/server_functions/error_rendering.rb +22 -29
  47. data/lib/ruact/server_functions/name_bridge.rb +14 -16
  48. data/lib/ruact/server_functions/query_source.rb +35 -8
  49. data/lib/ruact/server_functions/route_source.rb +3 -4
  50. data/lib/ruact/server_functions/snapshot.rb +12 -139
  51. data/lib/ruact/server_functions/validation_errors.rb +70 -0
  52. data/lib/ruact/server_functions.rb +21 -25
  53. data/lib/ruact/signed_references.rb +162 -0
  54. data/lib/ruact/string_distance.rb +72 -0
  55. data/lib/ruact/validation_errors_collector.rb +139 -0
  56. data/lib/ruact/version.rb +1 -1
  57. data/lib/ruact/view_helper.rb +102 -0
  58. data/lib/ruact.rb +19 -19
  59. data/lib/tasks/ruact.rake +10 -53
  60. data/spec/fixtures/story_7_9_views/controller_request_spec_support/errors_demo/new.html.erb +3 -0
  61. data/spec/ruact/client_manifest_spec.rb +36 -0
  62. data/spec/ruact/component_contract_spec.rb +119 -0
  63. data/spec/ruact/configuration_spec.rb +51 -34
  64. data/spec/ruact/controller_request_spec.rb +264 -0
  65. data/spec/ruact/controller_spec.rb +63 -326
  66. data/spec/ruact/doctor_spec.rb +201 -0
  67. data/spec/ruact/erb_preprocessor_hook_spec.rb +4 -1
  68. data/spec/ruact/erb_preprocessor_spec.rb +127 -0
  69. data/spec/ruact/errors_spec.rb +0 -45
  70. data/spec/ruact/html_converter_spec.rb +50 -0
  71. data/spec/ruact/install_generator_spec.rb +591 -4
  72. data/spec/ruact/query_request_spec.rb +109 -1
  73. data/spec/ruact/scaffold_generator_spec.rb +1835 -0
  74. data/spec/ruact/server_bucket_request_spec.rb +142 -0
  75. data/spec/ruact/server_function_name_spec.rb +1 -1
  76. data/spec/ruact/server_functions/codegen_spec.rb +158 -269
  77. data/spec/ruact/server_functions/name_bridge_spec.rb +9 -9
  78. data/spec/ruact/server_functions/query_source_spec.rb +51 -0
  79. data/spec/ruact/server_functions/railtie_integration_spec.rb +71 -268
  80. data/spec/ruact/server_functions/rake_spec.rb +29 -29
  81. data/spec/ruact/server_functions/snapshot_spec.rb +53 -213
  82. data/spec/ruact/server_rescue_request_spec.rb +6 -6
  83. data/spec/ruact/server_spec.rb +8 -9
  84. data/spec/ruact/signed_references_spec.rb +164 -0
  85. data/spec/ruact/string_distance_spec.rb +38 -0
  86. data/spec/ruact/validation_errors_spec.rb +116 -0
  87. data/spec/ruact/view_helper_spec.rb +79 -0
  88. data/spec/spec_helper.rb +0 -5
  89. data/vendor/javascript/ruact-server-functions-runtime/index.d.ts +44 -47
  90. data/vendor/javascript/ruact-server-functions-runtime/index.js +151 -107
  91. data/vendor/javascript/ruact-server-functions-runtime/index.test.mjs +72 -75
  92. data/vendor/javascript/ruact-server-functions-runtime/package.json +2 -2
  93. data/vendor/javascript/ruact-server-functions-runtime/usequery.test.mjs +187 -0
  94. data/vendor/javascript/vite-plugin-ruact/bootstrap.test.mjs +96 -0
  95. data/vendor/javascript/vite-plugin-ruact/index.js +353 -7
  96. data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
  97. data/vendor/javascript/vite-plugin-ruact/package-lock.json +16 -0
  98. data/vendor/javascript/vite-plugin-ruact/package.json +3 -1
  99. data/vendor/javascript/vite-plugin-ruact/registry.test.mjs +199 -0
  100. data/vendor/javascript/vite-plugin-ruact/runtime/bootstrap.jsx +68 -0
  101. data/vendor/javascript/vite-plugin-ruact/runtime/flight-client.js +235 -0
  102. data/vendor/javascript/vite-plugin-ruact/runtime/ruact-router.js +473 -0
  103. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.mjs +114 -139
  104. data/vendor/javascript/vite-plugin-ruact/server-functions-codegen.test.mjs +199 -262
  105. data/vendor/javascript/vite-plugin-ruact/tsconfig.json +18 -0
  106. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold-agnostic.json +18 -0
  107. data/vendor/javascript/vite-plugin-ruact/tsconfig.scaffold.json +20 -0
  108. data/vendor/javascript/vite-plugin-ruact/type-tests/emitted-module.test-d.ts +23 -0
  109. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostDeleteDialog.tsx +90 -0
  110. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostForm.tsx +238 -0
  111. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/PostList.tsx +339 -0
  112. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostDeleteDialog.tsx +85 -0
  113. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostForm.tsx +216 -0
  114. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/PostList.tsx +269 -0
  115. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/agnostic/ambient.d.ts +78 -0
  116. data/vendor/javascript/vite-plugin-ruact/type-tests/scaffold/ambient.d.ts +166 -0
  117. data/vendor/javascript/vite-plugin-ruact/type-tests/typed-query.test-d.ts +48 -0
  118. data/vendor/javascript/vite-plugin-ruact/type-tests/usequery.test-d.ts +26 -0
  119. metadata +55 -15
  120. data/lib/generators/ruact/install/templates/application.jsx.tt +0 -51
  121. data/lib/ruact/server_action.rb +0 -131
  122. data/lib/ruact/server_functions/endpoint_controller.rb +0 -237
  123. data/lib/ruact/server_functions/registry.rb +0 -148
  124. data/lib/ruact/server_functions/registry_entry.rb +0 -26
  125. data/lib/ruact/server_functions/standalone_context.rb +0 -103
  126. data/lib/ruact/server_functions/standalone_dispatcher.rb +0 -178
  127. data/spec/ruact/server_functions/csrf_request_spec.rb +0 -380
  128. data/spec/ruact/server_functions/dispatch_request_spec.rb +0 -819
  129. data/spec/ruact/server_functions/registry_spec.rb +0 -199
  130. data/spec/ruact/server_functions/standalone_action_spec.rb +0 -224
  131. data/spec/ruact/server_functions/standalone_context_spec.rb +0 -142
  132. data/spec/ruact/server_functions/standalone_dispatcher_spec.rb +0 -273
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ // Post delete confirmation — a CONTROLLED, DESIGN-SYSTEM-AGNOSTIC
4
+ // dialog (Story 14.4 / FR103) built on the native HTML <dialog> element — NO
5
+ // shadcn/ui, NO Radix, NO Tailwind. The PARENT owns `open` and provides
6
+ // `onConfirm`; this component owns only its local submit/error state and drives
7
+ // the native modal off the `open` prop (`showModal()`/`close()`). `onConfirm`
8
+ // performs the destroyPost call (DELETE /posts/:id) and reports `{ ok, error? }`:
9
+ //
10
+ // - on success the parent closes the dialog (in-list removal of the row, or the
11
+ // server-driven `$redirect` the runtime follows) — no URL is built here;
12
+ // - on failure the dialog STAYS OPEN and shows the message inline (the gem's
13
+ // structured-error shape: the exception message in development, the generic
14
+ // server message in production).
15
+ //
16
+ // There is no Rails `method=delete` fallback — the destroyPost action is the
17
+ // only path. Cancel closes via `onOpenChange(false)`; Escape fires the native
18
+ // `close` event, which we forward to `onOpenChange(false)` to keep the parent
19
+ // state in sync. (The shadcn AlertDialog styling is the opt-in `--shadcn` path —
20
+ // Story 14.5.)
21
+ import { useEffect, useRef, useState } from "react";
22
+
23
+ type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_on: string | null; published_at: string | null; author_id: number | null };
24
+
25
+ // FR100 — opt-in call-site contract. INERT for this component: it is rendered
26
+ // only from PostList.tsx (JSX), never from an ERB `<Component>` tag,
27
+ // so the 13.5 preprocess validator never runs against it. Retained for uniformity
28
+ // with the List/Form.
29
+ export const __ruactContract = {
30
+ props: { post: "required" },
31
+ };
32
+
33
+ export function PostDeleteDialog({
34
+ open,
35
+ onOpenChange,
36
+ post,
37
+ onConfirm,
38
+ }: {
39
+ open: boolean;
40
+ onOpenChange: (open: boolean) => void;
41
+ post: PostRow;
42
+ onConfirm: () => Promise<{ ok: boolean; error?: string }>;
43
+ }) {
44
+ const dialogRef = useRef<HTMLDialogElement | null>(null);
45
+ const [submitting, setSubmitting] = useState(false);
46
+ const [error, setError] = useState<string | null>(null);
47
+
48
+ // Drive the native <dialog> modal state off the controlled `open` prop.
49
+ useEffect(() => {
50
+ const node = dialogRef.current;
51
+ if (!node) return;
52
+ if (open && !node.open) node.showModal();
53
+ if (!open && node.open) node.close();
54
+ }, [open]);
55
+
56
+ async function handleConfirm(event: { preventDefault: () => void }) {
57
+ // The PARENT closes the dialog (via onOpenChange) only after a successful
58
+ // delete; a failure leaves it open with the message.
59
+ event.preventDefault();
60
+ setError(null);
61
+ setSubmitting(true);
62
+ const res = await onConfirm();
63
+ setSubmitting(false);
64
+ if (!res.ok) {
65
+ setError(res.error ?? "Could not delete this post");
66
+ }
67
+ }
68
+
69
+ return (
70
+ <dialog ref={dialogRef} onClose={() => onOpenChange(false)}>
71
+ <h2>Delete “{String(post.title ?? "")}”?</h2>
72
+ <p>This action cannot be undone.</p>
73
+ {error && <p>{error}</p>}
74
+ <p>
75
+ <button type="button" onClick={() => onOpenChange(false)} disabled={submitting}>
76
+ Cancel
77
+ </button>
78
+ {" "}
79
+ <button type="button" onClick={handleConfirm} disabled={submitting}>
80
+ {submitting ? "Deleting…" : "Delete"}
81
+ </button>
82
+ </p>
83
+ </dialog>
84
+ );
85
+ }
@@ -0,0 +1,216 @@
1
+ "use client";
2
+
3
+ // Post form — shared by `new` (initial == null) and `edit` (initial
4
+ // == the serialized record). DESIGN-SYSTEM-AGNOSTIC (Story 14.4 / FR103): every
5
+ // field renders a plain, native HTML control mapped to its attribute type
6
+ // (input / textarea / checkbox / select), labelled, with the FR98 server error
7
+ // surfaced inline beneath it — NO shadcn/ui, NO Tailwind, browser-default styling.
8
+ // Submits through the v2 action accessors: createPost (POST /posts) or
9
+ // updatePost (PATCH /posts/:id). On success the controller drives navigation
10
+ // SERVER-SIDE via `redirect_to` (the `$redirect` the runtime follows) — there is
11
+ // no client URL building here. On a validation failure the SAME response carries
12
+ // the FR98 attribute-keyed `errors` map, surfaced inline per field (the client
13
+ // stays on the form). (The richer shadcn controls are the opt-in `--shadcn` path
14
+ // — Story 14.5.)
15
+ //
16
+ // Client-side validation is intentionally NOT here — it is server-only with a
17
+ // full round-trip. The controlled `useState` keeps this dependency-free.
18
+ import { useState, type FormEvent } from "react";
19
+ import { createPost, updatePost } from "@/.ruact/server-functions";
20
+
21
+ type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_on: string | null; published_at: string | null; author_id: number | null };
22
+
23
+ // FR100 — opt-in call-site contract: `initial` is OPTIONAL (`new` omits it,
24
+ // `edit` passes the serialized record); each `references` field adds an
25
+ // optional `<name>Options` prop the new/edit views pass. A typo'd prop name
26
+ // fails at preprocess time instead of silently arriving as `undefined`.
27
+ export const __ruactContract = {
28
+ props: { initial: "optional", authorOptions: "optional" },
29
+ };
30
+
31
+ export function PostForm({ initial = null, authorOptions = [] }: { initial?: PostRow | null; authorOptions?: { id: number; label: string }[] }) {
32
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
33
+ // the row as numbers, so coerce here). The controller re-coerces.
34
+ const [title, setTitle] = useState(String(initial?.title ?? ""));
35
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
36
+ // the row as numbers, so coerce here). The controller re-coerces.
37
+ const [body, setBody] = useState(String(initial?.body ?? ""));
38
+ const [published, setPublished] = useState(Boolean(initial?.published ?? false));
39
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
40
+ // the row as numbers, so coerce here). The controller re-coerces.
41
+ const [views, setViews] = useState(String(initial?.views ?? ""));
42
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
43
+ // the row as numbers, so coerce here). The controller re-coerces.
44
+ const [publishedOn, setPublishedOn] = useState(String(initial?.published_on ?? ""));
45
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
46
+ // the row as numbers, so coerce here). The controller re-coerces.
47
+ const [publishedAt, setPublishedAt] = useState(String(initial?.published_at ?? ""));
48
+ // String-valued state: native controls bind `string` (numbers + refs arrive on
49
+ // the row as numbers, so coerce here). The controller re-coerces.
50
+ const [authorId, setAuthorId] = useState(String(initial?.author_id ?? ""));
51
+ // FR98 — keyed { [attr]: string[] }; `{}` means "no errors" (always present).
52
+ const [errors, setErrors] = useState<Record<string, string[]>>({});
53
+ const [saving, setSaving] = useState(false);
54
+
55
+ async function handleSubmit(event: FormEvent) {
56
+ event.preventDefault();
57
+ setSaving(true);
58
+ setErrors({});
59
+
60
+ const payload = {
61
+ title: title,
62
+ body: body,
63
+ published: published,
64
+ views: views,
65
+ published_on: publishedOn,
66
+ published_at: publishedAt,
67
+ author_id: authorId,
68
+ };
69
+
70
+ // On success the server returns a `$redirect` and the runtime navigates
71
+ // away (the code below does not run); on a validation failure we get the
72
+ // keyed `errors` map back instead.
73
+ const result = (initial != null
74
+ ? await updatePost({ id: initial.id, ...payload })
75
+ : await createPost(payload)) as { errors?: Record<string, string[]> } | null;
76
+
77
+ setSaving(false);
78
+ if (result && result.errors) {
79
+ setErrors(result.errors);
80
+ }
81
+ }
82
+
83
+ const errorsFor = (attr: string) => errors[attr] ?? [];
84
+
85
+ return (
86
+ <form onSubmit={handleSubmit}>
87
+ <h1>{initial != null ? `Edit Post #${initial.id}` : "New Post"}</h1>
88
+
89
+ {errorsFor("base").length > 0 && (
90
+ <ul>
91
+ {errorsFor("base").map((message, i) => (
92
+ <li key={i}>{message}</li>
93
+ ))}
94
+ </ul>
95
+ )}
96
+
97
+ <p>
98
+ <label htmlFor="title">Title</label>
99
+ <input
100
+ id="title"
101
+ type="text"
102
+ value={title}
103
+ onChange={(e) => setTitle(e.target.value)}
104
+ disabled={saving}
105
+ />
106
+ {errorsFor("title").map((message, i) => (
107
+ <span key={i}>{message}</span>
108
+ ))}
109
+ </p>
110
+
111
+ <p>
112
+ <label htmlFor="body">Body</label>
113
+ <textarea
114
+ id="body"
115
+ value={body}
116
+ onChange={(e) => setBody(e.target.value)}
117
+ disabled={saving}
118
+ />
119
+ {errorsFor("body").map((message, i) => (
120
+ <span key={i}>{message}</span>
121
+ ))}
122
+ </p>
123
+
124
+ <p>
125
+ <label htmlFor="published">
126
+ <input
127
+ id="published"
128
+ type="checkbox"
129
+ checked={published}
130
+ onChange={(e) => setPublished(e.target.checked)}
131
+ disabled={saving}
132
+ />
133
+ {" "}Published
134
+ </label>
135
+ {errorsFor("published").map((message, i) => (
136
+ <span key={i}>{message}</span>
137
+ ))}
138
+ </p>
139
+
140
+ <p>
141
+ <label htmlFor="views">Views</label>
142
+ <input
143
+ id="views"
144
+ type="number"
145
+ value={views}
146
+ onChange={(e) => setViews(e.target.value)}
147
+ disabled={saving}
148
+ />
149
+ {errorsFor("views").map((message, i) => (
150
+ <span key={i}>{message}</span>
151
+ ))}
152
+ </p>
153
+
154
+ <p>
155
+ <label htmlFor="published_on">Published on</label>
156
+ <input
157
+ id="published_on"
158
+ type="date"
159
+ value={publishedOn}
160
+ onChange={(e) => setPublishedOn(e.target.value)}
161
+ disabled={saving}
162
+ />
163
+ {errorsFor("published_on").map((message, i) => (
164
+ <span key={i}>{message}</span>
165
+ ))}
166
+ </p>
167
+
168
+ <p>
169
+ <label htmlFor="published_at">Published at</label>
170
+ <input
171
+ id="published_at"
172
+ type="datetime-local"
173
+ value={publishedAt}
174
+ onChange={(e) => setPublishedAt(e.target.value)}
175
+ disabled={saving}
176
+ />
177
+ {errorsFor("published_at").map((message, i) => (
178
+ <span key={i}>{message}</span>
179
+ ))}
180
+ </p>
181
+
182
+ <p>
183
+ <label htmlFor="author_id">Author</label>
184
+ {/* Eager native <select> of controller-provided options (ivar → `authorOptions`
185
+ prop). For a parent set larger than the generator's REFERENCE_OPTIONS_LIMIT,
186
+ swap this for a server-search combobox backed by a parent-options read
187
+ query (opt-in follow-up — that query is out of this scaffold's scope
188
+ since the parent model isn't scaffolded here). */}
189
+ <select
190
+ id="author_id"
191
+ value={authorId}
192
+ onChange={(e) => setAuthorId(e.target.value)}
193
+ disabled={saving}
194
+ >
195
+ <option value="">Select author</option>
196
+ {authorOptions.map((option) => (
197
+ <option key={option.id} value={String(option.id)}>
198
+ {option.label}
199
+ </option>
200
+ ))}
201
+ </select>
202
+ {errorsFor("author_id").map((message, i) => (
203
+ <span key={i}>{message}</span>
204
+ ))}
205
+ </p>
206
+
207
+ <p>
208
+ <button type="submit" disabled={saving}>
209
+ {saving ? "Saving…" : initial != null ? "Update post" : "Create post"}
210
+ </button>
211
+ {" "}
212
+ <a href={initial != null ? `/posts/${initial.id}` : "/posts"}>Cancel</a>
213
+ </p>
214
+ </form>
215
+ );
216
+ }
@@ -0,0 +1,269 @@
1
+ "use client";
2
+
3
+ // Post list — a DESIGN-SYSTEM-AGNOSTIC table (Story 14.4 / FR103). The
4
+ // default scaffold ships plain, native HTML elements styled by the browser /
5
+ // Rails-default CSS — NO shadcn/ui, NO Tailwind, NO table-engine dependency. The
6
+ // collection arrives as a SERVER-RENDERED prop (`posts`); there is no client query
7
+ // for the initial render. A query only enters when the *client* drives the read:
8
+ // the search box calls useQuery(searchPosts, { q }) and swaps in filtered rows as
9
+ // you type. Per-row delete drives a controlled PostDeleteDialog (DELETE
10
+ // /posts/:id via the destroyPost action); on `{ ok: true }` the row is
11
+ // removed from the displayed rows IN PLACE (no reload, no URL built). Column
12
+ // sorting is CLIENT-ONLY — the dataset is the index payload; server-side
13
+ // sort/pagination is Phase-3 territory. (The richer shadcn DataTable styling is
14
+ // the opt-in `--shadcn` path — Story 14.5.)
15
+ import { useState } from "react";
16
+ import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
17
+ import { PostDeleteDialog } from "./PostDeleteDialog";
18
+
19
+ type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_on: string | null; published_at: string | null; author_id: number | null };
20
+
21
+ // FR100 — opt-in call-site contract: `posts` is required. The index view passes
22
+ // `<PostList posts={rows} />` (satisfied); a call site that omits it
23
+ // fails at preprocess time, not as a silent `undefined` in the browser.
24
+ export const __ruactContract = {
25
+ props: { posts: "required" },
26
+ };
27
+
28
+ // Columns whose values are date/datetime strings: the sort compares these by
29
+ // epoch time (`new Date(value).getTime()`) rather than lexically, so they order
30
+ // chronologically. Generated from the model's date/datetime attributes.
31
+ const DATE_KEYS = new Set<string>(["published_on", "published_at"]);
32
+
33
+ // Generated client-side comparator (no table engine). Sorts a COPY of the rows
34
+ // by the active key + direction: `null`/`undefined` always sort LAST (so a blank
35
+ // cell never jumps to the top, regardless of direction); date columns compare by
36
+ // time, numbers numerically, booleans false-before-true, everything else via a
37
+ // locale-aware string compare. The direction flip is applied AFTER the null
38
+ // handling, never to it.
39
+ function compareRows(
40
+ a: PostRow,
41
+ b: PostRow,
42
+ sort: { key: string; dir: "asc" | "desc" },
43
+ ): number {
44
+ const av = a[sort.key as keyof PostRow];
45
+ const bv = b[sort.key as keyof PostRow];
46
+ if (av == null && bv == null) return 0;
47
+ if (av == null) return 1;
48
+ if (bv == null) return -1;
49
+
50
+ let result: number;
51
+ if (DATE_KEYS.has(sort.key)) {
52
+ result = new Date(String(av)).getTime() - new Date(String(bv)).getTime();
53
+ } else if (typeof av === "number" && typeof bv === "number") {
54
+ result = av - bv;
55
+ } else if (typeof av === "boolean" && typeof bv === "boolean") {
56
+ result = Number(av) - Number(bv);
57
+ } else {
58
+ result = String(av).localeCompare(String(bv));
59
+ }
60
+ return sort.dir === "desc" ? -result : result;
61
+ }
62
+
63
+ // Pull a human-readable message out of the failed-delete result. A thrown
64
+ // RuactActionError (non-2xx) carries the parsed structured-error body, whose
65
+ // `message` the gem shapes per env (the exception message in development, the
66
+ // generic server message in production); a resolved soft-failure may carry a
67
+ // top-level `error`/`message`. Returns undefined when none is present, so the
68
+ // dialog can fall back to its own generic copy.
69
+ function deleteErrorMessage(error: unknown): string | undefined {
70
+ if (error != null && typeof error === "object") {
71
+ const carrier = error as { body?: unknown; error?: unknown; message?: unknown };
72
+ if (carrier.body != null && typeof carrier.body === "object") {
73
+ const message = (carrier.body as { message?: unknown }).message;
74
+ if (typeof message === "string" && message.length > 0) return message;
75
+ }
76
+ if (typeof carrier.error === "string" && carrier.error.length > 0) return carrier.error;
77
+ if (typeof carrier.message === "string" && carrier.message.length > 0) return carrier.message;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ // A resolved delete is a SUCCESS only on the server-owned success shapes: the
83
+ // in-list default `{ ok: true }`, or the delete-from-show `$redirect` the
84
+ // accessor already followed (which resolves to `null`). Any other resolved shape
85
+ // (e.g. an explicit `{ ok: false }`) is a non-success the dialog surfaces.
86
+ function deleteSucceeded(result: unknown): boolean {
87
+ if (result === null) return true;
88
+ return (
89
+ typeof result === "object" &&
90
+ (result as { ok?: unknown }).ok === true
91
+ );
92
+ }
93
+
94
+ // Per-row actions (AC2) — an Edit link + a Delete TRIGGER that opens the
95
+ // controlled dialog. Each row owns its OWN dialog `open` state (one independent
96
+ // useState), so the dialog opens for exactly one row. `onConfirm` calls
97
+ // destroyPost and, on a server-owned success, asks the List to drop the row
98
+ // in place via `onDeleted`. The agnostic default keeps a single inline actions
99
+ // cell (the responsive overflow menu is a shadcn refinement deferred to the
100
+ // `--shadcn` path — Story 14.5).
101
+ function RowActions({ record, onDeleted }: {
102
+ record: PostRow;
103
+ onDeleted: (id: number) => void;
104
+ }) {
105
+ const [open, setOpen] = useState(false);
106
+
107
+ async function onConfirm(): Promise<{ ok: boolean; error?: string }> {
108
+ try {
109
+ const result = await destroyPost({ id: record.id });
110
+ if (deleteSucceeded(result)) {
111
+ onDeleted(record.id);
112
+ setOpen(false);
113
+ return { ok: true };
114
+ }
115
+ // Resolved but not a success shape → keep the dialog open with the message.
116
+ return { ok: false, error: deleteErrorMessage(result) };
117
+ } catch (error) {
118
+ // Failure (non-2xx structured error) → keep the dialog open, message inline.
119
+ return { ok: false, error: deleteErrorMessage(error) };
120
+ }
121
+ }
122
+
123
+ return (
124
+ <span>
125
+ <a href={`/posts/${record.id}/edit`}>Edit</a>
126
+ {" "}
127
+ <button type="button" onClick={() => setOpen(true)}>
128
+ Delete
129
+ </button>
130
+
131
+ <PostDeleteDialog
132
+ open={open}
133
+ onOpenChange={setOpen}
134
+ post={record}
135
+ onConfirm={onConfirm}
136
+ />
137
+ </span>
138
+ );
139
+ }
140
+
141
+ export function PostList({
142
+ posts = [],
143
+ emptyLabel = "No posts yet — create one.",
144
+ }: { posts?: PostRow[]; emptyLabel?: string }) {
145
+ const [q, setQ] = useState("");
146
+ const searching = q.trim().length > 0;
147
+
148
+ // Ids deleted IN PLACE — a single tombstone list (not a second row cache)
149
+ // applied to whichever source is displayed, so a delete removes the row
150
+ // immediately whether it happened on the server-rendered list OR on the live
151
+ // search results.
152
+ const [removedIds, setRemovedIds] = useState<number[]>([]);
153
+
154
+ // Sort state (AC2) — the active column key + direction, or null when unsorted
155
+ // (server/insertion order). A new key sorts ascending; clicking the same key
156
+ // again flips asc↔desc.
157
+ const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
158
+
159
+ // Client-driven read (AC5) — only meaningful while searching. When q is blank
160
+ // the box is idle and we fall back to the server-rendered rows.
161
+ const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
162
+
163
+ const source = searching ? searchData ?? [] : posts;
164
+ const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
165
+ // Always sort a COPY — never mutate the prop/source array.
166
+ const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
167
+
168
+ const onDeleted = (id: number) =>
169
+ setRemovedIds((current) => (current.includes(id) ? current : [...current, id]));
170
+
171
+ // Set the sort to a new key (ascending), or flip the direction when the active
172
+ // key is clicked again.
173
+ function toggleSort(key: string) {
174
+ setSort((current) =>
175
+ current && current.key === key
176
+ ? { key, dir: current.dir === "asc" ? "desc" : "asc" }
177
+ : { key, dir: "asc" },
178
+ );
179
+ }
180
+
181
+ return (
182
+ <section>
183
+ <h1>Posts</h1>
184
+ <p>
185
+ <a href="/posts/new">+ New post</a>
186
+ </p>
187
+
188
+ <input
189
+ type="search"
190
+ value={q}
191
+ onChange={(e) => setQ(e.target.value)}
192
+ placeholder="Search posts…"
193
+ />
194
+
195
+ {searching && searchLoading && <p>Searching…</p>}
196
+ {!searching && rows.length === 0 && <p>{emptyLabel}</p>}
197
+ {searching && !searchLoading && rows.length === 0 && (
198
+ <p>No posts match “{q.trim()}”.</p>
199
+ )}
200
+
201
+ {sortedRows.length > 0 && (
202
+ <table>
203
+ <thead>
204
+ <tr>
205
+ <th>
206
+ <button type="button" onClick={() => toggleSort("id")}>ID</button>
207
+ </th>
208
+ <th>
209
+ <button type="button" onClick={() => toggleSort("title")}>
210
+ Title
211
+ </button>
212
+ </th>
213
+ <th>
214
+ <button type="button" onClick={() => toggleSort("body")}>
215
+ Body
216
+ </button>
217
+ </th>
218
+ <th>
219
+ <button type="button" onClick={() => toggleSort("published")}>
220
+ Published
221
+ </button>
222
+ </th>
223
+ <th>
224
+ <button type="button" onClick={() => toggleSort("views")}>
225
+ Views
226
+ </button>
227
+ </th>
228
+ <th>
229
+ <button type="button" onClick={() => toggleSort("published_on")}>
230
+ Published on
231
+ </button>
232
+ </th>
233
+ <th>
234
+ <button type="button" onClick={() => toggleSort("published_at")}>
235
+ Published at
236
+ </button>
237
+ </th>
238
+ <th>
239
+ <button type="button" onClick={() => toggleSort("author_id")}>
240
+ Author
241
+ </button>
242
+ </th>
243
+ <th>Actions</th>
244
+ </tr>
245
+ </thead>
246
+ <tbody>
247
+ {sortedRows.map((row) => (
248
+ <tr key={row.id}>
249
+ <td>
250
+ <a href={`/posts/${row.id}`}>{row.id}</a>
251
+ </td>
252
+ <td>{String(row.title ?? "")}</td>
253
+ <td>{String(row.body ?? "")}</td>
254
+ <td>{row.published ? "Yes" : "No"}</td>
255
+ <td>{String(row.views ?? "")}</td>
256
+ <td>{row.published_on ? new Date(String(row.published_on)).toLocaleString() : ""}</td>
257
+ <td>{row.published_at ? new Date(String(row.published_at)).toLocaleString() : ""}</td>
258
+ <td>{String(row.author_id ?? "")}</td>
259
+ <td>
260
+ <RowActions record={row} onDeleted={onDeleted} />
261
+ </td>
262
+ </tr>
263
+ ))}
264
+ </tbody>
265
+ </table>
266
+ )}
267
+ </section>
268
+ );
269
+ }
@@ -0,0 +1,78 @@
1
+ // Story 14.4 (AC5) — AMBIENT stubs so the GENERATED design-system-AGNOSTIC
2
+ // `<Model>List.tsx` / `<Model>Form.tsx` / `<Model>DeleteDialog.tsx` type-check in
3
+ // ISOLATION. The fixtures in this directory are byte-identical to the generator's
4
+ // live agnostic output (a gem rspec example asserts that equality), so
5
+ // type-checking them under `tsconfig.scaffold-agnostic.json` proves the emitted
6
+ // typed components hold the FR99 server boundary with NO design-system dependency.
7
+ //
8
+ // The POINT of this stub is the opposite of the shadcn one (`../ambient.d.ts`):
9
+ // it declares ONLY React + `@/.ruact/server-functions` and deliberately NO
10
+ // `@/components/ui/*` module. So a stray shadcn import in an agnostic template
11
+ // would fail to resolve here and turn the `npm run typecheck` job RED — that
12
+ // unresolved-module failure is the AC5 proof that the agnostic default needs
13
+ // zero `@/components/ui` resolution.
14
+
15
+ // JSX without `lib: ["dom"]` / `@types/react`: a permissive intrinsic-element
16
+ // table (every native prop is `any`, so `onChange={(e) => …}` etc. need no DOM
17
+ // event types). `jsx: "preserve"` in the tsconfig still type-checks every JSX
18
+ // expression against this namespace.
19
+ declare namespace JSX {
20
+ interface Element {} // eslint-disable-line @typescript-eslint/no-empty-interface
21
+ interface ElementClass {} // eslint-disable-line @typescript-eslint/no-empty-interface
22
+ interface IntrinsicElements {
23
+ [elem: string]: any;
24
+ }
25
+ }
26
+
27
+ // The generated DeleteDialog types its <dialog> ref as `HTMLDialogElement` — the
28
+ // SAME type a real app's `lib: ["dom"]` gives `<dialog ref={…}>` (so the emitted
29
+ // .tsx type-checks end-to-end in the user's typed app, FR99). This isolated
30
+ // harness has no `lib: dom`, so we declare the minimal structural members the
31
+ // generated code touches (`open` / `showModal` / `close`). In a real app this
32
+ // merges with lib.dom's full HTMLDialogElement.
33
+ interface HTMLDialogElement {
34
+ open: boolean;
35
+ showModal(): void;
36
+ close(): void;
37
+ }
38
+
39
+ declare module "react" {
40
+ export function useState<S>(initial: S | (() => S)): [S, (next: S | ((prev: S) => S)) => void];
41
+ // The generated DeleteDialog drives the native <dialog> off a ref + an effect.
42
+ export function useRef<T>(initial: T): { current: T };
43
+ export function useEffect(effect: () => void | (() => void), deps?: ReadonlyArray<unknown>): void;
44
+ // The generated Form's `handleSubmit(event: FormEvent)` only calls
45
+ // `event.preventDefault()`; a minimal shim keeps the type load-bearing without
46
+ // pulling in `@types/react`.
47
+ export interface FormEvent {
48
+ preventDefault(): void;
49
+ }
50
+ }
51
+
52
+ declare module "@/.ruact/server-functions" {
53
+ // FR88/FR99 wire union — the only param value type a query accessor accepts.
54
+ type Wire = string | number | boolean | null;
55
+
56
+ // The codegen exports a generic `search` from `<Plural>Query#search(q:)`,
57
+ // typed from the declared `(q:)` kwarg (Story 13.4). The List aliases it
58
+ // `search<Plural>`.
59
+ export const search: (params: { q: Wire }) => Promise<unknown>;
60
+
61
+ // Action accessors the generated Form imports. Actions are NOT typed by FR99
62
+ // (only queries are — Story 13.4 decision A), so the accessors take a loose
63
+ // payload and return `Promise<unknown>`; the Form narrows the result with an
64
+ // `as { errors?: ... } | null` assertion at the call site.
65
+ export const createPost: (payload: Record<string, unknown>) => Promise<unknown>;
66
+ export const updatePost: (payload: Record<string, unknown>) => Promise<unknown>;
67
+ // The destroy accessor the List's RowActions calls in `onConfirm` (DELETE
68
+ // /posts/:id). Like the other actions it is not FR99-typed; it returns
69
+ // `Promise<unknown>` and throws a `RuactActionError` on a non-2xx response.
70
+ export const destroyPost: (payload: Record<string, unknown>) => Promise<unknown>;
71
+
72
+ // Mirrors the runtime declaration (`ruact-server-functions-runtime/index.d.ts`)
73
+ // — `reference` is the widened accessor shape (Story 13.4), `T` the return type.
74
+ export function useQuery<T = unknown>(
75
+ reference: (...args: never[]) => Promise<unknown>,
76
+ params?: Record<string, unknown>,
77
+ ): { data: T | undefined; loading: boolean; error: unknown };
78
+ }