@isi-ui7/bos7-shared 0.3.8 → 0.3.9

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,47 @@
1
+ import { ReactNode } from 'react';
2
+ import { DetailModalConfig, FormMode, FormSection } from './form-types';
3
+ import { BosSharedLabels } from './i18n';
4
+
5
+ /**
6
+ * Tabel ringkas + modal form per baris — badan tipe field `detail-modal`.
7
+ *
8
+ * Semua gaya di sini INLINE (atau memakai kelas `ui7-form-*` yang sudah ikut
9
+ * `corporate-themes/dist/globals.css`), mengikuti `wizard-stepper.tsx`.
10
+ * Sengaja tidak ada berkas `.css` bersebelahan: `packages/bos7-shared` tidak
11
+ * punya `sideEffects`, tidak punya titik impor CSS, dan `form-contract.css`
12
+ * membuktikan jalur itu berakhir sebagai berkas yang tidak pernah dimuat siapa
13
+ * pun — komponennya tetap jalan, hanya tampilannya rusak, tanpa satu pun galat.
14
+ */
15
+ export type DetailModalFieldProps = {
16
+ /** Dipakai untuk id/aria yang unik antar field dalam satu form. */
17
+ fieldKey: string;
18
+ label: ReactNode;
19
+ required?: boolean;
20
+ helperText?: ReactNode;
21
+ config: DetailModalConfig;
22
+ rows: Record<string, unknown>[];
23
+ mode: FormMode;
24
+ /** View mode atau field readonly: tabel tetap tampil, aksinya hilang. */
25
+ readOnly: boolean;
26
+ disabled: boolean;
27
+ labels: BosSharedLabels;
28
+ /** Pesan galat tingkat field dari validator form induk. */
29
+ invalidText?: string;
30
+ onChange: (rows: Record<string, unknown>[]) => void;
31
+ /**
32
+ * Perender form untuk isi modal.
33
+ *
34
+ * Disuntikkan dari `form-renderer.tsx` alih-alih di-import langsung supaya
35
+ * tidak ada lingkaran impor antara kedua modul — `form-renderer` sudah
36
+ * meng-import berkas ini.
37
+ */
38
+ renderForm: (props: {
39
+ value: Record<string, unknown>;
40
+ sections: FormSection<Record<string, unknown>>[];
41
+ mode: FormMode;
42
+ errors: Partial<Record<string, string>>;
43
+ disabled: boolean;
44
+ onChange: (patch: Partial<Record<string, unknown>>) => void;
45
+ }) => ReactNode;
46
+ };
47
+ export declare function DetailModalField({ fieldKey, label, required, helperText, config, rows, mode, readOnly, disabled, labels, invalidText, onChange, renderForm, }: DetailModalFieldProps): import("react/jsx-runtime").JSX.Element;
@@ -19,7 +19,7 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
19
19
  /** Array of validators — compose Rules.* or custom functions. Runs after built-in rules. */
20
20
  validators?: FieldValidatorFn<TData>[];
21
21
  };
22
- export type FormFieldType = "text" | "textarea" | "number" | "date" | "datetime" | "select" | "lookup" | "checkbox" | "toggle" | "detail-rows";
22
+ export type FormFieldType = "text" | "textarea" | "number" | "date" | "datetime" | "select" | "lookup" | "checkbox" | "toggle" | "detail-rows" | "detail-modal";
23
23
  /**
24
24
  * Select option. `value` may be a number for numeric-coded columns (e.g.
25
25
  * kolektibilitas 1–5); the renderer compares via `String(value)` and the
@@ -106,6 +106,93 @@ export type DetailRowsConfig = {
106
106
  */
107
107
  lockExistingRows?: boolean;
108
108
  };
