create-davepi-ui 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/index.js +229 -0
- package/bin/sync-templates.js +100 -0
- package/package.json +40 -0
- package/templates/default/.env.example +1 -0
- package/templates/default/index.html +13 -0
- package/templates/default/package.json +49 -0
- package/templates/default/postcss.config.cjs +6 -0
- package/templates/default/src/App.tsx +42 -0
- package/templates/default/src/components/AppShell.tsx +23 -0
- package/templates/default/src/components/BulkActionBar.tsx +47 -0
- package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
- package/templates/default/src/components/RelatedList.tsx +70 -0
- package/templates/default/src/components/ResourceForm.tsx +311 -0
- package/templates/default/src/components/ResourceTable.tsx +475 -0
- package/templates/default/src/components/RowActions.tsx +54 -0
- package/templates/default/src/components/Sidebar.tsx +171 -0
- package/templates/default/src/components/ui/button.tsx +43 -0
- package/templates/default/src/components/ui/card.tsx +47 -0
- package/templates/default/src/components/ui/checkbox.tsx +24 -0
- package/templates/default/src/components/ui/command.tsx +117 -0
- package/templates/default/src/components/ui/dialog.tsx +95 -0
- package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
- package/templates/default/src/components/ui/input.tsx +18 -0
- package/templates/default/src/components/ui/label.tsx +17 -0
- package/templates/default/src/components/ui/popover.tsx +27 -0
- package/templates/default/src/components/ui/select.tsx +83 -0
- package/templates/default/src/components/ui/switch.tsx +21 -0
- package/templates/default/src/components/ui/table.tsx +66 -0
- package/templates/default/src/components/ui/tabs.tsx +53 -0
- package/templates/default/src/components/ui/textarea.tsx +17 -0
- package/templates/default/src/davepi-ui.config.ts +14 -0
- package/templates/default/src/index.css +55 -0
- package/templates/default/src/lib/utils.ts +10 -0
- package/templates/default/src/main.tsx +34 -0
- package/templates/default/src/pages/DashboardPage.tsx +42 -0
- package/templates/default/src/pages/LoginScreen.tsx +77 -0
- package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
- package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
- package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
- package/templates/default/src/pages/ResourceListPage.tsx +8 -0
- package/templates/default/src/resourceOverrides.ts +34 -0
- package/templates/default/src/resources/account.ts +25 -0
- package/templates/default/src/resources/category.ts +7 -0
- package/templates/default/src/resources/contact.ts +40 -0
- package/templates/default/src/resources/product.ts +7 -0
- package/templates/default/src/resources/project.ts +7 -0
- package/templates/default/src/resources/quote.ts +12 -0
- package/templates/default/src/vite-env.d.ts +9 -0
- package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
- package/templates/default/src/widgets/DateInput.tsx +36 -0
- package/templates/default/src/widgets/EmailInput.tsx +28 -0
- package/templates/default/src/widgets/EnumSelect.tsx +35 -0
- package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
- package/templates/default/src/widgets/JsonEditor.tsx +64 -0
- package/templates/default/src/widgets/NumberInput.tsx +36 -0
- package/templates/default/src/widgets/RelationPicker.tsx +349 -0
- package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
- package/templates/default/src/widgets/TagInput.tsx +83 -0
- package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
- package/templates/default/src/widgets/TextInput.tsx +32 -0
- package/templates/default/src/widgets/UrlInput.tsx +27 -0
- package/templates/default/src/widgets/registry.ts +51 -0
- package/templates/default/src/widgets/types.ts +26 -0
- package/templates/default/tailwind.config.ts +54 -0
- package/templates/default/tsconfig.json +40 -0
- package/templates/default/vite.config.ts +16 -0
- package/templates/pinned-versions.json +5 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { ArrowDown, ArrowUp, ChevronsUpDown, Plus, Search } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
useDeleteResource,
|
|
6
|
+
useDescribe,
|
|
7
|
+
useResourceList,
|
|
8
|
+
useResourceConfig,
|
|
9
|
+
useResourcePerm,
|
|
10
|
+
} from '@davepi/ui-react';
|
|
11
|
+
import {
|
|
12
|
+
Table,
|
|
13
|
+
TableBody,
|
|
14
|
+
TableCell,
|
|
15
|
+
TableHead,
|
|
16
|
+
TableHeader,
|
|
17
|
+
TableRow,
|
|
18
|
+
} from '@/components/ui/table';
|
|
19
|
+
import { Input } from '@/components/ui/input';
|
|
20
|
+
import { Button } from '@/components/ui/button';
|
|
21
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
22
|
+
import { labelize, type descriptor } from '@davepi/ui-core';
|
|
23
|
+
import { cn } from '@/lib/utils';
|
|
24
|
+
import { RowActions } from './RowActions';
|
|
25
|
+
import { BulkActionBar } from './BulkActionBar';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Schema-driven resource list table.
|
|
29
|
+
*
|
|
30
|
+
* Three layers compose to produce the table:
|
|
31
|
+
* 1. `/_describe` — types, sortable hints, search affordance.
|
|
32
|
+
* 2. Consumer config (`davepi-ui.config.ts` + per-resource override file)
|
|
33
|
+
* — `listColumns`, `actions.row`, `actions.bulk`, `permissions`.
|
|
34
|
+
* 3. Inline JSX props — last-mile overrides on `columns`/`filters`.
|
|
35
|
+
*
|
|
36
|
+
* Selection is opt-in: appears when at least one bulk action is configured.
|
|
37
|
+
* Row actions menu (`...`) appears when at least one row action is configured.
|
|
38
|
+
* Both menus and the "New" button respect `permissions.create/delete` and
|
|
39
|
+
* the live JWT roles.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* <ResourceTable resourcePath="account" />
|
|
43
|
+
*/
|
|
44
|
+
export interface ResourceTableProps {
|
|
45
|
+
resourcePath: string;
|
|
46
|
+
/** Override the default first-N columns. Wins over consumer config. */
|
|
47
|
+
columns?: string[] | descriptor.ColumnSpec[];
|
|
48
|
+
/** Hidden filters merged into every list call. */
|
|
49
|
+
filters?: Record<string, unknown>;
|
|
50
|
+
/**
|
|
51
|
+
* Embedded mode: hides the page-level header (title, search bar, "New")
|
|
52
|
+
* so the table can sit inside another container (typically `<RelatedList>`).
|
|
53
|
+
* Standalone mode (default) renders the full chrome.
|
|
54
|
+
*/
|
|
55
|
+
embedded?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* When true (default in standalone mode), filter params from the URL
|
|
58
|
+
* (e.g. `?accountId=xxx`) are merged into the list query. Lets a child
|
|
59
|
+
* list page deep-link back from a parent detail.
|
|
60
|
+
*/
|
|
61
|
+
readUrlFilters?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const STAMPED = new Set(['_id', '__v', 'createdAt', 'updatedAt', 'deletedAt', 'userId', 'accountId']);
|
|
65
|
+
|
|
66
|
+
interface NormalizedColumn {
|
|
67
|
+
field: string;
|
|
68
|
+
label: string;
|
|
69
|
+
format?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ResourceTable({
|
|
73
|
+
resourcePath,
|
|
74
|
+
columns,
|
|
75
|
+
filters,
|
|
76
|
+
embedded = false,
|
|
77
|
+
readUrlFilters,
|
|
78
|
+
}: ResourceTableProps) {
|
|
79
|
+
const { data: describe } = useDescribe();
|
|
80
|
+
const config = useResourceConfig(resourcePath);
|
|
81
|
+
const createPerm = useResourcePerm(resourcePath, 'create');
|
|
82
|
+
const deletePerm = useResourcePerm(resourcePath, 'delete');
|
|
83
|
+
const remove = useDeleteResource(resourcePath);
|
|
84
|
+
const navigate = useNavigate();
|
|
85
|
+
|
|
86
|
+
const [page, setPage] = useState(1);
|
|
87
|
+
const [search, setSearch] = useState('');
|
|
88
|
+
const [sort, setSort] = useState<{ field: string; dir: 'asc' | 'desc' } | null>(null);
|
|
89
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
|
90
|
+
const [searchParams] = useSearchParams();
|
|
91
|
+
|
|
92
|
+
const shouldReadUrl = readUrlFilters ?? !embedded;
|
|
93
|
+
const urlFilters = useMemo(() => {
|
|
94
|
+
if (!shouldReadUrl) return undefined;
|
|
95
|
+
const out: Record<string, unknown> = {};
|
|
96
|
+
for (const [k, v] of searchParams.entries()) {
|
|
97
|
+
if (k.startsWith('__')) continue;
|
|
98
|
+
out[k] = v;
|
|
99
|
+
}
|
|
100
|
+
return Object.keys(out).length ? out : undefined;
|
|
101
|
+
}, [searchParams, shouldReadUrl]);
|
|
102
|
+
|
|
103
|
+
const mergedFilters = useMemo(
|
|
104
|
+
() => ({ ...(urlFilters ?? {}), ...(filters ?? {}) }),
|
|
105
|
+
[filters, urlFilters]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const sortParam = sort ? `${sort.field}:${sort.dir}` : undefined;
|
|
109
|
+
|
|
110
|
+
const list = useResourceList<Record<string, unknown>>(resourcePath, {
|
|
111
|
+
params: {
|
|
112
|
+
page,
|
|
113
|
+
q: search || undefined,
|
|
114
|
+
sort: sortParam,
|
|
115
|
+
filter: Object.keys(mergedFilters).length ? mergedFilters : undefined,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const entry = describe?.registry.get(resourcePath);
|
|
120
|
+
const display = describe?.registry.display(resourcePath);
|
|
121
|
+
|
|
122
|
+
const effectiveColumns = useMemo<NormalizedColumn[]>(() => {
|
|
123
|
+
const fromProp = normalizeColumns(columns);
|
|
124
|
+
if (fromProp) return fromProp;
|
|
125
|
+
const fromConfig = normalizeColumns(config.listColumns);
|
|
126
|
+
if (fromConfig) return fromConfig;
|
|
127
|
+
if (!entry) return [];
|
|
128
|
+
return entry.fields
|
|
129
|
+
.filter((f) => !STAMPED.has(f.name))
|
|
130
|
+
.slice(0, 5)
|
|
131
|
+
.map((f) => ({
|
|
132
|
+
field: f.name,
|
|
133
|
+
label: labelize(f.name, { stripIdSuffix: f.name.endsWith('Id') }),
|
|
134
|
+
}));
|
|
135
|
+
}, [columns, config.listColumns, entry]);
|
|
136
|
+
|
|
137
|
+
const rowActions = config.actions?.row ?? DEFAULT_ROW_ACTIONS(deletePerm.allowed);
|
|
138
|
+
const bulkActions = config.actions?.bulk ?? [];
|
|
139
|
+
const selectionEnabled = bulkActions.length > 0;
|
|
140
|
+
const searchable = entry?.features.search ?? [];
|
|
141
|
+
|
|
142
|
+
function toggleSort(field: string) {
|
|
143
|
+
setSort((prev) => {
|
|
144
|
+
if (!prev || prev.field !== field) return { field, dir: 'asc' };
|
|
145
|
+
if (prev.dir === 'asc') return { field, dir: 'desc' };
|
|
146
|
+
return null;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clearSelection() {
|
|
151
|
+
setSelected(new Set());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toggleRow(id: string) {
|
|
155
|
+
setSelected((prev) => {
|
|
156
|
+
const next = new Set(prev);
|
|
157
|
+
if (next.has(id)) next.delete(id);
|
|
158
|
+
else next.add(id);
|
|
159
|
+
return next;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toggleAll(rows: readonly Record<string, unknown>[]) {
|
|
164
|
+
const allIds = rows.map((r) => String(r._id ?? ''));
|
|
165
|
+
const allSelected = allIds.every((id) => selected.has(id));
|
|
166
|
+
if (allSelected) {
|
|
167
|
+
setSelected((prev) => {
|
|
168
|
+
const next = new Set(prev);
|
|
169
|
+
for (const id of allIds) next.delete(id);
|
|
170
|
+
return next;
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
setSelected((prev) => {
|
|
174
|
+
const next = new Set(prev);
|
|
175
|
+
for (const id of allIds) next.add(id);
|
|
176
|
+
return next;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function runRowAction(
|
|
182
|
+
action: descriptor.ActionSpec,
|
|
183
|
+
record: Record<string, unknown>
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
if (action.kind === 'navigate' && action.to) {
|
|
186
|
+
const id = String(record._id ?? '');
|
|
187
|
+
navigate(action.to.replace('{id}', id).replace('{path}', resourcePath));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (action.id === '__delete__') {
|
|
191
|
+
const id = String(record._id ?? '');
|
|
192
|
+
if (!confirm('Delete this record?')) return;
|
|
193
|
+
await remove.mutateAsync(id);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (action.id === '__edit__') {
|
|
197
|
+
navigate(`/r/${resourcePath}/${record._id}/edit`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function runBulkAction(action: descriptor.ActionSpec, ids: readonly string[]): void {
|
|
203
|
+
if (action.kind === 'bulkDelete') {
|
|
204
|
+
if (!confirm(`Delete ${ids.length} record(s)?`)) return;
|
|
205
|
+
void Promise.all(ids.map((id) => remove.mutateAsync(id))).then(clearSelection);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Custom actions handled by the consumer through the run callback they
|
|
209
|
+
// pass into their ActionSpec — for M3 we surface the noop and let
|
|
210
|
+
// future work plumb a richer dispatch.
|
|
211
|
+
console.warn('[davepi-ui] bulk action not wired:', action.id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!describe || !entry || !display) {
|
|
215
|
+
return <p className="text-muted-foreground">Loading…</p>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const visibleRows = list.data?.results ?? [];
|
|
219
|
+
const allSelected =
|
|
220
|
+
visibleRows.length > 0 && visibleRows.every((r) => selected.has(String(r._id ?? '')));
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="space-y-4">
|
|
224
|
+
{!embedded ? (
|
|
225
|
+
<header className="flex items-end justify-between gap-3">
|
|
226
|
+
<div>
|
|
227
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
228
|
+
{config.pluralLabel ?? display.pluralLabel}
|
|
229
|
+
</h1>
|
|
230
|
+
<p className="text-sm text-muted-foreground">
|
|
231
|
+
<code>{entry.path}</code>
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
{searchable.length ? (
|
|
236
|
+
<div className="relative">
|
|
237
|
+
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
238
|
+
<Input
|
|
239
|
+
value={search}
|
|
240
|
+
onChange={(e) => {
|
|
241
|
+
setPage(1);
|
|
242
|
+
setSearch(e.target.value);
|
|
243
|
+
}}
|
|
244
|
+
placeholder={`Search ${(config.pluralLabel ?? display.pluralLabel).toLowerCase()}…`}
|
|
245
|
+
className="w-64 pl-8"
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
) : null}
|
|
249
|
+
{createPerm.allowed ? (
|
|
250
|
+
<Button asChild>
|
|
251
|
+
<Link to={prefillCreateUrl(resourcePath, mergedFilters)}>
|
|
252
|
+
<Plus className="mr-1 h-4 w-4" />
|
|
253
|
+
New {config.label ?? display.label}
|
|
254
|
+
</Link>
|
|
255
|
+
</Button>
|
|
256
|
+
) : null}
|
|
257
|
+
</div>
|
|
258
|
+
</header>
|
|
259
|
+
) : null}
|
|
260
|
+
<div className="rounded-md border border-border bg-card">
|
|
261
|
+
<Table>
|
|
262
|
+
<TableHeader>
|
|
263
|
+
<TableRow>
|
|
264
|
+
{selectionEnabled ? (
|
|
265
|
+
<TableHead className="w-10">
|
|
266
|
+
<Checkbox
|
|
267
|
+
checked={allSelected}
|
|
268
|
+
onCheckedChange={() => toggleAll(visibleRows)}
|
|
269
|
+
aria-label="Select all visible rows"
|
|
270
|
+
/>
|
|
271
|
+
</TableHead>
|
|
272
|
+
) : null}
|
|
273
|
+
{effectiveColumns.map((col) => {
|
|
274
|
+
const isSorted = sort?.field === col.field;
|
|
275
|
+
return (
|
|
276
|
+
<TableHead key={col.field}>
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => toggleSort(col.field)}
|
|
280
|
+
className="inline-flex items-center gap-1 hover:text-foreground"
|
|
281
|
+
>
|
|
282
|
+
{col.label}
|
|
283
|
+
{isSorted ? (
|
|
284
|
+
sort?.dir === 'asc' ? (
|
|
285
|
+
<ArrowUp className="h-3.5 w-3.5" />
|
|
286
|
+
) : (
|
|
287
|
+
<ArrowDown className="h-3.5 w-3.5" />
|
|
288
|
+
)
|
|
289
|
+
) : (
|
|
290
|
+
<ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
|
|
291
|
+
)}
|
|
292
|
+
</button>
|
|
293
|
+
</TableHead>
|
|
294
|
+
);
|
|
295
|
+
})}
|
|
296
|
+
{rowActions.length ? <TableHead className="w-10" /> : null}
|
|
297
|
+
</TableRow>
|
|
298
|
+
</TableHeader>
|
|
299
|
+
<TableBody>
|
|
300
|
+
{list.isPending ? (
|
|
301
|
+
<TableRow>
|
|
302
|
+
<TableCell
|
|
303
|
+
colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
|
|
304
|
+
className="text-muted-foreground"
|
|
305
|
+
>
|
|
306
|
+
Loading…
|
|
307
|
+
</TableCell>
|
|
308
|
+
</TableRow>
|
|
309
|
+
) : null}
|
|
310
|
+
{list.error ? (
|
|
311
|
+
<TableRow>
|
|
312
|
+
<TableCell
|
|
313
|
+
colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
|
|
314
|
+
className="text-destructive"
|
|
315
|
+
>
|
|
316
|
+
{list.error.message}
|
|
317
|
+
</TableCell>
|
|
318
|
+
</TableRow>
|
|
319
|
+
) : null}
|
|
320
|
+
{!list.isPending && visibleRows.length === 0 ? (
|
|
321
|
+
<TableRow>
|
|
322
|
+
<TableCell
|
|
323
|
+
colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
|
|
324
|
+
className="text-center text-muted-foreground"
|
|
325
|
+
>
|
|
326
|
+
No records.
|
|
327
|
+
</TableCell>
|
|
328
|
+
</TableRow>
|
|
329
|
+
) : null}
|
|
330
|
+
{visibleRows.map((record) => {
|
|
331
|
+
const id = String(record._id ?? '');
|
|
332
|
+
const isSelected = selected.has(id);
|
|
333
|
+
return (
|
|
334
|
+
<TableRow key={id} className="cursor-pointer" data-state={isSelected ? 'selected' : undefined}>
|
|
335
|
+
{selectionEnabled ? (
|
|
336
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
337
|
+
<Checkbox
|
|
338
|
+
checked={isSelected}
|
|
339
|
+
onCheckedChange={() => toggleRow(id)}
|
|
340
|
+
aria-label={`Select row ${id}`}
|
|
341
|
+
/>
|
|
342
|
+
</TableCell>
|
|
343
|
+
) : null}
|
|
344
|
+
{effectiveColumns.map((col, idx) => (
|
|
345
|
+
<TableCell key={col.field} className={cn(idx === 0 && 'font-medium')}>
|
|
346
|
+
<Link to={`/r/${resourcePath}/${id}`} className="block w-full">
|
|
347
|
+
{formatCell(getField(record, col.field), col.format)}
|
|
348
|
+
</Link>
|
|
349
|
+
</TableCell>
|
|
350
|
+
))}
|
|
351
|
+
{rowActions.length ? (
|
|
352
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
353
|
+
<RowActions
|
|
354
|
+
actions={rowActions}
|
|
355
|
+
record={record}
|
|
356
|
+
onRun={(a, r) => void runRowAction(a, r)}
|
|
357
|
+
/>
|
|
358
|
+
</TableCell>
|
|
359
|
+
) : null}
|
|
360
|
+
</TableRow>
|
|
361
|
+
);
|
|
362
|
+
})}
|
|
363
|
+
</TableBody>
|
|
364
|
+
</Table>
|
|
365
|
+
</div>
|
|
366
|
+
<footer className="flex items-center justify-between text-xs text-muted-foreground">
|
|
367
|
+
<div>
|
|
368
|
+
{list.data
|
|
369
|
+
? `${list.data.totalResults} total · page ${list.data.page} of ${list.data.totalPages ?? '?'}`
|
|
370
|
+
: null}
|
|
371
|
+
</div>
|
|
372
|
+
<div className="flex items-center gap-2">
|
|
373
|
+
<Button
|
|
374
|
+
variant="outline"
|
|
375
|
+
size="sm"
|
|
376
|
+
disabled={!list.data || (list.data.prevPage ?? 0) < 1}
|
|
377
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
378
|
+
>
|
|
379
|
+
Previous
|
|
380
|
+
</Button>
|
|
381
|
+
<Button
|
|
382
|
+
variant="outline"
|
|
383
|
+
size="sm"
|
|
384
|
+
disabled={!list.data?.nextPage}
|
|
385
|
+
onClick={() => setPage((p) => p + 1)}
|
|
386
|
+
>
|
|
387
|
+
Next
|
|
388
|
+
</Button>
|
|
389
|
+
</div>
|
|
390
|
+
</footer>
|
|
391
|
+
{selectionEnabled ? (
|
|
392
|
+
<BulkActionBar
|
|
393
|
+
selectedIds={Array.from(selected)}
|
|
394
|
+
actions={bulkActions}
|
|
395
|
+
resourceLabel={config.pluralLabel ?? display.pluralLabel}
|
|
396
|
+
onClear={clearSelection}
|
|
397
|
+
onRun={runBulkAction}
|
|
398
|
+
/>
|
|
399
|
+
) : null}
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function normalizeColumns(
|
|
405
|
+
raw: ResourceTableProps['columns'] | descriptor.ColumnSpec[] | undefined
|
|
406
|
+
): NormalizedColumn[] | null {
|
|
407
|
+
if (!raw || !raw.length) return null;
|
|
408
|
+
return raw.map((col) => {
|
|
409
|
+
if (typeof col === 'string') {
|
|
410
|
+
return {
|
|
411
|
+
field: col,
|
|
412
|
+
label: labelize(col, { stripIdSuffix: col.endsWith('Id') }),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
field: col.field,
|
|
417
|
+
label:
|
|
418
|
+
col.label ?? labelize(col.field, { stripIdSuffix: col.field.endsWith('Id') }),
|
|
419
|
+
format: col.format,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getField(record: Record<string, unknown>, fieldPath: string): unknown {
|
|
425
|
+
if (!fieldPath.includes('.')) return record[fieldPath];
|
|
426
|
+
return fieldPath.split('.').reduce<unknown>((acc, segment) => {
|
|
427
|
+
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[segment];
|
|
428
|
+
return undefined;
|
|
429
|
+
}, record);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatCell(value: unknown, format?: string): string {
|
|
433
|
+
if (value == null) return '—';
|
|
434
|
+
if (format?.startsWith('currency:')) {
|
|
435
|
+
const code = format.slice('currency:'.length);
|
|
436
|
+
const num = Number(value);
|
|
437
|
+
if (Number.isFinite(num)) {
|
|
438
|
+
try {
|
|
439
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(num);
|
|
440
|
+
} catch {
|
|
441
|
+
return `${code} ${num}`;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (format === 'date') {
|
|
446
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
447
|
+
if (!Number.isNaN(date.getTime())) return date.toLocaleDateString();
|
|
448
|
+
}
|
|
449
|
+
if (Array.isArray(value)) return value.length ? value.join(', ') : '—';
|
|
450
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
451
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
452
|
+
return String(value);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function DEFAULT_ROW_ACTIONS(canDelete: boolean): descriptor.ActionSpec[] {
|
|
456
|
+
const out: descriptor.ActionSpec[] = [
|
|
457
|
+
{ id: '__edit__', label: 'Edit', kind: 'custom' },
|
|
458
|
+
];
|
|
459
|
+
if (canDelete) out.push({ id: '__delete__', label: 'Delete', kind: 'custom' });
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Carry active filters into the create URL so a child opened from a
|
|
465
|
+
* filtered list inherits the parent FK pre-stamped.
|
|
466
|
+
*/
|
|
467
|
+
function prefillCreateUrl(resourcePath: string, filters: Record<string, unknown>): string {
|
|
468
|
+
const entries = Object.entries(filters).filter(([k, v]) => v != null && k !== '__page');
|
|
469
|
+
if (!entries.length) return `/r/${resourcePath}/new`;
|
|
470
|
+
const qs = new URLSearchParams();
|
|
471
|
+
for (const [k, v] of entries) {
|
|
472
|
+
qs.set(`prefill_${k}`, String(v));
|
|
473
|
+
}
|
|
474
|
+
return `/r/${resourcePath}/new?${qs.toString()}`;
|
|
475
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { MoreHorizontal } from 'lucide-react';
|
|
2
|
+
import type { descriptor } from '@davepi/ui-core';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from '@/components/ui/dropdown-menu';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Per-row action menu. Renders a "three dots" trigger that opens a
|
|
13
|
+
* Radix dropdown of `ActionSpec` items. Each item dispatches via the
|
|
14
|
+
* `onRun` callback so the parent table can choose how to execute
|
|
15
|
+
* (mutation, navigate, etc.).
|
|
16
|
+
*/
|
|
17
|
+
export interface RowActionsProps {
|
|
18
|
+
actions: descriptor.ActionSpec[];
|
|
19
|
+
record: Record<string, unknown>;
|
|
20
|
+
onRun: (action: descriptor.ActionSpec, record: Record<string, unknown>) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function RowActions({ actions, record, onRun }: RowActionsProps) {
|
|
24
|
+
if (!actions.length) return null;
|
|
25
|
+
return (
|
|
26
|
+
<DropdownMenu>
|
|
27
|
+
<DropdownMenuTrigger asChild>
|
|
28
|
+
<Button
|
|
29
|
+
type="button"
|
|
30
|
+
variant="ghost"
|
|
31
|
+
size="icon"
|
|
32
|
+
className="h-8 w-8 data-[state=open]:bg-muted"
|
|
33
|
+
onClick={(e) => e.stopPropagation()}
|
|
34
|
+
>
|
|
35
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
36
|
+
<span className="sr-only">Open menu</span>
|
|
37
|
+
</Button>
|
|
38
|
+
</DropdownMenuTrigger>
|
|
39
|
+
<DropdownMenuContent align="end">
|
|
40
|
+
{actions.map((action) => (
|
|
41
|
+
<DropdownMenuItem
|
|
42
|
+
key={action.id}
|
|
43
|
+
onSelect={(e) => {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
onRun(action, record);
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{action.label}
|
|
49
|
+
</DropdownMenuItem>
|
|
50
|
+
))}
|
|
51
|
+
</DropdownMenuContent>
|
|
52
|
+
</DropdownMenu>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
3
|
+
import {
|
|
4
|
+
useDavepiConfig,
|
|
5
|
+
useDescribe,
|
|
6
|
+
useResourcePerm,
|
|
7
|
+
} from '@davepi/ui-react';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sidebar nav derived from the live `/_describe` manifest, with
|
|
12
|
+
* consumer-supplied config applied on top:
|
|
13
|
+
*
|
|
14
|
+
* - Resources grouped by `config.resources[path].category`. Order
|
|
15
|
+
* respects `config.categoryOrder` first, then alphabetical.
|
|
16
|
+
* - Labels prefer the consumer config's `label`/`pluralLabel`, falling
|
|
17
|
+
* back to backend hints, falling back to title-cased path.
|
|
18
|
+
* - Resources excluded by `permissions.list` (consumer config) OR
|
|
19
|
+
* `acl.list` (describe) are hidden — server still enforces.
|
|
20
|
+
*/
|
|
21
|
+
export function Sidebar() {
|
|
22
|
+
const { data, isPending, error } = useDescribe();
|
|
23
|
+
const { config } = useDavepiConfig();
|
|
24
|
+
const { pathname } = useLocation();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<aside className="flex w-60 flex-col border-r border-border bg-card">
|
|
28
|
+
<div className="flex h-14 items-center gap-2 border-b border-border px-4 font-semibold">
|
|
29
|
+
{config.branding?.name ?? 'davepi-ui'}
|
|
30
|
+
</div>
|
|
31
|
+
<nav className="flex flex-1 flex-col gap-2 overflow-auto p-3">
|
|
32
|
+
<Link
|
|
33
|
+
to="/"
|
|
34
|
+
className={cn(
|
|
35
|
+
'rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
|
36
|
+
pathname === '/' && 'bg-accent text-accent-foreground'
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
Dashboard
|
|
40
|
+
</Link>
|
|
41
|
+
{isPending ? <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div> : null}
|
|
42
|
+
{error ? (
|
|
43
|
+
<div className="px-3 py-2 text-xs text-destructive">
|
|
44
|
+
Failed to load schema: {error.message}
|
|
45
|
+
</div>
|
|
46
|
+
) : null}
|
|
47
|
+
{data ? <SidebarSections currentPath={pathname} /> : null}
|
|
48
|
+
</nav>
|
|
49
|
+
</aside>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SectionEntry {
|
|
54
|
+
path: string;
|
|
55
|
+
pluralLabel: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Section {
|
|
59
|
+
category: string;
|
|
60
|
+
items: SectionEntry[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function SidebarSections({ currentPath }: { currentPath: string }) {
|
|
64
|
+
const { data } = useDescribe();
|
|
65
|
+
const { config, resolveResource } = useDavepiConfig();
|
|
66
|
+
|
|
67
|
+
const sections = useMemo<Section[]>(() => {
|
|
68
|
+
if (!data) return [];
|
|
69
|
+
const byCategory = new Map<string, SectionEntry[]>();
|
|
70
|
+
for (const path of data.registry.paths()) {
|
|
71
|
+
const display = data.registry.display(path);
|
|
72
|
+
const cfg = resolveResource(path);
|
|
73
|
+
const category = cfg.category ?? '';
|
|
74
|
+
const label = cfg.pluralLabel ?? cfg.label ?? display.pluralLabel;
|
|
75
|
+
const list = byCategory.get(category) ?? [];
|
|
76
|
+
list.push({ path, pluralLabel: label });
|
|
77
|
+
byCategory.set(category, list);
|
|
78
|
+
}
|
|
79
|
+
const orderedCategories = orderCategories(
|
|
80
|
+
Array.from(byCategory.keys()),
|
|
81
|
+
config.categoryOrder
|
|
82
|
+
);
|
|
83
|
+
return orderedCategories.map((category) => ({
|
|
84
|
+
category,
|
|
85
|
+
items: (byCategory.get(category) ?? []).sort((a, b) =>
|
|
86
|
+
a.pluralLabel.localeCompare(b.pluralLabel)
|
|
87
|
+
),
|
|
88
|
+
}));
|
|
89
|
+
}, [config.categoryOrder, data, resolveResource]);
|
|
90
|
+
|
|
91
|
+
if (!sections.length) return null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{sections.map((section) => (
|
|
96
|
+
<SidebarSection
|
|
97
|
+
key={section.category || '__default__'}
|
|
98
|
+
section={section}
|
|
99
|
+
currentPath={currentPath}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function SidebarSection({
|
|
107
|
+
section,
|
|
108
|
+
currentPath,
|
|
109
|
+
}: {
|
|
110
|
+
section: Section;
|
|
111
|
+
currentPath: string;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<div className="flex flex-col gap-0.5">
|
|
115
|
+
{section.category ? (
|
|
116
|
+
<div className="px-3 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
117
|
+
{section.category}
|
|
118
|
+
</div>
|
|
119
|
+
) : null}
|
|
120
|
+
{section.items.map((entry) => (
|
|
121
|
+
<SidebarLink
|
|
122
|
+
key={entry.path}
|
|
123
|
+
path={entry.path}
|
|
124
|
+
pluralLabel={entry.pluralLabel}
|
|
125
|
+
currentPath={currentPath}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function SidebarLink({
|
|
133
|
+
path,
|
|
134
|
+
pluralLabel,
|
|
135
|
+
currentPath,
|
|
136
|
+
}: {
|
|
137
|
+
path: string;
|
|
138
|
+
pluralLabel: string;
|
|
139
|
+
currentPath: string;
|
|
140
|
+
}) {
|
|
141
|
+
const perm = useResourcePerm(path, 'list');
|
|
142
|
+
if (!perm.allowed) return null;
|
|
143
|
+
const href = `/r/${path}`;
|
|
144
|
+
const active = currentPath === href || currentPath.startsWith(`${href}/`);
|
|
145
|
+
return (
|
|
146
|
+
<Link
|
|
147
|
+
to={href}
|
|
148
|
+
className={cn(
|
|
149
|
+
'rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
|
|
150
|
+
active && 'bg-accent text-accent-foreground'
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{pluralLabel}
|
|
154
|
+
</Link>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function orderCategories(found: string[], order: readonly string[] | undefined): string[] {
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
if (order) {
|
|
162
|
+
for (const cat of order) {
|
|
163
|
+
if (found.includes(cat) && !seen.has(cat)) {
|
|
164
|
+
out.push(cat);
|
|
165
|
+
seen.add(cat);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const remaining = found.filter((c) => !seen.has(c)).sort((a, b) => a.localeCompare(b));
|
|
170
|
+
return [...out, ...remaining];
|
|
171
|
+
}
|