@kematjaya/crud-ui-generator 0.2.1 → 0.3.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 +13 -3
- package/dist/cli.js +6 -5
- package/dist/naming.js +10 -7
- package/dist/templates/bffRoutes.js +5 -3
- package/dist/templates/hook.js +8 -8
- package/dist/templates/queryLib.js +12 -5
- package/dist/templates/sharedComponents.js +144 -0
- package/dist/templates/table.js +63 -30
- package/dist/templates/useExport.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,9 +56,19 @@ Appended to (multi-entity, idempotent — each entity gets one marker-guarded bl
|
|
|
56
56
|
## Assumptions / caveats
|
|
57
57
|
|
|
58
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`.
|
|
59
|
-
- **The list
|
|
60
|
-
`
|
|
61
|
-
|
|
59
|
+
- **The list filter panel covers every `searchable` field except `textarea`.**
|
|
60
|
+
`naming.ts`'s `filterableFields()` renders one control per such field — text/number inputs
|
|
61
|
+
(ApiPlatform `SearchFilter` `'partial'`/`'exact'` strategy) and a boolean Any/Yes/No select
|
|
62
|
+
(`'exact'`) — all AND-combined into the same list query (one query param per property, backed
|
|
63
|
+
by a single `#[ApiFilter(SearchFilter::class, properties: [...])]` on the entity). The table's
|
|
64
|
+
collapsible Filter button toggles a `crud-filter-collapse` panel around the generated
|
|
65
|
+
`FilterPanel` component, and only appears when at least one field is filterable.
|
|
66
|
+
`searchable` `textarea` fields are excluded (same exclusion as table/CSV columns) — `cli.ts`
|
|
67
|
+
prints a next-steps note when that applies.
|
|
68
|
+
- **CSV export ("Export all") only reflects the first filterable field's current value**, sent as
|
|
69
|
+
a single OR-search `search` param the backend matches across *every* `searchable` field
|
|
70
|
+
(textarea included) server-side — a separate, broader contract from the list's precise
|
|
71
|
+
AND-per-property filtering. The other filter panel fields don't currently narrow the export.
|
|
62
72
|
- **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
|
|
63
73
|
feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`) becomes a
|
|
64
74
|
column.
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { loadSpec } from './spec.js';
|
|
4
|
-
import {
|
|
4
|
+
import { filterableFields, namesFromSpec } from './naming.js';
|
|
5
5
|
import { appendBlock, appendBlockWithImport, newLog, writeIfMissing, writeNewFile } from './write.js';
|
|
6
6
|
import * as shared from './templates/sharedComponents.js';
|
|
7
7
|
import { editPage, listPage, newPage } from './templates/pages.js';
|
|
@@ -71,8 +71,8 @@ function main() {
|
|
|
71
71
|
writeIfMissing(join(cruddir, 'BulkActionsBar.tsx'), shared.bulkActionsBar, log);
|
|
72
72
|
writeIfMissing(join(cruddir, 'PaginationBar.tsx'), shared.paginationBar, log);
|
|
73
73
|
writeIfMissing(join(cruddir, 'ExportAllButton.tsx'), shared.exportAllButton, log);
|
|
74
|
-
if (
|
|
75
|
-
writeIfMissing(join(cruddir, '
|
|
74
|
+
if (filterableFields(spec).length > 0) {
|
|
75
|
+
writeIfMissing(join(cruddir, 'FilterPanel.tsx'), shared.filterPanel, log);
|
|
76
76
|
}
|
|
77
77
|
const dashDir = join(src, 'app', 'dashboard', names.entitiesKebab);
|
|
78
78
|
writeNewFile(join(dashDir, 'page.tsx'), listPage(names), log);
|
|
@@ -99,8 +99,9 @@ function main() {
|
|
|
99
99
|
console.log(` 1. Run the backend maker's printed next-steps (ApiResource/ApiFilter attributes, permission keys, rate limiter config).`);
|
|
100
100
|
console.log(` 2. Run "npm run api:types" in the frontend project so src/types/api.ts's paths/components lookups resolve.`);
|
|
101
101
|
console.log(` 3. Confirm the OpenAPI collection path is "/api/${names.entitiesKebab}" — if the entity's #[ApiResource] uses a custom uriTemplate, fix the "paths[...]" lookups in the appended src/types/api.ts block by hand.`);
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
const excludedTextareaFields = spec.fields.filter((f) => f.searchable && f.type === 'textarea').map((f) => f.name);
|
|
103
|
+
if (excludedTextareaFields.length > 0) {
|
|
104
|
+
console.log(` 4. Note: "${excludedTextareaFields.join(', ')}" ${excludedTextareaFields.length > 1 ? 'are' : 'is'} marked searchable but excluded from the generated Filter panel (long text isn't a sensible filter input) — same exclusion as table/CSV columns.`);
|
|
104
105
|
}
|
|
105
106
|
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.`);
|
|
106
107
|
console.log(` Run "npm run format" afterwards — generated files aren't pre-formatted to this project's Prettier config.`);
|
package/dist/naming.js
CHANGED
|
@@ -13,13 +13,16 @@ export function namesFromSpec(spec) {
|
|
|
13
13
|
const entitiesCamel = entitiesPascal.charAt(0).toLowerCase() + entitiesPascal.slice(1);
|
|
14
14
|
return { entityPascal, entityCamel, entitiesKebab, entitiesPascal, entitiesCamel };
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Fields rendered as controls in the list view's Filter panel: every `searchable` field except
|
|
18
|
+
* `textarea` (long text doesn't make a sensible filter input), mirroring `displayFields()`'s
|
|
19
|
+
* exclusion. All of these are wired into the list query and AND-combined. The CSV export
|
|
20
|
+
* endpoint's OR-search (against every `searchable` field, textarea included) is a separate,
|
|
21
|
+
* server-side-only concept — it reuses this list's first filterable field's current value as
|
|
22
|
+
* its convenience search text (see `useExport.ts` / `bffRoutes.ts`'s `exportRoute`).
|
|
23
|
+
*/
|
|
24
|
+
export function filterableFields(spec) {
|
|
25
|
+
return spec.fields.filter((f) => f.searchable && f.type !== 'textarea');
|
|
23
26
|
}
|
|
24
27
|
/** "createdAt" -> "Created At", "TestArticle" -> "Test Article". */
|
|
25
28
|
export function humanize(identifier) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { displayFields,
|
|
1
|
+
import { displayFields, filterableFields } from '../naming.js';
|
|
2
2
|
function tsType(field) {
|
|
3
3
|
if (field.type === 'number')
|
|
4
4
|
return 'number';
|
|
@@ -90,7 +90,9 @@ export const revalidate = 0;
|
|
|
90
90
|
}
|
|
91
91
|
export function exportRoute(spec, names) {
|
|
92
92
|
const { entityPascal, entitiesPascal, entitiesKebab } = names;
|
|
93
|
-
|
|
93
|
+
// Reuses the filter panel's first field as the export endpoint's OR-search convenience
|
|
94
|
+
// text — it must be a property that actually exists on `{Entities}Query`.
|
|
95
|
+
const field = filterableFields(spec)[0]?.name ?? null;
|
|
94
96
|
const cols = displayFields(spec).map((f) => f.name);
|
|
95
97
|
if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
|
|
96
98
|
cols.push(spec.timestampField);
|
|
@@ -152,7 +154,7 @@ ${field ? ` const params = new URLSearchParams();
|
|
|
152
154
|
|
|
153
155
|
export async function GET(request: NextRequest): Promise<Response> {
|
|
154
156
|
try {
|
|
155
|
-
${field ? ` const { search } = parse${entitiesPascal}Query(request.nextUrl.searchParams);
|
|
157
|
+
${field ? ` const { ${field}: search } = parse${entitiesPascal}Query(request.nextUrl.searchParams);
|
|
156
158
|
const response = await authedBackend(buildExportPath(search));` : ` const response = await authedBackend(buildExportPath());`}
|
|
157
159
|
if (!response.ok) return response;
|
|
158
160
|
|
package/dist/templates/hook.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { filterableFields, lowerWords } from '../naming.js';
|
|
2
2
|
export function entityHook(spec, names) {
|
|
3
3
|
const { entityPascal, entitiesPascal, entitiesKebab } = names;
|
|
4
|
-
const
|
|
4
|
+
const filters = filterableFields(spec);
|
|
5
5
|
const pluralNoun = lowerWords(entitiesPascal);
|
|
6
|
-
const
|
|
7
|
-
? ` const
|
|
8
|
-
: ` const
|
|
6
|
+
const hasFiltersLine = filters.length > 0
|
|
7
|
+
? ` const hasFilters = [${filters.map((f) => `query.${f.name}`).join(', ')}].some(Boolean);\n`
|
|
8
|
+
: ` const hasFilters = false;\n`;
|
|
9
9
|
return `import { useRouter, useSearchParams } from 'next/navigation';
|
|
10
10
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
11
|
import { is${entityPascal}Collection } from '@/lib/api-shapes';
|
|
@@ -118,8 +118,8 @@ export function use${entitiesPascal}() {
|
|
|
118
118
|
router.push(build${entitiesPascal}Href(nextQuery));
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
${
|
|
122
|
-
const filteredEmpty = !loading && !error &&
|
|
121
|
+
${hasFiltersLine} const trueEmpty = !loading && !error && !hasFilters && totalItems === 0;
|
|
122
|
+
const filteredEmpty = !loading && !error && hasFilters && totalItems === 0;
|
|
123
123
|
const hasItems = !loading && items.length > 0;
|
|
124
124
|
const selectedItems = useMemo(
|
|
125
125
|
() => items.filter((item) => selectedIds.has(item.id)),
|
|
@@ -237,7 +237,7 @@ ${hasSearchLine} const trueEmpty = !loading && !error && !hasSearch && totalI
|
|
|
237
237
|
totalItems,
|
|
238
238
|
loading,
|
|
239
239
|
error,
|
|
240
|
-
|
|
240
|
+
hasFilters,
|
|
241
241
|
trueEmpty,
|
|
242
242
|
filteredEmpty,
|
|
243
243
|
hasItems,
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { filterableFields } from '../naming.js';
|
|
2
2
|
export function queryLib(spec, names) {
|
|
3
3
|
const { entitiesKebab, entitiesPascal, entitiesCamel } = names;
|
|
4
|
-
const
|
|
4
|
+
const fields = filterableFields(spec);
|
|
5
5
|
const constPrefix = entitiesCamel.toUpperCase();
|
|
6
|
+
const typeFields = fields.map((f) => ` ${f.name}: string;`).join('\n');
|
|
7
|
+
const parseFields = fields
|
|
8
|
+
.map((f) => ` ${f.name}: searchParams.get('${f.name}')?.trim() ?? '',`)
|
|
9
|
+
.join('\n');
|
|
10
|
+
const paramFields = fields
|
|
11
|
+
.map((f) => ` if (state.${f.name}) params.set('${f.name}', state.${f.name});`)
|
|
12
|
+
.join('\n');
|
|
6
13
|
return `export const ${constPrefix}_PAGE_SIZES = [10, 20, 30, 50] as const;
|
|
7
14
|
export const ${constPrefix}_DEFAULT_PAGE_SIZE = 30;
|
|
8
15
|
|
|
9
16
|
export type ${entitiesPascal}Query = {
|
|
10
|
-
${
|
|
17
|
+
${typeFields ? typeFields + '\n' : ''} page: number;
|
|
11
18
|
itemsPerPage: number;
|
|
12
19
|
};
|
|
13
20
|
|
|
@@ -25,7 +32,7 @@ function isAllowedPageSize(
|
|
|
25
32
|
|
|
26
33
|
export function parse${entitiesPascal}Query(searchParams: URLSearchParams): ${entitiesPascal}Query {
|
|
27
34
|
const state: ${entitiesPascal}Query = {
|
|
28
|
-
${
|
|
35
|
+
${parseFields ? parseFields + '\n' : ''} page: parsePositiveInteger(searchParams.get('page')) ?? 1,
|
|
29
36
|
itemsPerPage: ${constPrefix}_DEFAULT_PAGE_SIZE
|
|
30
37
|
};
|
|
31
38
|
const pageSize = parsePositiveInteger(searchParams.get('itemsPerPage'));
|
|
@@ -36,7 +43,7 @@ ${field !== null ? ` search: searchParams.get('${field}')?.trim() ?? '',\
|
|
|
36
43
|
|
|
37
44
|
function buildParams(state: ${entitiesPascal}Query): URLSearchParams {
|
|
38
45
|
const params = new URLSearchParams();
|
|
39
|
-
${
|
|
46
|
+
${paramFields ? paramFields + '\n' : ''} if (state.page !== 1) params.set('page', String(state.page));
|
|
40
47
|
if (state.itemsPerPage !== ${constPrefix}_DEFAULT_PAGE_SIZE) {
|
|
41
48
|
params.set('itemsPerPage', String(state.itemsPerPage));
|
|
42
49
|
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* ExportAllButton — all under src/components/notes/). Written once per frontend project into
|
|
5
5
|
* src/components/crud/ (skipped on repeat runs / for later entities) so N generated entities
|
|
6
6
|
* don't each carry a byte-identical copy.
|
|
7
|
+
*
|
|
8
|
+
* `filterPanel` is the generalization of `searchPanel` to N AND-combined fields (text/number/
|
|
9
|
+
* boolean) — `cli.ts` now writes `filterPanel` for any entity with a filterable field.
|
|
10
|
+
* `searchPanel` is kept as a standalone primitive (no longer auto-written) rather than deleted.
|
|
7
11
|
*/
|
|
8
12
|
export const deleteConfirmModal = `'use client';
|
|
9
13
|
|
|
@@ -413,3 +417,143 @@ export function SearchPanel({
|
|
|
413
417
|
);
|
|
414
418
|
}
|
|
415
419
|
`;
|
|
420
|
+
export const filterPanel = `import { useId } from 'react';
|
|
421
|
+
import { Button } from '@kematjaya/bootstrap-ui-kit';
|
|
422
|
+
|
|
423
|
+
type FilterFieldDef =
|
|
424
|
+
| { name: string; label: string; kind: 'text' }
|
|
425
|
+
| { name: string; label: string; kind: 'number' }
|
|
426
|
+
| { name: string; label: string; kind: 'boolean' };
|
|
427
|
+
|
|
428
|
+
type Props = {
|
|
429
|
+
fields: FilterFieldDef[];
|
|
430
|
+
values: Record<string, string>;
|
|
431
|
+
totalItems: number;
|
|
432
|
+
pluralNoun: string;
|
|
433
|
+
pending: boolean;
|
|
434
|
+
disabled: boolean;
|
|
435
|
+
onApply: (values: Record<string, string>) => void;
|
|
436
|
+
onClear: () => void;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
function summary(totalItems: number, pluralNoun: string, filtered: boolean) {
|
|
440
|
+
return filtered
|
|
441
|
+
? \`\${totalItems} \${pluralNoun} match your filters\`
|
|
442
|
+
: \`\${totalItems} \${pluralNoun} total\`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function FilterPanel({
|
|
446
|
+
fields,
|
|
447
|
+
values,
|
|
448
|
+
totalItems,
|
|
449
|
+
pluralNoun,
|
|
450
|
+
pending,
|
|
451
|
+
disabled,
|
|
452
|
+
onApply,
|
|
453
|
+
onClear
|
|
454
|
+
}: Props) {
|
|
455
|
+
const idPrefix = useId();
|
|
456
|
+
const activeFields = fields.filter((field) => values[field.name]);
|
|
457
|
+
const isFiltered = activeFields.length > 0;
|
|
458
|
+
const controlsDisabled = pending || disabled;
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<div className="crud-search-panel">
|
|
462
|
+
<form
|
|
463
|
+
className="crud-toolbar"
|
|
464
|
+
method="get"
|
|
465
|
+
onSubmit={(event) => {
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
const formData = new FormData(event.currentTarget);
|
|
468
|
+
const next: Record<string, string> = {};
|
|
469
|
+
for (const field of fields) {
|
|
470
|
+
next[field.name] =
|
|
471
|
+
formData.get(field.name)?.toString().trim() ?? '';
|
|
472
|
+
}
|
|
473
|
+
onApply(next);
|
|
474
|
+
}}
|
|
475
|
+
>
|
|
476
|
+
{fields.map((field) => (
|
|
477
|
+
<div className="crud-search-field" key={field.name}>
|
|
478
|
+
<label
|
|
479
|
+
htmlFor={\`\${idPrefix}-\${field.name}\`}
|
|
480
|
+
className="form-label mb-1"
|
|
481
|
+
>
|
|
482
|
+
{field.label}
|
|
483
|
+
</label>
|
|
484
|
+
{field.kind === 'boolean' ? (
|
|
485
|
+
<select
|
|
486
|
+
id={\`\${idPrefix}-\${field.name}\`}
|
|
487
|
+
className="form-select"
|
|
488
|
+
name={field.name}
|
|
489
|
+
defaultValue={values[field.name] ?? ''}
|
|
490
|
+
disabled={controlsDisabled}
|
|
491
|
+
>
|
|
492
|
+
<option value="">Any</option>
|
|
493
|
+
<option value="true">Yes</option>
|
|
494
|
+
<option value="false">No</option>
|
|
495
|
+
</select>
|
|
496
|
+
) : (
|
|
497
|
+
<input
|
|
498
|
+
id={\`\${idPrefix}-\${field.name}\`}
|
|
499
|
+
type={field.kind === 'number' ? 'number' : 'text'}
|
|
500
|
+
className="form-control"
|
|
501
|
+
name={field.name}
|
|
502
|
+
defaultValue={values[field.name] ?? ''}
|
|
503
|
+
placeholder={field.label}
|
|
504
|
+
autoComplete="off"
|
|
505
|
+
disabled={controlsDisabled}
|
|
506
|
+
/>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
))}
|
|
510
|
+
<div className="crud-toolbar-actions">
|
|
511
|
+
<Button type="submit" disabled={controlsDisabled}>
|
|
512
|
+
Apply filters
|
|
513
|
+
</Button>
|
|
514
|
+
<Button
|
|
515
|
+
variant="outline"
|
|
516
|
+
onClick={(event) => {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
onClear();
|
|
519
|
+
}}
|
|
520
|
+
aria-label="Clear all filters"
|
|
521
|
+
disabled={!isFiltered || pending}
|
|
522
|
+
>
|
|
523
|
+
Clear
|
|
524
|
+
</Button>
|
|
525
|
+
</div>
|
|
526
|
+
</form>
|
|
527
|
+
<div className="crud-search-meta">
|
|
528
|
+
<p
|
|
529
|
+
className="dash-card-subtitle m-0"
|
|
530
|
+
role="status"
|
|
531
|
+
aria-live="polite"
|
|
532
|
+
aria-busy={pending}
|
|
533
|
+
>
|
|
534
|
+
{pending ? 'Loading...' : summary(totalItems, pluralNoun, isFiltered)}
|
|
535
|
+
</p>
|
|
536
|
+
{isFiltered && (
|
|
537
|
+
<div
|
|
538
|
+
className="crud-filter-chip"
|
|
539
|
+
aria-label="Active filters"
|
|
540
|
+
>
|
|
541
|
+
<span>
|
|
542
|
+
{activeFields.length} filter
|
|
543
|
+
{activeFields.length === 1 ? '' : 's'} active
|
|
544
|
+
</span>
|
|
545
|
+
<button
|
|
546
|
+
type="button"
|
|
547
|
+
className="btn btn-sm btn-link p-0"
|
|
548
|
+
onClick={onClear}
|
|
549
|
+
disabled={pending}
|
|
550
|
+
>
|
|
551
|
+
Clear all filters
|
|
552
|
+
</button>
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
`;
|
package/dist/templates/table.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { displayFields, humanize, labelField, lowerWords
|
|
1
|
+
import { displayFields, filterableFields, humanize, labelField, lowerWords } from '../naming.js';
|
|
2
2
|
function columnCell(fieldName, timestampField) {
|
|
3
3
|
if (fieldName === timestampField) {
|
|
4
4
|
return ` <td>
|
|
@@ -9,6 +9,13 @@ function columnCell(fieldName, timestampField) {
|
|
|
9
9
|
}
|
|
10
10
|
return ` <td>{String(item.${fieldName})}</td>`;
|
|
11
11
|
}
|
|
12
|
+
function filterFieldKind(type) {
|
|
13
|
+
if (type === 'number')
|
|
14
|
+
return 'number';
|
|
15
|
+
if (type === 'boolean')
|
|
16
|
+
return 'boolean';
|
|
17
|
+
return 'text';
|
|
18
|
+
}
|
|
12
19
|
export function table(spec, names) {
|
|
13
20
|
const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
|
|
14
21
|
const prefix = entitiesKebab;
|
|
@@ -19,39 +26,65 @@ export function table(spec, names) {
|
|
|
19
26
|
const label = labelField(spec);
|
|
20
27
|
const noun = lowerWords(entityPascal);
|
|
21
28
|
const pluralNoun = lowerWords(entitiesPascal);
|
|
22
|
-
const
|
|
29
|
+
const filters = filterableFields(spec);
|
|
23
30
|
const constPrefix = entitiesCamel.toUpperCase();
|
|
31
|
+
const hasFilter = filters.length > 0;
|
|
32
|
+
const filterPanelId = `${noun}-filter-panel`;
|
|
24
33
|
const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
|
|
25
34
|
const bodyCells = cols.map((c) => columnCell(c, spec.timestampField)).join('\n');
|
|
26
|
-
const
|
|
27
|
-
? `
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
const filterButtonBlock = hasFilter
|
|
36
|
+
? ` <Button
|
|
37
|
+
variant="outline"
|
|
38
|
+
icon={filterOpen ? 'bi-chevron-up' : 'bi-funnel'}
|
|
39
|
+
aria-expanded={filterOpen}
|
|
40
|
+
aria-controls="${filterPanelId}"
|
|
41
|
+
onClick={() => setFilterOpen((open) => !open)}
|
|
42
|
+
>
|
|
43
|
+
Filter
|
|
44
|
+
</Button>
|
|
45
|
+
`
|
|
46
|
+
: '';
|
|
47
|
+
const filterFieldsLiteral = filters
|
|
48
|
+
.map((f) => `{ name: '${f.name}', label: '${humanize(f.name)}', kind: '${filterFieldKind(f.type)}' }`)
|
|
49
|
+
.join(',\n ');
|
|
50
|
+
const filterValuesLiteral = filters.map((f) => `${f.name}: query.${f.name}`).join(', ');
|
|
51
|
+
const clearedValuesLiteral = filters.map((f) => `${f.name}: ''`).join(', ');
|
|
52
|
+
const filterKeyExpr = filters.map((f) => '${query.' + f.name + '}').join('|');
|
|
53
|
+
const filterPanelBlock = hasFilter
|
|
54
|
+
? ` <div
|
|
55
|
+
id="${filterPanelId}"
|
|
56
|
+
className={\`crud-filter-collapse collapse\${filterOpen ? ' show' : ''}\`}
|
|
57
|
+
>
|
|
58
|
+
<FilterPanel
|
|
59
|
+
key={\`${filterKeyExpr}\`}
|
|
60
|
+
fields={[
|
|
61
|
+
${filterFieldsLiteral}
|
|
62
|
+
]}
|
|
63
|
+
values={{ ${filterValuesLiteral} }}
|
|
64
|
+
totalItems={totalItems}
|
|
65
|
+
pluralNoun="${pluralNoun}"
|
|
66
|
+
pending={loading}
|
|
67
|
+
disabled={trueEmpty}
|
|
68
|
+
onApply={(values) =>
|
|
69
|
+
navigateTo({ ...query, page: 1, ...values })
|
|
70
|
+
}
|
|
71
|
+
onClear={() =>
|
|
72
|
+
navigateTo({ ...query, page: 1, ${clearedValuesLiteral} })
|
|
73
|
+
}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
42
76
|
|
|
43
77
|
`
|
|
44
78
|
: '';
|
|
45
79
|
return `'use client';
|
|
46
80
|
|
|
47
81
|
import { Button, EmptyState, LinkButton, ListPageCard, Toast } from '@kematjaya/bootstrap-ui-kit';
|
|
48
|
-
import { useEffect, useRef } from 'react';
|
|
82
|
+
import { useEffect, useRef${hasFilter ? ', useState' : ''} } from 'react';
|
|
49
83
|
import { usePermissions } from '@kematjaya/access-control-ui';
|
|
50
84
|
import { BulkActionsBar } from '@/components/crud/BulkActionsBar';
|
|
51
85
|
import { DeleteConfirmModal } from '@/components/crud/DeleteConfirmModal';
|
|
52
86
|
import { ExportAllButton } from '@/components/crud/ExportAllButton';
|
|
53
|
-
import { PaginationBar } from '@/components/crud/PaginationBar';
|
|
54
|
-
import { SearchPanel } from '@/components/crud/SearchPanel';
|
|
87
|
+
${hasFilter ? "import { FilterPanel } from '@/components/crud/FilterPanel';\n" : ''}import { PaginationBar } from '@/components/crud/PaginationBar';
|
|
55
88
|
import {
|
|
56
89
|
${constPrefix}_PAGE_SIZES,
|
|
57
90
|
build${entitiesPascal}Href,
|
|
@@ -66,7 +99,7 @@ export function ${entityPascal}Table() {
|
|
|
66
99
|
totalItems,
|
|
67
100
|
loading,
|
|
68
101
|
error,
|
|
69
|
-
|
|
102
|
+
hasFilters,
|
|
70
103
|
trueEmpty,
|
|
71
104
|
filteredEmpty,
|
|
72
105
|
hasItems,
|
|
@@ -94,7 +127,7 @@ export function ${entityPascal}Table() {
|
|
|
94
127
|
} = use${entitiesPascal}();
|
|
95
128
|
const { has: hasPermission, loading: permissionsLoading } = usePermissions();
|
|
96
129
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
97
|
-
|
|
130
|
+
${hasFilter ? ' const [filterOpen, setFilterOpen] = useState(false);\n' : ''}
|
|
98
131
|
useEffect(() => {
|
|
99
132
|
if (selectAllRef.current) {
|
|
100
133
|
selectAllRef.current.indeterminate =
|
|
@@ -108,10 +141,10 @@ export function ${entityPascal}Table() {
|
|
|
108
141
|
error={error}
|
|
109
142
|
action={
|
|
110
143
|
<div className="crud-card-actions">
|
|
111
|
-
{(permissionsLoading || hasPermission('${prefix}.export_all')) && (
|
|
144
|
+
${filterButtonBlock} {(permissionsLoading || hasPermission('${prefix}.export_all')) && (
|
|
112
145
|
<ExportAllButton
|
|
113
146
|
totalItems={totalItems}
|
|
114
|
-
filtered={
|
|
147
|
+
filtered={hasFilters}
|
|
115
148
|
exporting={exportingAll}
|
|
116
149
|
disabled={loading || exportingAll || 0 === totalItems}
|
|
117
150
|
onExport={() => void handleExportAll()}
|
|
@@ -125,7 +158,7 @@ export function ${entityPascal}Table() {
|
|
|
125
158
|
</div>
|
|
126
159
|
}
|
|
127
160
|
>
|
|
128
|
-
${
|
|
161
|
+
${filterPanelBlock} {trueEmpty && (
|
|
129
162
|
<EmptyState
|
|
130
163
|
title="No ${pluralNoun} yet"
|
|
131
164
|
description="Create your first ${noun} to get started."
|
|
@@ -141,16 +174,16 @@ ${searchPanelBlock} {trueEmpty && (
|
|
|
141
174
|
|
|
142
175
|
{filteredEmpty && (
|
|
143
176
|
<EmptyState
|
|
144
|
-
title="No ${pluralNoun} match your
|
|
145
|
-
description="Try
|
|
177
|
+
title="No ${pluralNoun} match your filters"
|
|
178
|
+
description="Try different filters or clear them to see all ${pluralNoun}."
|
|
146
179
|
action={
|
|
147
180
|
<Button
|
|
148
181
|
variant="outline"
|
|
149
182
|
onClick={() =>
|
|
150
|
-
navigateTo({ ...query, page: 1${
|
|
183
|
+
navigateTo({ ...query, page: 1${hasFilter ? `, ${clearedValuesLiteral}` : ''} })
|
|
151
184
|
}
|
|
152
185
|
>
|
|
153
|
-
Clear
|
|
186
|
+
Clear filters
|
|
154
187
|
</Button>
|
|
155
188
|
}
|
|
156
189
|
/>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { filterableFields } from '../naming.js';
|
|
2
2
|
export function useExportHook(spec, names) {
|
|
3
3
|
const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
|
|
4
|
-
|
|
4
|
+
// Reuses the filter panel's first field as the export endpoint's OR-search convenience
|
|
5
|
+
// text — it must be a property that actually exists on `{Entities}Query`.
|
|
6
|
+
const field = filterableFields(spec)[0]?.name ?? null;
|
|
5
7
|
return `import { useState } from 'react';
|
|
6
8
|
import { build${entitiesPascal}Csv } from '@/lib/${entitiesKebab}-csv';
|
|
7
9
|
import type { ${entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
|
|
@@ -92,7 +94,7 @@ export function use${entitiesPascal}Export() {
|
|
|
92
94
|
async function exportAll(query: ${entitiesPascal}Query): Promise<ExportResult> {
|
|
93
95
|
setExportingAll(true);
|
|
94
96
|
const params = new URLSearchParams();
|
|
95
|
-
${field !== null ?
|
|
97
|
+
${field !== null ? ` if ('' !== query.${field}) params.set('search', query.${field});\n` : ''} const path = params.size
|
|
96
98
|
? \`/api/${entitiesKebab}/export?\${params.toString()}\`
|
|
97
99
|
: '/api/${entitiesKebab}/export';
|
|
98
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kematjaya/crud-ui-generator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|