@isi-ui7/bos7-shared 0.3.11 → 0.4.0
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/package.json +7 -7
- 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/form-renderer-aria-required.test.tsx +101 -0
- package/src/form-renderer.tsx +21 -1
- package/src/vitest.setup.ts +14 -0
package/dist/crud-types.d.ts
CHANGED
|
@@ -127,6 +127,11 @@ export type CrudListSchema = {
|
|
|
127
127
|
* "(all)" removes the param. Mirrors `showInactiveToggle`'s additionalParams
|
|
128
128
|
* wiring but for an arbitrary enum column (e.g. `vendor_type`). Additive +
|
|
129
129
|
* backward-compatible: omit it and the toolbar renders exactly as before.
|
|
130
|
+
*
|
|
131
|
+
* @deprecated Use `filterFields` (a single `{ type: "select", ... }` entry
|
|
132
|
+
* covers this exact case). Kept working, unchanged, so existing callers
|
|
133
|
+
* never have to move — the two coexist because they write to different
|
|
134
|
+
* query params and share the same additionalParams merge.
|
|
130
135
|
*/
|
|
131
136
|
filterSelect?: {
|
|
132
137
|
/** Query param name the backend reads (e.g. "vendor_type"). */
|
|
@@ -140,6 +145,51 @@ export type CrudListSchema = {
|
|
|
140
145
|
/** Label for the "no filter" option. Default "Semua". */
|
|
141
146
|
allLabel?: string;
|
|
142
147
|
};
|
|
148
|
+
/**
|
|
149
|
+
* Granular toolbar filters: N independent fields, each contributing one
|
|
150
|
+
* query param via the SAME additionalParams merge `filterSelect` and
|
|
151
|
+
* `showInactiveToggle` already use — `@isi-ui7/data-table` is untouched.
|
|
152
|
+
* Additive + backward-compatible: omit it and the toolbar renders exactly
|
|
153
|
+
* as before. Coexists with `filterSelect`/`showInactiveToggle` as long as
|
|
154
|
+
* `param` names don't collide (caller's responsibility, same as today).
|
|
155
|
+
*
|
|
156
|
+
* K-6 (user, 2026-08-26): prototype daftar nasabah wants filtering finer
|
|
157
|
+
* than one search bar + one dropdown. `filterSelect` proved the mechanism
|
|
158
|
+
* for exactly one enum column; this generalizes it to N fields of mixed
|
|
159
|
+
* type instead of duplicating that mechanism per field. The union is
|
|
160
|
+
* closed but extensible — a new variant (e.g. `dateRange`) is additive to
|
|
161
|
+
* this type, not a breaking change to it.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ This is FRONTEND capacity only. Each `param` still needs the target
|
|
164
|
+
* service's own query endpoint to read it and filter server-side — adding
|
|
165
|
+
* a field here does nothing until the backend honors that param.
|
|
166
|
+
*/
|
|
167
|
+
filterFields?: CrudFilterFieldDef[];
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* One granular toolbar filter field (see `CrudListSchema.filterFields`).
|
|
171
|
+
* Closed union so a new widget type is additive here, not a breaking change
|
|
172
|
+
* to `CrudListSchema` itself.
|
|
173
|
+
*/
|
|
174
|
+
export type CrudFilterFieldDef = {
|
|
175
|
+
type: "select";
|
|
176
|
+
/** Query param name the backend reads. */
|
|
177
|
+
param: string;
|
|
178
|
+
/** Toolbar label for the field. */
|
|
179
|
+
label: string;
|
|
180
|
+
options: Array<{
|
|
181
|
+
value: string;
|
|
182
|
+
label: string;
|
|
183
|
+
}>;
|
|
184
|
+
/** Label for the "no filter" option. Default "Semua". */
|
|
185
|
+
allLabel?: string;
|
|
186
|
+
} | {
|
|
187
|
+
type: "text";
|
|
188
|
+
/** Query param name the backend reads. */
|
|
189
|
+
param: string;
|
|
190
|
+
/** Toolbar label for the field. */
|
|
191
|
+
label: string;
|
|
192
|
+
placeholder?: string;
|
|
143
193
|
};
|
|
144
194
|
export type CrudDeleteSchema<TData extends Record<string, unknown>> = {
|
|
145
195
|
/** Omit to use i18n default ("Konfirmasi Hapus"). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isi-ui7/bos7-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.9",
|
|
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.
|
|
109
|
+
"@isi-ui7/corporate-themes": "0.2.9",
|
|
110
110
|
"@isi-ui7/data-table": "0.2.5",
|
|
111
111
|
"@isi-ui7/editable-table": "0.2.3",
|
|
112
112
|
"@isi-ui7/lookup-input": "0.2.4",
|
|
113
113
|
"@isi-ui7/modal-manager": "0.2.2",
|
|
114
|
+
"@isi-ui7/ui-shell": "0.2.8",
|
|
114
115
|
"@isi-ui7/i18n": "0.2.3",
|
|
115
|
-
"@isi-ui7/realtime": "0.2.3"
|
|
116
|
-
"@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",
|
|
@@ -127,8 +127,8 @@
|
|
|
127
127
|
"jspdf": "^4.2.1",
|
|
128
128
|
"jspdf-autotable": "^5.0.7",
|
|
129
129
|
"@isi-ui7/data-table": "0.2.5",
|
|
130
|
-
"@isi-ui7/
|
|
131
|
-
"@isi-ui7/
|
|
130
|
+
"@isi-ui7/editable-table": "0.2.3",
|
|
131
|
+
"@isi-ui7/lookup-input": "0.2.4"
|
|
132
132
|
},
|
|
133
133
|
"scripts": {
|
|
134
134
|
"build": "vite build",
|
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,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
|
+
});
|
package/src/form-renderer.tsx
CHANGED
|
@@ -64,7 +64,14 @@ function asString(value: unknown): string {
|
|
|
64
64
|
return String(value);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Wraps a field label with an optional required asterisk for Carbon's `labelText` prop.
|
|
68
|
+
// The asterisk is `aria-hidden` on purpose — it's decoration for sighted users.
|
|
69
|
+
// The actual screen-reader signal is `aria-required`, forwarded separately onto
|
|
70
|
+
// each control below. Forwarded as `aria-required`, NOT native `required`: native
|
|
71
|
+
// `required` adds a second, browser-native validation system with its own
|
|
72
|
+
// message/tooltip on top of `runFieldValidation`'s in-app enforcement of
|
|
73
|
+
// `validation.required` — two error systems for one field. `aria-required` only
|
|
74
|
+
// announces, doesn't enforce, which is exactly the gap that was missing.
|
|
68
75
|
function mkLabel(label: ReactNode, required?: boolean) {
|
|
69
76
|
return (
|
|
70
77
|
<>
|
|
@@ -225,6 +232,7 @@ function NumericField({
|
|
|
225
232
|
invalid,
|
|
226
233
|
invalidText,
|
|
227
234
|
placeholder,
|
|
235
|
+
required,
|
|
228
236
|
onChange,
|
|
229
237
|
}: {
|
|
230
238
|
id: string;
|
|
@@ -246,6 +254,8 @@ function NumericField({
|
|
|
246
254
|
invalid?: boolean;
|
|
247
255
|
invalidText?: string;
|
|
248
256
|
placeholder?: string;
|
|
257
|
+
/** Forwarded as `aria-required` on the underlying `TextInput` — see `mkLabel` for why not native `required`. */
|
|
258
|
+
required?: boolean;
|
|
249
259
|
onChange: (v: number) => void;
|
|
250
260
|
}) {
|
|
251
261
|
const [raw, setRaw] = useState<string | null>(null);
|
|
@@ -378,6 +388,7 @@ function NumericField({
|
|
|
378
388
|
invalid={invalid}
|
|
379
389
|
invalidText={invalidText}
|
|
380
390
|
placeholder={placeholder}
|
|
391
|
+
aria-required={required ? true : undefined}
|
|
381
392
|
/>
|
|
382
393
|
);
|
|
383
394
|
|
|
@@ -849,6 +860,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
849
860
|
disabled={disabled}
|
|
850
861
|
invalid={Boolean(invalidText)}
|
|
851
862
|
invalidText={invalidText}
|
|
863
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
852
864
|
/>
|
|
853
865
|
</DatePicker>
|
|
854
866
|
{field.helperText ? (
|
|
@@ -872,6 +884,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
872
884
|
disabled={disabled}
|
|
873
885
|
invalid={Boolean(invalidText)}
|
|
874
886
|
invalidText={invalidText}
|
|
887
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
875
888
|
>
|
|
876
889
|
{(field.options ?? []).map((opt) => (
|
|
877
890
|
<SelectItem key={String(opt.value)} value={opt.value} text={opt.label} />
|
|
@@ -944,6 +957,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
944
957
|
invalid={Boolean(effectiveInvalidText)}
|
|
945
958
|
invalidText={effectiveInvalidText}
|
|
946
959
|
placeholder={field.placeholder}
|
|
960
|
+
required={effectiveRequired}
|
|
947
961
|
onChange={(v) => setValue(field.key, v as TData[keyof TData])}
|
|
948
962
|
/>
|
|
949
963
|
</div>,
|
|
@@ -962,6 +976,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
962
976
|
setValue(field.key, Boolean(checked) as TData[keyof TData])
|
|
963
977
|
}
|
|
964
978
|
disabled={disabled}
|
|
979
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
965
980
|
/>
|
|
966
981
|
</div>,
|
|
967
982
|
];
|
|
@@ -999,6 +1014,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
999
1014
|
)
|
|
1000
1015
|
}
|
|
1001
1016
|
disabled={disabled}
|
|
1017
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
1002
1018
|
/>
|
|
1003
1019
|
</div>
|
|
1004
1020
|
</FieldShell>
|
|
@@ -1023,6 +1039,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
1023
1039
|
maxLength={field.maxLength}
|
|
1024
1040
|
invalid={Boolean(invalidText)}
|
|
1025
1041
|
invalidText={invalidText}
|
|
1042
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
1026
1043
|
/>
|
|
1027
1044
|
</div>,
|
|
1028
1045
|
];
|
|
@@ -1049,6 +1066,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
1049
1066
|
readOnly={effectiveReadonly}
|
|
1050
1067
|
invalid={Boolean(invalidText)}
|
|
1051
1068
|
invalidText={invalidText}
|
|
1069
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
1052
1070
|
/>
|
|
1053
1071
|
</div>,
|
|
1054
1072
|
];
|
|
@@ -1076,6 +1094,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
1076
1094
|
invalid={Boolean(invalidText)}
|
|
1077
1095
|
invalidText={invalidText}
|
|
1078
1096
|
placeholder={field.placeholder}
|
|
1097
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
1079
1098
|
/>
|
|
1080
1099
|
</div>,
|
|
1081
1100
|
];
|
|
@@ -1099,6 +1118,7 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
1099
1118
|
invalid={Boolean(invalidText)}
|
|
1100
1119
|
invalidText={invalidText}
|
|
1101
1120
|
placeholder={field.placeholder}
|
|
1121
|
+
aria-required={effectiveRequired ? true : undefined}
|
|
1102
1122
|
/>
|
|
1103
1123
|
</div>,
|
|
1104
1124
|
];
|
package/src/vitest.setup.ts
CHANGED
|
@@ -20,3 +20,17 @@ if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
|
|
|
20
20
|
/* no-op: jsdom nol punya viewport untuk digulir */
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// Sama alasannya, komponen beda: jsdom nol mengimplementasikan `ResizeObserver`.
|
|
25
|
+
// Carbon `TextArea` memanggilnya (auto-resize) saat mount — tanpa tambalan ini,
|
|
26
|
+
// SETIAP uji yang merender `TextArea` (langsung atau lewat `SchemaFormRenderer`)
|
|
27
|
+
// melempar `ReferenceError` di layout-effect, di luar rantai promise uji — kelas
|
|
28
|
+
// kegagalan yang sama: ringkasan `it()` bisa tetap hijau sementara exit code
|
|
29
|
+
// menolak. No-op sengaja: yang diuji adalah apa yang terender, bukan resize.
|
|
30
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
31
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
32
|
+
observe() {}
|
|
33
|
+
unobserve() {}
|
|
34
|
+
disconnect() {}
|
|
35
|
+
} as unknown as typeof ResizeObserver;
|
|
36
|
+
}
|