@kematjaya/crud-ui-generator 0.2.1 → 0.4.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,12 +56,31 @@ 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`, `date`, and
60
+ `datetime`.** `naming.ts`'s `filterableFields()` renders one control per remaining field
61
+ text/number inputs (ApiPlatform `SearchFilter` `'partial'`/`'exact'` strategy) and a boolean
62
+ Any/Yes/No select (`'exact'`) — all AND-combined into the same list query (one query param per
63
+ property, backed by a single `#[ApiFilter(SearchFilter::class, properties: [...])]` on the
64
+ entity). The table's collapsible Filter button toggles a `crud-filter-collapse` panel around
65
+ the generated `FilterPanel` component, and only appears when at least one field is filterable.
66
+ Excluded `searchable` fields get a `cli.ts` next-steps note — no date-range filter control
67
+ exists yet (a future iteration), and long text isn't a sensible filter input.
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.
72
+ - **`date`/`datetime` fields are real form fields and table/CSV columns.** Backed by
73
+ `ui-kit`'s `DateField`/`DateTimeField` (native `<input type="date">`/`type="datetime-local">`).
74
+ `date` fields round-trip as plain `"Y-m-d"` strings — the backend maker writes a
75
+ `config/serializer/{Entity}.yaml` Symfony serializer mapping pinning that format (otherwise
76
+ Symfony's default `DateTimeNormalizer` would emit a full RFC3339 datetime with a spurious
77
+ time/timezone for a date-only column). `datetime` fields keep the default RFC3339 wire format;
78
+ the generated form converts to/from `<input type="datetime-local">`'s local-wall-clock string
79
+ via `src/lib/datetime.ts` (written once, shared across entities) at the `reset()`/`submit()`
80
+ boundaries — the `Date` constructor's local-time parsing makes this conversion timezone-safe.
62
81
  - **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
63
- feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`) becomes a
64
- column.
82
+ feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`/`date`/
83
+ `datetime`) becomes a column.
65
84
  - **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
66
85
  camelCase-property shape used throughout this boilerplate.
67
86
  - **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
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';
@@ -15,6 +15,7 @@ import { apiShapesBlock, apiShapesFileHeader, apiShapesImport, apiShapesMarker }
15
15
  import { schemasBlock, schemasFileHeader, schemasMarker } from './templates/schemas.js';
16
16
  import { typesApiBlock, typesApiFileHeader, typesApiMarker } from './templates/typesApi.js';
17
17
  import { exportRoute, itemRoute, listRoute } from './templates/bffRoutes.js';
