@flaredev/cli 0.2.0 → 0.3.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/bin/flare.js +9 -1
- package/dist/index.js +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useMemo, useState, type ChangeEvent } from "react";
|
|
5
|
+
import { DownloadIcon, UploadIcon } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
createValidators,
|
|
8
|
+
issuesByField,
|
|
9
|
+
optionLabel,
|
|
10
|
+
storedFields,
|
|
11
|
+
type EnumField,
|
|
12
|
+
type MultiSelectField,
|
|
13
|
+
type Resource,
|
|
14
|
+
type StoredField,
|
|
15
|
+
} from "@flaredev/core";
|
|
16
|
+
import { importRecordsAction } from "@/app/dashboard/import-actions";
|
|
17
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
18
|
+
import { Button } from "@/components/ui/button";
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogDescription,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
DialogHeader,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
DialogTrigger,
|
|
27
|
+
} from "@/components/ui/dialog";
|
|
28
|
+
import { Field, FieldLabel } from "@/components/ui/field";
|
|
29
|
+
import { Input } from "@/components/ui/input";
|
|
30
|
+
import { Progress } from "@/components/ui/progress";
|
|
31
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
32
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
33
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
34
|
+
|
|
35
|
+
/** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
|
|
36
|
+
const SKIP = "__skip__";
|
|
37
|
+
|
|
38
|
+
/** Rows per server action call. Small enough to stay well inside a Worker's time budget. */
|
|
39
|
+
const BATCH_SIZE = 500;
|
|
40
|
+
|
|
41
|
+
const PREVIEW_ROWS = 5;
|
|
42
|
+
const LISTED_FAILURES = 10;
|
|
43
|
+
|
|
44
|
+
type MappedField = [string, StoredField & { label: string }];
|
|
45
|
+
|
|
46
|
+
/** A row the import rejected, numbered by its line in the file so it can be found again. */
|
|
47
|
+
interface RowFailure {
|
|
48
|
+
line: number;
|
|
49
|
+
error: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Summary {
|
|
53
|
+
created: number;
|
|
54
|
+
total: number;
|
|
55
|
+
failures: RowFailure[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Rows of a CSV. Written here rather than pulled from a package because the format an
|
|
60
|
+
* import needs is small: quoted fields, commas and newlines inside them, and `""` for a
|
|
61
|
+
* quote. Anything a spreadsheet exports parses; exotic dialects are not the point.
|
|
62
|
+
*/
|
|
63
|
+
function parseCsv(text: string): string[][] {
|
|
64
|
+
// A BOM from Excel would otherwise become part of the first header.
|
|
65
|
+
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
66
|
+
const rows: string[][] = [];
|
|
67
|
+
let row: string[] = [];
|
|
68
|
+
let value = "";
|
|
69
|
+
let quoted = false;
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
72
|
+
const char = source.charAt(index);
|
|
73
|
+
if (quoted) {
|
|
74
|
+
if (char !== '"') value += char;
|
|
75
|
+
else if (source.charAt(index + 1) === '"') {
|
|
76
|
+
value += '"';
|
|
77
|
+
index += 1;
|
|
78
|
+
} else quoted = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === '"') quoted = true;
|
|
82
|
+
else if (char === ",") {
|
|
83
|
+
row.push(value);
|
|
84
|
+
value = "";
|
|
85
|
+
} else if (char === "\n" || char === "\r") {
|
|
86
|
+
if (char === "\r" && source.charAt(index + 1) === "\n") index += 1;
|
|
87
|
+
row.push(value);
|
|
88
|
+
rows.push(row);
|
|
89
|
+
row = [];
|
|
90
|
+
value = "";
|
|
91
|
+
} else value += char;
|
|
92
|
+
}
|
|
93
|
+
// Only a genuine last row survives here, so a trailing newline adds no empty record.
|
|
94
|
+
if (value !== "" || row.length > 0) {
|
|
95
|
+
row.push(value);
|
|
96
|
+
rows.push(row);
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const csvCell = (value: string) => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value);
|
|
102
|
+
|
|
103
|
+
const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
|
|
104
|
+
|
|
105
|
+
/** A field must be mapped when nothing else can supply it: required, with no default. */
|
|
106
|
+
const needsColumn = (def: StoredField) => def.required && !("default" in def && def.default !== undefined);
|
|
107
|
+
|
|
108
|
+
/** An option from a cell holding either the stored value or the label shown in the admin. */
|
|
109
|
+
function matchOption(def: EnumField | MultiSelectField, text: string): string {
|
|
110
|
+
const match = def.options.find(
|
|
111
|
+
(option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
|
|
112
|
+
);
|
|
113
|
+
return match ?? text;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A cell as the value its field expects. Text that doesn't convert is passed through
|
|
118
|
+
* unchanged so validation reports it against the field, rather than the import guessing.
|
|
119
|
+
*/
|
|
120
|
+
function toValue(def: StoredField, raw: string): unknown {
|
|
121
|
+
const text = raw.trim();
|
|
122
|
+
if (text === "") return undefined;
|
|
123
|
+
switch (def.kind) {
|
|
124
|
+
case "int":
|
|
125
|
+
case "float": {
|
|
126
|
+
const number = Number(text);
|
|
127
|
+
return Number.isFinite(number) ? number : text;
|
|
128
|
+
}
|
|
129
|
+
case "boolean": {
|
|
130
|
+
const lower = text.toLowerCase();
|
|
131
|
+
if (["true", "yes", "1"].includes(lower)) return true;
|
|
132
|
+
if (["false", "no", "0"].includes(lower)) return false;
|
|
133
|
+
return text;
|
|
134
|
+
}
|
|
135
|
+
case "enum":
|
|
136
|
+
return matchOption(def, text);
|
|
137
|
+
case "multiselect":
|
|
138
|
+
return text
|
|
139
|
+
.split(/[;,]/)
|
|
140
|
+
.map((part) => part.trim())
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((part) => matchOption(def, part));
|
|
143
|
+
default:
|
|
144
|
+
// Dates keep their text: the descriptor's schema decides which formats it takes.
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A column per field, matched on header text; anything unrecognised starts as Skip. */
|
|
150
|
+
function guessMapping(fields: MappedField[], headers: string[]): Record<string, string> {
|
|
151
|
+
const taken = new Set<number>();
|
|
152
|
+
const mapping: Record<string, string> = {};
|
|
153
|
+
for (const [key, def] of fields) {
|
|
154
|
+
const names = [squash(key), squash(def.label)];
|
|
155
|
+
const index = headers.findIndex((header, position) => !taken.has(position) && names.includes(squash(header)));
|
|
156
|
+
mapping[key] = index === -1 ? SKIP : String(index);
|
|
157
|
+
if (index !== -1) taken.add(index);
|
|
158
|
+
}
|
|
159
|
+
return mapping;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Creates records from a CSV: pick a file, match its columns to the resource's fields,
|
|
164
|
+
* check the preview, then import. Rows are validated in the browser with the descriptor's
|
|
165
|
+
* schemas and again on the server, and sent in batches so a long file reports progress
|
|
166
|
+
* and never depends on one very long request.
|
|
167
|
+
*/
|
|
168
|
+
export function ImportDialog({ resource }: { resource: Resource }) {
|
|
169
|
+
const router = useRouter();
|
|
170
|
+
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
171
|
+
// Uploads need a signed URL per file, which a CSV cell can't stand in for.
|
|
172
|
+
const fields = useMemo(() => storedFields(resource).filter(([, def]) => def.kind !== "file"), [resource]);
|
|
173
|
+
|
|
174
|
+
const [open, setOpen] = useState(false);
|
|
175
|
+
const [fileName, setFileName] = useState("");
|
|
176
|
+
const [headers, setHeaders] = useState<string[]>([]);
|
|
177
|
+
const [rows, setRows] = useState<string[][]>([]);
|
|
178
|
+
const [mapping, setMapping] = useState<Record<string, string>>({});
|
|
179
|
+
const [fileError, setFileError] = useState("");
|
|
180
|
+
const [importError, setImportError] = useState("");
|
|
181
|
+
const [progress, setProgress] = useState(0);
|
|
182
|
+
const [running, setRunning] = useState(false);
|
|
183
|
+
const [summary, setSummary] = useState<Summary | null>(null);
|
|
184
|
+
|
|
185
|
+
const mapped = fields.filter(([key]) => mapping[key] !== undefined && mapping[key] !== SKIP);
|
|
186
|
+
const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
|
|
187
|
+
const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
|
|
188
|
+
|
|
189
|
+
function reset() {
|
|
190
|
+
setFileName("");
|
|
191
|
+
setHeaders([]);
|
|
192
|
+
setRows([]);
|
|
193
|
+
setMapping({});
|
|
194
|
+
setFileError("");
|
|
195
|
+
setImportError("");
|
|
196
|
+
setProgress(0);
|
|
197
|
+
setSummary(null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function onFile(event: ChangeEvent<HTMLInputElement>) {
|
|
201
|
+
const file = event.target.files?.[0];
|
|
202
|
+
if (!file) return;
|
|
203
|
+
reset();
|
|
204
|
+
|
|
205
|
+
const [head = [], ...body] = parseCsv(await file.text());
|
|
206
|
+
if (head.length === 0 || body.length === 0) {
|
|
207
|
+
setFileError("That file has no rows to import. A CSV needs a header row and at least one row under it.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
setFileName(file.name);
|
|
211
|
+
setHeaders(head);
|
|
212
|
+
setRows(body);
|
|
213
|
+
setMapping(guessMapping(fields, head));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function toInput(cells: string[]): Record<string, unknown> {
|
|
217
|
+
const input: Record<string, unknown> = {};
|
|
218
|
+
for (const [key, def] of mapped) {
|
|
219
|
+
const value = toValue(def, cells[Number(mapping[key])] ?? "");
|
|
220
|
+
// An empty cell leaves the field out entirely, so its default applies as on create.
|
|
221
|
+
if (value !== undefined) input[key] = value;
|
|
222
|
+
}
|
|
223
|
+
return input;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runImport() {
|
|
227
|
+
setRunning(true);
|
|
228
|
+
setImportError("");
|
|
229
|
+
setSummary(null);
|
|
230
|
+
setProgress(0);
|
|
231
|
+
|
|
232
|
+
const failures: RowFailure[] = [];
|
|
233
|
+
let created = 0;
|
|
234
|
+
|
|
235
|
+
for (let start = 0; start < rows.length; start += BATCH_SIZE) {
|
|
236
|
+
const batch = rows.slice(start, start + BATCH_SIZE);
|
|
237
|
+
const accepted: Record<string, unknown>[] = [];
|
|
238
|
+
const lines: number[] = [];
|
|
239
|
+
|
|
240
|
+
batch.forEach((cells, offset) => {
|
|
241
|
+
// The header is line 1, so the first record is line 2 of the file.
|
|
242
|
+
const line = start + offset + 2;
|
|
243
|
+
const input = toInput(cells);
|
|
244
|
+
const parsed = validators.create.safeParse(input);
|
|
245
|
+
if (parsed.success) {
|
|
246
|
+
accepted.push(input);
|
|
247
|
+
lines.push(line);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const issues = Object.entries(issuesByField(parsed.error.issues));
|
|
251
|
+
failures.push({
|
|
252
|
+
line,
|
|
253
|
+
error: issues.map(([key, message]) => (key === "_form" ? message : `${key}: ${message}`)).join("; "),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (accepted.length > 0) {
|
|
258
|
+
const result = await importRecordsAction(resource.name, accepted);
|
|
259
|
+
if (!result.ok) {
|
|
260
|
+
setImportError(result.error);
|
|
261
|
+
setSummary({ created, total: rows.length, failures });
|
|
262
|
+
setRunning(false);
|
|
263
|
+
if (created > 0) router.refresh();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
created += result.data.created;
|
|
267
|
+
for (const failure of result.data.failed) {
|
|
268
|
+
failures.push({ line: lines[failure.row - 1] ?? start + failure.row + 1, error: failure.error });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setProgress(Math.round(((start + batch.length) / rows.length) * 100));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
failures.sort((first, second) => first.line - second.line);
|
|
276
|
+
setSummary({ created, total: rows.length, failures });
|
|
277
|
+
setRunning(false);
|
|
278
|
+
if (created > 0) router.refresh();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function downloadFailures(failures: RowFailure[]) {
|
|
282
|
+
const csv = [
|
|
283
|
+
[...headers, "Import error"],
|
|
284
|
+
...failures.map((failure) => [...(rows[failure.line - 2] ?? []), failure.error]),
|
|
285
|
+
]
|
|
286
|
+
.map((cells) => cells.map(csvCell).join(","))
|
|
287
|
+
.join("\r\n");
|
|
288
|
+
|
|
289
|
+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
|
290
|
+
const link = document.createElement("a");
|
|
291
|
+
link.href = url;
|
|
292
|
+
link.download = `${fileName.replace(/\.csv$/i, "") || resource.slug}-failed.csv`;
|
|
293
|
+
link.click();
|
|
294
|
+
URL.revokeObjectURL(url);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<Dialog
|
|
299
|
+
open={open}
|
|
300
|
+
onOpenChange={(next) => {
|
|
301
|
+
setOpen(next);
|
|
302
|
+
if (!next) reset();
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<DialogTrigger asChild>
|
|
306
|
+
<Button variant="outline">
|
|
307
|
+
<UploadIcon data-icon="inline-start" />
|
|
308
|
+
Import CSV
|
|
309
|
+
</Button>
|
|
310
|
+
</DialogTrigger>
|
|
311
|
+
|
|
312
|
+
<DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
|
|
313
|
+
<DialogHeader>
|
|
314
|
+
<DialogTitle>Import {resource.pluralLabel.toLowerCase()}</DialogTitle>
|
|
315
|
+
<DialogDescription>
|
|
316
|
+
Upload a CSV, match its columns to fields, then import. Every row is created as a new {resource.label.toLowerCase()}; nothing
|
|
317
|
+
already saved is changed.
|
|
318
|
+
</DialogDescription>
|
|
319
|
+
</DialogHeader>
|
|
320
|
+
|
|
321
|
+
<div className="grid gap-6">
|
|
322
|
+
<Field data-invalid={fileError ? true : undefined}>
|
|
323
|
+
<FieldLabel htmlFor="import-file">CSV file</FieldLabel>
|
|
324
|
+
<Input id="import-file" type="file" accept=".csv,text/csv" disabled={running} onChange={onFile} />
|
|
325
|
+
</Field>
|
|
326
|
+
|
|
327
|
+
{fileError && (
|
|
328
|
+
<Alert variant="destructive">
|
|
329
|
+
<AlertDescription>{fileError}</AlertDescription>
|
|
330
|
+
</Alert>
|
|
331
|
+
)}
|
|
332
|
+
|
|
333
|
+
{rows.length > 0 && !summary && (
|
|
334
|
+
<>
|
|
335
|
+
<div className="grid gap-4">
|
|
336
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
337
|
+
<h3 className="text-sm font-medium">Match columns</h3>
|
|
338
|
+
<p className="text-sm text-muted-foreground">
|
|
339
|
+
{rows.length} {rows.length === 1 ? "row" : "rows"} in {fileName}
|
|
340
|
+
</p>
|
|
341
|
+
</div>
|
|
342
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
343
|
+
{fields.map(([key, def]) => (
|
|
344
|
+
<Field key={key}>
|
|
345
|
+
<FieldLabel htmlFor={`import-map-${key}`}>
|
|
346
|
+
{def.label}
|
|
347
|
+
{needsColumn(def) && (
|
|
348
|
+
<span className="ml-0.5 text-muted-foreground" aria-hidden>
|
|
349
|
+
*
|
|
350
|
+
</span>
|
|
351
|
+
)}
|
|
352
|
+
</FieldLabel>
|
|
353
|
+
<Select
|
|
354
|
+
value={mapping[key] ?? SKIP}
|
|
355
|
+
disabled={running}
|
|
356
|
+
onValueChange={(value) => setMapping((current) => ({ ...current, [key]: value }))}
|
|
357
|
+
>
|
|
358
|
+
<SelectTrigger id={`import-map-${key}`} className="w-full">
|
|
359
|
+
<SelectValue />
|
|
360
|
+
</SelectTrigger>
|
|
361
|
+
<SelectContent>
|
|
362
|
+
<SelectGroup>
|
|
363
|
+
<SelectItem value={SKIP}>Skip</SelectItem>
|
|
364
|
+
{headers.map((header, index) => (
|
|
365
|
+
<SelectItem key={index} value={String(index)}>
|
|
366
|
+
{header || `Column ${index + 1}`}
|
|
367
|
+
</SelectItem>
|
|
368
|
+
))}
|
|
369
|
+
</SelectGroup>
|
|
370
|
+
</SelectContent>
|
|
371
|
+
</Select>
|
|
372
|
+
</Field>
|
|
373
|
+
))}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{unmapped.length > 0 && (
|
|
378
|
+
<Alert variant="destructive">
|
|
379
|
+
<AlertTitle>Match every required field first</AlertTitle>
|
|
380
|
+
<AlertDescription>
|
|
381
|
+
{unmapped.map(([, def]) => def.label).join(", ")} {unmapped.length === 1 ? "has" : "have"} no column. Pick one for each,
|
|
382
|
+
or add the column to your file.
|
|
383
|
+
</AlertDescription>
|
|
384
|
+
</Alert>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{mapped.length > 0 && (
|
|
388
|
+
<div className="grid gap-2">
|
|
389
|
+
<h3 className="text-sm font-medium">Preview</h3>
|
|
390
|
+
<div className="overflow-x-auto rounded-md border">
|
|
391
|
+
<Table>
|
|
392
|
+
<TableHeader>
|
|
393
|
+
<TableRow>
|
|
394
|
+
{mapped.map(([key, def]) => (
|
|
395
|
+
<TableHead key={key}>{def.label}</TableHead>
|
|
396
|
+
))}
|
|
397
|
+
</TableRow>
|
|
398
|
+
</TableHeader>
|
|
399
|
+
<TableBody>
|
|
400
|
+
{rows.slice(0, PREVIEW_ROWS).map((cells, index) => (
|
|
401
|
+
<TableRow key={index}>
|
|
402
|
+
{mapped.map(([key]) => {
|
|
403
|
+
const cell = cells[Number(mapping[key])] ?? "";
|
|
404
|
+
return (
|
|
405
|
+
<TableCell key={key} className="max-w-48 truncate">
|
|
406
|
+
{cell || <span className="text-muted-foreground">—</span>}
|
|
407
|
+
</TableCell>
|
|
408
|
+
);
|
|
409
|
+
})}
|
|
410
|
+
</TableRow>
|
|
411
|
+
))}
|
|
412
|
+
</TableBody>
|
|
413
|
+
</Table>
|
|
414
|
+
</div>
|
|
415
|
+
{rows.length > PREVIEW_ROWS && (
|
|
416
|
+
<p className="text-sm text-muted-foreground">
|
|
417
|
+
Showing the first {PREVIEW_ROWS} of {rows.length} rows.
|
|
418
|
+
</p>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
|
|
425
|
+
{running && (
|
|
426
|
+
<div className="grid gap-2">
|
|
427
|
+
<Progress value={progress} aria-label={`Importing ${resource.pluralLabel.toLowerCase()}`} />
|
|
428
|
+
<p className="text-sm text-muted-foreground">Importing… {progress}% done. Leave this open until it finishes.</p>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
|
|
432
|
+
{importError && (
|
|
433
|
+
<Alert variant="destructive">
|
|
434
|
+
<AlertTitle>The import stopped</AlertTitle>
|
|
435
|
+
<AlertDescription>{importError}</AlertDescription>
|
|
436
|
+
</Alert>
|
|
437
|
+
)}
|
|
438
|
+
|
|
439
|
+
{summary && (
|
|
440
|
+
<div className="grid gap-4">
|
|
441
|
+
<p className="text-sm">
|
|
442
|
+
Imported {summary.created} of {summary.total} {summary.total === 1 ? "row" : "rows"}.
|
|
443
|
+
</p>
|
|
444
|
+
{summary.failures.length > 0 && (
|
|
445
|
+
<Alert variant="destructive">
|
|
446
|
+
<AlertTitle>
|
|
447
|
+
{summary.failures.length} {summary.failures.length === 1 ? "row was" : "rows were"} skipped
|
|
448
|
+
</AlertTitle>
|
|
449
|
+
<AlertDescription>
|
|
450
|
+
<ul className="grid gap-1">
|
|
451
|
+
{summary.failures.slice(0, LISTED_FAILURES).map((failure) => (
|
|
452
|
+
<li key={failure.line}>
|
|
453
|
+
Line {failure.line}: {failure.error}
|
|
454
|
+
</li>
|
|
455
|
+
))}
|
|
456
|
+
</ul>
|
|
457
|
+
{summary.failures.length > LISTED_FAILURES && (
|
|
458
|
+
<p>And {summary.failures.length - LISTED_FAILURES} more. Download the failed rows to see them all.</p>
|
|
459
|
+
)}
|
|
460
|
+
</AlertDescription>
|
|
461
|
+
</Alert>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<DialogFooter>
|
|
468
|
+
{summary && summary.failures.length > 0 && (
|
|
469
|
+
<Button variant="outline" onClick={() => downloadFailures(summary.failures)}>
|
|
470
|
+
<DownloadIcon data-icon="inline-start" />
|
|
471
|
+
Download failed rows
|
|
472
|
+
</Button>
|
|
473
|
+
)}
|
|
474
|
+
{summary ? (
|
|
475
|
+
<Button onClick={() => setOpen(false)}>Done</Button>
|
|
476
|
+
) : (
|
|
477
|
+
<Button disabled={!ready || running} onClick={runImport}>
|
|
478
|
+
{running && <Spinner data-icon="inline-start" />}
|
|
479
|
+
Import {rows.length > 0 ? `${rows.length} ` : ""}
|
|
480
|
+
{rows.length === 1 ? "row" : "rows"}
|
|
481
|
+
</Button>
|
|
482
|
+
)}
|
|
483
|
+
</DialogFooter>
|
|
484
|
+
</DialogContent>
|
|
485
|
+
</Dialog>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
export interface Crumb {
|
|
6
|
+
label: string;
|
|
7
|
+
href?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The top of every dashboard page: where you are, what this page is, and what you can
|
|
12
|
+
* do here. Every page uses it, so they all line up.
|
|
13
|
+
*/
|
|
14
|
+
export function PageHeader({
|
|
15
|
+
title,
|
|
16
|
+
description,
|
|
17
|
+
crumbs = [],
|
|
18
|
+
actions,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
title: React.ReactNode;
|
|
22
|
+
description?: React.ReactNode;
|
|
23
|
+
crumbs?: Crumb[];
|
|
24
|
+
/** Buttons for this page, right-aligned on a wide screen and below the title on a phone. */
|
|
25
|
+
actions?: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn("flex flex-col gap-3", className)}>
|
|
30
|
+
{crumbs.length > 0 && (
|
|
31
|
+
<nav aria-label="Breadcrumb">
|
|
32
|
+
<ol className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
|
33
|
+
{crumbs.map((crumb, index) => (
|
|
34
|
+
<li key={`${crumb.label}-${index}`} className="flex items-center gap-1">
|
|
35
|
+
{index > 0 && <ChevronRightIcon className="size-3.5 opacity-60" aria-hidden="true" />}
|
|
36
|
+
{crumb.href ? (
|
|
37
|
+
<Link href={crumb.href} className="hover:text-foreground">
|
|
38
|
+
{crumb.label}
|
|
39
|
+
</Link>
|
|
40
|
+
) : (
|
|
41
|
+
<span aria-current="page">{crumb.label}</span>
|
|
42
|
+
)}
|
|
43
|
+
</li>
|
|
44
|
+
))}
|
|
45
|
+
</ol>
|
|
46
|
+
</nav>
|
|
47
|
+
)}
|
|
48
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
49
|
+
<div className="flex flex-col gap-1">
|
|
50
|
+
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
51
|
+
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
52
|
+
</div>
|
|
53
|
+
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PlusIcon } from "lucide-react";
|
|
5
|
+
import type { Resource } from "@flaredev/core";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
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: Resource;
|
|
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 dialog, so adding a record doesn't lose the list, its
|
|
29
|
+
* filters or the place in it. Apps that would rather have a page set
|
|
30
|
+
* `site.dashboard.forms` to "page" (lib/site.ts) and get the same form full width.
|
|
31
|
+
*/
|
|
32
|
+
export function ResourceFormDialog({ resource, relations, listHref, mode, id, record, open, onOpenChange, trigger }: Props) {
|
|
33
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
34
|
+
const isOpen = open ?? internalOpen;
|
|
35
|
+
const setOpen = onOpenChange ?? setInternalOpen;
|
|
36
|
+
const noun = resource.label.toLowerCase();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog open={isOpen} onOpenChange={setOpen}>
|
|
40
|
+
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
|
41
|
+
<DialogContent className="sm:max-w-2xl">
|
|
42
|
+
<DialogHeader>
|
|
43
|
+
<DialogTitle>{mode === "create" ? `New ${noun}` : `Edit ${noun}`}</DialogTitle>
|
|
44
|
+
<DialogDescription>{mode === "create" ? `Fill this in and it appears in the list.` : "Changes save straight away."}</DialogDescription>
|
|
45
|
+
</DialogHeader>
|
|
46
|
+
{/* Remounting on open clears whatever was typed and abandoned last time. */}
|
|
47
|
+
{isOpen && (
|
|
48
|
+
<ResourceForm
|
|
49
|
+
key={`${mode}-${id ?? "new"}`}
|
|
50
|
+
resource={resource}
|
|
51
|
+
mode={mode}
|
|
52
|
+
id={id}
|
|
53
|
+
record={record}
|
|
54
|
+
relations={relations}
|
|
55
|
+
listHref={listHref}
|
|
56
|
+
onDone={() => setOpen(false)}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</DialogContent>
|
|
60
|
+
</Dialog>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The list page's "New …" button, when forms open in a dialog. */
|
|
65
|
+
export function NewRecordButton({ resource, relations, listHref }: { resource: Resource; relations: FormRelations; listHref: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<ResourceFormDialog
|
|
68
|
+
resource={resource}
|
|
69
|
+
relations={relations}
|
|
70
|
+
listHref={listHref}
|
|
71
|
+
mode="create"
|
|
72
|
+
trigger={
|
|
73
|
+
<Button>
|
|
74
|
+
<PlusIcon data-icon="inline-start" />
|
|
75
|
+
New {resource.label.toLowerCase()}
|
|
76
|
+
</Button>
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|