@flaredev/cli 0.4.0 → 0.5.0

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 (59) hide show
  1. package/dist/index.js +851 -405
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +38 -5
  4. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  5. package/templates/app/app/dashboard/page.tsx +4 -7
  6. package/templates/app/app/globals.css +0 -6
  7. package/templates/app/app/layout.tsx +37 -28
  8. package/templates/app/app/page.tsx +10 -0
  9. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  10. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  11. package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
  12. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  13. package/templates/app/components/dashboard/import-dialog.tsx +2 -2
  14. package/templates/app/components/dashboard/record-actions.tsx +9 -9
  15. package/templates/app/components/dashboard/record-detail.tsx +6 -3
  16. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  17. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  18. package/templates/app/components/dashboard/resource-form.tsx +261 -165
  19. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  20. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  21. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  22. package/templates/app/components/dashboard/resource-table.tsx +12 -9
  23. package/templates/app/components/dashboard/row-actions.tsx +5 -5
  24. package/templates/app/lib/costs.ts +138 -0
  25. package/templates/app/lib/dashboard-nav.ts +5 -1
  26. package/templates/app/lib/dashboard.ts +66 -61
  27. package/templates/app/lib/number.ts +33 -0
  28. package/templates/app/lib/site.ts +56 -56
  29. package/templates/app/lib/suggest.ts +35 -0
  30. package/templates/app/lib/usage.ts +32 -0
  31. package/templates/locks/package-lock.json +9 -9
  32. package/templates/locks/pnpm-lock.yaml +12 -12
  33. package/templates/locks/yarn.lock +3 -3
  34. package/templates/next/.env.example +21 -0
  35. package/templates/next/README.md +16 -0
  36. package/templates/next/app/api/health/route.ts +7 -0
  37. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  38. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  39. package/templates/next/app/dashboard/actions.ts +277 -0
  40. package/templates/next/app/dashboard/layout.tsx +64 -0
  41. package/templates/next/app/dashboard/page.tsx +127 -0
  42. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  43. package/templates/next/lib/analytics.ts +71 -0
  44. package/templates/next/lib/audit.ts +80 -0
  45. package/templates/next/lib/auth-emails.ts +92 -0
  46. package/templates/next/lib/auth.ts +156 -0
  47. package/templates/next/lib/dashboard.ts +161 -0
  48. package/templates/next/lib/db.ts +24 -0
  49. package/templates/next/lib/mail.ts +35 -0
  50. package/templates/next/lib/realtime.ts +23 -0
  51. package/templates/next/lib/redis.ts +17 -0
  52. package/templates/next/lib/storage.ts +57 -0
  53. package/templates/next/lib/theme.ts +83 -0
  54. package/templates/next/lib/views.ts +23 -0
  55. package/templates/next/next.config.ts +13 -0
  56. package/templates/next/prisma/schema/base.prisma +162 -0
  57. package/templates/next/prisma.config.ts +15 -0
  58. package/templates/next/tsconfig.json +24 -0
  59. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -1,186 +1,237 @@