109
+ /**
110
+ * Satu kolom pada tabel RINGKAS `detail-modal`.
111
+ *
112
+ * Sengaja BUKAN `EditableColumnDef`: kolom di sini hanya menampilkan, tidak
113
+ * pernah menyunting — penyuntingan seluruhnya terjadi di dalam modal. Memakai
114
+ * `EditableColumnDef` akan menuntut `columnType` untuk setiap kolom padahal
115
+ * tipe itu hanya menentukan editor sel yang tidak akan pernah dipakai.
116
+ */
117
+ export type DetailModalColumn = {
118
+ /** Nama properti pada baris. */
119
+ field: string;
120
+ header: ReactNode;
121
+ /** Lebar kolom dalam piksel. Tanpa ini kolom ikut lebar isi. */
122
+ width?: number;
123
+ /** `end` untuk angka. Default `start`. */
124
+ align?: "start" | "end";
125
+ /**
126
+ * Ubah nilai mentah jadi tampilan. Tanpa ini nilai kosong tampil sebagai
127
+ * em-dash (`labels.emptyValue`) dan sisanya lewat `String(value)`.
128
+ *
129
+ * ⚠️ Kolom ringkas menampilkan nilai SIMPAN, bukan label. Untuk field
130
+ * `select`, `String(value)` menghasilkan kodenya ("M"), bukan "Laki-laki" —
131
+ * pakai `format` bila yang harus terbaca operator adalah labelnya.
132
+ */
133
+ format?: (value: unknown, row: Record<string, unknown>) => ReactNode;
134
+ };
135
+ /**
136
+ * Detail-modal config — tabel RINGKAS + tombol Tambah yang membuka modal
137
+ * berisi form penuh untuk SATU baris.
138
+ *
139
+ * Bedanya dengan `detail-rows`: `detail-rows` menyunting DI DALAM sel, jadi
140
+ * setiap field baris harus muat sebagai satu kolom. Begitu satu baris punya
141
+ * belasan field (Beneficial Owner: ~20 field + dua blok alamat), barisnya jadi
142
+ * selebar beberapa layar dan tidak ada tempat untuk alur per-baris seperti cek
143
+ * identitas. `detail-modal` memindahkan seluruh form baris ke modal, dan
144
+ * menyisakan hanya beberapa kolom penanda di tabel.
145
+ *
146
+ * Nilai field di `key` harus berupa array objek biasa; renderer membaca dan
147
+ * menulis array itu secara utuh lewat `onChange({ [key]: newRows })` — sama
148
+ * seperti `detail-rows`.
149
+ *
150
+ * Isi modal dirender oleh `SchemaFormRenderer` yang sama dengan form induknya,
151
+ * jadi SEMUA tipe field yang sudah ada (lookup, date, numeric, toggle, bahkan
152
+ * `FormSection.render`) langsung bisa dipakai di dalam baris tanpa tambahan
153
+ * apa pun di sini.
154
+ */
155
+ export type DetailModalConfig = {
156
+ /**
157
+ * Form untuk SATU baris. Section-nya sama persis dengan section form biasa,
158
+ * dan `TData`-nya adalah barisnya — bukan form induk.
159
+ */
160
+ sections: FormSection<Record<string, unknown>>[];
161
+ /** Kolom yang tampil di tabel ringkas. Boleh sebagian kecil saja dari field baris. */
162
+ columns: DetailModalColumn[];
163
+ newRowFactory: () => Record<string, unknown>;
164
+ /**
165
+ * Aturan lintas-field untuk satu baris, dijalankan SETELAH validasi per-field
166
+ * milik `sections`. Kuncinya nama field; hasilnya menimpa error per-field.
167
+ *
168
+ * `index` adalah posisi baris — untuk baris baru, posisi tempat ia akan
169
+ * mendarat (`rows.length`).
170
+ */
171
+ validateRow?: (row: Record<string, unknown>, index: number) => Record<string, string>;
172
+ /** Batas jumlah baris. Tombol Tambah nonaktif setelah tercapai. */
173
+ maxRows?: number;
174
+ /** Teks tombol tambah, mis. "Tambah Ahli Waris". Default: "Tambah". */
175
+ addLabel?: ReactNode;
176
+ /** Judul modal saat menambah. Default: "Tambah Data". */
177
+ addModalTitle?: ReactNode;
178
+ /** Judul modal saat mengubah. Default: "Ubah Data". */
179
+ editModalTitle?: ReactNode;
180
+ /** Teks saat tabel kosong. Default: "Belum ada data." */
181
+ emptyText?: ReactNode;
182
+ /** Lebar modal Carbon. Default `lg` — form baris biasanya padat. */
183
+ modalSize?: "xs" | "sm" | "md" | "lg";
184
+ /** Izinkan membuka baris yang sudah ada untuk diubah. Default `true`. */
185
+ allowEdit?: boolean;
186
+ /** Izinkan menghapus baris. Default `true`. */
187
+ allowDelete?: boolean;
188
+ /**
189
+ * Kunci React per baris. Default memakai indeks.
190
+ *
191
+ * Berguna bila baris punya identitas sendiri (mis. `person_id`) supaya state
192
+ * modal tidak nyangkut ke baris lain setelah penghapusan di tengah.
193
+ */
194
+ rowKey?: (row: Record<string, unknown>, index: number) => string;
195
+ };
109
196
  export type FormField<TData extends Record<string, unknown>> = {
110
197
  key: keyof TData;
111
198
  label: ReactNode;
@@ -138,6 +225,7 @@ export type FormField<TData extends Record<string, unknown>> = {
138
225
  numeric?: FormFieldNumericConfig<TData>;
139
226
  toggle?: FormFieldToggleConfig;
140
227
  detailRows?: DetailRowsConfig;
228
+ detailModal?: DetailModalConfig;
141
229
  format?: (value: TData[keyof TData], data: TData) => ReactNode;
142
230
  validation?: FieldValidation<TData>;
143
231
  };
package/dist/i18n.d.ts CHANGED
@@ -34,5 +34,14 @@ export type BosSharedLabels = {
34
34
  deleteTitle: string;
35
35
  deleteDescription: string;
36
36
  deleteConfirm: string;
37
+ /** Tipe field `detail-modal` — tabel ringkas + modal form per baris. */
38
+ detailAdd: string;
39
+ detailEdit: string;
40
+ detailDelete: string;
41
+ detailEmpty: string;
42
+ detailNo: string;
43
+ detailActions: string;
44
+ detailAddTitle: string;
45
+ detailEditTitle: string;
37
46
  };
38
47
  export declare function useBosSharedI18n(): BosSharedLabels;
@@ -27391,7 +27391,7 @@ function ba(M, L) {
27391
27391
  return String(pr(M, L));
27392
27392
  }
27393
27393
  async function Io(M, L, r) {
27394
- const { default: x } = await import("./jspdf.es.min-DzRwzM6G.js").then((o) => o.j), { default: w } = await import("./jspdf.plugin.autotable-DFaknRns.js"), c = new x({ orientation: "landscape" });
27394
+ const { default: x } = await import("./jspdf.es.min-CCBzPHw4.js").then((o) => o.j), { default: w } = await import("./jspdf.plugin.autotable-DFaknRns.js"), c = new x({ orientation: "landscape" });
27395
27395
  c.setFontSize(14), c.text(r, 14, 15), c.setFontSize(10), w(c, {
27396
27396
  head: [L.map((o) => o.title)],
27397
27397
  body: M.map((o) => L.map((a) => ba(o[a.field], a.displayFormat))),
@@ -28654,7 +28654,15 @@ function tl() {
28654
28654
  submitEdit: M("bos7.submitEdit", "Update"),
28655
28655
  deleteTitle: M("bos7.deleteTitle", "Konfirmasi Hapus"),
28656
28656
  deleteDescription: M("bos7.deleteDescription", "Data berikut akan dihapus permanen."),
28657
- deleteConfirm: M("bos7.deleteConfirm", "Hapus")
28657
+ deleteConfirm: M("bos7.deleteConfirm", "Hapus"),
28658
+ detailAdd: M("bos7.detailAdd", "Tambah"),
28659
+ detailEdit: M("bos7.detailEdit", "Ubah"),
28660
+ detailDelete: M("bos7.detailDelete", "Hapus"),
28661
+ detailEmpty: M("bos7.detailEmpty", "Belum ada data."),
28662
+ detailNo: M("bos7.detailNo", "No"),
28663
+ detailActions: M("bos7.detailActions", "Aksi"),
28664
+ detailAddTitle: M("bos7.detailAddTitle", "Tambah Data"),
28665
+ detailEditTitle: M("bos7.detailEditTitle", "Ubah Data")
28658
28666
  };
28659
28667
  }
28660
28668
  function Ri(M, L) {
@@ -1,5 +1,5 @@
1
- import { c as Ke, g as Do } from "./index-BI2MxWot.js";
2
- import { _ as Xa } from "./jspdf.es.min-DzRwzM6G.js";
1
+ import { c as Ke, g as Do } from "./index-DqWPV29B.js";
2
+ import { _ as Xa } from "./jspdf.es.min-CCBzPHw4.js";
3
3
  var vt = function(a) {
4
4
  return a && a.Math === Math && a;
5
5
  }, _ = (
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as s, a as o, b as t, D as r, N as i, O as n, d as l, P as c, e as d, f as u, h as S, U as h, i as A, j as T, k, l as C, m as _, n as O, o as U, p as g, q as N, r as P, s as m, t as E, u as F, v as p, w as f, x as I, y as R, z as x, B as v, C as y, E as L, F as b, G as B, H as D, I as M, J as H, K as V, L as W, M as q, Q as K, R as w, S as z, T as G, V as Q, W as X, X as Y, Y as j, Z as J, _ as Z, $, a0 as aa, a1 as ea, a2 as sa, a3 as oa, a4 as ta, a5 as ra, a6 as ia, a7 as na, a8 as la, a9 as ca, aa as da, ab as ua, ac as Sa, ad as ha, ae as Aa, af as Ta, ag as ka, ah as Ca, ai as _a } from "./index-BI2MxWot.js";
1
+ import { A as s, a as o, b as t, D as r, N as i, O as n, d as l, P as c, e as d, f as u, h as S, U as h, i as A, j as T, k, l as C, m as _, n as O, o as U, p as g, q as N, r as P, s as m, t as E, u as F, v as p, w as f, x as I, y as R, z as x, B as v, C as y, E as L, F as b, G as B, H as D, I as M, J as H, K as V, L as W, M as q, Q as K, R as w, S as z, T as G, V as Q, W as X, X as Y, Y as j, Z as J, _ as Z, $, a0 as aa, a1 as ea, a2 as sa, a3 as oa, a4 as ta, a5 as ra, a6 as ia, a7 as na, a8 as la, a9 as ca, aa as da, ab as ua, ac as Sa, ad as ha, ae as Aa, af as Ta, ag as ka, ah as Ca, ai as _a } from "./index-DqWPV29B.js";
2
2
  export {
3
3
  s as AUTH7_SCOPES,
4
4
  o as AppShellLayout,
@@ -9216,7 +9216,7 @@ function(n) {
9216
9216
  var d = h.getContext("2d");
9217
9217
  d.fillStyle = "#fff", d.fillRect(0, 0, h.width, h.height);
9218
9218
  var m = { ignoreMouse: !0, ignoreAnimation: !0, ignoreDimensions: !0 }, _ = this;
9219
- return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-D-PR3JkH.js")).catch(function(k) {
9219
+ return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-9cvhYbjn.js")).catch(function(k) {
9220
9220
  return Promise.reject(new Error("Could not load canvg: " + k));
9221
9221
  }).then(function(k) {
9222
9222
  return k.default ? k.default : k;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,530 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { render, screen, within } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { SchemaFormRenderer } from "./form-renderer";
5
+ import type { FormSection } from "./form-types";
6
+
7
+ type D = Record<string, unknown>;
8
+
9
+ // jsdom tidak punya ResizeObserver, dan `ComposedModal` Carbon memakainya. Paket
10
+ // ini belum punya berkas setup vitest karena belum ada satu pun tesnya yang
11
+ // pernah memasang modal. Stub-nya ditaruh di sini, bukan di config global,
12
+ // supaya cakupannya jelas dan tidak diam-diam mengubah lingkungan tes lain.
13
+ beforeAll(() => {
14
+ if (!("ResizeObserver" in globalThis)) {
15
+ (globalThis as Record<string, unknown>).ResizeObserver = class {
16
+ observe() {}
17
+ unobserve() {}
18
+ disconnect() {}
19
+ };
20
+ }
21
+ });
22
+
23
+ /**
24
+ * Baris tabel yang memuat teks tertentu.
25
+ *
26
+ * Aksi per baris dicari lewat barisnya, bukan lewat nama tombol yang unik:
27
+ * nama terakses tombol ikon Carbon berasal dari tooltip-nya, jadi semua tombol
28
+ * Hapus bernama sama. Ini juga cara operator bekerja — ia mencari BARIS "Budi",
29
+ * lalu menekan Hapus di baris itu.
30
+ */
31
+ function rowWith(text: string): HTMLElement {
32
+ const cell = screen.getByText(text);
33
+ const row = cell.closest("tr");
34
+ if (!row) throw new Error(`Baris untuk "${text}" tidak ditemukan`);
35
+ return row as HTMLElement;
36
+ }
37
+
38
+ // Tipe field `detail-modal` — tabel ringkas + modal berisi form penuh satu baris.
39
+ //
40
+ // Uji ini ditulis dengan KOSAKATA OPERATOR, bukan kosakata API: yang dicari
41
+ // "Tambah Ahli Waris", "Nama Lengkap", "Simpan" — teks yang benar-benar ada di
42
+ // layar. Dua cacat di modul ini lolos justru karena tesnya memakai nama field
43
+ // versi backend, sehingga salah-petakan antara form dan payload tak terlihat.
44
+ //
45
+ // Yang paling dijaga: baris yang tersimpan harus membawa SELURUH field yang
46
+ // diisi operator. Kelas kegagalan termahal di wizard ini bukan galat, melainkan
47
+ // field yang hilang dari payload tanpa satu pun tanda.
48
+
49
+ const heirSections: FormSection<Record<string, unknown>>[] = [
50
+ {
51
+ title: "Data Ahli Waris",
52
+ columns: 2,
53
+ fields: [
54
+ {
55
+ key: "full_name",
56
+ label: "Nama Lengkap",
57
+ type: "text",
58
+ validation: { required: "Nama wajib diisi" },
59
+ },
60
+ { key: "seq_no", label: "Nomor Urut Prioritas", type: "text" },
61
+ { key: "notes", label: "Keterangan", type: "text" },
62
+ ],
63
+ },
64
+ ];
65
+
66
+ function heirField(overrides: Record<string, unknown> = {}) {
67
+ return {
68
+ key: "heirs",
69
+ label: "Daftar Ahli Waris",
70
+ type: "detail-modal" as const,
71
+ detailModal: {
72
+ sections: heirSections,
73
+ columns: [
74
+ { field: "full_name", header: "Nama" },
75
+ { field: "seq_no", header: "Prioritas" },
76
+ { field: "notes", header: "Keterangan" },
77
+ ],
78
+ newRowFactory: () => ({ full_name: "", seq_no: "", notes: "" }),
79
+ addLabel: "Tambah Ahli Waris",
80
+ addModalTitle: "Tambah Ahli Waris",
81
+ emptyText: 'Belum ada ahli waris. Klik "Tambah Ahli Waris".',
82
+ ...overrides,
83
+ },
84
+ };
85
+ }
86
+
87
+ function renderHeirs(
88
+ value: D,
89
+ onChange: (patch: Partial<D>) => void,
90
+ fieldOverrides: Record<string, unknown> = {},
91
+ mode: "create" | "edit" | "view" = "create",
92
+ ) {
93
+ const section: FormSection<D> = {
94
+ title: "Ahli Waris",
95
+ fields: [heirField(fieldOverrides)],
96
+ };
97
+ return render(
98
+ <SchemaFormRenderer<D>
99
+ mode={mode}
100
+ value={value}
101
+ sections={[section]}
102
+ onChange={onChange}
103
+ />,
104
+ );
105
+ }
106
+
107
+ describe("detail-modal — tabel ringkas", () => {
108
+ it("tabel kosong mengajak menambah, bukan menampilkan baris hantu", () => {
109
+ renderHeirs({ heirs: [] }, () => {});
110
+ expect(
111
+ screen.getByText('Belum ada ahli waris. Klik "Tambah Ahli Waris".'),
112
+ ).toBeTruthy();
113
+ });
114
+
115
+ it("baris yang ada tampil sebagai ringkasan, bernomor urut", () => {
116
+ renderHeirs(
117
+ {
118
+ heirs: [
119
+ { full_name: "Siti Aminah", seq_no: "1", notes: "Istri" },
120
+ { full_name: "Budi Santoso", seq_no: "2", notes: "Anak" },
121
+ ],
122
+ },
123
+ () => {},
124
+ );
125
+ expect(screen.getByText("Siti Aminah")).toBeTruthy();
126
+ expect(screen.getByText("Budi Santoso")).toBeTruthy();
127
+ // Nomor urut dihitung renderer, bukan diambil dari data.
128
+ expect(screen.getByText("Istri")).toBeTruthy();
129
+ });
130
+
131
+ it("nilai kosong tampil sebagai em-dash, bukan sel kosong yang ambigu", () => {
132
+ renderHeirs({ heirs: [{ full_name: "Siti Aminah", seq_no: "", notes: "" }] }, () => {});
133
+ expect(screen.getAllByText("—").length).toBeGreaterThan(0);
134
+ });
135
+
136
+ it("nilai simpan bisa dialihkan ke label lewat format()", () => {
137
+ renderHeirs(
138
+ { heirs: [{ full_name: "Siti", seq_no: "1", notes: "", gender: "M" }] },
139
+ () => {},
140
+ {
141
+ columns: [
142
+ { field: "full_name", header: "Nama" },
143
+ {
144
+ field: "gender",
145
+ header: "Jenis Kelamin",
146
+ format: (v: unknown) => (v === "M" ? "Laki-laki" : "Perempuan"),
147
+ },
148
+ ],
149
+ },
150
+ );
151
+ // Tanpa format, kolom ini akan menampilkan "M" — kode simpan, bukan
152
+ // sesuatu yang berarti bagi operator.
153
+ expect(screen.getByText("Laki-laki")).toBeTruthy();
154
+ });
155
+ });
156
+
157
+ describe("detail-modal — menambah baris lewat modal", () => {
158
+ it("tombol Tambah membuka modal berisi form penuh baris", async () => {
159
+ const user = userEvent.setup();
160
+ renderHeirs({ heirs: [] }, () => {});
161
+
162
+ // Sebelum diklik, form baris tidak ada di layar sama sekali.
163
+ expect(screen.queryByLabelText("Nomor Urut Prioritas")).toBeNull();
164
+
165
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
166
+
167
+ expect(screen.getByLabelText(/Nama Lengkap/)).toBeTruthy();
168
+ expect(screen.getByLabelText("Nomor Urut Prioritas")).toBeTruthy();
169
+ expect(screen.getByLabelText("Keterangan")).toBeTruthy();
170
+ });
171
+
172
+ it("Simpan menaruh SELURUH field yang diisi ke payload induk", async () => {
173
+ const user = userEvent.setup();
174
+ const onChange = vi.fn();
175
+ renderHeirs({ heirs: [] }, onChange);
176
+
177
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
178
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti Aminah");
179
+ await user.type(screen.getByLabelText("Nomor Urut Prioritas"), "1");
180
+ await user.type(screen.getByLabelText("Keterangan"), "Istri");
181
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
182
+
183
+ // Inti uji ini: bukan bahwa onChange terpanggil, tapi bahwa KETIGA field
184
+ // sampai utuh. Pemetaan yang meleset tidak menghasilkan galat di mana pun —
185
+ // baris tetap tersimpan, hanya isinya yang bolong.
186
+ expect(onChange).toHaveBeenCalledTimes(1);
187
+ expect(onChange).toHaveBeenCalledWith({
188
+ heirs: [{ full_name: "Siti Aminah", seq_no: "1", notes: "Istri" }],
189
+ });
190
+ });
191
+
192
+ it("baris baru DITAMBAHKAN, tidak menimpa baris yang sudah ada", async () => {
193
+ const user = userEvent.setup();
194
+ const onChange = vi.fn();
195
+ renderHeirs({ heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] }, onChange);
196
+
197
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
198
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti");
199
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
200
+
201
+ expect(onChange).toHaveBeenCalledWith({
202
+ heirs: [
203
+ { full_name: "Budi", seq_no: "1", notes: "" },
204
+ { full_name: "Siti", seq_no: "", notes: "" },
205
+ ],
206
+ });
207
+ });
208
+ });
209
+
210
+ describe("detail-modal — gerbang validasi", () => {
211
+ it("Nama kosong MENAHAN Simpan: baris tidak pernah masuk payload", async () => {
212
+ const user = userEvent.setup();
213
+ const onChange = vi.fn();
214
+ renderHeirs({ heirs: [] }, onChange);
215
+
216
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
217
+ await user.type(screen.getByLabelText("Keterangan"), "Istri");
218
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
219
+
220
+ // Yang diukur adalah AKIBATNYA, bukan hanya munculnya pesan: baris yang
221
+ // tidak sah tidak boleh sampai ke data form dengan cara apa pun.
222
+ expect(onChange).not.toHaveBeenCalled();
223
+ expect(screen.getByText("Nama wajib diisi")).toBeTruthy();
224
+ // Modal tetap terbuka supaya operator bisa memperbaikinya.
225
+ expect(screen.getByLabelText(/Nama Lengkap/)).toBeTruthy();
226
+ });
227
+
228
+ it("setelah diperbaiki, Simpan lolos dan barisnya masuk", async () => {
229
+ const user = userEvent.setup();
230
+ const onChange = vi.fn();
231
+ renderHeirs({ heirs: [] }, onChange);
232
+
233
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
234
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
235
+ expect(onChange).not.toHaveBeenCalled();
236
+
237
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti");
238
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
239
+
240
+ expect(onChange).toHaveBeenCalledWith({
241
+ heirs: [{ full_name: "Siti", seq_no: "", notes: "" }],
242
+ });
243
+ });
244
+
245
+ it("validateRow berjalan SETELAH validasi per-field dan bisa menahan sendiri", async () => {
246
+ const user = userEvent.setup();
247
+ const onChange = vi.fn();
248
+ renderHeirs({ heirs: [] }, onChange, {
249
+ validateRow: (row: Record<string, unknown>) =>
250
+ String(row.seq_no ?? "") === "" ? { seq_no: "Nomor urut wajib" } : {},
251
+ });
252
+
253
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
254
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti");
255
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
256
+
257
+ expect(onChange).not.toHaveBeenCalled();
258
+ expect(screen.getByText("Nomor urut wajib")).toBeTruthy();
259
+ });
260
+
261
+ it("validateRow menerima indeks tempat baris BARU akan mendarat", async () => {
262
+ const user = userEvent.setup();
263
+ const seen: number[] = [];
264
+ renderHeirs(
265
+ { heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] },
266
+ () => {},
267
+ {
268
+ validateRow: (_row: Record<string, unknown>, index: number) => {
269
+ seen.push(index);
270
+ return {};
271
+ },
272
+ },
273
+ );
274
+
275
+ await user.click(screen.getByRole("button", { name: "Tambah Ahli Waris" }));
276
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti");
277
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
278
+
279
+ // Sudah ada 1 baris, jadi baris baru akan mendarat di indeks 1 — bukan -1,
280
+ // dan bukan 0. Aturan yang bergantung urutan harus melihat posisi akhirnya.
281
+ expect(seen).toEqual([1]);
282
+ });
283
+ });
284
+
285
+ describe("detail-modal — ubah, batal, hapus", () => {
286
+ it("Batal membuang suntingan: payload induk tidak tersentuh", async () => {
287
+ const user = userEvent.setup();
288
+ const onChange = vi.fn();
289
+ renderHeirs({ heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] }, onChange);
290
+
291
+ await user.click(within(rowWith("Budi")).getByRole("button", { name: "Ubah" }));
292
+ await user.clear(screen.getByLabelText(/Nama Lengkap/));
293
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "DIUBAH");
294
+ await user.click(screen.getByRole("button", { name: "Batal" }));
295
+
296
+ // Draft disimpan terpisah justru untuk ini: menyunting objek baris langsung
297
+ // akan mengubah data induk sebelum Simpan, dan Batal tak bisa lagi
298
+ // mengembalikannya — tanpa galat, dan tanpa cara mengetahuinya.
299
+ expect(onChange).not.toHaveBeenCalled();
300
+ expect(screen.getByText("Budi")).toBeTruthy();
301
+ });
302
+
303
+ it("Ubah menulis kembali ke indeks yang sama, bukan menambah baris", async () => {
304
+ const user = userEvent.setup();
305
+ const onChange = vi.fn();
306
+ renderHeirs(
307
+ {
308
+ heirs: [
309
+ { full_name: "Budi", seq_no: "1", notes: "" },
310
+ { full_name: "Siti", seq_no: "2", notes: "" },
311
+ ],
312
+ },
313
+ onChange,
314
+ );
315
+
316
+ await user.click(within(rowWith("Siti")).getByRole("button", { name: "Ubah" }));
317
+ await user.clear(screen.getByLabelText(/Nama Lengkap/));
318
+ await user.type(screen.getByLabelText(/Nama Lengkap/), "Siti Aminah");
319
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
320
+
321
+ expect(onChange).toHaveBeenCalledWith({
322
+ heirs: [
323
+ { full_name: "Budi", seq_no: "1", notes: "" },
324
+ { full_name: "Siti Aminah", seq_no: "2", notes: "" },
325
+ ],
326
+ });
327
+ });
328
+
329
+ it("modal dibuka ulang membawa nilai baris itu, bukan sisa baris sebelumnya", async () => {
330
+ const user = userEvent.setup();
331
+ renderHeirs(
332
+ {
333
+ heirs: [
334
+ { full_name: "Budi", seq_no: "1", notes: "" },
335
+ { full_name: "Siti", seq_no: "2", notes: "" },
336
+ ],
337
+ },
338
+ () => {},
339
+ );
340
+
341
+ await user.click(within(rowWith("Budi")).getByRole("button", { name: "Ubah" }));
342
+ expect((screen.getByLabelText(/Nama Lengkap/) as HTMLInputElement).value).toBe("Budi");
343
+ await user.click(screen.getByRole("button", { name: "Batal" }));
344
+
345
+ await user.click(within(rowWith("Siti")).getByRole("button", { name: "Ubah" }));
346
+ expect((screen.getByLabelText(/Nama Lengkap/) as HTMLInputElement).value).toBe("Siti");
347
+ });
348
+
349
+ it("Hapus mengeluarkan baris itu saja", async () => {
350
+ const user = userEvent.setup();
351
+ const onChange = vi.fn();
352
+ renderHeirs(
353
+ {
354
+ heirs: [
355
+ { full_name: "Budi", seq_no: "1", notes: "" },
356
+ { full_name: "Siti", seq_no: "2", notes: "" },
357
+ ],
358
+ },
359
+ onChange,
360
+ );
361
+
362
+ await user.click(within(rowWith("Budi")).getByRole("button", { name: "Hapus" }));
363
+
364
+ expect(onChange).toHaveBeenCalledWith({
365
+ heirs: [{ full_name: "Siti", seq_no: "2", notes: "" }],
366
+ });
367
+ });
368
+ });
369
+
370
+ describe("detail-modal — mode & batas", () => {
371
+ it("mode view: tabel tetap terbaca, Tambah dan Aksi hilang", () => {
372
+ renderHeirs(
373
+ { heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] },
374
+ () => {},
375
+ {},
376
+ "view",
377
+ );
378
+
379
+ expect(screen.getByText("Budi")).toBeTruthy();
380
+ expect(screen.queryByRole("button", { name: "Tambah Ahli Waris" })).toBeNull();
381
+ expect(within(rowWith("Budi")).queryByRole("button", { name: "Ubah" })).toBeNull();
382
+ expect(within(rowWith("Budi")).queryByRole("button", { name: "Hapus" })).toBeNull();
383
+ });
384
+
385
+ it("maxRows menonaktifkan Tambah setelah batas tercapai", () => {
386
+ renderHeirs(
387
+ { heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] },
388
+ () => {},
389
+ { maxRows: 1 },
390
+ );
391
+ const btn = screen.getByRole("button", { name: "Tambah Ahli Waris" }) as HTMLButtonElement;
392
+ expect(btn.disabled).toBe(true);
393
+ });
394
+
395
+ it("allowEdit/allowDelete false menghilangkan aksinya masing-masing", () => {
396
+ renderHeirs(
397
+ { heirs: [{ full_name: "Budi", seq_no: "1", notes: "" }] },
398
+ () => {},
399
+ { allowEdit: false },
400
+ );
401
+ expect(within(rowWith("Budi")).queryByRole("button", { name: "Ubah" })).toBeNull();
402
+ expect(within(rowWith("Budi")).getByRole("button", { name: "Hapus" })).toBeTruthy();
403
+ });
404
+
405
+ it("nilai bukan-array tidak meruntuhkan tabel", () => {
406
+ // Field yang belum pernah diisi tiba sebagai undefined dari backend.
407
+ renderHeirs({}, () => {});
408
+ expect(
409
+ screen.getByText('Belum ada ahli waris. Klik "Tambah Ahli Waris".'),
410
+ ).toBeTruthy();
411
+ });
412
+
413
+ it("config yang hilang BERBUNYI, tidak melenyapkan field diam-diam", () => {
414
+ const section: FormSection<D> = {
415
+ title: "Ahli Waris",
416
+ fields: [{ key: "heirs", label: "Daftar Ahli Waris", type: "detail-modal" }],
417
+ };
418
+ render(
419
+ <SchemaFormRenderer<D>
420
+ mode="create"
421
+ value={{ heirs: [] }}
422
+ sections={[section]}
423
+ onChange={() => {}}
424
+ />,
425
+ );
426
+ // Mengembalikan [] di sini akan membuat field salah-tulis lenyap dari layar
427
+ // tanpa satu pun galat — bentuk kegagalan yang paling mahal di modul ini.
428
+ expect(screen.getByRole("alert").textContent).toContain("detail-modal");
429
+ });
430
+ });
431
+
432
+ describe("KONTROL REGRESI — jalur yang tidak disentuh", () => {
433
+ it("field text biasa tetap terender seperti sebelumnya", () => {
434
+ const section: FormSection<D> = {
435
+ title: "Biasa",
436
+ fields: [{ key: "nama", label: "Nama Lengkap", type: "text", span: 4 }],
437
+ };
438
+ render(
439
+ <SchemaFormRenderer<D> mode="create" value={{}} sections={[section]} onChange={() => {}} />,
440
+ );
441
+ expect(screen.getByLabelText(/Nama Lengkap/)).toBeTruthy();
442
+ });
443
+
444
+ it("detail-rows (EditableTable inline) tetap jalan berdampingan", () => {
445
+ const section: FormSection<D> = {
446
+ title: "Campur",
447
+ fields: [
448
+ {
449
+ key: "limits",
450
+ label: "Limit Transaksi",
451
+ type: "detail-rows",
452
+ detailRows: {
453
+ columns: [
454
+ { field: "kode", header: "Kode", columnType: { type: "text" } },
455
+ ],
456
+ newRowFactory: () => ({ kode: "" }),
457
+ },
458
+ },
459
+ heirField(),
460
+ ],
461
+ };
462
+ render(
463
+ <SchemaFormRenderer<D>
464
+ mode="create"
465
+ value={{ limits: [{ kode: "CASH_IN" }], heirs: [] }}
466
+ sections={[section]}
467
+ onChange={() => {}}
468
+ />,
469
+ );
470
+
471
+ // Dua tipe tabel yang berbeda hidup dalam satu form tanpa saling ganggu.
472
+ expect(screen.getByText("Limit Transaksi")).toBeTruthy();
473
+ expect(screen.getByRole("button", { name: "Tambah Ahli Waris" })).toBeTruthy();
474
+ });
475
+
476
+ it("dua detail-modal dalam satu form berdiri sendiri-sendiri", async () => {
477
+ const user = userEvent.setup();
478
+ const onChange = vi.fn();
479
+ const section: FormSection<D> = {
480
+ title: "Langkah 4",
481
+ fields: [
482
+ heirField(),
483
+ {
484
+ key: "relations",
485
+ label: "Daftar Relasi",
486
+ type: "detail-modal" as const,
487
+ detailModal: {
488
+ sections: [
489
+ {
490
+ title: "Data Relasi",
491
+ fields: [
492
+ {
493
+ key: "first_name",
494
+ label: "Nama Relasi",
495
+ type: "text" as const,
496
+ validation: { required: "Nama relasi wajib diisi" },
497
+ },
498
+ ],
499
+ },
500
+ ],
501
+ columns: [{ field: "first_name", header: "Nama" }],
502
+ newRowFactory: () => ({ first_name: "" }),
503
+ addLabel: "Tambah Relasi",
504
+ },
505
+ },
506
+ ],
507
+ };
508
+
509
+ render(
510
+ <SchemaFormRenderer<D>
511
+ mode="create"
512
+ value={{ heirs: [], relations: [] }}
513
+ sections={[section]}
514
+ onChange={onChange}
515
+ />,
516
+ );
517
+
518
+ // Membuka modal Relasi tidak boleh memunculkan form Ahli Waris.
519
+ await user.click(screen.getByRole("button", { name: "Tambah Relasi" }));
520
+ const dialog = screen.getByRole("dialog");
521
+ expect(within(dialog).getByLabelText(/Nama Relasi/)).toBeTruthy();
522
+ expect(within(dialog).queryByLabelText("Nomor Urut Prioritas")).toBeNull();
523
+
524
+ await user.type(screen.getByLabelText(/Nama Relasi/), "Andi");
525
+ await user.click(screen.getByRole("button", { name: "Simpan" }));
526
+
527
+ // Hanya kunci `relations` yang ditulis — `heirs` tidak ikut tersentuh.
528
+ expect(onChange).toHaveBeenCalledWith({ relations: [{ first_name: "Andi" }] });
529
+ });
530
+ });
@@ -0,0 +1,342 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import {
5
+ Button,
6
+ ComposedModal,
7
+ ModalBody,
8
+ ModalFooter,
9
+ ModalHeader,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableContainer,
14
+ TableHead,
15
+ TableHeader,
16
+ TableRow,
17
+ } from "@carbon/react";
18
+ import { Add, Edit, TrashCan } from "@carbon/icons-react";
19
+ import type { DetailModalConfig, FormMode, FormSection } from "./form-types";
20
+ import { runFieldValidation } from "./form-renderer-utils";
21
+ import type { BosSharedLabels } from "./i18n";
22
+ import { UI7_FORM_VISUAL_CLASSNAMES } from "./style-contract";
23
+
24
+ /**
25
+ * Tabel ringkas + modal form per baris — badan tipe field `detail-modal`.
26
+ *
27
+ * Semua gaya di sini INLINE (atau memakai kelas `ui7-form-*` yang sudah ikut
28
+ * `corporate-themes/dist/globals.css`), mengikuti `wizard-stepper.tsx`.
29
+ * Sengaja tidak ada berkas `.css` bersebelahan: `packages/bos7-shared` tidak
30
+ * punya `sideEffects`, tidak punya titik impor CSS, dan `form-contract.css`
31
+ * membuktikan jalur itu berakhir sebagai berkas yang tidak pernah dimuat siapa
32
+ * pun — komponennya tetap jalan, hanya tampilannya rusak, tanpa satu pun galat.
33
+ */
34
+
35
+ export type DetailModalFieldProps = {
36
+ /** Dipakai untuk id/aria yang unik antar field dalam satu form. */
37
+ fieldKey: string;
38
+ label: ReactNode;
39
+ required?: boolean;
40
+ helperText?: ReactNode;
41
+ config: DetailModalConfig;
42
+ rows: Record<string, unknown>[];
43
+ mode: FormMode;
44
+ /** View mode atau field readonly: tabel tetap tampil, aksinya hilang. */
45
+ readOnly: boolean;
46
+ disabled: boolean;
47
+ labels: BosSharedLabels;
48
+ /** Pesan galat tingkat field dari validator form induk. */
49
+ invalidText?: string;
50
+ onChange: (rows: Record<string, unknown>[]) => void;
51
+ /**
52
+ * Perender form untuk isi modal.
53
+ *
54
+ * Disuntikkan dari `form-renderer.tsx` alih-alih di-import langsung supaya
55
+ * tidak ada lingkaran impor antara kedua modul — `form-renderer` sudah
56
+ * meng-import berkas ini.
57
+ */
58
+ renderForm: (props: {
59
+ value: Record<string, unknown>;
60
+ sections: FormSection<Record<string, unknown>>[];
61
+ mode: FormMode;
62
+ errors: Partial<Record<string, string>>;
63
+ disabled: boolean;
64
+ onChange: (patch: Partial<Record<string, unknown>>) => void;
65
+ }) => ReactNode;
66
+ };
67
+
68
+ export function DetailModalField({
69
+ fieldKey,
70
+ label,
71
+ required = false,
72
+ helperText,
73
+ config,
74
+ rows,
75
+ mode,
76
+ readOnly,
77
+ disabled,
78
+ labels,
79
+ invalidText,
80
+ onChange,
81
+ renderForm,
82
+ }: DetailModalFieldProps) {
83
+ // `draft === null` berarti modal tertutup. State draft dipisah dari `rows`
84
+ // supaya Batal benar-benar membatalkan: baris asli tidak pernah tersentuh
85
+ // sampai Simpan lolos validasi.
86
+ const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
87
+ // `null` = baris baru; angka = indeks baris yang sedang diubah.
88
+ const [draftIndex, setDraftIndex] = useState<number | null>(null);
89
+ const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
90
+
91
+ const allowEdit = config.allowEdit ?? true;
92
+ const allowDelete = config.allowDelete ?? true;
93
+ const showActions = !readOnly && (allowEdit || allowDelete);
94
+ const atMax = config.maxRows !== undefined && rows.length >= config.maxRows;
95
+ const isOpen = draft !== null;
96
+
97
+ const openAdd = () => {
98
+ setDraft(config.newRowFactory());
99
+ setDraftIndex(null);
100
+ setErrors({});
101
+ };
102
+
103
+ const openEdit = (index: number) => {
104
+ // Salinan dangkal: menyunting objek baris langsung akan mengubah data form
105
+ // induk sebelum Simpan, sehingga Batal tidak lagi bisa mengembalikannya.
106
+ setDraft({ ...rows[index] });
107
+ setDraftIndex(index);
108
+ setErrors({});
109
+ };
110
+
111
+ const closeModal = () => {
112
+ setDraft(null);
113
+ setDraftIndex(null);
114
+ setErrors({});
115
+ };
116
+
117
+ const commit = () => {
118
+ if (draft === null) return;
119
+
120
+ // Validasi per-field memakai mesin yang SAMA dengan form biasa, dengan
121
+ // barisnya sebagai `TData`. Aturan `required` / `pattern` / `Rules.*` yang
122
+ // ditulis di section baris berlaku apa adanya, tanpa disalin ulang di sini.
123
+ const fields = config.sections.flatMap((s) => s.fields);
124
+ const fieldErrors = runFieldValidation(fields, draft, mode, labels);
125
+
126
+ // Baris baru divalidasi memakai posisi tempat ia AKAN mendarat, bukan -1,
127
+ // supaya aturan yang bergantung urutan (mis. nomor urut ahli waris) melihat
128
+ // indeks yang sama dengan yang akan dilihatnya setelah tersimpan.
129
+ const index = draftIndex ?? rows.length;
130
+ const rowErrors = config.validateRow?.(draft, index) ?? {};
131
+
132
+ const merged: Partial<Record<string, string>> = { ...fieldErrors, ...rowErrors };
133
+ if (Object.keys(merged).length > 0) {
134
+ setErrors(merged);
135
+ return;
136
+ }
137
+
138
+ onChange(
139
+ draftIndex === null
140
+ ? [...rows, draft]
141
+ : rows.map((row, i) => (i === draftIndex ? draft : row)),
142
+ );
143
+ closeModal();
144
+ };
145
+
146
+ const removeAt = (index: number) => {
147
+ onChange(rows.filter((_, i) => i !== index));
148
+ };
149
+
150
+ const renderCell = (
151
+ column: DetailModalConfig["columns"][number],
152
+ row: Record<string, unknown>,
153
+ ): ReactNode => {
154
+ const raw = row[column.field];
155
+ if (column.format) return column.format(raw, row);
156
+ if (raw === null || raw === undefined || raw === "") return labels.emptyValue;
157
+ return String(raw);
158
+ };
159
+
160
+ const columnCount = config.columns.length + 1 + (showActions ? 1 : 0);
161
+
162
+ return (
163
+ <div style={{ display: "grid", gap: "0.5rem" }}>
164
+ {/* Kepala panel: label kiri, tombol Tambah kanan — bentuk yang dipakai
165
+ contoh UI untuk Ahli Waris / Relasi / Beneficial Owner. */}
166
+ <div
167
+ style={{
168
+ display: "flex",
169
+ justifyContent: "space-between",
170
+ alignItems: "center",
171
+ gap: "1rem",
172
+ }}
173
+ >
174
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.label}>
175
+ <span>{label}</span>
176
+ {required ? (
177
+ <span className={UI7_FORM_VISUAL_CLASSNAMES.labelRequired}> *</span>
178
+ ) : null}
179
+ </div>
180
+ {!readOnly ? (
181
+ <Button
182
+ size="sm"
183
+ renderIcon={Add}
184
+ onClick={openAdd}
185
+ disabled={disabled || atMax}
186
+ >
187
+ {config.addLabel ?? labels.detailAdd}
188
+ </Button>
189
+ ) : null}
190
+ </div>
191
+
192
+ <TableContainer>
193
+ <Table size="sm" useZebraStyles={false}>
194
+ <TableHead>
195
+ <TableRow>
196
+ <TableHeader style={{ width: "3rem" }}>{labels.detailNo}</TableHeader>
197
+ {config.columns.map((column) => (
198
+ <TableHeader
199
+ key={column.field}
200
+ style={column.width ? { width: `${column.width}px` } : undefined}
201
+ >
202
+ {column.header}
203
+ </TableHeader>
204
+ ))}
205
+ {showActions ? (
206
+ <TableHeader style={{ width: "5.5rem" }}>
207
+ {labels.detailActions}
208
+ </TableHeader>
209
+ ) : null}
210
+ </TableRow>
211
+ </TableHead>
212
+ <TableBody>
213
+ {rows.length === 0 ? (
214
+ <TableRow>
215
+ <TableCell
216
+ colSpan={columnCount}
217
+ style={{
218
+ textAlign: "center",
219
+ fontStyle: "italic",
220
+ color: "var(--cds-text-secondary, #525252)",
221
+ }}
222
+ >
223
+ {config.emptyText ?? labels.detailEmpty}
224
+ </TableCell>
225
+ </TableRow>
226
+ ) : (
227
+ rows.map((row, index) => (
228
+ <TableRow key={config.rowKey?.(row, index) ?? String(index)}>
229
+ <TableCell>{index + 1}</TableCell>
230
+ {config.columns.map((column) => (
231
+ <TableCell
232
+ key={column.field}
233
+ style={
234
+ column.align === "end" ? { textAlign: "right" } : undefined
235
+ }
236
+ >
237
+ {renderCell(column, row)}
238
+ </TableCell>
239
+ ))}
240
+ {showActions ? (
241
+ <TableCell>
242
+ <div style={{ display: "flex", gap: "0.25rem" }}>
243
+ {allowEdit ? (
244
+ <Button
245
+ kind="ghost"
246
+ size="sm"
247
+ hasIconOnly
248
+ renderIcon={Edit}
249
+ iconDescription={labels.detailEdit}
250
+ aria-label={`${labels.detailEdit} ${index + 1}`}
251
+ disabled={disabled}
252
+ onClick={() => openEdit(index)}
253
+ />
254
+ ) : null}
255
+ {allowDelete ? (
256
+ <Button
257
+ kind="ghost"
258
+ size="sm"
259
+ hasIconOnly
260
+ renderIcon={TrashCan}
261
+ iconDescription={labels.detailDelete}
262
+ aria-label={`${labels.detailDelete} ${index + 1}`}
263
+ disabled={disabled}
264
+ onClick={() => removeAt(index)}
265
+ />
266
+ ) : null}
267
+ </div>
268
+ </TableCell>
269
+ ) : null}
270
+ </TableRow>
271
+ ))
272
+ )}
273
+ </TableBody>
274
+ </Table>
275
+ </TableContainer>
276
+
277
+ {invalidText ? (
278
+ <div
279
+ role="alert"
280
+ className={UI7_FORM_VISUAL_CLASSNAMES.helper}
281
+ style={{ color: "var(--cds-text-error, #da1e28)" }}
282
+ >
283
+ {invalidText}
284
+ </div>
285
+ ) : null}
286
+
287
+ {helperText ? (
288
+ <div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{helperText}</div>
289
+ ) : null}
290
+
291
+ {/* Modal dipasang HANYA saat terbuka supaya setiap kali dibuka formnya
292
+ benar-benar baru — tidak ada sisa nilai dari baris yang dibuka
293
+ sebelumnya. */}
294
+ {isOpen ? (
295
+ <ComposedModal
296
+ open
297
+ onClose={closeModal}
298
+ size={config.modalSize ?? "lg"}
299
+ aria-label={String(
300
+ draftIndex === null
301
+ ? config.addModalTitle ?? labels.detailAddTitle
302
+ : config.editModalTitle ?? labels.detailEditTitle,
303
+ )}
304
+ >
305
+ <ModalHeader
306
+ title={
307
+ draftIndex === null
308
+ ? config.addModalTitle ?? labels.detailAddTitle
309
+ : config.editModalTitle ?? labels.detailEditTitle
310
+ }
311
+ />
312
+ <ModalBody hasScrollingContent>
313
+ {renderForm({
314
+ value: draft,
315
+ sections: config.sections,
316
+ // Baris selalu disunting dalam mode tulis. Mode `view` induk tidak
317
+ // pernah sampai ke sini — tombol Ubah sudah hilang saat readOnly.
318
+ mode: draftIndex === null ? "create" : "edit",
319
+ errors,
320
+ disabled,
321
+ onChange: (patch) =>
322
+ setDraft((prev) => (prev === null ? prev : { ...prev, ...patch })),
323
+ })}
324
+ </ModalBody>
325
+ <ModalFooter>
326
+ <Button kind="secondary" onClick={closeModal} disabled={disabled}>
327
+ {labels.cancel}
328
+ </Button>
329
+ <Button
330
+ kind="primary"
331
+ onClick={commit}
332
+ disabled={disabled}
333
+ data-testid={`detail-modal-save-${fieldKey}`}
334
+ >
335
+ {labels.submitCreate}
336
+ </Button>
337
+ </ModalFooter>
338
+ </ComposedModal>
339
+ ) : null}
340
+ </div>
341
+ );
342
+ }
@@ -34,6 +34,7 @@ import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
34
34
  import { useBosSharedI18n } from "./i18n";
