@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 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 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;
@@ -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(`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';
@@ -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 = displayFields(spec).map((f) => f.name);
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) ?? { type: 'text' })};`)
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) ?? { type: 'text' });
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');
@@ -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 () => {
@@ -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.5.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",