1
- "use client";
2
-
3
- import { normalizeDomain, optionLabel, slugify, type FileField as FileFieldDef, type MultiSelectField as MultiSelectDef, type StoredField } from "@flaredev/core";
4
- import { Input } from "@/components/ui/input";
5
- import { Label } from "@/components/ui/label";
6
- import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
7
- import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
8
- import { Switch } from "@/components/ui/switch";
9
- import { Textarea } from "@/components/ui/textarea";
10
- import { DateField, DateTimeField } from "./date-field";
11
- import { CountryField } from "./country-field";
12
- import { FileField } from "./file-field";
13
- import { MultiSelectField } from "./multi-select-field";
14
- import { PhoneField } from "./phone-field";
15
- import { RelationField } from "./relation-field";
16
-
17
- export interface RelationMeta {
18
- name: string;
19
- label: string;
20
- pluralLabel: string;
21
- slug: string;
22
- titleField: string;
23
- }
24
-
25
- export interface WidgetProps {
26
- id: string;
27
- name: string;
28
- /** Resource name and field key, for upload actions. */
29
- resourceName: string;
30
- fieldKey: string;
31
- field: StoredField & { label: string };
32
- value: string | boolean;
33
- onChange: (value: string | boolean) => void;
34
- invalid: boolean;
35
- disabled?: boolean;
36
- /** belongsTo: the related resource and the current value's title. */
37
- relation?: RelationMeta & { initialTitle?: string };
38
- }
39
-
40
- const NONE = "__none__";
41
-
42
- /** The input for a field, chosen from its kind in the descriptor. */
43
- export function FieldWidget(props: WidgetProps) {
44
- const { id, name, field, value, onChange, invalid, disabled, relation, resourceName, fieldKey } = props;
45
- const text = typeof value === "string" ? value : "";
46
- const common = { id, name, disabled, "aria-invalid": invalid || undefined, placeholder: field.placeholder };
47
-
48
- switch (field.kind) {
49
- case "text":
50
- return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
51
-
52
- case "int":
53
- case "float":
54
- return (
55
- <Input
56
- {...common}
57
- type="text"
58
- inputMode={field.kind === "int" ? "numeric" : "decimal"}
59
- className="tabular-nums"
60
- value={text}
61
- onChange={(event) => onChange(event.target.value)}
62
- />
63
- );
64
-
65
- case "boolean":
66
- return (
67
- <Switch
68
- id={id}
69
- name={name}
70
- disabled={disabled}
71
- aria-invalid={invalid || undefined}
72
- checked={value === true}
73
- onCheckedChange={(checked) => onChange(checked)}
74
- />
75
- );
76
-
77
- case "enum":
78
- if (field.widget === "radio") {
79
- return (
80
- <RadioGroup id={id} name={name} value={text} onValueChange={onChange} disabled={disabled} aria-invalid={invalid || undefined} className="flex flex-wrap gap-x-5 gap-y-2.5">
81
- {field.options.map((option) => (
82
- <div key={option} className="flex items-center gap-2">
83
- <RadioGroupItem id={`${id}-${option}`} value={option} aria-invalid={invalid || undefined} />
84
- <Label htmlFor={`${id}-${option}`} className="font-normal">
85
- {optionLabel(field, option)}
86
- </Label>
87
- </div>
88
- ))}
89
- </RadioGroup>
90
- );
91
- }
92
- return (
93
- // No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
94
- <Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
95
- <SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
96
- <SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
97
- </SelectTrigger>
98
- <SelectContent>
99
- <SelectGroup>
100
- {!field.required && <SelectItem value={NONE}>None</SelectItem>}
101
- {field.options.map((option) => (
102
- <SelectItem key={option} value={option}>
103
- {optionLabel(field, option)}
104
- </SelectItem>
105
- ))}
106
- </SelectGroup>
107
- </SelectContent>
108
- </Select>
109
- );
110
-
111
- case "multiselect":
112
- return (
113
- <MultiSelectField id={id} field={field as MultiSelectDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />
114
- );
115
-
116
- case "date":
117
- return (
118
- <DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
119
- );
120
-
121
- case "datetime":
122
- return (
123
- <DateTimeField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
124
- );
125
-
126
- case "belongsTo":
127
- return relation ? (
128
- <RelationField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} relation={relation} />
129
- ) : (
130
- <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />
131
- );
132
-
133
- case "file":
134
- return (
135
- <FileField
136
- id={id}
137
- value={text}
138
- onChange={onChange}
139
- invalid={invalid}
140
- disabled={disabled}
141
- field={field as FileFieldDef & { label: string }}
142
- resourceName={resourceName}
143
- fieldKey={fieldKey}
144
- />
145
- );
146
-
147
- case "string":
148
- switch (field.format) {
149
- case "tel":
150
- return <PhoneField id={id} name={name} label={field.label} value={text} onChange={onChange} invalid={invalid} disabled={disabled} placeholder={field.placeholder} />;
151
- case "country":
152
- return <CountryField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />;
153
- case "color":
154
- return (
155
- <div className="flex items-center gap-2">
156
- <input
157
- type="color"
158
- aria-label={`${field.label} picker`}
159
- value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#000000"}
160
- onChange={(event) => onChange(event.target.value)}
161
- disabled={disabled}
162
- className="h-9 w-11 shrink-0 cursor-pointer rounded-md border border-input bg-transparent p-1"
163
- />
164
- <Input {...common} className="font-mono" placeholder={field.placeholder ?? "#f2541d"} value={text} onChange={(event) => onChange(event.target.value)} />
165
- </div>
166
- );
167
- case "slug":
168
- return (
169
- <Input {...common} className="font-mono" placeholder={field.placeholder ?? "my-first-post"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(slugify(text))} />
170
- );
171
- case "domain":
172
- return (
173
- <Input {...common} inputMode="url" autoCapitalize="none" spellCheck={false} placeholder={field.placeholder ?? "example.com"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(normalizeDomain(text))} />
174
- );
175
- case "email":
176
- return <Input {...common} type="email" autoComplete="email" value={text} onChange={(event) => onChange(event.target.value)} />;
177
- case "url":
178
- return <Input {...common} type="url" placeholder={field.placeholder ?? "https://"} value={text} onChange={(event) => onChange(event.target.value)} />;
179
- default:
180
- return <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />;
181
- }
182
-
183
- default:
184
- return <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />;
185
- }
186
- }
1
+ "use client";
2
+
3
+ import { normalizeDomain, optionLabel, slugify, type FileField as FileFieldDef, type MultiSelectField as MultiSelectDef, type StoredField } from "@flaredev/core";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
7
+ import { Label } from "@/components/ui/label";
8
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
9
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10
+ import { Switch } from "@/components/ui/switch";
11
+ import { Textarea } from "@/components/ui/textarea";
12
+ import { groupDigits, ungroup } from "@/lib/number";
13
+ import { DateField, DateTimeField } from "./date-field";
14
+ import { CountryField } from "./country-field";
15
+ import { FileField } from "./file-field";
16
+ import { MultiSelectField } from "./multi-select-field";
17
+ import { PhoneField } from "./phone-field";
18
+ import { RelationField } from "./relation-field";
19
+
20
+ export interface RelationMeta {
21
+ name: string;
22
+ label: string;
23
+ pluralLabel: string;
24
+ slug: string;
25
+ titleField: string;
26
+ }
27
+
28
+ export interface WidgetProps {
29
+ id: string;
30
+ name: string;
31
+ /** Resource name and field key, for upload actions. */
32
+ resourceName: string;
33
+ fieldKey: string;
34
+ field: StoredField & { label: string };
35
+ value: string | boolean;
36
+ onChange: (value: string | boolean) => void;
37
+ invalid: boolean;
38
+ disabled?: boolean;
39
+ /** belongsTo: the related resource and the current value's title. */
40
+ relation?: RelationMeta & { initialTitle?: string };
41
+ /**
42
+ * A value this field can make up for itself — a SKU, a slug, a reference. Given for
43
+ * unique codes, where typing one by hand is busywork, and shown as a button beside
44
+ * the input so whatever it suggests can still be typed over.
45
+ */
46
+ suggest?: () => string;
47
+ }
48
+
49
+ const NONE = "__none__";
50
+
51
+ /** The input for a field, chosen from its kind in the descriptor. */
52
+ export function FieldWidget(props: WidgetProps) {
53
+ const { id, name, field, value, onChange, invalid, disabled, relation, resourceName, fieldKey, suggest } = props;
54
+ const text = typeof value === "string" ? value : "";
55
+ const common = { id, name, disabled, "aria-invalid": invalid || undefined, placeholder: field.placeholder };
56
+
57
+ switch (field.kind) {
58
+ case "text":
59
+ return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
60
+
61
+ case "int":
62
+ case "float":
63
+ // Shown grouped (2,000) and stored plain (2000), so a price can be read at a glance
64
+ // and nothing downstream has to strip a comma back out.
65
+ return (
66
+ <Input
67
+ {...common}
68
+ type="text"
69
+ inputMode={field.kind === "int" ? "numeric" : "decimal"}
70
+ className="tabular-nums"
71
+ value={groupDigits(text)}
72
+ onChange={(event) => onChange(ungroup(event.target.value, field.kind === "float"))}
73
+ />
74
+ );
75
+
76
+ case "boolean":
77
+ return (
78
+ <Switch
79
+ id={id}
80
+ name={name}
81
+ disabled={disabled}
82
+ aria-invalid={invalid || undefined}
83
+ checked={value === true}
84
+ onCheckedChange={(checked) => onChange(checked)}
85
+ />
86
+ );
87
+
88
+ case "enum":
89
+ if (field.widget === "radio") {
90
+ return (
91
+ <RadioGroup id={id} name={name} value={text} onValueChange={onChange} disabled={disabled} aria-invalid={invalid || undefined} className="flex flex-wrap gap-x-5 gap-y-2.5">
92
+ {field.options.map((option) => (
93
+ <div key={option} className="flex items-center gap-2">
94
+ <RadioGroupItem id={`${id}-${option}`} value={option} aria-invalid={invalid || undefined} />
95
+ <Label htmlFor={`${id}-${option}`} className="font-normal">
96
+ {optionLabel(field, option)}
97
+ </Label>
98
+ </div>
99
+ ))}
100
+ </RadioGroup>
101
+ );
102
+ }
103
+ return (
104
+ // No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
105
+ <Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
106
+ <SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
107
+ <SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
108
+ </SelectTrigger>
109
+ <SelectContent>
110
+ <SelectGroup>
111
+ {!field.required && <SelectItem value={NONE}>None</SelectItem>}
112
+ {field.options.map((option) => (
113
+ <SelectItem key={option} value={option}>
114
+ {optionLabel(field, option)}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectGroup>
118
+ </SelectContent>
119
+ </Select>
120
+ );
121
+
122
+ case "multiselect":
123
+ return (
124
+ <MultiSelectField id={id} field={field as MultiSelectDef & { label: string }} value={text} onChange={onChange} invalid={invalid} disabled={disabled} />
125
+ );
126
+
127
+ case "date":
128
+ return (
129
+ <DateField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
130
+ );
131
+
132
+ case "datetime":
133
+ return (
134
+ <DateTimeField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />
135
+ );
136
+
137
+ case "belongsTo":
138
+ return relation ? (
139
+ <RelationField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} relation={relation} />
140
+ ) : (
141
+ <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />
142
+ );
143
+
144
+ case "file":
145
+ return (
146
+ <FileField
147
+ id={id}
148
+ value={text}
149
+ onChange={onChange}
150
+ invalid={invalid}
151
+ disabled={disabled}
152
+ field={field as FileFieldDef & { label: string }}
153
+ resourceName={resourceName}
154
+ fieldKey={fieldKey}
155
+ />
156
+ );
157
+
158
+ case "string":
159
+ switch (field.format) {
160
+ case "tel":
161
+ return <PhoneField id={id} name={name} label={field.label} value={text} onChange={onChange} invalid={invalid} disabled={disabled} placeholder={field.placeholder} />;
162
+ case "country":
163
+ return <CountryField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} required={field.required} placeholder={field.placeholder} />;
164
+ case "color":
165
+ return (
166
+ <div className="flex items-center gap-2">
167
+ <input
168
+ type="color"
169
+ aria-label={`${field.label} picker`}
170
+ value={/^#[0-9a-f]{6}$/i.test(text) ? text : "#000000"}
171
+ onChange={(event) => onChange(event.target.value)}
172
+ disabled={disabled}
173
+ className="h-9 w-11 shrink-0 cursor-pointer rounded-md border border-input bg-transparent p-1"
174
+ />
175
+ <Input {...common} className="font-mono" placeholder={field.placeholder ?? "#f2541d"} value={text} onChange={(event) => onChange(event.target.value)} />
176
+ </div>
177
+ );
178
+ case "slug":
179
+ return (
180
+ <WithSuggestion suggest={suggest} disabled={disabled} onChange={onChange}>
181
+ <InputGroupInput {...common} className="font-mono" placeholder={field.placeholder ?? "my-first-post"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(slugify(text))} />
182
+ </WithSuggestion>
183
+ );
184
+ case "domain":
185
+ return (
186
+ <Input {...common} inputMode="url" autoCapitalize="none" spellCheck={false} placeholder={field.placeholder ?? "example.com"} value={text} onChange={(event) => onChange(event.target.value)} onBlur={() => text && onChange(normalizeDomain(text))} />
187
+ );
188
+ case "email":
189
+ return <Input {...common} type="email" autoComplete="email" value={text} onChange={(event) => onChange(event.target.value)} />;
190
+ case "url":
191
+ return <Input {...common} type="url" placeholder={field.placeholder ?? "https://"} value={text} onChange={(event) => onChange(event.target.value)} />;
192
+ default:
193
+ return (
194
+ <WithSuggestion suggest={suggest} disabled={disabled} onChange={onChange}>
195
+ <InputGroupInput {...common} value={text} onChange={(event) => onChange(event.target.value)} />
196
+ </WithSuggestion>
197
+ );
198
+ }
199
+
200
+ default:
201
+ return <Input {...common} value={text} onChange={(event) => onChange(event.target.value)} />;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * An input with a "Generate" button, when the form offered one for this field.
207
+ *
208
+ * Without a suggestion it stays an ordinary input, so the same branch serves a field
209
+ * that can make up its own value and one that can't.
210
+ */
211
+ function WithSuggestion({
212
+ suggest,
213
+ disabled,
214
+ onChange,
215
+ children,
216
+ }: {
217
+ suggest?: () => string;
218
+ disabled?: boolean;
219
+ onChange: (value: string) => void;
220
+ children: React.ReactElement;
221
+ }) {
222
+ if (!suggest) {
223
+ // InputGroupInput outside an InputGroup would lose its border; hand back a plain one.
224
+ const { className, ...rest } = children.props as { className?: string } & Record<string, unknown>;
225
+ return <Input {...rest} className={className} />;
226
+ }
227
+ return (
228
+ <InputGroup>
229
+ {children}
230
+ <InputGroupAddon align="inline-end">
231
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => onChange(suggest())}>
232
+ Generate
233
+ </Button>
234
+ </InputGroupAddon>
235
+ </InputGroup>
236
+ );
237
+ }
@@ -48,6 +48,7 @@ function formatBytes(bytes: number): string {
48
48
 
49
49
  /** ["image", "pdf"] → "image or PDF" (acronyms stay upper case). */
50
50
  function describeAccept(accept: readonly string[]): string {
51
+ if (accept.includes("any")) return "any file";
51
52
  const names = accept.map((category) => (category === "pdf" || category === "csv" ? category.toUpperCase() : category));
52
53
  return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0]!;
53
54
  }
@@ -129,6 +130,9 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
129
130
  const problem = await check(file);
130
131
  if (problem) return setError(problem);
131
132
 
133
+ // A browser leaves `type` empty for an extension it doesn't know, and an upload has
134
+ // to say what it is sending, so fall back to the type that means "bytes".
135
+ const contentType = file.type || "application/octet-stream";
132
136
  const signal: { abort?: () => void } = {};
133
137
  const localPreview = IMAGE_TYPES.has(file.type) ? URL.createObjectURL(file) : null;
134
138
  setUpload({ name: file.name, loaded: 0, total: file.size, abort: () => signal.abort?.() });
@@ -136,7 +140,7 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
136
140
  setStatus(`Uploading ${file.name}…`);
137
141
 
138
142
  try {
139
- const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: file.type, size: file.size });
143
+ const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: contentType, size: file.size });
140
144
  if (!signed.ok) throw new Error(signed.error);
