@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.
- package/dist/crud-types.d.ts +50 -0
- package/dist/form-renderer-utils.d.ts +11 -0
- package/dist/{index-C_114Ysm.js → index-DqWPV29B.js} +71 -71
- package/dist/{index.es-B-MluT1E.js → index.es-9cvhYbjn.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{jspdf.es.min-C5VEsLZd.js → jspdf.es.min-CCBzPHw4.js} +2 -2
- package/dist/{jspdf.plugin.autotable-DcntYaes.js → jspdf.plugin.autotable-DFaknRns.js} +19 -22
- package/dist/purify.es-Cm3utOpm.js +560 -0
- package/package.json +9 -9
- package/src/crud-components.tsx +81 -3
- package/src/crud-filter-fields.test.tsx +156 -0
- package/src/crud-types.ts +50 -0
- package/src/date-field-render.test.tsx +83 -0
- package/src/form-renderer-aria-required.test.tsx +101 -0
- package/src/form-renderer-utils.ts +19 -0
- package/src/form-renderer.tsx +28 -3
- package/src/vitest.setup.ts +14 -0
- package/dist/purify.es-CiEWEeUM.js +0 -605
package/src/crud-components.tsx
CHANGED
|
@@ -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
|
+
}
|