@flaredev/cli 0.4.1 → 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 (48) hide show
  1. package/dist/index.js +685 -341
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  4. package/templates/app/app/dashboard/page.tsx +4 -7
  5. package/templates/app/app/page.tsx +10 -0
  6. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  7. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  8. package/templates/app/components/dashboard/record-actions.tsx +7 -7
  9. package/templates/app/components/dashboard/record-detail.tsx +1 -1
  10. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  11. package/templates/app/components/dashboard/resource-form.tsx +129 -33
  12. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  13. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  14. package/templates/app/components/dashboard/resource-table.tsx +4 -4
  15. package/templates/app/components/dashboard/row-actions.tsx +3 -3
  16. package/templates/app/lib/costs.ts +138 -0
  17. package/templates/app/lib/dashboard-nav.ts +5 -1
  18. package/templates/app/lib/dashboard.ts +66 -61
  19. package/templates/app/lib/number.ts +33 -0
  20. package/templates/app/lib/site.ts +56 -56
  21. package/templates/app/lib/suggest.ts +35 -0
  22. package/templates/app/lib/usage.ts +32 -0
  23. package/templates/next/.env.example +21 -0
  24. package/templates/next/README.md +16 -0
  25. package/templates/next/app/api/health/route.ts +7 -0
  26. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  27. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  28. package/templates/next/app/dashboard/actions.ts +277 -0
  29. package/templates/next/app/dashboard/layout.tsx +64 -0
  30. package/templates/next/app/dashboard/page.tsx +127 -0
  31. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  32. package/templates/next/lib/analytics.ts +71 -0
  33. package/templates/next/lib/audit.ts +80 -0
  34. package/templates/next/lib/auth-emails.ts +92 -0
  35. package/templates/next/lib/auth.ts +156 -0
  36. package/templates/next/lib/dashboard.ts +161 -0
  37. package/templates/next/lib/db.ts +24 -0
  38. package/templates/next/lib/mail.ts +35 -0
  39. package/templates/next/lib/realtime.ts +23 -0
  40. package/templates/next/lib/redis.ts +17 -0
  41. package/templates/next/lib/storage.ts +57 -0
  42. package/templates/next/lib/theme.ts +83 -0
  43. package/templates/next/lib/views.ts +23 -0
  44. package/templates/next/next.config.ts +13 -0
  45. package/templates/next/prisma/schema/base.prisma +162 -0
  46. package/templates/next/prisma.config.ts +15 -0
  47. package/templates/next/tsconfig.json +24 -0
  48. 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
+ }
@@ -18,7 +18,7 @@ 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
24
  resource: ClientResource;
@@ -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}
@@ -92,7 +92,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
92
92
  editHref={resourcePath(resource, id, "edit")}
93
93
  canUpdate={permissions.update}
94
94
  canDelete={permissions.delete}
95
- modalForms={site.dashboard.forms === "modal"}
95
+ overlayForms={site.dashboard.forms === "sheet"}
96
96
  />
97
97
  }
98
98
  />
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { PlusIcon } from "lucide-react";
5
+ import type { ClientResource } from "@flaredev/core";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
8
+ import type { RelationMeta } from "./fields/field-widget";
9
+ import { ResourceForm } from "./resource-form";
10
+
11
+ export type FormRelations = Record<string, RelationMeta & { initialTitle?: string }>;
12
+
13
+ interface Props {
14
+ resource: ClientResource;
15
+ relations: FormRelations;
16
+ listHref: string;
17
+ mode: "create" | "edit";
18
+ /** Edit: the record and its id. */
19
+ id?: string;
20
+ record?: Record<string, unknown> | null;
21
+ /** Controlled use (a row menu opens it); otherwise pass `trigger`. */
22
+ open?: boolean;
23
+ onOpenChange?: (open: boolean) => void;
24
+ trigger?: React.ReactNode;
25
+ }
26
+
27
+ /**
28
+ * The create/edit form in a sheet, so adding a record doesn't lose the list, its filters
29
+ * or the place in it.
30
+ *
31
+ * A sheet rather than a centred dialog: a form is a column of fields, and a panel down
32
+ * the side of the screen has room for them without the page underneath jumping about or
33
+ * the fields being squeezed into a box. Apps that would rather have a page set
34
+ * `site.dashboard.forms` to "page" (lib/site.ts) and get the same form full width.
35
+ */
36
+ export function ResourceFormSheet({ resource, relations, listHref, mode, id, record, open, onOpenChange, trigger }: Props) {
37
+ const [internalOpen, setInternalOpen] = useState(false);
38
+ const isOpen = open ?? internalOpen;
39
+ const setOpen = onOpenChange ?? setInternalOpen;
40
+ const noun = resource.label.toLowerCase();
41
+
42
+ return (
43
+ <Sheet open={isOpen} onOpenChange={setOpen}>
44
+ {trigger && <SheetTrigger asChild>{trigger}</SheetTrigger>}
45
+ <SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
46
+ <SheetHeader className="border-b">
47
+ <SheetTitle>{mode === "create" ? `New ${noun}` : `Edit ${noun}`}</SheetTitle>
48
+ <SheetDescription>{mode === "create" ? "Fill this in and it appears in the list." : "Changes save straight away."}</SheetDescription>
49
+ </SheetHeader>
50
+ {/* Remounting on open clears whatever was typed and abandoned last time. */}
51
+ {isOpen && (
52
+ <div className="flex min-h-0 flex-1 flex-col p-4">
53
+ <ResourceForm
54
+ key={`${mode}-${id ?? "new"}`}
55
+ resource={resource}
56
+ mode={mode}
57
+ id={id}
58
+ record={record}
59
+ relations={relations}
60
+ listHref={listHref}
61
+ onDone={() => setOpen(false)}
62
+ />
63
+ </div>
64
+ )}
65
+ </SheetContent>
66
+ </Sheet>
67
+ );
68
+ }
69
+
70
+ /** The list's "New …" button, with the sheet it opens. */
71
+ export function NewRecordButton({ resource, relations, listHref }: { resource: ClientResource; relations: FormRelations; listHref: string }) {
72
+ return (
73
+ <ResourceFormSheet
74
+ resource={resource}
75
+ relations={relations}
76
+ listHref={listHref}
77
+ mode="create"
78
+ trigger={
79
+ <Button>
80
+ <PlusIcon data-icon="inline-start" />
81
+ New {resource.label.toLowerCase()}
82
+ </Button>
83
+ }
84
+ />
85
+ );
86
+ }