@kematjaya/crud-ui-generator 0.3.0 → 0.5.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
@@ -1,85 +1,134 @@
1
1
  # @kematjaya/crud-ui-generator
2
2
 
3
- Next.js CRUD frontend generator that reads the `crud-specs/{Entity}.json` sidecar written by
4
- [kematjaya/crud-maker-bundle](https://github.com/kematjaya0/crud-maker-bundle)'s
5
- `make:kmj-api-crud`, and generates pages, components, and BFF routes matching the shape of a
6
- hand-written CRUD feature (list/create/edit pages, table with search/pagination/bulk-delete/CSV
7
- export, form, BFF proxy routes).
3
+ Generator frontend CRUD untuk Next.js yang membaca sidecar `crud-specs/{Entity}.json` yang
4
+ ditulis oleh `make:kmj-api-crud` dari
5
+ [kematjaya/crud-maker-bundle](https://github.com/kematjaya0/crud-maker-bundle), lalu
6
+ menghasilkan halaman, komponen, dan BFF routes dengan bentuk yang sama seperti fitur CRUD yang
7
+ ditulis manual (halaman list/create/edit, tabel dengan search/pagination/bulk-delete/export CSV,
8
+ form, BFF proxy routes).
8
9
 
9
- This is a dev-time code generator (like Plop/Hygen), not a runtime component library. It targets
10
- projects that already follow this monorepo's Next.js conventions:
10
+ Ini adalah code generator yang dijalankan saat development (seperti Plop/Hygen), bukan library
11
+ komponen runtime. Generator ini menyasar project yang sudah mengikuti konvensi Next.js di
12
+ monorepo ini:
11
13
 
12
- - `@kematjaya/bootstrap-ui-kit` for `ListPageCard`/`TextField`/`Button`/etc.
13
- - `@kematjaya/access-control-ui` for `usePermissions()`
14
- - `src/lib/http.ts`, `src/lib/bff.ts` (BFF proxy helpers — `authedBackend`, `validateOrigin`, `parseJson`, `jsonProblem`)
15
- - `src/lib/permissions.ts` exporting `requirePermission()`
16
- - `src/types/api.ts` + `src/types/api.generated.ts` (OpenAPI types via `openapi-typescript`)
14
+ - `@kematjaya/bootstrap-ui-kit` untuk `ListPageCard`/`TextField`/`Button`/dll.
15
+ - `@kematjaya/access-control-ui` untuk `usePermissions()`
16
+ - `src/lib/http.ts`, `src/lib/bff.ts` (helper proxy BFF — `authedBackend`, `validateOrigin`, `parseJson`, `jsonProblem`)
17
+ - `src/lib/permissions.ts` yang meng-export `requirePermission()`
18
+ - `src/types/api.ts` + `src/types/api.generated.ts` (tipe dari OpenAPI via `openapi-typescript`)
17
19
 
18
- It is not a general-purpose Next.js scaffolderrunning it against a project that doesn't already
19
- have those pieces will produce files that don't compile until you add them.
20
+ Ini bukan scaffolder Next.js serba-gunamenjalankannya pada project yang belum punya
21
+ komponen-komponen di atas akan menghasilkan file yang tidak bisa di-compile sampai Anda
22
+ menambahkannya.
20
23
 
21
- ## Usage
24
+ ## Instalasi
25
+
26
+ Tidak perlu instalasi terpisah untuk pemakaian sekali pakai — `npx` akan mengambilnya otomatis:
27
+
28
+ ```
29
+ npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
30
+ ```
31
+
32
+ Kalau ingin dipasang sebagai dev dependency permanen alih-alih `npx` setiap kali:
33
+
34
+ ```
35
+ npm install --save-dev @kematjaya/crud-ui-generator
36
+ ```
37
+
38
+ ## Cara pakai
22
39
 
23
40
  ```
24
41
  npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
25
42
  ```
26
43
 
27
- - `<spec-path>` — path to the `crud-specs/{Entity}.json` file written by `make:kmj-api-crud`.
28
- - `--src <dir>` — the frontend project's `src/` directory to generate into (default: `src`).
44
+ - `<spec-path>` — path ke file `crud-specs/{Entity}.json` yang ditulis oleh `make:kmj-api-crud`.
45
+ - `--src <dir>` — direktori `src/` project frontend tempat hasil generate ditulis (default: `src`).
29
46
 
30
- Example, run from the frontend project root, with the backend as a sibling directory:
47
+ Contoh, dijalankan dari root project frontend, dengan backend sebagai direktori bertetangga:
31
48
 
32
49
  ```
33
50
  npx @kematjaya/crud-ui-generator ../backend/crud-specs/Note.json --src src
34
51
  ```
35
52
 
36
- ## What it generates
53
+ ## Apa saja yang di-generate
37
54
 
38
- Per entity (skipped if the file already existssafe to re-run):
55
+ Per entity (dilewati kalau file sudah adaaman dijalankan ulang):
39
56
 
40
57
  - `app/dashboard/{entities}/page.tsx`, `new/page.tsx`, `[id]/edit/page.tsx`
41
58
  - `components/{entities}/{Entity}Table.tsx`, `{Entity}Form.tsx`, `use{Entities}Export.ts`
42
59
  - `lib/{entities}-query.ts`, `lib/{entities}-csv.ts`
43
60
  - `app/api/{entities}/route.ts`, `[id]/route.ts`, `export/route.ts` (BFF proxy)
44
61
 
45
- Shared, entity-agnostic UI primitives (written once, reused by every entity):
62
+ Primitive UI yang dipakai bersama, tidak spesifik ke satu entity (ditulis sekali, dipakai ulang
63
+ oleh semua entity):
46
64
 
47
65
  - `components/crud/DeleteConfirmModal.tsx`, `BulkActionsBar.tsx`, `PaginationBar.tsx`,
48
66
  `SearchPanel.tsx`, `ExportAllButton.tsx`
49
67
 
50
- Appended to (multi-entity, idempotent — each entity gets one marker-guarded block):
51
-
52
- - `lib/api-shapes.ts` — `is{Entity}`/`is{Entity}Collection` type guards
53
- - `lib/schemas.ts` — a Zod schema per entity
54
- - `types/api.ts` — types derived from the OpenAPI-generated `paths`/`components`
55
-
56
- ## Assumptions / caveats
57
-
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.
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
- - **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.
75
- - **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
76
- camelCase-property shape used throughout this boilerplate.
77
- - **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
78
- `#[ApiFilter]` attributes — see `make:kmj-api-crud`'s printed next-steps) so
79
- `src/types/api.ts`'s `paths['/api/{entities}']` / `components['schemas'][...]` lookups resolve.
80
- If the entity's `#[ApiResource]` uses a custom `uriTemplate` that doesn't match
81
- `permissionPrefix`, fix those lookups by hand.
82
- - Generated files aren't run through Prettier run `npm run format` afterwards.
68
+ Ditambahkan ke (multi-entity, idempotent — tiap entity dapat satu blok yang dijaga marker):
69
+
70
+ - `lib/api-shapes.ts` — type guard `is{Entity}`/`is{Entity}Collection`
71
+ - `lib/schemas.ts` — satu Zod schema per entity
72
+ - `types/api.ts` — tipe hasil turunan dari `paths`/`components` OpenAPI
73
+
74
+ ## Asumsi / catatan penting
75
+
76
+ - **Tipe id diambil dari `idType` di spec** (`uuid`/`int`/`string`, ditulis oleh
77
+ `ApiCrudRenderer::detectIdType()` berdasarkan kolom id sebenarnya di entity) `validId()` di
78
+ `[id]/route.ts` hasil generate dan type guard `is{Entity}` di `api-shapes.ts` dibuat
79
+ menyesuaikan. Spec lama tanpa `idType` default ke `uuid`.
80
+ - **Panel filter di halaman list mencakup semua field `searchable` kecuali `textarea`, `date`,
81
+ dan `datetime`.** `filterableFields()` di `naming.ts` merender satu kontrol per field yang
82
+ tersisa input text/number (strategi ApiPlatform `SearchFilter` `'partial'`/`'exact'`) dan
83
+ select boolean Any/Yes/No (`'exact'`) semuanya digabung dengan AND dalam satu query list
84
+ (satu query param per properti, didukung satu
85
+ `#[ApiFilter(SearchFilter::class, properties: [...])]` di entity). Tombol Filter yang bisa
86
+ dilipat di tabel membuka panel `crud-filter-collapse` berisi komponen `FilterPanel` hasil
87
+ generate, dan hanya muncul kalau minimal satu field bisa difilter. Field `searchable` yang
88
+ dikecualikan akan dapat catatan next-steps di `cli.ts` belum ada kontrol filter rentang
89
+ tanggal (iterasi berikutnya), dan teks panjang memang bukan input filter yang masuk akal.
90
+ - **Export CSV ("Export all") hanya mencerminkan nilai field filterable pertama saat ini**,
91
+ dikirim sebagai satu parameter `search` (OR-search) yang di backend dicocokkan ke *semua*
92
+ field `searchable` (termasuk textarea) — kontrak yang berbeda dan lebih longgar dari filter
93
+ AND-per-properti yang presisi di halaman list. Field filter lain belum mempersempit hasil
94
+ export.
95
+ - **Field `date`/`datetime` adalah field form dan kolom tabel/CSV sungguhan.** Didukung
96
+ `DateField`/`DateTimeField` dari `ui-kit` (native `<input type="date">`/
97
+ `type="datetime-local">`). Field `date` bolak-balik sebagai string `"Y-m-d"` polos — maker di
98
+ sisi backend menulis mapping serializer Symfony `config/serializer/{Entity}.yaml` yang
99
+ mengunci format itu (kalau tidak, `DateTimeNormalizer` default Symfony akan menghasilkan
100
+ datetime RFC3339 lengkap dengan jam/timezone yang tidak seharusnya ada untuk kolom
101
+ date-only). Field `datetime` tetap pakai format wire RFC3339 default; form hasil generate
102
+ mengonversi ke/dari string local-wall-clock `<input type="datetime-local">` lewat
103
+ `src/lib/datetime.ts` (ditulis sekali, dipakai bersama semua entity) di boundary
104
+ `reset()`/`submit()` — parsing local-time dari constructor `Date` membuat konversi ini aman
105
+ terhadap timezone.
106
+ - **Tabel/export CSV melewati field `textarea`** (teks panjang), meniru fitur Notes yang ditulis
107
+ manual (menampilkan `title`, bukan `body`). Selain itu (`text`/`number`/`boolean`/`date`/
108
+ `datetime`) menjadi kolom.
109
+ - **Getter diasumsikan ada** pada entity/tipe hasil generate, dalam bentuk konvensional
110
+ `get{Field}()` / properti camelCase yang dipakai di seluruh boilerplate ini.
111
+ - **`npm run api:types` harus dijalankan lebih dulu** (setelah atribut `#[ApiResource]`/
112
+ `#[ApiFilter]` backend ditambahkan — lihat next-steps yang dicetak `make:kmj-api-crud`) supaya
113
+ lookup `paths['/api/{entities}']` / `components['schemas'][...]` di `src/types/api.ts`
114
+ berhasil. Lookup ini berdasarkan `apiResourcePath` (catatan literal URI ApiPlatform
115
+ sebenarnya milik entity, dari spec), **bukan** `permissionPrefix` — kalau `#[ApiResource]`
116
+ entity memakai `uriTemplate` custom yang tidak cocok dengan `apiResourcePath`, atau project
117
+ meng-override `api_platform.path_segment_name_generator` dari default-nya, perbaiki lookup itu
118
+ secara manual.
119
+ - **Panggilan BFF ke backend (`app/api/{entities}/route.ts`, `[id]/route.ts`,
120
+ `build{Entities}BackendPath` di `lib/{entities}-query.ts`) selalu memakai `apiResourcePath`
121
+ dari spec, tidak pernah `permissionPrefix`.** `permissionPrefix` hanya menamai route/folder
122
+ frontend sendiri dan permission key — nilainya bebas (free text) dan bisa berbeda dari URI
123
+ plural asli ApiPlatform (misalnya entity `Category` yang di-generate dengan
124
+ `permissionPrefix: "category"` tetap saja disajikan backend di `/api/categories`).
125
+ `apiResourcePath` ditulis oleh `make:kmj-api-crud` sebagai `pluralize(tableize($shortName))`,
126
+ meniru persis `path_segment_name_generator` default ApiPlatform — frontend wajib mengikuti
127
+ nilai ini apa adanya, karena backend digenerate lebih dulu dan menjadi satu-satunya sumber
128
+ kebenaran untuk URL miliknya sendiri. Spec lama dari sebelum field ini ada akan gagal dimuat
129
+ dengan pesan error yang jelas; tambahkan `"apiResourcePath"` secara manual (atau generate
130
+ ulang).
131
+ - File hasil generate tidak melewati Prettier — jalankan `npm run format` setelahnya.
83
132
 
84
133
  ## Development
85
134
 
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');
@@ -31,6 +31,12 @@ export function loadSpec(specPath) {
31
31
  if (typeof spec.entity !== 'string' || spec.entity === '') {
32
32
  throw new Error(`Spec file missing "entity": ${specPath}`);
33
33
  }
34
+ if (typeof spec.apiResourcePath !== 'string' || spec.apiResourcePath === '') {
35
+ throw new Error(`Spec file missing "apiResourcePath": ${specPath}\n` +
36
+ 'Regenerate it with a current version of make:kmj-api-crud, or add the field by ' +
37
+ "hand — it must match the entity's real ApiPlatform collection/item URI (e.g. " +
38
+ '"/api/categories"), not a guess derived from "permissionPrefix" or "entity".');
39
+ }
34
40
  if (typeof spec.permissionPrefix !== 'string' || spec.permissionPrefix === '') {
35
41
  throw new Error(`Spec file missing "permissionPrefix": ${specPath}`);
36
42
  }
@@ -40,6 +46,7 @@ export function loadSpec(specPath) {
40
46
  const idType = spec.idType === 'int' || spec.idType === 'string' || spec.idType === 'uuid' ? spec.idType : 'uuid';
41
47
  return {
42
48
  entity: spec.entity,
49
+ apiResourcePath: spec.apiResourcePath,
43
50
  permissionPrefix: spec.permissionPrefix,
44
51
  ownerProperty: typeof spec.ownerProperty === 'string' ? spec.ownerProperty : null,
45
52
  timestampField: typeof spec.timestampField === 'string' ? spec.timestampField : null,
@@ -6,17 +6,18 @@ function tsType(field) {
6
6
  return 'boolean';
7
7
  return 'string';
8
8
  }
9
- export function listRoute(_spec, names) {
9
+ export function listRoute(spec, names) {
10
10
  const { entityCamel, entitiesKebab } = names;
11
+ const backendPath = spec.apiResourcePath;
11
12
  return `import type { NextRequest } from 'next/server';
12
13
  import { authedBackend } from '@/lib/bff';
13
14
  import { parseJson, validateOrigin } from '@/lib/http';
14
- import { build${names.entitiesPascal}ApiPath, parse${names.entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
15
+ import { build${names.entitiesPascal}ApiPath, build${names.entitiesPascal}BackendPath, parse${names.entitiesPascal}Query } from '@/lib/${entitiesKebab}-query';
15
16
  import { ${entityCamel}Schema } from '@/lib/schemas';
16
17
 
17
18
  export async function GET(request: NextRequest) {
18
19
  return authedBackend(
19
- build${names.entitiesPascal}ApiPath(parse${names.entitiesPascal}Query(request.nextUrl.searchParams))
20
+ build${names.entitiesPascal}BackendPath(parse${names.entitiesPascal}Query(request.nextUrl.searchParams))
20
21
  );
21
22
  }
22
23
 
@@ -25,7 +26,7 @@ export async function POST(request: NextRequest) {
25
26
  if (badOrigin) return badOrigin;
26
27
  const parsed = await parseJson(request, ${entityCamel}Schema);
27
28
  if ('error' in parsed) return parsed.error;
28
- return authedBackend('/api/${entitiesKebab}', {
29
+ return authedBackend('${backendPath}', {
29
30
  method: 'POST',
30
31
  body: JSON.stringify(parsed.data)
31
32
  });
@@ -43,7 +44,8 @@ function validIdCheck(idType) {
43
44
  return "return /^[0-9a-fA-F-]{36}$/.test(id);";
44
45
  }
45
46
  export function itemRoute(spec, names) {
46
- const { entityCamel, entitiesKebab } = names;
47
+ const { entityCamel } = names;
48
+ const backendPath = spec.apiResourcePath;
47
49
  return `import type { NextRequest } from 'next/server';
48
50
  import { authedBackend } from '@/lib/bff';
49
51
  import { jsonProblem, parseJson, validateOrigin } from '@/lib/http';
@@ -58,7 +60,7 @@ function validId(id: string) {
58
60
  export async function GET(_request: NextRequest, context: Params) {
59
61
  const { id } = await context.params;
60
62
  if (!validId(id)) return jsonProblem(404, { title: 'Not Found' });
61
- return authedBackend(\`/api/${entitiesKebab}/\${id}\`);
63
+ return authedBackend(\`${backendPath}/\${id}\`);
62
64
  }
63
65
 
64
66
  export async function PATCH(request: NextRequest, context: Params) {
@@ -70,7 +72,7 @@ export async function PATCH(request: NextRequest, context: Params) {
70
72
  if ('error' in parsed) return parsed.error;
71
73
  // Backend only exposes a Put operation (full replacement) — no Patch operation exists,
72
74
  // so the upstream call uses PUT even though the BFF's own contract to the browser stays PATCH.
73
- return authedBackend(\`/api/${entitiesKebab}/\${id}\`, {
75
+ return authedBackend(\`${backendPath}/\${id}\`, {
74
76
  method: 'PUT',
75
77
  body: JSON.stringify(parsed.data)
76
78
  });
@@ -81,7 +83,7 @@ export async function DELETE(request: NextRequest, context: Params) {
81
83
  if (badOrigin) return badOrigin;
82
84
  const { id } = await context.params;
83
85
  if (!validId(id)) return jsonProblem(404, { title: 'Not Found' });
84
- return authedBackend(\`/api/${entitiesKebab}/\${id}\`, { method: 'DELETE' });
86
+ return authedBackend(\`${backendPath}/\${id}\`, { method: 'DELETE' });
85
87
  }
86
88
 
87
89
  export const dynamic = 'force-dynamic';
@@ -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}
@@ -63,6 +63,15 @@ export function build${entitiesPascal}ApiPath(state: ${entitiesPascal}Query): st
63
63
  return query ? \`/api/${entitiesKebab}?\${query}\` : '/api/${entitiesKebab}';
64
64
  }
65
65
 
66
+ // The BFF's own route (above) is a free-to-choose frontend slug. The real backend resource
67
+ // lives at a URI ApiPlatform derives from the entity name (see crud-specs/${spec.entity}.json's
68
+ // "apiResourcePath") — used only by the BFF route handler's server-side call to the backend,
69
+ // never by the browser.
70
+ export function build${entitiesPascal}BackendPath(state: ${entitiesPascal}Query): string {
71
+ const query = buildParams(state).toString();
72
+ return query ? \`${spec.apiResourcePath}?\${query}\` : '${spec.apiResourcePath}';
73
+ }
74
+
66
75
  export function clampPageToTotal(
67
76
  page: number,
68
77
  itemsPerPage: number,
@@ -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.5.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",