141
145
  await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
142
146
  onChange(signed.data.key);
@@ -33,7 +33,29 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
33
33
  const [query, setQuery] = useState("");
34
34
  const [options, setOptions] = useState<Option[]>([]);
35
35
  const [loading, setLoading] = useState(false);
36
- const [title, setTitle] = useState(relation.initialTitle ?? "");
36
+ // The title is remembered against the id it belongs to, so a value that changes
37
+ // underneath the field doesn't keep the name of the record it used to hold.
38
+ const [known, setKnown] = useState({ id: value, title: relation.initialTitle ?? "" });
39
+ const title = known.id === value ? known.title : "";
40
+
41
+ // A form opened from a table row is given the id but not the name behind it. Looking it
42
+ // up here means the field reads correctly wherever the form was rendered.
43
+ useEffect(() => {
44
+ if (!value || title) return;
45
+ const controller = new AbortController();
46
+ void (async () => {
47
+ try {
48
+ const response = await fetch(`/api/${relation.slug}/${value}`, { signal: controller.signal, credentials: "same-origin" });
49
+ if (!response.ok) return;
50
+ const record = (await response.json()) as Record<string, unknown>;
51
+ const found = record[relation.titleField];
52
+ if (found) setKnown({ id: value, title: String(found) });
53
+ } catch {
54
+ // Leave the id showing; the field still works.
55
+ }
56
+ })();
57
+ return () => controller.abort();
58
+ }, [value, title, relation.slug, relation.titleField]);
37
59
 
