@kematjaya/crud-ui-generator 0.4.0 → 0.5.0

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