@isi-ui7/bos7-shared 0.4.2 → 0.4.3

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.
@@ -42,4 +42,42 @@ export declare function toLocalISODate(date: Date | undefined | null): string;
42
42
  * menghasilkan tanggal lain sama sekali (laporan DG-00, agent7 2026-08-31).
43
43
  */
44
44
  export declare function fromLocalISODate(iso: string | undefined | null): Date | undefined;
45
+ /**
46
+ * Mem-parse tanggal yang DIKETIK operator dalam format TAMPILAN "d-m-Y"
47
+ * ("15-06-1990", juga menerima "5-6-1990") jadi `Date` lokal.
48
+ *
49
+ * 🔑 Kenapa ini ada, dan kenapa ia TIDAK memakai flatpickr.
50
+ *
51
+ * Tanggal yang diketik tangan nol pernah masuk state form. Layar menunjukkan
52
+ * tanggalnya, tapi validasi berkata "is required" — cacat yang paling
53
+ * menyesatkan yang bisa dialami operator, karena nol ada yang terlihat salah.
54
+ *
55
+ * Diukur di browser sungguhan (DG-00, cif.ibos7.com 2026-08-31), commit-nya
56
+ * BERBEDA menurut gestur yang mengakhiri ketikan:
57
+ *
58
+ * gestur flatpickr.selectedDates state React
59
+ * Tab [] kosong RUSAK
60
+ * Enter terisi terisi benar
61
+ * klik di luar terisi kosong RUSAK
62
+ *
63
+ * Sesudah Tab, `fp.isOpen` tetap `true` dan fokus lompat ke elemen di dalam
64
+ * kalender — jadi jalur "parse saat close" milik flatpickr nol pernah
65
+ * tersentuh. Pada klik-di-luar flatpickr BERHASIL mem-parse tapi onChange-nya
66
+ * nol sampai ke React: flatpickr dan React memegang dua kebenaran berbeda.
67
+ *
68
+ * 🔴 Maka commit-nya nol boleh bergantung pada flatpickr. Kita mem-parse
69
+ * sendiri saat `blur`, dan itu berlaku untuk KETIGA gestur — termasuk yang
70
+ * belum kita pahami sepenuhnya. Bergantung pada perilaku yang sudah terbukti
71
+ * berbeda antar gestur berarti memperbaiki gestur yang kebetulan diuji saja.
72
+ *
73
+ * ⚠️ jsdom NOL bisa mereproduksi cacat ini: di sana `fireEvent.blur` memang
74
+ * meng-commit, pada kode lama maupun baru. Uji fungsi ini apa adanya, dan
75
+ * jangan menyimpulkan dari tes komponen bahwa cacatnya tuntas.
76
+ *
77
+ * Mengembalikan `undefined` untuk teks yang bukan tanggal utuh — termasuk
78
+ * ketikan separuh jalan ("15-06") dan tanggal yang nol ada ("31-02-1990").
79
+ * Nol menebak: teks yang nol terbaca dibiarkan apa adanya, dan validasi
80
+ * "wajib diisi" yang bicara.
81
+ */
82
+ export declare function parseDisplayDate(teks: string | undefined | null): Date | undefined;
45
83
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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.12",
73
+ "@isi-ui7/corporate-themes": ">=0.2.13",
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,7 +106,7 @@
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",
109
+ "@isi-ui7/corporate-themes": "0.2.13",
110
110
  "@isi-ui7/data-table": "0.2.5",
111
111
  "@isi-ui7/editable-table": "0.2.3",
112
112
  "@isi-ui7/i18n": "0.2.3",
@@ -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",
@@ -219,3 +219,62 @@ export function fromLocalISODate(iso: string | undefined | null): Date | undefin
219
219
  const date = new Date(Number(y), Number(mo) - 1, Number(d));
220
220
  return Number.isNaN(date.getTime()) ? undefined : date;
221
221
  }
