@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 +165 -63
- package/dist/cli.js +6 -2
- package/dist/naming.js +24 -4
- package/dist/spec.js +40 -7
- package/dist/templates/apiShapes.js +11 -1
- package/dist/templates/bffRoutes.js +16 -13
- package/dist/templates/csvLib.js +2 -2
- package/dist/templates/form.js +153 -12
- package/dist/templates/queryLib.js +9 -0
- package/dist/templates/schemas.js +3 -1
- package/dist/templates/table.js +6 -2
- package/dist/templates/typesApi.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,94 +1,196 @@
|
|
|
1
1
|
# @kematjaya/crud-ui-generator
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
32
|
+
```
|
|
33
|
+
npx @kematjaya/crud-ui-generator <spec-path> [--src <dir>]
|
|
34
|
+
```
|
|
11
35
|
|
|
12
|
-
-
|
|
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
|
-
|
|
19
|
-
|
|
38
|
+
```
|
|
39
|
+
npm install --save-dev @kematjaya/crud-ui-generator
|
|
40
|
+
```
|
|
20
41
|
|
|
21
|
-
##
|
|
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
|
|
28
|
-
- `--src <dir>` —
|
|
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
|
-
|
|
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
|
-
##
|
|
57
|
+
## Apa saja yang di-generate
|
|
37
58
|
|
|
38
|
-
Per entity (
|
|
59
|
+
Per entity (dilewati kalau file sudah ada — aman 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
- `lib/api-shapes.ts` — `is{Entity}`/`is{Entity}Collection`
|
|
53
|
-
- `lib/schemas.ts` —
|
|
54
|
-
- `types/api.ts` —
|
|
55
|
-
|
|
56
|
-
##
|
|
57
|
-
|
|
58
|
-
- **
|
|
59
|
-
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
`
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`datetime`
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`
|
|
91
|
-
-
|
|
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
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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}
|
|
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('
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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) ??
|
|
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) ??
|
|
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');
|
package/dist/templates/csvLib.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { exportableFields, humanize } from '../naming.js';
|
|
2
2
|
export function csvLib(spec, names) {
|
|
3
3
|
const { entityPascal, entitiesPascal, entitiesCamel } = names;
|
|
4
|
-
const fields =
|
|
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(' | ');
|
package/dist/templates/form.js
CHANGED
|
@@ -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 {
|
|
89
|
-
import {
|
|
90
|
-
import {
|
|
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()';
|
package/dist/templates/table.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|