@isi-ui7/bos7-shared 0.3.3 → 0.3.5

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.
@@ -145,6 +145,39 @@ export type FormSection<TData extends Record<string, unknown>> = {
145
145
  /** Number of equal columns in the section grid. Default 1. */
146
146
  columns?: 1 | 2 | 3;
147
147
  fields: FormField<TData>[];
148
+ /**
149
+ * Jalan keluar: render konten sendiri MENGGANTIKAN grid `fields`.
150
+ *
151
+ * Ada untuk panel yang harus MEMBACA dan MENULIS state form sekaligus, yang
152
+ * tidak bisa dinyatakan sebagai field — mis. panel "Cek Identitas" pada
153
+ * registrasi nasabah, yang memanggil backend lalu menyetel penanda hasilnya
154
+ * agar gerbang `canLeavePage` bisa membacanya.
155
+ *
156
+ * `description` sudah ReactNode, tapi ia tidak punya akses ke `value` maupun
157
+ * cara menulis balik — jadi tidak cukup untuk keperluan ini.
158
+ *
159
+ * Opsional: section tanpa `render` dirender persis seperti sebelumnya.
160
+ * `fields` tetap wajib (pakai `[]`) supaya tipe section tidak bercabang dua.
161
+ */
162
+ render?: (ctx: {
163
+ value: TData;
164
+ setValue: (patch: Partial<TData>) => void;
165
+ mode: FormMode;
166
+ disabled: boolean;
167
+ }) => ReactNode;
168
+ /**
169
+ * Sembunyikan SELURUH section — judul, deskripsi, dan isinya.
170
+ *
171
+ * Bentuknya sengaja sama persis dengan `FormField.hidden` supaya tidak ada
172
+ * dua idiom untuk hal yang sama.
173
+ *
174
+ * Ada karena menyembunyikan tiap field satu per satu TIDAK cukup: judul dan
175
+ * deskripsi section tetap terender, meninggalkan kepala panel kosong yang
176
+ * terlihat seperti bug — dan pada form yang panelnya memang harus terkunci
177
+ * dulu, kepala kosong itu justru mengiklankan keberadaan bagian yang belum
178
+ * boleh diisi.
179
+ */
180
+ hidden?: boolean | ((mode: FormMode, data: TData) => boolean);
148
181
  };
149
182
  export type FormPageDef<TData extends Record<string, unknown>> = {
150
183
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -484,6 +484,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
484
484
  style={{ display: "grid", gap: dt.sectionGap }}
485
485
  >
486
486
  {sections.map((section, sectionIndex) => {
487
+ // Section tersembunyi mengembalikan null — React tidak merender apa
488
+ // pun untuknya, termasuk judul & deskripsinya.
489
+ const sectionHidden =
490
+ typeof section.hidden === "function"
491
+ ? section.hidden(mode, value)
492
+ : section.hidden === true;
493
+ if (sectionHidden) return null;
487
494
  const cols = section.columns ?? 1;
488
495
  const defaultSpan = Math.floor(12 / cols);
489
496
 
@@ -521,6 +528,14 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
521
528
  </div>
522
529
  ) : null}
523
530
 
531
+ {section.render ? (
532
+ section.render({
533
+ value,
534
+ setValue: (patch) => onChange?.(patch),
535
+ mode,
536
+ disabled,
537
+ })
538
+ ) : (
524
539
  <div
525
540
  className={UI7_FORM_VISUAL_CLASSNAMES.row}
526
541
  style={{
@@ -978,6 +993,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
978
993
  ];
979
994
  })}
980
995
  </div>
996
+ )}
981
997
  </section>
982
998
  );
