@flaredev/cli 0.2.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/bin/flare.js +9 -1
- package/dist/index.js +991 -344
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -31
- 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 +246 -0
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +66 -39
- package/templates/app/app/dashboard/observability/page.tsx +175 -0
- package/templates/app/app/dashboard/page.tsx +108 -27
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
- package/templates/app/components/dashboard/import-dialog.tsx +540 -0
- 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/page-header.tsx +57 -0
- package/templates/app/components/{admin → 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-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +48 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +55 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/dashboard.ts +165 -0
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +25 -2
- package/templates/app/migrations/meta/0000_snapshot.json +159 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/locks/yarn.lock +3548 -0
- package/templates/app/app/admin/actions.ts +0 -113
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/admin/admin-header.tsx +0 -78
- package/templates/app/components/admin/resource-nav.tsx +0 -120
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- package/templates/app/lib/admin.ts +0 -103
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PlusIcon } from "lucide-react";
|
|
5
|
+
import type { Resource } from "@flaredev/core";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
8
|
+
import type { RelationMeta } from "./fields/field-widget";
|
|
9
|
+
import { ResourceForm } from "./resource-form";
|
|
10
|
+
|
|
11
|
+
export type FormRelations = Record<string, RelationMeta & { initialTitle?: string }>;
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
resource: Resource;
|
|
15
|
+
relations: FormRelations;
|
|
16
|
+
listHref: string;
|
|
17
|
+
mode: "create" | "edit";
|
|
18
|
+
/** Edit: the record and its id. */
|
|
19
|
+
id?: string;
|
|
20
|
+
record?: Record<string, unknown> | null;
|
|
21
|
+
/** Controlled use (a row menu opens it); otherwise pass `trigger`. */
|
|
22
|
+
open?: boolean;
|
|
23
|
+
onOpenChange?: (open: boolean) => void;
|
|
24
|
+
trigger?: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The create/edit form in a dialog, so adding a record doesn't lose the list, its
|
|
29
|
+
* filters or the place in it. Apps that would rather have a page set
|
|
30
|
+
* `site.dashboard.forms` to "page" (lib/site.ts) and get the same form full width.
|
|
31
|
+
*/
|
|
32
|
+
export function ResourceFormDialog({ resource, relations, listHref, mode, id, record, open, onOpenChange, trigger }: Props) {
|
|
33
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
34
|
+
const isOpen = open ?? internalOpen;
|
|
35
|
+
const setOpen = onOpenChange ?? setInternalOpen;
|
|
36
|
+
const noun = resource.label.toLowerCase();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Dialog open={isOpen} onOpenChange={setOpen}>
|
|
40
|
+
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
|
41
|
+
<DialogContent className="sm:max-w-2xl">
|
|
42
|
+
<DialogHeader>
|
|
43
|
+
<DialogTitle>{mode === "create" ? `New ${noun}` : `Edit ${noun}`}</DialogTitle>
|
|
44
|
+
<DialogDescription>{mode === "create" ? `Fill this in and it appears in the list.` : "Changes save straight away."}</DialogDescription>
|
|
45
|
+
</DialogHeader>
|
|
46
|
+
{/* Remounting on open clears whatever was typed and abandoned last time. */}
|
|
47
|
+
{isOpen && (
|
|
48
|
+
<ResourceForm
|
|
49
|
+
key={`${mode}-${id ?? "new"}`}
|
|
50
|
+
resource={resource}
|
|
51
|
+
mode={mode}
|
|
52
|
+
id={id}
|
|
53
|
+
record={record}
|
|
54
|
+
relations={relations}
|
|
55
|
+
listHref={listHref}
|
|
56
|
+
onDone={() => setOpen(false)}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</DialogContent>
|
|
60
|
+
</Dialog>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The list page's "New …" button, when forms open in a dialog. */
|
|
65
|
+
export function NewRecordButton({ resource, relations, listHref }: { resource: Resource; relations: FormRelations; listHref: string }) {
|
|
66
|
+
return (
|
|
67
|
+
<ResourceFormDialog
|
|
68
|
+
resource={resource}
|
|
69
|
+
relations={relations}
|
|
70
|
+
listHref={listHref}
|
|
71
|
+
mode="create"
|
|
72
|
+
trigger={
|
|
73
|
+
<Button>
|
|
74
|
+
<PlusIcon data-icon="inline-start" />
|
|
75
|
+
New {resource.label.toLowerCase()}
|
|
76
|
+
</Button>
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -1,55 +1,61 @@
|
|
|
1
|
-
import { notFound } from "next/navigation";
|
|
2
|
-
import { formatValue, storedFields, type Resource } from "@flaredev/core";
|
|
3
|
-
import {
|
|
4
|
-
import type { RelationMeta } from "./fields/field-widget";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
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
|
+
let record: Record<string, unknown> | null = null;
|
|
12
|
+
if (id) {
|
|
13
|
+
const result = await dashboardStore(resource.name).get(id);
|
|
14
|
+
if (!result.ok) notFound();
|
|
15
|
+
record = result.data;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const byName = new Map(allResources().map((r) => [r.name, r]));
|
|
19
|
+
const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
|
|
20
|
+
for (const [key, def] of storedFields(resource)) {
|
|
21
|
+
if (def.kind !== "belongsTo") continue;
|
|
22
|
+
const target = byName.get(def.target);
|
|
23
|
+
if (!target) continue;
|
|
24
|
+
const value = record?.[key];
|
|
25
|
+
const titles = typeof value === "string" ? await dashboardStore(target.name).titles([value]) : {};
|
|
26
|
+
relations[key] = {
|
|
27
|
+
name: target.name,
|
|
28
|
+
label: target.label,
|
|
29
|
+
pluralLabel: target.pluralLabel,
|
|
30
|
+
slug: target.slug,
|
|
31
|
+
titleField: target.titleField,
|
|
32
|
+
initialTitle: typeof value === "string" ? titles[value] : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const titleDef = resource.fields[resource.titleField];
|
|
37
|
+
const title =
|
|
38
|
+
record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="flex flex-col gap-6">
|
|
42
|
+
<PageHeader
|
|
43
|
+
title={title}
|
|
44
|
+
description={record ? `Edit this ${resource.label.toLowerCase()}.` : `Add a ${resource.label.toLowerCase()} to ${resource.pluralLabel.toLowerCase()}.`}
|
|
45
|
+
crumbs={[
|
|
46
|
+
{ label: "Dashboard", href: "/dashboard" },
|
|
47
|
+
{ label: resource.pluralLabel, href: resourcePath(resource) },
|
|
48
|
+
{ label: record ? "Edit" : "New" },
|
|
49
|
+
]}
|
|
50
|
+
/>
|
|
51
|
+
<ResourceForm
|
|
52
|
+
resource={resource}
|
|
53
|
+
mode={record ? "edit" : "create"}
|
|
54
|
+
id={id}
|
|
55
|
+
record={record}
|
|
56
|
+
relations={relations}
|
|
57
|
+
listHref={resourcePath(resource)}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,139 +1,165 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
|
6
|
-
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type Resource } from "@flaredev/core";
|
|
7
|
-
import { toast } from "sonner";
|
|
8
|
-
import { createRecordAction, updateRecordAction } from "@/app/
|
|
9
|
-
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
10
|
-
import { Button } from "@/components/ui/button";
|
|
11
|
-
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
|
12
|
-
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
13
|
-
import { Spinner } from "@/components/ui/spinner";
|
|
14
|
-
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
15
|
-
|
|
16
|
-
export interface ResourceFormProps {
|
|
17
|
-
resource: Resource;
|
|
18
|
-
mode: "create" | "edit";
|
|
19
|
-
/** Record id (edit mode). */
|
|
20
|
-
id?: string;
|
|
21
|
-
record?: Record<string, unknown> | null;
|
|
22
|
-
/** belongsTo field key → related resource metadata and the current value's title. */
|
|
23
|
-
relations?: Record<string, RelationMeta & { initialTitle?: string }>;
|
|
24
|
-
/** Where Cancel and a successful save go. */
|
|
25
|
-
listHref: string;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const [
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useMemo, useState, useTransition, type FormEvent } from "react";
|
|
6
|
+
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type Resource } from "@flaredev/core";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
|
12
|
+
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
13
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
14
|
+
import { FieldWidget, type RelationMeta } from "./fields/field-widget";
|
|
15
|
+
|
|
16
|
+
export interface ResourceFormProps {
|
|
17
|
+
resource: Resource;
|
|
18
|
+
mode: "create" | "edit";
|
|
19
|
+
/** Record id (edit mode). */
|
|
20
|
+
id?: string;
|
|
21
|
+
record?: Record<string, unknown> | null;
|
|
22
|
+
/** belongsTo field key → related resource metadata and the current value's title. */
|
|
23
|
+
relations?: Record<string, RelationMeta & { initialTitle?: string }>;
|
|
24
|
+
/** Where Cancel and a successful save go, when the form owns the page. */
|
|
25
|
+
listHref: string;
|
|
26
|
+
/** In a dialog: close it instead of navigating, and drop the card around the fields. */
|
|
27
|
+
onDone?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const WIDE = new Set(["text", "file"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create/edit form rendered from field metadata. Values are validated in the browser
|
|
34
|
+
* with the descriptor's zod schemas, then again on the server by the same store the
|
|
35
|
+
* REST API uses; server errors (e.g. unique conflicts) land on their fields.
|
|
36
|
+
*/
|
|
37
|
+
export function ResourceForm({ resource, mode, id, record, relations = {}, listHref, onDone }: ResourceFormProps) {
|
|
38
|
+
const router = useRouter();
|
|
39
|
+
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
40
|
+
const fields = useMemo(() => storedFields(resource), [resource]);
|
|
41
|
+
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
42
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
43
|
+
const [pending, startTransition] = useTransition();
|
|
44
|
+
|
|
45
|
+
const setValue = (key: string, value: string | boolean) => {
|
|
46
|
+
setValues((current) => ({ ...current, [key]: value }));
|
|
47
|
+
if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function focusFirstError(fieldErrors: Record<string, string>) {
|
|
51
|
+
const first = fields.find(([key]) => fieldErrors[key]);
|
|
52
|
+
if (first) document.getElementById(`field-${first[0]}`)?.focus();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onSubmit(event: FormEvent) {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
const input = formValuesToInput(resource, values, mode);
|
|
58
|
+
const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
const fieldErrors = issuesByField(parsed.error.issues);
|
|
61
|
+
setErrors(fieldErrors);
|
|
62
|
+
focusFirstError(fieldErrors);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
const result = mode === "create" ? await createRecordAction(resource.name, input) : await updateRecordAction(resource.name, id!, input);
|
|
68
|
+
if (result.ok) {
|
|
69
|
+
toast.success(`${resource.label} ${mode === "create" ? "created" : "saved"}.`);
|
|
70
|
+
if (onDone) onDone();
|
|
71
|
+
else router.push(listHref);
|
|
72
|
+
router.refresh();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
|
|
76
|
+
setErrors(fieldErrors);
|
|
77
|
+
focusFirstError(fieldErrors);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const Frame = onDone ? DialogFrame : Card;
|
|
82
|
+
const Body = onDone ? DialogBody : CardContent;
|
|
83
|
+
const Footer = onDone ? DialogFooterRow : CardFooter;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<form onSubmit={onSubmit} noValidate>
|
|
87
|
+
<Frame>
|
|
88
|
+
<Body>
|
|
89
|
+
{errors._form && (
|
|
90
|
+
<Alert variant="destructive" className="mb-6">
|
|
91
|
+
<AlertDescription>{errors._form}</AlertDescription>
|
|
92
|
+
</Alert>
|
|
93
|
+
)}
|
|
94
|
+
<FieldGroup className="grid gap-6 md:grid-cols-2">
|
|
95
|
+
{fields.map(([key, def]) => {
|
|
96
|
+
const error = errors[key];
|
|
97
|
+
const widgetId = `field-${key}`;
|
|
98
|
+
const horizontal = def.kind === "boolean";
|
|
99
|
+
return (
|
|
100
|
+
<Field
|
|
101
|
+
key={key}
|
|
102
|
+
data-invalid={error ? true : undefined}
|
|
103
|
+
className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}
|
|
104
|
+
>
|
|
105
|
+
<FieldLabel htmlFor={widgetId}>
|
|
106
|
+
{def.label}
|
|
107
|
+
{def.required && def.kind !== "boolean" && (
|
|
108
|
+
<span className="ml-0.5 text-muted-foreground" aria-hidden>
|
|
109
|
+
*
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</FieldLabel>
|
|
113
|
+
<div className={horizontal ? "flex h-9 items-center" : undefined}>
|
|
114
|
+
<FieldWidget
|
|
115
|
+
id={widgetId}
|
|
116
|
+
name={key}
|
|
117
|
+
resourceName={resource.name}
|
|
118
|
+
fieldKey={key}
|
|
119
|
+
field={def}
|
|
120
|
+
value={values[key] ?? ""}
|
|
121
|
+
onChange={(value) => setValue(key, value)}
|
|
122
|
+
invalid={Boolean(error)}
|
|
123
|
+
disabled={pending}
|
|
124
|
+
relation={relations[key]}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
{def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
|
|
128
|
+
{error && <FieldError>{error}</FieldError>}
|
|
129
|
+
</Field>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</FieldGroup>
|
|
133
|
+
</Body>
|
|
134
|
+
<Footer className="flex justify-end gap-2 border-t pt-4">
|
|
135
|
+
{onDone ? (
|
|
136
|
+
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
|
|
137
|
+
Cancel
|
|
138
|
+
</Button>
|
|
139
|
+
) : (
|
|
140
|
+
<Button variant="outline" asChild>
|
|
141
|
+
<Link href={listHref}>Cancel</Link>
|
|
142
|
+
</Button>
|
|
143
|
+
)}
|
|
144
|
+
<Button type="submit" disabled={pending}>
|
|
145
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
146
|
+
{mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
|
|
147
|
+
</Button>
|
|
148
|
+
</Footer>
|
|
149
|
+
</Frame>
|
|
150
|
+
</form>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Plain wrappers so the same form works in a page (a card) and in a dialog (no card). */
|
|
155
|
+
function DialogFrame({ children }: { children: React.ReactNode }) {
|
|
156
|
+
return <div className="flex flex-col gap-4">{children}</div>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function DialogBody({ children }: { children: React.ReactNode }) {
|
|
160
|
+
return <div className="max-h-[60vh] overflow-y-auto px-1">{children}</div>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function DialogFooterRow({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
164
|
+
return <div className={className}>{children}</div>;
|
|
165
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { storedFields, optionLabel, type Resource } from "@flaredev/core";
|
|
2
|
+
import { recentCounts, recordCount, trend, valueCounts } from "@/lib/dashboard";
|
|
3
|
+
import { resourceIcon } from "./resource-icon";
|
|
4
|
+
import { StatCards, type Stat } from "./stat-card";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The numbers above a resource's table: how many there are, how many are new, and how
|
|
8
|
+
* they split across the resource's own status field.
|
|
9
|
+
*
|
|
10
|
+
* The split comes from the first enum field the descriptor marks as filterable, which
|
|
11
|
+
* in practice is the status of the thing — a deal's stage, an invoice's state.
|
|
12
|
+
*/
|
|
13
|
+
export async function ResourceStats({ resource }: { resource: Resource }) {
|
|
14
|
+
const [total, recent] = await Promise.all([recordCount(resource.name), recentCounts(resource.name)]);
|
|
15
|
+
|
|
16
|
+
const stats: Stat[] = [
|
|
17
|
+
{
|
|
18
|
+
label: `Total ${resource.pluralLabel.toLowerCase()}`,
|
|
19
|
+
value: total,
|
|
20
|
+
icon: resourceIcon(resource.icon),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: "New this week",
|
|
24
|
+
value: recent.current,
|
|
25
|
+
change: trend(recent.current, recent.previous),
|
|
26
|
+
hint: `${recent.previous.toLocaleString()} the week before`,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const statusField = storedFields(resource).find(([, def]) => def.kind === "enum" && def.filterable !== false);
|
|
31
|
+
if (statusField && total > 0) {
|
|
32
|
+
const [key, def] = statusField;
|
|
33
|
+
if (def.kind === "enum") {
|
|
34
|
+
const counts = await valueCounts(resource.name, key);
|
|
35
|
+
// Two options at most: a strip of eight cards is a wall, not a summary.
|
|
36
|
+
for (const option of def.options.slice(0, 2)) {
|
|
37
|
+
const value = counts[option] ?? 0;
|
|
38
|
+
stats.push({
|
|
39
|
+
label: optionLabel(def, option),
|
|
40
|
+
value,
|
|
41
|
+
hint: `${Math.round((value / total) * 100)}% of all`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return <StatCards stats={stats.slice(0, 4)} />;
|
|
48
|
+
}
|