@kematjaya/crud-ui-generator 0.3.0 → 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,22 +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 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.
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
68
  - **CSV export ("Export all") only reflects the first filterable field's current value**, sent as
69
69
  a single OR-search `search` param the backend matches across *every* `searchable` field
70
70
  (textarea included) server-side — a separate, broader contract from the list's precise
71
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.
72
81
  - **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
73
- feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`) becomes a
74
- column.
82
+ feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`/`date`/
83
+ `datetime`) becomes a column.
75
84
  - **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
76
85
  camelCase-property shape used throughout this boilerplate.
77
86
  - **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
package/dist/cli.js CHANGED
@@ -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';
@@ -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,9 +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
- 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.`);
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.`);
105
111
  }
106
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.`);
107
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,16 +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
+ /** Field types with no filter-panel control yet — excluded from `filterableFields()`. */
17
+ const UNFILTERABLE_TYPES = ['textarea', 'date', 'datetime'];
16
18
  /**
17
19
  * 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`).
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`).
23
27
  */
24
28
  export function filterableFields(spec) {
25
- return spec.fields.filter((f) => f.searchable && f.type !== 'textarea');
29
+ return spec.fields.filter((f) => f.searchable && !UNFILTERABLE_TYPES.includes(f.type));
26
30
  }
27
31
  /** "createdAt" -> "Created At", "TestArticle" -> "Test Article". */
28
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');
@@ -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}
@@ -418,7 +418,7 @@ export function SearchPanel({
418
418
  }
419
419
  `;
420
420
  export const filterPanel = `import { useId } from 'react';
421
- import { Button } from '@kematjaya/bootstrap-ui-kit';
421
+ import { Button, Input, Select } from '@kematjaya/bootstrap-ui-kit';
422
422
 
423
423
  type FilterFieldDef =
424
424
  | { name: string; label: string; kind: 'text' }
@@ -482,9 +482,8 @@ export function FilterPanel({
482
482
  {field.label}
483
483
  </label>
484
484
  {field.kind === 'boolean' ? (
485
- <select
485
+ <Select
486
486
  id={\`\${idPrefix}-\${field.name}\`}
487
- className="form-select"
488
487
  name={field.name}
489
488
  defaultValue={values[field.name] ?? ''}
490
489
  disabled={controlsDisabled}
@@ -492,12 +491,11 @@ export function FilterPanel({
492
491
  <option value="">Any</option>
493
492
  <option value="true">Yes</option>
494
493
  <option value="false">No</option>
495
- </select>
494
+ </Select>
496
495
  ) : (
497
- <input
496
+ <Input
498
497
  id={\`\${idPrefix}-\${field.name}\`}
499
498
  type={field.kind === 'number' ? 'number' : 'text'}
500
- className="form-control"
501
499
  name={field.name}
502
500
  defaultValue={values[field.name] ?? ''}
503
501
  placeholder={field.label}
@@ -1,12 +1,21 @@
1
1
  import { displayFields, filterableFields, humanize, labelField, lowerWords } from '../naming.js';
2
- function columnCell(fieldName, timestampField) {
3
- if (fieldName === timestampField) {
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
  }
12
21
  function filterFieldKind(type) {
@@ -23,6 +32,7 @@ export function table(spec, names) {
23
32
  if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
24
33
  cols.push(spec.timestampField);
25
34
  }
35
+ const fieldTypeByName = new Map(spec.fields.map((f) => [f.name, f.type]));
26
36
  const label = labelField(spec);
27
37
  const noun = lowerWords(entityPascal);
28
38
  const pluralNoun = lowerWords(entitiesPascal);
@@ -31,7 +41,9 @@ export function table(spec, names) {
31
41
  const hasFilter = filters.length > 0;
32
42
  const filterPanelId = `${noun}-filter-panel`;
33
43
  const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
34
- const bodyCells = cols.map((c) => columnCell(c, spec.timestampField)).join('\n');
44
+ const bodyCells = cols
45
+ .map((c) => columnCell(c, fieldTypeByName.get(c) ?? null, spec.timestampField))
46
+ .join('\n');
35
47
  const filterButtonBlock = hasFilter
36
48
  ? ` <Button
37
49
  variant="outline"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kematjaya/crud-ui-generator",
3
- "version": "0.3.0",
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",