@kematjaya/crud-ui-generator 0.4.0 → 0.6.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,94 +1,196 @@
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).
9
+
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:
13
+
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`)
19
+ - `react-select` — hanya kalau ada entity dengan field relasi (`"type": "relation"`); form hasil
20
+ generate untuk field itu meng-`import AsyncSelect from 'react-select/async'` langsung, jadi
21
+ `npm install react-select` di project frontend sebelum menjalankan generator untuk entity
22
+ semacam itu.
23
+
24
+ Ini bukan scaffolder Next.js serba-guna — menjalankannya pada project yang belum punya
25
+ komponen-komponen di atas akan menghasilkan file yang tidak bisa di-compile sampai Anda
26
+ menambahkannya.
27
+
28
+ ## Instalasi
29
+
30
+ Tidak perlu instalasi terpisah untuk pemakaian sekali pakai — `npx` akan mengambilnya otomatis:
8
31
 
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:
32
+ ```
33
+ npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
34
+ ```
11
35
 
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`)
36
+ Kalau ingin dipasang sebagai dev dependency permanen alih-alih `npx` setiap kali:
17
37
 
18
- It is not a general-purpose Next.js scaffolder — running it against a project that doesn't already
19
- have those pieces will produce files that don't compile until you add them.
38
+ ```
39
+ npm install --save-dev @kematjaya/crud-ui-generator
40
+ ```
20
41
 
21
- ## Usage
42
+ ## Cara pakai
22
43
 
23
44
  ```
24
45
  npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
25
46
  ```
26
47
 
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`).
48
+ - `<spec-path>` — path ke file `crud-specs/{Entity}.json` yang ditulis oleh `make:kmj-api-crud`.
49
+ - `--src <dir>` — direktori `src/` project frontend tempat hasil generate ditulis (default: `src`).
29
50
 
30
- Example, run from the frontend project root, with the backend as a sibling directory:
51
+ Contoh, dijalankan dari root project frontend, dengan backend sebagai direktori bertetangga:
31
52
 
32
53
  ```
33
54
  npx @kematjaya/crud-ui-generator ../backend/crud-specs/Note.json --src src
