@kematjaya/crud-ui-generator 0.2.0 → 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 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 search box only searches one field.** If more than one field is marked
60
- `searchable` in the spec, the list view (backed by ApiPlatform's `SearchFilter`, one query
61
- param per property) only wires up the first one. The export endpoint ORs across all of them.
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 { namesFromSpec, searchField, searchableFields } from './naming.js';
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 (null !== searchField(spec)) {
75
- writeIfMissing(join(cruddir, 'SearchPanel.tsx'), shared.searchPanel, log);
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
- if (searchableFields(spec).length > 1) {
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
+ 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
- /** The single field used for the search box / list-view SearchFilter query param, if any. */
17
- export function searchField(spec) {
18
- return spec.fields.find((f) => f.searchable)?.name ?? null;
19
- }
20
- /** All searchable field names, used by the export endpoint's OR-search. */
21
- export function searchableFields(spec) {
22
- return spec.fields.filter((f) => f.searchable).map((f) => f.name);
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, searchField } from '../naming.js';
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
- const field = searchField(spec);
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
 
@@ -1,11 +1,11 @@
1
- import { lowerWords, searchField } from '../naming.js';
1
+ import { filterableFields, lowerWords } from '../naming.js';
2
2
  export function entityHook(spec, names) {
3
3
  const { entityPascal, entitiesPascal, entitiesKebab } = names;
4
- const field = searchField(spec);
4
+ const filters = filterableFields(spec);
5
5
  const pluralNoun = lowerWords(entitiesPascal);
6
- const hasSearchLine = field
7
- ? ` const hasSearch = Boolean(query.search);\n`
8
- : ` const hasSearch = false;\n`;
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
- ${hasSearchLine} const trueEmpty = !loading && !error && !hasSearch && totalItems === 0;
122
- const filteredEmpty = !loading && !error && hasSearch && totalItems === 0;
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
- hasSearch,
240
+ hasFilters,
241
241
  trueEmpty,
242
242
  filteredEmpty,
243
243
  hasItems,
@@ -1,13 +1,20 @@
1
- import { searchField } from '../naming.js';
1
+ import { filterableFields } from '../naming.js';
2
2
  export function queryLib(spec, names) {
3
3
  const { entitiesKebab, entitiesPascal, entitiesCamel } = names;
4
- const field = searchField(spec);
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
- ${field !== null ? ' search: string;\n' : ''} page: number;
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
- ${field !== null ? ` search: searchParams.get('${field}')?.trim() ?? '',\n` : ''} page: parsePositiveInteger(searchParams.get('page')) ?? 1,
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
- ${field !== null ? ` if (state.search) params.set('${field}', state.search);\n` : ''} if (state.page !== 1) params.set('page', String(state.page));
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
+ `;
@@ -1,4 +1,4 @@
1
- import { displayFields, humanize, labelField, lowerWords, searchField } from '../naming.js';
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 field = searchField(spec);
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 searchPanelBlock = field
27
- ? ` <SearchPanel
28
- key={query.search}
29
- label="Search by ${humanize(field)}"
30
- value={query.search}
31
- totalItems={totalItems}
32
- pluralNoun="${pluralNoun}"
33
- pending={loading}
34
- disabled={trueEmpty}
35
- onSearch={(search) =>
36
- navigateTo({ ...query, page: 1, search })
37
- }
38
- onClear={() =>
39
- navigateTo({ ...query, page: 1, search: '' })
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
- hasSearch,
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={hasSearch}
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
- ${searchPanelBlock} {trueEmpty && (
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 search"
145
- description="Try a different search or clear it to see all ${pluralNoun}."
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${field ? ", search: ''" : ''} })
183
+ navigateTo({ ...query, page: 1${hasFilter ? `, ${clearedValuesLiteral}` : ''} })
151
184
  }
152
185
  >
153
- Clear search
186
+ Clear filters
154
187
  </Button>
155
188
  }
156
189
  />
@@ -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 these same JsonLd/Json
5
- * helpers and the `paths`/`components` import from the OpenAPI-generated `./api.generated` —
6
- * this header is only a fallback for a from-scratch project that hasn't set that up yet.
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 JsonLd<T> = T extends { 'application/ld+json': infer V } ? V : never;
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<JsonLd<${entityPascal}PostResponses[201]['content']>>;
35
+ type Generated${entityPascal} = NonNullable<Json<${entityPascal}PostResponses[201]['content']>>;
32
36
  type Generated${entitiesPascal}Collection = NonNullable<
33
- JsonLd<${entitiesPascal}CollectionResponses[200]['content']>
37
+ Json<${entitiesPascal}CollectionResponses[200]['content']>
34
38
  >;
35
39
 
36
40
  export type ${entityPascal}Input = components['schemas']['${entityPascal}.${entityPascal}Input'];
@@ -1,7 +1,9 @@
1
- import { searchField } from '../naming.js';
1
+ import { filterableFields } from '../naming.js';
2
2
  export function useExportHook(spec, names) {
3
3
  const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
4
- const field = searchField(spec);
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 ? " if ('' !== query.search) params.set('search', query.search);\n" : ''} const path = params.size
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.2.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",