@isi-ui7/bos7-shared 0.3.2 → 0.3.4

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.
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { SchemaFormRenderer } from "./form-renderer";
4
+ import type { FormSection } from "./form-types";
5
+
6
+ type D = Record<string, unknown>;
7
+
8
+ // Kait `render` pada FormSection — jalan keluar untuk panel yang harus MEMBACA
9
+ // dan MENULIS state form sekaligus (mis. "Cek Identitas" di registrasi
10
+ // nasabah), yang tidak bisa dinyatakan sebagai field.
11
+ //
12
+ // Dua hal yang diuji, dan yang KEDUA sama pentingnya: perubahan ini menyentuh
13
+ // jalur render SETIAP section di seluruh platform. Kalau section tanpa `render`
14
+ // ikut berubah, kerusakannya menyebar ke semua form sekaligus — dan bentuknya
15
+ // bukan galat, melainkan field yang diam-diam hilang.
16
+
17
+ const fieldsOnly: FormSection<D> = {
18
+ title: "Biasa",
19
+ fields: [{ key: "nama", label: "Nama Lengkap", type: "text", span: 4 }],
20
+ };
21
+
22
+ describe("FormSection.render", () => {
23
+ it("section BER-render menampilkan konten kustom dan TIDAK merender fields-nya", () => {
24
+ const section: FormSection<D> = {
25
+ title: "Verifikasi",
26
+ fields: [{ key: "nama", label: "Nama Lengkap", type: "text", span: 4 }],
27
+ render: () => <div>PANEL KUSTOM</div>,
28
+ };
29
+
30
+ render(
31
+ <SchemaFormRenderer<D> mode="create" value={{}} sections={[section]} onChange={() => {}} />,
32
+ );
33
+
34
+ expect(screen.getByText("PANEL KUSTOM")).toBeTruthy();
35
+ // `fields` sengaja tetap diisi di atas: kalau grid-nya ikut terender,
36
+ // panel dan field akan tampil bersamaan — bukan itu maksud "menggantikan".
37
+ expect(screen.queryByLabelText("Nama Lengkap")).toBeNull();
38
+ });
39
+
40
+ it("menerima value & mode, dan setValue meneruskan patch ke onChange", () => {
41
+ const onChange = vi.fn();
42
+ const seen: Record<string, unknown>[] = [];
43
+
44
+ const section: FormSection<D> = {
45
+ title: "Verifikasi",
46
+ fields: [],
47
+ render: (ctx) => {
48
+ seen.push({ value: ctx.value, mode: ctx.mode, disabled: ctx.disabled });
49
+ return <button onClick={() => ctx.setValue({ verify_status: "NEW" })}>Cek</button>;
50
+ },
51
+ };
52
+
53
+ render(
54
+ <SchemaFormRenderer<D>
55
+ mode="create"
56
+ value={{ identity_number: "327101" }}
57
+ sections={[section]}
58
+ onChange={onChange}
59
+ />,
60
+ );
61
+
62
+ expect(seen[0].value).toEqual({ identity_number: "327101" });
63
+ expect(seen[0].mode).toBe("create");
64
+ expect(seen[0].disabled).toBe(false);
65
+
66
+ screen.getByText("Cek").click();
67
+ expect(onChange).toHaveBeenCalledWith({ verify_status: "NEW" });
68
+ });
69
+
70
+ it("KONTROL REGRESI: section TANPA render tetap merender field-nya seperti sebelumnya", () => {
71
+ render(
72
+ <SchemaFormRenderer<D> mode="create" value={{}} sections={[fieldsOnly]} onChange={() => {}} />,
73
+ );
74
+ expect(screen.getByLabelText("Nama Lengkap")).toBeTruthy();
75
+ });
76
+
77
+ it("KONTROL REGRESI: dua section bercampur — yang polos tidak terpengaruh tetangganya", () => {
78
+ const custom: FormSection<D> = {
79
+ title: "Verifikasi",
80
+ fields: [],
81
+ render: () => <div>PANEL KUSTOM</div>,
82
+ };
83
+
84
+ render(
85
+ <SchemaFormRenderer<D>
86
+ mode="create"
87
+ value={{}}
88
+ sections={[custom, fieldsOnly]}
89
+ onChange={() => {}}
90
+ />,
91
+ );
92
+
93
+ expect(screen.getByText("PANEL KUSTOM")).toBeTruthy();
94
+ expect(screen.getByLabelText("Nama Lengkap")).toBeTruthy();
95
+ });
96
+ });
package/src/form-types.ts CHANGED
@@ -157,6 +157,26 @@ export type FormSection<TData extends Record<string, unknown>> = {
157
157
  /** Number of equal columns in the section grid. Default 1. */
158
158
  columns?: 1 | 2 | 3;
159
159
  fields: FormField<TData>[];
160
+ /**
161
+ * Jalan keluar: render konten sendiri MENGGANTIKAN grid `fields`.
162
+ *
163
+ * Ada untuk panel yang harus MEMBACA dan MENULIS state form sekaligus, yang
164
+ * tidak bisa dinyatakan sebagai field — mis. panel "Cek Identitas" pada
165
+ * registrasi nasabah, yang memanggil backend lalu menyetel penanda hasilnya
166
+ * agar gerbang `canLeavePage` bisa membacanya.
167
+ *
168
+ * `description` sudah ReactNode, tapi ia tidak punya akses ke `value` maupun
169
+ * cara menulis balik — jadi tidak cukup untuk keperluan ini.
170
+ *
171
+ * Opsional: section tanpa `render` dirender persis seperti sebelumnya.
172
+ * `fields` tetap wajib (pakai `[]`) supaya tipe section tidak bercabang dua.
173
+ */
174
+ render?: (ctx: {
175
+ value: TData;
176
+ setValue: (patch: Partial<TData>) => void;
177
+ mode: FormMode;
178
+ disabled: boolean;
179
+ }) => ReactNode;
160
180
  };
