@kematjaya/crud-ui-generator 0.1.0 → 0.2.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 +1 -2
- package/dist/cli.js +3 -1
- package/dist/spec.js +2 -0
- package/dist/templates/apiShapes.js +2 -1
- package/dist/templates/bffRoutes.js +9 -2
- package/dist/templates/hook.js +269 -0
- package/dist/templates/table.js +40 -228
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,8 +55,7 @@ Appended to (multi-entity, idempotent — each entity gets one marker-guarded bl
|
|
|
55
55
|
|
|
56
56
|
## Assumptions / caveats
|
|
57
57
|
|
|
58
|
-
- **
|
|
59
|
-
hex-with-dashes pattern. Adjust by hand if an entity uses a different id type.
|
|
58
|
+
- **Id type comes from the spec's `idType`** (`uuid`/`int`/`string`, written by `ApiCrudRenderer::detectIdType()` off the entity's actual id column) — `validId()` in the generated `[id]/route.ts` and the `is{Entity}` type guard in `api-shapes.ts` are generated to match. Older spec files without `idType` default to `uuid`.
|
|
60
59
|
- **The list search box only searches one field.** If more than one field is marked
|
|
61
60
|
`searchable` in the spec, the list view (backed by ApiPlatform's `SearchFilter`, one query
|
|
62
61
|
param per property) only wires up the first one. The export endpoint ORs across all of them.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { appendBlock, appendBlockWithImport, newLog, writeIfMissing, writeNewFil
|
|
|
6
6
|
import * as shared from './templates/sharedComponents.js';
|
|
7
7
|
import { editPage, listPage, newPage } from './templates/pages.js';
|
|
8
8
|
import { table } from './templates/table.js';
|
|
9
|
+
import { entityHook } from './templates/hook.js';
|
|
9
10
|
import { form } from './templates/form.js';
|
|
10
11
|
import { useExportHook } from './templates/useExport.js';
|
|
11
12
|
import { queryLib } from './templates/queryLib.js';
|
|
@@ -81,6 +82,7 @@ function main() {
|
|
|
81
82
|
writeNewFile(join(compDir, `${names.entityPascal}Table.tsx`), table(spec, names), log);
|
|
82
83
|
writeNewFile(join(compDir, `${names.entityPascal}Form.tsx`), form(spec, names), log);
|
|
83
84
|
writeNewFile(join(compDir, `use${names.entitiesPascal}Export.ts`), useExportHook(spec, names), log);
|
|
85
|
+
writeNewFile(join(compDir, `use${names.entitiesPascal}.ts`), entityHook(spec, names), log);
|
|
84
86
|
const libDir = join(src, 'lib');
|
|
85
87
|
writeNewFile(join(libDir, `${names.entitiesKebab}-query.ts`), queryLib(spec, names), log);
|
|
86
88
|
writeNewFile(join(libDir, `${names.entitiesKebab}-csv.ts`), csvLib(spec, names), log);
|
|
@@ -100,7 +102,7 @@ function main() {
|
|
|
100
102
|
if (searchableFields(spec).length > 1) {
|
|
101
103
|
console.log(` 4. Note: multiple searchable fields were configured (${searchableFields(spec).join(', ')}); the list view's single search box only queries by "${searchField(spec)}" (ApiPlatform SearchFilter's per-property param convention doesn't support one box matching several properties). The export endpoint does OR across all of them.`);
|
|
102
104
|
}
|
|
103
|
-
console.log(`
|
|
105
|
+
console.log(` Id type: "${spec.idType}" (from the spec's "idType") — validId() in the generated app/api/${names.entitiesKebab}/[id]/route.ts was generated to match.`);
|
|
104
106
|
console.log(` Run "npm run format" afterwards — generated files aren't pre-formatted to this project's Prettier config.`);
|
|
105
107
|
}
|
|
106
108
|
main();
|
package/dist/spec.js
CHANGED
|
@@ -37,11 +37,13 @@ export function loadSpec(specPath) {
|
|
|
37
37
|
if (!Array.isArray(spec.fields) || !spec.fields.every(isFieldSpec)) {
|
|
38
38
|
throw new Error(`Spec file "fields" is missing or malformed: ${specPath}`);
|
|
39
39
|
}
|
|
40
|
+
const idType = spec.idType === 'int' || spec.idType === 'string' || spec.idType === 'uuid' ? spec.idType : 'uuid';
|
|
40
41
|
return {
|
|
41
42
|
entity: spec.entity,
|
|
42
43
|
permissionPrefix: spec.permissionPrefix,
|
|
43
44
|
ownerProperty: typeof spec.ownerProperty === 'string' ? spec.ownerProperty : null,
|
|
44
45
|
timestampField: typeof spec.timestampField === 'string' ? spec.timestampField : null,
|
|
46
|
+
idType,
|
|
45
47
|
fields: spec.fields,
|
|
46
48
|
};
|
|
47
49
|
}
|
|
@@ -18,7 +18,8 @@ export function apiShapesImport(names) {
|
|
|
18
18
|
}
|
|
19
19
|
export function apiShapesBlock(spec, names) {
|
|
20
20
|
const { entityPascal, entitiesPascal } = names;
|
|
21
|
-
const
|
|
21
|
+
const idJsType = 'int' === spec.idType ? 'number' : 'string';
|
|
22
|
+
const checks = ['isRecord(value)', `typeof value.id === '${idJsType}'`];
|
|
22
23
|
for (const field of spec.fields) {
|
|
23
24
|
checks.push(`typeof value.${field.name} === '${tsType(field)}'`);
|
|
24
25
|
}
|
|
@@ -35,7 +35,14 @@ export const dynamic = 'force-dynamic';
|
|
|
35
35
|
export const revalidate = 0;
|
|
36
36
|
`;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
function validIdCheck(idType) {
|
|
39
|
+
if (idType === 'int')
|
|
40
|
+
return 'return /^[1-9][0-9]*$/.test(id);';
|
|
41
|
+
if (idType === 'string')
|
|
42
|
+
return "return id.length > 0;";
|
|
43
|
+
return "return /^[0-9a-fA-F-]{36}$/.test(id);";
|
|
44
|
+
}
|
|
45
|
+
export function itemRoute(spec, names) {
|
|
39
46
|
const { entityCamel, entitiesKebab } = names;
|
|
40
47
|
return `import type { NextRequest } from 'next/server';
|
|
41
48
|
import { authedBackend } from '@/lib/bff';
|
|
@@ -45,7 +52,7 @@ import { ${entityCamel}Schema } from '@/lib/schemas';
|
|
|
45
52
|
type Params = { params: Promise<{ id: string }> };
|
|
46
53
|
|
|
47
54
|
function validId(id: string) {
|
|
48
|
-
|
|
55
|
+
${validIdCheck(spec.idType)}
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
export async function GET(_request: NextRequest, context: Params) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { lowerWords, searchField } from '../naming.js';
|
|
2
|
+
export function entityHook(spec, names) {
|
|
3
|
+
const { entityPascal, entitiesPascal, entitiesKebab } = names;
|
|
4
|
+
const field = searchField(spec);
|
|
5
|
+
const pluralNoun = lowerWords(entitiesPascal);
|
|
6
|
+
const hasSearchLine = field
|
|
7
|
+
? ` const hasSearch = Boolean(query.search);\n`
|
|
8
|
+
: ` const hasSearch = false;\n`;
|
|
9
|
+
return `import { useRouter, useSearchParams } from 'next/navigation';
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { is${entityPascal}Collection } from '@/lib/api-shapes';
|
|
12
|
+
import {
|
|
13
|
+
build${entitiesPascal}ApiPath,
|
|
14
|
+
build${entitiesPascal}Href,
|
|
15
|
+
clampPageToTotal,
|
|
16
|
+
parse${entitiesPascal}Query,
|
|
17
|
+
type ${entitiesPascal}Query
|
|
18
|
+
} from '@/lib/${entitiesKebab}-query';
|
|
19
|
+
import type { ${entityPascal} } from '@/types/api';
|
|
20
|
+
import { use${entitiesPascal}Export } from './use${entitiesPascal}Export';
|
|
21
|
+
|
|
22
|
+
export function use${entitiesPascal}() {
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const searchParams = useSearchParams();
|
|
25
|
+
const query = useMemo(() => parse${entitiesPascal}Query(searchParams), [searchParams]);
|
|
26
|
+
const [items, setItems] = useState<${entityPascal}[]>([]);
|
|
27
|
+
const [totalItems, setTotalItems] = useState(0);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [error, setError] = useState('');
|
|
30
|
+
const [deleteTarget, setDeleteTarget] = useState<${entityPascal} | null>(null);
|
|
31
|
+
const [deleting, setDeleting] = useState(false);
|
|
32
|
+
const [selectedIds, setSelectedIds] = useState<Set<${entityPascal}['id']>>(new Set());
|
|
33
|
+
const [bulkDeleteConfirming, setBulkDeleteConfirming] = useState(false);
|
|
34
|
+
const [bulkDeleting, setBulkDeleting] = useState(false);
|
|
35
|
+
const [toastMessage, setToastMessage] = useState(
|
|
36
|
+
() => searchParams.get('toast') ?? ''
|
|
37
|
+
);
|
|
38
|
+
const { exportingSelected, exportingAll, exportSelected, exportAll } =
|
|
39
|
+
use${entitiesPascal}Export();
|
|
40
|
+
const requestId = useRef(0);
|
|
41
|
+
|
|
42
|
+
const loadItems = useCallback(
|
|
43
|
+
async (signal?: AbortSignal) => {
|
|
44
|
+
const id = requestId.current + 1;
|
|
45
|
+
requestId.current = id;
|
|
46
|
+
setError('');
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setItems([]);
|
|
49
|
+
setTotalItems(0);
|
|
50
|
+
let response: Response;
|
|
51
|
+
try {
|
|
52
|
+
response = await fetch(build${entitiesPascal}ApiPath(query), {
|
|
53
|
+
cache: 'no-store',
|
|
54
|
+
signal
|
|
55
|
+
});
|
|
56
|
+
} catch (fetchError) {
|
|
57
|
+
if (
|
|
58
|
+
fetchError instanceof DOMException &&
|
|
59
|
+
fetchError.name === 'AbortError'
|
|
60
|
+
)
|
|
61
|
+
return;
|
|
62
|
+
if (requestId.current !== id) return;
|
|
63
|
+
setError('Network error while loading ${pluralNoun}.');
|
|
64
|
+
setLoading(false);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (requestId.current !== id || signal?.aborted) return;
|
|
68
|
+
if (response.status === 401) {
|
|
69
|
+
router.push('/login');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
setError('Could not load ${pluralNoun}.');
|
|
74
|
+
setLoading(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const data: unknown = await response.json();
|
|
78
|
+
if (!is${entityPascal}Collection(data)) {
|
|
79
|
+
setError('Unexpected response.');
|
|
80
|
+
setLoading(false);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const clampedPage = clampPageToTotal(
|
|
84
|
+
query.page,
|
|
85
|
+
query.itemsPerPage,
|
|
86
|
+
data.totalItems
|
|
87
|
+
);
|
|
88
|
+
if (clampedPage !== query.page) {
|
|
89
|
+
router.replace(build${entitiesPascal}Href(query, { page: clampedPage }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setItems(data.member);
|
|
93
|
+
setTotalItems(data.totalItems);
|
|
94
|
+
setLoading(false);
|
|
95
|
+
},
|
|
96
|
+
[query, router]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = window.setTimeout(
|
|
102
|
+
() => void loadItems(controller.signal),
|
|
103
|
+
0
|
|
104
|
+
);
|
|
105
|
+
return () => {
|
|
106
|
+
window.clearTimeout(timer);
|
|
107
|
+
controller.abort();
|
|
108
|
+
};
|
|
109
|
+
}, [loadItems]);
|
|
110
|
+
|
|
111
|
+
const [selectionQuery, setSelectionQuery] = useState(query);
|
|
112
|
+
if (selectionQuery !== query) {
|
|
113
|
+
setSelectionQuery(query);
|
|
114
|
+
setSelectedIds(new Set());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function navigateTo(nextQuery: ${entitiesPascal}Query) {
|
|
118
|
+
router.push(build${entitiesPascal}Href(nextQuery));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
${hasSearchLine} const trueEmpty = !loading && !error && !hasSearch && totalItems === 0;
|
|
122
|
+
const filteredEmpty = !loading && !error && hasSearch && totalItems === 0;
|
|
123
|
+
const hasItems = !loading && items.length > 0;
|
|
124
|
+
const selectedItems = useMemo(
|
|
125
|
+
() => items.filter((item) => selectedIds.has(item.id)),
|
|
126
|
+
[items, selectedIds]
|
|
127
|
+
);
|
|
128
|
+
const allOnPageSelected =
|
|
129
|
+
items.length > 0 && items.every((item) => selectedIds.has(item.id));
|
|
130
|
+
const someOnPageSelected = items.some((item) => selectedIds.has(item.id));
|
|
131
|
+
|
|
132
|
+
function toggleSelect(id: ${entityPascal}['id']) {
|
|
133
|
+
setSelectedIds((prev) => {
|
|
134
|
+
const next = new Set(prev);
|
|
135
|
+
if (next.has(id)) next.delete(id);
|
|
136
|
+
else next.add(id);
|
|
137
|
+
return next;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toggleSelectAll() {
|
|
142
|
+
setSelectedIds((prev) => {
|
|
143
|
+
const allSelected =
|
|
144
|
+
items.length > 0 && items.every((item) => prev.has(item.id));
|
|
145
|
+
return allSelected
|
|
146
|
+
? new Set()
|
|
147
|
+
: new Set(items.map((item) => item.id));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function clearSelection() {
|
|
152
|
+
setSelectedIds(new Set());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function confirmDelete() {
|
|
156
|
+
if (!deleteTarget) return;
|
|
157
|
+
setDeleting(true);
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetch(\`/api/${entitiesKebab}/\${deleteTarget.id}\`, {
|
|
161
|
+
method: 'DELETE'
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
setError('Network error while deleting.');
|
|
165
|
+
setDeleting(false);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setDeleting(false);
|
|
169
|
+
if (!response.ok && response.status !== 204) {
|
|
170
|
+
setError('Could not delete.');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
setDeleteTarget(null);
|
|
174
|
+
setToastMessage('Deleted.');
|
|
175
|
+
await loadItems();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function confirmBulkDelete() {
|
|
179
|
+
const ids = Array.from(selectedIds);
|
|
180
|
+
if (ids.length === 0) return;
|
|
181
|
+
setBulkDeleting(true);
|
|
182
|
+
const outcomes = await Promise.allSettled(
|
|
183
|
+
ids.map(async (id) => {
|
|
184
|
+
const response = await fetch(\`/api/${entitiesKebab}/\${id}\`, {
|
|
185
|
+
method: 'DELETE'
|
|
186
|
+
});
|
|
187
|
+
if (!response.ok && response.status !== 204) {
|
|
188
|
+
throw new Error('delete failed');
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
setBulkDeleting(false);
|
|
193
|
+
setBulkDeleteConfirming(false);
|
|
194
|
+
const failedIds = ids.filter(
|
|
195
|
+
(_, index) => outcomes[index]?.status === 'rejected'
|
|
196
|
+
);
|
|
197
|
+
const deletedCount = ids.length - failedIds.length;
|
|
198
|
+
if (failedIds.length === ids.length) {
|
|
199
|
+
setError('Could not delete selected ${pluralNoun}.');
|
|
200
|
+
} else if (failedIds.length > 0) {
|
|
201
|
+
setError(
|
|
202
|
+
\`Deleted \${deletedCount} of \${ids.length}. Some deletions failed.\`
|
|
203
|
+
);
|
|
204
|
+
setToastMessage(\`\${deletedCount} deleted.\`);
|
|
205
|
+
} else {
|
|
206
|
+
setToastMessage(\`\${deletedCount} deleted.\`);
|
|
207
|
+
}
|
|
208
|
+
setSelectedIds(new Set(failedIds));
|
|
209
|
+
await loadItems();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function handleExportSelected() {
|
|
213
|
+
setError('');
|
|
214
|
+
if (0 === selectedItems.length) return;
|
|
215
|
+
const result = exportSelected(selectedItems);
|
|
216
|
+
if (result.ok) {
|
|
217
|
+
setSelectedIds(new Set());
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
setError(result.message);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function handleExportAll() {
|
|
224
|
+
setError('');
|
|
225
|
+
const result = await exportAll(query);
|
|
226
|
+
if (result.ok) return;
|
|
227
|
+
if (401 === result.status) {
|
|
228
|
+
router.push('/login');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
setError(result.message);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
query,
|
|
236
|
+
items,
|
|
237
|
+
totalItems,
|
|
238
|
+
loading,
|
|
239
|
+
error,
|
|
240
|
+
hasSearch,
|
|
241
|
+
trueEmpty,
|
|
242
|
+
filteredEmpty,
|
|
243
|
+
hasItems,
|
|
244
|
+
navigateTo,
|
|
245
|
+
selectedIds,
|
|
246
|
+
selectedItems,
|
|
247
|
+
allOnPageSelected,
|
|
248
|
+
someOnPageSelected,
|
|
249
|
+
toggleSelect,
|
|
250
|
+
toggleSelectAll,
|
|
251
|
+
clearSelection,
|
|
252
|
+
deleteTarget,
|
|
253
|
+
setDeleteTarget,
|
|
254
|
+
deleting,
|
|
255
|
+
confirmDelete,
|
|
256
|
+
bulkDeleteConfirming,
|
|
257
|
+
setBulkDeleteConfirming,
|
|
258
|
+
bulkDeleting,
|
|
259
|
+
confirmBulkDelete,
|
|
260
|
+
toastMessage,
|
|
261
|
+
setToastMessage,
|
|
262
|
+
exportingSelected,
|
|
263
|
+
exportingAll,
|
|
264
|
+
handleExportSelected,
|
|
265
|
+
handleExportAll
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
}
|
package/dist/templates/table.js
CHANGED
|
@@ -33,154 +33,68 @@ export function table(spec, names) {
|
|
|
33
33
|
pending={loading}
|
|
34
34
|
disabled={trueEmpty}
|
|
35
35
|
onSearch={(search) =>
|
|
36
|
-
|
|
36
|
+
navigateTo({ ...query, page: 1, search })
|
|
37
37
|
}
|
|
38
38
|
onClear={() =>
|
|
39
|
-
|
|
39
|
+
navigateTo({ ...query, page: 1, search: '' })
|
|
40
40
|
}
|
|
41
41
|
/>
|
|
42
42
|
|
|
43
43
|
`
|
|
44
44
|
: '';
|
|
45
|
-
const hasTitleFilterLine = field
|
|
46
|
-
? ` const hasSearch = Boolean(query.search);\n`
|
|
47
|
-
: ` const hasSearch = false;\n`;
|
|
48
45
|
return `'use client';
|
|
49
46
|
|
|
50
47
|
import { Button, EmptyState, LinkButton, ListPageCard, Toast } from '@kematjaya/bootstrap-ui-kit';
|
|
51
|
-
import {
|
|
52
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
48
|
+
import { useEffect, useRef } from 'react';
|
|
53
49
|
import { usePermissions } from '@kematjaya/access-control-ui';
|
|
54
50
|
import { BulkActionsBar } from '@/components/crud/BulkActionsBar';
|
|
55
51
|
import { DeleteConfirmModal } from '@/components/crud/DeleteConfirmModal';
|
|
56
52
|
import { ExportAllButton } from '@/components/crud/ExportAllButton';
|
|
57
53
|
import { PaginationBar } from '@/components/crud/PaginationBar';
|
|
58
54
|
import { SearchPanel } from '@/components/crud/SearchPanel';
|
|
59
|
-
import { is${entityPascal}Collection } from '@/lib/api-shapes';
|
|
60
55
|
import {
|
|
61
56
|
${constPrefix}_PAGE_SIZES,
|
|
62
|
-
build${entitiesPascal}ApiPath,
|
|
63
57
|
build${entitiesPascal}Href,
|
|
64
|
-
|
|
65
|
-
nextItemsPerPagePage,
|
|
66
|
-
parse${entitiesPascal}Query,
|
|
67
|
-
type ${entitiesPascal}Query
|
|
58
|
+
nextItemsPerPagePage
|
|
68
59
|
} from '@/lib/${entitiesKebab}-query';
|
|
69
|
-
import
|
|
70
|
-
import { use${entitiesPascal}Export } from './use${entitiesPascal}Export';
|
|
60
|
+
import { use${entitiesPascal} } from './use${entitiesPascal}';
|
|
71
61
|
|
|
72
62
|
export function ${entityPascal}Table() {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
63
|
+
const {
|
|
64
|
+
query,
|
|
65
|
+
items,
|
|
66
|
+
totalItems,
|
|
67
|
+
loading,
|
|
68
|
+
error,
|
|
69
|
+
hasSearch,
|
|
70
|
+
trueEmpty,
|
|
71
|
+
filteredEmpty,
|
|
72
|
+
hasItems,
|
|
73
|
+
navigateTo,
|
|
74
|
+
selectedIds,
|
|
75
|
+
allOnPageSelected,
|
|
76
|
+
someOnPageSelected,
|
|
77
|
+
toggleSelect,
|
|
78
|
+
toggleSelectAll,
|
|
79
|
+
clearSelection,
|
|
80
|
+
deleteTarget,
|
|
81
|
+
setDeleteTarget,
|
|
82
|
+
deleting,
|
|
83
|
+
confirmDelete,
|
|
84
|
+
bulkDeleteConfirming,
|
|
85
|
+
setBulkDeleteConfirming,
|
|
86
|
+
bulkDeleting,
|
|
87
|
+
confirmBulkDelete,
|
|
88
|
+
toastMessage,
|
|
89
|
+
setToastMessage,
|
|
90
|
+
exportingSelected,
|
|
91
|
+
exportingAll,
|
|
92
|
+
handleExportSelected,
|
|
93
|
+
handleExportAll
|
|
94
|
+
} = use${entitiesPascal}();
|
|
90
95
|
const { has: hasPermission, loading: permissionsLoading } = usePermissions();
|
|
91
|
-
const requestId = useRef(0);
|
|
92
96
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
93
97
|
|
|
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
98
|
useEffect(() => {
|
|
185
99
|
if (selectAllRef.current) {
|
|
186
100
|
selectAllRef.current.indeterminate =
|
|
@@ -188,104 +102,6 @@ ${hasTitleFilterLine} const trueEmpty = !loading && !error && !hasSearch && t
|
|
|
188
102
|
}
|
|
189
103
|
}, [someOnPageSelected, allOnPageSelected]);
|
|
190
104
|
|
|
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
105
|
return (
|
|
290
106
|
<ListPageCard
|
|
291
107
|
title="${entitiesPascal}"
|
|
@@ -331,9 +147,7 @@ ${searchPanelBlock} {trueEmpty && (
|
|
|
331
147
|
<Button
|
|
332
148
|
variant="outline"
|
|
333
149
|
onClick={() =>
|
|
334
|
-
|
|
335
|
-
build${entitiesPascal}Href(query, { page: 1${field ? ", search: ''" : ''} })
|
|
336
|
-
)
|
|
150
|
+
navigateTo({ ...query, page: 1${field ? ", search: ''" : ''} })
|
|
337
151
|
}
|
|
338
152
|
>
|
|
339
153
|
Clear search
|
|
@@ -350,7 +164,7 @@ ${searchPanelBlock} {trueEmpty && (
|
|
|
350
164
|
disabled={bulkDeleting || exportingSelected}
|
|
351
165
|
onDelete={permissionsLoading || hasPermission('${prefix}.bulk_delete') || hasPermission('${prefix}.delete') ? () => setBulkDeleteConfirming(true) : undefined}
|
|
352
166
|
onExportSelected={permissionsLoading || hasPermission('${prefix}.export_selected') ? handleExportSelected : undefined}
|
|
353
|
-
onClearSelection={
|
|
167
|
+
onClearSelection={clearSelection}
|
|
354
168
|
/>
|
|
355
169
|
)}
|
|
356
170
|
|
|
@@ -435,9 +249,7 @@ ${bodyCells}
|
|
|
435
249
|
totalItems={totalItems}
|
|
436
250
|
loadedItems={items.length}
|
|
437
251
|
buildHref={(patch) => build${entitiesPascal}Href(query, patch)}
|
|
438
|
-
onPageChange={(page) =>
|
|
439
|
-
router.push(build${entitiesPascal}Href(query, { page }))
|
|
440
|
-
}
|
|
252
|
+
onPageChange={(page) => navigateTo({ ...query, page })}
|
|
441
253
|
onPageSizeChange={(itemsPerPage) =>
|
|
442
254
|
navigateTo({
|
|
443
255
|
...query,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kematjaya/crud-ui-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Next.js CRUD frontend generator that reads crud-specs/{Entity}.json sidecars written by kematjaya/crud-maker-bundle's make:kmj-api-crud and generates pages, components, and BFF routes matching the boilerplate's hand-written Notes feature.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|