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,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
|
+
}
|