222
+
223
+ /**
224
+ * Mem-parse tanggal yang DIKETIK operator dalam format TAMPILAN "d-m-Y"
225
+ * ("15-06-1990", juga menerima "5-6-1990") jadi `Date` lokal.
226
+ *
227
+ * 🔑 Kenapa ini ada, dan kenapa ia TIDAK memakai flatpickr.
228
+ *
229
+ * Tanggal yang diketik tangan nol pernah masuk state form. Layar menunjukkan
230
+ * tanggalnya, tapi validasi berkata "is required" — cacat yang paling
231
+ * menyesatkan yang bisa dialami operator, karena nol ada yang terlihat salah.
232
+ *
233
+ * Diukur di browser sungguhan (DG-00, cif.ibos7.com 2026-08-31), commit-nya
234
+ * BERBEDA menurut gestur yang mengakhiri ketikan:
235
+ *
236
+ * gestur flatpickr.selectedDates state React
237
+ * Tab [] kosong RUSAK
238
+ * Enter terisi terisi benar
239
+ * klik di luar terisi kosong RUSAK
240
+ *
241
+ * Sesudah Tab, `fp.isOpen` tetap `true` dan fokus lompat ke elemen di dalam
242
+ * kalender — jadi jalur "parse saat close" milik flatpickr nol pernah
243
+ * tersentuh. Pada klik-di-luar flatpickr BERHASIL mem-parse tapi onChange-nya
244
+ * nol sampai ke React: flatpickr dan React memegang dua kebenaran berbeda.
245
+ *
246
+ * 🔴 Maka commit-nya nol boleh bergantung pada flatpickr. Kita mem-parse
247
+ * sendiri saat `blur`, dan itu berlaku untuk KETIGA gestur — termasuk yang
248
+ * belum kita pahami sepenuhnya. Bergantung pada perilaku yang sudah terbukti
249
+ * berbeda antar gestur berarti memperbaiki gestur yang kebetulan diuji saja.
250
+ *
251
+ * ⚠️ jsdom NOL bisa mereproduksi cacat ini: di sana `fireEvent.blur` memang
252
+ * meng-commit, pada kode lama maupun baru. Uji fungsi ini apa adanya, dan
253
+ * jangan menyimpulkan dari tes komponen bahwa cacatnya tuntas.
254
+ *
255
+ * Mengembalikan `undefined` untuk teks yang bukan tanggal utuh — termasuk
256
+ * ketikan separuh jalan ("15-06") dan tanggal yang nol ada ("31-02-1990").
257
+ * Nol menebak: teks yang nol terbaca dibiarkan apa adanya, dan validasi
258
+ * "wajib diisi" yang bicara.
259
+ */
260
+ export function parseDisplayDate(teks: string | undefined | null): Date | undefined {
261
+ const s = String(teks ?? "").trim();
262
+ const m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec(s);
263
+ if (!m) return undefined;
264
+ const [, d, mo, y] = m;
265
+ const hari = Number(d);
266
+ const bulan = Number(mo);
267
+ const tahun = Number(y);
268
+ const date = new Date(tahun, bulan - 1, hari);
269
+ // Konstruktor Date MENGGULUNG nilai di luar batas: 31-02-1990 diam-diam
270
+ // jadi 3 Maret. Membaca ulang komponennya menolak tanggal yang nol ada,
271
+ // alih-alih menyimpan tanggal LAIN yang nol pernah diketik siapa pun.
272
+ if (
273
+ date.getFullYear() !== tahun ||
274
+ date.getMonth() !== bulan - 1 ||
275
+ date.getDate() !== hari
276
+ ) {
277
+ return undefined;
278
+ }
279
+ return date;
280
+ }
@@ -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 ? (
@@ -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?: string };
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 ?? `Gagal (HTTP ${res.status})`);
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?: string };
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 ?? `Gagal (HTTP ${res.status})`);
73
+ throw new Error(errorMessage(body.error, `Gagal (HTTP ${res.status})`));
74
74
  }