35
35
  import type { FormField, FormMode, FormSection } from "./form-types";
36
36
  import { toLocalISODate } from "./form-renderer-utils";
37
+ import { DetailModalField } from "./detail-modal-field";
37
38
 
38
39
  export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
39
40
  mode: FormMode;
@@ -663,6 +664,88 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
663
664
  ];
664
665
  }
665
666
 
667
+ // ── Detail-modal (tabel ringkas + modal form per baris) ─────
668
+ // Sama seperti detail-rows: dirender SEBELUM cabang readonly
669
+ // supaya view / edit / create memakai satu komponen yang sama —
670
+ // yang readonly hanya kehilangan tombol Tambah dan kolom Aksi.
671
+ // Default span 12 karena tabel tidak pernah pas di setengah
672
+ // kolom.
673
+ if (field.type === "detail-modal") {
674
+ const detailSpan = clampSpan(field.span, 12);
675
+ const detailWrapperStyle = {
676
+ gridColumn: field.breakBefore
677
+ ? `1 / span ${detailSpan}`
678
+ : `span ${detailSpan} / span ${detailSpan}`,
679
+ minWidth: 0,
680
+ } as const;
681
+
682
+ // Config yang hilang TIDAK boleh membuat field lenyap diam-diam.
683
+ // `detail-rows` mengembalikan [] di sini, dan akibatnya field
684
+ // yang salah tulis hilang dari layar tanpa satu pun galat —
685
+ // persis kelas kegagalan yang paling mahal di modul ini.
686
+ const detail = field.detailModal;
687
+ if (!detail) {
688
+ return [
689
+ <div
690
+ key={key}
691
+ className={UI7_FORM_VISUAL_CLASSNAMES.column}
692
+ style={detailWrapperStyle}
693
+ >
694
+ <div role="alert" style={{ color: "var(--cds-text-error, #da1e28)" }}>
695
+ {`Field "${key}" bertipe detail-modal tetapi tidak punya detailModal.`}
696
+ </div>
697
+ </div>,
698
+ ];
699
+ }
700
+
701
+ const rows = Array.isArray(rawValue)
702
+ ? (rawValue as Record<string, unknown>[])
703
+ : [];
704
+
705
+ return [
706
+ <div
707
+ key={key}
708
+ className={UI7_FORM_VISUAL_CLASSNAMES.column}
709
+ style={detailWrapperStyle}
710
+ >
711
+ <DetailModalField
712
+ fieldKey={key}
713
+ label={field.label}
714
+ required={effectiveRequired}
715
+ helperText={field.helperText}
716
+ config={detail}
717
+ rows={rows}
718
+ mode={mode}
719
+ readOnly={effectiveReadonly}
720
+ disabled={disabled}
721
+ labels={labels}
722
+ invalidText={invalidText}
723
+ onChange={(newRows) =>
724
+ setValue(
725
+ field.key,
726
+ (newRows as unknown) as TData[keyof TData],
727
+ )
728
+ }
729
+ renderForm={(p) => (
730
+ <SchemaFormRenderer
731
+ mode={p.mode}
732
+ value={p.value}
733
+ sections={p.sections}
734
+ errors={p.errors}
735
+ disabled={p.disabled}
736
+ onChange={p.onChange}
737
+ density={density}
738
+ // Form baris mengisi lebar modal. Tanpa ini ia
739
+ // mewarisi batas two-thirds milik form induk dan
740
+ // menyisakan sepertiga modal kosong.
741
+ width="full"
742
+ />
743
+ )}
744
+ />
745
+ </div>,
746
+ ];
747
+ }
748
+
666
749
  // ── View / readonly ─────────────────────────────────────────
