@flaredev/cli 0.12.2 → 0.14.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 +126 -57
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +217 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +20 -5
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/README.md +1 -1
- package/templates/next/app/dashboard/layout.tsx +2 -2
- package/templates/next/app/dashboard/observability/page.tsx +156 -0
- package/templates/next/app/dashboard/page.tsx +87 -23
- package/templates/next/app/dashboard/users/page.tsx +51 -0
- package/templates/next/lib/auth.ts +3 -3
- package/templates/next/lib/dashboard-nav.ts +29 -0
- package/templates/next/lib/dashboard.ts +20 -5
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +37 -0
- package/templates/next/tsconfig.json +2 -2
- /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
- /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
- /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
- /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
- /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
- /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
- /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useCallback, useMemo, useRef, useState, useTransition, type ClipboardEvent, type KeyboardEvent } from "react";
|
|
5
|
+
import { PlusIcon, RowsIcon, Trash2Icon, TriangleAlertIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import {
|
|
8
|
+
createValidators,
|
|
9
|
+
issuesByField,
|
|
10
|
+
optionLabel,
|
|
11
|
+
storedFields,
|
|
12
|
+
type ClientResource,
|
|
13
|
+
type FileField as FileFieldDef,
|
|
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 { Checkbox } from "@/components/ui/checkbox";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
21
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
|
22
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
24
|
+
import { toValue } from "./cell-value";
|
|
25
|
+
import { FileField } from "./fields/file-field";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adding many records at once, as a spreadsheet.
|
|
29
|
+
*
|
|
30
|
+
* The form is right for one record and wrong for forty: it asks for a save per record,
|
|
31
|
+
* it scrolls, and it cannot be pasted into. People who have forty records already have
|
|
32
|
+
* them in Excel, so this is a grid they can paste into, tab across, and submit once.
|
|
33
|
+
*
|
|
34
|
+
* Three things it does not do, deliberately. It does not define its own validation —
|
|
35
|
+
* the cells are checked against the same validators the form and the API use, so a row
|
|
36
|
+
* that passes here cannot fail there for a different reason. It does not write records
|
|
37
|
+
* itself — it hands rows to `importRecordsAction`, which is the same path the CSV
|
|
38
|
+
* import takes, so hooks, policies and ownership all apply. And it does not coerce text
|
|
39
|
+
* its own way: {@link toValue} is shared with the import, so "yes", "1" and "Lead" mean
|
|
40
|
+
* the same in both.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const BLANK_ROWS = 8;
|
|
44
|
+
/** Matches the per-batch cap in import-actions.ts; the sheet stops you before the server does. */
|
|
45
|
+
const MAX_ROWS = 500;
|
|
46
|
+
|
|
47
|
+
type Row = Record<string, string>;
|
|
48
|
+
|
|
49
|
+
interface CellError {
|
|
50
|
+
row: number;
|
|
51
|
+
field: string;
|
|
52
|
+
message: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every stored column.
|
|
57
|
+
*
|
|
58
|
+
* File columns included: a clipboard cannot carry files, so they stay empty on a paste
|
|
59
|
+
* and are attached per row afterwards — which is the real flow anyway, since the text
|
|
60
|
+
* came from a spreadsheet and the files came from a folder.
|
|
61
|
+
*/
|
|
62
|
+
function bulkColumns(resource: ClientResource): [string, StoredField & { label: string }][] {
|
|
63
|
+
return storedFields(resource);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A short hint under the column name, so the grid says what it will accept. */
|
|
67
|
+
function columnHint(def: StoredField & { label: string }): string {
|
|
68
|
+
switch (def.kind) {
|
|
69
|
+
case "boolean":
|
|
70
|
+
return "yes / no";
|
|
71
|
+
case "enum":
|
|
72
|
+
return def.options.slice(0, 3).map((option) => optionLabel(def, option)).join(" · ") + (def.options.length > 3 ? " …" : "");
|
|
73
|
+
case "multiselect":
|
|
74
|
+
case "tags":
|
|
75
|
+
return "separate with ;";
|
|
76
|
+
case "date":
|
|
77
|
+
return "YYYY-MM-DD";
|
|
78
|
+
case "file":
|
|
79
|
+
return "attach per row";
|
|
80
|
+
case "json":
|
|
81
|
+
return "JSON";
|
|
82
|
+
case "int":
|
|
83
|
+
case "float":
|
|
84
|
+
return "number";
|
|
85
|
+
default:
|
|
86
|
+
return def.required ? "required" : "";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function BulkAddSheet({ resource, label }: { resource: ClientResource; label: string }) {
|
|
91
|
+
const router = useRouter();
|
|
92
|
+
const [open, setOpen] = useState(false);
|
|
93
|
+
const [rows, setRows] = useState<Row[]>(() => Array.from({ length: BLANK_ROWS }, () => ({})));
|
|
94
|
+
const [errors, setErrors] = useState<CellError[]>([]);
|
|
95
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
96
|
+
const [pending, startTransition] = useTransition();
|
|
97
|
+
const gridRef = useRef<HTMLTableSectionElement>(null);
|
|
98
|
+
|
|
99
|
+
const columns = useMemo(() => bulkColumns(resource), [resource]);
|
|
100
|
+
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
101
|
+
|
|
102
|
+
const errorAt = useCallback(
|
|
103
|
+
(row: number, field: string) => errors.find((issue) => issue.row === row && issue.field === field)?.message,
|
|
104
|
+
[errors],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const setCell = (row: number, key: string, value: string) => {
|
|
108
|
+
setRows((current) => {
|
|
109
|
+
const next = current.slice();
|
|
110
|
+
next[row] = { ...next[row], [key]: value };
|
|
111
|
+
return next;
|
|
112
|
+
});
|
|
113
|
+
// Clear this cell's error as soon as it is touched; re-checked on submit.
|
|
114
|
+
setErrors((current) => current.filter((issue) => !(issue.row === row && issue.field === key)));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const addRows = (count: number) => setRows((current) => [...current, ...Array.from({ length: count }, () => ({}) as Row)]);
|
|
118
|
+
const removeRow = (row: number) => {
|
|
119
|
+
setRows((current) => (current.length === 1 ? [{}] : current.filter((_, index) => index !== row)));
|
|
120
|
+
setErrors((current) => current.filter((issue) => issue.row !== row));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** A row with nothing in it is not a record; it is the empty space under your data. */
|
|
124
|
+
const filled = (row: Row) => Object.values(row).some((value) => value.trim() !== "");
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Paste from a spreadsheet.
|
|
128
|
+
*
|
|
129
|
+
* Excel and Sheets put tab-separated rows on the clipboard, so a block pasted into a
|
|
130
|
+
* cell fills from that cell down and right, growing the grid if it needs to. Pasting
|
|
131
|
+
* a single value falls through to the browser's own handling.
|
|
132
|
+
*/
|
|
133
|
+
const paste = (event: ClipboardEvent<HTMLInputElement>, row: number, column: number) => {
|
|
134
|
+
const text = event.clipboardData.getData("text/plain");
|
|
135
|
+
if (!text.includes("\t") && !text.includes("\n")) return;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
|
|
138
|
+
const grid = text
|
|
139
|
+
.replace(/\r\n/g, "\n")
|
|
140
|
+
.replace(/\n+$/, "")
|
|
141
|
+
.split("\n")
|
|
142
|
+
.map((line) => line.split("\t"));
|
|
143
|
+
|
|
144
|
+
setRows((current) => {
|
|
145
|
+
const next = current.slice();
|
|
146
|
+
while (next.length < row + grid.length) next.push({});
|
|
147
|
+
grid.forEach((cells, rowOffset) => {
|
|
148
|
+
const target = { ...next[row + rowOffset] };
|
|
149
|
+
cells.forEach((cell, columnOffset) => {
|
|
150
|
+
const key = columns[column + columnOffset]?.[0];
|
|
151
|
+
if (key) target[key] = cell;
|
|
152
|
+
});
|
|
153
|
+
next[row + rowOffset] = target;
|
|
154
|
+
});
|
|
155
|
+
return next.slice(0, MAX_ROWS);
|
|
156
|
+
});
|
|
157
|
+
setErrors([]);
|
|
158
|
+
toast.success(`Pasted ${grid.length} ${grid.length === 1 ? "row" : "rows"}.`);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Enter moves down a row, which is what a spreadsheet does. Tab is the browser's. */
|
|
162
|
+
const navigate = (event: KeyboardEvent<HTMLInputElement>, row: number, column: number) => {
|
|
163
|
+
if (event.key !== "Enter") return;
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
const selector = `[data-cell="${row + 1}-${column}"]`;
|
|
166
|
+
const below = gridRef.current?.querySelector<HTMLInputElement>(selector);
|
|
167
|
+
if (below) below.focus();
|
|
168
|
+
else {
|
|
169
|
+
addRows(1);
|
|
170
|
+
// The row does not exist until React has rendered it.
|
|
171
|
+
requestAnimationFrame(() => gridRef.current?.querySelector<HTMLInputElement>(selector)?.focus());
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const submit = () => {
|
|
176
|
+
const candidates = rows.map((row, index) => ({ row, index })).filter((entry) => filled(entry.row));
|
|
177
|
+
if (candidates.length === 0) {
|
|
178
|
+
toast.error("Nothing to add — the grid is empty.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Checked here against the same validators the form and the API use, so a row that
|
|
183
|
+
// passes this grid cannot fail later for a reason this grid could have told you.
|
|
184
|
+
const found: CellError[] = [];
|
|
185
|
+
const payload: Record<string, unknown>[] = [];
|
|
186
|
+
for (const { row, index } of candidates) {
|
|
187
|
+
const values: Record<string, unknown> = {};
|
|
188
|
+
for (const [key, def] of columns) {
|
|
189
|
+
const value = toValue(def, row[key] ?? "");
|
|
190
|
+
if (value !== undefined) values[key] = value;
|
|
191
|
+
}
|
|
192
|
+
const result = validators.create.safeParse(values);
|
|
193
|
+
if (result.success) {
|
|
194
|
+
payload.push(result.data as Record<string, unknown>);
|
|
195
|
+
} else {
|
|
196
|
+
// issuesByField gives one message per field, not a list — indexing it would
|
|
197
|
+
// hand the cell the first *character* of "Required".
|
|
198
|
+
for (const [field, message] of Object.entries(issuesByField(result.error.issues))) {
|
|
199
|
+
found.push({ row: index, field, message });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (found.length > 0) {
|
|
205
|
+
setErrors(found);
|
|
206
|
+
setFailure(null);
|
|
207
|
+
const rowsWithErrors = new Set(found.map((issue) => issue.row)).size;
|
|
208
|
+
toast.error(`${rowsWithErrors} ${rowsWithErrors === 1 ? "row needs" : "rows need"} fixing. Nothing was added.`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
startTransition(async () => {
|
|
213
|
+
const result = await importRecordsAction(resource.name, payload);
|
|
214
|
+
if (!result.ok) {
|
|
215
|
+
setFailure(result.error);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const { created, failed } = result.data;
|
|
219
|
+
if (failed.length > 0) {
|
|
220
|
+
// Row numbers come back 1-based over the rows we sent, which are the filled ones.
|
|
221
|
+
setErrors(failed.map((entry) => ({ row: candidates[entry.row - 1]?.index ?? 0, field: entry.field ?? "", message: entry.error })));
|
|
222
|
+
setFailure(`Added ${created}. ${failed.length} ${failed.length === 1 ? "row" : "rows"} were refused — see below.`);
|
|
223
|
+
router.refresh();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
toast.success(`Added ${created} ${created === 1 ? label.toLowerCase() : resource.pluralLabel.toLowerCase()}.`);
|
|
227
|
+
setRows(Array.from({ length: BLANK_ROWS }, () => ({})));
|
|
228
|
+
setErrors([]);
|
|
229
|
+
setFailure(null);
|
|
230
|
+
setOpen(false);
|
|
231
|
+
router.refresh();
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const filledCount = rows.filter(filled).length;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
239
|
+
<SheetTrigger asChild>
|
|
240
|
+
<Button variant="outline">
|
|
241
|
+
<RowsIcon data-icon="inline-start" />
|
|
242
|
+
Bulk add
|
|
243
|
+
</Button>
|
|
244
|
+
</SheetTrigger>
|
|
245
|
+
<SheetContent side="bottom" className="flex h-[92vh] flex-col gap-0 p-0 sm:max-w-none">
|
|
246
|
+
<SheetHeader className="border-b px-6 py-4">
|
|
247
|
+
<SheetTitle>Add many {resource.pluralLabel.toLowerCase()}</SheetTitle>
|
|
248
|
+
<SheetDescription>
|
|
249
|
+
Type, or paste a block straight from a spreadsheet. Enter moves down, Tab moves across. Nothing is saved until you add
|
|
250
|
+
them.
|
|
251
|
+
</SheetDescription>
|
|
252
|
+
</SheetHeader>
|
|
253
|
+
|
|
254
|
+
<div className="min-h-0 flex-1 overflow-auto px-6 py-4">
|
|
255
|
+
{failure && (
|
|
256
|
+
<Alert variant="destructive" className="mb-4">
|
|
257
|
+
<TriangleAlertIcon />
|
|
258
|
+
<AlertTitle>Not everything went in</AlertTitle>
|
|
259
|
+
<AlertDescription>{failure}</AlertDescription>
|
|
260
|
+
</Alert>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
<table className="w-full border-separate border-spacing-0 text-sm">
|
|
264
|
+
<thead className="sticky top-0 z-10 bg-background">
|
|
265
|
+
<tr>
|
|
266
|
+
<th className="w-10 border-b px-2 py-2 text-left font-normal text-muted-foreground">#</th>
|
|
267
|
+
{columns.map(([key, def]) => (
|
|
268
|
+
<th key={key} className="border-b px-2 py-2 text-left align-bottom">
|
|
269
|
+
<span className="block font-medium">
|
|
270
|
+
{def.label}
|
|
271
|
+
{def.required && <span className="text-destructive"> *</span>}
|
|
272
|
+
</span>
|
|
273
|
+
{columnHint(def) && <span className="block text-xs font-normal text-muted-foreground">{columnHint(def)}</span>}
|
|
274
|
+
</th>
|
|
275
|
+
))}
|
|
276
|
+
<th className="w-10 border-b" />
|
|
277
|
+
</tr>
|
|
278
|
+
</thead>
|
|
279
|
+
<tbody ref={gridRef}>
|
|
280
|
+
{rows.map((row, rowIndex) => (
|
|
281
|
+
<tr key={rowIndex} className="group">
|
|
282
|
+
<td className="border-b px-2 py-1 text-xs tabular-nums text-muted-foreground">{rowIndex + 1}</td>
|
|
283
|
+
{columns.map(([key, def], columnIndex) => {
|
|
284
|
+
const message = errorAt(rowIndex, key);
|
|
285
|
+
return (
|
|
286
|
+
<td key={key} className="border-b p-0 align-top">
|
|
287
|
+
{def.kind === "file" ? (
|
|
288
|
+
<div className="min-w-44">
|
|
289
|
+
<FileField
|
|
290
|
+
compact
|
|
291
|
+
id={`bulk-${resource.name}-${key}-${rowIndex}`}
|
|
292
|
+
field={def as FileFieldDef & { label: string }}
|
|
293
|
+
value={row[key] ?? ""}
|
|
294
|
+
onChange={(next) => setCell(rowIndex, key, typeof next === "string" ? next : "")}
|
|
295
|
+
invalid={Boolean(message)}
|
|
296
|
+
resourceName={resource.name}
|
|
297
|
+
fieldKey={key}
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
) : def.kind === "boolean" ? (
|
|
301
|
+
<div className="flex h-9 items-center px-2">
|
|
302
|
+
<Checkbox
|
|
303
|
+
checked={["true", "yes", "1"].includes((row[key] ?? "").toLowerCase())}
|
|
304
|
+
onCheckedChange={(next) => setCell(rowIndex, key, next === true ? "true" : "false")}
|
|
305
|
+
aria-label={`${def.label}, row ${rowIndex + 1}`}
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
) : (
|
|
309
|
+
<Input
|
|
310
|
+
data-cell={`${rowIndex}-${columnIndex}`}
|
|
311
|
+
value={row[key] ?? ""}
|
|
312
|
+
onChange={(event) => setCell(rowIndex, key, event.target.value)}
|
|
313
|
+
onPaste={(event) => paste(event, rowIndex, columnIndex)}
|
|
314
|
+
onKeyDown={(event) => navigate(event, rowIndex, columnIndex)}
|
|
315
|
+
aria-label={`${def.label}, row ${rowIndex + 1}`}
|
|
316
|
+
aria-invalid={Boolean(message) || undefined}
|
|
317
|
+
title={message}
|
|
318
|
+
list={def.kind === "enum" ? `${resource.name}-${key}-options` : undefined}
|
|
319
|
+
className={cn(
|
|
320
|
+
"h-9 rounded-none border-0 border-r shadow-none focus-visible:ring-1 focus-visible:ring-inset",
|
|
321
|
+
message && "bg-destructive/5 text-destructive",
|
|
322
|
+
)}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
{message && <span className="block px-2 pb-1 text-xs text-destructive">{message}</span>}
|
|
326
|
+
</td>
|
|
327
|
+
);
|
|
328
|
+
})}
|
|
329
|
+
<td className="border-b px-1 text-center align-middle">
|
|
330
|
+
<Button
|
|
331
|
+
variant="ghost"
|
|
332
|
+
size="icon"
|
|
333
|
+
className="size-7 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
|
334
|
+
aria-label={`Remove row ${rowIndex + 1}`}
|
|
335
|
+
onClick={() => removeRow(rowIndex)}
|
|
336
|
+
>
|
|
337
|
+
<Trash2Icon />
|
|
338
|
+
</Button>
|
|
339
|
+
</td>
|
|
340
|
+
</tr>
|
|
341
|
+
))}
|
|
342
|
+
</tbody>
|
|
343
|
+
</table>
|
|
344
|
+
|
|
345
|
+
{/* Enum options, offered to every cell in the column without a dropdown per row. */}
|
|
346
|
+
{columns
|
|
347
|
+
.filter(([, def]) => def.kind === "enum")
|
|
348
|
+
.map(([key, def]) => (
|
|
349
|
+
<datalist key={key} id={`${resource.name}-${key}-options`}>
|
|
350
|
+
{"options" in def && def.options.map((option) => <option key={option} value={optionLabel(def, option)} />)}
|
|
351
|
+
</datalist>
|
|
352
|
+
))}
|
|
353
|
+
|
|
354
|
+
<Button variant="outline" size="sm" className="mt-3" onClick={() => addRows(5)} disabled={rows.length >= MAX_ROWS}>
|
|
355
|
+
<PlusIcon data-icon="inline-start" />
|
|
356
|
+
Add 5 rows
|
|
357
|
+
</Button>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div className="flex items-center justify-between gap-3 border-t px-6 py-4">
|
|
361
|
+
<span className="text-sm text-muted-foreground">
|
|
362
|
+
{filledCount} {filledCount === 1 ? "row" : "rows"} ready{rows.length >= MAX_ROWS ? ` · ${MAX_ROWS} is the most per batch` : ""}
|
|
363
|
+
</span>
|
|
364
|
+
<div className="flex gap-2">
|
|
365
|
+
<Button variant="ghost" onClick={() => setOpen(false)} disabled={pending}>
|
|
366
|
+
Cancel
|
|
367
|
+
</Button>
|
|
368
|
+
<Button onClick={submit} disabled={pending || filledCount === 0}>
|
|
369
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
370
|
+
Add {filledCount > 0 ? filledCount : ""} {filledCount === 1 ? label.toLowerCase() : resource.pluralLabel.toLowerCase()}
|
|
371
|
+
</Button>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</SheetContent>
|
|
375
|
+
</Sheet>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { optionLabel, type EnumField, type MultiSelectField, type StoredField } from "@flaredev/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turning typed or pasted text into the value a field expects.
|
|
5
|
+
*
|
|
6
|
+
* Shared by the CSV import and the bulk-add sheet, deliberately: both take text a
|
|
7
|
+
* person produced in a spreadsheet, and if they disagreed about what "yes", "1" or
|
|
8
|
+
* "Lead" means, the same file would import one way and paste another.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Comparison that ignores case, spaces, dashes and underscores. */
|
|
12
|
+
export const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
|
|
13
|
+
|
|
14
|
+
/** An option from a cell holding either the stored value or the label shown in the admin. */
|
|
15
|
+
export function matchOption(def: EnumField | MultiSelectField, text: string): string {
|
|
16
|
+
const match = def.options.find(
|
|
17
|
+
(option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
|
|
18
|
+
);
|
|
19
|
+
return match ?? text;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A cell as the value its field expects. Text that doesn't convert is passed through
|
|
24
|
+
* unchanged so validation reports it against the field, rather than guessing.
|
|
25
|
+
*/
|
|
26
|
+
export function toValue(def: StoredField, raw: string): unknown {
|
|
27
|
+
const text = raw.trim();
|
|
28
|
+
if (text === "") return undefined;
|
|
29
|
+
switch (def.kind) {
|
|
30
|
+
case "int":
|
|
31
|
+
case "float": {
|
|
32
|
+
const number = Number(text);
|
|
33
|
+
return Number.isFinite(number) ? number : text;
|
|
34
|
+
}
|
|
35
|
+
case "boolean": {
|
|
36
|
+
const lower = text.toLowerCase();
|
|
37
|
+
if (["true", "yes", "1"].includes(lower)) return true;
|
|
38
|
+
if (["false", "no", "0"].includes(lower)) return false;
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
case "enum":
|
|
42
|
+
return matchOption(def, text);
|
|
43
|
+
case "multiselect":
|
|
44
|
+
return text
|
|
45
|
+
.split(/[;,]/)
|
|
46
|
+
.map((part) => part.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((part) => matchOption(def, part));
|
|
49
|
+
case "tags":
|
|
50
|
+
// Semicolons first so a tag may contain a comma: "a, b; c" is two tags.
|
|
51
|
+
return text
|
|
52
|
+
.split(text.includes(";") ? ";" : ",")
|
|
53
|
+
.map((part) => part.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
case "json":
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
// Handed over as typed: the validator reports it against the row and the column,
|
|
60
|
+
// which is more use than this throwing halfway through.
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
// Dates keep their text: the descriptor's schema decides which formats it takes.
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -30,7 +30,7 @@ function applyTheme(theme: Theme) {
|
|
|
30
30
|
if (theme !== "system") root.classList.add(theme);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function DashboardHeader({ user, initialTheme, notices = [] }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[] }) {
|
|
33
|
+
export function DashboardHeader({ user, initialTheme, notices = [], isAdmin = false }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[]; isAdmin?: boolean }) {
|
|
34
34
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
|
35
35
|
const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
36
36
|
const needsAttention = notices.some((notice) => notice.tone === "warning");
|
|
@@ -106,7 +106,7 @@ export function DashboardHeader({ user, initialTheme, notices = [] }: { user: Da
|
|
|
106
106
|
</Button>
|
|
107
107
|
</DropdownMenuTrigger>
|
|
108
108
|
<DropdownMenuContent align="end" className="w-60">
|
|
109
|
-
<UserMenuItems user={user} />
|
|
109
|
+
<UserMenuItems user={user} isAdmin={isAdmin} />
|
|
110
110
|
</DropdownMenuContent>
|
|
111
111
|
</DropdownMenu>
|
|
112
112
|
</div>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
5
|
-
import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } from "lucide-react";
|
|
5
|
+
import { ActivityIcon, LayoutDashboardIcon, ShieldIcon, UserCircleIcon, UsersIcon } from "lucide-react";
|
|
6
6
|
import { ChevronsUpDownIcon } from "lucide-react";
|
|
7
7
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
8
8
|
import {
|
|
@@ -56,8 +56,12 @@ function byGroup(resources: NavResource[]): [string, NavResource[]][] {
|
|
|
56
56
|
/** Sections every dashboard has, whatever resources the app defines. */
|
|
57
57
|
const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
|
|
58
58
|
// Security has its own page once `flare gen security` runs, which adds its link to
|
|
59
|
-
// lib/dashboard-nav.ts; observability
|
|
60
|
-
const ADMIN_LINKS = [
|
|
59
|
+
// lib/dashboard-nav.ts; users and observability ship with every app.
|
|
60
|
+
const ADMIN_LINKS = [
|
|
61
|
+
{ href: "/dashboard/users", label: "Users", icon: UsersIcon },
|
|
62
|
+
{ href: "/dashboard/roles", label: "Roles", icon: ShieldIcon },
|
|
63
|
+
{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon },
|
|
64
|
+
];
|
|
61
65
|
|
|
62
66
|
export function DashboardSidebar({
|
|
63
67
|
appName,
|
|
@@ -113,8 +117,8 @@ export function DashboardSidebar({
|
|
|
113
117
|
</SidebarGroup>
|
|
114
118
|
|
|
115
119
|
{byGroup(resources).map(([heading, group]) => (
|
|
116
|
-
<SidebarGroup key={heading} className="px-2 py-1">
|
|
117
|
-
<SidebarGroupLabel className="mt-1 h-6">{heading}</SidebarGroupLabel>
|
|
120
|
+
<SidebarGroup key={heading} className="border-t px-2 py-1">
|
|
121
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">{heading}</SidebarGroupLabel>
|
|
118
122
|
<SidebarGroupContent>
|
|
119
123
|
<SidebarMenu>
|
|
120
124
|
{group.map((resource) => {
|
|
@@ -149,8 +153,8 @@ export function DashboardSidebar({
|
|
|
149
153
|
</SidebarGroup>
|
|
150
154
|
))}
|
|
151
155
|
|
|
152
|
-
<SidebarGroup className="px-2 py-1">
|
|
153
|
-
<SidebarGroupLabel className="mt-1 h-6">Manage</SidebarGroupLabel>
|
|
156
|
+
<SidebarGroup className="border-t px-2 py-1">
|
|
157
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Manage</SidebarGroupLabel>
|
|
154
158
|
<SidebarGroupContent>
|
|
155
159
|
<SidebarMenu>
|
|
156
160
|
{manage.map((link) => (
|
|
@@ -168,8 +172,8 @@ export function DashboardSidebar({
|
|
|
168
172
|
</SidebarGroup>
|
|
169
173
|
|
|
170
174
|
{links.length > 0 && (
|
|
171
|
-
<SidebarGroup className="px-2 py-1">
|
|
172
|
-
<SidebarGroupLabel className="mt-1 h-6">Platform</SidebarGroupLabel>
|
|
175
|
+
<SidebarGroup className="border-t px-2 py-1">
|
|
176
|
+
<SidebarGroupLabel className="mt-1 h-6 text-[11px] font-medium uppercase tracking-wider">Platform</SidebarGroupLabel>
|
|
173
177
|
<SidebarGroupContent>
|
|
174
178
|
<SidebarMenu>
|
|
175
179
|
{links.map((link) => {
|
|
@@ -217,7 +221,7 @@ export function DashboardSidebar({
|
|
|
217
221
|
</SidebarMenuButton>
|
|
218
222
|
</DropdownMenuTrigger>
|
|
219
223
|
<DropdownMenuContent side="top" align="start" className="w-60">
|
|
220
|
-
<UserMenuItems user={user} />
|
|
224
|
+
<UserMenuItems user={user} isAdmin={isAdmin} />
|
|
221
225
|
</DropdownMenuContent>
|
|
222
226
|
</DropdownMenu>
|
|
223
227
|
</SidebarMenuItem>
|
|
@@ -24,6 +24,7 @@ import { CountryField } from "./country-field";
|
|
|
24
24
|
import { FileField } from "./file-field";
|
|
25
25
|
import { JsonField } from "./json-field";
|
|
26
26
|
import { MarkdownField } from "./markdown-field";
|
|
27
|
+
import { Combobox } from "@/components/ui/combobox";
|
|
27
28
|
import { MultiSelectField } from "./multi-select-field";
|
|
28
29
|
import { TagsField } from "./tags-field";
|
|
29
30
|
import { PhoneField } from "./phone-field";
|
|
@@ -177,22 +178,25 @@ export function FieldWidget(props: WidgetProps) {
|
|
|
177
178
|
);
|
|
178
179
|
}
|
|
179
180
|
return (
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
181
|
+
// Searchable, like every other "choose one" in the app. An enum with six
|
|
182
|
+
// options is a list you scan; one with sixty is a list you search, and the
|
|
183
|
+
// descriptor does not say which you have — so the field is always typeable.
|
|
184
|
+
// "None" clears an optional value.
|
|
185
|
+
<Combobox
|
|
186
|
+
id={id}
|
|
187
|
+
name={name}
|
|
188
|
+
value={text}
|
|
189
|
+
onChange={(next) => onChange(next === NONE ? "" : next)}
|
|
190
|
+
options={[
|
|
191
|
+
...(field.required ? [] : [{ value: NONE, label: "None" }]),
|
|
192
|
+
...field.options.map((option) => ({ value: option, label: optionLabel(field, option) })),
|
|
193
|
+
]}
|
|
194
|
+
placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`}
|
|
195
|
+
searchPlaceholder={`Search ${field.label.toLowerCase()}…`}
|
|
196
|
+
emptyMessage="No match."
|
|
197
|
+
disabled={disabled}
|
|
198
|
+
invalid={invalid}
|
|
199
|
+
/>
|
|
196
200
|
);
|
|
197
201
|
|
|
198
202
|
case "multiselect":
|
|
@@ -149,7 +149,7 @@ function put(url: string, file: Prepared, onProgress: (loaded: number) => void,
|
|
|
149
149
|
* first (the server repeats every check), uploads straight to storage through a signed
|
|
150
150
|
* URL with progress, and stores the object key in the form.
|
|
151
151
|
*/
|
|
152
|
-
export function FileField({ id, value, onChange, invalid, disabled, field, resourceName, fieldKey }: Props) {
|
|
152
|
+
export function FileField({ id, value, onChange, invalid, disabled, field, resourceName, fieldKey, compact = false }: Props & { compact?: boolean }) {
|
|
153
153
|
const input = useRef<HTMLInputElement>(null);
|
|
154
154
|
const helpId = useId();
|
|
155
155
|
const instructionId = useId();
|
|
@@ -384,6 +384,28 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
|
|
|
384
384
|
)}
|
|
385
385
|
</div>
|
|
386
386
|
</div>
|
|
387
|
+
) : compact ? (
|
|
388
|
+
// One line, for a cell in the bulk-add grid: a dropzone per row would make
|
|
389
|
+
// every row as tall as a paragraph and the sheet stop looking like a sheet.
|
|
390
|
+
// Same upload path, same checks — only the control is smaller.
|
|
391
|
+
<button
|
|
392
|
+
id={id}
|
|
393
|
+
type="button"
|
|
394
|
+
{...dropHandlers}
|
|
395
|
+
onClick={choose}
|
|
396
|
+
disabled={disabled}
|
|
397
|
+
aria-label={`Attach ${field.label.toLowerCase()}`}
|
|
398
|
+
aria-describedby={helpId}
|
|
399
|
+
aria-invalid={invalid || Boolean(error) || undefined}
|
|
400
|
+
className={cn(
|
|
401
|
+
"flex h-9 w-full items-center gap-2 border-r px-2 text-left text-sm text-muted-foreground outline-none transition-colors",
|
|
402
|
+
"hover:bg-muted/50 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
403
|
+
dragging ? "bg-primary/5 text-foreground" : (invalid || error) && "text-destructive",
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
<UploadIcon aria-hidden="true" className="size-4 shrink-0" />
|
|
407
|
+
<span className="truncate">{dragging ? "Drop it" : "Attach"}</span>
|
|
408
|
+
</button>
|
|
387
409
|
) : (
|
|
388
410
|
<button
|
|
389
411
|
id={id}
|