@isi-ui7/bos7-shared 0.4.2 โ 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/form-renderer-utils.d.ts +56 -0
- package/package.json +5 -5
- package/src/crud-client-error.test.ts +45 -1
- package/src/date-and-hidden-validation.test.ts +69 -1
- package/src/date-field-render.test.tsx +12 -0
- package/src/form-renderer-utils.ts +99 -0
- package/src/form-renderer.tsx +34 -1
- package/src/spanduk-galat-medan.test.ts +44 -0
- package/src/workflow/crud-client.ts +4 -4
- package/src/workflow/use-crud-form-spanduk.test.tsx +66 -0
- package/src/workflow/use-crud-form.ts +7 -1
|
@@ -4,6 +4,24 @@ import { BosSharedLabels } from './i18n';
|
|
|
4
4
|
export declare function resolveApiPath(path: ApiPath, params: Record<string, string>): string;
|
|
5
5
|
type ValidationI18n = Pick<BosSharedLabels, "validRequired" | "validMinLength" | "validMaxLength" | "validMin" | "validMax" | "validPattern">;
|
|
6
6
|
export declare function runFieldValidation<TData extends Record<string, unknown>>(fields: FormField<TData>[], data: TData, mode: FormMode, i18n?: Partial<ValidationI18n>): Partial<Record<keyof TData, string>>;
|
|
7
|
+
/**
|
|
8
|
+
* Merangkum `fieldErrors` jadi satu kalimat yang MENYEBUT medannya.
|
|
9
|
+
*
|
|
10
|
+
* ๐ Ada karena daftar medannya SUDAH dihitung dan nol pernah sampai ke layar.
|
|
11
|
+
* Spanduk lama satu string tetap ("Periksa field wajib") untuk seluruh galat,
|
|
12
|
+
* jadi "medan kosong" dan "medan terisi tapi format salah" terbaca identik โ
|
|
13
|
+
* dan medan yang nol punya field ter-render terbaca sama pula.
|
|
14
|
+
*
|
|
15
|
+
* Ongkosnya terukur: dua medan (`cif_number`, `branch_code`) menahan create
|
|
16
|
+
* Nasabah Perorangan 21 hari (2026-08-15 s/d 09-05). Keduanya duduk di
|
|
17
|
+
* `fieldErrors` sepanjang waktu itu. Satu penyelidikan penuh dihabiskan untuk
|
|
18
|
+
* mencari nama yang aplikasinya sudah tahu sejak detik pertama.
|
|
19
|
+
*
|
|
20
|
+
* Label dipakai bila ada; kalau nol ada, JATUH ke kuncinya โ jangan sel kosong
|
|
21
|
+
* dan jangan sembunyikan barisnya, karena medan tanpa label justru yang paling
|
|
22
|
+
* mungkin jadi medan yatim seperti di atas.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ringkasGalatMedan<TData extends Record<string, unknown>>(fieldErrors: Partial<Record<keyof TData, string>>, fields: FormField<TData>[], maks?: number): string | null;
|
|
7
25
|
export declare function collectAllFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>): FormField<TData>[];
|
|
8
26
|
/**
|
|
9
27
|
* Field yang BOLEH divalidasi pada satu halaman.
|
|
@@ -42,4 +60,42 @@ export declare function toLocalISODate(date: Date | undefined | null): string;
|
|
|
42
60
|
* menghasilkan tanggal lain sama sekali (laporan DG-00, agent7 2026-08-31).
|
|
43
61
|
*/
|
|
44
62
|
export declare function fromLocalISODate(iso: string | undefined | null): Date | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Mem-parse tanggal yang DIKETIK operator dalam format TAMPILAN "d-m-Y"
|
|
65
|
+
* ("15-06-1990", juga menerima "5-6-1990") jadi `Date` lokal.
|
|
66
|
+
*
|
|
67
|
+
* ๐ Kenapa ini ada, dan kenapa ia TIDAK memakai flatpickr.
|
|
68
|
+
*
|
|
69
|
+
* Tanggal yang diketik tangan nol pernah masuk state form. Layar menunjukkan
|
|
70
|
+
* tanggalnya, tapi validasi berkata "is required" โ cacat yang paling
|
|
71
|
+
* menyesatkan yang bisa dialami operator, karena nol ada yang terlihat salah.
|
|
72
|
+
*
|
|
73
|
+
* Diukur di browser sungguhan (DG-00, cif.ibos7.com 2026-08-31), commit-nya
|
|
74
|
+
* BERBEDA menurut gestur yang mengakhiri ketikan:
|
|
75
|
+
*
|
|
76
|
+
* gestur flatpickr.selectedDates state React
|
|
77
|
+
* Tab [] kosong RUSAK
|
|
78
|
+
* Enter terisi terisi benar
|
|
79
|
+
* klik di luar terisi kosong RUSAK
|
|
80
|
+
*
|
|
81
|
+
* Sesudah Tab, `fp.isOpen` tetap `true` dan fokus lompat ke elemen di dalam
|
|
82
|
+
* kalender โ jadi jalur "parse saat close" milik flatpickr nol pernah
|
|
83
|
+
* tersentuh. Pada klik-di-luar flatpickr BERHASIL mem-parse tapi onChange-nya
|
|
84
|
+
* nol sampai ke React: flatpickr dan React memegang dua kebenaran berbeda.
|
|
85
|
+
*
|
|
86
|
+
* ๐ด Maka commit-nya nol boleh bergantung pada flatpickr. Kita mem-parse
|
|
87
|
+
* sendiri saat `blur`, dan itu berlaku untuk KETIGA gestur โ termasuk yang
|
|
88
|
+
* belum kita pahami sepenuhnya. Bergantung pada perilaku yang sudah terbukti
|
|
89
|
+
* berbeda antar gestur berarti memperbaiki gestur yang kebetulan diuji saja.
|
|
90
|
+
*
|
|
91
|
+
* โ ๏ธ jsdom NOL bisa mereproduksi cacat ini: di sana `fireEvent.blur` memang
|
|
92
|
+
* meng-commit, pada kode lama maupun baru. Uji fungsi ini apa adanya, dan
|
|
93
|
+
* jangan menyimpulkan dari tes komponen bahwa cacatnya tuntas.
|
|
94
|
+
*
|
|
95
|
+
* Mengembalikan `undefined` untuk teks yang bukan tanggal utuh โ termasuk
|
|
96
|
+
* ketikan separuh jalan ("15-06") dan tanggal yang nol ada ("31-02-1990").
|
|
97
|
+
* Nol menebak: teks yang nol terbaca dibiarkan apa adanya, dan validasi
|
|
98
|
+
* "wajib diisi" yang bicara.
|
|
99
|
+
*/
|
|
100
|
+
export declare function parseDisplayDate(teks: string | undefined | null): Date | undefined;
|
|
45
101
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isi-ui7/bos7-shared",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Shared auth7 and layout primitives for bos7 applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"@carbon/icons-react": "^11.71.0",
|
|
72
72
|
"@carbon/react": "^1.97.0",
|
|
73
|
-
"@isi-ui7/corporate-themes": ">=0.2.
|
|
73
|
+
"@isi-ui7/corporate-themes": ">=0.2.14",
|
|
74
74
|
"@isi-ui7/data-table": ">=0.2.5",
|
|
75
75
|
"@isi-ui7/editable-table": ">=0.2.3",
|
|
76
76
|
"@isi-ui7/i18n": ">=0.2.3",
|
|
@@ -106,14 +106,14 @@
|
|
|
106
106
|
"vite": "^5.0.0",
|
|
107
107
|
"vite-plugin-dts": "^3.6.0",
|
|
108
108
|
"vitest": "^1.0.0",
|
|
109
|
-
"@isi-ui7/corporate-themes": "0.2.12",
|
|
110
109
|
"@isi-ui7/data-table": "0.2.5",
|
|
111
110
|
"@isi-ui7/editable-table": "0.2.3",
|
|
112
111
|
"@isi-ui7/i18n": "0.2.3",
|
|
112
|
+
"@isi-ui7/corporate-themes": "0.2.14",
|
|
113
113
|
"@isi-ui7/lookup-input": "0.2.4",
|
|
114
114
|
"@isi-ui7/modal-manager": "0.2.2",
|
|
115
|
-
"@isi-ui7/
|
|
116
|
-
"@isi-ui7/
|
|
115
|
+
"@isi-ui7/ui-shell": "0.2.8",
|
|
116
|
+
"@isi-ui7/realtime": "0.2.3"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
|
119
119
|
"@carbon/icons-react": "^11.71.0",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
import { fetchDetailData } from './workflow/crud-client';
|
|
2
|
+
import { fetchDetailData, submitWorkflowForm, submitWorkflowDelete } from './workflow/crud-client';
|
|
3
3
|
|
|
4
4
|
// Bentuk error yang BENAR-BENAR dikirim service Go di estate ini. Menyerahkan
|
|
5
5
|
// objek langsung ke `new Error()` menghasilkan pesan literal "[object Object]",
|
|
@@ -51,3 +51,47 @@ describe('fetchDetailData โ pesan error', () => {
|
|
|
51
51
|
await expect(fetchDetailData<{ id: number }>('/x')).resolves.toEqual({ id: 7, name: 'ok' });
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
// submitWorkflowForm/submitWorkflowDelete โ sebelum perbaikan ini (2026-08-31,
|
|
56
|
+
// ditemukan claim-verifier + PM saat audit FT-292/293), kedua fungsi ini
|
|
57
|
+
// mengetik `error?: string` dan menyerahkan `body.error` LANGSUNG ke
|
|
58
|
+
// `new Error()`, melewati `errorMessage` sama sekali โ beda dari
|
|
59
|
+
// `fetchDetailData` yang sudah benar. Akibatnya saat backend genuinely
|
|
60
|
+
// menolak dengan bentuk objek `{code, message}` (bentuk NYATA service Go di
|
|
61
|
+
// estate ini, lihat komentar `errorMessage`), operator melihat literal
|
|
62
|
+
// "[object Object]", bukan alasannya.
|
|
63
|
+
describe('submitWorkflowForm โ pesan error (cermin fetchDetailData)', () => {
|
|
64
|
+
it('membuka error berbentuk OBJEK {code, message} โ bukan "[object Object]"', async () => {
|
|
65
|
+
mockRes(403, { error: { code: 'FORBIDDEN', message: 'access denied' } });
|
|
66
|
+
await expect(submitWorkflowForm('/x', 'POST', {})).rejects.toThrow('access denied (FORBIDDEN)');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('tetap menerima error berbentuk string', async () => {
|
|
70
|
+
mockRes(400, { error: 'validation error: tujuan transaksi belum diputuskan' });
|
|
71
|
+
await expect(submitWorkflowForm('/x', 'POST', {})).rejects.toThrow(
|
|
72
|
+
'validation error: tujuan transaksi belum diputuskan',
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('jatuh ke pesan HTTP ketika body tidak memuat error yang bisa dibaca', async () => {
|
|
77
|
+
mockRes(502, {});
|
|
78
|
+
await expect(submitWorkflowForm('/x', 'PUT', {})).rejects.toThrow('Gagal (HTTP 502)');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('sukses (202 + instance_id) tetap mengembalikan instance_id', async () => {
|
|
82
|
+
mockRes(202, { instance_id: 'wf-123' });
|
|
83
|
+
await expect(submitWorkflowForm('/x', 'POST', {})).resolves.toBe('wf-123');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('submitWorkflowDelete โ pesan error (cermin fetchDetailData)', () => {
|
|
88
|
+
it('membuka error berbentuk OBJEK {code, message} โ bukan "[object Object]"', async () => {
|
|
89
|
+
mockRes(409, { error: { code: 'CONFLICT', message: 'sudah tidak aktif' } });
|
|
90
|
+
await expect(submitWorkflowDelete('/x')).rejects.toThrow('sudah tidak aktif (CONFLICT)');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sukses (202 + instance_id) tetap mengembalikan instance_id', async () => {
|
|
94
|
+
mockRes(202, { instance_id: 'wf-456' });
|
|
95
|
+
await expect(submitWorkflowDelete('/x')).resolves.toBe('wf-456');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { toLocalISODate, fromLocalISODate, collectPageFields, runFieldValidation } from "./form-renderer-utils";
|
|
2
|
+
import { toLocalISODate, fromLocalISODate, parseDisplayDate, collectPageFields, runFieldValidation } from "./form-renderer-utils";
|
|
3
3
|
import { Rules } from "./form-rules";
|
|
4
4
|
import type { FormLayout, FormSection } from "./form-types";
|
|
5
5
|
|
|
@@ -180,3 +180,71 @@ describe("collectPageFields menghormati section.hidden", () => {
|
|
|
180
180
|
expect(collectPageFields(l, 0).map((f) => String(f.key))).toEqual(["a"]);
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
185
|
+
// parseDisplayDate โ commit tanggal yang DIKETIK tangan (Cacat A)
|
|
186
|
+
//
|
|
187
|
+
// โ ๏ธ Tes di berkas ini menguji FUNGSINYA, bukan cacatnya. jsdom NOL bisa
|
|
188
|
+
// mereproduksi Cacat A: di sana `fireEvent.blur` sudah meng-commit pada kode
|
|
189
|
+
// lama maupun baru (dicatat DG-05 di date-field-render.test.tsx, dan saya
|
|
190
|
+
// konfirmasi). Cacatnya direproduksi di BROWSER sungguhan โ dgn Tab,
|
|
191
|
+
// flatpickr nol pernah mem-parse teksnya dan state tetap kosong.
|
|
192
|
+
//
|
|
193
|
+
// Maka: jangan menyimpulkan dari hijaunya berkas ini bahwa Cacat A tuntas.
|
|
194
|
+
// Buktinya ada di larian browser (DG-00, 2026-08-31: Tab ๐ดโโ
).
|
|
195
|
+
describe("parseDisplayDate โ tanggal yang diketik operator", () => {
|
|
196
|
+
it("membaca format tampilan d-m-Y", () => {
|
|
197
|
+
expect(toLocalISODate(parseDisplayDate("15-06-1990"))).toBe("1990-06-15");
|
|
198
|
+
expect(toLocalISODate(parseDisplayDate("31-12-2026"))).toBe("2026-12-31");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("menerima satu digit โ operator nol selalu menulis nol di depan", () => {
|
|
202
|
+
expect(toLocalISODate(parseDisplayDate("5-6-1990"))).toBe("1990-06-05");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("mengabaikan spasi di tepi", () => {
|
|
206
|
+
expect(toLocalISODate(parseDisplayDate(" 15-06-1990 "))).toBe("1990-06-15");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("MENOLAK tanggal yang nol ada, bukan menggulungnya", () => {
|
|
210
|
+
// Konstruktor Date diam-diam mengubah 31-02-1990 jadi 3 Maret. Menyimpan
|
|
211
|
+
// tanggal yang nol pernah diketik siapa pun jauh lebih buruk daripada
|
|
212
|
+
// menolaknya.
|
|
213
|
+
expect(parseDisplayDate("31-02-1990")).toBeUndefined();
|
|
214
|
+
expect(parseDisplayDate("32-01-1990")).toBeUndefined();
|
|
215
|
+
expect(parseDisplayDate("15-13-1990")).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("kontrol: 29-02 diterima di tahun KABISAT, ditolak di tahun biasa", () => {
|
|
219
|
+
expect(toLocalISODate(parseDisplayDate("29-02-2024"))).toBe("2024-02-29");
|
|
220
|
+
expect(parseDisplayDate("29-02-2025")).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("MENOLAK ketikan separuh jalan dan teks yang bukan tanggal", () => {
|
|
224
|
+
for (const teks of ["", " ", "15", "15-06", "abc", "1990-06-15", "15/06/1990"]) {
|
|
225
|
+
expect(parseDisplayDate(teks)).toBeUndefined();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("MENOLAK tanggal sah yang terbenam di teks lain", () => {
|
|
230
|
+
// Kasus ini ada karena sabotase: membuang jangkar ^$ dari regex nol
|
|
231
|
+
// membuat satu pun tes di atas merah. Tanpa jangkar, "115-06-1990"
|
|
232
|
+
// diam-diam terbaca sbg 15-06-1990 โ angka yang nol pernah diketik
|
|
233
|
+
// operator, tersimpan tanpa satu pun gejala.
|
|
234
|
+
for (const teks of ["115-06-1990", "15-06-1990x", "a15-06-1990", "15-06-19900"]) {
|
|
235
|
+
expect(parseDisplayDate(teks)).toBeUndefined();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("kontrol: nilai kosong/null nol melempar", () => {
|
|
240
|
+
expect(parseDisplayDate(undefined)).toBeUndefined();
|
|
241
|
+
expect(parseDisplayDate(null)).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("tanggal lokal, BUKAN UTC โ nol bergeser sehari di zona timur", () => {
|
|
245
|
+
const d = parseDisplayDate("01-01-1990")!;
|
|
246
|
+
expect(d.getFullYear()).toBe(1990);
|
|
247
|
+
expect(d.getMonth()).toBe(0);
|
|
248
|
+
expect(d.getDate()).toBe(1);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -23,6 +23,18 @@ type D = Record<string, unknown>;
|
|
|
23
23
|
// tuntas di halaman produksi (mungkin bergantung interaksi re-render field
|
|
24
24
|
// lain yg tak tercakup harness minimal ini). Perlu re-verifikasi Tester/
|
|
25
25
|
// DG-00 langsung di browser thd kode yg sudah diperbaiki.
|
|
26
|
+
//
|
|
27
|
+
// TINDAK LANJUT (DG-00, 2026-08-31): sudah diverifikasi di browser sungguhan,
|
|
28
|
+
// dan Cacat A TERBUKTI ADA bahkan sesudah #152/#154. Catatan DG-05 di atas
|
|
29
|
+
// benar sepenuhnya: jsdom nol bisa mereproduksinya. Harness browser (Vite +
|
|
30
|
+
// Playwright, di luar repo) mereproduksi kegagalan Tab persis seperti di
|
|
31
|
+
// cif.ibos7.com โ `fp.selectedDates` kosong, `fp.isOpen` tetap true, state
|
|
32
|
+
// kosong. Perbaikannya NOL bergantung pada flatpickr: commit dilakukan
|
|
33
|
+
// sendiri saat `blur` lewat `parseDisplayDate()`. Tab ๐ดโโ
terukur.
|
|
34
|
+
//
|
|
35
|
+
// ๐ Tes DI BERKAS INI tetap hijau sebelum maupun sesudah perbaikan. Ia
|
|
36
|
+
// penjaga alur commit, BUKAN reproduksi cacatnya. Jangan pakai hijaunya
|
|
37
|
+
// sbg bukti Cacat A tuntas โ buktinya ada di larian browser.
|
|
26
38
|
|
|
27
39
|
const dateSection: FormSection<D> = {
|
|
28
40
|
title: "Tanggal",
|
|
@@ -113,6 +113,46 @@ export function runFieldValidation<TData extends Record<string, unknown>>(
|
|
|
113
113
|
return errors;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Merangkum `fieldErrors` jadi satu kalimat yang MENYEBUT medannya.
|
|
118
|
+
*
|
|
119
|
+
* ๐ Ada karena daftar medannya SUDAH dihitung dan nol pernah sampai ke layar.
|
|
120
|
+
* Spanduk lama satu string tetap ("Periksa field wajib") untuk seluruh galat,
|
|
121
|
+
* jadi "medan kosong" dan "medan terisi tapi format salah" terbaca identik โ
|
|
122
|
+
* dan medan yang nol punya field ter-render terbaca sama pula.
|
|
123
|
+
*
|
|
124
|
+
* Ongkosnya terukur: dua medan (`cif_number`, `branch_code`) menahan create
|
|
125
|
+
* Nasabah Perorangan 21 hari (2026-08-15 s/d 09-05). Keduanya duduk di
|
|
126
|
+
* `fieldErrors` sepanjang waktu itu. Satu penyelidikan penuh dihabiskan untuk
|
|
127
|
+
* mencari nama yang aplikasinya sudah tahu sejak detik pertama.
|
|
128
|
+
*
|
|
129
|
+
* Label dipakai bila ada; kalau nol ada, JATUH ke kuncinya โ jangan sel kosong
|
|
130
|
+
* dan jangan sembunyikan barisnya, karena medan tanpa label justru yang paling
|
|
131
|
+
* mungkin jadi medan yatim seperti di atas.
|
|
132
|
+
*/
|
|
133
|
+
export function ringkasGalatMedan<TData extends Record<string, unknown>>(
|
|
134
|
+
fieldErrors: Partial<Record<keyof TData, string>>,
|
|
135
|
+
fields: FormField<TData>[],
|
|
136
|
+
maks = 5,
|
|
137
|
+
): string | null {
|
|
138
|
+
const kunci = Object.keys(fieldErrors).filter((k) => fieldErrors[k as keyof TData]);
|
|
139
|
+
if (kunci.length === 0) return null;
|
|
140
|
+
|
|
141
|
+
const label = new Map<string, string>();
|
|
142
|
+
for (const f of fields) {
|
|
143
|
+
const k = String(f.key);
|
|
144
|
+
const l = typeof f.label === "string" ? f.label.trim() : "";
|
|
145
|
+
if (l && !label.has(k)) label.set(k, l);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const butir = kunci.slice(0, maks).map((k) => {
|
|
149
|
+
const nama = label.get(k) || k; // JATUH ke kunci, bukan kosong
|
|
150
|
+
return `${nama}: ${fieldErrors[k as keyof TData]}`;
|
|
151
|
+
});
|
|
152
|
+
const sisa = kunci.length - butir.length;
|
|
153
|
+
return butir.join(" ยท ") + (sisa > 0 ? ` ยท +${sisa} lagi` : "");
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
// โโ Layout helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
117
157
|
|
|
118
158
|
export function collectAllFields<TData extends Record<string, unknown>>(
|
|
@@ -219,3 +259,62 @@ export function fromLocalISODate(iso: string | undefined | null): Date | undefin
|
|
|
219
259
|
const date = new Date(Number(y), Number(mo) - 1, Number(d));
|
|
220
260
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
221
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Mem-parse tanggal yang DIKETIK operator dalam format TAMPILAN "d-m-Y"
|
|
265
|
+
* ("15-06-1990", juga menerima "5-6-1990") jadi `Date` lokal.
|
|
266
|
+
*
|
|
267
|
+
* ๐ Kenapa ini ada, dan kenapa ia TIDAK memakai flatpickr.
|
|
268
|
+
*
|
|
269
|
+
* Tanggal yang diketik tangan nol pernah masuk state form. Layar menunjukkan
|
|
270
|
+
* tanggalnya, tapi validasi berkata "is required" โ cacat yang paling
|
|
271
|
+
* menyesatkan yang bisa dialami operator, karena nol ada yang terlihat salah.
|
|
272
|
+
*
|
|
273
|
+
* Diukur di browser sungguhan (DG-00, cif.ibos7.com 2026-08-31), commit-nya
|
|
274
|
+
* BERBEDA menurut gestur yang mengakhiri ketikan:
|
|
275
|
+
*
|
|
276
|
+
* gestur flatpickr.selectedDates state React
|
|
277
|
+
* Tab [] kosong RUSAK
|
|
278
|
+
* Enter terisi terisi benar
|
|
279
|
+
* klik di luar terisi kosong RUSAK
|
|
280
|
+
*
|
|
281
|
+
* Sesudah Tab, `fp.isOpen` tetap `true` dan fokus lompat ke elemen di dalam
|
|
282
|
+
* kalender โ jadi jalur "parse saat close" milik flatpickr nol pernah
|
|
283
|
+
* tersentuh. Pada klik-di-luar flatpickr BERHASIL mem-parse tapi onChange-nya
|
|
284
|
+
* nol sampai ke React: flatpickr dan React memegang dua kebenaran berbeda.
|
|
285
|
+
*
|
|
286
|
+
* ๐ด Maka commit-nya nol boleh bergantung pada flatpickr. Kita mem-parse
|
|
287
|
+
* sendiri saat `blur`, dan itu berlaku untuk KETIGA gestur โ termasuk yang
|
|
288
|
+
* belum kita pahami sepenuhnya. Bergantung pada perilaku yang sudah terbukti
|
|
289
|
+
* berbeda antar gestur berarti memperbaiki gestur yang kebetulan diuji saja.
|
|
290
|
+
*
|
|
291
|
+
* โ ๏ธ jsdom NOL bisa mereproduksi cacat ini: di sana `fireEvent.blur` memang
|
|
292
|
+
* meng-commit, pada kode lama maupun baru. Uji fungsi ini apa adanya, dan
|
|
293
|
+
* jangan menyimpulkan dari tes komponen bahwa cacatnya tuntas.
|
|
294
|
+
*
|
|
295
|
+
* Mengembalikan `undefined` untuk teks yang bukan tanggal utuh โ termasuk
|
|
296
|
+
* ketikan separuh jalan ("15-06") dan tanggal yang nol ada ("31-02-1990").
|
|
297
|
+
* Nol menebak: teks yang nol terbaca dibiarkan apa adanya, dan validasi
|
|
298
|
+
* "wajib diisi" yang bicara.
|
|
299
|
+
*/
|
|
300
|
+
export function parseDisplayDate(teks: string | undefined | null): Date | undefined {
|
|
301
|
+
const s = String(teks ?? "").trim();
|
|
302
|
+
const m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(s);
|
|
303
|
+
if (!m) return undefined;
|
|
304
|
+
const [, d, mo, y] = m;
|
|
305
|
+
const hari = Number(d);
|
|
306
|
+
const bulan = Number(mo);
|
|
307
|
+
const tahun = Number(y);
|
|
308
|
+
const date = new Date(tahun, bulan - 1, hari);
|
|
309
|
+
// Konstruktor Date MENGGULUNG nilai di luar batas: 31-02-1990 diam-diam
|
|
310
|
+
// jadi 3 Maret. Membaca ulang komponennya menolak tanggal yang nol ada,
|
|
311
|
+
// alih-alih menyimpan tanggal LAIN yang nol pernah diketik siapa pun.
|
|
312
|
+
if (
|
|
313
|
+
date.getFullYear() !== tahun ||
|
|
314
|
+
date.getMonth() !== bulan - 1 ||
|
|
315
|
+
date.getDate() !== hari
|
|
316
|
+
) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return date;
|
|
320
|
+
}
|
package/src/form-renderer.tsx
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
34
34
|
import { useBosSharedI18n } from "./i18n";
|
|
35
35
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
36
|
-
import { toLocalISODate, fromLocalISODate } from "./form-renderer-utils";
|
|
36
|
+
import { toLocalISODate, fromLocalISODate, parseDisplayDate } from "./form-renderer-utils";
|
|
37
37
|
import { DetailModalField } from "./detail-modal-field";
|
|
38
38
|
|
|
39
39
|
export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
@@ -866,6 +866,39 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
866
866
|
invalid={Boolean(invalidText)}
|
|
867
867
|
invalidText={invalidText}
|
|
868
868
|
aria-required={effectiveRequired ? true : undefined}
|
|
869
|
+
// ๐ Commit ketikan tangan TIDAK boleh diserahkan ke
|
|
870
|
+
// flatpickr. Diukur di browser sungguhan (DG-00,
|
|
871
|
+
// cif.ibos7.com 2026-08-31): dgn Tab, flatpickr NOL
|
|
872
|
+
// pernah mem-parse teksnya (`selectedDates` kosong,
|
|
873
|
+
// `isOpen` tetap true โ kalendernya masih terbuka,
|
|
874
|
+
// jadi jalur "parse saat close" nol tersentuh); dgn
|
|
875
|
+
// klik di luar ia mem-parse tapi onChange-nya nol
|
|
876
|
+
// sampai ke React. Hanya Enter yang benar.
|
|
877
|
+
//
|
|
878
|
+
// Akibatnya tanggal terlihat di layar sementara state
|
|
879
|
+
// kosong, dan validasi berkata "is required" di atas
|
|
880
|
+
// kotak yang jelas berisi tanggal.
|
|
881
|
+
//
|
|
882
|
+
// `blur` berlaku untuk KETIGA gestur, jadi commit
|
|
883
|
+
// dilakukan di sini โ bukan menambal satu gestur.
|
|
884
|
+
onBlur={(e) => {
|
|
885
|
+
const teks = (e.target as HTMLInputElement).value;
|
|
886
|
+
const kini = asString(rawValue);
|
|
887
|
+
if (teks.trim() === "") {
|
|
888
|
+
// Mengosongkan kotak lalu keluar = menghapus
|
|
889
|
+
// nilainya. Tapi blur juga terjadi saat fokus
|
|
890
|
+
// pindah KE kalender, jadi jangan menulis kalau
|
|
891
|
+
// memang sudah kosong.
|
|
892
|
+
if (kini !== "") setValue(field.key, "" as TData[keyof TData]);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const d = parseDisplayDate(teks);
|
|
896
|
+
// Teks yang nol terbaca dibiarkan: nol menebak, dan
|
|
897
|
+
// validasi "wajib diisi" yang bicara.
|
|
898
|
+
if (!d) return;
|
|
899
|
+
const iso = toLocalISODate(d);
|
|
900
|
+
if (iso !== kini) setValue(field.key, iso as TData[keyof TData]);
|
|
901
|
+
}}
|
|
869
902
|
/>
|
|
870
903
|
</DatePicker>
|
|
871
904
|
{field.helperText ? (
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ringkasGalatMedan } from "./form-renderer-utils";
|
|
3
|
+
import type { FormField } from "./form-types";
|
|
4
|
+
|
|
5
|
+
type D = Record<string, unknown>;
|
|
6
|
+
const f = (key: string, label?: string) => ({ key, label, type: "text" } as unknown as FormField<D>);
|
|
7
|
+
|
|
8
|
+
describe("spanduk galat menyebut MEDANNYA", () => {
|
|
9
|
+
it("menyebut label + alasan, bukan satu kalimat tetap", () => {
|
|
10
|
+
const s = ringkasGalatMedan<D>(
|
|
11
|
+
{ identity_number: "NIK harus 16 digit angka", birth_date: "Tanggal Lahir wajib diisi" },
|
|
12
|
+
[f("identity_number", "NIK"), f("birth_date", "Tanggal Lahir")],
|
|
13
|
+
);
|
|
14
|
+
expect(s).toContain("NIK");
|
|
15
|
+
expect(s).toContain("16 digit");
|
|
16
|
+
expect(s).toContain("Tanggal Lahir");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("PEMANCAR: nol ada galat -> spanduk DIAM (null), bukan kalimat kosong", () => {
|
|
20
|
+
expect(ringkasGalatMedan<D>({}, [f("a", "A")])).toBeNull();
|
|
21
|
+
expect(ringkasGalatMedan<D>({ a: undefined } as never, [f("a", "A")])).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("medan tanpa label JATUH ke kuncinya โ nol disembunyikan", () => {
|
|
25
|
+
// Justru medan tanpa label yang paling mungkin jadi medan yatim
|
|
26
|
+
// (cif_number/branch_code, cacat 21 hari). Ia WAJIB kelihatan.
|
|
27
|
+
const s = ringkasGalatMedan<D>({ branch_code: "Cabang wajib diisi" }, []);
|
|
28
|
+
expect(s).toContain("branch_code");
|
|
29
|
+
expect(s).toContain("Cabang wajib diisi");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("dipotong kalau panjang, dan SEBUT berapa yang tersisa", () => {
|
|
33
|
+
const errs: Record<string, string> = {};
|
|
34
|
+
const fields: FormField<D>[] = [];
|
|
35
|
+
for (let i = 0; i < 9; i++) { errs["k" + i] = "wajib diisi"; fields.push(f("k" + i, "L" + i)); }
|
|
36
|
+
const s = ringkasGalatMedan<D>(errs, fields, 5)!;
|
|
37
|
+
expect(s.split(" ยท ").filter((x) => !x.startsWith("+")).length).toBe(5);
|
|
38
|
+
expect(s).toContain("+4 lagi");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("label kosong/spasi diperlakukan sbg TIDAK ADA", () => {
|
|
42
|
+
expect(ringkasGalatMedan<D>({ x: "wajib" }, [f("x", " ")])).toContain("x:");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -53,22 +53,22 @@ export async function submitWorkflowForm<TPayload>(
|
|
|
53
53
|
headers: { 'Content-Type': 'application/json' },
|
|
54
54
|
body: JSON.stringify(payload),
|
|
55
55
|
});
|
|
56
|
-
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?:
|
|
56
|
+
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?: unknown };
|
|
57
57
|
|
|
58
58
|
if (res.status === 202 && body.instance_id) {
|
|
59
59
|
return body.instance_id;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
throw new Error(body.error
|
|
62
|
+
throw new Error(errorMessage(body.error, `Gagal (HTTP ${res.status})`));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export async function submitWorkflowDelete(url: string): Promise<string> {
|
|
66
66
|
const res = await fetch(url, { method: 'DELETE' });
|
|
67
|
-
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?:
|
|
67
|
+
const body = (await res.json().catch(() => ({}))) as { instance_id?: string; error?: unknown };
|
|
68
68
|
|
|
69
69
|
if (res.status === 202 && body.instance_id) {
|
|
70
70
|
return body.instance_id;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
throw new Error(body.error
|
|
73
|
+
throw new Error(errorMessage(body.error, `Gagal (HTTP ${res.status})`));
|
|
74
74
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
* Spanduk galat diuji lewat PERILAKU `handleSave`, bukan lewat helper-nya.
|
|
10
|
+
*
|
|
11
|
+
* Uji atas `ringkasGalatMedan` sendirian TETAP HIJAU meski nilai balik `error`
|
|
12
|
+
* nol pernah memakainya โ diukur: mencabut penyambungan di `use-crud-form`
|
|
13
|
+
* meninggalkan 154 uji hijau. Yang membedakan "terpasang" dari "sekadar ada"
|
|
14
|
+
* cuma membaca `error` yang benar-benar keluar dari hook.
|
|
15
|
+
*/
|
|
16
|
+
function schema(): CrudForm<D> {
|
|
17
|
+
return {
|
|
18
|
+
title: { create: "c", edit: "e", view: "v" },
|
|
19
|
+
emptyData: { nik: "", lahir: "", yatim: "" } as D,
|
|
20
|
+
createApiPath: "/api/x",
|
|
21
|
+
layout: {
|
|
22
|
+
type: "single-page",
|
|
23
|
+
sections: [
|
|
24
|
+
{
|
|
25
|
+
title: "s",
|
|
26
|
+
fields: [
|
|
27
|
+
{ key: "nik", label: "NIK", type: "text", span: 4, validation: { required: true } },
|
|
28
|
+
{ key: "lahir", label: "Tanggal Lahir", type: "text", span: 4, validation: { required: true } },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
// Menuntut medan yang NOL punya field โ cacat cif_number/branch_code.
|
|
34
|
+
validate: (d: D) => (String(d.yatim ?? "").trim() ? {} : { yatim: "Cabang wajib diisi" }),
|
|
35
|
+
} as unknown as CrudForm<D>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("spanduk galat: PERILAKU handleSave", () => {
|
|
39
|
+
it("menyebut LABEL medan yang gagal, bukan satu kalimat tetap", async () => {
|
|
40
|
+
const { result } = renderHook(() => useCrudForm<D>({ schema: schema(), mode: "create" }));
|
|
41
|
+
await act(async () => { await result.current.handleSave(); });
|
|
42
|
+
|
|
43
|
+
const e = String(result.current.error ?? "");
|
|
44
|
+
expect(e).toContain("NIK");
|
|
45
|
+
expect(e).toContain("Tanggal Lahir");
|
|
46
|
+
expect(e).not.toBe("Periksa field wajib");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("medan YATIM (dituntut validate, nol punya field) tetap DISEBUT", async () => {
|
|
50
|
+
const { result } = renderHook(() => useCrudForm<D>({ schema: schema(), mode: "create" }));
|
|
51
|
+
await act(async () => { await result.current.handleSave(); });
|
|
52
|
+
// Justru inilah yang menahan create Nasabah Perorangan 21 hari tanpa nama.
|
|
53
|
+
expect(String(result.current.error ?? "")).toContain("yatim");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("PEMANCAR: nol ada galat validasi -> spanduk DIAM", async () => {
|
|
57
|
+
const { result } = renderHook(() => useCrudForm<D>({ schema: schema(), mode: "create" }));
|
|
58
|
+
act(() => { result.current.setForm({ nik: "1", lahir: "2", yatim: "3" } as D); });
|
|
59
|
+
await act(async () => { await result.current.handleSave(); });
|
|
60
|
+
// Save akan gagal di jaringan (nol ada server) -> itu serverError, BUKAN
|
|
61
|
+
// spanduk validasi. Yang diasersi: ia nol lagi berbunyi soal medan.
|
|
62
|
+
const e = String(result.current.error ?? "");
|
|
63
|
+
expect(e).not.toContain("NIK:");
|
|
64
|
+
expect(e).not.toContain("Tanggal Lahir:");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
4
4
|
import type { CrudForm, FormMode } from "../form-types";
|
|
5
5
|
import {
|
|
6
6
|
collectAllFields,
|
|
7
|
+
ringkasGalatMedan,
|
|
7
8
|
collectPageFields,
|
|
8
9
|
getLayoutPageCount,
|
|
9
10
|
resolveApiPath,
|
|
@@ -206,7 +207,12 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
206
207
|
setForm,
|
|
207
208
|
loading,
|
|
208
209
|
saving,
|
|
209
|
-
|
|
210
|
+
// Cabang validasi MENYEBUT medannya; cabang serverError nol tersentuh โ
|
|
211
|
+
// keduanya menjawab pertanyaan berbeda dan nol boleh dilebur.
|
|
212
|
+
error: hasValidationError
|
|
213
|
+
? ringkasGalatMedan(fieldErrors, collectAllFields(schema.layout)) ??
|
|
214
|
+
labels.validationError
|
|
215
|
+
: serverError,
|
|
210
216
|
fieldErrors,
|
|
211
217
|
isDirty,
|
|
212
218
|
page,
|