@kematjaya/crud-ui-generator 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/README.md +80 -0
- package/dist/cli.js +106 -0
- package/dist/naming.js +47 -0
- package/dist/spec.js +47 -0
- package/dist/templates/apiShapes.js +45 -0
- package/dist/templates/bffRoutes.js +175 -0
- package/dist/templates/csvLib.js +30 -0
- package/dist/templates/form.js +172 -0
- package/dist/templates/pages.js +38 -0
- package/dist/templates/queryLib.js +77 -0
- package/dist/templates/schemas.js +36 -0
- package/dist/templates/sharedComponents.js +415 -0
- package/dist/templates/table.js +499 -0
- package/dist/templates/typesApi.js +45 -0
- package/dist/templates/useExport.js +121 -0
- package/dist/write.js +70 -0
- package/package.json +26 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { displayFields, humanize, labelField, lowerWords, searchField } from '../naming.js';
|
|
2
|
+
function columnCell(fieldName, timestampField) {
|
|
3
|
+
if (fieldName === timestampField) {
|
|
4
|
+
return ` <td>
|
|
5
|
+
{new Date(
|
|
6
|
+
item.${fieldName}
|
|
7
|
+
).toLocaleString()}
|
|
8
|
+
</td>`;
|
|
9
|
+
}
|
|
10
|
+
return ` <td>{String(item.${fieldName})}</td>`;
|
|
11
|
+
}
|
|
12
|
+
export function table(spec, names) {
|
|
13
|
+
const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
|
|
14
|
+
const prefix = entitiesKebab;
|
|
15
|
+
const cols = displayFields(spec).map((f) => f.name);
|
|
16
|
+
if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
|
|
17
|
+
cols.push(spec.timestampField);
|
|
18
|
+
}
|
|
19
|
+
const label = labelField(spec);
|
|
20
|
+
const noun = lowerWords(entityPascal);
|
|
21
|
+
const pluralNoun = lowerWords(entitiesPascal);
|
|
22
|
+
const field = searchField(spec);
|
|
23
|
+
const constPrefix = entitiesCamel.toUpperCase();
|
|
24
|
+
const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
|
|
25
|
+
const bodyCells = cols.map((c) => columnCell(c, spec.timestampField)).join('\n');
|
|
26
|
+
const searchPanelBlock = field
|
|
27
|
+
? ` <SearchPanel
|
|
28
|
+
key={query.search}
|
|
29
|
+
label="Search by ${humanize(field)}"
|
|
30
|
+
value={query.search}
|
|
31
|
+
totalItems={totalItems}
|
|
32
|
+
pluralNoun="${pluralNoun}"
|
|
33
|
+
pending={loading}
|
|
34
|
+
disabled={trueEmpty}
|
|
35
|
+
onSearch={(search) =>
|
|
36
|
+
router.push(build${entitiesPascal}Href(query, { page: 1, search }))
|
|
37
|
+
}
|
|
38
|
+
onClear={() =>
|
|
39
|
+
router.push(build${entitiesPascal}Href(query, { page: 1, search: '' }))
|
|
40
|
+
}
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
`
|
|
44
|
+
: '';
|
|
45
|
+
const hasTitleFilterLine = field
|
|
46
|
+
? ` const hasSearch = Boolean(query.search);\n`
|
|
47
|
+
: ` const hasSearch = false;\n`;
|
|
48
|
+
return `'use client';
|
|
49
|
+
|
|
50
|
+
import { Button, EmptyState, LinkButton, ListPageCard, Toast } from '@kematjaya/bootstrap-ui-kit';
|
|
51
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
52
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
53
|
+
import { usePermissions } from '@kematjaya/access-control-ui';
|
|
54
|
+
import { BulkActionsBar } from '@/components/crud/BulkActionsBar';
|
|
55
|
+
import { DeleteConfirmModal } from '@/components/crud/DeleteConfirmModal';
|
|
56
|
+
import { ExportAllButton } from '@/components/crud/ExportAllButton';
|
|
57
|
+
import { PaginationBar } from '@/components/crud/PaginationBar';
|
|
58
|
+
import { SearchPanel } from '@/components/crud/SearchPanel';
|
|
59
|
+
import { is${entityPascal}Collection } from '@/lib/api-shapes';
|
|
60
|
+
import {
|
|
61
|
+
${constPrefix}_PAGE_SIZES,
|
|
62
|
+
build${entitiesPascal}ApiPath,
|
|
63
|
+
build${entitiesPascal}Href,
|
|
64
|
+
clampPageToTotal,
|
|
65
|
+
nextItemsPerPagePage,
|
|
66
|
+
parse${entitiesPascal}Query,
|
|
67
|
+
type ${entitiesPascal}Query
|
|
68
|
+
} from '@/lib/${entitiesKebab}-query';
|
|
69
|
+
import type { ${entityPascal} } from '@/types/api';
|
|
70
|
+
import { use${entitiesPascal}Export } from './use${entitiesPascal}Export';
|
|
71
|
+
|
|
72
|
+
export function ${entityPascal}Table() {
|
|
73
|
+
const router = useRouter();
|
|
74
|
+
const searchParams = useSearchParams();
|
|
75
|
+
const query = useMemo(() => parse${entitiesPascal}Query(searchParams), [searchParams]);
|
|
76
|
+
const [items, setItems] = useState<${entityPascal}[]>([]);
|
|
77
|
+
const [totalItems, setTotalItems] = useState(0);
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [error, setError] = useState('');
|
|
80
|
+
const [deleteTarget, setDeleteTarget] = useState<${entityPascal} | null>(null);
|
|
81
|
+
const [deleting, setDeleting] = useState(false);
|
|
82
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
83
|
+
const [bulkDeleteConfirming, setBulkDeleteConfirming] = useState(false);
|
|
84
|
+
const [bulkDeleting, setBulkDeleting] = useState(false);
|
|
85
|
+
const [toastMessage, setToastMessage] = useState(
|
|
86
|
+
() => searchParams.get('toast') ?? ''
|
|
87
|
+
);
|
|
88
|
+
const { exportingSelected, exportingAll, exportSelected, exportAll } =
|
|
89
|
+
use${entitiesPascal}Export();
|
|
90
|
+
const { has: hasPermission, loading: permissionsLoading } = usePermissions();
|
|
91
|
+
const requestId = useRef(0);
|
|
92
|
+
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
93
|
+
|
|
94
|
+
const loadItems = useCallback(
|
|
95
|
+
async (signal?: AbortSignal) => {
|
|
96
|
+
const id = requestId.current + 1;
|
|
97
|
+
requestId.current = id;
|
|
98
|
+
setError('');
|
|
99
|
+
setLoading(true);
|
|
100
|
+
setItems([]);
|
|
101
|
+
setTotalItems(0);
|
|
102
|
+
let response: Response;
|
|
103
|
+
try {
|
|
104
|
+
response = await fetch(build${entitiesPascal}ApiPath(query), {
|
|
105
|
+
cache: 'no-store',
|
|
106
|
+
signal
|
|
107
|
+
});
|
|
108
|
+
} catch (fetchError) {
|
|
109
|
+
if (
|
|
110
|
+
fetchError instanceof DOMException &&
|
|
111
|
+
fetchError.name === 'AbortError'
|
|
112
|
+
)
|
|
113
|
+
return;
|
|
114
|
+
if (requestId.current !== id) return;
|
|
115
|
+
setError('Network error while loading ${pluralNoun}.');
|
|
116
|
+
setLoading(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (requestId.current !== id || signal?.aborted) return;
|
|
120
|
+
if (response.status === 401) {
|
|
121
|
+
router.push('/login');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
setError('Could not load ${pluralNoun}.');
|
|
126
|
+
setLoading(false);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const data: unknown = await response.json();
|
|
130
|
+
if (!is${entityPascal}Collection(data)) {
|
|
131
|
+
setError('Unexpected response.');
|
|
132
|
+
setLoading(false);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const clampedPage = clampPageToTotal(
|
|
136
|
+
query.page,
|
|
137
|
+
query.itemsPerPage,
|
|
138
|
+
data.totalItems
|
|
139
|
+
);
|
|
140
|
+
if (clampedPage !== query.page) {
|
|
141
|
+
router.replace(build${entitiesPascal}Href(query, { page: clampedPage }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setItems(data.member);
|
|
145
|
+
setTotalItems(data.totalItems);
|
|
146
|
+
setLoading(false);
|
|
147
|
+
},
|
|
148
|
+
[query, router]
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = window.setTimeout(
|
|
154
|
+
() => void loadItems(controller.signal),
|
|
155
|
+
0
|
|
156
|
+
);
|
|
157
|
+
return () => {
|
|
158
|
+
window.clearTimeout(timer);
|
|
159
|
+
controller.abort();
|
|
160
|
+
};
|
|
161
|
+
}, [loadItems]);
|
|
162
|
+
|
|
163
|
+
const [selectionQuery, setSelectionQuery] = useState(query);
|
|
164
|
+
if (selectionQuery !== query) {
|
|
165
|
+
setSelectionQuery(query);
|
|
166
|
+
setSelectedIds(new Set());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function navigateTo(nextQuery: ${entitiesPascal}Query) {
|
|
170
|
+
router.push(build${entitiesPascal}Href(nextQuery));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
${hasTitleFilterLine} const trueEmpty = !loading && !error && !hasSearch && totalItems === 0;
|
|
174
|
+
const filteredEmpty = !loading && !error && hasSearch && totalItems === 0;
|
|
175
|
+
const hasItems = !loading && items.length > 0;
|
|
176
|
+
const selectedItems = useMemo(
|
|
177
|
+
() => items.filter((item) => selectedIds.has(item.id)),
|
|
178
|
+
[items, selectedIds]
|
|
179
|
+
);
|
|
180
|
+
const allOnPageSelected =
|
|
181
|
+
items.length > 0 && items.every((item) => selectedIds.has(item.id));
|
|
182
|
+
const someOnPageSelected = items.some((item) => selectedIds.has(item.id));
|
|
183
|
+
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (selectAllRef.current) {
|
|
186
|
+
selectAllRef.current.indeterminate =
|
|
187
|
+
someOnPageSelected && !allOnPageSelected;
|
|
188
|
+
}
|
|
189
|
+
}, [someOnPageSelected, allOnPageSelected]);
|
|
190
|
+
|
|
191
|
+
function toggleSelect(id: string) {
|
|
192
|
+
setSelectedIds((prev) => {
|
|
193
|
+
const next = new Set(prev);
|
|
194
|
+
if (next.has(id)) next.delete(id);
|
|
195
|
+
else next.add(id);
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function toggleSelectAll() {
|
|
201
|
+
setSelectedIds((prev) => {
|
|
202
|
+
const allSelected =
|
|
203
|
+
items.length > 0 && items.every((item) => prev.has(item.id));
|
|
204
|
+
return allSelected
|
|
205
|
+
? new Set()
|
|
206
|
+
: new Set(items.map((item) => item.id));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function confirmDelete() {
|
|
211
|
+
if (!deleteTarget) return;
|
|
212
|
+
setDeleting(true);
|
|
213
|
+
let response: Response;
|
|
214
|
+
try {
|
|
215
|
+
response = await fetch(\`/api/${entitiesKebab}/\${deleteTarget.id}\`, {
|
|
216
|
+
method: 'DELETE'
|
|
217
|
+
});
|
|
218
|
+
} catch {
|
|
219
|
+
setError('Network error while deleting.');
|
|
220
|
+
setDeleting(false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
setDeleting(false);
|
|
224
|
+
if (!response.ok && response.status !== 204) {
|
|
225
|
+
setError('Could not delete.');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
setDeleteTarget(null);
|
|
229
|
+
setToastMessage('Deleted.');
|
|
230
|
+
await loadItems();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function confirmBulkDelete() {
|
|
234
|
+
const ids = Array.from(selectedIds);
|
|
235
|
+
if (ids.length === 0) return;
|
|
236
|
+
setBulkDeleting(true);
|
|
237
|
+
const outcomes = await Promise.allSettled(
|
|
238
|
+
ids.map(async (id) => {
|
|
239
|
+
const response = await fetch(\`/api/${entitiesKebab}/\${id}\`, {
|
|
240
|
+
method: 'DELETE'
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok && response.status !== 204) {
|
|
243
|
+
throw new Error('delete failed');
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
setBulkDeleting(false);
|
|
248
|
+
setBulkDeleteConfirming(false);
|
|
249
|
+
const failedIds = ids.filter(
|
|
250
|
+
(_, index) => outcomes[index]?.status === 'rejected'
|
|
251
|
+
);
|
|
252
|
+
const deletedCount = ids.length - failedIds.length;
|
|
253
|
+
if (failedIds.length === ids.length) {
|
|
254
|
+
setError('Could not delete selected ${pluralNoun}.');
|
|
255
|
+
} else if (failedIds.length > 0) {
|
|
256
|
+
setError(
|
|
257
|
+
\`Deleted \${deletedCount} of \${ids.length}. Some deletions failed.\`
|
|
258
|
+
);
|
|
259
|
+
setToastMessage(\`\${deletedCount} deleted.\`);
|
|
260
|
+
} else {
|
|
261
|
+
setToastMessage(\`\${deletedCount} deleted.\`);
|
|
262
|
+
}
|
|
263
|
+
setSelectedIds(new Set(failedIds));
|
|
264
|
+
await loadItems();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function handleExportSelected() {
|
|
268
|
+
setError('');
|
|
269
|
+
if (0 === selectedItems.length) return;
|
|
270
|
+
const result = exportSelected(selectedItems);
|
|
271
|
+
if (result.ok) {
|
|
272
|
+
setSelectedIds(new Set());
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
setError(result.message);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function handleExportAll() {
|
|
279
|
+
setError('');
|
|
280
|
+
const result = await exportAll(query);
|
|
281
|
+
if (result.ok) return;
|
|
282
|
+
if (401 === result.status) {
|
|
283
|
+
router.push('/login');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
setError(result.message);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<ListPageCard
|
|
291
|
+
title="${entitiesPascal}"
|
|
292
|
+
error={error}
|
|
293
|
+
action={
|
|
294
|
+
<div className="crud-card-actions">
|
|
295
|
+
{(permissionsLoading || hasPermission('${prefix}.export_all')) && (
|
|
296
|
+
<ExportAllButton
|
|
297
|
+
totalItems={totalItems}
|
|
298
|
+
filtered={hasSearch}
|
|
299
|
+
exporting={exportingAll}
|
|
300
|
+
disabled={loading || exportingAll || 0 === totalItems}
|
|
301
|
+
onExport={() => void handleExportAll()}
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
{(permissionsLoading || hasPermission('${prefix}.create')) && (
|
|
305
|
+
<LinkButton href="/dashboard/${entitiesKebab}/new" icon="bi-plus-lg">
|
|
306
|
+
New
|
|
307
|
+
</LinkButton>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
}
|
|
311
|
+
>
|
|
312
|
+
${searchPanelBlock} {trueEmpty && (
|
|
313
|
+
<EmptyState
|
|
314
|
+
title="No ${pluralNoun} yet"
|
|
315
|
+
description="Create your first ${noun} to get started."
|
|
316
|
+
action={
|
|
317
|
+
(permissionsLoading || hasPermission('${prefix}.create')) && (
|
|
318
|
+
<LinkButton href="/dashboard/${entitiesKebab}/new">
|
|
319
|
+
New
|
|
320
|
+
</LinkButton>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{filteredEmpty && (
|
|
327
|
+
<EmptyState
|
|
328
|
+
title="No ${pluralNoun} match your search"
|
|
329
|
+
description="Try a different search or clear it to see all ${pluralNoun}."
|
|
330
|
+
action={
|
|
331
|
+
<Button
|
|
332
|
+
variant="outline"
|
|
333
|
+
onClick={() =>
|
|
334
|
+
router.push(
|
|
335
|
+
build${entitiesPascal}Href(query, { page: 1${field ? ", search: ''" : ''} })
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
>
|
|
339
|
+
Clear search
|
|
340
|
+
</Button>
|
|
341
|
+
}
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{hasItems && 0 < selectedIds.size && (
|
|
346
|
+
<BulkActionsBar
|
|
347
|
+
selectedCount={selectedIds.size}
|
|
348
|
+
noun="${noun}"
|
|
349
|
+
pluralNoun="${pluralNoun}"
|
|
350
|
+
disabled={bulkDeleting || exportingSelected}
|
|
351
|
+
onDelete={permissionsLoading || hasPermission('${prefix}.bulk_delete') || hasPermission('${prefix}.delete') ? () => setBulkDeleteConfirming(true) : undefined}
|
|
352
|
+
onExportSelected={permissionsLoading || hasPermission('${prefix}.export_selected') ? handleExportSelected : undefined}
|
|
353
|
+
onClearSelection={() => setSelectedIds(new Set())}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
|
|
357
|
+
{hasItems && (
|
|
358
|
+
<div className="dash-table-scroll">
|
|
359
|
+
<table className="dash-table">
|
|
360
|
+
<thead>
|
|
361
|
+
<tr>
|
|
362
|
+
{(permissionsLoading || hasPermission('${prefix}.bulk_delete') || hasPermission('${prefix}.delete')) && (
|
|
363
|
+
<th style={{ width: 36 }}>
|
|
364
|
+
<input
|
|
365
|
+
ref={selectAllRef}
|
|
366
|
+
type="checkbox"
|
|
367
|
+
className="form-check-input"
|
|
368
|
+
checked={allOnPageSelected}
|
|
369
|
+
onChange={toggleSelectAll}
|
|
370
|
+
aria-label="Select all ${pluralNoun} on this page"
|
|
371
|
+
/>
|
|
372
|
+
</th>
|
|
373
|
+
)}
|
|
374
|
+
${headCells}
|
|
375
|
+
<th>Actions</th>
|
|
376
|
+
</tr>
|
|
377
|
+
</thead>
|
|
378
|
+
<tbody>
|
|
379
|
+
{items.map((item) => (
|
|
380
|
+
<tr key={item.id}>
|
|
381
|
+
{(permissionsLoading || hasPermission('${prefix}.bulk_delete') || hasPermission('${prefix}.delete')) && (
|
|
382
|
+
<td>
|
|
383
|
+
<input
|
|
384
|
+
type="checkbox"
|
|
385
|
+
className="form-check-input"
|
|
386
|
+
checked={selectedIds.has(item.id)}
|
|
387
|
+
onChange={() =>
|
|
388
|
+
toggleSelect(item.id)
|
|
389
|
+
}
|
|
390
|
+
aria-label={\`Select \${item.${label}}\`}
|
|
391
|
+
/>
|
|
392
|
+
</td>
|
|
393
|
+
)}
|
|
394
|
+
${bodyCells}
|
|
395
|
+
<td>
|
|
396
|
+
<div className="d-flex gap-2">
|
|
397
|
+
{(permissionsLoading || hasPermission('${prefix}.edit')) && (
|
|
398
|
+
<LinkButton
|
|
399
|
+
href={\`/dashboard/${entitiesKebab}/\${item.id}/edit\`}
|
|
400
|
+
variant="outline-primary"
|
|
401
|
+
size="sm"
|
|
402
|
+
icon="bi-pencil"
|
|
403
|
+
aria-label={\`Edit \${item.${label}}\`}
|
|
404
|
+
>
|
|
405
|
+
Edit
|
|
406
|
+
</LinkButton>
|
|
407
|
+
)}
|
|
408
|
+
{(permissionsLoading || hasPermission('${prefix}.delete')) && (
|
|
409
|
+
<Button
|
|
410
|
+
variant="outline-danger"
|
|
411
|
+
size="sm"
|
|
412
|
+
icon="bi-trash"
|
|
413
|
+
onClick={() =>
|
|
414
|
+
setDeleteTarget(item)
|
|
415
|
+
}
|
|
416
|
+
aria-label={\`Delete \${item.${label}}\`}
|
|
417
|
+
>
|
|
418
|
+
Delete
|
|
419
|
+
</Button>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</td>
|
|
423
|
+
</tr>
|
|
424
|
+
))}
|
|
425
|
+
</tbody>
|
|
426
|
+
</table>
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
{hasItems && (
|
|
431
|
+
<PaginationBar
|
|
432
|
+
page={query.page}
|
|
433
|
+
itemsPerPage={query.itemsPerPage}
|
|
434
|
+
pageSizes={${constPrefix}_PAGE_SIZES}
|
|
435
|
+
totalItems={totalItems}
|
|
436
|
+
loadedItems={items.length}
|
|
437
|
+
buildHref={(patch) => build${entitiesPascal}Href(query, patch)}
|
|
438
|
+
onPageChange={(page) =>
|
|
439
|
+
router.push(build${entitiesPascal}Href(query, { page }))
|
|
440
|
+
}
|
|
441
|
+
onPageSizeChange={(itemsPerPage) =>
|
|
442
|
+
navigateTo({
|
|
443
|
+
...query,
|
|
444
|
+
itemsPerPage,
|
|
445
|
+
page: nextItemsPerPagePage(
|
|
446
|
+
query.page,
|
|
447
|
+
query.itemsPerPage,
|
|
448
|
+
itemsPerPage
|
|
449
|
+
)
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{deleteTarget && (
|
|
456
|
+
<DeleteConfirmModal
|
|
457
|
+
title="Delete ${noun}?"
|
|
458
|
+
message={
|
|
459
|
+
<>
|
|
460
|
+
Are you sure you want to delete{' '}
|
|
461
|
+
<strong>{String(deleteTarget.${label})}</strong>? This action
|
|
462
|
+
cannot be undone.
|
|
463
|
+
</>
|
|
464
|
+
}
|
|
465
|
+
confirmLabel="Delete"
|
|
466
|
+
confirming={deleting}
|
|
467
|
+
onConfirm={() => void confirmDelete()}
|
|
468
|
+
onCancel={() => setDeleteTarget(null)}
|
|
469
|
+
/>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{bulkDeleteConfirming && (
|
|
473
|
+
<DeleteConfirmModal
|
|
474
|
+
title={\`Delete \${selectedIds.size} ${pluralNoun}?\`}
|
|
475
|
+
message={
|
|
476
|
+
<>
|
|
477
|
+
Are you sure you want to delete{' '}
|
|
478
|
+
<strong>{selectedIds.size}</strong> selected ${pluralNoun}?
|
|
479
|
+
This action cannot be undone.
|
|
480
|
+
</>
|
|
481
|
+
}
|
|
482
|
+
confirmLabel="Delete"
|
|
483
|
+
confirming={bulkDeleting}
|
|
484
|
+
onConfirm={() => void confirmBulkDelete()}
|
|
485
|
+
onCancel={() => setBulkDeleteConfirming(false)}
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
{toastMessage && (
|
|
490
|
+
<Toast
|
|
491
|
+
message={toastMessage}
|
|
492
|
+
onDismiss={() => setToastMessage('')}
|
|
493
|
+
/>
|
|
494
|
+
)}
|
|
495
|
+
</ListPageCard>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
`;
|
|
499
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { displayFields } from '../naming.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fresh-file bootstrap, only used when src/types/api.ts doesn't exist yet. In the boilerplate
|
|
4
|
+
* app this file already exists (written for the Notes feature) with these same JsonLd/Json
|
|
5
|
+
* helpers and the `paths`/`components` import from the OpenAPI-generated `./api.generated` —
|
|
6
|
+
* this header is only a fallback for a from-scratch project that hasn't set that up yet.
|
|
7
|
+
*/
|
|
8
|
+
export const typesApiFileHeader = `import type { components, paths } from './api.generated';
|
|
9
|
+
|
|
10
|
+
type JsonLd<T> = T extends { 'application/ld+json': infer V } ? V : never;
|
|
11
|
+
`;
|
|
12
|
+
export function typesApiMarker(entityPascal) {
|
|
13
|
+
return `export type ${entityPascal} =`;
|
|
14
|
+
}
|
|
15
|
+
export function typesApiBlock(spec, names) {
|
|
16
|
+
const { entityPascal, entitiesPascal, entitiesKebab } = names;
|
|
17
|
+
const picked = ['id', ...displayFields(spec).map((f) => f.name)];
|
|
18
|
+
if (spec.fields.some((f) => f.type === 'textarea')) {
|
|
19
|
+
for (const f of spec.fields) {
|
|
20
|
+
if (f.type === 'textarea' && !picked.includes(f.name))
|
|
21
|
+
picked.push(f.name);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (null !== spec.timestampField && !picked.includes(spec.timestampField)) {
|
|
25
|
+
picked.push(spec.timestampField);
|
|
26
|
+
}
|
|
27
|
+
const pickList = picked.map((p) => `'${p}'`).join(' | ');
|
|
28
|
+
return `
|
|
29
|
+
type ${entitiesPascal}CollectionResponses = paths['/api/${entitiesKebab}']['get']['responses'];
|
|
30
|
+
type ${entityPascal}PostResponses = paths['/api/${entitiesKebab}']['post']['responses'];
|
|
31
|
+
type Generated${entityPascal} = NonNullable<JsonLd<${entityPascal}PostResponses[201]['content']>>;
|
|
32
|
+
type Generated${entitiesPascal}Collection = NonNullable<
|
|
33
|
+
JsonLd<${entitiesPascal}CollectionResponses[200]['content']>
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
export type ${entityPascal}Input = components['schemas']['${entityPascal}.${entityPascal}Input'];
|
|
37
|
+
export type ${entityPascal} = Required<
|
|
38
|
+
Pick<Generated${entityPascal}, ${pickList}>
|
|
39
|
+
>;
|
|
40
|
+
export type ${entitiesPascal}Collection = Omit<
|
|
41
|
+
Generated${entitiesPascal}Collection,
|
|
42
|
+
'member' | 'totalItems'
|
|
43
|
+
> & { member: ${entityPascal}[]; totalItems: number };
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { searchField } from '../naming.js';
|
|
2
|
+
export function useExportHook(spec, names) {
|
|
3
|
+
const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
|
|
4
|
+
const field = searchField(spec);
|
|
5
|
+
return `import { useState } from 'react';
|
|
6
|
+
import { build${entitiesPascal}Csv } from '@/lib/${entitiesKebab}-csv';
|
|
7
|
+
import type { ${entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
|
|
8
|
+
import type { ${entityPascal} } from '@/types/api';
|
|
9
|
+
|
|
10
|
+
type ExportFailure = {
|
|
11
|
+
ok: false;
|
|
12
|
+
message: string;
|
|
13
|
+
status?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ExportResult = { ok: true } | ExportFailure;
|
|
17
|
+
|
|
18
|
+
function exportDate(): string {
|
|
19
|
+
return new Date().toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === 'object' && null !== value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function exportError(response: Response): Promise<ExportFailure> {
|
|
27
|
+
let data: unknown;
|
|
28
|
+
try {
|
|
29
|
+
data = await response.json();
|
|
30
|
+
} catch {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
status: response.status,
|
|
34
|
+
message: 'Could not export ${entitiesCamel}.'
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const detail = isRecord(data) ? data.detail : undefined;
|
|
39
|
+
const title = isRecord(data) ? data.title : undefined;
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
status: response.status,
|
|
43
|
+
message:
|
|
44
|
+
typeof detail === 'string'
|
|
45
|
+
? detail
|
|
46
|
+
: typeof title === 'string'
|
|
47
|
+
? title
|
|
48
|
+
: 'Could not export ${entitiesCamel}.'
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function triggerDownload(blob: Blob, filename: string): void {
|
|
53
|
+
const url = URL.createObjectURL(blob);
|
|
54
|
+
const link = document.createElement('a');
|
|
55
|
+
link.href = url;
|
|
56
|
+
link.download = filename;
|
|
57
|
+
link.style.display = 'none';
|
|
58
|
+
document.body.append(link);
|
|
59
|
+
try {
|
|
60
|
+
link.click();
|
|
61
|
+
} finally {
|
|
62
|
+
link.remove();
|
|
63
|
+
URL.revokeObjectURL(url);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function use${entitiesPascal}Export() {
|
|
68
|
+
const [exportingSelected, setExportingSelected] = useState(false);
|
|
69
|
+
const [exportingAll, setExportingAll] = useState(false);
|
|
70
|
+
|
|
71
|
+
function exportSelected(${entitiesCamel}: ${entityPascal}[]): ExportResult {
|
|
72
|
+
if (0 === ${entitiesCamel}.length) {
|
|
73
|
+
return { ok: false, message: 'Select ${entitiesCamel} to export.' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setExportingSelected(true);
|
|
77
|
+
try {
|
|
78
|
+
triggerDownload(
|
|
79
|
+
new Blob([build${entitiesPascal}Csv(${entitiesCamel})], {
|
|
80
|
+
type: 'text/csv;charset=utf-8'
|
|
81
|
+
}),
|
|
82
|
+
\`${entitiesKebab}-export-selected-\${exportDate()}.csv\`
|
|
83
|
+
);
|
|
84
|
+
return { ok: true };
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, message: 'Could not export selected ${entitiesCamel}.' };
|
|
87
|
+
} finally {
|
|
88
|
+
setExportingSelected(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function exportAll(query: ${entitiesPascal}Query): Promise<ExportResult> {
|
|
93
|
+
setExportingAll(true);
|
|
94
|
+
const params = new URLSearchParams();
|
|
95
|
+
${field !== null ? " if ('' !== query.search) params.set('search', query.search);\n" : ''} const path = params.size
|
|
96
|
+
? \`/api/${entitiesKebab}/export?\${params.toString()}\`
|
|
97
|
+
: '/api/${entitiesKebab}/export';
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(path, { cache: 'no-store' });
|
|
101
|
+
if (!response.ok) return exportError(response);
|
|
102
|
+
|
|
103
|
+
triggerDownload(
|
|
104
|
+
await response.blob(),
|
|
105
|
+
\`${entitiesKebab}-export-all-\${exportDate()}.csv\`
|
|
106
|
+
);
|
|
107
|
+
return { ok: true };
|
|
108
|
+
} catch {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
message: 'Network error while exporting ${entitiesCamel}.'
|
|
112
|
+
};
|
|
113
|
+
} finally {
|
|
114
|
+
setExportingAll(false);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { exportingSelected, exportingAll, exportSelected, exportAll };
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
}
|