@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.
- package/dist/index.js +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- 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/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- 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-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- 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/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- 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
|
+
}
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 {
|
|
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 {
|
|
21
|
+
import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
|
|
22
22
|
|
|
23
23
|
interface Props {
|
|
24
|
-
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
|
|
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}
|
|
@@ -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={
|
|
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
|
-
|
|
95
|
+
overlayForms={site.dashboard.forms === "sheet"}
|
|
93
96
|
/>
|
|
94
97
|
}
|
|
95
98
|
/>
|