@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.
@@ -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
+ }