@flaredev/cli 0.4.1 → 0.5.1
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.
- package/dist/index.js +698 -341
- package/package.json +2 -2
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/record-actions.tsx +7 -7
- package/templates/app/components/dashboard/record-detail.tsx +1 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +129 -33
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table.tsx +4 -4
- package/templates/app/components/dashboard/row-actions.tsx +3 -3
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
field
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 {
|
|
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
|
|
33
|
-
|
|
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,
|
|
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
|
-
(
|
|
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 &&
|
|
81
|
-
<
|
|
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
|
-
|
|
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
|
+
}
|