@kematjaya/crud-ui-generator 0.5.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 +62 -0
- package/dist/cli.js +6 -2
- package/dist/naming.js +24 -4
- package/dist/spec.js +33 -7
- package/dist/templates/apiShapes.js +11 -1
- package/dist/templates/bffRoutes.js +6 -5
- package/dist/templates/csvLib.js +2 -2
- package/dist/templates/form.js +153 -12
- 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
|
@@ -16,6 +16,10 @@ monorepo ini:
|
|
|
16
16
|
- `src/lib/http.ts`, `src/lib/bff.ts` (helper proxy BFF — `authedBackend`, `validateOrigin`, `parseJson`, `jsonProblem`)
|
|
17
17
|
- `src/lib/permissions.ts` yang meng-export `requirePermission()`
|
|
18
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.
|
|
19
23
|
|
|
20
24
|
Ini bukan scaffolder Next.js serba-guna — menjalankannya pada project yang belum punya
|
|
21
25
|
komponen-komponen di atas akan menghasilkan file yang tidak bisa di-compile sampai Anda
|
|
@@ -106,6 +110,64 @@ Ditambahkan ke (multi-entity, idempotent — tiap entity dapat satu blok yang di
|
|
|
106
110
|
- **Tabel/export CSV melewati field `textarea`** (teks panjang), meniru fitur Notes yang ditulis
|
|
107
111
|
manual (menampilkan `title`, bukan `body`). Selain itu (`text`/`number`/`boolean`/`date`/
|
|
108
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`.
|
|
109
171
|
- **Getter diasumsikan ada** pada entity/tipe hasil generate, dalam bentuk konvensional
|
|
110
172
|
`get{Field}()` / properti camelCase yang dipakai di seluruh boilerplate ini.
|
|
111
173
|
- **`npm run api:types` harus dijalankan lebih dulu** (setelah atribut `#[ApiResource]`/
|
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;
|
|
@@ -41,7 +64,10 @@ export function loadSpec(specPath) {
|
|
|
41
64
|
throw new Error(`Spec file missing "permissionPrefix": ${specPath}`);
|
|
42
65
|
}
|
|
43
66
|
if (!Array.isArray(spec.fields) || !spec.fields.every(isFieldSpec)) {
|
|
44
|
-
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.');
|
|
45
71
|
}
|
|
46
72
|
const idType = spec.idType === 'int' || spec.idType === 'string' || spec.idType === 'uuid' ? spec.idType : 'uuid';
|
|
47
73
|
return {
|
|
@@ -51,6 +77,6 @@ export function loadSpec(specPath) {
|
|
|
51
77
|
ownerProperty: typeof spec.ownerProperty === 'string' ? spec.ownerProperty : null,
|
|
52
78
|
timestampField: typeof spec.timestampField === 'string' ? spec.timestampField : null,
|
|
53
79
|
idType,
|
|
54
|
-
fields: spec.fields,
|
|
80
|
+
fields: spec.fields.map(normalizeField),
|
|
55
81
|
};
|
|
56
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';
|
|
@@ -95,16 +95,17 @@ export function exportRoute(spec, names) {
|
|
|
95
95
|
// Reuses the filter panel's first field as the export endpoint's OR-search convenience
|
|
96
96
|
// text — it must be a property that actually exists on `{Entities}Query`.
|
|
97
97
|
const field = filterableFields(spec)[0]?.name ?? null;
|
|
98
|
-
const cols =
|
|
98
|
+
const cols = exportableFields(spec).map((f) => f.name);
|
|
99
99
|
if (null !== spec.timestampField && !cols.includes(spec.timestampField)) {
|
|
100
100
|
cols.push(spec.timestampField);
|
|
101
101
|
}
|
|
102
|
-
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 };
|
|
103
104
|
const exportFieldsType = cols
|
|
104
|
-
.map((c) => ` ${c}: ${c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ??
|
|
105
|
+
.map((c) => ` ${c}: ${c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? fallbackField)};`)
|
|
105
106
|
.join('\n');
|
|
106
107
|
const guardLines = cols.flatMap((c) => {
|
|
107
|
-
const t = c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ??
|
|
108
|
+
const t = c === spec.timestampField ? 'string' : tsType(fieldsByName.get(c) ?? fallbackField);
|
|
108
109
|
return [`'${c}' in value`, `typeof value.${c} === '${t}'`];
|
|
109
110
|
});
|
|
110
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 () => {
|
|
@@ -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",
|