ruact 0.0.5 → 0.0.7
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 +74 -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 +79 -425
- data/lib/ruact/doctor.rb +133 -4
- data/lib/ruact/erb_preprocessor.rb +71 -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/manifest_resolver.rb +149 -0
- data/lib/ruact/railtie.rb +70 -203
- data/lib/ruact/render_pipeline.rb +8 -1
- 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 +109 -0
- data/lib/ruact.rb +20 -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 +70 -328
- 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/manifest_resolver_spec.rb +159 -0
- 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 +15 -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 +372 -6
- data/vendor/javascript/vite-plugin-ruact/manifest-contract.test.mjs +182 -0
- data/vendor/javascript/vite-plugin-ruact/manifest-http.test.mjs +125 -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 +58 -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,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Post delete confirmation — a CONTROLLED shadcn AlertDialog. The
|
|
4
|
+
// PARENT owns `open` and provides `onConfirm`; this component owns only its local
|
|
5
|
+
// submit/error state. `onConfirm` performs the destroyPost call (DELETE
|
|
6
|
+
// /posts/:id) and reports `{ ok, error? }`:
|
|
7
|
+
//
|
|
8
|
+
// - on success the parent closes the dialog (in-list removal of the row, or the
|
|
9
|
+
// server-driven `$redirect` the runtime follows) — no URL is built here;
|
|
10
|
+
// - on failure the dialog STAYS OPEN and shows the message inline (the gem's
|
|
11
|
+
// structured-error shape: the exception message in development, the generic
|
|
12
|
+
// server message in production).
|
|
13
|
+
//
|
|
14
|
+
// There is no Rails `method=delete` fallback — the destroyPost action
|
|
15
|
+
// is the only path. Cancel / Escape / outside-click close via `onOpenChange(false)`
|
|
16
|
+
// without deleting; focus returns to the row's Delete trigger (Radix default —
|
|
17
|
+
// the trigger is never unmounted while the dialog animates).
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import {
|
|
20
|
+
AlertDialog,
|
|
21
|
+
AlertDialogAction,
|
|
22
|
+
AlertDialogCancel,
|
|
23
|
+
AlertDialogContent,
|
|
24
|
+
AlertDialogDescription,
|
|
25
|
+
AlertDialogFooter,
|
|
26
|
+
AlertDialogHeader,
|
|
27
|
+
AlertDialogTitle,
|
|
28
|
+
} from "@/components/ui/alert-dialog";
|
|
29
|
+
|
|
30
|
+
type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_at: string | null; author_id: number | null };
|
|
31
|
+
|
|
32
|
+
// FR100 — opt-in call-site contract. INERT for this component: it is rendered
|
|
33
|
+
// only from PostList.tsx (JSX), never from an ERB `<Component>` tag,
|
|
34
|
+
// so the 13.5 preprocess validator never runs against it. Retained for uniformity
|
|
35
|
+
// with the List/Form.
|
|
36
|
+
export const __ruactContract = {
|
|
37
|
+
props: { post: "required" },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function PostDeleteDialog({
|
|
41
|
+
open,
|
|
42
|
+
onOpenChange,
|
|
43
|
+
post,
|
|
44
|
+
onConfirm,
|
|
45
|
+
}: {
|
|
46
|
+
open: boolean;
|
|
47
|
+
onOpenChange: (open: boolean) => void;
|
|
48
|
+
post: PostRow;
|
|
49
|
+
onConfirm: () => Promise<{ ok: boolean; error?: string }>;
|
|
50
|
+
}) {
|
|
51
|
+
const [submitting, setSubmitting] = useState(false);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
async function handleConfirm(event: { preventDefault: () => void }) {
|
|
55
|
+
// Keep the dialog open across the async destroy — Radix's AlertDialogAction
|
|
56
|
+
// would otherwise close it on click. The PARENT closes it (via onOpenChange)
|
|
57
|
+
// only after a successful delete; a failure leaves it open with the message.
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
setError(null);
|
|
60
|
+
setSubmitting(true);
|
|
61
|
+
const res = await onConfirm();
|
|
62
|
+
setSubmitting(false);
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
setError(res.error ?? "Could not delete this post");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
70
|
+
<AlertDialogContent>
|
|
71
|
+
<AlertDialogHeader>
|
|
72
|
+
<AlertDialogTitle>Delete “{String(post.title ?? "")}”?</AlertDialogTitle>
|
|
73
|
+
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
|
|
74
|
+
</AlertDialogHeader>
|
|
75
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
76
|
+
<AlertDialogFooter>
|
|
77
|
+
{/* Cancel gets default focus (Radix) — the safe choice is the default. */}
|
|
78
|
+
<AlertDialogCancel disabled={submitting}>Cancel</AlertDialogCancel>
|
|
79
|
+
<AlertDialogAction
|
|
80
|
+
onClick={handleConfirm}
|
|
81
|
+
disabled={submitting}
|
|
82
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
83
|
+
>
|
|
84
|
+
{submitting ? "Deleting…" : "Delete"}
|
|
85
|
+
</AlertDialogAction>
|
|
86
|
+
</AlertDialogFooter>
|
|
87
|
+
</AlertDialogContent>
|
|
88
|
+
</AlertDialog>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Post form — shared by `new` (initial == null) and `edit` (initial
|
|
4
|
+
// == the serialized record). Each field renders the shadcn control mapped to its
|
|
5
|
+
// attribute type (Input / Textarea / Switch / Select), labelled, with the FR98
|
|
6
|
+
// server error surfaced inline beneath it. Submits through the v2 action
|
|
7
|
+
// accessors: createPost (POST /posts) or updatePost (PATCH /posts/:id). On
|
|
8
|
+
// success the controller drives navigation SERVER-SIDE via `redirect_to`
|
|
9
|
+
// (the `$redirect` the runtime follows) — there is no client URL building here.
|
|
10
|
+
// On a validation failure the SAME response carries the FR98 attribute-keyed
|
|
11
|
+
// `errors` map, surfaced inline per field (the client stays on the form).
|
|
12
|
+
//
|
|
13
|
+
// The `@/components/ui/*` primitives below are installed by Story 10.5; a freshly
|
|
14
|
+
// scaffolded app will not resolve them until then (the live demo is Story 10.7).
|
|
15
|
+
//
|
|
16
|
+
// Client-side validation is intentionally NOT here — it is server-only with a
|
|
17
|
+
// full round-trip (Story 10.6 adds the model-validation bridge). The controlled
|
|
18
|
+
// `useState` keeps this dependency-free; to adopt rich client validation later,
|
|
19
|
+
// swap the controlled state for react-hook-form's `useForm` + shadcn `<Form>` and
|
|
20
|
+
// feed these same FR98 `errors` into `setError` per key (opt-in — not the default).
|
|
21
|
+
import { useState, type FormEvent } from "react";
|
|
22
|
+
import { createPost, updatePost } from "@/.ruact/server-functions";
|
|
23
|
+
import { Button } from "@/components/ui/button";
|
|
24
|
+
import { Label } from "@/components/ui/label";
|
|
25
|
+
import { Input } from "@/components/ui/input";
|
|
26
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
27
|
+
import { Switch } from "@/components/ui/switch";
|
|
28
|
+
import {
|
|
29
|
+
Select,
|
|
30
|
+
SelectContent,
|
|
31
|
+
SelectItem,
|
|
32
|
+
SelectTrigger,
|
|
33
|
+
SelectValue,
|
|
34
|
+
} from "@/components/ui/select";
|
|
35
|
+
|
|
36
|
+
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 };
|
|
37
|
+
|
|
38
|
+
// FR100 — opt-in call-site contract: `initial` is OPTIONAL (`new` omits it,
|
|
39
|
+
// `edit` passes the serialized record); each `references` field adds an
|
|
40
|
+
// optional `<name>Options` prop the new/edit views pass. A typo'd prop name
|
|
41
|
+
// fails at preprocess time instead of silently arriving as `undefined`.
|
|
42
|
+
export const __ruactContract = {
|
|
43
|
+
props: { initial: "optional", authorOptions: "optional" },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function PostForm({ initial = null, authorOptions = [] }: { initial?: PostRow | null; authorOptions?: { id: number; label: string }[] }) {
|
|
47
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
48
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
49
|
+
const [title, setTitle] = useState(String(initial?.title ?? ""));
|
|
50
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
51
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
52
|
+
const [body, setBody] = useState(String(initial?.body ?? ""));
|
|
53
|
+
const [published, setPublished] = useState(Boolean(initial?.published ?? false));
|
|
54
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
55
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
56
|
+
const [views, setViews] = useState(String(initial?.views ?? ""));
|
|
57
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
58
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
59
|
+
const [publishedOn, setPublishedOn] = useState(String(initial?.published_on ?? ""));
|
|
60
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
61
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
62
|
+
const [publishedAt, setPublishedAt] = useState(String(initial?.published_at ?? ""));
|
|
63
|
+
// String-valued state: native/shadcn controls bind `string` (numbers + refs
|
|
64
|
+
// arrive on the row as numbers, so coerce here). The controller re-coerces.
|
|
65
|
+
const [authorId, setAuthorId] = useState(String(initial?.author_id ?? ""));
|
|
66
|
+
// FR98 — keyed { [attr]: string[] }; `{}` means "no errors" (always present).
|
|
67
|
+
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
|
68
|
+
const [saving, setSaving] = useState(false);
|
|
69
|
+
|
|
70
|
+
async function handleSubmit(event: FormEvent) {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
setSaving(true);
|
|
73
|
+
setErrors({});
|
|
74
|
+
|
|
75
|
+
const payload = {
|
|
76
|
+
title: title,
|
|
77
|
+
body: body,
|
|
78
|
+
published: published,
|
|
79
|
+
views: views,
|
|
80
|
+
published_on: publishedOn,
|
|
81
|
+
published_at: publishedAt,
|
|
82
|
+
author_id: authorId,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// On success the server returns a `$redirect` and the runtime navigates
|
|
86
|
+
// away (the code below does not run); on a validation failure we get the
|
|
87
|
+
// keyed `errors` map back instead.
|
|
88
|
+
const result = (initial != null
|
|
89
|
+
? await updatePost({ id: initial.id, ...payload })
|
|
90
|
+
: await createPost(payload)) as { errors?: Record<string, string[]> } | null;
|
|
91
|
+
|
|
92
|
+
setSaving(false);
|
|
93
|
+
if (result && result.errors) {
|
|
94
|
+
setErrors(result.errors);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const errorsFor = (attr: string) => errors[attr] ?? [];
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<form onSubmit={handleSubmit} className="mx-auto max-w-xl space-y-6 px-4 py-8">
|
|
102
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
103
|
+
{initial != null ? `Edit Post #${initial.id}` : "New Post"}
|
|
104
|
+
</h1>
|
|
105
|
+
|
|
106
|
+
{errorsFor("base").length > 0 && (
|
|
107
|
+
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
108
|
+
<ul className="list-disc space-y-1 pl-4">
|
|
109
|
+
{errorsFor("base").map((message, i) => (
|
|
110
|
+
<li key={i}>{message}</li>
|
|
111
|
+
))}
|
|
112
|
+
</ul>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="space-y-2">
|
|
117
|
+
<Label htmlFor="title">Title</Label>
|
|
118
|
+
<Input
|
|
119
|
+
id="title"
|
|
120
|
+
type="text"
|
|
121
|
+
value={title}
|
|
122
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
123
|
+
disabled={saving}
|
|
124
|
+
/>
|
|
125
|
+
{errorsFor("title").map((message, i) => (
|
|
126
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Label htmlFor="body">Body</Label>
|
|
132
|
+
<Textarea
|
|
133
|
+
id="body"
|
|
134
|
+
value={body}
|
|
135
|
+
onChange={(e) => setBody(e.target.value)}
|
|
136
|
+
disabled={saving}
|
|
137
|
+
/>
|
|
138
|
+
{errorsFor("body").map((message, i) => (
|
|
139
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className="space-y-2">
|
|
144
|
+
<div className="flex items-center gap-2">
|
|
145
|
+
<Switch
|
|
146
|
+
id="published"
|
|
147
|
+
checked={published}
|
|
148
|
+
onCheckedChange={setPublished}
|
|
149
|
+
disabled={saving}
|
|
150
|
+
/>
|
|
151
|
+
<Label htmlFor="published">Published</Label>
|
|
152
|
+
</div>
|
|
153
|
+
{errorsFor("published").map((message, i) => (
|
|
154
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="space-y-2">
|
|
159
|
+
<Label htmlFor="views">Views</Label>
|
|
160
|
+
<Input
|
|
161
|
+
id="views"
|
|
162
|
+
type="number"
|
|
163
|
+
value={views}
|
|
164
|
+
onChange={(e) => setViews(e.target.value)}
|
|
165
|
+
disabled={saving}
|
|
166
|
+
/>
|
|
167
|
+
{errorsFor("views").map((message, i) => (
|
|
168
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="space-y-2">
|
|
173
|
+
<Label htmlFor="published_on">Published on</Label>
|
|
174
|
+
<Input
|
|
175
|
+
id="published_on"
|
|
176
|
+
type="date"
|
|
177
|
+
value={publishedOn}
|
|
178
|
+
onChange={(e) => setPublishedOn(e.target.value)}
|
|
179
|
+
disabled={saving}
|
|
180
|
+
/>
|
|
181
|
+
{errorsFor("published_on").map((message, i) => (
|
|
182
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="space-y-2">
|
|
187
|
+
<Label htmlFor="published_at">Published at</Label>
|
|
188
|
+
<Input
|
|
189
|
+
id="published_at"
|
|
190
|
+
type="datetime-local"
|
|
191
|
+
value={publishedAt}
|
|
192
|
+
onChange={(e) => setPublishedAt(e.target.value)}
|
|
193
|
+
disabled={saving}
|
|
194
|
+
/>
|
|
195
|
+
{errorsFor("published_at").map((message, i) => (
|
|
196
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="space-y-2">
|
|
201
|
+
<Label htmlFor="author_id">Author</Label>
|
|
202
|
+
{/* Eager <Select> of controller-provided options (ivar → `authorOptions` prop),
|
|
203
|
+
capped at the generator's REFERENCE_OPTIONS_LIMIT. For a parent set
|
|
204
|
+
larger than that, swap this for a server-search combobox backed by a
|
|
205
|
+
parent-options read query (opt-in follow-up — that query is out of this
|
|
206
|
+
scaffold's scope since the parent model isn't scaffolded here). */}
|
|
207
|
+
<Select
|
|
208
|
+
value={authorId}
|
|
209
|
+
onValueChange={setAuthorId}
|
|
210
|
+
disabled={saving}
|
|
211
|
+
>
|
|
212
|
+
<SelectTrigger id="author_id">
|
|
213
|
+
<SelectValue placeholder="Select author" />
|
|
214
|
+
</SelectTrigger>
|
|
215
|
+
<SelectContent>
|
|
216
|
+
{authorOptions.map((option) => (
|
|
217
|
+
<SelectItem key={option.id} value={String(option.id)}>
|
|
218
|
+
{option.label}
|
|
219
|
+
</SelectItem>
|
|
220
|
+
))}
|
|
221
|
+
</SelectContent>
|
|
222
|
+
</Select>
|
|
223
|
+
{errorsFor("author_id").map((message, i) => (
|
|
224
|
+
<p key={i} className="text-sm text-destructive">{message}</p>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="flex items-center gap-3 pt-2">
|
|
229
|
+
<Button type="submit" disabled={saving}>
|
|
230
|
+
{saving ? "Saving…" : initial != null ? "Update post" : "Create post"}
|
|
231
|
+
</Button>
|
|
232
|
+
<Button variant="outline" asChild>
|
|
233
|
+
<a href={initial != null ? `/posts/${initial.id}` : "/posts"}>Cancel</a>
|
|
234
|
+
</Button>
|
|
235
|
+
</div>
|
|
236
|
+
</form>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Post list — a shadcn `table` primitive (a styled HTML table) plus a
|
|
4
|
+
// small GENERATED client-side sort. There is NO table-engine dependency: the
|
|
5
|
+
// scaffold stays dep-free (no react-table runtime), matching the dep-free
|
|
6
|
+
// Form and the native date inputs. The collection arrives as a SERVER-RENDERED
|
|
7
|
+
// prop (`posts`); there is no client query for the initial render. A query only
|
|
8
|
+
// enters when the *client* drives the read: the search box calls
|
|
9
|
+
// useQuery(searchPosts, { q }) and swaps in filtered rows as you type.
|
|
10
|
+
// Per-row delete drives a controlled PostDeleteDialog (DELETE
|
|
11
|
+
// /posts/:id via the destroyPost action); on `{ ok: true }` the row is
|
|
12
|
+
// removed from the displayed rows IN PLACE (no reload, no URL built). Column
|
|
13
|
+
// sorting is CLIENT-ONLY — the dataset is the index payload; server-side
|
|
14
|
+
// sort/pagination is Phase-3 territory.
|
|
15
|
+
//
|
|
16
|
+
// The `@/components/ui/table` primitive (and the Badge/Button/DropdownMenu
|
|
17
|
+
// primitives below) is installed by Story 10.5 (`npx shadcn add table`). A
|
|
18
|
+
// freshly scaffolded app will not resolve these until 10.5 lands; that is
|
|
19
|
+
// expected (the end-to-end live demo is Story 10.7).
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
import { search as searchPosts, destroyPost, useQuery } from "@/.ruact/server-functions";
|
|
22
|
+
import { PostDeleteDialog } from "./PostDeleteDialog";
|
|
23
|
+
import { Badge } from "@/components/ui/badge";
|
|
24
|
+
import { Button } from "@/components/ui/button";
|
|
25
|
+
import {
|
|
26
|
+
Table,
|
|
27
|
+
TableBody,
|
|
28
|
+
TableCell,
|
|
29
|
+
TableHead,
|
|
30
|
+
TableHeader,
|
|
31
|
+
TableRow,
|
|
32
|
+
} from "@/components/ui/table";
|
|
33
|
+
import {
|
|
34
|
+
DropdownMenu,
|
|
35
|
+
DropdownMenuContent,
|
|
36
|
+
DropdownMenuItem,
|
|
37
|
+
DropdownMenuTrigger,
|
|
38
|
+
} from "@/components/ui/dropdown-menu";
|
|
39
|
+
|
|
40
|
+
type PostRow = { id: number; title: string | null; body: string | null; published: boolean | null; views: number | null; published_at: string | null; author_id: number | null };
|
|
41
|
+
|
|
42
|
+
// FR100 — opt-in call-site contract: `posts` is required. The index view passes
|
|
43
|
+
// `<PostList posts={rows} />` (satisfied); a call site that omits it
|
|
44
|
+
// fails at preprocess time, not as a silent `undefined` in the browser.
|
|
45
|
+
export const __ruactContract = {
|
|
46
|
+
props: { posts: "required" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Columns whose values are date/datetime strings: the sort compares these by
|
|
50
|
+
// epoch time (`new Date(value).getTime()`) rather than lexically, so they order
|
|
51
|
+
// chronologically. Generated from the model's date/datetime attributes.
|
|
52
|
+
const DATE_KEYS = new Set<string>(["published_at"]);
|
|
53
|
+
|
|
54
|
+
// Generated client-side comparator (no table engine). Sorts a COPY of the rows
|
|
55
|
+
// by the active key + direction: `null`/`undefined` always sort LAST (so a blank
|
|
56
|
+
// cell never jumps to the top, regardless of direction); date columns compare by
|
|
57
|
+
// time, numbers numerically, booleans false-before-true, everything else via a
|
|
58
|
+
// locale-aware string compare. The direction flip is applied AFTER the null
|
|
59
|
+
// handling, never to it.
|
|
60
|
+
function compareRows(
|
|
61
|
+
a: PostRow,
|
|
62
|
+
b: PostRow,
|
|
63
|
+
sort: { key: string; dir: "asc" | "desc" },
|
|
64
|
+
): number {
|
|
65
|
+
const av = a[sort.key as keyof PostRow];
|
|
66
|
+
const bv = b[sort.key as keyof PostRow];
|
|
67
|
+
if (av == null && bv == null) return 0;
|
|
68
|
+
if (av == null) return 1;
|
|
69
|
+
if (bv == null) return -1;
|
|
70
|
+
|
|
71
|
+
let result: number;
|
|
72
|
+
if (DATE_KEYS.has(sort.key)) {
|
|
73
|
+
result = new Date(String(av)).getTime() - new Date(String(bv)).getTime();
|
|
74
|
+
} else if (typeof av === "number" && typeof bv === "number") {
|
|
75
|
+
result = av - bv;
|
|
76
|
+
} else if (typeof av === "boolean" && typeof bv === "boolean") {
|
|
77
|
+
result = Number(av) - Number(bv);
|
|
78
|
+
} else {
|
|
79
|
+
result = String(av).localeCompare(String(bv));
|
|
80
|
+
}
|
|
81
|
+
return sort.dir === "desc" ? -result : result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pull a human-readable message out of the failed-delete result. A thrown
|
|
85
|
+
// RuactActionError (non-2xx) carries the parsed structured-error body, whose
|
|
86
|
+
// `message` the gem shapes per env (the exception message in development, the
|
|
87
|
+
// generic server message in production); a resolved soft-failure may carry a
|
|
88
|
+
// top-level `error`/`message`. Returns undefined when none is present, so the
|
|
89
|
+
// dialog can fall back to its own generic copy.
|
|
90
|
+
function deleteErrorMessage(error: unknown): string | undefined {
|
|
91
|
+
if (error != null && typeof error === "object") {
|
|
92
|
+
const carrier = error as { body?: unknown; error?: unknown; message?: unknown };
|
|
93
|
+
if (carrier.body != null && typeof carrier.body === "object") {
|
|
94
|
+
const message = (carrier.body as { message?: unknown }).message;
|
|
95
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
96
|
+
}
|
|
97
|
+
if (typeof carrier.error === "string" && carrier.error.length > 0) return carrier.error;
|
|
98
|
+
if (typeof carrier.message === "string" && carrier.message.length > 0) return carrier.message;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A resolved delete is a SUCCESS only on the server-owned success shapes: the
|
|
104
|
+
// in-list default `{ ok: true }`, or the delete-from-show `$redirect` the
|
|
105
|
+
// accessor already followed (which resolves to `null`). Any other resolved shape
|
|
106
|
+
// (e.g. an explicit `{ ok: false }`) is a non-success the dialog surfaces.
|
|
107
|
+
function deleteSucceeded(result: unknown): boolean {
|
|
108
|
+
if (result === null) return true;
|
|
109
|
+
return (
|
|
110
|
+
typeof result === "object" &&
|
|
111
|
+
(result as { ok?: unknown }).ok === true
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Per-row actions (AC5) — Edit + a Delete TRIGGER that opens the controlled
|
|
116
|
+
// AlertDialog. Each row owns its OWN dialog `open` state (one independent
|
|
117
|
+
// useState), so the dialog opens for exactly one row. `onConfirm` calls
|
|
118
|
+
// destroyPost and, on a server-owned success, asks the List to drop the row
|
|
119
|
+
// in place via `onDeleted`. The same component renders BOTH the inline (≥ md) and
|
|
120
|
+
// the overflow-menu (< md) layouts. The trigger is never unmounted, so Radix
|
|
121
|
+
// returns focus to it on close.
|
|
122
|
+
function RowActions({ record, onDeleted }: {
|
|
123
|
+
record: PostRow;
|
|
124
|
+
onDeleted: (id: number) => void;
|
|
125
|
+
}) {
|
|
126
|
+
const [open, setOpen] = useState(false);
|
|
127
|
+
// The overflow menu is CONTROLLED so the Delete item can close it explicitly
|
|
128
|
+
// and open the dialog in the same batch — preventing Radix's default
|
|
129
|
+
// close-autofocus from pulling focus back to the trigger as the dialog mounts.
|
|
130
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
131
|
+
|
|
132
|
+
async function onConfirm(): Promise<{ ok: boolean; error?: string }> {
|
|
133
|
+
try {
|
|
134
|
+
const result = await destroyPost({ id: record.id });
|
|
135
|
+
if (deleteSucceeded(result)) {
|
|
136
|
+
onDeleted(record.id);
|
|
137
|
+
setOpen(false);
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
// Resolved but not a success shape → keep the dialog open with the message.
|
|
141
|
+
return { ok: false, error: deleteErrorMessage(result) };
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// Failure (non-2xx structured error) → keep the dialog open, message inline.
|
|
144
|
+
return { ok: false, error: deleteErrorMessage(error) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex justify-end">
|
|
150
|
+
{/* ≥ md (768px): inline actions */}
|
|
151
|
+
<div className="hidden items-center gap-2 md:flex">
|
|
152
|
+
<Button variant="ghost" asChild>
|
|
153
|
+
<a href={`/posts/${record.id}/edit`}>Edit</a>
|
|
154
|
+
</Button>
|
|
155
|
+
<Button variant="ghost" className="text-destructive" onClick={() => setOpen(true)}>
|
|
156
|
+
Delete
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
{/* < md: collapse into a … overflow menu so actions never break layout */}
|
|
160
|
+
<div className="md:hidden">
|
|
161
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
162
|
+
<DropdownMenuTrigger asChild>
|
|
163
|
+
<Button variant="ghost" aria-label="Open actions menu">…</Button>
|
|
164
|
+
</DropdownMenuTrigger>
|
|
165
|
+
<DropdownMenuContent align="end">
|
|
166
|
+
<DropdownMenuItem asChild>
|
|
167
|
+
<a href={`/posts/${record.id}/edit`}>Edit</a>
|
|
168
|
+
</DropdownMenuItem>
|
|
169
|
+
{/* Close the menu and open the AlertDialog together: preventDefault
|
|
170
|
+
stops the default close (whose autofocus would fight the dialog),
|
|
171
|
+
then we close the menu and open the dialog deterministically. */}
|
|
172
|
+
<DropdownMenuItem
|
|
173
|
+
className="text-destructive"
|
|
174
|
+
onSelect={(event) => {
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
setMenuOpen(false);
|
|
177
|
+
setOpen(true);
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
Delete
|
|
181
|
+
</DropdownMenuItem>
|
|
182
|
+
</DropdownMenuContent>
|
|
183
|
+
</DropdownMenu>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<PostDeleteDialog
|
|
187
|
+
open={open}
|
|
188
|
+
onOpenChange={setOpen}
|
|
189
|
+
post={record}
|
|
190
|
+
onConfirm={onConfirm}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function PostList({
|
|
197
|
+
posts = [],
|
|
198
|
+
emptyLabel = "No posts yet — create one.",
|
|
199
|
+
}: { posts?: PostRow[]; emptyLabel?: string }) {
|
|
200
|
+
const [q, setQ] = useState("");
|
|
201
|
+
const searching = q.trim().length > 0;
|
|
202
|
+
|
|
203
|
+
// Ids deleted IN PLACE — a single tombstone list (not a second row cache)
|
|
204
|
+
// applied to whichever source is displayed, so a delete removes the row
|
|
205
|
+
// immediately whether it happened on the server-rendered list OR on the live
|
|
206
|
+
// search results.
|
|
207
|
+
const [removedIds, setRemovedIds] = useState<number[]>([]);
|
|
208
|
+
|
|
209
|
+
// Sort state (AC2) — the active column key + direction, or null when unsorted
|
|
210
|
+
// (server/insertion order). A new key sorts ascending; clicking the same key
|
|
211
|
+
// again flips asc↔desc.
|
|
212
|
+
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
|
213
|
+
|
|
214
|
+
// Client-driven read (AC5) — only meaningful while searching. When q is blank
|
|
215
|
+
// the box is idle and we fall back to the server-rendered rows.
|
|
216
|
+
const { data: searchData, loading: searchLoading } = useQuery<PostRow[]>(searchPosts, { q: q.trim() });
|
|
217
|
+
|
|
218
|
+
const source = searching ? searchData ?? [] : posts;
|
|
219
|
+
const rows = removedIds.length === 0 ? source : source.filter((row) => !removedIds.includes(row.id));
|
|
220
|
+
// Always sort a COPY — never mutate the prop/source array.
|
|
221
|
+
const sortedRows = sort ? [...rows].sort((a, b) => compareRows(a, b, sort)) : rows;
|
|
222
|
+
|
|
223
|
+
const onDeleted = (id: number) =>
|
|
224
|
+
setRemovedIds((current) => (current.includes(id) ? current : [...current, id]));
|
|
225
|
+
|
|
226
|
+
// Set the sort to a new key (ascending), or flip the direction when the active
|
|
227
|
+
// key is clicked again.
|
|
228
|
+
function toggleSort(key: string) {
|
|
229
|
+
setSort((current) =>
|
|
230
|
+
current && current.key === key
|
|
231
|
+
? { key, dir: current.dir === "asc" ? "desc" : "asc" }
|
|
232
|
+
: { key, dir: "asc" },
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<section className="mx-auto max-w-4xl px-4 py-8">
|
|
238
|
+
<div className="mb-4 flex items-center justify-between gap-4">
|
|
239
|
+
<h1 className="text-2xl font-semibold tracking-tight">Posts</h1>
|
|
240
|
+
<Button asChild>
|
|
241
|
+
<a href="/posts/new">+ New post</a>
|
|
242
|
+
</Button>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<input
|
|
246
|
+
type="search"
|
|
247
|
+
value={q}
|
|
248
|
+
onChange={(e) => setQ(e.target.value)}
|
|
249
|
+
placeholder="Search posts…"
|
|
250
|
+
className="mb-4 w-full rounded-md border px-3 py-2 text-sm"
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
{searching && searchLoading && <p className="text-sm text-muted-foreground">Searching…</p>}
|
|
254
|
+
{!searching && rows.length === 0 && <p className="text-sm text-muted-foreground">{emptyLabel}</p>}
|
|
255
|
+
{searching && !searchLoading && rows.length === 0 && (
|
|
256
|
+
<p className="text-sm text-muted-foreground">No posts match “{q.trim()}”.</p>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
{sortedRows.length > 0 && (
|
|
260
|
+
<Table>
|
|
261
|
+
<TableHeader>
|
|
262
|
+
<TableRow>
|
|
263
|
+
<TableHead>
|
|
264
|
+
<Button variant="ghost" onClick={() => toggleSort("id")}>ID</Button>
|
|
265
|
+
</TableHead>
|
|
266
|
+
<TableHead>
|
|
267
|
+
<Button variant="ghost" onClick={() => toggleSort("title")}>
|
|
268
|
+
Title
|
|
269
|
+
</Button>
|
|
270
|
+
</TableHead>
|
|
271
|
+
<TableHead>
|
|
272
|
+
<Button variant="ghost" onClick={() => toggleSort("body")}>
|
|
273
|
+
Body
|
|
274
|
+
</Button>
|
|
275
|
+
</TableHead>
|
|
276
|
+
<TableHead>
|
|
277
|
+
<Button variant="ghost" onClick={() => toggleSort("published")}>
|
|
278
|
+
Published
|
|
279
|
+
</Button>
|
|
280
|
+
</TableHead>
|
|
281
|
+
<TableHead>
|
|
282
|
+
<div className="text-right">
|
|
283
|
+
<Button variant="ghost" onClick={() => toggleSort("views")}>
|
|
284
|
+
Views
|
|
285
|
+
</Button>
|
|
286
|
+
</div>
|
|
287
|
+
</TableHead>
|
|
288
|
+
<TableHead>
|
|
289
|
+
<Button variant="ghost" onClick={() => toggleSort("published_at")}>
|
|
290
|
+
Published at
|
|
291
|
+
</Button>
|
|
292
|
+
</TableHead>
|
|
293
|
+
<TableHead>
|
|
294
|
+
<div className="text-right">
|
|
295
|
+
<Button variant="ghost" onClick={() => toggleSort("author_id")}>
|
|
296
|
+
Author
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
</TableHead>
|
|
300
|
+
<TableHead className="text-right">Actions</TableHead>
|
|
301
|
+
</TableRow>
|
|
302
|
+
</TableHeader>
|
|
303
|
+
<TableBody>
|
|
304
|
+
{sortedRows.map((row) => (
|
|
305
|
+
<TableRow key={row.id}>
|
|
306
|
+
<TableCell>
|
|
307
|
+
<a href={`/posts/${row.id}`} className="font-medium underline-offset-4 hover:underline">
|
|
308
|
+
{row.id}
|
|
309
|
+
</a>
|
|
310
|
+
</TableCell>
|
|
311
|
+
<TableCell>
|
|
312
|
+
<span>{String(row.title ?? "")}</span>
|
|
313
|
+
</TableCell>
|
|
314
|
+
<TableCell>
|
|
315
|
+
<span>{String(row.body ?? "")}</span>
|
|
316
|
+
</TableCell>
|
|
317
|
+
<TableCell>
|
|
318
|
+
<Badge variant={row.published ? "default" : "secondary"}>{row.published ? "Yes" : "No"}</Badge>
|
|
319
|
+
</TableCell>
|
|
320
|
+
<TableCell>
|
|
321
|
+
<div className="text-right tabular-nums">{String(row.views ?? "")}</div>
|
|
322
|
+
</TableCell>
|
|
323
|
+
<TableCell>
|
|
324
|
+
<span>{row.published_at ? new Date(String(row.published_at)).toLocaleString() : ""}</span>
|
|
325
|
+
</TableCell>
|
|
326
|
+
<TableCell>
|
|
327
|
+
<div className="text-right tabular-nums">{String(row.author_id ?? "")}</div>
|
|
328
|
+
</TableCell>
|
|
329
|
+
<TableCell>
|
|
330
|
+
<RowActions record={row} onDeleted={onDeleted} />
|
|
331
|
+
</TableCell>
|
|
332
|
+
</TableRow>
|
|
333
|
+
))}
|
|
334
|
+
</TableBody>
|
|
335
|
+
</Table>
|
|
336
|
+
)}
|
|
337
|
+
</section>
|
|
338
|
+
);
|
|
339
|
+
}
|