@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.
- package/dist/detail-modal-field.d.ts +47 -0
- package/dist/form-types.d.ts +89 -1
- package/dist/i18n.d.ts +9 -0
- package/dist/{index-BI2MxWot.js → index-DqWPV29B.js} +10 -2
- package/dist/{index.es-D-PR3JkH.js → index.es-9cvhYbjn.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{jspdf.es.min-DzRwzM6G.js → jspdf.es.min-CCBzPHw4.js} +1 -1
- package/package.json +1 -1
- package/src/detail-modal-field.test.tsx +530 -0
- package/src/detail-modal-field.tsx +342 -0
- package/src/form-renderer.tsx +83 -0
- package/src/form-types.ts +94 -1
- package/src/i18n.tsx +19 -0
|
@@ -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;
|
package/dist/form-types.d.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
2
|
-
import { _ as Xa } from "./jspdf.es.min-
|
|
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-
|
|
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-
|
|
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
|
@@ -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
|
+
}
|
package/src/form-renderer.tsx
CHANGED
|
@@ -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
|
}
|