161
181
 
162
182
  // ── Layout ────────────────────────────────────────────────────────────────────
@@ -171,7 +191,31 @@ export type FormPageDef<TData extends Record<string, unknown>> = {
171
191
  export type FormLayout<TData extends Record<string, unknown>> =
172
192
  | { type: "single-page"; sections: FormSection<TData>[] }
173
193
  | { type: "tabs"; pages: FormPageDef<TData>[] }
174
- | { type: "wizard"; pages: FormPageDef<TData>[]; validateOnNext?: boolean };
194
+ | {
195
+ type: "wizard";
196
+ pages: FormPageDef<TData>[];
197
+ validateOnNext?: boolean;
198
+ /**
199
+ * Gerbang antar-langkah. Dipanggil SEBELUM pindah dari `fromPage`;
200
+ * mengembalikan `{ ok: false, message }` membatalkan navigasi dan
201
+ * menampilkan pesannya.
202
+ *
203
+ * Ada karena sebagian wizard punya langkah yang berfungsi sebagai
204
+ * GERBANG, bukan sekadar formulir — mis. registrasi nasabah: verifikasi
205
+ * identitas di Langkah 1 harus berhasil sebelum data lengkap boleh
206
+ * diisi, supaya dua CIF untuk satu orang tidak pernah terbentuk.
207
+ *
208
+ * Tanpa ini, satu-satunya cara menegakkannya adalah membangun wizard
209
+ * bespoke per layar — dan ada EMPAT layar registrasi (perorangan,
210
+ * badan usaha, WIC perorangan, WIC badan usaha) yang butuh gerbang yang
211
+ * sama persis.
212
+ *
213
+ * ⚠️ Ini penjaga NAVIGASI, bukan penjaga keamanan. Ia mencegah operator
214
+ * melangkah lebih jauh; ia TIDAK mencegah payload dikirim langsung ke
215
+ * backend. Aturan yang sama WAJIB ditegakkan lagi di server.
216
+ */
217
+ canLeavePage?: (fromPage: number, data: TData) => { ok: boolean; message?: string };
218
+ };
175
219
 
176
220
  // ── CrudForm (schema-driven) ──────────────────────────────────────────────────
177
221
 
@@ -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
+ });
@@ -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);