34
55
  ```
35
56
 
36
- ## What it generates
57
+ ## Apa saja yang di-generate
37
58
 
38
- Per entity (skipped if the file already existssafe to re-run):
59
+ Per entity (dilewati kalau file sudah adaaman dijalankan ulang):
39
60
 
40
61
  - `app/dashboard/{entities}/page.tsx`, `new/page.tsx`, `[id]/edit/page.tsx`
41
62
  - `components/{entities}/{Entity}Table.tsx`, `{Entity}Form.tsx`, `use{Entities}Export.ts`
42
63
  - `lib/{entities}-query.ts`, `lib/{entities}-csv.ts`
43
64
  - `app/api/{entities}/route.ts`, `[id]/route.ts`, `export/route.ts` (BFF proxy)
44
65
 
45
- Shared, entity-agnostic UI primitives (written once, reused by every entity):
66
+ Primitive UI yang dipakai bersama, tidak spesifik ke satu entity (ditulis sekali, dipakai ulang
67
+ oleh semua entity):
46
68
 
47
69
  - `components/crud/DeleteConfirmModal.tsx`, `BulkActionsBar.tsx`, `PaginationBar.tsx`,
48
70
  `SearchPanel.tsx`, `ExportAllButton.tsx`
49
71
 
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`, `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.
81
- - **The table/CSV export skip `textarea` fields** (long text), mirroring the hand-written Notes
82
- feature (shows `title`, not `body`). Everything else (`text`/`number`/`boolean`/`date`/
83
- `datetime`) becomes a column.
84
- - **Getters are assumed on the entity/generated types** in the conventional `get{Field}()` /
85
- camelCase-property shape used throughout this boilerplate.
86
- - **`npm run api:types` must be run first** (after adding the backend's `#[ApiResource]`/
87
- `#[ApiFilter]` attributes — see `make:kmj-api-crud`'s printed next-steps) so
88
- `src/types/api.ts`'s `paths['/api/{entities}']` / `components['schemas'][...]` lookups resolve.
89
- If the entity's `#[ApiResource]` uses a custom `uriTemplate` that doesn't match
90
- `permissionPrefix`, fix those lookups by hand.
91
- - Generated files aren't run through Prettier run `npm run format` afterwards.
72
+ Ditambahkan ke (multi-entity, idempotent — tiap entity dapat satu blok yang dijaga marker):
73
+
74
+ - `lib/api-shapes.ts` — type guard `is{Entity}`/`is{Entity}Collection`
75
+ - `lib/schemas.ts` — satu Zod schema per entity
76
+ - `types/api.ts` — tipe hasil turunan dari `paths`/`components` OpenAPI
77
+
78
+ ## Asumsi / catatan penting
79
+
80
+ - **Tipe id diambil dari `idType` di spec** (`uuid`/`int`/`string`, ditulis oleh
81
+ `ApiCrudRenderer::detectIdType()` berdasarkan kolom id sebenarnya di entity) `validId()` di
82
+ `[id]/route.ts` hasil generate dan type guard `is{Entity}` di `api-shapes.ts` dibuat
83
+ menyesuaikan. Spec lama tanpa `idType` default ke `uuid`.
84
+ - **Panel filter di halaman list mencakup semua field `searchable` kecuali `textarea`, `date`,
85
+ dan `datetime`.** `filterableFields()` di `naming.ts` merender satu kontrol per field yang
86
+ tersisa input text/number (strategi ApiPlatform `SearchFilter` `'partial'`/`'exact'`) dan
87
+ select boolean Any/Yes/No (`'exact'`) semuanya digabung dengan AND dalam satu query list
88
+ (satu query param per properti, didukung satu
89
+ `#[ApiFilter(SearchFilter::class, properties: [...])]` di entity). Tombol Filter yang bisa
90
+ dilipat di tabel membuka panel `crud-filter-collapse` berisi komponen `FilterPanel` hasil
91
+ generate, dan hanya muncul kalau minimal satu field bisa difilter. Field `searchable` yang
92
+ dikecualikan akan dapat catatan next-steps di `cli.ts` belum ada kontrol filter rentang
93
+ tanggal (iterasi berikutnya), dan teks panjang memang bukan input filter yang masuk akal.
94
+ - **Export CSV ("Export all") hanya mencerminkan nilai field filterable pertama saat ini**,
95
+ dikirim sebagai satu parameter `search` (OR-search) yang di backend dicocokkan ke *semua*
96
+ field `searchable` (termasuk textarea) kontrak yang berbeda dan lebih longgar dari filter
97
+ AND-per-properti yang presisi di halaman list. Field filter lain belum mempersempit hasil
98
+ export.
99
+ - **Field `date`/`datetime` adalah field form dan kolom tabel/CSV sungguhan.** Didukung
100
+ `DateField`/`DateTimeField` dari `ui-kit` (native `<input type="date">`/
101
+ `type="datetime-local">`). Field `date` bolak-balik sebagai string `"Y-m-d"` polos maker di
102
+ sisi backend menulis mapping serializer Symfony `config/serializer/{Entity}.yaml` yang
103
+ mengunci format itu (kalau tidak, `DateTimeNormalizer` default Symfony akan menghasilkan
104
+ datetime RFC3339 lengkap dengan jam/timezone yang tidak seharusnya ada untuk kolom
105
+ date-only). Field `datetime` tetap pakai format wire RFC3339 default; form hasil generate
106
+ mengonversi ke/dari string local-wall-clock `<input type="datetime-local">` lewat
107
+ `src/lib/datetime.ts` (ditulis sekali, dipakai bersama semua entity) di boundary
108
+ `reset()`/`submit()` parsing local-time dari constructor `Date` membuat konversi ini aman
109
+ terhadap timezone.
110
+ - **Tabel/export CSV melewati field `textarea`** (teks panjang), meniru fitur Notes yang ditulis
111
+ manual (menampilkan `title`, bukan `body`). Selain itu (`text`/`number`/`boolean`/`date`/
112
+ `datetime`) menjadi kolom.
113
+ - **Field relasi ManyToOne (`"type": "relation"`) dirender sebagai `react-select` `AsyncSelect`
114
+ bertipe pencarian server-side**, bukan `<select>` native — cocok untuk daftar terkait yang bisa
115
+ bertambah banyak (dropdown biasa hanya nyaman untuk puluhan opsi statis). Field ini butuh
116
+ metadata tambahan di spec, tidak ditebak dari nama:
117
+ ```json
118
+ {
119
+ "name": "category",
120
+ "type": "relation",
121
+ "required": true,
122
+ "searchable": false,
123
+ "relatedEntity": "Category",
124
+ "relatedApiResourcePath": "/api/categories",
125
+ "relatedFrontendPath": "/api/categories",
126
+ "displayField": "name",
127
+ "searchParam": "name"
128
+ }
129
+ ```
130
+ - `relatedEntity` — nama PascalCase entity terkait (harus sudah pernah di-generate lebih dulu
131
+ lewat tool ini juga, minimal punya `is{RelatedEntity}`/`is{RelatedEntity}Collection` di
132
+ `lib/api-shapes.ts` dan tipe `{RelatedEntity}` di `types/api.ts` — generator ini tidak pernah
133
+ menulis keduanya untuk tipe yang belum pernah di-generate sendiri).
134
+ - `relatedApiResourcePath` dan `relatedFrontendPath` sengaja **dua field terpisah**, sama seperti
135
+ `apiResourcePath`/`permissionPrefix` di level entity (lihat catatan di bawah soal "fix url") —
136
+ JANGAN digabung jadi satu:
137
+ - `relatedApiResourcePath` — URI ApiPlatform asli milik entity terkait (dihitung sama seperti
138
+ `apiResourcePath`: `pluralize(tableize(relatedEntity))`), ditanam sebagai prefix IRI yang
139
+ dikirim balik ke backend sebagai value submit (`{relatedApiResourcePath}/{id}`). TIDAK
140
+ bergantung pada `permissionPrefix` entity terkait.
141
+ - `relatedFrontendPath` — URL yang dipanggil BROWSER untuk mencari opsi sambil mengetik
142
+ (dipakai sebagai `fetch` di `load{Field}Options` hasil generate). Ini BFF route frontend
143
+ milik entity terkait sendiri (`/api/{entitiesKebab-nya}`, dari `permissionPrefix` di spec-nya
144
+ sendiri) — ambil dari `crud-specs/{RelatedEntity}.json`-nya kalau sudah ada, jangan ditebak
145
+ di sini.
146
+ - Dalam kondisi normal keduanya sama persis (`permissionPrefix` default-nya memang plural yang
147
+ sama), tapi begitu `permissionPrefix` entity terkait dikustomisasi menyimpang, cuma
148
+ `relatedFrontendPath` yang perlu diperbaiki manual — `relatedApiResourcePath` tetap benar
149
+ karena tidak pernah bergantung padanya. Menggabungkan keduanya jadi satu field akan mengulang
150
+ persis bug yang pernah ditambal commit "fix url" untuk `apiResourcePath`/`permissionPrefix` di
151
+ level entity (permissionPrefix custom bikin frontend manggil URL yang tidak pernah di-serve
152
+ ApiPlatform → 404).
153
+ - `displayField` — properti di entity terkait yang ditampilkan sebagai label opsi (mis. `name`)
154
+ — field ini harus benar-benar ada di spec entity terkait sendiri.
155
+ - `searchParam` — nama query param yang dipakai list endpoint entity terkait untuk memfilter
156
+ berdasarkan `displayField` itu (biasanya sama persis dengan `displayField`, cocok dengan
157
+ `#[ApiFilter(SearchFilter::class, properties: [...])]` di entity terkait).
158
+ - Pencarian di-debounce 300ms di sisi client, dan defaultOptions memuat halaman pertama begitu
159
+ dropdown dibuka (tanpa perlu mengetik dulu) — meniru UX dropdown biasa tapi tanpa
160
+ menarik SELURUH koleksi terkait sekaligus ke browser.
161
+ - **Dikecualikan dari Filter panel dan CSV export** (baik "Export All" maupun "Export
162
+ Selected") — sama seperti `textarea`/`date`/`datetime` untuk filter, karena belum ada kontrol
163
+ filter untuk relasi; untuk CSV karena backend export-data controller (query builder kolom
164
+ flat, tanpa join) tidak generik bisa menarik properti entity terkait. Field relasi tetap
165
+ tampil sebagai kolom tabel (`item.{field}.{displayField}`) dan sebagai dropdown form.
166
+ - **PHP maker (`make:kmj-api-crud`) belum otomatis mendeteksi properti ManyToOne dan menulis
167
+ field relasi ini ke `crud-specs/{Entity}.json`** — untuk saat ini field relasi ditambahkan ke
168
+ spec JSON secara manual setelah entity dibuat. Ini murni perluasan generator frontend (`js/`);
169
+ mendeteksi ManyToOne otomatis di sisi PHP adalah pekerjaan terpisah di
170
+ `crud-maker-api-bundle`/`crud-maker-core`.
171
+ - **Getter diasumsikan ada** pada entity/tipe hasil generate, dalam bentuk konvensional
172
+ `get{Field}()` / properti camelCase yang dipakai di seluruh boilerplate ini.
173
+ - **`npm run api:types` harus dijalankan lebih dulu** (setelah atribut `#[ApiResource]`/
174
+ `#[ApiFilter]` backend ditambahkan — lihat next-steps yang dicetak `make:kmj-api-crud`) supaya
175
+ lookup `paths['/api/{entities}']` / `components['schemas'][...]` di `src/types/api.ts`
176
+ berhasil. Lookup ini berdasarkan `apiResourcePath` (catatan literal URI ApiPlatform
177
+ sebenarnya milik entity, dari spec), **bukan** `permissionPrefix` — kalau `#[ApiResource]`
178
+ entity memakai `uriTemplate` custom yang tidak cocok dengan `apiResourcePath`, atau project
179
+ meng-override `api_platform.path_segment_name_generator` dari default-nya, perbaiki lookup itu
180
+ secara manual.
181
+ - **Panggilan BFF ke backend (`app/api/{entities}/route.ts`, `[id]/route.ts`,
182
+ `build{Entities}BackendPath` di `lib/{entities}-query.ts`) selalu memakai `apiResourcePath`
183
+ dari spec, tidak pernah `permissionPrefix`.** `permissionPrefix` hanya menamai route/folder
184
+ frontend sendiri dan permission key — nilainya bebas (free text) dan bisa berbeda dari URI
185
+ plural asli ApiPlatform (misalnya entity `Category` yang di-generate dengan
186
+ `permissionPrefix: "category"` tetap saja disajikan backend di `/api/categories`).
187
+ `apiResourcePath` ditulis oleh `make:kmj-api-crud` sebagai `pluralize(tableize($shortName))`,
188
+ meniru persis `path_segment_name_generator` default ApiPlatform — frontend wajib mengikuti
189
+ nilai ini apa adanya, karena backend digenerate lebih dulu dan menjadi satu-satunya sumber
190
+ kebenaran untuk URL miliknya sendiri. Spec lama dari sebelum field ini ada akan gagal dimuat
191
+ dengan pesan error yang jelas; tambahkan `"apiResourcePath"` secara manual (atau generate
192
+ ulang).
193
+ - File hasil generate tidak melewati Prettier — jalankan `npm run format` setelahnya.
92
194
 
93
195
  ## Development
94
196
 
package/dist/cli.js CHANGED
@@ -104,10 +104,14 @@ function main() {
104
104
  console.log(` 2. Run "npm run api:types" in the frontend project so src/types/api.ts's paths/components lookups resolve.`);
105
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.`);
106
106
  const excludedFilterFields = spec.fields
107
- .filter((f) => f.searchable && ['textarea', 'date', 'datetime'].includes(f.type))
107
+ .filter((f) => f.searchable && ['textarea', 'date', 'datetime', 'relation'].includes(f.type))
108
108
  .map((f) => f.name);
109
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.`);
110
+ console.log(` 4. Note: "${excludedFilterFields.join(', ')}" ${excludedFilterFields.length > 1 ? 'are' : 'is'} marked searchable but excluded from the generated Filter panel (long text, date/datetime, and relation fields don't have a filter control yet) — long-text and relation fields are also excluded from table/CSV columns.`);
111
+ }
112
+ const relationFields = spec.fields.filter((f) => f.type === 'relation');
113
+ if (relationFields.length > 0) {
114
+ console.log(` 5. Relation field(s) "${relationFields.map((f) => f.name).join(', ')}" each carry two related-entity paths that must stay correct independently: "relatedApiResourcePath" (the real ApiPlatform URI, embedded in the submitted IRI) and "relatedFrontendPath" (the related entity's own frontend BFF route, called by load{Field}Options while searching). They're normally identical, but diverge if the related entity's spec customized "permissionPrefix" away from its "apiResourcePath" plural — conflating them was exactly the bug this project's "fix url" commit fixed for top-level apiResourcePath/permissionPrefix, so don't collapse them back into one value.`);
111
115
  }
