@isi-ui7/bos7-shared 0.3.11 → 0.4.1

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.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { type ReactNode, useCallback, useEffect, useState } from "react";
3
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
4
4
  import { Add } from "@carbon/icons-react";
5
5
  import {
6
6
  Button,
@@ -13,12 +13,13 @@ import {
13
13
  Select,
14
14
  SelectItem,
15
15
  TextArea,
16
+ TextInput,
16
17
  ToastNotification,
17
18
  Toggle,
18
19
  } from "@carbon/react";
19
20
  import { ServerDataTable } from "@isi-ui7/data-table";
20
21
  import type { I_DataTblColumn } from "@isi-ui7/data-table";
21
- import type { CrudCustomActionSchema, CrudDeleteSchema, CrudListSchema } from "./crud-types";
22
+ import type { CrudCustomActionSchema, CrudDeleteSchema, CrudFilterFieldDef, CrudListSchema } from "./crud-types";
22
23
  import type { CrudForm, FormMode, FormPageDef } from "./form-types";
23
24
  import type { Ui7FormDensity } from "./style-contract";
24
25
  import { SchemaFormRenderer } from "./form-renderer";
@@ -79,11 +80,57 @@ export function SharedCrudListPage({
79
80
  const [filterValue, setFilterValue] = useState("");
80
81
  const hasInactiveToggle = Boolean(schema.showInactiveToggle);
81
82
  const hasFilterSelect = Boolean(schema.filterSelect);
83
+
84
+ // Granular toolbar filters (opt-in via schema.filterFields) — N fields,
85
+ // each contributing one param to the SAME additionalParams merge as
86
+ // filterSelect/showInactiveToggle above.
87
+ //
88
+ // Two states per field, not one: `filterFieldValues` is what the widget
89
+ // shows (updates on every keystroke, so typing feels immediate);
90
+ // `filterFieldCommitted` is what actually reaches additionalParams (and
91
+ // therefore triggers a refetch). For `type: "select"` they're set
92
+ // together — a discrete pick, same as filterSelect's existing immediate
93
+ // commit. For `type: "text"` the commit is DEBOUNCED: this mirrors the
94
+ // codebase's own precedent for free-text-triggers-a-fetch
95
+ // (OffsetDataTable's search box debounces the same way) — committing
96
+ // every keystroke straight to a network request is the one inconsistency
97
+ // an un-debounced text field would introduce that filterSelect's select
98
+ // never had.
99
+ const FILTER_TEXT_DEBOUNCE_MS = 400;
100
+ const [filterFieldValues, setFilterFieldValues] = useState<Record<string, string>>({});
101
+ const [filterFieldCommitted, setFilterFieldCommitted] = useState<Record<string, string>>({});
102
+ const filterFieldTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
103
+ useEffect(() => {
104
+ const timers = filterFieldTimers.current;
105
+ return () => {
106
+ for (const t of Object.values(timers)) clearTimeout(t);
107
+ };
108
+ }, []);
109
+ const setFilterFieldValue = useCallback((field: CrudFilterFieldDef, value: string) => {
110
+ setFilterFieldValues((prev) => ({ ...prev, [field.param]: value }));
111
+ if (field.type === "text") {
112
+ if (filterFieldTimers.current[field.param]) {
113
+ clearTimeout(filterFieldTimers.current[field.param]);
114
+ }
115
+ filterFieldTimers.current[field.param] = setTimeout(() => {
116
+ setFilterFieldCommitted((prev) => ({ ...prev, [field.param]: value }));
117
+ }, FILTER_TEXT_DEBOUNCE_MS);
118
+ } else {
119
+ setFilterFieldCommitted((prev) => ({ ...prev, [field.param]: value }));
120
+ }
121
+ }, []);
122
+ const hasFilterFields = Boolean(schema.filterFields?.length);
123
+
82
124
  const additionalParams =
83
- hasInactiveToggle || hasFilterSelect
125
+ hasInactiveToggle || hasFilterSelect || hasFilterFields
84
126
  ? {
85
127
  ...(hasInactiveToggle ? { [inactiveParam]: showInactive ? "true" : "" } : {}),
86
128
  ...(hasFilterSelect ? { [schema.filterSelect!.param]: filterValue } : {}),
129
+ ...(hasFilterFields
130
+ ? Object.fromEntries(
131
+ schema.filterFields!.map((f) => [f.param, filterFieldCommitted[f.param] ?? ""]),
132
+ )
133
+ : {}),
87
134
  }
88
135
  : undefined;
89
136
  return (
@@ -133,6 +180,37 @@ export function SharedCrudListPage({
133
180
  </Select>
134
181
  </div>
135
182
  ) : null}
183
+ {schema.filterFields?.map((f) =>
184
+ f.type === "select" ? (
185
+ <div key={f.param} style={{ minWidth: "10rem" }}>
186
+ <Select
187
+ id={`filter-field-${schema.apiPath}-${f.param}`}
188
+ labelText={f.label}
189
+ hideLabel
190
+ size="sm"
191
+ value={filterFieldValues[f.param] ?? ""}
192
+ onChange={(e) => setFilterFieldValue(f, e.target.value)}
193
+ >
194
+ <SelectItem value="" text={f.allLabel ?? "Semua"} />
195
+ {f.options.map((opt) => (
196
+ <SelectItem key={opt.value} value={opt.value} text={opt.label} />
197
+ ))}
198
+ </Select>
199
+ </div>
200
+ ) : (
201
+ <div key={f.param} style={{ minWidth: "10rem" }}>
202
+ <TextInput
203
+ id={`filter-field-${schema.apiPath}-${f.param}`}
204
+ labelText={f.label}
205
+ hideLabel
206
+ size="sm"
207
+ placeholder={f.placeholder ?? f.label}
208
+ value={filterFieldValues[f.param] ?? ""}
209
+ onChange={(e) => setFilterFieldValue(f, e.target.value)}
210
+ />
211
+ </div>
212
+ ),
213
+ )}
136
214
  {toolbarExtra}
137
215
  {(schema.showAddButton ?? true) ? (
138
216
  <Button renderIcon={Add} size="sm" onClick={onAdd}>
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+
5
+ // K-6 (user, 2026-08-26): CrudListSchema.filterFields — N granular toolbar
6
+ // filters, generalizing filterSelect (1 enum dropdown) to mixed-type fields.
7
+ // Additive + backward-compatible: omit it, toolbar renders exactly as before.
8
+ //
9
+ // ServerDataTable menarik jaringan & Carbon DataTable penuh; yang diuji di
10
+ // sini TOOLBAR-nya dan additionalParams yang dikirim ke ServerDataTable, jadi
11
+ // tabelnya dimock — additionalParams diserialisasi ke satu node supaya test
12
+ // bisa membacanya tanpa mock jaringan sungguhan.
13
+ vi.mock("@isi-ui7/data-table", () => ({
14
+ ServerDataTable: ({
15
+ toolbarActions,
16
+ additionalParams,
17
+ }: {
18
+ toolbarActions?: React.ReactNode;
19
+ additionalParams?: Record<string, string>;
20
+ }) => (
21
+ <div>
22
+ <div data-testid="toolbar">{toolbarActions}</div>
23
+ <div data-testid="params">{JSON.stringify(additionalParams ?? {})}</div>
24
+ </div>
25
+ ),
26
+ }));
27
+
28
+ import { SharedCrudListPage } from "./crud-components";
29
+ import type { CrudListSchema } from "./crud-types";
30
+
31
+ const dasar: CrudListSchema = {
32
+ title: "Daftar",
33
+ apiPath: "/api/x",
34
+ columns: { a: { title: "A" } },
35
+ popupMenuItems: [],
36
+ addButtonLabel: "Tambah",
37
+ };
38
+
39
+ function paramsRendered(): Record<string, string> {
40
+ return JSON.parse(screen.getByTestId("params").textContent ?? "{}");
41
+ }
42
+
43
+ describe("CrudListSchema.filterFields", () => {
44
+ it("DEFAULT (omit) — toolbar params kosong, gerbang kompatibilitas", () => {
45
+ render(<SharedCrudListPage schema={dasar} onAdd={() => {}} onPopupClick={() => {}} />);
46
+ expect(paramsRendered()).toEqual({});
47
+ });
48
+
49
+ it("field type:select merender Select dan mengirim param SEGERA saat dipilih", async () => {
50
+ const user = userEvent.setup();
51
+ render(
52
+ <SharedCrudListPage
53
+ schema={{
54
+ ...dasar,
55
+ filterFields: [
56
+ {
57
+ type: "select",
58
+ param: "identity_type",
59
+ label: "Jenis Identitas",
60
+ options: [{ value: "KTP", label: "KTP" }],
61
+ },
62
+ ],
63
+ }}
64
+ onAdd={() => {}}
65
+ onPopupClick={() => {}}
66
+ />,
67
+ );
68
+ expect(paramsRendered()).toEqual({ identity_type: "" });
69
+
70
+ const select = screen.getByLabelText("Jenis Identitas") as HTMLSelectElement;
71
+ await user.selectOptions(select, "KTP");
72
+
73
+ expect(paramsRendered()).toEqual({ identity_type: "KTP" });
74
+ });
75
+
76
+ it("field type:text merender TextInput dan DEBOUNCE param (nol segera saat mengetik)", async () => {
77
+ const user = userEvent.setup();
78
+ render(
79
+ <SharedCrudListPage
80
+ schema={{
81
+ ...dasar,
82
+ filterFields: [{ type: "text", param: "full_name", label: "Nama" }],
83
+ }}
84
+ onAdd={() => {}}
85
+ onPopupClick={() => {}}
86
+ />,
87
+ );
88
+ expect(paramsRendered()).toEqual({ full_name: "" });
89
+
90
+ const input = screen.getByLabelText("Nama") as HTMLInputElement;
91
+ await user.type(input, "Budi");
92
+
93
+ // 🔑 Klaim inti: mengetik TIDAK segera mengirim param -- kalau ini gagal,
94
+ // field teks refetch di SETIAP keystroke (inkonsisten dgn search bar
95
+ // bawaan, yang sudah didebounce di OffsetDataTable).
96
+ expect(paramsRendered()).toEqual({ full_name: "" });
97
+ expect(input.value).toBe("Budi"); // tapi WIDGET-nya tetap responsif
98
+
99
+ await waitFor(() => expect(paramsRendered()).toEqual({ full_name: "Budi" }), { timeout: 1000 });
100
+ });
101
+
102
+ it("filterSelect (lama) DAN filterFields (baru) coexist tanpa saling menimpa", async () => {
103
+ const user = userEvent.setup();
104
+ render(
105
+ <SharedCrudListPage
106
+ schema={{
107
+ ...dasar,
108
+ filterSelect: { param: "status", label: "Status", options: [{ value: "ACTIVE", label: "Aktif" }] },
109
+ filterFields: [
110
+ {
111
+ type: "select",
112
+ param: "branch_code",
113
+ label: "Cabang",
114
+ options: [{ value: "001", label: "Cabang 001" }],
115
+ },
116
+ ],
117
+ }}
118
+ onAdd={() => {}}
119
+ onPopupClick={() => {}}
120
+ />,
121
+ );
122
+ expect(paramsRendered()).toEqual({ status: "", branch_code: "" });
123
+
124
+ await user.selectOptions(screen.getByLabelText("Cabang"), "001");
125
+ expect(paramsRendered()).toEqual({ status: "", branch_code: "001" });
126
+ });
127
+
128
+ it("dua field granular independen — mengubah satu nol menyentuh yang lain", async () => {
129
+ const user = userEvent.setup();
130
+ render(
131
+ <SharedCrudListPage
132
+ schema={{
133
+ ...dasar,
134
+ filterFields: [
135
+ {
136
+ type: "select",
137
+ param: "identity_type",
138
+ label: "Jenis Identitas",
139
+ options: [{ value: "KTP", label: "KTP" }],
140
+ },
141
+ {
142
+ type: "select",
143
+ param: "branch_code",
144
+ label: "Cabang",
145
+ options: [{ value: "001", label: "Cabang 001" }],
146
+ },
147
+ ],
148
+ }}
149
+ onAdd={() => {}}
150
+ onPopupClick={() => {}}
151
+ />,
152
+ );
153
+ await user.selectOptions(screen.getByLabelText("Jenis Identitas"), "KTP");
154
+ expect(paramsRendered()).toEqual({ identity_type: "KTP", branch_code: "" });
155
+ });
156
+ });
package/src/crud-types.ts CHANGED
@@ -108,6 +108,11 @@ export type CrudListSchema = {
108
108
  * "(all)" removes the param. Mirrors `showInactiveToggle`'s additionalParams
109
109
  * wiring but for an arbitrary enum column (e.g. `vendor_type`). Additive +
110
110
  * backward-compatible: omit it and the toolbar renders exactly as before.
111
+ *
112
+ * @deprecated Use `filterFields` (a single `{ type: "select", ... }` entry
113
+ * covers this exact case). Kept working, unchanged, so existing callers
114
+ * never have to move — the two coexist because they write to different
115
+ * query params and share the same additionalParams merge.
111
116
  */
112
117
  filterSelect?: {
113
118
  /** Query param name the backend reads (e.g. "vendor_type"). */
@@ -118,8 +123,53 @@ export type CrudListSchema = {
118
123
  /** Label for the "no filter" option. Default "Semua". */
119
124
  allLabel?: string;
120
125
  };
126
+ /**
127
+ * Granular toolbar filters: N independent fields, each contributing one
128
+ * query param via the SAME additionalParams merge `filterSelect` and
129
+ * `showInactiveToggle` already use — `@isi-ui7/data-table` is untouched.
130
+ * Additive + backward-compatible: omit it and the toolbar renders exactly
131
+ * as before. Coexists with `filterSelect`/`showInactiveToggle` as long as
132
+ * `param` names don't collide (caller's responsibility, same as today).
133
+ *
134
+ * K-6 (user, 2026-08-26): prototype daftar nasabah wants filtering finer
135
+ * than one search bar + one dropdown. `filterSelect` proved the mechanism
136
+ * for exactly one enum column; this generalizes it to N fields of mixed
137
+ * type instead of duplicating that mechanism per field. The union is
138
+ * closed but extensible — a new variant (e.g. `dateRange`) is additive to
139
+ * this type, not a breaking change to it.
140
+ *
141
+ * ⚠️ This is FRONTEND capacity only. Each `param` still needs the target
142
+ * service's own query endpoint to read it and filter server-side — adding
143
+ * a field here does nothing until the backend honors that param.
144
+ */
145
+ filterFields?: CrudFilterFieldDef[];
121
146
  };
122
147
 
148
+ /**
149
+ * One granular toolbar filter field (see `CrudListSchema.filterFields`).
150
+ * Closed union so a new widget type is additive here, not a breaking change
151
+ * to `CrudListSchema` itself.
152
+ */
153
+ export type CrudFilterFieldDef =
154
+ | {
155
+ type: "select";
156
+ /** Query param name the backend reads. */
157
+ param: string;
158
+ /** Toolbar label for the field. */
159
+ label: string;
160
+ options: Array<{ value: string; label: string }>;
161
+ /** Label for the "no filter" option. Default "Semua". */
162
+ allLabel?: string;
163
+ }
164
+ | {
165
+ type: "text";
166
+ /** Query param name the backend reads. */
167
+ param: string;
168
+ /** Toolbar label for the field. */
169
+ label: string;
170
+ placeholder?: string;
171
+ };
172
+
123
173
  export type CrudDeleteSchema<TData extends Record<string, unknown>> = {
124
174
  /** Omit to use i18n default ("Konfirmasi Hapus"). */
125
175
  title?: string;
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } 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
+ // Laporan DG-00 (agent7, 2026-08-31): field "date" di form-renderer.tsx punya
10
+ // DUA cacat. Cacat B (TERBUKTI + DIPERBAIKI di sini): `value={asString(rawValue)}`
11
+ // mengirim string ISO mentah ke DatePicker Carbon (flatpickr) yang
12
+ // dikonfigurasi dateFormat="d-m-Y", jadi flatpickr mem-parse ULANG string ISO
13
+ // itu SEOLAH ia format d-m-Y — tanggal TERSIMPAN tampil SALAH saat re-render.
14
+ // Diperbaiki dgn `fromLocalISODate()` (Date, bukan string) di form-renderer.tsx.
15
+ //
16
+ // Cacat A (diklaim DG-00 dari fiber React browser sungguhan: tanggal yg
17
+ // DIKETIK nol pernah masuk state) — TIDAK bisa direproduksi di tes terisolasi
18
+ // ini, bahkan pada kode SEBELUM perbaikan (lihat ledger dg-05 2026-08-31):
19
+ // `allowInput` Carbon versi ini SUDAH default `true` (DatePicker.js:377,
20
+ // `allowInput ?? true`), dan `fireEvent.blur` mengonfirmasi commit-on-blur
21
+ // bekerja di kedua kode lama & baru. Tes di bawah HANYA membuktikan alur
22
+ // commit bekerja di form-renderer terisolasi — ia BUKAN bukti Cacat A sudah
23
+ // tuntas di halaman produksi (mungkin bergantung interaksi re-render field
24
+ // lain yg tak tercakup harness minimal ini). Perlu re-verifikasi Tester/
25
+ // DG-00 langsung di browser thd kode yg sudah diperbaiki.
26
+
27
+ const dateSection: FormSection<D> = {
28
+ title: "Tanggal",
29
+ fields: [{ key: "tanggal_lahir", label: "Tanggal Lahir", type: "date", span: 4 }],
30
+ };
31
+
32
+ describe("field date — re-render dari value tersimpan (Cacat B)", () => {
33
+ it("ISO tersimpan tampil sbg tanggal yg SAMA dlm format d-m-Y, bukan digeser", () => {
34
+ render(
35
+ <SchemaFormRenderer<D>
36
+ mode="edit"
37
+ value={{ tanggal_lahir: "1990-06-15" }}
38
+ sections={[dateSection]}
39
+ onChange={() => {}}
40
+ />,
41
+ );
42
+ const input = screen.getByLabelText("Tanggal Lahir") as HTMLInputElement;
43
+ // Harus 15-06-1990 (hari yg SAMA, cuma beda format tampilan) — BUKAN
44
+ // tanggal lain hasil salah-parse "1990-06-15" seolah format d-m-Y.
45
+ expect(input.value).toBe("15-06-1990");
46
+ });
47
+
48
+ it("kontrol: tanggal akhir-tahun tetap utuh (bukan kebetulan lolos di satu kasus)", () => {
49
+ render(
50
+ <SchemaFormRenderer<D>
51
+ mode="edit"
52
+ value={{ tanggal_lahir: "2026-12-31" }}
53
+ sections={[dateSection]}
54
+ onChange={() => {}}
55
+ />,
56
+ );
57
+ const input = screen.getByLabelText("Tanggal Lahir") as HTMLInputElement;
58
+ expect(input.value).toBe("31-12-2026");
59
+ });
60
+ });
61
+
62
+ describe("field date — ketik tangan lalu commit (Cacat A)", () => {
63
+ it("tanggal yg DIKETIK (bukan dipilih dari kalender) masuk ke onChange", async () => {
64
+ const user = userEvent.setup();
65
+ const onChange = vi.fn();
66
+
67
+ render(
68
+ <SchemaFormRenderer<D>
69
+ mode="create"
70
+ value={{}}
71
+ sections={[dateSection]}
72
+ onChange={onChange}
73
+ />,
74
+ );
75
+ const input = screen.getByLabelText("Tanggal Lahir") as HTMLInputElement;
76
+ await user.type(input, "15-06-1990");
77
+ fireEvent.blur(input); // commit alami operator (pindah fokus keluar kotak)
78
+
79
+ const patches = onChange.mock.calls.map((c) => c[0]);
80
+ const got = patches.find((p) => p?.tanggal_lahir);
81
+ expect(got?.tanggal_lahir).toBe("1990-06-15");
82
+ });
83
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { SchemaFormRenderer } from "./form-renderer";
4
+ import type { FormSection } from "./form-types";
5
+
6
+ type D = Record<string, unknown>;
7
+
8
+ // `field.validation.required` was already read correctly into `effectiveRequired`
9
+ // (form-renderer.tsx) — it just never reached the actual Carbon control, only the
10
+ // visual asterisk (`aria-hidden="true"`, decorative by design). Screen-reader users
11
+ // got zero signal despite the field genuinely being required. Fixed by forwarding
12
+ // `aria-required` (NOT native `required` — see the comment on `mkLabel` for why) to
13
+ // each of the 9 distinct render sites. One `it` per site, each with its own positive
14
+ // AND negative assertion, so a fix that lands on 8/9 sites cannot pass silently under
15
+ // one aggregate green checkmark.
16
+ //
17
+ // `detail-rows`/`detail-modal` (table-shaped fields) and `lookup` (a separate
18
+ // `@isi-ui7/lookup-input` package) are OUT OF SCOPE — there is no single control to
19
+ // attach `aria-required` to (detail-rows/-modal) or the control isn't ours to change
20
+ // (lookup). 9 sites, not more.
21
+
22
+ function ariaRequiredOf(container: HTMLElement, key: string): string | null {
23
+ const el = container.querySelector(`#${key}`);
24
+ expect(el, `#${key} should exist in the rendered DOM`).toBeTruthy();
25
+ return el!.getAttribute("aria-required");
26
+ }
27
+
28
+ function renderField(field: FormSection<D>["fields"][number]) {
29
+ const section: FormSection<D> = { title: "T", fields: [field] };
30
+ return render(
31
+ <SchemaFormRenderer<D> mode="create" value={{}} sections={[section]} onChange={() => {}} />,
32
+ );
33
+ }
34
+
35
+ describe("aria-required forwarded to the actual control (not just the visual asterisk)", () => {
36
+ it("text (default) — required forwards, optional omits the attribute", () => {
37
+ const req = renderField({ key: "nama", label: "Nama", type: "text", validation: { required: true } });
38
+ expect(ariaRequiredOf(req.container, "nama")).toBe("true");
39
+ const opt = renderField({ key: "nama", label: "Nama", type: "text" });
40
+ expect(ariaRequiredOf(opt.container, "nama")).toBeNull();
41
+ });
42
+
43
+ it("textarea — required forwards, optional omits the attribute", () => {
44
+ const req = renderField({ key: "catatan", label: "Catatan", type: "textarea", validation: { required: true } });
45
+ expect(ariaRequiredOf(req.container, "catatan")).toBe("true");
46
+ const opt = renderField({ key: "catatan", label: "Catatan", type: "textarea" });
47
+ expect(ariaRequiredOf(opt.container, "catatan")).toBeNull();
48
+ });
49
+
50
+ it("select — required forwards, optional omits the attribute", () => {
51
+ const field = { key: "status", label: "Status", type: "select" as const, options: [{ value: "A", label: "A" }] };
52
+ const req = renderField({ ...field, validation: { required: true } });
53
+ expect(ariaRequiredOf(req.container, "status")).toBe("true");
54
+ const opt = renderField(field);
55
+ expect(ariaRequiredOf(opt.container, "status")).toBeNull();
56
+ });
57
+
58
+ it("date — required forwards, optional omits the attribute", () => {
59
+ const req = renderField({ key: "tgl", label: "Tanggal", type: "date", validation: { required: true } });
60
+ expect(ariaRequiredOf(req.container, "tgl")).toBe("true");
61
+ const opt = renderField({ key: "tgl", label: "Tanggal", type: "date" });
62
+ expect(ariaRequiredOf(opt.container, "tgl")).toBeNull();
63
+ });
64
+
65
+ it("checkbox — required forwards, optional omits the attribute", () => {
66
+ const req = renderField({ key: "setuju", label: "Setuju", type: "checkbox", validation: { required: true } });
67
+ expect(ariaRequiredOf(req.container, "setuju")).toBe("true");
68
+ const opt = renderField({ key: "setuju", label: "Setuju", type: "checkbox" });
69
+ expect(ariaRequiredOf(opt.container, "setuju")).toBeNull();
70
+ });
71
+
72
+ it("toggle — required forwards, optional omits the attribute", () => {
73
+ const req = renderField({ key: "aktif", label: "Aktif", type: "toggle", validation: { required: true } });
74
+ expect(ariaRequiredOf(req.container, "aktif")).toBe("true");
75
+ const opt = renderField({ key: "aktif", label: "Aktif", type: "toggle" });
76
+ expect(ariaRequiredOf(opt.container, "aktif")).toBeNull();
77
+ });
78
+
79
+ it("number (NumericField, incl. currency/percent/integer) — required forwards, optional omits the attribute", () => {
80
+ const req = renderField({ key: "jumlah", label: "Jumlah", type: "number", validation: { required: true } });
81
+ expect(ariaRequiredOf(req.container, "jumlah")).toBe("true");
82
+ const opt = renderField({ key: "jumlah", label: "Jumlah", type: "number" });
83
+ expect(ariaRequiredOf(opt.container, "jumlah")).toBeNull();
84
+ });
85
+
86
+ it("NPWP (text + numeric.kind='npwp') — required forwards, optional omits the attribute", () => {
87
+ const field = { key: "npwp", label: "NPWP", type: "text" as const, numeric: { kind: "npwp" as const } };
88
+ const req = renderField({ ...field, validation: { required: true } });
89
+ expect(ariaRequiredOf(req.container, "npwp")).toBe("true");
90
+ const opt = renderField(field);
91
+ expect(ariaRequiredOf(opt.container, "npwp")).toBeNull();
92
+ });
93
+
94
+ it("Phone (text + numeric.kind='phone') — required forwards, optional omits the attribute", () => {
95
+ const field = { key: "telp", label: "Telepon", type: "text" as const, numeric: { kind: "phone" as const, minDigits: 8, maxDigits: 15 } };
96
+ const req = renderField({ ...field, validation: { required: true } });
97
+ expect(ariaRequiredOf(req.container, "telp")).toBe("true");
98
+ const opt = renderField(field);
99
+ expect(ariaRequiredOf(opt.container, "telp")).toBeNull();
100
+ });
101
+ });
@@ -185,3 +185,22 @@ export function toLocalISODate(date: Date | undefined | null): string {
185
185
  const d = String(date.getDate()).padStart(2, "0");
186
186
  return `${y}-${m}-${d}`;
187
187
  }
188
+
189
+ /**
190
+ * Kebalikan `toLocalISODate` — mem-parse "YYYY-MM-DD" jadi `Date` lokal
191
+ * (bukan `new Date(iso)` yang mem-parse sbg UTC dan bisa menggeser sehari,
192
+ * kelas bug yang sama dgn di atas, arah terbalik).
193
+ *
194
+ * Dipakai supaya prop `value` DatePicker Carbon menerima `Date`, BUKAN
195
+ * string ISO mentah — string ISO yang diberikan ke DatePicker dgn
196
+ * `dateFormat="d-m-Y"` di-parse ULANG flatpickr SEOLAH ia format d-m-Y,
197
+ * menghasilkan tanggal lain sama sekali (laporan DG-00, agent7 2026-08-31).
198
+ */
199
+ export function fromLocalISODate(iso: string | undefined | null): Date | undefined {
200
+ if (!iso) return undefined;
201
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
202
+ if (!m) return undefined;
203
+ const [, y, mo, d] = m;
204
+ const date = new Date(Number(y), Number(mo) - 1, Number(d));
205
+ return Number.isNaN(date.getTime()) ? undefined : date;
206
+ }