@flaredev/cli 0.3.0 → 0.4.1
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 +498 -220
- 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 +73 -5
- 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/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- 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 +310 -302
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +62 -9
- 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 +203 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form.tsx +165 -165
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +408 -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 +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3548 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { clientResource, 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
|
+
// Hooks and computed values are functions, and a function can't be sent to the browser.
|
|
27
|
+
// This is the same resource without them, for the client components below.
|
|
28
|
+
const forClient = clientResource(resource);
|
|
29
|
+
const store = dashboardStore(resource.name);
|
|
30
|
+
const result = await store.get(id);
|
|
31
|
+
if (!result.ok) notFound();
|
|
32
|
+
const record = result.data;
|
|
33
|
+
const permissions = await adminPermissions(resource.name);
|
|
34
|
+
|
|
35
|
+
const all = allResources();
|
|
36
|
+
const byName = new Map(all.map((item) => [item.name, item]));
|
|
37
|
+
const fields = storedFields(resource);
|
|
38
|
+
|
|
39
|
+
// Relation titles, so a belongsTo reads as a name rather than an id.
|
|
40
|
+
const relationTitles: Record<string, string> = {};
|
|
41
|
+
const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
|
|
42
|
+
for (const [key, def] of fields) {
|
|
43
|
+
if (def.kind !== "belongsTo") continue;
|
|
44
|
+
const target = byName.get(def.target);
|
|
45
|
+
if (!target) continue;
|
|
46
|
+
relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
|
|
47
|
+
const value = record[key];
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
const titles = await dashboardStore(target.name).titles([value]);
|
|
50
|
+
const title = titles[value];
|
|
51
|
+
if (title) relationTitles[key] = title;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Children: resources with a belongsTo pointing back here.
|
|
56
|
+
const graph = relationGraph(all);
|
|
57
|
+
const children = (graph.byResource[resource.name]?.hasMany ?? []).flatMap((relation) => {
|
|
58
|
+
const target = byName.get(relation.target);
|
|
59
|
+
return target ? [{ relation, target }] : [];
|
|
60
|
+
});
|
|
61
|
+
const related = await Promise.all(
|
|
62
|
+
children.map(async ({ relation, target }) => {
|
|
63
|
+
const params = new URLSearchParams({ perPage: String(RELATED_LIMIT), [`filter[${relation.foreignKey}]`]: id });
|
|
64
|
+
const list = await dashboardStore(target.name).list(params);
|
|
65
|
+
return { relation, target, rows: list.ok ? list.data.data : [], total: list.ok ? list.data.meta.total : 0, filterKey: relation.foreignKey };
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const history = await recentAudit(10, resource.name);
|
|
70
|
+
const forThisRecord = history.filter((entry) => entry.recordId === id);
|
|
71
|
+
|
|
72
|
+
const titleDef = resource.fields[resource.titleField];
|
|
73
|
+
const title = titleDef && titleDef.kind !== "hasMany" ? String(formatValue(titleDef, record[resource.titleField]) || id) : id;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<PageHeader
|
|
78
|
+
title={title}
|
|
79
|
+
description={<>Created <LocalTime value={record.createdAt as string | number | Date} /></>}
|
|
80
|
+
crumbs={[
|
|
81
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
82
|
+
{ label: resource.pluralLabel, href: resourcePath(resource) },
|
|
83
|
+
{ label: title },
|
|
84
|
+
]}
|
|
85
|
+
actions={
|
|
86
|
+
<RecordActions
|
|
87
|
+
resource={forClient}
|
|
88
|
+
id={id}
|
|
89
|
+
record={record}
|
|
90
|
+
relations={relations}
|
|
91
|
+
listHref={resourcePath(resource)}
|
|
92
|
+
editHref={resourcePath(resource, id, "edit")}
|
|
93
|
+
canUpdate={permissions.update}
|
|
94
|
+
canDelete={permissions.delete}
|
|
95
|
+
modalForms={site.dashboard.forms === "modal"}
|
|
96
|
+
/>
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
|
101
|
+
<Card>
|
|
102
|
+
<CardHeader>
|
|
103
|
+
<CardTitle className="text-base">Details</CardTitle>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent className="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
|
106
|
+
{fields.map(([key, def]) => {
|
|
107
|
+
const value = record[key];
|
|
108
|
+
let content: React.ReactNode = formatValue(def, value);
|
|
109
|
+
if ((def.kind === "datetime") && value != null) {
|
|
110
|
+
content = <LocalTime value={value as string | number | Date} />;
|
|
111
|
+
} else if (def.kind === "enum" && value != null) {
|
|
112
|
+
const tone = statusTone(value);
|
|
113
|
+
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
114
|
+
} else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
|
|
115
|
+
content = (
|
|
116
|
+
<span className="flex flex-wrap gap-1">
|
|
117
|
+
{value.map((item) => (
|
|
118
|
+
<Badge key={String(item)} variant="secondary">
|
|
119
|
+
{optionLabel(def, String(item))}
|
|
120
|
+
</Badge>
|
|
121
|
+
))}
|
|
122
|
+
</span>
|
|
123
|
+
);
|
|
124
|
+
} else if (def.kind === "belongsTo" && typeof value === "string") {
|
|
125
|
+
const target = byName.get(def.target);
|
|
126
|
+
content = target ? (
|
|
127
|
+
<Link href={resourcePath(target, value)} className="text-primary hover:underline">
|
|
128
|
+
{relationTitles[key] ?? value}
|
|
129
|
+
</Link>
|
|
130
|
+
) : (
|
|
131
|
+
value
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return (
|
|
135
|
+
<div key={key} className={def.kind === "text" ? "flex flex-col gap-1 sm:col-span-2" : "flex flex-col gap-1"}>
|
|
136
|
+
<dt className="text-xs text-muted-foreground">{def.label}</dt>
|
|
137
|
+
<dd className="text-sm break-words">{content}</dd>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
|
|
144
|
+
<div className="flex flex-col gap-6">
|
|
145
|
+
<Card>
|
|
146
|
+
<CardHeader>
|
|
147
|
+
<CardTitle className="text-base">History</CardTitle>
|
|
148
|
+
<CardDescription>What's been done to this {resource.label.toLowerCase()}.</CardDescription>
|
|
149
|
+
</CardHeader>
|
|
150
|
+
<CardContent className="flex flex-col">
|
|
151
|
+
{forThisRecord.length === 0 ? (
|
|
152
|
+
<p className="text-sm text-muted-foreground">Nothing since the audit log started.</p>
|
|
153
|
+
) : (
|
|
154
|
+
forThisRecord.map((entry) => (
|
|
155
|
+
<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">
|
|
156
|
+
<span>
|
|
157
|
+
<Badge variant={entry.action === "delete" ? "destructive" : "secondary"}>{entry.action}</Badge>
|
|
158
|
+
<span className="ml-2 text-muted-foreground">{entry.userEmail ?? "someone"}</span>
|
|
159
|
+
</span>
|
|
160
|
+
<LocalTime value={entry.createdAt} className="text-xs text-muted-foreground tabular-nums" />
|
|
161
|
+
</div>
|
|
162
|
+
))
|
|
163
|
+
)}
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
|
|
167
|
+
{related.map(({ relation, target, rows, total, filterKey }) => (
|
|
168
|
+
<Card key={relation.key}>
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<CardTitle className="text-base">{target.pluralLabel}</CardTitle>
|
|
171
|
+
<CardDescription>
|
|
172
|
+
{total === 0 ? `No ${target.pluralLabel.toLowerCase()} yet.` : `${total.toLocaleString()} in total.`}
|
|
173
|
+
</CardDescription>
|
|
174
|
+
</CardHeader>
|
|
175
|
+
{rows.length > 0 && (
|
|
176
|
+
<CardContent className="flex flex-col">
|
|
177
|
+
{rows.map((row) => {
|
|
178
|
+
const childTitle = target.fields[target.titleField];
|
|
179
|
+
const label = childTitle && childTitle.kind !== "hasMany" ? formatValue(childTitle, row[target.titleField]) : String(row.id);
|
|
180
|
+
return (
|
|
181
|
+
<Link
|
|
182
|
+
key={String(row.id)}
|
|
183
|
+
href={resourcePath(target, String(row.id))}
|
|
184
|
+
className="truncate border-b py-2 text-sm last:border-b-0 last:pb-0 hover:text-primary"
|
|
185
|
+
>
|
|
186
|
+
{label || String(row.id)}
|
|
187
|
+
</Link>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
{total > rows.length && (
|
|
191
|
+
<Button variant="link" className="h-auto justify-start p-0 pt-2" asChild>
|
|
192
|
+
<Link href={`${resourcePath(target)}?filter[${filterKey}]=${id}`}>See all {total.toLocaleString()}</Link>
|
|
193
|
+
</Button>
|
|
194
|
+
)}
|
|
195
|
+
</CardContent>
|
|
196
|
+
)}
|
|
197
|
+
</Card>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import type { Resource } from "@flaredev/core";
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { getDb } from "@/db";
|
|
5
|
+
import { resourceTables } from "@/resources/server";
|
|
6
|
+
import { cached, resourceTag, TTL } from "@/lib/cache";
|
|
7
|
+
|
|
8
|
+
export interface DayCount {
|
|
9
|
+
/** "2026-09-23" */
|
|
10
|
+
date: string;
|
|
11
|
+
total: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How many records were created a day, grouped in SQL rather than by reading rows: one
|
|
16
|
+
* query over the created_at index, whatever the table's size.
|
|
17
|
+
*/
|
|
18
|
+
export function createdPerDay(name: string, days = 30): Promise<DayCount[]> {
|
|
19
|
+
const counts = cached(
|
|
20
|
+
async (resourceName: string, window: number) => {
|
|
21
|
+
const entry = (resourceTables as unknown as Record<string, { table: unknown } | undefined>)[resourceName];
|
|
22
|
+
const table = entry?.table as { createdAt?: unknown } | undefined;
|
|
23
|
+
if (!table?.createdAt) return [];
|
|
24
|
+
const since = new Date(Date.now() - window * 86_400_000);
|
|
25
|
+
const rows = (await getDb()
|
|
26
|
+
.select({
|
|
27
|
+
date: sql<string>`strftime('%Y-%m-%d', ${table.createdAt} / 1000, 'unixepoch')`,
|
|
28
|
+
total: sql<number>`count(*)`,
|
|
29
|
+
})
|
|
30
|
+
.from(table as never)
|
|
31
|
+
.where(sql`${table.createdAt} >= ${since.getTime()}`)
|
|
32
|
+
.groupBy(sql`1`)
|
|
33
|
+
.orderBy(sql`1`)) as DayCount[];
|
|
34
|
+
return rows;
|
|
35
|
+
},
|
|
36
|
+
["flare", "created-per-day"],
|
|
37
|
+
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
38
|
+
);
|
|
39
|
+
return counts(name, days);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fill the gaps: a day with nothing created still needs a column. */
|
|
43
|
+
function series(rows: DayCount[], days: number): DayCount[] {
|
|
44
|
+
const found = new Map(rows.map((row) => [row.date, row.total]));
|
|
45
|
+
return Array.from({ length: days }, (_, index) => {
|
|
46
|
+
const date = new Date(Date.now() - (days - 1 - index) * 86_400_000).toISOString().slice(0, 10);
|
|
47
|
+
return { date, total: found.get(date) ?? 0 };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* New records a day for the last month, above the table.
|
|
53
|
+
*
|
|
54
|
+
* Drawn with CSS rather than a charting library: thirty numbers don't justify shipping
|
|
55
|
+
* one to the browser, and this renders on the server with the rest of the page.
|
|
56
|
+
*/
|
|
57
|
+
export async function ResourceChart({ resource, days = 30 }: { resource: Resource; days?: number }) {
|
|
58
|
+
const rows = series(await createdPerDay(resource.name, days), days);
|
|
59
|
+
const total = rows.reduce((sum, row) => sum + row.total, 0);
|
|
60
|
+
if (total === 0) return null;
|
|
61
|
+
|
|
62
|
+
const peak = Math.max(1, ...rows.map((row) => row.total));
|
|
63
|
+
const month = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" });
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Card>
|
|
67
|
+
<CardHeader>
|
|
68
|
+
<CardTitle className="text-base">New {resource.pluralLabel.toLowerCase()}</CardTitle>
|
|
69
|
+
<CardDescription>
|
|
70
|
+
{total.toLocaleString()} in the last {days} days.
|
|
71
|
+
</CardDescription>
|
|
72
|
+
</CardHeader>
|
|
73
|
+
<CardContent>
|
|
74
|
+
<div className="flex h-28 items-end gap-0.5" role="img" aria-label={`New ${resource.pluralLabel.toLowerCase()} a day for the last ${days} days`}>
|
|
75
|
+
{rows.map((row) => (
|
|
76
|
+
<div
|
|
77
|
+
key={row.date}
|
|
78
|
+
title={`${row.date}: ${row.total}`}
|
|
79
|
+
style={{ height: `${Math.max(2, Math.round((row.total / peak) * 100))}%` }}
|
|
80
|
+
className="flex-1 rounded-t-sm bg-primary/70 transition-colors hover:bg-primary"
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
|
85
|
+
<span>{month.format(new Date(`${rows[0]!.date}T00:00:00Z`))}</span>
|
|
86
|
+
<span>{month.format(new Date(`${rows[rows.length - 1]!.date}T00:00:00Z`))}</span>
|
|
87
|
+
</div>
|
|
88
|
+
</CardContent>
|
|
89
|
+
</Card>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { PlusIcon } from "lucide-react";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ClientResource } from "@flaredev/core";
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
8
8
|
import type { RelationMeta } from "./fields/field-widget";
|
|
@@ -11,7 +11,7 @@ import { ResourceForm } from "./resource-form";
|
|
|
11
11
|
export type FormRelations = Record<string, RelationMeta & { initialTitle?: string }>;
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
14
|
-
resource:
|
|
14
|
+
resource: ClientResource;
|
|
15
15
|
relations: FormRelations;
|
|
16
16
|
listHref: string;
|
|
17
17
|
mode: "create" | "edit";
|
|
@@ -62,7 +62,7 @@ export function ResourceFormDialog({ resource, relations, listHref, mode, id, re
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** The list page's "New …" button, when forms open in a dialog. */
|
|
65
|
-
export function NewRecordButton({ resource, relations, listHref }: { resource:
|
|
65
|
+
export function NewRecordButton({ resource, relations, listHref }: { resource: ClientResource; relations: FormRelations; listHref: string }) {
|
|
66
66
|
return (
|
|
67
67
|
<ResourceFormDialog
|
|
68
68
|
resource={resource}
|
|
@@ -1,61 +1,64 @@
|
|
|
1
|
-
import { notFound } from "next/navigation";
|
|
2
|
-
import { formatValue, storedFields, type Resource } from "@flaredev/core";
|
|
3
|
-
import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
4
|
-
import type { RelationMeta } from "./fields/field-widget";
|
|
5
|
-
import { PageHeader } from "./page-header";
|
|
6
|
-
import { ResourceForm } from "./resource-form";
|
|
7
|
-
|
|
8
|
-
/** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
|
|
9
|
-
export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
|
|
10
|
-
await requireAccess(resource, id ? "update" : "create");
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
import { notFound } from "next/navigation";
|
|
2
|
+
import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
|
|
3
|
+
import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
4
|
+
import type { RelationMeta } from "./fields/field-widget";
|
|
5
|
+
import { PageHeader } from "./page-header";
|
|
6
|
+
import { ResourceForm } from "./resource-form";
|
|
7
|
+
|
|
8
|
+
/** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
|
|
9
|
+
export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
|
|
10
|
+
await requireAccess(resource, id ? "update" : "create");
|
|
11
|
+
// Hooks and computed values are functions, and a function can't be sent to the browser.
|
|
12
|
+
// This is the same resource without them, for the client components below.
|
|
13
|
+
const forClient = clientResource(resource);
|
|
14
|
+
let record: Record<string, unknown> | null = null;
|
|
15
|
+
if (id) {
|
|
16
|
+
const result = await dashboardStore(resource.name).get(id);
|
|
17
|
+
if (!result.ok) notFound();
|
|
18
|
+
record = result.data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const byName = new Map(allResources().map((r) => [r.name, r]));
|
|
22
|
+
const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
|
|
23
|
+
for (const [key, def] of storedFields(resource)) {
|
|
24
|
+
if (def.kind !== "belongsTo") continue;
|
|
25
|
+
const target = byName.get(def.target);
|
|
26
|
+
if (!target) continue;
|
|
27
|
+
const value = record?.[key];
|
|
28
|
+
const titles = typeof value === "string" ? await dashboardStore(target.name).titles([value]) : {};
|
|
29
|
+
relations[key] = {
|
|
30
|
+
name: target.name,
|
|
31
|
+
label: target.label,
|
|
32
|
+
pluralLabel: target.pluralLabel,
|
|
33
|
+
slug: target.slug,
|
|
34
|
+
titleField: target.titleField,
|
|
35
|
+
initialTitle: typeof value === "string" ? titles[value] : undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const titleDef = resource.fields[resource.titleField];
|
|
40
|
+
const title =
|
|
41
|
+
record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex flex-col gap-6">
|
|
45
|
+
<PageHeader
|
|
46
|
+
title={title}
|
|
47
|
+
description={record ? `Edit this ${resource.label.toLowerCase()}.` : `Add a ${resource.label.toLowerCase()} to ${resource.pluralLabel.toLowerCase()}.`}
|
|
48
|
+
crumbs={[
|
|
49
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
50
|
+
{ label: resource.pluralLabel, href: resourcePath(resource) },
|
|
51
|
+
{ label: record ? "Edit" : "New" },
|
|
52
|
+
]}
|
|
53
|
+
/>
|
|
54
|
+
<ResourceForm
|
|
55
|
+
resource={forClient}
|
|
56
|
+
mode={record ? "edit" : "create"}
|
|
57
|
+
id={id}
|
|
58
|
+
record={record}
|
|
59
|
+
relations={relations}
|
|
60
|
+
listHref={resourcePath(resource)}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|