@kematjaya/crud-ui-generator 0.1.1 → 0.2.1
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/dist/cli.js +2 -0
- package/dist/templates/hook.js +269 -0
- package/dist/templates/table.js +40 -228
- package/dist/templates/typesApi.js +10 -6
- package/package.json +1 -1
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);
|
|
@@ -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: ${entityPascal}['id']) {
|
|
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,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { displayFields } from '../naming.js';
|
|
2
2
|
/**
|
|
3
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* app this file already exists (written for the Notes feature) with this same Json helper
|
|
5
|
+
* and the `paths`/`components` import from the OpenAPI-generated `./api.generated` — this
|
|
6
|
+
* header is only a fallback for a from-scratch project that hasn't set that up yet.
|
|
7
|
+
*
|
|
8
|
+
* Extracts the `application/json` (not `application/ld+json`/Hydra) response shape, matching
|
|
9
|
+
* how `lib/bff.ts`'s `authedBackend()` calls the API Platform backend — plain JSON, so
|
|
10
|
+
* collections come back as `{ totalItems, member }` rather than a Hydra-wrapped `hydra:member`.
|
|
7
11
|
*/
|
|
8
12
|
export const typesApiFileHeader = `import type { components, paths } from './api.generated';
|
|
9
13
|
|
|
10
|
-
type
|
|
14
|
+
type Json<T> = T extends { 'application/json': infer V } ? V : never;
|
|
11
15
|
`;
|
|
12
16
|
export function typesApiMarker(entityPascal) {
|
|
13
17
|
return `export type ${entityPascal} =`;
|
|
@@ -28,9 +32,9 @@ export function typesApiBlock(spec, names) {
|
|
|
28
32
|
return `
|
|
29
33
|
type ${entitiesPascal}CollectionResponses = paths['/api/${entitiesKebab}']['get']['responses'];
|
|
30
34
|
type ${entityPascal}PostResponses = paths['/api/${entitiesKebab}']['post']['responses'];
|
|
31
|
-
type Generated${entityPascal} = NonNullable<
|
|
35
|
+
type Generated${entityPascal} = NonNullable<Json<${entityPascal}PostResponses[201]['content']>>;
|
|
32
36
|
type Generated${entitiesPascal}Collection = NonNullable<
|
|
33
|
-
|
|
37
|
+
Json<${entitiesPascal}CollectionResponses[200]['content']>
|
|
34
38
|
>;
|
|
35
39
|
|
|
36
40
|
export type ${entityPascal}Input = components['schemas']['${entityPascal}.${entityPascal}Input'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kematjaya/crud-ui-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|