667
750
  if (effectiveReadonly) {
668
751
  return [
package/src/form-types.ts CHANGED
@@ -33,7 +33,7 @@ export type FieldValidation<TData extends Record<string, unknown>> = {
33
33
  export type FormFieldType =
34
34
  | "text" | "textarea" | "number" | "date" | "datetime"
35
35
  | "select" | "lookup" | "checkbox" | "toggle"
36
- | "detail-rows";
36
+ | "detail-rows" | "detail-modal";
37
37
 
38
38
  /**
39
39
  * Select option. `value` may be a number for numeric-coded columns (e.g.
@@ -114,6 +114,98 @@ export type DetailRowsConfig = {
114
114
  lockExistingRows?: boolean;
115
115
  };
116
116
 
117
+ /**
118
+ * Satu kolom pada tabel RINGKAS `detail-modal`.
119
+ *
120
+ * Sengaja BUKAN `EditableColumnDef`: kolom di sini hanya menampilkan, tidak
121
+ * pernah menyunting — penyuntingan seluruhnya terjadi di dalam modal. Memakai
122
+ * `EditableColumnDef` akan menuntut `columnType` untuk setiap kolom padahal
123
+ * tipe itu hanya menentukan editor sel yang tidak akan pernah dipakai.
124
+ */
125
+ export type DetailModalColumn = {
126
+ /** Nama properti pada baris. */
127
+ field: string;
128
+ header: ReactNode;
129
+ /** Lebar kolom dalam piksel. Tanpa ini kolom ikut lebar isi. */
130
+ width?: number;
131
+ /** `end` untuk angka. Default `start`. */
132
+ align?: "start" | "end";
133
+ /**
134
+ * Ubah nilai mentah jadi tampilan. Tanpa ini nilai kosong tampil sebagai
135
+ * em-dash (`labels.emptyValue`) dan sisanya lewat `String(value)`.
136
+ *
137
+ * ⚠️ Kolom ringkas menampilkan nilai SIMPAN, bukan label. Untuk field
138
+ * `select`, `String(value)` menghasilkan kodenya ("M"), bukan "Laki-laki" —
139
+ * pakai `format` bila yang harus terbaca operator adalah labelnya.
140
+ */
141
+ format?: (value: unknown, row: Record<string, unknown>) => ReactNode;
142
+ };
143
+
144
+ /**
145
+ * Detail-modal config — tabel RINGKAS + tombol Tambah yang membuka modal
146
+ * berisi form penuh untuk SATU baris.
147
+ *
148
+ * Bedanya dengan `detail-rows`: `detail-rows` menyunting DI DALAM sel, jadi
149
+ * setiap field baris harus muat sebagai satu kolom. Begitu satu baris punya
150
+ * belasan field (Beneficial Owner: ~20 field + dua blok alamat), barisnya jadi
151
+ * selebar beberapa layar dan tidak ada tempat untuk alur per-baris seperti cek
152
+ * identitas. `detail-modal` memindahkan seluruh form baris ke modal, dan
153
+ * menyisakan hanya beberapa kolom penanda di tabel.
154
+ *
155
+ * Nilai field di `key` harus berupa array objek biasa; renderer membaca dan
156
+ * menulis array itu secara utuh lewat `onChange({ [key]: newRows })` — sama
157
+ * seperti `detail-rows`.
158
+ *
159
+ * Isi modal dirender oleh `SchemaFormRenderer` yang sama dengan form induknya,
160
+ * jadi SEMUA tipe field yang sudah ada (lookup, date, numeric, toggle, bahkan
161
+ * `FormSection.render`) langsung bisa dipakai di dalam baris tanpa tambahan
162
+ * apa pun di sini.
163
+ */
164
+ export type DetailModalConfig = {
165
+ /**
166
+ * Form untuk SATU baris. Section-nya sama persis dengan section form biasa,
167
+ * dan `TData`-nya adalah barisnya — bukan form induk.
168
+ */
169
+ sections: FormSection<Record<string, unknown>>[];
170
+ /** Kolom yang tampil di tabel ringkas. Boleh sebagian kecil saja dari field baris. */
171
+ columns: DetailModalColumn[];
172
+ newRowFactory: () => Record<string, unknown>;
173
+ /**
174
+ * Aturan lintas-field untuk satu baris, dijalankan SETELAH validasi per-field
175
+ * milik `sections`. Kuncinya nama field; hasilnya menimpa error per-field.
176
+ *
177
+ * `index` adalah posisi baris — untuk baris baru, posisi tempat ia akan
178
+ * mendarat (`rows.length`).
179
+ */
180
+ validateRow?: (
181
+ row: Record<string, unknown>,
182
+ index: number,
183
+ ) => Record<string, string>;
184
+ /** Batas jumlah baris. Tombol Tambah nonaktif setelah tercapai. */
185
+ maxRows?: number;
186
+ /** Teks tombol tambah, mis. "Tambah Ahli Waris". Default: "Tambah". */
187
+ addLabel?: ReactNode;
188
+ /** Judul modal saat menambah. Default: "Tambah Data". */
189
+ addModalTitle?: ReactNode;
190
+ /** Judul modal saat mengubah. Default: "Ubah Data". */
191
+ editModalTitle?: ReactNode;
192
+ /** Teks saat tabel kosong. Default: "Belum ada data." */
193
+ emptyText?: ReactNode;
194
+ /** Lebar modal Carbon. Default `lg` — form baris biasanya padat. */
195
+ modalSize?: "xs" | "sm" | "md" | "lg";
196
+ /** Izinkan membuka baris yang sudah ada untuk diubah. Default `true`. */
197
+ allowEdit?: boolean;
198
+ /** Izinkan menghapus baris. Default `true`. */
199
+ allowDelete?: boolean;
200
+ /**
201
+ * Kunci React per baris. Default memakai indeks.
202
+ *
203
+ * Berguna bila baris punya identitas sendiri (mis. `person_id`) supaya state
204
+ * modal tidak nyangkut ke baris lain setelah penghapusan di tengah.
205
+ */
206
+ rowKey?: (row: Record<string, unknown>, index: number) => string;
207
+ };
208
+
117
209
  export type FormField<TData extends Record<string, unknown>> = {
118
210
  key: keyof TData;
119
211
  label: ReactNode;
@@ -146,6 +238,7 @@ export type FormField<TData extends Record<string, unknown>> = {
146
238
  numeric?: FormFieldNumericConfig<TData>;
147
239
  toggle?: FormFieldToggleConfig;
148
240
  detailRows?: DetailRowsConfig;
241
+ detailModal?: DetailModalConfig;
149
242
  format?: (value: TData[keyof TData], data: TData) => ReactNode;
150
243
  validation?: FieldValidation<TData>;
151
244
  };
package/src/i18n.tsx CHANGED
@@ -44,6 +44,16 @@ export type BosSharedLabels = {
44
44
  deleteTitle: string;
45
45
  deleteDescription: string;
46
46
  deleteConfirm: string;
47
+
48
+ /** Tipe field `detail-modal` — tabel ringkas + modal form per baris. */
49
+ detailAdd: string;
50
+ detailEdit: string;
51
+ detailDelete: string;
52
+ detailEmpty: string;
53
+ detailNo: string;
54
+ detailActions: string;
55
+ detailAddTitle: string;
56
+ detailEditTitle: string;
47
57
  };
48
58
 
49
59
  // ── Thin adapter — reads from @isi-ui7/i18n (wired in createShellProvider) ───
@@ -93,5 +103,14 @@ export function useBosSharedI18n(): BosSharedLabels {
93
103
  deleteTitle: t("bos7.deleteTitle", "Konfirmasi Hapus"),
94
104
  deleteDescription: t("bos7.deleteDescription", "Data berikut akan dihapus permanen."),
95
105
  deleteConfirm: t("bos7.deleteConfirm", "Hapus"),
106
+
107
+ detailAdd: t("bos7.detailAdd", "Tambah"),
108
+ detailEdit: t("bos7.detailEdit", "Ubah"),
109
+ detailDelete: t("bos7.detailDelete", "Hapus"),
110
+ detailEmpty: t("bos7.detailEmpty", "Belum ada data."),
111
+ detailNo: t("bos7.detailNo", "No"),
112
+ detailActions: t("bos7.detailActions", "Aksi"),
113
+ detailAddTitle: t("bos7.detailAddTitle", "Tambah Data"),
114
+ detailEditTitle: t("bos7.detailEditTitle", "Ubah Data"),
96
115
  };
97
116
  }