38
60
  useEffect(() => {
39
61
  if (!open) return;
@@ -99,7 +121,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
99
121
  value={option.id}
100
122
  onSelect={() => {
101
123
  onChange(option.id);
102
- setTitle(option.title);
124
+ setKnown({ id: option.id, title: option.title });
103
125
  setOpen(false);
104
126
  }}
105
127
  >
@@ -121,7 +143,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
121
143
  disabled={disabled}
122
144
  onClick={() => {
123
145
  onChange("");
124
- setTitle("");
146
+ setKnown({ id: "", title: "" });
125
147
  }}
126
148
  >
127
149
  <XIcon />
@@ -10,7 +10,7 @@ import {
10
10
  storedFields,
11
11
  type EnumField,
12
12
  type MultiSelectField,
13
- type Resource,
13
+ type ClientResource,
14
14
  type StoredField,
15
15
  } from "@flaredev/core";
16
16
  import { importRecordsAction } from "@/app/dashboard/import-actions";
@@ -202,7 +202,7 @@ function guessMapping(fields: MappedField[], headers: string[]): Record<string,
202
202
  * schemas and again on the server, and sent in batches so a long file reports progress
203
203
  * and never depends on one very long request.
204
204
  */
205
- export function ImportDialog({ resource }: { resource: Resource }) {
205
+ export function ImportDialog({ resource }: { resource: ClientResource }) {
206
206
  const router = useRouter();
207
207
  const validators = useMemo(() => createValidators(resource), [resource]);
208
208
  // Uploads need a signed URL per file, which a CSV cell can't stand in for.
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
6
  import { PencilIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
- import type { Resource } from "@flaredev/core";
8
+ import type { ClientResource } from "@flaredev/core";
9
9
  import { deleteRecordAction } from "@/app/dashboard/actions";
10
10
  import {
11
11
  AlertDialog,
@@ -18,10 +18,10 @@ import {
18
18
  } from "@/components/ui/alert-dialog";
19
19
  import { Button } from "@/components/ui/button";
20
20
  import { Spinner } from "@/components/ui/spinner";
21
- import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
21
+ import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
22
22
 
23
23
  interface Props {
24
- resource: Resource;
24
+ resource: ClientResource;
25
25
  id: string;
26
26
  record: Record<string, unknown>;
27
27
  relations: FormRelations;
@@ -29,12 +29,12 @@ interface Props {
29
29
  editHref: string;
30
30
  canUpdate: boolean;
31
31
  canDelete: boolean;
32
- /** Whether editing opens a dialog (site.dashboard.forms) or goes to the form page. */
33
- modalForms: boolean;
32
+ /** Whether editing opens a sheet (site.dashboard.forms) or goes to the form page. */
33
+ overlayForms: boolean;
34
34
  }
35
35
 
36
36
  /** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
37
- export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, modalForms }: Props) {
37
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms }: Props) {
38
38
  const router = useRouter();
39
39
  const [editing, setEditing] = useState(false);
40
40
  const [confirming, setConfirming] = useState(false);
@@ -57,7 +57,7 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
57
57
  return (
58
58
  <>
59
59
  {canUpdate &&
60
- (modalForms ? (
60
+ (overlayForms ? (
61
61
  <Button variant="outline" onClick={() => setEditing(true)}>
62
62
  <PencilIcon data-icon="inline-start" />
63
63
  Edit
@@ -77,8 +77,8 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
77
77
  </Button>
78
78
  )}
79
79
 
80
- {canUpdate && modalForms && (
81
- <ResourceFormDialog
80
+ {canUpdate && overlayForms && (
81
+ <ResourceFormSheet
82
82
  resource={resource}
83
83
  relations={relations}
84
84
  listHref={listHref}
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { notFound } from "next/navigation";
3
- import { formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
3
+ import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +23,9 @@ const RELATED_LIMIT = 5;
23
23
  */
24
24
  export async function RecordDetail({ resource, id }: { resource: Resource; id: string }) {
25
25
  await requireAccess(resource, "read");
26
+ // Hooks and computed values are functions, and a function can't be sent to the browser.
27
+ // This is the same resource without them, for the client components below.
28
+ const forClient = clientResource(resource);
26
29
  const store = dashboardStore(resource.name);
27
30
  const result = await store.get(id);
28
31
  if (!result.ok) notFound();
@@ -81,7 +84,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
81
84
  ]}
82
85
  actions={
83
86
  <RecordActions
84
- resource={resource}
87
+ resource={forClient}
85
88
  id={id}
86
89
  record={record}
87
90
  relations={relations}
@@ -89,7 +92,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
89
92
  editHref={resourcePath(resource, id, "edit")}
90
93
  canUpdate={permissions.update}
91
94
  canDelete={permissions.delete}
92
- modalForms={site.dashboard.forms === "modal"}
95
+ overlayForms={site.dashboard.forms === "sheet"}
93
96
  />
94
97
  }
95
98
  />