@isi-ui7/bos7-shared 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/form-renderer-utils.d.ts +22 -1
- package/dist/{index-DqWPV29B.js → index-C_114Ysm.js} +71 -71
- package/dist/{index.es-9cvhYbjn.js → index.es-B-MluT1E.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{jspdf.es.min-CCBzPHw4.js → jspdf.es.min-C5VEsLZd.js} +2 -2
- package/dist/{jspdf.plugin.autotable-DFaknRns.js → jspdf.plugin.autotable-DcntYaes.js} +22 -19
- package/dist/purify.es-CiEWEeUM.js +605 -0
- package/package.json +6 -6
- package/src/detail-rows-invalid-text.test.tsx +125 -0
- package/src/detail-rows-renders-existing.test.tsx +176 -0
- package/src/form-renderer-utils.ts +24 -1
- package/src/form-renderer.tsx +25 -0
- package/src/workflow/use-crud-form-hidden.test.tsx +62 -0
- package/src/workflow/use-crud-form.ts +1 -1
- package/dist/purify.es-Cm3utOpm.js +0 -560
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
3
|
+
import { afterEach } from "vitest";
|
|
4
|
+
import { SchemaFormRenderer } from "./form-renderer";
|
|
5
|
+
import type { FormSection } from "./form-types";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
type D = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
// Medan `detail-rows` NOL meneruskan `invalidText` sampai 2026-09-06, sementara
|
|
12
|
+
// saudaranya `detail-modal` SUDAH (detail-modal-field.tsx:277).
|
|
13
|
+
//
|
|
14
|
+
// 🔴 Akibatnya: sebuah aturan validasi yang keyed ke medan detail-rows
|
|
15
|
+
// DIHITUNG, MENAHAN SUBMIT (use-crud-form.ts:167 `if (Object.keys(errs).length)
|
|
16
|
+
// return`), lalu NOL PERNAH DITAMPILKAN. Petugas melihat spanduk generik
|
|
17
|
+
// "Check required fields" dan tabel yang bersih — pesannya sudah ditulis, sudah
|
|
18
|
+
// benar, dan invisible.
|
|
19
|
+
//
|
|
20
|
+
// Terukur pada Produk Pembiayaan (bos7-financing, errors.costs). Radius ledak
|
|
21
|
+
// lintas estate: 5 formulir yang benar-benar menyetel error pada kunci
|
|
22
|
+
// detail-rows (financing ×2 `costs`, treasury ×3 `items`/`items`/`mappings`);
|
|
23
|
+
// 12 dari 17 berkas ber-detail-rows lainnya nol menyetelnya dan karena itu nol
|
|
24
|
+
// dapat melihat perbedaan.
|
|
25
|
+
|
|
26
|
+
const PESAN = "Setiap baris biaya harus memilih Element Biaya";
|
|
27
|
+
|
|
28
|
+
const section: FormSection<D> = {
|
|
29
|
+
title: "Parameter Biaya",
|
|
30
|
+
fields: [
|
|
31
|
+
{
|
|
32
|
+
key: "costs",
|
|
33
|
+
label: "Daftar Biaya",
|
|
34
|
+
type: "detail-rows",
|
|
35
|
+
span: 12,
|
|
36
|
+
detailRows: {
|
|
37
|
+
columns: [{ key: "cost_element_id", label: "Element Biaya", type: "text" }],
|
|
38
|
+
newRowFactory: () => ({ cost_element_id: "" }),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// 🔴 Menghitung SEMUA [role=alert] mengukur populasi yang salah: Carbon's
|
|
45
|
+
// TextInput selalu merender <span class="cds--text-input__counter-alert"> yang
|
|
46
|
+
// KOSONG, jadi asersi "tepat satu alert" gagal karena alasan yang nol
|
|
47
|
+
// berhubungan. Terukur saat menulis berkas ini — uji "galat nol bocor" berbunyi
|
|
48
|
+
// 2 padahal cuma satu medan bergalat.
|
|
49
|
+
//
|
|
50
|
+
// Yang dihitung: alert yang BERISI teks. Itu populasi yang pertanyaannya
|
|
51
|
+
// maksud.
|
|
52
|
+
function alertsBerteks(root: HTMLElement) {
|
|
53
|
+
return Array.from(root.querySelectorAll('[role="alert"]')).filter(
|
|
54
|
+
(el) => (el.textContent ?? "").trim() !== "",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderWith(errors: Record<string, string>) {
|
|
59
|
+
return render(
|
|
60
|
+
<SchemaFormRenderer<D>
|
|
61
|
+
mode="create"
|
|
62
|
+
value={{ costs: [] }}
|
|
63
|
+
sections={[section]}
|
|
64
|
+
errors={errors}
|
|
65
|
+
onChange={() => {}}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("detail-rows menampilkan galat medannya", () => {
|
|
71
|
+
// ARAH 1 — merah sebelum perbaikan: pesannya nol pernah muncul.
|
|
72
|
+
it("error DISETEL ⇒ pesannya MUNCUL, dan isinya menyebut biaya", () => {
|
|
73
|
+
renderWith({ costs: PESAN });
|
|
74
|
+
const el = screen.getByText(PESAN);
|
|
75
|
+
expect(el).toBeTruthy();
|
|
76
|
+
// 🔴 Diasersi ISINYA, bukan "pesannya berubah". Generik yang diganti
|
|
77
|
+
// generik lain akan lulus asersi "bukan lagi Check required fields"
|
|
78
|
+
// sambil nol menutup AC-nya.
|
|
79
|
+
expect(el.textContent).toContain("baris biaya");
|
|
80
|
+
// Bentuk yang sama dgn DetailModalField: role=alert supaya pembaca layar
|
|
81
|
+
// mengumumkannya, bukan sekadar teks merah.
|
|
82
|
+
expect(el.getAttribute("role")).toBe("alert");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ARAH 2 — dan ini yang menjaga treasury: perubahan yang cuma dapat MENAMBAH
|
|
86
|
+
// nol dapat meregresi. Formulir yang nol menyetel error nol boleh melihat
|
|
87
|
+
// apa pun yang baru.
|
|
88
|
+
it("error KOSONG ⇒ NOL ada yang muncul, dan nol ada elemen alert", () => {
|
|
89
|
+
const { container } = renderWith({});
|
|
90
|
+
expect(screen.queryByText(PESAN)).toBeNull();
|
|
91
|
+
expect(alertsBerteks(container).length).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// KONTROL POSITIF utk instrumen: tabelnya memang dirender di kedua keadaan,
|
|
95
|
+
// jadi "nol ada alert" di atas berarti "nol ada galat", bukan "nol ada apa-apa".
|
|
96
|
+
it("KONTROL: tabelnya dirender di KEDUA keadaan", () => {
|
|
97
|
+
const a = renderWith({ costs: PESAN });
|
|
98
|
+
expect(a.container.querySelector("table")).toBeTruthy();
|
|
99
|
+
cleanup();
|
|
100
|
+
const b = renderWith({});
|
|
101
|
+
expect(b.container.querySelector("table")).toBeTruthy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Medan LAIN di section yang sama nol boleh ikut menampilkan galat medan ini.
|
|
105
|
+
it("galat detail-rows nol bocor ke medan lain", () => {
|
|
106
|
+
const dua: FormSection<D> = {
|
|
107
|
+
...section,
|
|
108
|
+
fields: [
|
|
109
|
+
...section.fields,
|
|
110
|
+
{ key: "catatan", label: "Catatan", type: "text", span: 6 },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<SchemaFormRenderer<D>
|
|
115
|
+
mode="create"
|
|
116
|
+
value={{ costs: [], catatan: "" }}
|
|
117
|
+
sections={[dua]}
|
|
118
|
+
errors={{ costs: PESAN }}
|
|
119
|
+
onChange={() => {}}
|
|
120
|
+
/>,
|
|
121
|
+
);
|
|
122
|
+
expect(alertsBerteks(container).length).toBe(1);
|
|
123
|
+
expect(alertsBerteks(container)[0].textContent).toBe(PESAN);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
3
|
+
import { afterEach } from "vitest";
|
|
4
|
+
import { SchemaFormRenderer } from "./form-renderer";
|
|
5
|
+
import type { FormSection } from "./form-types";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
type D = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
// Medan `detail-rows` MENAMPILKAN baris yang sudah ada — dan sampai berkas ini,
|
|
12
|
+
// NOL ADA satu uji pun di paket ini yang mengasersinya.
|
|
13
|
+
//
|
|
14
|
+
// ── Celah yang ditutup ───────────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// Satu-satunya uji yang pernah merender `detail-rows` dengan nilai TAK-KOSONG
|
|
17
|
+
// adalah `detail-modal-field.test.tsx:444` (`value={{ limits: [{kode:
|
|
18
|
+
// "CASH_IN"}] }}`), dan asersinya:
|
|
19
|
+
//
|
|
20
|
+
// getByText("Limit Transaksi") <- LABEL medannya
|
|
21
|
+
// getByRole("button", {name:"Tambah Ahli Waris"}) <- tombol medan LAIN
|
|
22
|
+
//
|
|
23
|
+
// `CASH_IN` sendiri nol pernah disentuh. Uji itu jujur — ia berjudul "tetap
|
|
24
|
+
// jalan berdampingan" dan memang mengukur koeksistensi. Yang salah adalah nol
|
|
25
|
+
// ada yang lain. `detail-rows-invalid-text.test.tsx` (2026-09-06) juga nol
|
|
26
|
+
// menutupnya: ia merender `value={{ costs: [] }}` — nol baris.
|
|
27
|
+
//
|
|
28
|
+
// ── Kenapa kelas ini mahal ───────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// form-renderer.tsx:623
|
|
31
|
+
//
|
|
32
|
+
// const rows = Array.isArray(rawValue) ? rawValue : [];
|
|
33
|
+
//
|
|
34
|
+
// Fallback `[]` itu DIAM. Dan setiap konsumen `detail-rows` di estate ini
|
|
35
|
+
// menulis dengan semantik GANTI-SEMUA — backend menghapus seluruh anak lalu
|
|
36
|
+
// menyisipkan ulang apa yang dikirim. Gabungannya:
|
|
37
|
+
//
|
|
38
|
+
// nilai non-array (atau nol ter-bind) ⇒ tabel render KOSONG
|
|
39
|
+
// ⇒ Save mengirim []
|
|
40
|
+
// ⇒ ganti-semua MENGHAPUS SEMUANYA
|
|
41
|
+
// ⇒ dan layarnya SUKSES
|
|
42
|
+
//
|
|
43
|
+
// ── Siapa yang dijaga berkas ini ─────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Uji yang nol menyebut konsumennya akan dihapus oleh orang yang mengira ia
|
|
46
|
+
// mengukur satu instans. Keempatnya:
|
|
47
|
+
//
|
|
48
|
+
// holidays bos7-enterprise calendar/_schema.ts (DeleteCalendarHolidays + Insert)
|
|
49
|
+
// costs bos7-financing produk pembiayaan (2 layar)
|
|
50
|
+
// items bos7-accounting controlling/budgets (DeleteBudgetItemsByBudget + Insert)
|
|
51
|
+
// bos7-treasury journal-template, pof-account-group
|
|
52
|
+
// mappings bos7-treasury pof
|
|
53
|
+
//
|
|
54
|
+
// ⚠️ `detail-modal` (form-renderer.tsx:737) punya fallback `[]` yang IDENTIK dan
|
|
55
|
+
// tetap tanpa penjaga. Di luar lingkup berkas ini — sengaja disebut, bukan
|
|
56
|
+
// diam-diam dilewati.
|
|
57
|
+
|
|
58
|
+
const BARIS = [
|
|
59
|
+
{ description: "Idul Fitri", holiday_type: "NATIONAL" },
|
|
60
|
+
{ description: "Natal", holiday_type: "NATIONAL" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const section: FormSection<D> = {
|
|
64
|
+
title: "Hari Libur",
|
|
65
|
+
fields: [
|
|
66
|
+
{
|
|
67
|
+
key: "holidays",
|
|
68
|
+
label: "Daftar Hari Libur",
|
|
69
|
+
type: "detail-rows",
|
|
70
|
+
span: 12,
|
|
71
|
+
detailRows: {
|
|
72
|
+
columns: [
|
|
73
|
+
{ field: "description", header: "Keterangan", columnType: { type: "text" } },
|
|
74
|
+
{ field: "holiday_type", header: "Tipe", columnType: { type: "text" } },
|
|
75
|
+
],
|
|
76
|
+
newRowFactory: () => ({ description: "", holiday_type: "" }),
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function renderRows(rows: unknown, mode: "edit" | "view" = "edit") {
|
|
83
|
+
return render(
|
|
84
|
+
<SchemaFormRenderer<D>
|
|
85
|
+
mode={mode}
|
|
86
|
+
value={{ holidays: rows }}
|
|
87
|
+
sections={[section]}
|
|
88
|
+
onChange={() => {}}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Sel-sel `<td>` sebuah tabel sebagai teks — populasi yang pertanyaannya maksud. */
|
|
94
|
+
function selTabel(root: HTMLElement): string[] {
|
|
95
|
+
return Array.from(root.querySelectorAll("tbody td")).map((td) =>
|
|
96
|
+
(td.textContent ?? "").trim(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("detail-rows menampilkan baris yang sudah ada", () => {
|
|
101
|
+
// ── ARAH 1 — yang merah kalau perendernya berhenti mem-bind nilainya.
|
|
102
|
+
//
|
|
103
|
+
// 🔴 Diasersi ISI SELNYA, bukan JUMLAH BARIS. "2 baris" dapat dipenuhi dua
|
|
104
|
+
// baris KOSONG — tepat kelas yang sama dengan uji lama yang mengasersi label,
|
|
105
|
+
// satu tingkat lebih halus.
|
|
106
|
+
it("dua baris terisi ⇒ isi tiap selnya muncul", () => {
|
|
107
|
+
const { container } = renderRows(BARIS);
|
|
108
|
+
|
|
109
|
+
// Nilai yang UNIK per baris: membuktikan KEDUANYA dirender, bukan satu
|
|
110
|
+
// baris yang kebetulan cocok dua kali.
|
|
111
|
+
expect(screen.getByText("Idul Fitri")).toBeTruthy();
|
|
112
|
+
expect(screen.getByText("Natal")).toBeTruthy();
|
|
113
|
+
|
|
114
|
+
// Kolom kedua sengaja bernilai SAMA di kedua baris — jadi ia hanya lulus
|
|
115
|
+
// kalau perendernya benar-benar mengulang per baris.
|
|
116
|
+
expect(screen.getAllByText("NATIONAL").length).toBe(2);
|
|
117
|
+
|
|
118
|
+
// Dan keduanya harus duduk di <tr> yang BERBEDA. Tanpa ini, sebuah
|
|
119
|
+
// perender yang menumpuk semua nilai di satu baris tetap lulus di atas.
|
|
120
|
+
const barisIdul = screen.getByText("Idul Fitri").closest("tr");
|
|
121
|
+
const barisNatal = screen.getByText("Natal").closest("tr");
|
|
122
|
+
expect(barisIdul).toBeTruthy();
|
|
123
|
+
expect(barisNatal).toBeTruthy();
|
|
124
|
+
expect(barisIdul).not.toBe(barisNatal);
|
|
125
|
+
|
|
126
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── ARAH 2 — KONTROL yang membedakan "menampilkan yang ada" dari
|
|
130
|
+
// "selalu menampilkan sesuatu".
|
|
131
|
+
//
|
|
132
|
+
// Tanpa arah ini, sebuah perender yang MENGARANG baris (mis. selalu
|
|
133
|
+
// menyisipkan satu baris kosong dari newRowFactory) akan lulus arah 1 dan
|
|
134
|
+
// nol pernah merah. Dan nol-baris BUKAN kegagalan — ia keadaan sah sebuah
|
|
135
|
+
// kalender tanpa hari libur.
|
|
136
|
+
it("KONTROL: nol baris ⇒ nol sel, dan itu BUKAN kegagalan", () => {
|
|
137
|
+
const { container } = renderRows([]);
|
|
138
|
+
expect(selTabel(container)).toEqual([]);
|
|
139
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
140
|
+
expect(screen.queryByText("Idul Fitri")).toBeNull();
|
|
141
|
+
// Tabelnya sendiri TETAP dirender — jadi "nol ada sel" di atas berarti
|
|
142
|
+
// "nol ada baris", bukan "medannya nol ada".
|
|
143
|
+
expect(container.querySelector("table")).toBeTruthy();
|
|
144
|
+
expect(screen.getByText("Keterangan")).toBeTruthy(); // header kolom
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── ARAH 3 — jalur yang mengubah fallback diam jadi TERUKUR.
|
|
148
|
+
//
|
|
149
|
+
// `Array.isArray(rawValue) ? rawValue : []` memperlakukan nilai non-array
|
|
150
|
+
// sebagai nol-baris tanpa satu galat pun. Itu perilaku yang DISENGAJA
|
|
151
|
+
// (perender nol boleh melempar atas data cacat), tapi ia harus punya uji —
|
|
152
|
+
// kalau suatu hari ia berubah jadi melempar, atau jadi merender sesuatu,
|
|
153
|
+
// seseorang harus melihatnya di sini.
|
|
154
|
+
it("nilai non-array diperlakukan sebagai nol-baris, tanpa melempar", () => {
|
|
155
|
+
for (const buruk of [undefined, null, "Idul Fitri", 42, { description: "Idul Fitri" }]) {
|
|
156
|
+
const { container } = renderRows(buruk);
|
|
157
|
+
expect(container.querySelector("table")).toBeTruthy();
|
|
158
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(0);
|
|
159
|
+
// 🔴 Ini kalimat yang harus dibaca orang berikutnya: string
|
|
160
|
+
// "Idul Fitri" dan objek ber-`description` TIDAK muncul. Digabung
|
|
161
|
+
// ganti-semua, keadaan ini = hapus senyap.
|
|
162
|
+
expect(screen.queryByText("Idul Fitri")).toBeNull();
|
|
163
|
+
cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── ARAH 4 — layar `view` memakai <EditableTable> yang SAMA (readOnly).
|
|
168
|
+
// Kalau binding-nya putus hanya di jalur readonly, checker melihat tabel
|
|
169
|
+
// kosong pada layar approval sementara datanya utuh.
|
|
170
|
+
it("mode view: baris tetap muncul (readOnly, bukan kosong)", () => {
|
|
171
|
+
const { container } = renderRows(BARIS, "view");
|
|
172
|
+
expect(screen.getByText("Idul Fitri")).toBeTruthy();
|
|
173
|
+
expect(screen.getByText("Natal")).toBeTruthy();
|
|
174
|
+
expect(container.querySelectorAll("tbody tr").length).toBe(2);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -155,13 +155,36 @@ export function ringkasGalatMedan<TData extends Record<string, unknown>>(
|
|
|
155
155
|
|
|
156
156
|
// ── Layout helpers ────────────────────────────────────────────────────────────
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Seluruh field yang BOLEH divalidasi — section `hidden` DILEWATI, sama seperti
|
|
160
|
+
* `collectPageFields`.
|
|
161
|
+
*
|
|
162
|
+
* 🔴 Dulu ia nol menyaring apa pun, bahkan `hidden: true` literal. Akibatnya
|
|
163
|
+
* `validateAll` (Save) menuntut field yang aplikasinya sendiri sembunyikan,
|
|
164
|
+
* sementara `validatePage` (Next) melewatinya dengan benar — jadi operator
|
|
165
|
+
* lolos di setiap halaman lalu ditolak di langkah terakhir, untuk field yang
|
|
166
|
+
* nol ada di layar mana pun dan nol bisa diisi gestur apa pun.
|
|
167
|
+
*
|
|
168
|
+
* `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya
|
|
169
|
+
* hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
|
|
170
|
+
* diperlakukan TIDAK tersembunyi, arah aman untuk validasi.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ Konsekuensi yang wajib dijaga di sisi SKEMA: kalau satu key punya dua
|
|
173
|
+
* salinan field (satu tampil di `create`, satu di `edit`), keduanya WAJIB
|
|
174
|
+
* membawa `validation`. Sebelum perbaikan ini, salinan tersembunyi ikut
|
|
175
|
+
* divalidasi sehingga satu salinan bervalidasi terasa cukup — ketergantungan
|
|
176
|
+
* terbalik yang kini nol berlaku lagi. Skema yang bersandar padanya akan
|
|
177
|
+
* kehilangan "wajib diisi" tanpa satu galat pun.
|
|
178
|
+
*/
|
|
158
179
|
export function collectAllFields<TData extends Record<string, unknown>>(
|
|
159
180
|
layout: FormLayout<TData>,
|
|
181
|
+
mode?: FormMode,
|
|
182
|
+
data?: TData,
|
|
160
183
|
): FormField<TData>[] {
|
|
161
184
|
const sections = layout.type === "single-page"
|
|
162
185
|
? layout.sections
|
|
163
186
|
: layout.pages.flatMap((p) => p.sections);
|
|
164
|
-
return sections.flatMap((s) => s.fields);
|
|
187
|
+
return sections.filter((s) => !isSectionHidden(s, mode, data)).flatMap((s) => s.fields);
|
|
165
188
|
}
|
|
166
189
|
|
|
167
190
|
/**
|
package/src/form-renderer.tsx
CHANGED
|
@@ -186,12 +186,21 @@ function FieldShell({
|
|
|
186
186
|
required,
|
|
187
187
|
children,
|
|
188
188
|
helperText,
|
|
189
|
+
invalidText,
|
|
189
190
|
htmlFor,
|
|
190
191
|
}: {
|
|
191
192
|
label: ReactNode;
|
|
192
193
|
required?: boolean;
|
|
193
194
|
children: ReactNode;
|
|
194
195
|
helperText?: ReactNode;
|
|
196
|
+
/**
|
|
197
|
+
* Galat tingkat-medan. Bentuknya SAMA PERSIS dgn DetailModalField
|
|
198
|
+
* (detail-modal-field.tsx:277) — role="alert", kelas helper, token warna
|
|
199
|
+
* galat, dirender DI ATAS helperText. Tipe medan itu SUDAH menampilkan
|
|
200
|
+
* galatnya dan `detail-rows` NOL; ini menyamakan, bukan menciptakan bentuk
|
|
201
|
+
* kedua.
|
|
202
|
+
*/
|
|
203
|
+
invalidText?: ReactNode;
|
|
195
204
|
htmlFor?: string;
|
|
196
205
|
}) {
|
|
197
206
|
const labelStyle = { color: "var(--cds-text-secondary, #525252)", display: "flex", alignItems: "center", gap: "0.25rem" } as const;
|
|
@@ -211,6 +220,15 @@ function FieldShell({
|
|
|
211
220
|
<div className={UI7_FORM_VISUAL_CLASSNAMES.label} style={labelStyle}>{labelContent}</div>
|
|
212
221
|
)}
|
|
213
222
|
{children}
|
|
223
|
+
{invalidText ? (
|
|
224
|
+
<div
|
|
225
|
+
role="alert"
|
|
226
|
+
className={UI7_FORM_VISUAL_CLASSNAMES.helper}
|
|
227
|
+
style={{ color: "var(--cds-text-error, #da1e28)" }}
|
|
228
|
+
>
|
|
229
|
+
{invalidText}
|
|
230
|
+
</div>
|
|
231
|
+
) : null}
|
|
214
232
|
{helperText ? (
|
|
215
233
|
<div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{helperText}</div>
|
|
216
234
|
) : null}
|
|
@@ -622,6 +640,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
622
640
|
label={field.label}
|
|
623
641
|
required={effectiveRequired}
|
|
624
642
|
helperText={field.helperText}
|
|
643
|
+
// 🔴 `detail-rows` NOL meneruskan ini sampai 2026-09-06, sementara
|
|
644
|
+
// saudaranya `detail-modal` SUDAH. Akibatnya aturan validasi yang
|
|
645
|
+
// keyed ke medan detail-rows DIHITUNG, MENAHAN SUBMIT, lalu NOL
|
|
646
|
+
// PERNAH DITAMPILKAN — petugas melihat spanduk generik "Check
|
|
647
|
+
// required fields" dan tabel yang bersih. Terukur pada Produk
|
|
648
|
+
// Pembiayaan (errors.costs, bos7-financing#200).
|
|
649
|
+
invalidText={invalidText}
|
|
625
650
|
>
|
|
626
651
|
<EditableTable
|
|
627
652
|
columns={detail.columns}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { useCrudForm } from "./use-crud-form";
|
|
4
|
+
import type { CrudForm } from "../form-types";
|
|
5
|
+
|
|
6
|
+
type D = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `validateAll` (Save) dulu memakai `collectAllFields` TANPA mode, jadi ia
|
|
10
|
+
* menuntut field di section TERSEMBUNYI — sementara `validatePage` (Next)
|
|
11
|
+
* melewatinya dengan benar. Operator lolos di tiap halaman lalu ditolak di
|
|
12
|
+
* langkah terakhir, untuk field yang nol ada di layarnya.
|
|
13
|
+
*
|
|
14
|
+
* Diuji lewat PERILAKU handleSave, bukan lewat collectAllFields langsung:
|
|
15
|
+
* uji atas helper tetap hijau meski hook nol memakainya.
|
|
16
|
+
*/
|
|
17
|
+
function schema(sembunyi: boolean | ((m: string, d: D) => boolean)): CrudForm<D> {
|
|
18
|
+
return {
|
|
19
|
+
title: { create: "c", edit: "e", view: "v" },
|
|
20
|
+
emptyData: { tampak: "", tersembunyi: "" } as D,
|
|
21
|
+
createApiPath: "/api/x",
|
|
22
|
+
layout: {
|
|
23
|
+
type: "single-page",
|
|
24
|
+
sections: [
|
|
25
|
+
{ title: "tampak", fields: [
|
|
26
|
+
{ key: "tampak", label: "Tampak", type: "text", span: 4, validation: { required: true } }] },
|
|
27
|
+
{ title: "rahasia", hidden: sembunyi as never, fields: [
|
|
28
|
+
{ key: "tersembunyi", label: "Tersembunyi", type: "text", span: 4, validation: { required: true } }] },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
} as unknown as CrudForm<D>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function galat(s: CrudForm<D>, isi: D) {
|
|
35
|
+
const { result } = renderHook(() => useCrudForm<D>({ schema: s, mode: "create" }));
|
|
36
|
+
act(() => { result.current.setForm(isi); });
|
|
37
|
+
await act(async () => { await result.current.handleSave(); });
|
|
38
|
+
return { pesan: String(result.current.error ?? ""), medan: result.current.fieldErrors as Record<string,string> };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("Save nol menuntut field di section tersembunyi", () => {
|
|
42
|
+
it("hidden literal: field tersembunyi NOL memblokir Save", async () => {
|
|
43
|
+
const r = await galat(schema(true), { tampak: "x", tersembunyi: "" });
|
|
44
|
+
expect(r.medan.tersembunyi, "field tersembunyi nol boleh dituntut").toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("hidden predikat: sama", async () => {
|
|
48
|
+
const r = await galat(schema(() => true), { tampak: "x", tersembunyi: "" });
|
|
49
|
+
expect(r.medan.tersembunyi).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("KONTROL POSITIF: section TERLIHAT tetap dituntut", async () => {
|
|
53
|
+
const r = await galat(schema(true), { tampak: "", tersembunyi: "" });
|
|
54
|
+
expect(r.medan.tampak, "penjaga masih menggigit utk yg terlihat").toBeTruthy();
|
|
55
|
+
expect(r.pesan).toContain("Tampak");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("KONTROL POSITIF: section yang TIDAK tersembunyi tetap dituntut", async () => {
|
|
59
|
+
const r = await galat(schema(false), { tampak: "x", tersembunyi: "" });
|
|
60
|
+
expect(r.medan.tersembunyi, "hidden=false ⇒ tetap divalidasi").toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -109,7 +109,7 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
109
109
|
};
|
|
110
110
|
|
|
111
111
|
const validateAll = () => {
|
|
112
|
-
const errs = runFieldValidation(collectAllFields(schema.layout), form, mode, labels);
|
|
112
|
+
const errs = runFieldValidation(collectAllFields(schema.layout, mode, form), form, mode, labels);
|
|
113
113
|
if (schema.validate) {
|
|
114
114
|
return { ...errs, ...schema.validate(form) };
|
|
115
115
|
}
|