@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
|
@@ -1,331 +1,408 @@
|
|
|
1
|
-
import Link from "next/link";
|
|
2
|
-
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
4
|
-
import { isSortable } from "@flaredev/core/server";
|
|
5
|
-
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
6
|
-
import { Badge } from "@/components/ui/badge";
|
|
7
|
-
import { Button } from "@/components/ui/button";
|
|
8
|
-
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
9
|
-
import {
|
|
10
|
-
Pagination,
|
|
11
|
-
PaginationContent,
|
|
12
|
-
PaginationItem,
|
|
13
|
-
PaginationLink,
|
|
14
|
-
PaginationNext,
|
|
15
|
-
PaginationPrevious,
|
|
16
|
-
} from "@/components/ui/pagination";
|
|
17
|
-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
18
|
-
import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
19
|
-
import { site } from "@/lib/site";
|
|
20
|
-
import { cn } from "@/lib/utils";
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
const
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
3
|
+
import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
4
|
+
import { isSortable } from "@flaredev/core/server";
|
|
5
|
+
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
9
|
+
import {
|
|
10
|
+
Pagination,
|
|
11
|
+
PaginationContent,
|
|
12
|
+
PaginationItem,
|
|
13
|
+
PaginationLink,
|
|
14
|
+
PaginationNext,
|
|
15
|
+
PaginationPrevious,
|
|
16
|
+
} from "@/components/ui/pagination";
|
|
17
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
18
|
+
import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
|
|
19
|
+
import { site } from "@/lib/site";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
import { ColumnMenu } from "./column-menu";
|
|
22
|
+
import { ExportButton } from "./export-button";
|
|
23
|
+
import { isInlineEditable } from "./editable";
|
|
24
|
+
import { InlineCell } from "./inline-cell";
|
|
25
|
+
import type { RelationMeta } from "./fields/field-widget";
|
|
26
|
+
import { ImportDialog } from "./import-dialog";
|
|
27
|
+
import { LocalTime } from "./local-time";
|
|
28
|
+
import { hrefWith, toSearchParams, type SearchParams } from "./query";
|
|
29
|
+
import { NewRecordButton } from "./resource-form-dialog";
|
|
30
|
+
import { ResourceTableToolbar } from "./resource-table-toolbar";
|
|
31
|
+
import { SaveViewButton } from "./save-view-button";
|
|
32
|
+
import { RowActions } from "./row-actions";
|
|
33
|
+
import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
|
|
34
|
+
|
|
35
|
+
type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
|
|
36
|
+
|
|
37
|
+
const NUMERIC = new Set(["int", "float"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Paginated, sortable, filterable list of a resource's records. Everything comes from the
|
|
41
|
+
* descriptor: columns (fields with `list`), sortable headers, search, and filters. State
|
|
42
|
+
* lives in the URL (?page, ?sort, ?q, ?filter[field]), so views are linkable and work
|
|
43
|
+
* without client-side data fetching.
|
|
44
|
+
*/
|
|
45
|
+
export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
|
|
46
|
+
await requireAccess(resource, "read");
|
|
47
|
+
// Hooks and computed values are functions, and a function can't be sent to the browser.
|
|
48
|
+
// This is the same resource without them, for the client components below.
|
|
49
|
+
const forClient = clientResource(resource);
|
|
50
|
+
const basePath = resourcePath(resource);
|
|
51
|
+
const permissions = await adminPermissions(resource.name);
|
|
52
|
+
const params = toSearchParams(searchParams);
|
|
53
|
+
const store = dashboardStore(resource.name);
|
|
54
|
+
|
|
55
|
+
let result = await store.list(params);
|
|
56
|
+
let ignoredQuery = false;
|
|
57
|
+
if (!result.ok) {
|
|
58
|
+
ignoredQuery = true;
|
|
59
|
+
result = await store.list(new URLSearchParams());
|
|
60
|
+
}
|
|
61
|
+
if (!result.ok) throw new Error(result.error);
|
|
62
|
+
const { data: rows, meta } = result.data;
|
|
63
|
+
|
|
64
|
+
const fields = storedFields(resource);
|
|
65
|
+
const allColumns: Column[] = [
|
|
66
|
+
...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
|
|
67
|
+
{ key: "createdAt", label: "Created", def: { kind: "timestamp" as const } },
|
|
68
|
+
];
|
|
69
|
+
// ?columns=name,email narrows the table; anything unknown in it is ignored.
|
|
70
|
+
const chosen = (params.get("columns") ?? "").split(",").filter(Boolean);
|
|
71
|
+
const columns = chosen.length > 0 ? allColumns.filter((column) => chosen.includes(column.key)) : allColumns;
|
|
72
|
+
|
|
73
|
+
// Resolve belongsTo ids to their related record titles, one query per relation.
|
|
74
|
+
const byName = new Map(allResources().map((r) => [r.name, r]));
|
|
75
|
+
const titles: Record<string, Record<string, string>> = {};
|
|
76
|
+
await Promise.all(
|
|
77
|
+
columns.map(async ({ key, def }) => {
|
|
78
|
+
if (def.kind !== "belongsTo") return;
|
|
79
|
+
const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
|
|
80
|
+
titles[key] = byName.has(def.target) ? await dashboardStore(def.target).titles(ids) : {};
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// The dialog form needs the same relation metadata the form page builds.
|
|
85
|
+
const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
|
|
86
|
+
for (const [key, def] of fields) {
|
|
87
|
+
if (def.kind !== "belongsTo") continue;
|
|
88
|
+
const target = byName.get(def.target);
|
|
89
|
+
if (!target) continue;
|
|
90
|
+
relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const modalForms = site.dashboard.forms === "modal";
|
|
94
|
+
const rowIds = rows.map((row) => String(row.id));
|
|
95
|
+
const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
|
|
96
|
+
|
|
97
|
+
const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
|
|
98
|
+
const sortField = sort.replace(/^-/, "");
|
|
99
|
+
const sortDescending = sort.startsWith("-");
|
|
100
|
+
const activeFilters = [...params.keys()].filter((key) => key.startsWith("filter[")).length + (params.get("q") ? 1 : 0);
|
|
101
|
+
const first = meta.total === 0 ? 0 : (meta.page - 1) * meta.perPage + 1;
|
|
102
|
+
const last = Math.min(meta.page * meta.perPage, meta.total);
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<TableSelection ids={rowIds}>
|
|
106
|
+
<div className="flex flex-col gap-4">
|
|
107
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
108
|
+
<ResourceTableToolbar resource={forClient} />
|
|
109
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
110
|
+
<SaveViewButton resourceName={resource.name} label={resource.pluralLabel} />
|
|
111
|
+
<ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
|
|
112
|
+
<ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
|
|
113
|
+
{permissions.create && <ImportDialog resource={forClient} />}
|
|
114
|
+
{permissions.create &&
|
|
115
|
+
(modalForms ? (
|
|
116
|
+
<NewRecordButton resource={forClient} relations={relations} listHref={basePath} />
|
|
117
|
+
) : (
|
|
118
|
+
<Button asChild>
|
|
119
|
+
<Link href={resourcePath(resource, "new")}>
|
|
120
|
+
<PlusIcon data-icon="inline-start" />
|
|
121
|
+
New {resource.label.toLowerCase()}
|
|
122
|
+
</Link>
|
|
123
|
+
</Button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{ignoredQuery && (
|
|
129
|
+
<Alert>
|
|
130
|
+
<AlertDescription>Some filters or sorting in the address weren't valid for {resource.pluralLabel.toLowerCase()} and were ignored.</AlertDescription>
|
|
131
|
+
</Alert>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{rows.length === 0 ? (
|
|
135
|
+
<Empty className="rounded-lg border border-dashed">
|
|
136
|
+
<EmptyHeader>
|
|
137
|
+
<EmptyTitle>{activeFilters ? `No ${resource.pluralLabel.toLowerCase()} match` : `No ${resource.pluralLabel.toLowerCase()} yet`}</EmptyTitle>
|
|
138
|
+
<EmptyDescription>
|
|
139
|
+
{activeFilters ? "Try a different search or clear the filters." : `Create the first ${resource.label.toLowerCase()} to see it here.`}
|
|
140
|
+
</EmptyDescription>
|
|
141
|
+
</EmptyHeader>
|
|
142
|
+
<EmptyContent>
|
|
143
|
+
{activeFilters || !permissions.create ? (
|
|
144
|
+
<Button variant="outline" asChild>
|
|
145
|
+
<Link href={basePath}>Clear filters</Link>
|
|
146
|
+
</Button>
|
|
147
|
+
) : (
|
|
148
|
+
<Button asChild>
|
|
149
|
+
<Link href={resourcePath(resource, "new")}>
|
|
150
|
+
<PlusIcon data-icon="inline-start" />
|
|
151
|
+
New {resource.label.toLowerCase()}
|
|
152
|
+
</Link>
|
|
153
|
+
</Button>
|
|
154
|
+
)}
|
|
155
|
+
</EmptyContent>
|
|
156
|
+
</Empty>
|
|
157
|
+
) : (
|
|
158
|
+
<div className="overflow-hidden rounded-lg border bg-card">
|
|
159
|
+
<Table>
|
|
160
|
+
<TableHeader className="bg-muted">
|
|
161
|
+
<TableRow className="hover:bg-muted">
|
|
162
|
+
<TableHead className="w-10">
|
|
163
|
+
<SelectAllCheckbox label={resource.pluralLabel.toLowerCase()} />
|
|
164
|
+
</TableHead>
|
|
165
|
+
{columns.map((column) => {
|
|
166
|
+
const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
|
|
167
|
+
const active = sortField === column.key;
|
|
168
|
+
const nextSort = active && !sortDescending ? `-${column.key}` : column.key;
|
|
169
|
+
const SortIcon = !active ? ChevronsUpDownIcon : sortDescending ? ArrowDownIcon : ArrowUpIcon;
|
|
170
|
+
return (
|
|
171
|
+
<TableHead key={column.key} className={cn(NUMERIC.has(column.def.kind) && "text-right")}>
|
|
172
|
+
{sortable ? (
|
|
173
|
+
<Link
|
|
174
|
+
href={hrefWith(basePath, params, { sort: nextSort })}
|
|
175
|
+
className="group inline-flex items-center gap-1 hover:text-foreground"
|
|
176
|
+
aria-label={`Sort by ${column.label}`}
|
|
177
|
+
>
|
|
178
|
+
{column.label}
|
|
179
|
+
<SortIcon className={cn("size-3.5", active ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
|
|
180
|
+
</Link>
|
|
181
|
+
) : (
|
|
182
|
+
column.label
|
|
183
|
+
)}
|
|
184
|
+
</TableHead>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
<TableHead className="w-12">
|
|
188
|
+
<span className="sr-only">Actions</span>
|
|
189
|
+
</TableHead>
|
|
190
|
+
</TableRow>
|
|
191
|
+
</TableHeader>
|
|
192
|
+
<TableBody>
|
|
193
|
+
{rows.map((row) => {
|
|
194
|
+
const id = String(row.id);
|
|
195
|
+
return (
|
|
196
|
+
<TableRow key={id}>
|
|
197
|
+
<TableCell className="w-10">
|
|
198
|
+
<RowCheckbox id={id} label={rowLabel(resource, row)} />
|
|
199
|
+
</TableCell>
|
|
200
|
+
{columns.map((column, index) => {
|
|
201
|
+
const value = row[column.key];
|
|
202
|
+
let content: React.ReactNode = formatValue(column.def, value);
|
|
203
|
+
// Timestamps are formatted in the browser: the Worker's clock is UTC,
|
|
204
|
+
// so a date rendered here would be in nobody's timezone.
|
|
205
|
+
if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
|
|
206
|
+
content = <LocalTime value={value as string | number | Date} />;
|
|
207
|
+
} else if (column.def.kind === "enum" && value != null) {
|
|
208
|
+
const tone = statusTone(value);
|
|
209
|
+
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
210
|
+
} else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
|
|
211
|
+
const def = column.def;
|
|
212
|
+
content = (
|
|
213
|
+
<span className="flex flex-wrap gap-1">
|
|
214
|
+
{value.map((item) => (
|
|
215
|
+
<Badge key={String(item)} variant="secondary">
|
|
216
|
+
{optionLabel(def, String(item))}
|
|
217
|
+
</Badge>
|
|
218
|
+
))}
|
|
219
|
+
</span>
|
|
220
|
+
);
|
|
221
|
+
} else if (index !== 0 && column.def.kind === "string" && typeof value === "string" && value) {
|
|
222
|
+
// Contact-style values are links; a colour shows its swatch.
|
|
223
|
+
const format = column.def.format;
|
|
224
|
+
const href =
|
|
225
|
+
format === "email" ? `mailto:${value}` : format === "tel" ? `tel:${value}` : format === "url" ? value : format === "domain" ? `https://${value}` : undefined;
|
|
226
|
+
if (href) {
|
|
227
|
+
content = (
|
|
228
|
+
<a href={href} className="hover:underline" {...(format === "url" || format === "domain" ? { target: "_blank", rel: "noreferrer" } : {})}>
|
|
229
|
+
{content}
|
|
230
|
+
</a>
|
|
231
|
+
);
|
|
232
|
+
} else if (format === "color") {
|
|
233
|
+
content = (
|
|
234
|
+
<span className="inline-flex items-center gap-2 font-mono text-xs">
|
|
235
|
+
<span className="size-3.5 rounded-sm border" style={{ backgroundColor: value }} aria-hidden="true" />
|
|
236
|
+
{value}
|
|
237
|
+
</span>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
} else if (column.def.kind === "belongsTo" && typeof value === "string") {
|
|
241
|
+
const target = byName.get(column.def.target);
|
|
242
|
+
content = target ? (
|
|
243
|
+
<Link href={resourcePath(target, value)} className="hover:underline">
|
|
244
|
+
{titles[column.key]?.[value] ?? value}
|
|
245
|
+
</Link>
|
|
246
|
+
) : (
|
|
247
|
+
value
|
|
248
|
+
);
|
|
249
|
+
} else if (index === 0) {
|
|
250
|
+
content = (
|
|
251
|
+
<Link href={resourcePath(resource, id)} className="font-medium hover:underline">
|
|
252
|
+
{content}
|
|
253
|
+
</Link>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// The first column is the link to the record; the rest of the simple
|
|
257
|
+
// ones can be changed where they are.
|
|
258
|
+
if (index !== 0 && permissions.update && column.def.kind !== "timestamp" && isInlineEditable(column.def)) {
|
|
259
|
+
content = (
|
|
260
|
+
<InlineCell
|
|
261
|
+
resourceName={resource.name}
|
|
262
|
+
id={id}
|
|
263
|
+
fieldKey={column.key}
|
|
264
|
+
def={column.def as StoredField & { label: string }}
|
|
265
|
+
value={value}
|
|
266
|
+
>
|
|
267
|
+
{content}
|
|
268
|
+
</InlineCell>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return (
|
|
272
|
+
<TableCell
|
|
273
|
+
key={column.key}
|
|
274
|
+
className={cn(
|
|
275
|
+
"max-w-80 truncate",
|
|
276
|
+
NUMERIC.has(column.def.kind) && "text-right tabular-nums",
|
|
277
|
+
(column.def.kind === "timestamp" || column.def.kind === "datetime" || column.def.kind === "date") &&
|
|
278
|
+
"text-muted-foreground tabular-nums",
|
|
279
|
+
)}
|
|
280
|
+
>
|
|
281
|
+
{content}
|
|
282
|
+
</TableCell>
|
|
283
|
+
);
|
|
284
|
+
})}
|
|
285
|
+
<TableCell className="text-right">
|
|
286
|
+
<RowActions
|
|
287
|
+
resource={forClient}
|
|
288
|
+
id={id}
|
|
289
|
+
record={modalForms ? row : undefined}
|
|
290
|
+
relations={relations}
|
|
291
|
+
listHref={basePath}
|
|
292
|
+
editHref={resourcePath(resource, id, "edit")}
|
|
293
|
+
detailHref={resourcePath(resource, id)}
|
|
294
|
+
canUpdate={permissions.update}
|
|
295
|
+
canDelete={permissions.delete}
|
|
296
|
+
/>
|
|
297
|
+
</TableCell>
|
|
298
|
+
</TableRow>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</TableBody>
|
|
302
|
+
</Table>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{(meta.total > 0 || rows.length > 0) && (
|
|
307
|
+
<div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
|
|
308
|
+
<p className="tabular-nums">
|
|
309
|
+
{meta.exactTotal ? (
|
|
310
|
+
<>
|
|
311
|
+
{first}–{last} of {meta.total.toLocaleString()} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
|
|
312
|
+
</>
|
|
313
|
+
) : (
|
|
314
|
+
<>
|
|
315
|
+
Showing {rows.length} of more than {meta.total.toLocaleString()} {resource.pluralLabel.toLowerCase()}
|
|
316
|
+
</>
|
|
317
|
+
)}
|
|
318
|
+
{activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
|
|
319
|
+
</p>
|
|
320
|
+
|
|
321
|
+
{meta.exactTotal && meta.totalPages > 1 && (
|
|
322
|
+
<Pagination className="mx-0 w-auto">
|
|
323
|
+
<PaginationContent>
|
|
324
|
+
<PaginationItem>
|
|
325
|
+
<PaginationPrevious
|
|
326
|
+
href={hrefWith(basePath, params, { page: String(Math.max(1, meta.page - 1)) })}
|
|
327
|
+
aria-disabled={meta.page === 1}
|
|
328
|
+
className={cn(meta.page === 1 && "pointer-events-none opacity-50")}
|
|
329
|
+
/>
|
|
330
|
+
</PaginationItem>
|
|
331
|
+
{pageWindow(meta.page, meta.totalPages).map((page) => (
|
|
332
|
+
<PaginationItem key={page}>
|
|
333
|
+
<PaginationLink href={hrefWith(basePath, params, { page: String(page) })} isActive={page === meta.page}>
|
|
334
|
+
{page}
|
|
335
|
+
</PaginationLink>
|
|
336
|
+
</PaginationItem>
|
|
337
|
+
))}
|
|
338
|
+
<PaginationItem>
|
|
339
|
+
<PaginationNext
|
|
340
|
+
href={hrefWith(basePath, params, { page: String(Math.min(meta.totalPages, meta.page + 1)) })}
|
|
341
|
+
aria-disabled={meta.page === meta.totalPages}
|
|
342
|
+
className={cn(meta.page === meta.totalPages && "pointer-events-none opacity-50")}
|
|
343
|
+
/>
|
|
344
|
+
</PaginationItem>
|
|
345
|
+
</PaginationContent>
|
|
346
|
+
</Pagination>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
{/* Past the count limit, pages are walked with cursors: numbering them would mean
|
|
350
|
+
counting every row, and jumping to page 20,000 would mean reading the 500,000
|
|
351
|
+
rows before it. */}
|
|
352
|
+
{!meta.exactTotal && (
|
|
353
|
+
<div className="flex items-center gap-2">
|
|
354
|
+
<Button variant="outline" size="sm" asChild={Boolean(meta.prevCursor)} disabled={!meta.prevCursor}>
|
|
355
|
+
{meta.prevCursor ? (
|
|
356
|
+
<Link href={hrefWith(basePath, params, { cursor: meta.prevCursor, page: null })}>
|
|
357
|
+
<ChevronLeftIcon data-icon="inline-start" />
|
|
358
|
+
Previous
|
|
359
|
+
</Link>
|
|
360
|
+
) : (
|
|
361
|
+
<span>
|
|
362
|
+
<ChevronLeftIcon data-icon="inline-start" />
|
|
363
|
+
Previous
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
</Button>
|
|
367
|
+
<Button variant="outline" size="sm" asChild={Boolean(meta.nextCursor)} disabled={!meta.nextCursor}>
|
|
368
|
+
{meta.nextCursor ? (
|
|
369
|
+
<Link href={hrefWith(basePath, params, { cursor: meta.nextCursor, page: null })}>
|
|
370
|
+
Next
|
|
371
|
+
<ChevronRightIcon data-icon="inline-end" />
|
|
372
|
+
</Link>
|
|
373
|
+
) : (
|
|
374
|
+
<span>
|
|
375
|
+
Next
|
|
376
|
+
<ChevronRightIcon data-icon="inline-end" />
|
|
377
|
+
</span>
|
|
378
|
+
)}
|
|
379
|
+
</Button>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
<SelectionBar
|
|
386
|
+
resourceName={resource.name}
|
|
387
|
+
label={resource.label}
|
|
388
|
+
pluralLabel={resource.pluralLabel}
|
|
389
|
+
canDelete={permissions.delete}
|
|
390
|
+
rows={rows}
|
|
391
|
+
columns={exportColumns}
|
|
392
|
+
/>
|
|
393
|
+
</div>
|
|
394
|
+
</TableSelection>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** What a row is called, for the checkbox's label. */
|
|
399
|
+
function rowLabel(resource: Resource, row: Record<string, unknown>): string {
|
|
400
|
+
const value = row[resource.titleField];
|
|
401
|
+
return typeof value === "string" && value ? value : String(row.id);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Up to five page numbers centred on the current page. */
|
|
405
|
+
function pageWindow(page: number, totalPages: number): number[] {
|
|
406
|
+
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
|
407
|
+
return Array.from({ length: Math.min(5, totalPages) }, (_, i) => start + i);
|
|
408
|
+
}
|