112
116
  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.`);
113
117
  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,8 +13,12 @@ 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
+ /**
17
+ * Field types with no filter-panel control yet — excluded from `filterableFields()`.
18
+ * `relation` has no filter control either (no generated support for filtering by a related
19
+ * entity's property yet) — same treatment as `date`/`datetime`.
20
+ */
21
+ export const UNFILTERABLE_TYPES = ['textarea', 'date', 'datetime', 'relation'];
18
22
  /**
19
23
  * Fields rendered as controls in the list view's Filter panel: every `searchable` field except
20
24
  * `textarea` (long text doesn't make a sensible filter input) and `date`/`datetime` (no
@@ -48,7 +52,23 @@ export function lowerWords(identifier) {
48
52
  export function displayFields(spec) {
49
53
  return spec.fields.filter((f) => f.type !== 'textarea');
50
54
  }
51
- /** Field used to label a single row in delete-confirmation copy / aria-labels. */
55
+ /**
56
+ * Field used to label a single row in delete-confirmation copy / aria-labels — rendered with
57
+ * `String(item.{label})`, so it must be a scalar (a relation field would stringify to
58
+ * "[object Object]").
59
+ */
52
60
  export function labelField(spec) {
53
- return displayFields(spec)[0]?.name ?? spec.fields[0]?.name ?? 'id';
61
+ const scalarDisplay = displayFields(spec).filter((f) => f.type !== 'relation');
62
+ return scalarDisplay[0]?.name ?? spec.fields.find((f) => f.type !== 'relation')?.name ?? 'id';
63
+ }
64
+ /**
65
+ * Fields written into CSV export columns (`lib/{entities}-csv.ts`'s `Csv{Entity}` pick and the
66
+ * export BFF route's `Export{Entity}` shape): `displayFields()` minus `relation` — the backend's
67
+ * hand-rolled `findExportData()`-style query (a flat column `select()`, no join) has no generic
68
+ * way to pull a related entity's display property, so a relation column would need bespoke
69
+ * backend work per entity. Excluded here so generated export code doesn't assume a join that
70
+ * isn't there; the relation still shows as a table column and a form dropdown.
71
+ */
72
+ export function exportableFields(spec) {
73
+ return displayFields(spec).filter((f) => f.type !== 'relation');
54
74
  }
package/dist/spec.js CHANGED
@@ -1,13 +1,36 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ const SCALAR_TYPES = ['text', 'textarea', 'number', 'boolean', 'date', 'datetime'];
3
+ function isRelationFieldSpec(f) {
4
+ return (f.type === 'relation' &&
5
+ typeof f.relatedEntity === 'string' &&
6
+ f.relatedEntity !== '' &&
7
+ typeof f.relatedApiResourcePath === 'string' &&
8
+ f.relatedApiResourcePath !== '' &&
9
+ typeof f.relatedFrontendPath === 'string' &&
10
+ f.relatedFrontendPath !== '' &&
11
+ typeof f.displayField === 'string' &&
12
+ f.displayField !== '' &&
13
+ typeof f.searchParam === 'string' &&
14
+ f.searchParam !== '');
15
+ }
16
+ function isScalarFieldSpec(f) {
17
+ return (SCALAR_TYPES.includes(f.type) &&
18
+ (f.maxLength === null || f.maxLength === undefined || typeof f.maxLength === 'number'));
19
+ }
2
20
  function isFieldSpec(value) {
3
21
  if (typeof value !== 'object' || value === null)
4
22
  return false;
5
23
  const f = value;
6
- return (typeof f.name === 'string' &&
7
- ['text', 'textarea', 'number', 'boolean', 'date', 'datetime'].includes(f.type) &&
8
- typeof f.required === 'boolean' &&
9
- (f.maxLength === null || typeof f.maxLength === 'number') &&
10
- typeof f.searchable === 'boolean');
24
+ if (typeof f.name !== 'string' || typeof f.required !== 'boolean' || typeof f.searchable !== 'boolean') {
25
+ return false;
26
+ }
27
+ return isRelationFieldSpec(f) || isScalarFieldSpec(f);
28
+ }
29
+ /** Fills in `maxLength: null` for scalar fields that omitted it (relation fields don't have one). */
30
+ function normalizeField(field) {
31
+ if (field.type === 'relation')
32
+ return field;
33
+ return { ...field, maxLength: field.maxLength ?? null };
11
34
  }
12
35
  export function loadSpec(specPath) {
13
36
  let raw;
@@ -31,19 +54,29 @@ export function loadSpec(specPath) {
31
54
  if (typeof spec.entity !== 'string' || spec.entity === '') {
32
55
  throw new Error(`Spec file missing "entity": ${specPath}`);
33
56
  }
57
+ if (typeof spec.apiResourcePath !== 'string' || spec.apiResourcePath === '') {
58
+ throw new Error(`Spec file missing "apiResourcePath": ${specPath}\n` +
59
+ 'Regenerate it with a current version of make:kmj-api-crud, or add the field by ' +
60
+ "hand — it must match the entity's real ApiPlatform collection/item URI (e.g. " +
61
+ '"/api/categories"), not a guess derived from "permissionPrefix" or "entity".');
62
+ }
34
63
  if (typeof spec.permissionPrefix !== 'string' || spec.permissionPrefix === '') {
35
64
  throw new Error(`Spec file missing "permissionPrefix": ${specPath}`);
36
65
  }
37
66
  if (!Array.isArray(spec.fields) || !spec.fields.every(isFieldSpec)) {
38
- throw new Error(`Spec file "fields" is missing or malformed: ${specPath}`);
67
+ throw new Error(`Spec file "fields" is missing or malformed: ${specPath}\n` +
68
+ 'Relation fields (type: "relation") require "relatedEntity", ' +
69
+ '"relatedApiResourcePath", "relatedFrontendPath", "displayField", and ' +
70
+ '"searchParam" — see spec.ts\'s RelationFieldSpec doc comment.');
39
71
  }
40
72
  const idType = spec.idType === 'int' || spec.idType === 'string' || spec.idType === 'uuid' ? spec.idType : 'uuid';
41
73
  return {
42
74
  entity: spec.entity,
75
+ apiResourcePath: spec.apiResourcePath,
43
76
  permissionPrefix: spec.permissionPrefix,
44
77
  ownerProperty: typeof spec.ownerProperty === 'string' ? spec.ownerProperty : null,
45
78
  timestampField: typeof spec.timestampField === 'string' ? spec.timestampField : null,
46
79
  idType,
47
- fields: spec.fields,
80
+ fields: spec.fields.map(normalizeField),
48
81
  };
49
82
  }
@@ -16,12 +16,22 @@ export function apiShapesMarker(entityPascal) {
16
16
  export function apiShapesImport(names) {
17
17
  return `import type { ${names.entityPascal}, ${names.entitiesPascal}Collection } from '@/types/api';\n`;
18
18
  }
19
+ function fieldCheck(field) {
20
+ // `is{RelatedEntity}` is a function *declaration* — hoisted, so it's callable here
21
+ // regardless of whether that entity's own block appears earlier or later in this same
22
+ // lib/api-shapes.ts file. It must exist in this file already (i.e. the related entity has
23
+ // already been through this generator) — this generator never writes it for a type it
24
+ // hasn't itself generated.
25
+ if (field.type === 'relation')
26
+ return `is${field.relatedEntity}(value.${field.name})`;
27
+ return `typeof value.${field.name} === '${tsType(field)}'`;
28
+ }
19
29
  export function apiShapesBlock(spec, names) {
20
30
  const { entityPascal, entitiesPascal } = names;
21
31
  const idJsType = 'int' === spec.idType ? 'number' : 'string';
22
32
  const checks = ['isRecord(value)', `typeof value.id === '${idJsType}'`];
23
33
  for (const field of spec.fields) {
24
- checks.push(`typeof value.${field.name} === '${tsType(field)}'`);
34
+ checks.push(fieldCheck(field));
25
35
  }
26
36
  if (null !== spec.timestampField) {
27
37
  checks.push(`typeof value.${spec.timestampField} === 'string'`);
@@ -1,4 +1,4 @@
1
- import { displayFields, filterableFields } from '../naming.js';
1
+ import { exportableFields, filterableFields } from '../naming.js';
2
2
  function tsType(field) {
3
3
  if (field.type === 'number')
4
4
  return 'number';
@@ -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';
@@ -93,16 +95,17 @@ export function exportRoute(spec, names) {
93
95
  // Reuses the filter panel's first field as the export endpoint's OR-search convenience
94
96
  // text — it must be a property that actually exists on `{Entities}Query`.
95
97
  const field = filterableFields(spec)[0]?.name ?? null;
96
- const cols = displayFields(spec).map((f) => f.name);
98
+ const cols = exportableFields(spec).map((f) => f.name);
97
99
  if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
98
100
  cols.push(spec.timestampField);
99
101
  }
100
- const fieldsByName = new Map(spec.fields.map((f) => [f.name, f]));
102
+ const fieldsByName = new Map(spec.fields.filter((f) => f.type !== 'relation').map((f) => [f.name, f]));
103
+ const fallbackField = { name: '', type: 'text', required: false, maxLength: null, searchable: false };
101
104
  const exportFieldsType = cols
102
- .map((c) => ` ${c}: ${c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? { type: 'text' })};`)
105
+ .map((c) => ` ${c}: ${c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? fallbackField)};`)
103
106
  .join('\n');
104
107
  const guardLines = cols.flatMap((c) => {
105
- const t = c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? { type: 'text' });
108
+ const t = c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? fallbackField);
106
109
  return [`'${c}' in value`, `typeof value.${c} === '${t}'`];
107
110
  });
108
111
  const guardChecks = guardLines.map((line, i) => ` ${line}${i < guardLines.length - 1 ? ' &&' : ''}`).join('\n');
@@ -1,7 +1,7 @@
1
- import { displayFields, humanize } from '../naming.js';
1
+ import { exportableFields, humanize } from '../naming.js';
2
2
  export function csvLib(spec, names) {
3
3
  const { entityPascal, entitiesPascal, entitiesCamel } = names;
4
- const fields = displayFields(spec).map((f) => f.name);
4
+ const fields = exportableFields(spec).map((f) => f.name);
5
5
  if (null !== spec.timestampField)
6
6
  fields.push(spec.timestampField);
7
7
  const pickList = fields.map((f) => `'${f}'`).join(' | ');
@@ -1,8 +1,54 @@
1
1
  import { humanize } from '../naming.js';
2
+ function capitalize(s) {
3
+ return s.charAt(0).toUpperCase() + s.slice(1);
4
+ }
5
+ /** "assignedUser" -> "ASSIGNED_USER" — for this field's per-entity module-level constants. */
6
+ function screamingSnake(name) {
7
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
8
+ }
9
+ function relationFieldMarkup(field, entitiesKebab) {
10
+ const label = humanize(field.name);
11
+ const id = `${entitiesKebab}-${field.name}`;
12
+ const cap = capitalize(field.name);
13
+ return ` <div className="mb-3">
14
+ <label htmlFor="${id}" className="form-label">
15
+ ${label}
16
+ </label>
17
+ <Controller
18
+ name="${field.name}"
19
+ control={control}
20
+ render={({ field }) => (
21
+ <AsyncSelect<RelationOption, false>
22
+ inputId="${id}"
23
+ instanceId="${id}"
24
+ classNamePrefix="react-select"
25
+ cacheOptions
26
+ defaultOptions
27
+ loadOptions={load${cap}Options}
28
+ value={${field.name}Option}
29
+ onChange={(option) => {
30
+ set${cap}Option(option);
31
+ field.onChange(option ? option.value : '');
32
+ }}
33
+ onBlur={field.onBlur}
34
+ placeholder="Select ${label.toLowerCase()}"
35
+ isClearable
36
+ aria-invalid={errors.${field.name} ? true : undefined}
37
+ />
38
+ )}
39
+ />
40
+ {errors.${field.name} && (
41
+ <div className="invalid-feedback d-block">{errors.${field.name}.message}</div>
42
+ )}
43
+ </div>`;
44
+ }
2
45
  function fieldMarkup(field, entitiesKebab, autoFocus) {
3
46
  const label = humanize(field.name);
4
47
  const id = `${entitiesKebab}-${field.name}`;
5
48
  const autoFocusProp = autoFocus ? '\n autoFocus={mode === \'create\'}' : '';
49
+ if (field.type === 'relation') {
50
+ return relationFieldMarkup(field, entitiesKebab);
51
+ }
6
52
  if (field.type === 'textarea') {
7
53
  return ` <TextareaField
