@isi-ui7/bos7-shared 0.3.1 → 0.3.3
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/dist/form-types.d.ts +23 -0
- package/package.json +79 -28
- package/src/form-types.ts +25 -1
- package/src/wizard-step-gate.test.ts +65 -0
- package/src/workflow/starter.ts +37 -4
- package/src/workflow/use-crud-form-gate.test.tsx +138 -0
- package/src/workflow/use-crud-form.ts +13 -0
package/dist/form-types.d.ts
CHANGED
|
@@ -162,6 +162,29 @@ export type FormLayout<TData extends Record<string, unknown>> = {
|
|
|
162
162
|
type: "wizard";
|
|
163
163
|
pages: FormPageDef<TData>[];
|
|
164
164
|
validateOnNext?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* Gerbang antar-langkah. Dipanggil SEBELUM pindah dari `fromPage`;
|
|
167
|
+
* mengembalikan `{ ok: false, message }` membatalkan navigasi dan
|
|
168
|
+
* menampilkan pesannya.
|
|
169
|
+
*
|
|
170
|
+
* Ada karena sebagian wizard punya langkah yang berfungsi sebagai
|
|
171
|
+
* GERBANG, bukan sekadar formulir — mis. registrasi nasabah: verifikasi
|
|
172
|
+
* identitas di Langkah 1 harus berhasil sebelum data lengkap boleh
|
|
173
|
+
* diisi, supaya dua CIF untuk satu orang tidak pernah terbentuk.
|
|
174
|
+
*
|
|
175
|
+
* Tanpa ini, satu-satunya cara menegakkannya adalah membangun wizard
|
|
176
|
+
* bespoke per layar — dan ada EMPAT layar registrasi (perorangan,
|
|
177
|
+
* badan usaha, WIC perorangan, WIC badan usaha) yang butuh gerbang yang
|
|
178
|
+
* sama persis.
|
|
179
|
+
*
|
|
180
|
+
* ⚠️ Ini penjaga NAVIGASI, bukan penjaga keamanan. Ia mencegah operator
|
|
181
|
+
* melangkah lebih jauh; ia TIDAK mencegah payload dikirim langsung ke
|
|
182
|
+
* backend. Aturan yang sama WAJIB ditegakkan lagi di server.
|
|
183
|
+
*/
|
|
184
|
+
canLeavePage?: (fromPage: number, data: TData) => {
|
|
185
|
+
ok: boolean;
|
|
186
|
+
message?: string;
|
|
187
|
+
};
|
|
165
188
|
};
|
|
166
189
|
export type CrudForm<TData extends Record<string, unknown>> = {
|
|
167
190
|
title: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isi-ui7/bos7-shared",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Shared auth7 and layout primitives for bos7 applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"directory": "packages/bos7-shared"
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
|
-
"main": "
|
|
13
|
-
"module": "
|
|
14
|
-
"types": "
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"module": "src/index.ts",
|
|
14
|
+
"types": "src/index.ts",
|
|
15
15
|
"exports": {
|
|
16
|
-
".": "./
|
|
16
|
+
".": "./src/index.ts",
|
|
17
17
|
"./auth7": "./src/auth7/index.ts",
|
|
18
18
|
"./page-layout": "./src/page-layout.tsx",
|
|
19
19
|
"./modal-layout": "./src/modal-layout.tsx",
|
|
@@ -50,23 +50,80 @@
|
|
|
50
50
|
"./bff/health": "./src/bff/health.ts",
|
|
51
51
|
"./observability/otel-init": "./src/observability/otel-init.ts",
|
|
52
52
|
"./form-types": "./src/form-types.ts",
|
|
53
|
+
"./form-value-schema": "./src/form-value-schema.ts",
|
|
53
54
|
"./form-numeric": "./src/form-numeric.ts",
|
|
54
55
|
"./form-renderer": "./src/form-renderer.tsx",
|
|
55
56
|
"./form-renderer-utils": "./src/form-renderer-utils.ts",
|
|
56
57
|
"./form-rules": "./src/form-rules.ts",
|
|
57
58
|
"./i18n": "./src/i18n.tsx",
|
|
58
59
|
"./workflow/use-crud-form": "./src/workflow/use-crud-form.ts",
|
|
59
|
-
"./styles": "./src/form-contract.css"
|
|
60
|
-
"./form-value-schema": "./src/form-value-schema.ts"
|
|
60
|
+
"./styles": "./src/form-contract.css"
|
|
61
61
|
},
|
|
62
62
|
"publishConfig": {
|
|
63
63
|
"registry": "https://registry.npmjs.org",
|
|
64
|
-
"access": "public"
|
|
64
|
+
"access": "public",
|
|
65
|
+
"main": "dist/index.js",
|
|
66
|
+
"module": "dist/index.js",
|
|
67
|
+
"types": "dist/index.d.ts",
|
|
68
|
+
"exports": {
|
|
69
|
+
".": "./dist/index.js",
|
|
70
|
+
"./auth7": "./src/auth7/index.ts",
|
|
71
|
+
"./page-layout": "./src/page-layout.tsx",
|
|
72
|
+
"./modal-layout": "./src/modal-layout.tsx",
|
|
73
|
+
"./form-layout": "./src/form-layout.tsx",
|
|
74
|
+
"./backend": "./src/backend.ts",
|
|
75
|
+
"./export": "./src/export.ts",
|
|
76
|
+
"./types/notification": "./src/types/notification.ts",
|
|
77
|
+
"./nats/client": "./src/nats/client.ts",
|
|
78
|
+
"./event-bus": "./src/event-bus/index.ts",
|
|
79
|
+
"./event-bus/useEventBus": "./src/event-bus/useEventBus.ts",
|
|
80
|
+
"./api": "./src/api.ts",
|
|
81
|
+
"./types/core": "./src/types/core.ts",
|
|
82
|
+
"./style-contract": "./src/style-contract.ts",
|
|
83
|
+
"./crud-components": "./src/crud-components.tsx",
|
|
84
|
+
"./workflow/backend": "./src/workflow/backend.ts",
|
|
85
|
+
"./workflow/notif7": "./src/workflow/notif7.ts",
|
|
86
|
+
"./workflow/api-client": "./src/workflow/api-client.ts",
|
|
87
|
+
"./workflow/starter": "./src/workflow/starter.ts",
|
|
88
|
+
"./workflow/use-workflow-tracker": "./src/workflow/use-workflow-tracker.ts",
|
|
89
|
+
"./workflow/crud-client": "./src/workflow/crud-client.ts",
|
|
90
|
+
"./data-table/cursor-adapter": "./src/data-table/cursor-adapter.ts",
|
|
91
|
+
"./data-table/proxy": "./src/data-table/proxy.ts",
|
|
92
|
+
"./notification/routing": "./src/notification/routing.ts",
|
|
93
|
+
"./shell/interaction": "./src/shell/interaction.ts",
|
|
94
|
+
"./shell/branch": "./src/shell/branch.ts",
|
|
95
|
+
"./notif7": "./src/notif7.ts",
|
|
96
|
+
"./shell/provider": "./src/shell/provider.tsx",
|
|
97
|
+
"./shell/app-shell-layout": "./src/shell/app-shell-layout.tsx",
|
|
98
|
+
"./notifications-bff": "./src/notifications-bff.ts",
|
|
99
|
+
"./bff/auth-me": "./src/bff/auth-me.ts",
|
|
100
|
+
"./bff/auth-refresh": "./src/bff/auth-refresh.ts",
|
|
101
|
+
"./bff/auth-logout": "./src/bff/auth-logout.ts",
|
|
102
|
+
"./bff/auth-preferences": "./src/bff/auth-preferences.ts",
|
|
103
|
+
"./bff/health": "./src/bff/health.ts",
|
|
104
|
+
"./observability/otel-init": "./src/observability/otel-init.ts",
|
|
105
|
+
"./form-types": "./src/form-types.ts",
|
|
106
|
+
"./form-numeric": "./src/form-numeric.ts",
|
|
107
|
+
"./form-renderer": "./src/form-renderer.tsx",
|
|
108
|
+
"./form-renderer-utils": "./src/form-renderer-utils.ts",
|
|
109
|
+
"./form-rules": "./src/form-rules.ts",
|
|
110
|
+
"./i18n": "./src/i18n.tsx",
|
|
111
|
+
"./workflow/use-crud-form": "./src/workflow/use-crud-form.ts",
|
|
112
|
+
"./styles": "./src/form-contract.css",
|
|
113
|
+
"./form-value-schema": "./src/form-value-schema.ts"
|
|
114
|
+
}
|
|
65
115
|
},
|
|
66
116
|
"files": [
|
|
67
117
|
"dist",
|
|
68
118
|
"src"
|
|
69
119
|
],
|
|
120
|
+
"scripts": {
|
|
121
|
+
"build": "vite build",
|
|
122
|
+
"prepublishOnly": "pnpm run build",
|
|
123
|
+
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
124
|
+
"test": "vitest run --passWithNoTests",
|
|
125
|
+
"typecheck": "tsc --noEmit"
|
|
126
|
+
},
|
|
70
127
|
"peerDependencies": {
|
|
71
128
|
"@carbon/icons-react": "^11.71.0",
|
|
72
129
|
"@carbon/react": "^1.97.0",
|
|
@@ -87,6 +144,14 @@
|
|
|
87
144
|
"@carbon/react": "^1.97.0",
|
|
88
145
|
"@eslint/eslintrc": "^3.3.1",
|
|
89
146
|
"@eslint/js": "^9.39.0",
|
|
147
|
+
"@isi-ui7/corporate-themes": "workspace:*",
|
|
148
|
+
"@isi-ui7/data-table": "workspace:*",
|
|
149
|
+
"@isi-ui7/editable-table": "workspace:*",
|
|
150
|
+
"@isi-ui7/i18n": "workspace:*",
|
|
151
|
+
"@isi-ui7/lookup-input": "workspace:*",
|
|
152
|
+
"@isi-ui7/modal-manager": "workspace:*",
|
|
153
|
+
"@isi-ui7/realtime": "workspace:*",
|
|
154
|
+
"@isi-ui7/ui-shell": "workspace:*",
|
|
90
155
|
"@testing-library/dom": "^10.0.0",
|
|
91
156
|
"@testing-library/react": "^16.0.0",
|
|
92
157
|
"@testing-library/user-event": "^14.5.0",
|
|
@@ -105,19 +170,14 @@
|
|
|
105
170
|
"typescript": "^5.3.3",
|
|
106
171
|
"vite": "^5.0.0",
|
|
107
172
|
"vite-plugin-dts": "^3.6.0",
|
|
108
|
-
"vitest": "^1.0.0"
|
|
109
|
-
"@isi-ui7/data-table": "0.2.5",
|
|
110
|
-
"@isi-ui7/corporate-themes": "0.2.6",
|
|
111
|
-
"@isi-ui7/editable-table": "0.2.3",
|
|
112
|
-
"@isi-ui7/lookup-input": "0.2.4",
|
|
113
|
-
"@isi-ui7/i18n": "0.2.3",
|
|
114
|
-
"@isi-ui7/modal-manager": "0.2.2",
|
|
115
|
-
"@isi-ui7/ui-shell": "0.2.8",
|
|
116
|
-
"@isi-ui7/realtime": "0.2.3"
|
|
173
|
+
"vitest": "^1.0.0"
|
|
117
174
|
},
|
|
118
175
|
"dependencies": {
|
|
119
176
|
"@carbon/icons-react": "^11.71.0",
|
|
120
177
|
"@carbon/react": "^1.97.0",
|
|
178
|
+
"@isi-ui7/data-table": "workspace:*",
|
|
179
|
+
"@isi-ui7/editable-table": "workspace:*",
|
|
180
|
+
"@isi-ui7/lookup-input": "workspace:*",
|
|
121
181
|
"@opentelemetry/auto-instrumentations-node": "^0.57.0",
|
|
122
182
|
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
|
|
123
183
|
"@opentelemetry/sdk-node": "^0.57.0",
|
|
@@ -125,15 +185,6 @@
|
|
|
125
185
|
"exceljs": "^4.4.0",
|
|
126
186
|
"jose": "^5.9.6",
|
|
127
187
|
"jspdf": "^4.2.1",
|
|
128
|
-
"jspdf-autotable": "^5.0.7"
|
|
129
|
-
"@isi-ui7/editable-table": "0.2.3",
|
|
130
|
-
"@isi-ui7/data-table": "0.2.5",
|
|
131
|
-
"@isi-ui7/lookup-input": "0.2.4"
|
|
132
|
-
},
|
|
133
|
-
"scripts": {
|
|
134
|
-
"build": "vite build",
|
|
135
|
-
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
136
|
-
"test": "vitest run --passWithNoTests",
|
|
137
|
-
"typecheck": "tsc --noEmit"
|
|
188
|
+
"jspdf-autotable": "^5.0.7"
|
|
138
189
|
}
|
|
139
|
-
}
|
|
190
|
+
}
|
package/src/form-types.ts
CHANGED
|
@@ -171,7 +171,31 @@ export type FormPageDef<TData extends Record<string, unknown>> = {
|
|
|
171
171
|
export type FormLayout<TData extends Record<string, unknown>> =
|
|
172
172
|
| { type: "single-page"; sections: FormSection<TData>[] }
|
|
173
173
|
| { type: "tabs"; pages: FormPageDef<TData>[] }
|
|
174
|
-
| {
|
|
174
|
+
| {
|
|
175
|
+
type: "wizard";
|
|
176
|
+
pages: FormPageDef<TData>[];
|
|
177
|
+
validateOnNext?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* Gerbang antar-langkah. Dipanggil SEBELUM pindah dari `fromPage`;
|
|
180
|
+
* mengembalikan `{ ok: false, message }` membatalkan navigasi dan
|
|
181
|
+
* menampilkan pesannya.
|
|
182
|
+
*
|
|
183
|
+
* Ada karena sebagian wizard punya langkah yang berfungsi sebagai
|
|
184
|
+
* GERBANG, bukan sekadar formulir — mis. registrasi nasabah: verifikasi
|
|
185
|
+
* identitas di Langkah 1 harus berhasil sebelum data lengkap boleh
|
|
186
|
+
* diisi, supaya dua CIF untuk satu orang tidak pernah terbentuk.
|
|
187
|
+
*
|
|
188
|
+
* Tanpa ini, satu-satunya cara menegakkannya adalah membangun wizard
|
|
189
|
+
* bespoke per layar — dan ada EMPAT layar registrasi (perorangan,
|
|
190
|
+
* badan usaha, WIC perorangan, WIC badan usaha) yang butuh gerbang yang
|
|
191
|
+
* sama persis.
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ Ini penjaga NAVIGASI, bukan penjaga keamanan. Ia mencegah operator
|
|
194
|
+
* melangkah lebih jauh; ia TIDAK mencegah payload dikirim langsung ke
|
|
195
|
+
* backend. Aturan yang sama WAJIB ditegakkan lagi di server.
|
|
196
|
+
*/
|
|
197
|
+
canLeavePage?: (fromPage: number, data: TData) => { ok: boolean; message?: string };
|
|
198
|
+
};
|
|
175
199
|
|
|
176
200
|
// ── CrudForm (schema-driven) ──────────────────────────────────────────────────
|
|
177
201
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { CrudForm } from './form-types';
|
|
3
|
+
|
|
4
|
+
type D = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
// Gerbang antar-langkah wizard. Diuji sebagai KONTRAK, bukan lewat render:
|
|
7
|
+
// yang penting bentuk & urutan pemanggilannya, dan itu bisa dipastikan tanpa
|
|
8
|
+
// memasang seluruh form.
|
|
9
|
+
//
|
|
10
|
+
// Kenapa diuji sama sekali: gerbang yang gagal-terbuka tidak terlihat sebagai
|
|
11
|
+
// galat — operator sekadar melewati langkah yang seharusnya mengunci, dan
|
|
12
|
+
// akibatnya (dua CIF untuk satu orang) baru muncul jauh kemudian.
|
|
13
|
+
|
|
14
|
+
function makeSchema(gate: NonNullable<Extract<CrudForm<D>['layout'], { type: 'wizard' }>['canLeavePage']>): CrudForm<D> {
|
|
15
|
+
return {
|
|
16
|
+
title: { create: 'c', edit: 'e', view: 'v' },
|
|
17
|
+
emptyData: {},
|
|
18
|
+
layout: {
|
|
19
|
+
type: 'wizard',
|
|
20
|
+
validateOnNext: true,
|
|
21
|
+
canLeavePage: gate,
|
|
22
|
+
pages: [
|
|
23
|
+
{ label: 'Verifikasi', sections: [] },
|
|
24
|
+
{ label: 'Data', sections: [] },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
} as CrudForm<D>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('wizard canLeavePage', () => {
|
|
31
|
+
it('menolak pindah ketika gerbang mengembalikan ok:false, dan membawa pesannya', () => {
|
|
32
|
+
const gate = vi.fn().mockReturnValue({ ok: false, message: 'Verifikasi identitas dulu.' });
|
|
33
|
+
const schema = makeSchema(gate);
|
|
34
|
+
const layout = schema.layout as Extract<typeof schema.layout, { type: 'wizard' }>;
|
|
35
|
+
|
|
36
|
+
const res = layout.canLeavePage!(0, { verified: false });
|
|
37
|
+
expect(res.ok).toBe(false);
|
|
38
|
+
expect(res.message).toBe('Verifikasi identitas dulu.');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('mengizinkan pindah ketika gerbang ok', () => {
|
|
42
|
+
const layout = makeSchema(() => ({ ok: true })).layout as Extract<
|
|
43
|
+
CrudForm<D>['layout'],
|
|
44
|
+
{ type: 'wizard' }
|
|
45
|
+
>;
|
|
46
|
+
expect(layout.canLeavePage!(0, { verified: true }).ok).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('menerima nomor halaman ASAL, bukan tujuan — gerbang milik langkah yang ditinggalkan', () => {
|
|
50
|
+
const gate = vi.fn().mockReturnValue({ ok: true });
|
|
51
|
+
const layout = makeSchema(gate).layout as Extract<CrudForm<D>['layout'], { type: 'wizard' }>;
|
|
52
|
+
layout.canLeavePage!(0, {});
|
|
53
|
+
expect(gate).toHaveBeenCalledWith(0, {});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('opsional — wizard tanpa gerbang tetap sah secara tipe', () => {
|
|
57
|
+
const schema: CrudForm<D> = {
|
|
58
|
+
title: { create: 'c', edit: 'e', view: 'v' },
|
|
59
|
+
emptyData: {},
|
|
60
|
+
layout: { type: 'wizard', pages: [{ label: 'A', sections: [] }] },
|
|
61
|
+
} as CrudForm<D>;
|
|
62
|
+
const layout = schema.layout as Extract<typeof schema.layout, { type: 'wizard' }>;
|
|
63
|
+
expect(layout.canLeavePage).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
package/src/workflow/starter.ts
CHANGED
|
@@ -9,10 +9,43 @@
|
|
|
9
9
|
// workflow7 EventRecorder dispatch SSE event `instance.completed` balik ke
|
|
10
10
|
// initiator. Kalau initiatedBy kosong → no dispatch → notifikasi silent.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// 🔑 WORKFLOW7_URL only. NEXT_PUBLIC_BOS7_WORKFLOW_URL is deliberately NOT
|
|
13
|
+
// consulted here, and that is the fix — it used to be consulted FIRST.
|
|
14
|
+
//
|
|
15
|
+
// That variable names the workflow WEBAPP, in both environments:
|
|
16
|
+
//
|
|
17
|
+
// local workflow.bos7.local → workflow7-web :3002 (webapp)
|
|
18
|
+
// api.workflow.bos7.local → workflow7-svc :8081 (service)
|
|
19
|
+
// Railway workflow.ibos7.com → bos7-workflow (webapp)
|
|
20
|
+
// core7-service … no public domain — internal only
|
|
21
|
+
//
|
|
22
|
+
// This helper POSTs to /wf/instances/start on the SERVICE. Pointing it at the
|
|
23
|
+
// webapp produced a redirect chain that ends in a misleading 405, reproduced
|
|
24
|
+
// end to end on Railway:
|
|
25
|
+
//
|
|
26
|
+
// POST https://workflow.ibos7.com/wf/instances/start
|
|
27
|
+
// → 307 Location: /api/auth/login?returnTo=/wf/instances/start
|
|
28
|
+
// → fetch preserves POST across a 307
|
|
29
|
+
// → /api/auth/login accepts GET only
|
|
30
|
+
// → 405 Method Not Allowed
|
|
31
|
+
//
|
|
32
|
+
// Control, same path against the service: 401 — the route exists and only
|
|
33
|
+
// wants auth.
|
|
34
|
+
//
|
|
35
|
+
// ⚠️ The old order only broke where NEXT_PUBLIC_BOS7_WORKFLOW_URL is SET.
|
|
36
|
+
// Locally it is usually unset, so the chain fell through to the service and
|
|
37
|
+
// every flow worked — which is why this survived local testing across four
|
|
38
|
+
// webapps. On Railway it is set on all of them, so it won.
|
|
39
|
+
//
|
|
40
|
+
// The NEXT_PUBLIC fallback is removed rather than demoted: for this endpoint
|
|
41
|
+
// it can only ever produce that 405, and a fallback whose sole outcome is a
|
|
42
|
+
// misleading error is worse than none. With it gone, an unconfigured
|
|
43
|
+
// container fails to connect — which names the real problem.
|
|
44
|
+
//
|
|
45
|
+
// NEXT_PUBLIC_BOS7_WORKFLOW_URL stays correct in notifications-bff.ts, which
|
|
46
|
+
// builds user-facing LINKS to the workflow webapp. Same variable, different
|
|
47
|
+
// job; only this file was using it for the wrong one.
|
|
48
|
+
const WORKFLOW7_URL = process.env.WORKFLOW7_URL ?? 'http://localhost:8081';
|
|
16
49
|
|
|
17
50
|
type CookieReqLike = Request & {
|
|
18
51
|
cookies?: { get?: (name: string) => { value?: string } | undefined };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { useCrudForm } from "./use-crud-form";
|
|
4
|
+
import type { CrudForm } from "../form-types";
|
|
5
|
+
|
|
6
|
+
type D = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// Gerbang antar-langkah wizard, diuji lewat PERILAKU goNext.
|
|
9
|
+
//
|
|
10
|
+
// Kenapa ada tes kedua padahal sudah ada wizard-step-gate.test.ts: tes itu
|
|
11
|
+
// hanya memastikan BENTUK tipenya — ia akan tetap hijau meski goNext tidak
|
|
12
|
+
// pernah memanggil canLeavePage sama sekali. Persis kegagalan yang gerbang ini
|
|
13
|
+
// ada untuk mencegahnya: gagal-terbuka tidak berbunyi sebagai galat, operator
|
|
14
|
+
// sekadar melewati langkah yang seharusnya mengunci, dan akibatnya (dua CIF
|
|
15
|
+
// untuk satu orang) baru muncul jauh kemudian.
|
|
16
|
+
//
|
|
17
|
+
// Tes di bawah memeriksa `page` — halaman yang BENAR-BENAR ditempati — bukan
|
|
18
|
+
// nilai balik gerbangnya. Itu satu-satunya yang membedakan "terpasang" dari
|
|
19
|
+
// "sekadar ada".
|
|
20
|
+
|
|
21
|
+
function schemaWith(
|
|
22
|
+
gate?: (from: number, d: D) => { ok: boolean; message?: string },
|
|
23
|
+
opts: { validateOnNext?: boolean } = {},
|
|
24
|
+
): CrudForm<D> {
|
|
25
|
+
return {
|
|
26
|
+
title: { create: "c", edit: "e", view: "v" },
|
|
27
|
+
emptyData: { verified: false } as D,
|
|
28
|
+
layout: {
|
|
29
|
+
type: "wizard",
|
|
30
|
+
validateOnNext: opts.validateOnNext ?? false,
|
|
31
|
+
canLeavePage: gate,
|
|
32
|
+
pages: [
|
|
33
|
+
{
|
|
34
|
+
label: "Verifikasi",
|
|
35
|
+
sections: [
|
|
36
|
+
{
|
|
37
|
+
title: "s",
|
|
38
|
+
fields: [
|
|
39
|
+
{
|
|
40
|
+
key: "nama",
|
|
41
|
+
label: "Nama",
|
|
42
|
+
type: "text",
|
|
43
|
+
span: 4,
|
|
44
|
+
validation: { required: true },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{ label: "Data", sections: [] },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
} as unknown as CrudForm<D>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("useCrudForm — gerbang canLeavePage", () => {
|
|
57
|
+
it("gerbang ok:false MENAHAN halaman di 0 dan menampilkan pesannya", async () => {
|
|
58
|
+
const { result } = renderHook(() =>
|
|
59
|
+
useCrudForm<D>({
|
|
60
|
+
schema: schemaWith(() => ({ ok: false, message: "Cek identitas dulu." })),
|
|
61
|
+
mode: "create",
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(result.current.page).toBe(0);
|
|
66
|
+
await act(async () => {
|
|
67
|
+
await result.current.goNext();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result.current.page).toBe(0);
|
|
71
|
+
expect(result.current.error).toBe("Cek identitas dulu.");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("gerbang ok:true MEMBIARKAN pindah ke halaman 1", async () => {
|
|
75
|
+
const { result } = renderHook(() =>
|
|
76
|
+
useCrudForm<D>({ schema: schemaWith(() => ({ ok: true })), mode: "create" }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await result.current.goNext();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.current.page).toBe(1);
|
|
84
|
+
expect(result.current.error).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("gerbang menerima nomor halaman ASAL beserta isi form saat itu", async () => {
|
|
88
|
+
const gate = vi.fn().mockReturnValue({ ok: true });
|
|
89
|
+
const { result } = renderHook(() =>
|
|
90
|
+
useCrudForm<D>({ schema: schemaWith(gate), mode: "create" }),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await act(async () => {
|
|
94
|
+
result.current.setForm({ verified: true });
|
|
95
|
+
});
|
|
96
|
+
await act(async () => {
|
|
97
|
+
await result.current.goNext();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(gate).toHaveBeenCalledWith(0, { verified: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("gerbang berjalan SEBELUM validasi field — pesan yang muncul alasan sebenarnya, bukan galat field", async () => {
|
|
104
|
+
// `nama` wajib dan kosong, jadi validasi PASTI gagal juga. Kalau urutannya
|
|
105
|
+
// terbalik, yang tampil adalah galat validasi generik dan operator tidak
|
|
106
|
+
// pernah tahu bahwa yang sesungguhnya menahan adalah verifikasi identitas.
|
|
107
|
+
const { result } = renderHook(() =>
|
|
108
|
+
useCrudForm<D>({
|
|
109
|
+
schema: schemaWith(() => ({ ok: false, message: "Cek identitas dulu." }), {
|
|
110
|
+
validateOnNext: true,
|
|
111
|
+
}),
|
|
112
|
+
mode: "create",
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await result.current.goNext();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.current.page).toBe(0);
|
|
121
|
+
expect(result.current.error).toBe("Cek identitas dulu.");
|
|
122
|
+
expect(result.current.fieldErrors).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("KONTROL negatif: wizard tanpa gerbang tetap bisa pindah halaman", async () => {
|
|
126
|
+
// Kalau tes ini ikut gagal ketika gerbang dicabut, berarti yang diukur
|
|
127
|
+
// bukan gerbangnya melainkan navigasinya secara umum.
|
|
128
|
+
const { result } = renderHook(() =>
|
|
129
|
+
useCrudForm<D>({ schema: schemaWith(undefined), mode: "create" }),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
await result.current.goNext();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.current.page).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -100,6 +100,19 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
100
100
|
setServerError(null);
|
|
101
101
|
setHasValidationError(false);
|
|
102
102
|
const layout = schema.layout;
|
|
103
|
+
|
|
104
|
+
// Gerbang antar-langkah — diperiksa SEBELUM validasi field, karena gerbang
|
|
105
|
+
// yang gagal berarti langkah ini belum boleh ditinggalkan sama sekali;
|
|
106
|
+
// menjalankan validasi lebih dulu akan menampilkan galat field yang tidak
|
|
107
|
+
// relevan dan menutupi alasan sebenarnya.
|
|
108
|
+
if (layout.type === "wizard" && layout.canLeavePage) {
|
|
109
|
+
const gate = layout.canLeavePage(page, form);
|
|
110
|
+
if (!gate.ok) {
|
|
111
|
+
setServerError(gate.message ?? "Langkah ini belum bisa dilanjutkan.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
103
116
|
if (layout.type === "wizard" && layout.validateOnNext) {
|
|
104
117
|
const errs = validatePage(page);
|
|
105
118
|
setFieldErrors(errs);
|