@kematjaya/crud-ui-generator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/cli.js +106 -0
- package/dist/naming.js +47 -0
- package/dist/spec.js +47 -0
- package/dist/templates/apiShapes.js +45 -0
- package/dist/templates/bffRoutes.js +175 -0
- package/dist/templates/csvLib.js +30 -0
- package/dist/templates/form.js +172 -0
- package/dist/templates/pages.js +38 -0
- package/dist/templates/queryLib.js +77 -0
- package/dist/templates/schemas.js +36 -0
- package/dist/templates/sharedComponents.js +415 -0
- package/dist/templates/table.js +499 -0
- package/dist/templates/typesApi.js +45 -0
- package/dist/templates/useExport.js +121 -0
- package/dist/write.js +70 -0
- package/package.json +26 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity-agnostic CRUD UI primitives, genericized from boilerplate/frontend's hand-written
|
|
3
|
+
* Notes feature (DeleteConfirmModal, NotesBulkActionsBar, NotesPaginationBar, NotesSearchPanel,
|
|
4
|
+
* ExportAllButton — all under src/components/notes/). Written once per frontend project into
|
|
5
|
+
* src/components/crud/ (skipped on repeat runs / for later entities) so N generated entities
|
|
6
|
+
* don't each carry a byte-identical copy.
|
|
7
|
+
*/
|
|
8
|
+
export const deleteConfirmModal = `'use client';
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
import { Button } from '@kematjaya/bootstrap-ui-kit';
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
title: string;
|
|
15
|
+
message: ReactNode;
|
|
16
|
+
confirmLabel?: string;
|
|
17
|
+
confirming: boolean;
|
|
18
|
+
onConfirm: () => void;
|
|
19
|
+
onCancel: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function DeleteConfirmModal({
|
|
23
|
+
title,
|
|
24
|
+
message,
|
|
25
|
+
confirmLabel = 'Delete',
|
|
26
|
+
confirming,
|
|
27
|
+
onConfirm,
|
|
28
|
+
onCancel
|
|
29
|
+
}: Props) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<div className="modal-backdrop fade show" />
|
|
33
|
+
<div
|
|
34
|
+
className="modal fade show d-block"
|
|
35
|
+
tabIndex={-1}
|
|
36
|
+
role="dialog"
|
|
37
|
+
aria-modal="true"
|
|
38
|
+
aria-labelledby="crud-delete-modal-title"
|
|
39
|
+
>
|
|
40
|
+
<div className="modal-dialog modal-dialog-centered">
|
|
41
|
+
<div className="modal-content">
|
|
42
|
+
<div className="modal-header">
|
|
43
|
+
<h5 className="modal-title" id="crud-delete-modal-title">
|
|
44
|
+
{title}
|
|
45
|
+
</h5>
|
|
46
|
+
<button type="button" className="btn-close" aria-label="Close" onClick={onCancel} />
|
|
47
|
+
</div>
|
|
48
|
+
<div className="modal-body">
|
|
49
|
+
<p className="m-0">{message}</p>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="modal-footer" style={{ flexDirection: 'row-reverse' }}>
|
|
52
|
+
<Button variant="danger" disabled={confirming} onClick={onConfirm}>
|
|
53
|
+
{confirming ? 'Deleting…' : confirmLabel}
|
|
54
|
+
</Button>
|
|
55
|
+
<Button variant="outline" onClick={onCancel}>
|
|
56
|
+
Cancel
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
export const bulkActionsBar = `type Props = {
|
|
67
|
+
selectedCount: number;
|
|
68
|
+
noun: string;
|
|
69
|
+
pluralNoun: string;
|
|
70
|
+
disabled?: boolean;
|
|
71
|
+
onDelete?: () => void;
|
|
72
|
+
onExportSelected?: () => void;
|
|
73
|
+
onClearSelection: () => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function BulkActionsBar({
|
|
77
|
+
selectedCount,
|
|
78
|
+
noun,
|
|
79
|
+
pluralNoun,
|
|
80
|
+
disabled = false,
|
|
81
|
+
onDelete,
|
|
82
|
+
onExportSelected,
|
|
83
|
+
onClearSelection
|
|
84
|
+
}: Props) {
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
className="crud-bulk-bar"
|
|
88
|
+
role="toolbar"
|
|
89
|
+
aria-label="Bulk actions"
|
|
90
|
+
>
|
|
91
|
+
<span className="crud-bulk-count">
|
|
92
|
+
{selectedCount} {selectedCount === 1 ? noun : pluralNoun}{' '}
|
|
93
|
+
selected
|
|
94
|
+
</span>
|
|
95
|
+
<div className="crud-bulk-actions">
|
|
96
|
+
{onDelete && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
className="btn btn-outline-danger btn-sm"
|
|
100
|
+
disabled={disabled}
|
|
101
|
+
onClick={onDelete}
|
|
102
|
+
>
|
|
103
|
+
<i className="bi bi-trash" aria-hidden="true" /> Delete
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
{onExportSelected && (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
className="btn btn-outline-primary btn-sm"
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
onClick={onExportSelected}
|
|
112
|
+
>
|
|
113
|
+
<i className="bi bi-download" aria-hidden="true" /> Export
|
|
114
|
+
Selected
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="btn btn-sm btn-link p-0"
|
|
120
|
+
onClick={onClearSelection}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
>
|
|
123
|
+
Clear selection
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
export const exportAllButton = `type Props = {
|
|
131
|
+
totalItems: number;
|
|
132
|
+
filtered: boolean;
|
|
133
|
+
exporting: boolean;
|
|
134
|
+
disabled: boolean;
|
|
135
|
+
onExport: () => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function ExportAllButton({
|
|
139
|
+
totalItems,
|
|
140
|
+
filtered,
|
|
141
|
+
exporting,
|
|
142
|
+
disabled,
|
|
143
|
+
onExport
|
|
144
|
+
}: Props) {
|
|
145
|
+
return (
|
|
146
|
+
<div className="crud-export-all">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="btn btn-outline-primary"
|
|
150
|
+
disabled={disabled}
|
|
151
|
+
aria-busy={exporting}
|
|
152
|
+
onClick={onExport}
|
|
153
|
+
>
|
|
154
|
+
<i className="bi bi-download" aria-hidden="true" /> Export All (
|
|
155
|
+
{totalItems})
|
|
156
|
+
</button>
|
|
157
|
+
{filtered && (
|
|
158
|
+
<span className="crud-export-filter" aria-live="polite">
|
|
159
|
+
matching current search
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
`;
|
|
166
|
+
export const paginationBar = `import { Button, LinkButton, dashButtonClassName } from '@kematjaya/bootstrap-ui-kit';
|
|
167
|
+
|
|
168
|
+
export type PageSizeChange = { page?: number; itemsPerPage?: number };
|
|
169
|
+
|
|
170
|
+
type Props = {
|
|
171
|
+
page: number;
|
|
172
|
+
itemsPerPage: number;
|
|
173
|
+
pageSizes: readonly number[];
|
|
174
|
+
totalItems: number;
|
|
175
|
+
loadedItems: number;
|
|
176
|
+
buildHref: (patch: PageSizeChange) => string;
|
|
177
|
+
onPageSizeChange: (itemsPerPage: number) => void;
|
|
178
|
+
onPageChange: (page: number) => void;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function PageControl({
|
|
182
|
+
disabled,
|
|
183
|
+
href,
|
|
184
|
+
label
|
|
185
|
+
}: {
|
|
186
|
+
disabled: boolean;
|
|
187
|
+
href: string;
|
|
188
|
+
label: string;
|
|
189
|
+
}) {
|
|
190
|
+
if (disabled) {
|
|
191
|
+
return (
|
|
192
|
+
<span
|
|
193
|
+
className={\`\${dashButtonClassName('outline')} disabled\`}
|
|
194
|
+
aria-disabled="true"
|
|
195
|
+
>
|
|
196
|
+
{label}
|
|
197
|
+
</span>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<LinkButton variant="outline" href={href}>
|
|
203
|
+
{label}
|
|
204
|
+
</LinkButton>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function PaginationBar({
|
|
209
|
+
page,
|
|
210
|
+
itemsPerPage,
|
|
211
|
+
pageSizes,
|
|
212
|
+
totalItems,
|
|
213
|
+
loadedItems,
|
|
214
|
+
buildHref,
|
|
215
|
+
onPageSizeChange,
|
|
216
|
+
onPageChange
|
|
217
|
+
}: Props) {
|
|
218
|
+
if (totalItems <= itemsPerPage) return null;
|
|
219
|
+
|
|
220
|
+
const pageCount = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
|
221
|
+
const first = totalItems === 0 ? 0 : (page - 1) * itemsPerPage + 1;
|
|
222
|
+
const last = first === 0 ? 0 : first + loadedItems - 1;
|
|
223
|
+
const summary = \`\${first}–\${last} of \${totalItems}\`;
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<nav className="crud-pagination" aria-label="Pagination">
|
|
227
|
+
<p className="dash-card-subtitle" aria-live="polite">
|
|
228
|
+
Showing {summary}
|
|
229
|
+
</p>
|
|
230
|
+
<label className="crud-page-size">
|
|
231
|
+
<span>Rows</span>
|
|
232
|
+
<select
|
|
233
|
+
className="form-select form-select-sm"
|
|
234
|
+
value={itemsPerPage}
|
|
235
|
+
aria-label="Rows per page"
|
|
236
|
+
onChange={(event) =>
|
|
237
|
+
onPageSizeChange(Number(event.target.value))
|
|
238
|
+
}
|
|
239
|
+
>
|
|
240
|
+
{pageSizes.map((size) => (
|
|
241
|
+
<option key={size} value={size}>
|
|
242
|
+
{size}
|
|
243
|
+
</option>
|
|
244
|
+
))}
|
|
245
|
+
</select>
|
|
246
|
+
</label>
|
|
247
|
+
<div className="crud-page-links">
|
|
248
|
+
<PageControl
|
|
249
|
+
disabled={page === 1}
|
|
250
|
+
href={buildHref({ page: 1 })}
|
|
251
|
+
label="First"
|
|
252
|
+
/>
|
|
253
|
+
<PageControl
|
|
254
|
+
disabled={page === 1}
|
|
255
|
+
href={buildHref({ page: Math.max(1, page - 1) })}
|
|
256
|
+
label="Previous"
|
|
257
|
+
/>
|
|
258
|
+
<span className="dash-card-subtitle">
|
|
259
|
+
Page {page} of {pageCount}
|
|
260
|
+
</span>
|
|
261
|
+
<form
|
|
262
|
+
className="crud-page-jump"
|
|
263
|
+
onSubmit={(event) => {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
const value = Number(
|
|
266
|
+
new FormData(event.currentTarget).get('page')
|
|
267
|
+
);
|
|
268
|
+
if (Number.isInteger(value))
|
|
269
|
+
onPageChange(Math.min(pageCount, Math.max(1, value)));
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<label>
|
|
273
|
+
<span>Go to page</span>
|
|
274
|
+
<input
|
|
275
|
+
key={page}
|
|
276
|
+
className="form-control form-control-sm"
|
|
277
|
+
name="page"
|
|
278
|
+
type="number"
|
|
279
|
+
min={1}
|
|
280
|
+
max={pageCount}
|
|
281
|
+
defaultValue={page}
|
|
282
|
+
aria-label="Go to page"
|
|
283
|
+
/>
|
|
284
|
+
</label>
|
|
285
|
+
<Button type="submit" variant="outline">
|
|
286
|
+
Go
|
|
287
|
+
</Button>
|
|
288
|
+
</form>
|
|
289
|
+
<PageControl
|
|
290
|
+
disabled={page >= pageCount}
|
|
291
|
+
href={buildHref({ page: Math.min(pageCount, page + 1) })}
|
|
292
|
+
label="Next"
|
|
293
|
+
/>
|
|
294
|
+
<PageControl
|
|
295
|
+
disabled={page >= pageCount}
|
|
296
|
+
href={buildHref({ page: pageCount })}
|
|
297
|
+
label="Last"
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
</nav>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
`;
|
|
304
|
+
export const searchPanel = `import { useId } from 'react';
|
|
305
|
+
import { Button } from '@kematjaya/bootstrap-ui-kit';
|
|
306
|
+
|
|
307
|
+
type Props = {
|
|
308
|
+
label: string;
|
|
309
|
+
value: string;
|
|
310
|
+
totalItems: number;
|
|
311
|
+
pluralNoun: string;
|
|
312
|
+
pending: boolean;
|
|
313
|
+
disabled: boolean;
|
|
314
|
+
onSearch: (value: string) => void;
|
|
315
|
+
onClear: () => void;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
function summary(totalItems: number, pluralNoun: string, filtered: boolean) {
|
|
319
|
+
return filtered
|
|
320
|
+
? \`\${totalItems} \${pluralNoun} match your search\`
|
|
321
|
+
: \`\${totalItems} \${pluralNoun} total\`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function SearchPanel({
|
|
325
|
+
label,
|
|
326
|
+
value,
|
|
327
|
+
totalItems,
|
|
328
|
+
pluralNoun,
|
|
329
|
+
pending,
|
|
330
|
+
disabled,
|
|
331
|
+
onSearch,
|
|
332
|
+
onClear
|
|
333
|
+
}: Props) {
|
|
334
|
+
const inputId = useId();
|
|
335
|
+
const trimmedValue = value.trim();
|
|
336
|
+
const searchDisabled = pending || disabled;
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="crud-search-panel">
|
|
340
|
+
<form
|
|
341
|
+
className="crud-toolbar"
|
|
342
|
+
method="get"
|
|
343
|
+
role="search"
|
|
344
|
+
onSubmit={(event) => {
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
onSearch(
|
|
347
|
+
new FormData(event.currentTarget)
|
|
348
|
+
.get('q')
|
|
349
|
+
?.toString()
|
|
350
|
+
.trim() ?? ''
|
|
351
|
+
);
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
<div className="crud-search-field">
|
|
355
|
+
<label htmlFor={inputId} className="form-label mb-1">
|
|
356
|
+
{label}
|
|
357
|
+
</label>
|
|
358
|
+
<input
|
|
359
|
+
id={inputId}
|
|
360
|
+
type="search"
|
|
361
|
+
className="form-control"
|
|
362
|
+
name="q"
|
|
363
|
+
defaultValue={value}
|
|
364
|
+
placeholder={label}
|
|
365
|
+
autoComplete="off"
|
|
366
|
+
disabled={searchDisabled}
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
<div className="crud-toolbar-actions">
|
|
370
|
+
<Button type="submit" disabled={searchDisabled}>
|
|
371
|
+
Search
|
|
372
|
+
</Button>
|
|
373
|
+
<Button
|
|
374
|
+
variant="outline"
|
|
375
|
+
onClick={(event) => {
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
onClear();
|
|
378
|
+
}}
|
|
379
|
+
aria-label="Clear search"
|
|
380
|
+
disabled={!trimmedValue || pending}
|
|
381
|
+
>
|
|
382
|
+
Clear
|
|
383
|
+
</Button>
|
|
384
|
+
</div>
|
|
385
|
+
</form>
|
|
386
|
+
<div className="crud-search-meta">
|
|
387
|
+
<p
|
|
388
|
+
className="dash-card-subtitle m-0"
|
|
389
|
+
role="status"
|
|
390
|
+
aria-live="polite"
|
|
391
|
+
aria-busy={pending}
|
|
392
|
+
>
|
|
393
|
+
{pending ? 'Loading...' : summary(totalItems, pluralNoun, Boolean(trimmedValue))}
|
|
394
|
+
</p>
|
|
395
|
+
{trimmedValue && (
|
|
396
|
+
<div
|
|
397
|
+
className="crud-filter-chip"
|
|
398
|
+
aria-label={\`Active filter: \${trimmedValue}\`}
|
|
399
|
+
>
|
|
400
|
+
<span>Search: {trimmedValue}</span>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
className="btn btn-sm btn-link p-0"
|
|
404
|
+
onClick={onClear}
|
|
405
|
+
disabled={pending}
|
|
406
|
+
>
|
|
407
|
+
Clear all filters
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
`;
|