@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 +103 -63
- package/dist/spec.js +7 -0
- package/dist/templates/bffRoutes.js +10 -8
- package/dist/templates/queryLib.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,94 +1,134 @@
|
|
|
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).
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
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`
|
|
13
|
-
- `@kematjaya/access-control-ui`
|
|
14
|
-
- `src/lib/http.ts`, `src/lib/bff.ts` (
|
|
15
|
-
- `src/lib/permissions.ts`
|
|
16
|
-
- `src/types/api.ts` + `src/types/api.generated.ts` (OpenAPI
|
|
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
|
-
|
|
19
|
-
|
|
20
|
+
Ini bukan scaffolder Next.js serba-guna — menjalankannya 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
|
-
##
|
|
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
|
|
28
|
-
- `--src <dir>` —
|
|
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
|
-
|
|
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
|
-
##
|
|
53
|
+
## Apa saja yang di-generate
|
|
37
54
|
|
|
38
|
-
Per entity (
|
|
55
|
+
Per entity (dilewati kalau file sudah ada — aman 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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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(
|
|
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';
|
|
@@ -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.
|
|
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",
|