8
54
  id="${id}"
@@ -54,8 +100,83 @@ function fieldMarkup(field, entitiesKebab, autoFocus) {
54
100
  registration={register('${field.name}')}
55
101
  />`;
56
102
  }
103
+ function resetFieldExpr(field) {
104
+ if (field.type === 'datetime')
105
+ return `${field.name}: toDatetimeLocalValue(data.${field.name})`;
106
+ // References the local const declared just above `reset(...)` in the edit-load effect (see
107
+ // `relationLocalsBlock`) rather than `data.${field.name}` directly, since the submitted
108
+ // value is the IRI string, not the embedded related-entity object the API returns.
109
+ if (field.type === 'relation')
110
+ return field.name;
111
+ // A non-required text/textarea field is `string | null` in the generated response type
112
+ // (OpenAPI's `nullable` optional-scalar convention), but the zod schema's `.optional()`
113
+ // makes the form value `string | undefined` — `?? undefined` bridges that at the reset()
114
+ // boundary instead of widening the form/schema type to accept `null` everywhere.
115
+ if ((field.type === 'text' || field.type === 'textarea') && !field.required) {
116
+ return `${field.name}: data.${field.name} ?? undefined`;
117
+ }
118
+ return `${field.name}: data.${field.name}`;
119
+ }
120
+ function relationConstants(field) {
121
+ const screaming = screamingSnake(field.name);
122
+ return `const ${screaming}_SEARCH_DEBOUNCE_MS = 300;
123
+ const ${screaming}_SEARCH_PAGE_SIZE = 20;`;
124
+ }
125
+ function relationPreamble(field) {
126
+ const cap = capitalize(field.name);
127
+ return ` const [${field.name}Option, set${cap}Option] = useState<RelationOption | null>(null);
128
+ const ${field.name}DebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);`;
129
+ }
130
+ function relationLoadOptions(field) {
131
+ const cap = capitalize(field.name);
132
+ const screaming = screamingSnake(field.name);
133
+ return ` const load${cap}Options = useCallback((inputValue: string): Promise<RelationOption[]> => {
134
+ return new Promise((resolve) => {
135
+ if (${field.name}DebounceRef.current) clearTimeout(${field.name}DebounceRef.current);
136
+ ${field.name}DebounceRef.current = setTimeout(async () => {
137
+ let response: Response;
138
+ try {
139
+ response = await fetch(
140
+ \`${field.relatedFrontendPath}?${field.searchParam}=\${encodeURIComponent(inputValue)}&itemsPerPage=\${${screaming}_SEARCH_PAGE_SIZE}\`,
141
+ { cache: 'no-store' }
142
+ );
143
+ } catch {
144
+ resolve([]);
145
+ return;
146
+ }
147
+ if (!response.ok) {
148
+ resolve([]);
149
+ return;
150
+ }
151
+ const data: unknown = await response.json();
152
+ if (!is${field.relatedEntity}Collection(data)) {
153
+ resolve([]);
154
+ return;
155
+ }
156
+ resolve(
157
+ data.member.map((item) => ({
158
+ value: \`${field.relatedApiResourcePath}/\${item.id}\`,
159
+ label: item.${field.displayField}
160
+ }))
161
+ );
162
+ }, ${screaming}_SEARCH_DEBOUNCE_MS);
163
+ });
164
+ }, []);`;
165
+ }
166
+ function relationCleanupEffect(fields) {
167
+ const lines = fields
168
+ .map((f) => ` if (${f.name}DebounceRef.current) clearTimeout(${f.name}DebounceRef.current);`)
169
+ .join('\n');
170
+ return ` useEffect(() => {
171
+ return () => {
172
+ ${lines}
173
+ };
174
+ }, []);`;
175
+ }
57
176
  export function form(spec, names) {
58
177
  const { entityPascal, entityCamel, entitiesKebab } = names;
178
+ const relationFields = spec.fields.filter((f) => f.type === 'relation');
179
+ const usesRelation = relationFields.length > 0;
59
180
  const usesTextarea = spec.fields.some((f) => f.type === 'textarea');
60
181
  const usesText = spec.fields.some((f) => f.type === 'text' || f.type === 'number');
61
182
  const usesBoolean = spec.fields.some((f) => f.type === 'boolean');
@@ -73,23 +194,43 @@ export function form(spec, names) {
73
194
  const fieldsMarkup = spec.fields
74
195
  .map((f, i) => fieldMarkup(f, entitiesKebab, i === 0))
75
196
  .join('\n');
76
- const resetFields = spec.fields
77
- .map((f) => (f.type === 'datetime' ? `${f.name}: toDatetimeLocalValue(data.${f.name})` : `${f.name}: data.${f.name}`))
78
- .join(', ');
197
+ const resetFields = spec.fields.map(resetFieldExpr).join(', ');
79
198
  const submitFields = spec.fields
80
199
  .map((f) => (f.type === 'datetime' ? `${f.name}: fromDatetimeLocalValue(values.${f.name})` : null))
81
200
  .filter((c) => c !== null)
82
201
  .join(', ');
202
+ const reactImports = ['useEffect', 'useState', ...(usesRelation ? ['useCallback', 'useRef'] : [])].sort();
203
+ const reactHookFormImports = usesRelation ? 'Controller, useForm' : 'useForm';
204
+ const relatedCollectionGuards = Array.from(new Set(relationFields.map((f) => `is${f.relatedEntity}Collection`)));
205
+ const apiShapesImports = [`is${entityPascal}`, ...relatedCollectionGuards].join(', ');
206
+ const relationTypeBlock = usesRelation ? '\ntype RelationOption = { value: string; label: string };\n' : '';
207
+ const relationConstantsBlock = usesRelation
208
+ ? '\n' + relationFields.map(relationConstants).join('\n') + '\n'
209
+ : '';
210
+ const relationPreambleBlock = usesRelation
211
+ ? '\n' + relationFields.map(relationPreamble).join('\n')
212
+ : '';
213
+ const controlDestructure = usesRelation ? '\n control,' : '';
214
+ const relationLoadOptionsBlock = usesRelation
215
+ ? '\n\n' + relationFields.map(relationLoadOptions).join('\n\n')
216
+ : '';
217
+ const relationCleanupBlock = usesRelation ? '\n\n' + relationCleanupEffect(relationFields) : '';
218
+ const relationLocalsBlock = relationFields
219
+ .map((f) => ` const ${f.name} = \`${f.relatedApiResourcePath}/\${data.${f.name}.id}\`;`)
220
+ .join('\n');
221
+ const relationSetOptionBlock = relationFields
222
+ .map((f) => ` set${capitalize(f.name)}Option({ value: ${f.name}, label: data.${f.name}.${f.displayField} });`)
223
+ .join('\n');
83
224
  return `'use client';
84
225
 
85
226
  import { zodResolver } from '@hookform/resolvers/zod';
86
227
  import { Button, ListPageCard${fieldComponents ? `, ${fieldComponents}` : ''} } from '@kematjaya/bootstrap-ui-kit';
87
228
  import { useRouter } from 'next/navigation';
88
- import { useEffect, useState } from 'react';
89
- import { useForm } from 'react-hook-form';
90
- import { is${entityPascal} } from '@/lib/api-shapes';
229
+ import { ${reactImports.join(', ')} } from 'react';
230
+ import { ${reactHookFormImports} } from 'react-hook-form';${usesRelation ? "\nimport AsyncSelect from 'react-select/async';" : ''}
231
+ import { ${apiShapesImports} } from '@/lib/api-shapes';
91
232
  import { ${entityCamel}Schema, type ${entityPascal}FormValues } from '@/lib/schemas';${usesDateTime ? "\nimport { toDatetimeLocalValue, fromDatetimeLocalValue } from '@/lib/datetime';" : ''}
92
-
233
+ ${relationTypeBlock}${relationConstantsBlock}
93
234
  type Props = {
94
235
  mode: 'create' | 'edit';
95
236
  ${entityCamel}Id?: string;
@@ -98,17 +239,17 @@ type Props = {
98
239
  export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
99
240
  const router = useRouter();
100
241
  const [error, setError] = useState('');
101
- const [loading, setLoading] = useState(mode === 'edit');
242
+ const [loading, setLoading] = useState(mode === 'edit');${relationPreambleBlock}
102
243
  const {
103
244
  register,
104
245
  handleSubmit,
105
- reset,
246
+ reset,${controlDestructure}
106
247
  formState: { errors, isSubmitting }
107
248
  } = useForm<${entityPascal}FormValues>({
108
249
  resolver: zodResolver(${entityCamel}Schema),
109
250
  mode: 'onBlur',
110
251
  reValidateMode: 'onChange'
111
- });
252
+ });${relationLoadOptionsBlock}${relationCleanupBlock}
112
253
 
113
254
  useEffect(() => {
114
255
  if (mode !== 'edit' || !${entityCamel}Id) return;
@@ -135,8 +276,8 @@ export function ${entityPascal}Form({ mode, ${entityCamel}Id }: Props) {
135
276
  return;
136
277
  }
137
278
  if (!cancelled) {
138
- reset({ ${resetFields} });
139
- setLoading(false);
279
+ ${relationLocalsBlock ? relationLocalsBlock + '\n' : ''} reset({ ${resetFields} });
280
+ ${relationSetOptionBlock ? relationSetOptionBlock + '\n' : ''} setLoading(false);
140
281
  }
141
282
  })();
142
283
  return () => {
@@ -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,
@@ -12,10 +12,12 @@ function zodField(field) {
12
12
  if (field.type === 'number') {
13
13
  return field.required ? 'z.number()' : 'z.number().optional()';
14
14
  }
15
+ // Also covers `relation` (the submitted value is an IRI-reference string) — it has no
16
+ // `maxLength` concept, so that check is simply skipped for it below.
15
17
  let expr = 'z.string().trim()';
16
18
  if (field.required)
17
19
  expr += `.min(1, '${label} is required')`;
18
- if (field.maxLength !== null)
20
+ if (field.type !== 'relation' && field.maxLength !== null)
19
21
  expr += `.max(${field.maxLength})`;
20
22
  if (!field.required)
21
23
  expr += '.optional()';
@@ -1,5 +1,5 @@
1
1
  import { displayFields, filterableFields, humanize, labelField, lowerWords } from '../naming.js';
2
- function columnCell(fieldName, fieldType, timestampField) {
2
+ function columnCell(fieldName, fieldType, timestampField, relationDisplayField) {
3
3
  if (fieldName === timestampField || fieldType === 'datetime') {
4
4
  return ` <td>
5
5
  {new Date(
@@ -16,6 +16,9 @@ function columnCell(fieldName, fieldType, timestampField) {
16
16
  ).toLocaleDateString()}
17
17
  </td>`;
18
18
  }
19
+ if (fieldType === 'relation') {
20
+ return ` <td>{item.${fieldName}.${relationDisplayField}}</td>`;
21
+ }
19
22
  return ` <td>{String(item.${fieldName})}</td>`;
20
23
  }
21
24
  function filterFieldKind(type) {
@@ -33,6 +36,7 @@ export function table(spec, names) {
33
36
  cols.push(spec.timestampField);
34
37
  }
35
38
  const fieldTypeByName = new Map(spec.fields.map((f) => [f.name, f.type]));
39
+ const relationDisplayByName = new Map(spec.fields.filter((f) => f.type === 'relation').map((f) => [f.name, f.displayField]));
36
40
  const label = labelField(spec);
37
41
  const noun = lowerWords(entityPascal);
38
42
  const pluralNoun = lowerWords(entitiesPascal);
@@ -42,7 +46,7 @@ export function table(spec, names) {
42
46
  const filterPanelId = `${noun}-filter-panel`;
43
47
  const headCells = cols.map((c) => ` <th>${humanize(c)}</th>`).join('\n');
44
48
  const bodyCells = cols
45
- .map((c) => columnCell(c, fieldTypeByName.get(c) ?? null, spec.timestampField))
49
+ .map((c) => columnCell(c, fieldTypeByName.get(c) ?? null, spec.timestampField, relationDisplayByName.get(c) ?? null))
46
50
  .join('\n');
47
51
  const filterButtonBlock = hasFilter
48
52
  ? ` <Button
@@ -18,7 +18,13 @@ export function typesApiMarker(entityPascal) {
18
18
  }
19
19
  export function typesApiBlock(spec, names) {
20
20
  const { entityPascal, entitiesPascal, entitiesKebab } = names;
21
- const picked = ['id', ...displayFields(spec).map((f) => f.name)];
21
+ const relationFields = spec.fields.filter((f) => f.type === 'relation');
22
+ // Relation fields are excluded from the `Pick<Generated{Entity}, ...>` below and intersected
23
+ // in separately as `{RelatedEntity}` (this entity's own already-generated type, in this same
24
+ // file) instead — `Generated{Entity}.{field}` is the raw OpenAPI-embedded shape
25
+ // (`components['schemas']['{RelatedEntity}.jsonld']`), not the nicer `Pick<...>` type this
26
+ // generator writes for the related entity itself.
27
+ const picked = ['id', ...displayFields(spec).filter((f) => f.type !== 'relation').map((f) => f.name)];
22
28
  if (spec.fields.some((f) => f.type === 'textarea')) {
23
29
  for (const f of spec.fields) {
24
30
  if (f.type === 'textarea' && !picked.includes(f.name))
@@ -29,6 +35,9 @@ export function typesApiBlock(spec, names) {
29
35
  picked.push(spec.timestampField);
30
36
  }
31
37
  const pickList = picked.map((p) => `'${p}'`).join(' | ');
38
+ const relationIntersection = relationFields.length > 0
39
+ ? ' &\n { ' + relationFields.map((f) => `${f.name}: ${f.relatedEntity}`).join('; ') + ' }'
40
+ : '';
32
41
  return `
33
42
  type ${entitiesPascal}CollectionResponses = paths['/api/${entitiesKebab}']['get']['responses'];
34
43
  type ${entityPascal}PostResponses = paths['/api/${entitiesKebab}']['post']['responses'];
@@ -40,7 +49,7 @@ type Generated${entitiesPascal}Collection = NonNullable<
40
49
  export type ${entityPascal}Input = components['schemas']['${entityPascal}.${entityPascal}Input'];
41
50
  export type ${entityPascal} = Required<
42
51
  Pick<Generated${entityPascal}, ${pickList}>
43
- >;
52
+ >${relationIntersection};
44
53
  export type ${entitiesPascal}Collection = Omit<
45
54
  Generated${entitiesPascal}Collection,
46
55
  'member' | 'totalItems'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kematjaya/crud-ui-generator",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",