@flaredev/cli 0.13.0 → 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.
Files changed (33) hide show
  1. package/dist/index.js +72 -37
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/error.tsx +18 -5
  4. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  5. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  6. package/templates/app/app/error.tsx +14 -5
  7. package/templates/app/components/account/sections.tsx +23 -3
  8. package/templates/app/components/auth/password-field.tsx +51 -32
  9. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  10. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  11. package/templates/app/components/dashboard/cell-value.ts +67 -0
  12. package/templates/app/components/dashboard/dashboard-sidebar.tsx +2 -1
  13. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  14. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  15. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  16. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  17. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  18. package/templates/app/components/dashboard/user-actions.tsx +55 -9
  19. package/templates/app/components/dashboard/user-menu.tsx +1 -1
  20. package/templates/app/components/ui/combobox.tsx +133 -0
  21. package/templates/app/db/flare-schema.ts +27 -1
  22. package/templates/app/lib/api.ts +4 -1
  23. package/templates/app/lib/dashboard.ts +15 -4
  24. package/templates/app/lib/permissions.ts +105 -0
  25. package/templates/app/lib/stale-chunk.ts +50 -0
  26. package/templates/app/migrations/0000_init.sql +9 -0
  27. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  28. package/templates/app/migrations/meta/_journal.json +1 -1
  29. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  30. package/templates/locks/next/pnpm-lock.yaml +4 -4
  31. package/templates/next/lib/dashboard.ts +15 -4
  32. package/templates/next/lib/permissions.ts +100 -0
  33. package/templates/next/prisma/schema/base.prisma +31 -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
+ }
@@ -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, UsersIcon } 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 {
@@ -59,6 +59,7 @@ const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: Use
59
59
  // lib/dashboard-nav.ts; users and observability ship with every app.
60
60
  const ADMIN_LINKS = [
61
61
  { href: "/dashboard/users", label: "Users", icon: UsersIcon },
62
+ { href: "/dashboard/roles", label: "Roles", icon: ShieldIcon },
62
63
  { href: "/dashboard/observability", label: "Observability", icon: ActivityIcon },
63
64
  ];
64
65
 
@@ -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
- // No selection is `undefined`, so the placeholder shows; "None" clears an optional value.
181
- <Select value={text || undefined} onValueChange={(next) => onChange(next === NONE ? "" : next)} disabled={disabled} name={name}>
182
- <SelectTrigger id={id} aria-invalid={invalid || undefined} className="w-full">
183
- <SelectValue placeholder={field.placeholder ?? `Choose ${field.label.toLowerCase()}`} />
184
- </SelectTrigger>
185
- <SelectContent>
186
- <SelectGroup>
187
- {!field.required && <SelectItem value={NONE}>None</SelectItem>}
188
- {field.options.map((option) => (
189
- <SelectItem key={option} value={option}>
190
- {optionLabel(field, option)}
191
- </SelectItem>
192
- ))}
193
- </SelectGroup>
194
- </SelectContent>
195
- </Select>
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}
@@ -31,6 +31,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
31
31
  import { Spinner } from "@/components/ui/spinner";
32
32
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
33
33
  import { toCsv } from "@/lib/csv";
34
+ import { matchOption, squash, toValue } from "./cell-value";
34
35
  import { Dropzone } from "./dropzone";
35
36
  import { downloadCsv } from "./table-selection";
36
37
 
@@ -142,66 +143,8 @@ function parseCsv(text: string): string[][] {
142
143
 
143
144
  const csvCell = (value: string) => (/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value);
144
145
 
145
- const squash = (text: string) => text.toLowerCase().replace(/[\s_-]+/g, "");
146
-
147
- /** A field must be mapped when nothing else can supply it: required, with no default. */
148
146
  const needsColumn = (def: StoredField) => def.required && !("default" in def && def.default !== undefined);
149
147
 
150
- /** An option from a cell holding either the stored value or the label shown in the admin. */
151
- function matchOption(def: EnumField | MultiSelectField, text: string): string {
152
- const match = def.options.find(
153
- (option) => option === text || squash(option) === squash(text) || squash(optionLabel(def, option)) === squash(text),
154
- );
155
- return match ?? text;
156
- }
157
-
158
- /**
159
- * A cell as the value its field expects. Text that doesn't convert is passed through
160
- * unchanged so validation reports it against the field, rather than the import guessing.
161
- */
162
- function toValue(def: StoredField, raw: string): unknown {
163
- const text = raw.trim();
164
- if (text === "") return undefined;
165
- switch (def.kind) {
166
- case "int":
167
- case "float": {
168
- const number = Number(text);
169
- return Number.isFinite(number) ? number : text;
170
- }
171
- case "boolean": {
172
- const lower = text.toLowerCase();
173
- if (["true", "yes", "1"].includes(lower)) return true;
174
- if (["false", "no", "0"].includes(lower)) return false;
175
- return text;
176
- }
177
- case "enum":
178
- return matchOption(def, text);
179
- case "multiselect":
180
- return text
181
- .split(/[;,]/)
182
- .map((part) => part.trim())
183
- .filter(Boolean)
184
- .map((part) => matchOption(def, part));
185
- case "tags":
186
- // Semicolons first so a tag may contain a comma: "a, b; c" is two tags.
187
- return text
188
- .split(text.includes(";") ? ";" : ",")
189
- .map((part) => part.trim())
190
- .filter(Boolean);
191
- case "json":
192
- try {
193
- return JSON.parse(text);
194
- } catch {
195
- // Handed over as typed: the validator reports it against the row and the column,
196
- // which is more use than this throwing halfway through a file.
197
- return text;
198
- }
199
- default:
200
- // Dates keep their text: the descriptor's schema decides which formats it takes.
201
- return text;
202
- }
203
- }
204
-
205
148
  /** A column per field, matched on header text; anything unrecognised starts as Skip. */
206
149
  function guessMapping(fields: MappedField[], headers: string[]): Record<string, string> {
207
150
  const taken = new Set<number>();
@@ -25,6 +25,7 @@ import { TrashToggle } from "./trash-toggle";
25
25
  import { isInlineEditable } from "./editable";
26
26
  import { InlineCell } from "./inline-cell";
27
27
  import type { RelationMeta } from "./fields/field-widget";
28
+ import { BulkAddSheet } from "./bulk-add-sheet";
28
29
  import { ImportDialog } from "./import-dialog";
29
30
  import { LocalTime } from "./local-time";
30
31
  import { hrefWith, toSearchParams, type SearchParams } from "./query";
@@ -125,6 +126,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
125
126
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
126
127
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
127
128
  {permissions.create && <ImportDialog resource={forClient} />}
129
+ {permissions.create && <BulkAddSheet resource={forClient} label={resource.label} />}
128
130
  {permissions.create &&
129
131
  (overlayForms ? (
130
132
  <NewRecordButton resource={forClient} relations={relations} listHref={basePath} omit={omit} />