@flaredev/cli 0.3.0 → 0.4.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 +385 -209
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +35 -0
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
- package/templates/app/components/dashboard/import-dialog.tsx +60 -7
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +200 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table.tsx +405 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +3 -3
- package/templates/locks/pnpm-lock.yaml +4 -4
- package/templates/locks/yarn.lock +3548 -0
|
@@ -26,11 +26,48 @@ import {
|
|
|
26
26
|
DialogTrigger,
|
|
27
27
|
} from "@/components/ui/dialog";
|
|
28
28
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
29
|
-
import { Input } from "@/components/ui/input";
|
|
30
29
|
import { Progress } from "@/components/ui/progress";
|
|
31
30
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
32
31
|
import { Spinner } from "@/components/ui/spinner";
|
|
33
32
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
33
|
+
import { toCsv } from "@/lib/csv";
|
|
34
|
+
import { Dropzone } from "./dropzone";
|
|
35
|
+
import { downloadCsv } from "./table-selection";
|
|
36
|
+
|
|
37
|
+
/** What a column looks like in the downloadable template. */
|
|
38
|
+
function exampleFor(key: string, def: StoredField & { label: string }): string {
|
|
39
|
+
switch (def.kind) {
|
|
40
|
+
case "string":
|
|
41
|
+
if (def.format === "email") return "ada@example.com";
|
|
42
|
+
if (def.format === "tel") return "+256772123456";
|
|
43
|
+
if (def.format === "url") return "https://example.com";
|
|
44
|
+
if (def.format === "domain") return "example.com";
|
|
45
|
+
if (def.format === "country") return "UG";
|
|
46
|
+
if (def.format === "color") return "#f2541d";
|
|
47
|
+
if (def.format === "slug") return "my-first-" + key.toLowerCase();
|
|
48
|
+
return def.label;
|
|
49
|
+
case "text":
|
|
50
|
+
return "A longer note about this record.";
|
|
51
|
+
case "int":
|
|
52
|
+
return "42";
|
|
53
|
+
case "float":
|
|
54
|
+
return "9.99";
|
|
55
|
+
case "boolean":
|
|
56
|
+
return "true";
|
|
57
|
+
case "date":
|
|
58
|
+
return new Date().toISOString().slice(0, 10);
|
|
59
|
+
case "datetime":
|
|
60
|
+
return new Date().toISOString();
|
|
61
|
+
case "enum":
|
|
62
|
+
return String((def as EnumField).options[0] ?? "");
|
|
63
|
+
case "multiselect":
|
|
64
|
+
return ((def as MultiSelectField).options.slice(0, 2) as string[]).join("; ");
|
|
65
|
+
case "belongsTo":
|
|
66
|
+
return "id of an existing " + (def as { target: string }).target;
|
|
67
|
+
default:
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
34
71
|
|
|
35
72
|
/** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
|
|
36
73
|
const SKIP = "__skip__";
|
|
@@ -186,6 +223,13 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
186
223
|
const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
|
|
187
224
|
const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
|
|
188
225
|
|
|
226
|
+
/** A starter file: the header this import wants, and one row of what each column holds. */
|
|
227
|
+
function downloadTemplate() {
|
|
228
|
+
const columns = fields.map(([key, def]) => ({ key, label: def.label }));
|
|
229
|
+
const example = Object.fromEntries(fields.map(([key, def]) => [key, exampleFor(key, def)]));
|
|
230
|
+
downloadCsv(`${resource.slug}-template.csv`, toCsv([example], columns));
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
function reset() {
|
|
190
234
|
setFileName("");
|
|
191
235
|
setHeaders([]);
|
|
@@ -197,8 +241,8 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
197
241
|
setSummary(null);
|
|
198
242
|
}
|
|
199
243
|
|
|
200
|
-
async function onFile(
|
|
201
|
-
const file =
|
|
244
|
+
async function onFile(files: File[]) {
|
|
245
|
+
const file = files[0];
|
|
202
246
|
if (!file) return;
|
|
203
247
|
reset();
|
|
204
248
|
|
|
@@ -319,10 +363,19 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
319
363
|
</DialogHeader>
|
|
320
364
|
|
|
321
365
|
<div className="grid gap-6">
|
|
322
|
-
<
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
366
|
+
<div className="flex flex-col gap-2">
|
|
367
|
+
<Dropzone
|
|
368
|
+
onFiles={onFile}
|
|
369
|
+
accept=".csv,text/csv"
|
|
370
|
+
disabled={running}
|
|
371
|
+
label={fileName ? fileName : "Drop a CSV here"}
|
|
372
|
+
hint={fileName ? `${rows.length.toLocaleString()} rows ready to map` : "One row per " + resource.label.toLowerCase() + ", with a header row on top"}
|
|
373
|
+
/>
|
|
374
|
+
<Button type="button" variant="link" size="sm" className="h-auto w-fit p-0" onClick={downloadTemplate}>
|
|
375
|
+
<DownloadIcon data-icon="inline-start" />
|
|
376
|
+
Download a template with an example row
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
326
379
|
|
|
327
380
|
{fileError && (
|
|
328
381
|
<Alert variant="destructive">
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useTransition } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { CheckIcon, ChevronDownIcon, PencilIcon, XIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { optionLabel, type StoredField } from "@flaredev/core";
|
|
8
|
+
import { updateRecordAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
12
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
13
|
+
import { Switch } from "@/components/ui/switch";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
resourceName: string;
|
|
18
|
+
id: string;
|
|
19
|
+
fieldKey: string;
|
|
20
|
+
def: StoredField & { label: string };
|
|
21
|
+
value: unknown;
|
|
22
|
+
/** What the cell shows when it isn't being edited. */
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NONE = "__none__";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Edit one value without leaving the table.
|
|
30
|
+
*
|
|
31
|
+
* For the small changes — a status, a flag, a number — opening a form to change one
|
|
32
|
+
* field and closing it again is most of the work. Anything with a picker, an upload or
|
|
33
|
+
* a relation still goes through the form, where there's room to explain itself.
|
|
34
|
+
*/
|
|
35
|
+
export function InlineCell({ resourceName, id, fieldKey, def, value, children }: Props) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const [editing, setEditing] = useState(false);
|
|
38
|
+
const [draft, setDraft] = useState("");
|
|
39
|
+
const [pending, startTransition] = useTransition();
|
|
40
|
+
const input = useRef<HTMLInputElement>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (editing) input.current?.focus();
|
|
44
|
+
}, [editing]);
|
|
45
|
+
|
|
46
|
+
const save = (next: unknown) => {
|
|
47
|
+
setEditing(false);
|
|
48
|
+
if (next === value || (next === null && value === null)) return;
|
|
49
|
+
startTransition(async () => {
|
|
50
|
+
const result = await updateRecordAction(resourceName, id, { [fieldKey]: next });
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
toast.error(result.issues?.[0]?.message ?? result.error);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
toast.success(`${def.label} saved.`);
|
|
56
|
+
router.refresh();
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (pending) {
|
|
61
|
+
return (
|
|
62
|
+
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
|
63
|
+
<Spinner className="size-3.5" />
|
|
64
|
+
{children}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A switch and a select are one click either way, so they're always live.
|
|
70
|
+
if (def.kind === "boolean") {
|
|
71
|
+
return (
|
|
72
|
+
<Switch
|
|
73
|
+
checked={value === true}
|
|
74
|
+
onCheckedChange={(checked) => save(checked)}
|
|
75
|
+
aria-label={`${def.label} for this record`}
|
|
76
|
+
className="data-[state=unchecked]:bg-muted-foreground/30"
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (def.kind === "enum") {
|
|
82
|
+
// The badge stays until it's clicked: a row of grey dropdowns loses the colour that
|
|
83
|
+
// makes a status column readable at a glance.
|
|
84
|
+
if (!editing) {
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setEditing(true)}
|
|
89
|
+
className="group/cell inline-flex items-center gap-1 rounded px-0.5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
90
|
+
aria-label={`Change ${def.label.toLowerCase()}`}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
<ChevronDownIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<Select
|
|
99
|
+
defaultOpen
|
|
100
|
+
value={typeof value === "string" ? value : NONE}
|
|
101
|
+
onValueChange={(next) => save(next === NONE ? null : next)}
|
|
102
|
+
onOpenChange={(open) => {
|
|
103
|
+
if (!open) setEditing(false);
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<SelectTrigger size="sm" className="h-7 w-auto px-1.5" aria-label={def.label}>
|
|
107
|
+
<SelectValue placeholder="—" />
|
|
108
|
+
</SelectTrigger>
|
|
109
|
+
<SelectContent>
|
|
110
|
+
<SelectGroup>
|
|
111
|
+
{!def.required && <SelectItem value={NONE}>—</SelectItem>}
|
|
112
|
+
{def.options.map((option) => (
|
|
113
|
+
<SelectItem key={option} value={option}>
|
|
114
|
+
{optionLabel(def, option)}
|
|
115
|
+
</SelectItem>
|
|
116
|
+
))}
|
|
117
|
+
</SelectGroup>
|
|
118
|
+
</SelectContent>
|
|
119
|
+
</Select>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (editing) {
|
|
124
|
+
const numeric = def.kind === "int" || def.kind === "float";
|
|
125
|
+
return (
|
|
126
|
+
<span className="flex items-center gap-1">
|
|
127
|
+
<Input
|
|
128
|
+
ref={input}
|
|
129
|
+
value={draft}
|
|
130
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
131
|
+
type={numeric ? "number" : "text"}
|
|
132
|
+
step={def.kind === "float" ? "any" : undefined}
|
|
133
|
+
className="h-7 w-full min-w-24 px-1.5"
|
|
134
|
+
aria-label={def.label}
|
|
135
|
+
onKeyDown={(event) => {
|
|
136
|
+
if (event.key === "Enter") save(numeric ? (draft === "" ? null : Number(draft)) : draft);
|
|
137
|
+
if (event.key === "Escape") setEditing(false);
|
|
138
|
+
}}
|
|
139
|
+
onBlur={() => save(numeric ? (draft === "" ? null : Number(draft)) : draft)}
|
|
140
|
+
/>
|
|
141
|
+
<Button variant="ghost" size="icon" className="size-6" aria-label="Save" onMouseDown={(event) => event.preventDefault()}>
|
|
142
|
+
<CheckIcon />
|
|
143
|
+
</Button>
|
|
144
|
+
<Button variant="ghost" size="icon" className="size-6" aria-label="Cancel" onMouseDown={(event) => { event.preventDefault(); setEditing(false); }}>
|
|
145
|
+
<XIcon />
|
|
146
|
+
</Button>
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={() => {
|
|
155
|
+
setDraft(value === null || value === undefined ? "" : String(value));
|
|
156
|
+
setEditing(true);
|
|
157
|
+
}}
|
|
158
|
+
className={cn("group/cell inline-flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted", "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary")}
|
|
159
|
+
aria-label={`Edit ${def.label.toLowerCase()}`}
|
|
160
|
+
>
|
|
161
|
+
<span className="truncate">{children}</span>
|
|
162
|
+
<PencilIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A timestamp in the reader's own timezone.
|
|
7
|
+
*
|
|
8
|
+
* Pages render on a Worker, where the clock is UTC, so a date formatted on the server is
|
|
9
|
+
* in the wrong timezone for everyone. The server's version is rendered first (so the
|
|
10
|
+
* markup isn't empty and search engines see something), then the browser replaces it
|
|
11
|
+
* with its own once it's running.
|
|
12
|
+
*/
|
|
13
|
+
export function LocalTime({
|
|
14
|
+
value,
|
|
15
|
+
dateStyle = "medium",
|
|
16
|
+
timeStyle = "short",
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
/** Milliseconds, an ISO string, or a Date. */
|
|
20
|
+
value: number | string | Date | null | undefined;
|
|
21
|
+
dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
|
|
22
|
+
timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
|
|
26
|
+
const utc = date ? new Intl.DateTimeFormat("en-GB", { dateStyle, timeStyle, timeZone: "UTC" }).format(date) : "—";
|
|
27
|
+
const [text, setText] = useState(utc);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!date) return;
|
|
31
|
+
setText(new Intl.DateTimeFormat(undefined, { dateStyle, timeStyle }).format(date));
|
|
32
|
+
}, [date?.getTime(), dateStyle, timeStyle]);
|
|
33
|
+
|
|
34
|
+
if (!date) return <span className={className}>—</span>;
|
|
35
|
+
return (
|
|
36
|
+
<time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
|
|
37
|
+
{text}
|
|
38
|
+
</time>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** "3 minutes ago", "yesterday", falling back to a date once it's a week old. */
|
|
43
|
+
export function RelativeTime({ value, className }: { value: number | string | Date | null | undefined; className?: string }) {
|
|
44
|
+
const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
|
|
45
|
+
const [text, setText] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!date) return;
|
|
49
|
+
const format = () => {
|
|
50
|
+
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
|
51
|
+
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
52
|
+
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
53
|
+
["second", 60],
|
|
54
|
+
["minute", 60],
|
|
55
|
+
["hour", 24],
|
|
56
|
+
["day", 7],
|
|
57
|
+
];
|
|
58
|
+
let value = seconds;
|
|
59
|
+
for (const [unit, size] of units) {
|
|
60
|
+
if (Math.abs(value) < size) return relative.format(Math.round(value), unit);
|
|
61
|
+
value /= size;
|
|
62
|
+
}
|
|
63
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
|
|
64
|
+
};
|
|
65
|
+
setText(format());
|
|
66
|
+
// Re-read every half minute, so "just now" doesn't stay "just now" all afternoon.
|
|
67
|
+
const timer = setInterval(() => setText(format()), 30_000);
|
|
68
|
+
return () => clearInterval(timer);
|
|
69
|
+
}, [date?.getTime()]);
|
|
70
|
+
|
|
71
|
+
if (!date) return <span className={className}>—</span>;
|
|
72
|
+
return (
|
|
73
|
+
<time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
|
|
74
|
+
{text ?? new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" }).format(date)}
|
|
75
|
+
</time>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -19,7 +19,9 @@ export function hrefWith(path: string, current: URLSearchParams, changes: Record
|
|
|
19
19
|
if (value === null || value === "") next.delete(key);
|
|
20
20
|
else next.set(key, value);
|
|
21
21
|
}
|
|
22
|
+
// A cursor belongs to the query that produced it, so changing anything else drops it.
|
|
22
23
|
if (!("page" in changes)) next.delete("page");
|
|
24
|
+
if (!("cursor" in changes)) next.delete("cursor");
|
|
23
25
|
const query = next.toString();
|
|
24
26
|
return query ? `${path}?${query}` : path;
|
|
25
27
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState, useTransition } from "react";
|
|
6
|
+
import { PencilIcon, Trash2Icon } from "lucide-react";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import type { Resource } from "@flaredev/core";
|
|
9
|
+
import { deleteRecordAction } from "@/app/dashboard/actions";
|
|
10
|
+
import {
|
|
11
|
+
AlertDialog,
|
|
12
|
+
AlertDialogCancel,
|
|
13
|
+
AlertDialogContent,
|
|
14
|
+
AlertDialogDescription,
|
|
15
|
+
AlertDialogFooter,
|
|
16
|
+
AlertDialogHeader,
|
|
17
|
+
AlertDialogTitle,
|
|
18
|
+
} from "@/components/ui/alert-dialog";
|
|
19
|
+
import { Button } from "@/components/ui/button";
|
|
20
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
21
|
+
import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
resource: Resource;
|
|
25
|
+
id: string;
|
|
26
|
+
record: Record<string, unknown>;
|
|
27
|
+
relations: FormRelations;
|
|
28
|
+
listHref: string;
|
|
29
|
+
editHref: string;
|
|
30
|
+
canUpdate: boolean;
|
|
31
|
+
canDelete: boolean;
|
|
32
|
+
/** Whether editing opens a dialog (site.dashboard.forms) or goes to the form page. */
|
|
33
|
+
modalForms: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
|
|
37
|
+
export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, modalForms }: Props) {
|
|
38
|
+
const router = useRouter();
|
|
39
|
+
const [editing, setEditing] = useState(false);
|
|
40
|
+
const [confirming, setConfirming] = useState(false);
|
|
41
|
+
const [pending, startTransition] = useTransition();
|
|
42
|
+
|
|
43
|
+
const remove = () => {
|
|
44
|
+
startTransition(async () => {
|
|
45
|
+
const result = await deleteRecordAction(resource.name, id);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
toast.error(result.error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
toast.success(`${resource.label} deleted.`);
|
|
51
|
+
// The record this page is about is gone, so the list is the only place left to be.
|
|
52
|
+
router.push(listHref);
|
|
53
|
+
router.refresh();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{canUpdate &&
|
|
60
|
+
(modalForms ? (
|
|
61
|
+
<Button variant="outline" onClick={() => setEditing(true)}>
|
|
62
|
+
<PencilIcon data-icon="inline-start" />
|
|
63
|
+
Edit
|
|
64
|
+
</Button>
|
|
65
|
+
) : (
|
|
66
|
+
<Button variant="outline" asChild>
|
|
67
|
+
<Link href={editHref}>
|
|
68
|
+
<PencilIcon data-icon="inline-start" />
|
|
69
|
+
Edit
|
|
70
|
+
</Link>
|
|
71
|
+
</Button>
|
|
72
|
+
))}
|
|
73
|
+
{canDelete && (
|
|
74
|
+
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
|
|
75
|
+
<Trash2Icon data-icon="inline-start" />
|
|
76
|
+
Delete
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{canUpdate && modalForms && (
|
|
81
|
+
<ResourceFormDialog
|
|
82
|
+
resource={resource}
|
|
83
|
+
relations={relations}
|
|
84
|
+
listHref={listHref}
|
|
85
|
+
mode="edit"
|
|
86
|
+
id={id}
|
|
87
|
+
record={record}
|
|
88
|
+
open={editing}
|
|
89
|
+
onOpenChange={(open) => {
|
|
90
|
+
setEditing(open);
|
|
91
|
+
if (!open) router.refresh();
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
|
97
|
+
<AlertDialogContent>
|
|
98
|
+
<AlertDialogHeader>
|
|
99
|
+
<AlertDialogTitle>Delete this {resource.label.toLowerCase()}?</AlertDialogTitle>
|
|
100
|
+
<AlertDialogDescription>This can't be undone. Anything that belongs to it may be deleted too.</AlertDialogDescription>
|
|
101
|
+
</AlertDialogHeader>
|
|
102
|
+
<AlertDialogFooter>
|
|
103
|
+
<AlertDialogCancel disabled={pending}>Keep it</AlertDialogCancel>
|
|
104
|
+
<Button variant="destructive" onClick={remove} disabled={pending}>
|
|
105
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
106
|
+
Delete
|
|
107
|
+
</Button>
|
|
108
|
+
</AlertDialogFooter>
|
|
109
|
+
</AlertDialogContent>
|
|
110
|
+
</AlertDialog>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { recentAudit } from "@/lib/audit";
|
|
8
|
+
import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
|
|
9
|
+
import { site } from "@/lib/site";
|
|
10
|
+
import { LocalTime } from "./local-time";
|
|
11
|
+
import { PageHeader } from "./page-header";
|
|
12
|
+
import { RecordActions } from "./record-actions";
|
|
13
|
+
import type { RelationMeta } from "./fields/field-widget";
|
|
14
|
+
|
|
15
|
+
/** How many of a record's children to show before pointing at the filtered list. */
|
|
16
|
+
const RELATED_LIMIT = 5;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One record: its fields, what belongs to it, and what's been done to it.
|
|
20
|
+
*
|
|
21
|
+
* A row in a table is a summary; this is the page you send someone when you want them
|
|
22
|
+
* to look at the thing itself.
|
|
23
|
+
*/
|
|
24
|
+
export async function RecordDetail({ resource, id }: { resource: Resource; id: string }) {
|
|
25
|
+
await requireAccess(resource, "read");
|
|
26
|
+
const store = dashboardStore(resource.name);
|
|
27
|
+
const result = await store.get(id);
|
|
28
|
+
if (!result.ok) notFound();
|
|
29
|
+
const record = result.data;
|
|
30
|
+
const permissions = await adminPermissions(resource.name);
|
|
31
|
+
|
|
32
|
+
const all = allResources();
|
|
33
|
+
const byName = new Map(all.map((item) => [item.name, item]));
|
|
34
|
+
const fields = storedFields(resource);
|
|
35
|
+
|
|
36
|
+
// Relation titles, so a belongsTo reads as a name rather than an id.
|
|
37
|
+
const relationTitles: Record<string, string> = {};
|
|
38
|
+
const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
|
|
39
|
+
for (const [key, def] of fields) {
|
|
40
|
+
if (def.kind !== "belongsTo") continue;
|
|
41
|
+
const target = byName.get(def.target);
|
|
42
|
+
if (!target) continue;
|
|
43
|
+
relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
|
|
44
|
+
const value = record[key];
|
|
45
|
+
if (typeof value === "string") {
|
|
46
|
+
const titles = await dashboardStore(target.name).titles([value]);
|
|
47
|
+
const title = titles[value];
|
|
48
|
+
if (title) relationTitles[key] = title;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Children: resources with a belongsTo pointing back here.
|
|
53
|
+
const graph = relationGraph(all);
|
|
54
|
+
const children = (graph.byResource[resource.name]?.hasMany ?? []).flatMap((relation) => {
|
|
55
|
+
const target = byName.get(relation.target);
|
|
56
|
+
return target ? [{ relation, target }] : [];
|
|
57
|
+
});
|
|
58
|
+
const related = await Promise.all(
|
|
59
|
+
children.map(async ({ relation, target }) => {
|
|
60
|
+
const params = new URLSearchParams({ perPage: String(RELATED_LIMIT), [`filter[${relation.foreignKey}]`]: id });
|
|
61
|
+
const list = await dashboardStore(target.name).list(params);
|
|
62
|
+
return { relation, target, rows: list.ok ? list.data.data : [], total: list.ok ? list.data.meta.total : 0, filterKey: relation.foreignKey };
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const history = await recentAudit(10, resource.name);
|
|
67
|
+
const forThisRecord = history.filter((entry) => entry.recordId === id);
|
|
68
|
+
|
|
69
|
+
const titleDef = resource.fields[resource.titleField];
|
|
70
|
+
const title = titleDef && titleDef.kind !== "hasMany" ? String(formatValue(titleDef, record[resource.titleField]) || id) : id;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<PageHeader
|
|
75
|
+
title={title}
|
|
76
|
+
description={<>Created <LocalTime value={record.createdAt as string | number | Date} /></>}
|
|
77
|
+
crumbs={[
|
|
78
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
79
|
+
{ label: resource.pluralLabel, href: resourcePath(resource) },
|
|
80
|
+
{ label: title },
|
|
81
|
+
]}
|
|
82
|
+
actions={
|
|
83
|
+
<RecordActions
|
|
84
|
+
resource={resource}
|
|
85
|
+
id={id}
|
|
86
|
+
record={record}
|
|
87
|
+
relations={relations}
|
|
88
|
+
listHref={resourcePath(resource)}
|
|
89
|
+
editHref={resourcePath(resource, id, "edit")}
|
|
90
|
+
canUpdate={permissions.update}
|
|
91
|
+
canDelete={permissions.delete}
|
|
92
|
+
modalForms={site.dashboard.forms === "modal"}
|
|
93
|
+
/>
|
|
94
|
+
}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
|
98
|
+
<Card>
|
|
99
|
+
<CardHeader>
|
|
100
|
+
<CardTitle className="text-base">Details</CardTitle>
|
|
101
|
+
</CardHeader>
|
|
102
|
+
<CardContent className="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
|
103
|
+
{fields.map(([key, def]) => {
|
|
104
|
+
const value = record[key];
|
|
105
|
+
let content: React.ReactNode = formatValue(def, value);
|
|
106
|
+
if ((def.kind === "datetime") && value != null) {
|
|
107
|
+
content = <LocalTime value={value as string | number | Date} />;
|
|
108
|
+
} else if (def.kind === "enum" && value != null) {
|
|
109
|
+
const tone = statusTone(value);
|
|
110
|
+
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
111
|
+
} else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
|
|
112
|
+
content = (
|
|
113
|
+
<span className="flex flex-wrap gap-1">
|
|
114
|
+
{value.map((item) => (
|
|
115
|
+
<Badge key={String(item)} variant="secondary">
|
|
116
|
+
{optionLabel(def, String(item))}
|
|
117
|
+
</Badge>
|
|
118
|
+
))}
|
|
119
|
+
</span>
|
|
120
|
+
);
|
|
121
|
+
} else if (def.kind === "belongsTo" && typeof value === "string") {
|
|
122
|
+
const target = byName.get(def.target);
|
|
123
|
+
content = target ? (
|
|
124
|
+
<Link href={resourcePath(target, value)} className="text-primary hover:underline">
|
|
125
|
+
{relationTitles[key] ?? value}
|
|
126
|
+
</Link>
|
|
127
|
+
) : (
|
|
128
|
+
value
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<div key={key} className={def.kind === "text" ? "flex flex-col gap-1 sm:col-span-2" : "flex flex-col gap-1"}>
|
|
133
|
+
<dt className="text-xs text-muted-foreground">{def.label}</dt>
|
|
134
|
+
<dd className="text-sm break-words">{content}</dd>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</CardContent>
|
|
139
|
+
</Card>
|
|
140
|
+
|
|
141
|
+
<div className="flex flex-col gap-6">
|
|
142
|
+
<Card>
|
|
143
|
+
<CardHeader>
|
|
144
|
+
<CardTitle className="text-base">History</CardTitle>
|
|
145
|
+
<CardDescription>What's been done to this {resource.label.toLowerCase()}.</CardDescription>
|
|
146
|
+
</CardHeader>
|
|
147
|
+
<CardContent className="flex flex-col">
|
|
148
|
+
{forThisRecord.length === 0 ? (
|
|
149
|
+
<p className="text-sm text-muted-foreground">Nothing since the audit log started.</p>
|
|
150
|
+
) : (
|
|
151
|
+
forThisRecord.map((entry) => (
|
|
152
|
+
<div key={entry.id} className="flex flex-col gap-0.5 border-b py-2.5 text-sm last:border-b-0 last:pb-0">
|
|
153
|
+
<span>
|
|
154
|
+
<Badge variant={entry.action === "delete" ? "destructive" : "secondary"}>{entry.action}</Badge>
|
|
155
|
+
<span className="ml-2 text-muted-foreground">{entry.userEmail ?? "someone"}</span>
|
|
156
|
+
</span>
|
|
157
|
+
<LocalTime value={entry.createdAt} className="text-xs text-muted-foreground tabular-nums" />
|
|
158
|
+
</div>
|
|
159
|
+
))
|
|
160
|
+
)}
|
|
161
|
+
</CardContent>
|
|
162
|
+
</Card>
|
|
163
|
+
|
|
164
|
+
{related.map(({ relation, target, rows, total, filterKey }) => (
|
|
165
|
+
<Card key={relation.key}>
|
|
166
|
+
<CardHeader>
|
|
167
|
+
<CardTitle className="text-base">{target.pluralLabel}</CardTitle>
|
|
168
|
+
<CardDescription>
|
|
169
|
+
{total === 0 ? `No ${target.pluralLabel.toLowerCase()} yet.` : `${total.toLocaleString()} in total.`}
|
|
170
|
+
</CardDescription>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
{rows.length > 0 && (
|
|
173
|
+
<CardContent className="flex flex-col">
|
|
174
|
+
{rows.map((row) => {
|
|
175
|
+
const childTitle = target.fields[target.titleField];
|
|
176
|
+
const label = childTitle && childTitle.kind !== "hasMany" ? formatValue(childTitle, row[target.titleField]) : String(row.id);
|
|
177
|
+
return (
|
|
178
|
+
<Link
|
|
179
|
+
key={String(row.id)}
|
|
180
|
+
href={resourcePath(target, String(row.id))}
|
|
181
|
+
className="truncate border-b py-2 text-sm last:border-b-0 last:pb-0 hover:text-primary"
|
|
182
|
+
>
|
|
183
|
+
{label || String(row.id)}
|
|
184
|
+
</Link>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
{total > rows.length && (
|
|
188
|
+
<Button variant="link" className="h-auto justify-start p-0 pt-2" asChild>
|
|
189
|
+
<Link href={`${resourcePath(target)}?filter[${filterKey}]=${id}`}>See all {total.toLocaleString()}</Link>
|
|
190
|
+
</Button>
|
|
191
|
+
)}
|
|
192
|
+
</CardContent>
|
|
193
|
+
)}
|
|
194
|
+
</Card>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</>
|
|
199
|
+
);
|
|
200
|
+
}
|