@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 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
- - **Ids are UUID strings.** `validId()` in the generated `[id]/route.ts` checks a 36-char
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(` Assumes UUID-shaped ids (matches this boilerplate's convention) — see validId() in the generated app/api/${names.entitiesKebab}/[id]/route.ts if this entity uses a different id type.`);
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 checks = ['isRecord(value)', "typeof value.id === 'string'"];
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
- export function itemRoute(_spec, names) {
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
- return /^[0-9a-fA-F-]{36}$/.test(id);
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
+ }
@@ -33,154 +33,68 @@ export function table(spec, names) {
33
33
  pending={loading}
34
34
  disabled={trueEmpty}
35
35
  onSearch={(search) =>
36
- router.push(build${entitiesPascal}Href(query, { page: 1, search }))
36
+ navigateTo({ ...query, page: 1, search })
37
37
  }
38
38
  onClear={() =>
39
- router.push(build${entitiesPascal}Href(query, { page: 1, search: '' }))
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 { useRouter, useSearchParams } from 'next/navigation';
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
- clampPageToTotal,
65
- nextItemsPerPagePage,
66
- parse${entitiesPascal}Query,
67
- type ${entitiesPascal}Query
58
+ nextItemsPerPagePage
68
59
  } from '@/lib/${entitiesKebab}-query';
69
- import type { ${entityPascal} } from '@/types/api';
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 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();
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
- router.push(
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={() => setSelectedIds(new Set())}
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.1.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",