983
999
  })}
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } 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
+ // `FormSection.hidden` — menyembunyikan SELURUH section, bukan hanya field-nya.
9
+ //
10
+ // Menyembunyikan tiap field satu per satu tidak cukup: judul dan deskripsi
11
+ // section tetap terender, meninggalkan kepala panel kosong. Pada form yang
12
+ // panelnya memang harus terkunci dulu, kepala kosong itu justru mengiklankan
13
+ // bagian yang belum boleh diisi.
14
+
15
+ const locked: FormSection<D> = {
16
+ title: "Data Diri",
17
+ description: "Terbuka setelah verifikasi",
18
+ fields: [{ key: "nama", label: "Nama Lengkap", type: "text", span: 4 }],
19
+ hidden: (_mode, data) => data.verified !== true,
20
+ };
21
+
22
+ const always: FormSection<D> = {
23
+ title: "Verifikasi Identitas",
24
+ fields: [{ key: "nik", label: "Nomor Identitas", type: "text", span: 4 }],
25
+ };
26
+
27
+ function renderWith(value: D) {
28
+ return render(
29
+ <SchemaFormRenderer<D>
30
+ mode="create"
31
+ value={value}
32
+ sections={[always, locked]}
33
+ onChange={() => {}}
34
+ />,
35
+ );
36
+ }
37
+
38
+ describe("FormSection.hidden", () => {
39
+ it("menyembunyikan JUDUL dan DESKRIPSI, bukan cuma field-nya", () => {
40
+ renderWith({ verified: false });
41
+ expect(screen.queryByText("Data Diri")).toBeNull();
42
+ expect(screen.queryByText("Terbuka setelah verifikasi")).toBeNull();
43
+ expect(screen.queryByLabelText("Nama Lengkap")).toBeNull();
44
+ });
45
+
46
+ it("menampilkan section ketika predikatnya false", () => {
47
+ renderWith({ verified: true });
48
+ expect(screen.getByText("Data Diri")).toBeTruthy();
49
+ expect(screen.getByLabelText("Nama Lengkap")).toBeTruthy();
50
+ });
51
+
52
+ it("KONTROL: section tetangga tanpa hidden tidak ikut terpengaruh", () => {
53
+ // Kalau kontrol ini ikut gagal, yang diukur bukan `hidden` melainkan
54
+ // perenderan section secara umum.
55
+ renderWith({ verified: false });
56
+ expect(screen.getByText("Verifikasi Identitas")).toBeTruthy();
57
+ expect(screen.getByLabelText("Nomor Identitas")).toBeTruthy();
58
+ });
59
+
60
+ it("menerima boolean, bukan hanya fungsi — bentuknya sama dgn FormField.hidden", () => {
61
+ render(
62
+ <SchemaFormRenderer<D>
63
+ mode="create"
64
+ value={{}}
65
+ sections={[{ title: "Selalu Tersembunyi", fields: [], hidden: true }]}
66
+ onChange={() => {}}
67
+ />,
68
+ );
69
+ expect(screen.queryByText("Selalu Tersembunyi")).toBeNull();
70
+ });
71
+
72
+ it("predikat menerima mode — section bisa disembunyikan hanya saat create", () => {
73
+ const s: FormSection<D> = {
74
+ title: "Hanya Saat Edit",
75
+ fields: [],
76
+ hidden: (mode) => mode === "create",
77
+ };
78
+ const { unmount } = render(
79
+ <SchemaFormRenderer<D> mode="create" value={{}} sections={[s]} onChange={() => {}} />,
80
+ );
81
+ expect(screen.queryByText("Hanya Saat Edit")).toBeNull();
82
+ unmount();
83
+
84
+ render(<SchemaFormRenderer<D> mode="edit" value={{}} sections={[s]} onChange={() => {}} />);
85
+ expect(screen.getByText("Hanya Saat Edit")).toBeTruthy();
86
+ });
87
+ });
@@ -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,39 @@ 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;
180
+ /**
181
+ * Sembunyikan SELURUH section — judul, deskripsi, dan isinya.
182
+ *
183
+ * Bentuknya sengaja sama persis dengan `FormField.hidden` supaya tidak ada
184
+ * dua idiom untuk hal yang sama.
185
+ *
186
+ * Ada karena menyembunyikan tiap field satu per satu TIDAK cukup: judul dan
187
+ * deskripsi section tetap terender, meninggalkan kepala panel kosong yang
188
+ * terlihat seperti bug — dan pada form yang panelnya memang harus terkunci
189
+ * dulu, kepala kosong itu justru mengiklankan keberadaan bagian yang belum
190
+ * boleh diisi.
191
+ */
192
+ hidden?: boolean | ((mode: FormMode, data: TData) => boolean);
160
193
  };
161
194
 
162
195
  // ── Layout ────────────────────────────────────────────────────────────────────