18
+ import { dateLib } from './templates/dateLib.js';
18
19
  function parseArgs(argv) {
19
20
  const positional = [];
20
21
  let srcDir = 'src';
@@ -71,8 +72,8 @@ function main() {
71
72
  writeIfMissing(join(cruddir, 'BulkActionsBar.tsx'), shared.bulkActionsBar, log);
72
73
  writeIfMissing(join(cruddir, 'PaginationBar.tsx'), shared.paginationBar, log);
73
74
  writeIfMissing(join(cruddir, 'ExportAllButton.tsx'), shared.exportAllButton, log);
74
- if (null !== searchField(spec)) {
75
- writeIfMissing(join(cruddir, 'SearchPanel.tsx'), shared.searchPanel, log);
75
+ if (filterableFields(spec).length > 0) {
76
+ writeIfMissing(join(cruddir, 'FilterPanel.tsx'), shared.filterPanel, log);
76
77
  }
77
78
  const dashDir = join(src, 'app', 'dashboard', names.entitiesKebab);
78
79
  writeNewFile(join(dashDir, 'page.tsx'), listPage(names), log);
@@ -86,6 +87,9 @@ function main() {
86
87
  const libDir = join(src, 'lib');
87
88
  writeNewFile(join(libDir, `${names.entitiesKebab}-query.ts`), queryLib(spec, names), log);
88
89
  writeNewFile(join(libDir, `${names.entitiesKebab}-csv.ts`), csvLib(spec, names), log);
90
+ if (spec.fields.some((f) => f.type === 'datetime')) {
91
+ writeIfMissing(join(libDir, 'datetime.ts'), dateLib, log);
92
+ }
89
93
  appendBlockWithImport(join(libDir, 'api-shapes.ts'), apiShapesFileHeader, apiShapesImport(names), apiShapesMarker(names.entityPascal), apiShapesBlock(spec, names), log);
90
94
  appendBlock(join(libDir, 'schemas.ts'), schemasFileHeader, schemasMarker(names.entityCamel), schemasBlock(spec, names), log);
91
95
  appendBlock(join(src, 'types', 'api.ts'), typesApiFileHeader, typesApiMarker(names.entityPascal), typesApiBlock(spec, names), log);
@@ -99,8 +103,11 @@ function main() {
99
103
  console.log(` 1. Run the backend maker's printed next-steps (ApiResource/ApiFilter attributes, permission keys, rate limiter config).`);
100
104
  console.log(` 2. Run "npm run api:types" in the frontend project so src/types/api.ts's paths/components lookups resolve.`);
101
105
  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.`);
106
+ const excludedFilterFields = spec.fields
107
+ .filter((f) => f.searchable && ['textarea', 'date', 'datetime'].includes(f.type))
108
+ .map((f) => f.name);
109
+ if (excludedFilterFields.length > 0) {
110
+ console.log(` 4. Note: "${excludedFilterFields.join(', ')}" ${excludedFilterFields.length > 1 ? 'are' : 'is'} marked searchable but excluded from the generated Filter panel (long text and date/datetime don't have a filter control yet) — long-text fields are also excluded from table/CSV columns.`);
104
111
  }
105
112
  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
113
  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,20 @@ 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
+ /** Field types with no filter-panel control yet excluded from `filterableFields()`. */
17
+ const UNFILTERABLE_TYPES = ['textarea', 'date', 'datetime'];
18
+ /**
19
+ * Fields rendered as controls in the list view's Filter panel: every `searchable` field except
20
+ * `textarea` (long text doesn't make a sensible filter input) and `date`/`datetime` (no
21
+ * date-range filter control exists yet — a future iteration), mirroring `displayFields()`'s
22
+ * `textarea` exclusion plus this narrower one. All of these are wired into the list query and
23
+ * AND-combined. The CSV export endpoint's OR-search (against every `searchable` field, textarea
24
+ * included) is a separate, server-side-only concept — it reuses this list's first filterable
25
+ * field's current value as its convenience search text (see `useExport.ts` / `bffRoutes.ts`'s
26
+ * `exportRoute`).
27
+ */
28
+ export function filterableFields(spec) {
29
+ return spec.fields.filter((f) => f.searchable && !UNFILTERABLE_TYPES.includes(f.type));
23
30
  }
24
31
  /** "createdAt" -> "Created At", "TestArticle" -> "Test Article". */
25
32
  export function humanize(identifier) {
package/dist/spec.js CHANGED
@@ -4,7 +4,7 @@ function isFieldSpec(value) {
4
4
  return false;
5
5
  const f = value;
6
6
  return (typeof f.name === 'string' &&
7
- ['text', 'textarea', 'number', 'boolean'].includes(f.type) &&
7
+ ['text', 'textarea', 'number', 'boolean', 'date', 'datetime'].includes(f.type) &&
8
8
  typeof f.required === 'boolean' &&
9
9
  (f.maxLength === null || typeof f.maxLength === 'number') &&
10
10
  typeof f.searchable === 'boolean');
@@ -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
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Written once (via `writeIfMissing`, shared across every generated entity that has a
3
+ * `datetime` field) into `src/lib/datetime.ts` — converts between the API's RFC3339 wire value
4
+ * (`2026-09-03T14:30:00+07:00`) and `<input type="datetime-local">`'s native value
5
+ * (`2026-09-03T14:30`, local wall-clock, no offset). Both directions go through the `Date`
6
+ * constructor, which parses a string with no offset suffix as LOCAL time per spec — that makes
7
+ * `fromDatetimeLocalValue`'s `.toISOString()` call a correct, timezone-safe local -> UTC
8
+ * conversion, not a naive string transform.
9
+ */
10
+ export const dateLib = `function pad(n: number): string {
11
+ return String(n).padStart(2, '0');
12
+ }
13
+
14
+ export function toDatetimeLocalValue(iso: string): string {
15
+ const date = new Date(iso);
16
+ if (Number.isNaN(date.getTime())) return '';
17
+ return \`\${date.getFullYear()}-\${pad(date.getMonth() + 1)}-\${pad(date.getDate())}T\${pad(date.getHours())}:\${pad(date.getMinutes())}\`;
18
+ }
19
+
20
+ export function fromDatetimeLocalValue(local: string): string {
21
+ const date = new Date(local);
22
+ if (Number.isNaN(date.getTime())) return local;
23
+ return date.toISOString();
24
+ }
25
+ `;
@@ -13,17 +13,12 @@ function fieldMarkup(field, entitiesKebab, autoFocus) {
13
13
  />`;
14
14
  }
15
15
  if (field.type === 'boolean') {
16
- return ` <div className="form-check mb-3">
17
- <input
18
- id="${id}"
19
- type="checkbox"
20
- className="form-check-input"
21
- {...register('${field.name}')}
22
- />
23
- <label htmlFor="${id}" className="form-check-label">
24
- ${label}
25
- </label>
26
- </div>`;
16
+ return ` <CheckboxField
17
+ id="${id}"
18
+ label="${label}"
19
+ error={errors.${field.name}}
20
+ registration={register('${field.name}')}
21
+ />`;
27
22
  }
28
23
  if (field.type === 'number') {
29
24
  return ` <TextField
@@ -34,6 +29,22 @@ function fieldMarkup(field, entitiesKebab, autoFocus) {
34
29
  registration={register('${field.name}', { valueAsNumber: true })}
35
30
  />`;
36
31
  }
32
+ if (field.type === 'date') {
33
+ return ` <DateField
34
+ id="${id}"
35
+ label="${label}"${autoFocusProp}
36
+ error={errors.${field.name}}
37
+ registration={register('${field.name}')}
38
+ />`;
39
+ }
40
+ if (field.type === 'datetime') {
41
+ return ` <DateTimeField
42
+ id="${id}"
43
+ label="${label}"${autoFocusProp}
44
+ error={errors.${field.name}}
45
+ registration={register('${field.name}')}
46
+ />`;
47
+ }
37
48
  return ` <TextField
38
49
  id="${id}"
39
50
  label="${label}"
@@ -47,13 +58,28 @@ export function form(spec, names) {
47
58
  const { entityPascal, entityCamel, entitiesKebab } = names;
48
59
  const usesTextarea = spec.fields.some((f) => f.type === 'textarea');
49
60
  const usesText = spec.fields.some((f) => f.type === 'text' || f.type === 'number');
50
- const fieldComponents = [usesText ? 'TextField' : null, usesTextarea ? 'TextareaField' : null]
61
+ const usesBoolean = spec.fields.some((f) => f.type === 'boolean');
62
+ const usesDate = spec.fields.some((f) => f.type === 'date');
63
+ const usesDateTime = spec.fields.some((f) => f.type === 'datetime');
64
+ const fieldComponents = [
65
+ usesText ? 'TextField' : null,
66
+ usesTextarea ? 'TextareaField' : null,
67
+ usesBoolean ? 'CheckboxField' : null,
68
+ usesDate ? 'DateField' : null,
69
+ usesDateTime ? 'DateTimeField' : null
70
+ ]
51
71
  .filter((c) => c !== null)
52
72
  .join(', ');
53
73
  const fieldsMarkup = spec.fields
54
74
  .map((f, i) => fieldMarkup(f, entitiesKebab, i === 0))
55
75
  .join('\n');
56
- const resetFields = spec.fields.map((f) => `${f.name}: data.${f.name}`).join(', ');
76
+ const resetFields = spec.fields
77
+ .map((f) => (f.type === 'datetime' ? `${f.name}: toDatetimeLocalValue(data.${f.name})` : `${f.name}: data.${f.name}`))
78
+ .join(', ');
79
+ const submitFields = spec.fields
80
+ .map((f) => (f.type === 'datetime' ? `${f.name}: fromDatetimeLocalValue(values.${f.name})` : null))
81
+ .filter((c) => c !== null)
82
+ .join(', ');
57
83
  return `'use client';
58
84
 
59
85
  import { zodResolver } from '@hookform/resolvers/zod';
@@ -62,7 +88,7 @@ import { useRouter } from 'next/navigation';
62
88
  import { useEffect, useState } from 'react';
63
89
  import { useForm } from 'react-hook-form';
64
90
  import { is${entityPascal} } from '@/lib/api-shapes';
65
- import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';
91
+ import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';${usesDateTime ? "\nimport { toDatetimeLocalValue, fromDatetimeLocalValue } from '@/lib/datetime';" : ''}
66
92
 
67
93
  type Props = {
68
94
  mode: 'create' | 'edit';
@@ -126,7 +152,7 @@ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
126
152
  response = await fetch(path, {
127
153
  method: mode === 'edit' ? 'PATCH' : 'POST',
128
154
  headers: { 'content-type': 'application/json' },
129
- body: JSON.stringify(values)
155
+ body: JSON.stringify(${usesDateTime ? `{ ...values, ${submitFields} }` : 'values'})
130
156
  });
131
157
  } catch {
132
158
  setError('Network error while saving. Check your connection and try again.');
@@ -148,7 +174,7 @@ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
148
174
  <ListPageCard
149
175
  title={mode === 'edit' ? 'Edit' : 'New'}
150
176
  error={error}
151
- style={{ maxWidth: 640 }}
177
+ style={{ width: '100%' }}
152
178
  >
153
179
  <form onSubmit={handleSubmit(submit)} noValidate>
154
180
  ${fieldsMarkup}
@@ -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,141 @@ export function SearchPanel({
413
417
  );
414
418
  }
415
419
  `;
420
+ export const filterPanel = `import { useId } from 'react';
421
+ import { Button, Input, Select } 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
+ name={field.name}
488
+ defaultValue={values[field.name] ?? ''}
489
+ disabled={controlsDisabled}
490
+ >
491
+ <option value="">Any</option>
492
+ <option value="true">Yes</option>
493
+ <option value="false">No</option>
494
+ </Select>
495
+ ) : (
496
+ <Input
497
+ id={\`\${idPrefix}-\${field.name}\`}
498
+ type={field.kind === 'number' ? 'number' : 'text'}
499
+ name={field.name}
500
+ defaultValue={values[field.name] ?? ''}
501
+ placeholder={field.label}
502
+ autoComplete="off"
503
+ disabled={controlsDisabled}
504
+ />
505
+ )}
506
+ </div>
507
+ ))}
508
+ <div className="crud-toolbar-actions">
509
+ <Button type="submit" disabled={controlsDisabled}>
510
+ Apply filters
511
+ </Button>
512
+ <Button
513
+ variant="outline"
514
+ onClick={(event) => {
515
+ event.preventDefault();
516
+ onClear();
517
+ }}
518
+ aria-label="Clear all filters"
519
+ disabled={!isFiltered || pending}
520
+ >
521
+ Clear
522
+ </Button>
523
+ </div>
524
+ </form>
525
+ <div className="crud-search-meta">
526
+ <p
527
+ className="dash-card-subtitle m-0"
528
+ role="status"
529
+ aria-live="polite"
530
+ aria-busy={pending}
531
+ >
532
+ {pending ? 'Loading...' : summary(totalItems, pluralNoun, isFiltered)}
533
+ </p>
534
+ {isFiltered && (
535
+ <div
536
+ className="crud-filter-chip"
537
+ aria-label="Active filters"
538
+ >
539
+ <span>
540
+ {activeFields.length} filter
541
+ {activeFields.length === 1 ? '' : 's'} active
542
+ </span>
543
+ <button
544
+ type="button"
545
+ className="btn btn-sm btn-link p-0"
546
+ onClick={onClear}
547
+ disabled={pending}
548
+ >
549
+ Clear all filters
550
+ </button>
551
+ </div>
552
+ )}
553
+ </div>
554
+ </div>
555
+ );
556
+ }
557
+ `;
@@ -1,14 +1,30 @@
1
- import { displayFields, humanize, labelField, lowerWords, searchField } from '../naming.js';
2
- function columnCell(fieldName, timestampField) {
3
- if (fieldName === timestampField) {
1
+ import { displayFields, filterableFields, humanize, labelField, lowerWords } from '../naming.js';
2
+ function columnCell(fieldName, fieldType, timestampField) {
3
+ if (fieldName === timestampField || fieldType === 'datetime') {
4
4
  return ` <td>
5
5
  {new Date(
6
6
  item.${fieldName}
7
7
  ).toLocaleString()}
8
8
  </td>`;
9
9
  }
10
+ if (fieldType === 'date') {
11
+ // "YYYY-MM-DD" alone parses as UTC midnight per spec (wrong day in timezones behind
12
+ // UTC); appending a time forces local-time parsing instead.
13
+ return ` <td>
14
+ {new Date(
15
+ \`\${item.${fieldName}}T00:00:00\`
16
+ ).toLocaleDateString()}
17
+ </td>`;
18
+ }
10
19
  return ` <td>{String(item.${fieldName})}</td>`;
11
20
  }
21
+ function filterFieldKind(type) {
22
+ if (type === 'number')
23
+ return 'number';
24
+ if (type === 'boolean')
25
+ return 'boolean';
26
+ return 'text';
27
+ }
12
28
  export function table(spec, names) {
13
29
  const { entityPascal, entitiesPascal, entitiesCamel, entitiesKebab } = names;
14
30
  const prefix = entitiesKebab;
@@ -16,42 +32,71 @@ export function table(spec, names) {
16
32
  if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
17
33
  cols.push(spec.timestampField);
18
34
  }
35
+ const fieldTypeByName = new Map(spec.fields.map((f) => [f.name, f.type]));
19
36
  const label = labelField(spec);
20
37
  const noun = lowerWords(entityPascal);
21
38
  const pluralNoun = lowerWords(entitiesPascal);
22
- const field = searchField(spec);
39
+ const filters = filterableFields(spec);
23
40
  const constPrefix = entitiesCamel.toUpperCase();
41
+ const hasFilter = filters.length > 0;
42
+ const filterPanelId = `${noun}-filter-panel`;
24
43
  const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
25
- 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
- />
44
+ const bodyCells = cols
45
+ .map((c) => columnCell(c, fieldTypeByName.get(c) ?? null, spec.timestampField))
46
+ .join('\n');
47
+ const filterButtonBlock = hasFilter
48
+ ? ` <Button
49
+ variant="outline"
50
+ icon={filterOpen ? 'bi-chevron-up' : 'bi-funnel'}
51
+ aria-expanded={filterOpen}
52
+ aria-controls="${filterPanelId}"
53
+ onClick={() => setFilterOpen((open) => !open)}
54
+ >
55
+ Filter
56
+ </Button>
57
+ `
58
+ : '';
59
+ const filterFieldsLiteral = filters
60
+ .map((f) => `{ name: '${f.name}', label: '${humanize(f.name)}', kind: '${filterFieldKind(f.type)}' }`)
61
+ .join(',\n ');
62
+ const filterValuesLiteral = filters.map((f) => `${f.name}: query.${f.name}`).join(', ');
63
+ const clearedValuesLiteral = filters.map((f) => `${f.name}: ''`).join(', ');
64
+ const filterKeyExpr = filters.map((f) => '${query.' + f.name + '}').join('|');
65
+ const filterPanelBlock = hasFilter
66
+ ? ` <div
67
+ id="${filterPanelId}"
68
+ className={\`crud-filter-collapse collapse\${filterOpen ? ' show' : ''}\`}
69
+ >
70
+ <FilterPanel
71
+ key={\`${filterKeyExpr}\`}
72
+ fields={[
73
+ ${filterFieldsLiteral}
74
+ ]}
75
+ values={{ ${filterValuesLiteral} }}
76
+ totalItems={totalItems}
77
+ pluralNoun="${pluralNoun}"
78
+ pending={loading}
79
+ disabled={trueEmpty}
80
+ onApply={(values) =>
81
+ navigateTo({ ...query, page: 1, ...values })
82
+ }
83
+ onClear={() =>
84
+ navigateTo({ ...query, page: 1, ${clearedValuesLiteral} })
85
+ }
86
+ />
87
+ </div>
42
88
 
43
89
  `
44
90
  : '';
45
91
  return `'use client';
46
92
 
47
93
  import { Button, EmptyState, LinkButton, ListPageCard, Toast } from '@kematjaya/bootstrap-ui-kit';
48
- import { useEffect, useRef } from 'react';
94
+ import { useEffect, useRef${hasFilter ? ', useState' : ''} } from 'react';
49
95
  import { usePermissions } from '@kematjaya/access-control-ui';
50
96
  import { BulkActionsBar } from '@/components/crud/BulkActionsBar';
51
97
  import { DeleteConfirmModal } from '@/components/crud/DeleteConfirmModal';
52
98
  import { ExportAllButton } from '@/components/crud/ExportAllButton';
53
- import { PaginationBar } from '@/components/crud/PaginationBar';
54
- import { SearchPanel } from '@/components/crud/SearchPanel';
99
+ ${hasFilter ? "import { FilterPanel } from '@/components/crud/FilterPanel';\n" : ''}import { PaginationBar } from '@/components/crud/PaginationBar';
55
100
  import {
56
101
  ${constPrefix}_PAGE_SIZES,
57
102
  build${entitiesPascal}Href,
@@ -66,7 +111,7 @@ export function ${entityPascal}Table() {
66
111
  totalItems,
67
112
  loading,
68
113
  error,
69
- hasSearch,
114
+ hasFilters,
70
115
  trueEmpty,
71
116
  filteredEmpty,
72
117
  hasItems,
@@ -94,7 +139,7 @@ export function ${entityPascal}Table() {
94
139
  } = use${entitiesPascal}();
95
140
  const { has: hasPermission, loading: permissionsLoading } = usePermissions();
96
141
  const selectAllRef = useRef<HTMLInputElement>(null);
97
-
142
+ ${hasFilter ? ' const [filterOpen, setFilterOpen] = useState(false);\n' : ''}
98
143
  useEffect(() => {
99
144
  if (selectAllRef.current) {
100
145
  selectAllRef.current.indeterminate =
@@ -108,10 +153,10 @@ export function ${entityPascal}Table() {
108
153
  error={error}
109
154
  action={
110
155
  <div className="crud-card-actions">
111
- {(permissionsLoading || hasPermission('${prefix}.export_all')) && (
156
+ ${filterButtonBlock} {(permissionsLoading || hasPermission('${prefix}.export_all')) && (
112
157
  <ExportAllButton
113
158
  totalItems={totalItems}
114
- filtered={hasSearch}
159
+ filtered={hasFilters}
115
160
  exporting={exportingAll}
116
161
  disabled={loading || exportingAll || 0 === totalItems}
117
162
  onExport={() => void handleExportAll()}
@@ -125,7 +170,7 @@ export function ${entityPascal}Table() {
125
170
  </div>
126
171
  }
127
172
  >
128
- ${searchPanelBlock} {trueEmpty && (
173
+ ${filterPanelBlock} {trueEmpty && (
129
174
  <EmptyState
130
175
  title="No ${pluralNoun} yet"
131
176
  description="Create your first ${noun} to get started."
@@ -141,16 +186,16 @@ ${searchPanelBlock} {trueEmpty && (
141
186
 
142
187
  {filteredEmpty && (
143
188
  <EmptyState
144
- title="No ${pluralNoun} match your search"
145
- description="Try a different search or clear it to see all ${pluralNoun}."
189
+ title="No ${pluralNoun} match your filters"
190
+ description="Try different filters or clear them to see all ${pluralNoun}."
146
191
  action={
147
192
  <Button
148
193
  variant="outline"
149
194
  onClick={() =>
150
- navigateTo({ ...query, page: 1${field ? ", search: ''" : ''} })
195
+ navigateTo({ ...query, page: 1${hasFilter ? `, ${clearedValuesLiteral}` : ''} })
151
196
  }
152
197
  >
153
- Clear search
198
+ Clear filters
154
199
  </Button>
155
200
  }
156
201
  />
@@ -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.1",
3
+ "version": "0.4.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",