@isi-ui7/bos7-shared 0.3.10 → 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 +66 -0
- package/package.json +4 -4
- package/src/crud-components.tsx +187 -121
- package/src/crud-filter-fields.test.tsx +156 -0
- package/src/crud-types.ts +66 -0
- package/src/form-renderer-aria-required.test.tsx +101 -0
- package/src/form-renderer.tsx +21 -1
- package/src/list-add-button.test.tsx +74 -0
- package/src/vitest.setup.ts +14 -0
- package/src/wizard-mode-nav.test.tsx +58 -21
package/dist/crud-types.d.ts
CHANGED
|
@@ -75,6 +75,22 @@ export type CrudListSchema = {
|
|
|
75
75
|
columns: Record<string, unknown>;
|
|
76
76
|
popupMenuItems: CrudPopupMenuItem[];
|
|
77
77
|
addButtonLabel: string;
|
|
78
|
+
/**
|
|
79
|
+
* Tampilkan tombol "Tambah" di toolbar daftar. Default **true**.
|
|
80
|
+
*
|
|
81
|
+
* Ada karena sebagian modul menaruh entri "… Baru" di SIDE NAV, sehingga
|
|
82
|
+
* tombol di toolbar jadi jalan KEDUA ke halaman yang sama. Dua jalan ke satu
|
|
83
|
+
* halaman bukan sekadar mubazir: keduanya harus dijaga tetap sama, dan yang
|
|
84
|
+
* satu akan tertinggal saat rutenya berubah — tanpa berbunyi sebagai galat.
|
|
85
|
+
*
|
|
86
|
+
* Opsional dan default true SUPAYA 109 pemakai lain nol berubah; hanya yang
|
|
87
|
+
* menyetel `false` yang kehilangan tombolnya.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ Jangan setel `false` kalau modulnya nol punya jalur lain ke halaman
|
|
90
|
+
* create — halamannya tetap ada, hanya nol bisa dicapai siapa pun, dan itu
|
|
91
|
+
* bentuk kerusakan yang nol terlihat di uji mana pun.
|
|
92
|
+
*/
|
|
93
|
+
showAddButton?: boolean;
|
|
78
94
|
/** Enable column sorting. Defaults to true. */
|
|
79
95
|
showSort?: boolean;
|
|
80
96
|
/** Enable search bar. Defaults to true. */
|
|
@@ -111,6 +127,11 @@ export type CrudListSchema = {
|
|
|
111
127
|
* "(all)" removes the param. Mirrors `showInactiveToggle`'s additionalParams
|
|
112
128
|
* wiring but for an arbitrary enum column (e.g. `vendor_type`). Additive +
|
|
113
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.
|
|
114
135
|
*/
|
|
115
136
|
filterSelect?: {
|
|
116
137
|
/** Query param name the backend reads (e.g. "vendor_type"). */
|
|
@@ -124,6 +145,51 @@ export type CrudListSchema = {
|
|
|
124
145
|
/** Label for the "no filter" option. Default "Semua". */
|
|
125
146
|
allLabel?: string;
|
|
126
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;
|
|
127
193
|
};
|
|
128
194
|
export type CrudDeleteSchema<TData extends Record<string, unknown>> = {
|
|
129
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,13 +106,13 @@
|
|
|
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
|
-
"@isi-ui7/i18n": "0.2.3",
|
|
113
112
|
"@isi-ui7/lookup-input": "0.2.4",
|
|
114
113
|
"@isi-ui7/modal-manager": "0.2.2",
|
|
115
114
|
"@isi-ui7/ui-shell": "0.2.8",
|
|
115
|
+
"@isi-ui7/i18n": "0.2.3",
|
|
116
116
|
"@isi-ui7/realtime": "0.2.3"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
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,13 +13,14 @@ 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 { CrudForm, FormMode } from "./form-types";
|
|
22
|
+
import type { CrudCustomActionSchema, CrudDeleteSchema, CrudFilterFieldDef, CrudListSchema } from "./crud-types";
|
|
23
|
+
import type { CrudForm, FormMode, FormPageDef } from "./form-types";
|
|
23
24
|
import type { Ui7FormDensity } from "./style-contract";
|
|
24
25
|
import { SchemaFormRenderer } from "./form-renderer";
|
|
25
26
|
import { WizardStepper, type WizardStepState } from "./wizard-stepper";
|
|
@@ -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,10 +180,43 @@ 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
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
215
|
+
{(schema.showAddButton ?? true) ? (
|
|
216
|
+
<Button renderIcon={Add} size="sm" onClick={onAdd}>
|
|
217
|
+
{schema.addButtonLabel || labels.next}
|
|
218
|
+
</Button>
|
|
219
|
+
) : null}
|
|
140
220
|
</div>
|
|
141
221
|
}
|
|
142
222
|
/>
|
|
@@ -501,15 +581,21 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
|
|
|
501
581
|
* dilewati: footer mode-view sengaja nol tombol Lanjut, jadi satu-satunya
|
|
502
582
|
* jalan maju adalah menebak bahwa stepper-nya bisa diklik.
|
|
503
583
|
*
|
|
504
|
-
* Karena itu di luar `create` wizard dirender
|
|
505
|
-
*
|
|
506
|
-
* Yang HILANG hanya
|
|
584
|
+
* Karena itu di luar `create` wizard dirender sebagai TAB biasa: halaman
|
|
585
|
+
* tetap terpisah dan bisa dilompati bebas, tanpa stepper, tanpa urutan
|
|
586
|
+
* wajib, tanpa Lanjut/Kembali. Yang HILANG hanya PEMANDUANNYA; isinya
|
|
587
|
+
* tidak ada yang dibuang.
|
|
588
|
+
*
|
|
589
|
+
* (2026-08-19 ini sempat dirender DATAR — satu aliran panjang. Diganti
|
|
590
|
+
* 2026-08-20: pada form 5 langkah hasilnya 13 panel beruntun dalam satu
|
|
591
|
+
* kolom yang sangat panjang. Yang dilarang adalah wizard MEMANDU, bukan
|
|
592
|
+
* halamannya terpisah; tab memisahkan tanpa memandu.)
|
|
507
593
|
*
|
|
508
594
|
* Aman terhadap validasi: `handleSave` memakai `validateAll()`, yang sudah
|
|
509
595
|
* mengumpulkan field dari SELURUH halaman tanpa peduli halaman aktif —
|
|
510
|
-
* jadi menyimpan dari bentuk
|
|
596
|
+
* jadi menyimpan dari bentuk bertab memeriksa hal yang sama persis.
|
|
511
597
|
*
|
|
512
|
-
* Keputusan PM 2026-08-19. Sengaja dipasang di sini, BUKAN di skema tiap
|
|
598
|
+
* Keputusan PM 2026-08-19, direvisi 2026-08-20. Sengaja dipasang di sini, BUKAN di skema tiap
|
|
513
599
|
* app: ini keputusan tentang cara me-render sebuah layout, dan menaruhnya
|
|
514
600
|
* di skema berarti tiap app harus mengingat untuk mengulanginya.
|
|
515
601
|
*/
|
|
@@ -534,117 +620,80 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
|
|
|
534
620
|
);
|
|
535
621
|
}
|
|
536
622
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
marginBottom: "-1px",
|
|
566
|
-
cursor: "pointer",
|
|
567
|
-
fontSize: "0.875rem",
|
|
568
|
-
lineHeight: "1.25rem",
|
|
569
|
-
fontWeight: i === activeTab ? 600 : 400,
|
|
570
|
-
color:
|
|
571
|
-
i === activeTab
|
|
572
|
-
? "var(--cds-text-primary, #161616)"
|
|
573
|
-
: "var(--cds-text-secondary, #525252)",
|
|
574
|
-
}}
|
|
575
|
-
>
|
|
576
|
-
{pg.label}
|
|
577
|
-
</button>
|
|
578
|
-
))}
|
|
579
|
-
</div>
|
|
580
|
-
{layout.pages.map((pg, i) => (
|
|
581
|
-
<div key={i} style={{ display: i === activeTab ? "block" : "none" }}>
|
|
582
|
-
<SchemaFormRenderer
|
|
583
|
-
mode={mode}
|
|
584
|
-
value={form}
|
|
585
|
-
sections={pg.sections}
|
|
586
|
-
errors={fieldErrors}
|
|
587
|
-
disabled={saving}
|
|
588
|
-
onChange={setForm}
|
|
589
|
-
density={density}
|
|
590
|
-
width={width}
|
|
591
|
-
/>
|
|
592
|
-
</div>
|
|
593
|
-
))}
|
|
594
|
-
</div>
|
|
595
|
-
</PageBody>
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Wizard di luar `create` — datar, tanpa stepper. Lihat `wizardNav`.
|
|
600
|
-
//
|
|
601
|
-
// Syaratnya ditulis LENGKAP (`layout.type === "wizard" && !wizardNav`),
|
|
602
|
-
// bukan `!wizardNav` saja. Keduanya setara hari ini, tapi bentuk pendek
|
|
603
|
-
// benar hanya karena `single-page` dan `tabs` sudah return di atas — ia
|
|
604
|
-
// cabang *sisa*, bukan cabang *wizard*. Varian layout ke-5 yang lupa
|
|
605
|
-
// ditaruh di atas sini akan ikut dirender datar tanpa galat, tanpa lint,
|
|
606
|
-
// tanpa uji yang menahan. Dengan bentuk lengkap, kelalaian yang sama
|
|
607
|
-
// menjadi galat `tsc` di `layout.pages` beberapa baris di bawah.
|
|
608
|
-
if (layout.type === "wizard" && !wizardNav) {
|
|
609
|
-
return (
|
|
610
|
-
<PageBody>
|
|
611
|
-
{errBanner}
|
|
612
|
-
{layout.pages.map((pg, i) => (
|
|
613
|
-
<div key={i} style={{ marginBottom: "2.5rem" }}>
|
|
614
|
-
{/* Label halaman tetap dirender — sebagai JUDUL KELOMPOK, bukan
|
|
615
|
-
navigasi. Membuang steppernya tidak boleh ikut membuang satu-
|
|
616
|
-
satunya tempat `pg.label`/`pg.sublabel` pernah tampil: pada
|
|
617
|
-
form 5 langkah, hasilnya 13 panel beruntun tanpa satu pun
|
|
618
|
-
pemisah tingkat-halaman. Yang dilarang PM adalah navigasinya,
|
|
619
|
-
bukan judulnya. */}
|
|
620
|
-
<div
|
|
623
|
+
/**
|
|
624
|
+
* Halaman ber-tab. Dipakai DUA layout: `tabs`, dan `wizard` di luar
|
|
625
|
+
* `create` (lihat `wizardNav`).
|
|
626
|
+
*
|
|
627
|
+
* Sengaja SATU fungsi, bukan dua blok kembar. Keduanya wajib tetap
|
|
628
|
+
* terlihat sama, dan salinan kedua akan tertinggal saat yang satu
|
|
629
|
+
* disunting — tanpa berbunyi sebagai galat, karena keduanya tetap
|
|
630
|
+
* me-render tab yang bekerja.
|
|
631
|
+
*/
|
|
632
|
+
const renderTabbedPages = (pages: FormPageDef<TData>[]) => (
|
|
633
|
+
<PageBody>
|
|
634
|
+
{errBanner}
|
|
635
|
+
<div>
|
|
636
|
+
<div
|
|
637
|
+
role="tablist"
|
|
638
|
+
style={{
|
|
639
|
+
display: "flex",
|
|
640
|
+
borderBottom: "1px solid var(--cds-border-subtle-01, #e0e0e0)",
|
|
641
|
+
marginBottom: 0,
|
|
642
|
+
}}
|
|
643
|
+
>
|
|
644
|
+
{pages.map((pg, i) => (
|
|
645
|
+
<button
|
|
646
|
+
key={i}
|
|
647
|
+
type="button"
|
|
648
|
+
role="tab"
|
|
649
|
+
aria-selected={i === activeTab}
|
|
650
|
+
onClick={() => setActiveTab(i)}
|
|
621
651
|
style={{
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
652
|
+
padding: "0.75rem 1rem",
|
|
653
|
+
background: "none",
|
|
654
|
+
border: "none",
|
|
655
|
+
borderBottom:
|
|
656
|
+
i === activeTab
|
|
657
|
+
? "2px solid var(--cds-interactive-01, #0f62fe)"
|
|
658
|
+
: "2px solid transparent",
|
|
659
|
+
marginBottom: "-1px",
|
|
660
|
+
cursor: "pointer",
|
|
661
|
+
fontSize: "0.875rem",
|
|
662
|
+
lineHeight: "1.25rem",
|
|
663
|
+
fontWeight: i === activeTab ? 600 : 400,
|
|
664
|
+
color:
|
|
665
|
+
i === activeTab
|
|
666
|
+
? "var(--cds-text-primary, #161616)"
|
|
667
|
+
: "var(--cds-text-secondary, #525252)",
|
|
625
668
|
}}
|
|
626
669
|
>
|
|
670
|
+
{pg.label}
|
|
671
|
+
</button>
|
|
672
|
+
))}
|
|
673
|
+
</div>
|
|
674
|
+
{pages.map((pg, i) => (
|
|
675
|
+
<div
|
|
676
|
+
key={i}
|
|
677
|
+
role="tabpanel"
|
|
678
|
+
style={{ display: i === activeTab ? "block" : "none" }}
|
|
679
|
+
>
|
|
680
|
+
{/* `sublabel` dirender di dalam panel, bukan di tombol tab —
|
|
681
|
+
tombolnya cuma memuat label supaya barisnya tidak melebar.
|
|
682
|
+
Ia HARUS ada di salah satu dari keduanya: pada wizard, tempat
|
|
683
|
+
`sublabel` pernah tampil adalah steppernya, dan stepper itu
|
|
684
|
+
yang barusan dibuang. Tanpa baris ini 5 string lenyap dari
|
|
685
|
+
layar tanpa satu pun galat. */}
|
|
686
|
+
{pg.sublabel ? (
|
|
627
687
|
<div
|
|
628
688
|
style={{
|
|
629
|
-
fontSize: "
|
|
630
|
-
|
|
631
|
-
|
|
689
|
+
fontSize: "0.75rem",
|
|
690
|
+
color: "var(--cds-text-secondary, #525252)",
|
|
691
|
+
margin: "0.75rem 0 0",
|
|
632
692
|
}}
|
|
633
693
|
>
|
|
634
|
-
{pg.
|
|
694
|
+
{pg.sublabel}
|
|
635
695
|
</div>
|
|
636
|
-
|
|
637
|
-
<div
|
|
638
|
-
style={{
|
|
639
|
-
fontSize: "0.75rem",
|
|
640
|
-
color: "var(--cds-text-secondary, #525252)",
|
|
641
|
-
marginTop: "0.125rem",
|
|
642
|
-
}}
|
|
643
|
-
>
|
|
644
|
-
{pg.sublabel}
|
|
645
|
-
</div>
|
|
646
|
-
) : null}
|
|
647
|
-
</div>
|
|
696
|
+
) : null}
|
|
648
697
|
<SchemaFormRenderer
|
|
649
698
|
mode={mode}
|
|
650
699
|
value={form}
|
|
@@ -654,16 +703,33 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
|
|
|
654
703
|
onChange={setForm}
|
|
655
704
|
density={density}
|
|
656
705
|
// `width` apa adanya, BUKAN `width ?? "full"` seperti cabang
|
|
657
|
-
// wizard. `full` di sana berarti "penuhi kolom
|
|
658
|
-
// keluar untuk ruang yang dipersempit stepper.
|
|
659
|
-
// stepper, jadi alasannya hilang dan default
|
|
660
|
-
// ("two-thirds") kembali berlaku.
|
|
706
|
+
// wizard ber-stepper. `full` di sana berarti "penuhi kolom
|
|
707
|
+
// KIRI", jalan keluar untuk ruang yang dipersempit stepper.
|
|
708
|
+
// Di sini nol stepper, jadi alasannya hilang dan default
|
|
709
|
+
// kontrak ("two-thirds") kembali berlaku.
|
|
661
710
|
width={width}
|
|
662
711
|
/>
|
|
663
712
|
</div>
|
|
664
713
|
))}
|
|
665
|
-
</
|
|
666
|
-
|
|
714
|
+
</div>
|
|
715
|
+
</PageBody>
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
if (layout.type === "tabs") {
|
|
719
|
+
return renderTabbedPages(layout.pages);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Wizard di luar `create`. Lihat `wizardNav`.
|
|
723
|
+
//
|
|
724
|
+
// Syaratnya ditulis LENGKAP (`layout.type === "wizard" && !wizardNav`),
|
|
725
|
+
// bukan `!wizardNav` saja. Keduanya setara hari ini, tapi bentuk pendek
|
|
726
|
+
// benar hanya karena `single-page` dan `tabs` sudah return di atas — ia
|
|
727
|
+
// cabang *sisa*, bukan cabang *wizard*. Varian layout ke-5 yang lupa
|
|
728
|
+
// ditaruh di atas sini akan ikut dirender bertab tanpa galat, tanpa lint,
|
|
729
|
+
// tanpa uji yang menahan. Dengan bentuk lengkap, kelalaian yang sama
|
|
730
|
+
// menjadi galat `tsc` di `layout.pages`.
|
|
731
|
+
if (layout.type === "wizard" && !wizardNav) {
|
|
732
|
+
return renderTabbedPages(layout.pages);
|
|
667
733
|
}
|
|
668
734
|
|
|
669
735
|
const currentPage = layout.pages[page];
|
|
@@ -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
|
@@ -56,6 +56,22 @@ export type CrudListSchema = {
|
|
|
56
56
|
columns: Record<string, unknown>;
|
|
57
57
|
popupMenuItems: CrudPopupMenuItem[];
|
|
58
58
|
addButtonLabel: string;
|
|
59
|
+
/**
|
|
60
|
+
* Tampilkan tombol "Tambah" di toolbar daftar. Default **true**.
|
|
61
|
+
*
|
|
62
|
+
* Ada karena sebagian modul menaruh entri "… Baru" di SIDE NAV, sehingga
|
|
63
|
+
* tombol di toolbar jadi jalan KEDUA ke halaman yang sama. Dua jalan ke satu
|
|
64
|
+
* halaman bukan sekadar mubazir: keduanya harus dijaga tetap sama, dan yang
|
|
65
|
+
* satu akan tertinggal saat rutenya berubah — tanpa berbunyi sebagai galat.
|
|
66
|
+
*
|
|
67
|
+
* Opsional dan default true SUPAYA 109 pemakai lain nol berubah; hanya yang
|
|
68
|
+
* menyetel `false` yang kehilangan tombolnya.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ Jangan setel `false` kalau modulnya nol punya jalur lain ke halaman
|
|
71
|
+
* create — halamannya tetap ada, hanya nol bisa dicapai siapa pun, dan itu
|
|
72
|
+
* bentuk kerusakan yang nol terlihat di uji mana pun.
|
|
73
|
+
*/
|
|
74
|
+
showAddButton?: boolean;
|
|
59
75
|
/** Enable column sorting. Defaults to true. */
|
|
60
76
|
showSort?: boolean;
|
|
61
77
|
/** Enable search bar. Defaults to true. */
|
|
@@ -92,6 +108,11 @@ export type CrudListSchema = {
|
|
|
92
108
|
* "(all)" removes the param. Mirrors `showInactiveToggle`'s additionalParams
|
|
93
109
|
* wiring but for an arbitrary enum column (e.g. `vendor_type`). Additive +
|
|
94
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.
|
|
95
116
|
*/
|
|
96
117
|
filterSelect?: {
|
|
97
118
|
/** Query param name the backend reads (e.g. "vendor_type"). */
|
|
@@ -102,8 +123,53 @@ export type CrudListSchema = {
|
|
|
102
123
|
/** Label for the "no filter" option. Default "Semua". */
|
|
103
124
|
allLabel?: string;
|
|
104
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[];
|
|
105
146
|
};
|
|
106
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
|
+
|
|
107
173
|
export type CrudDeleteSchema<TData extends Record<string, unknown>> = {
|
|
108
174
|
/** Omit to use i18n default ("Konfirmasi Hapus"). */
|
|
109
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
|
];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
// ServerDataTable menarik jaringan & Carbon DataTable penuh; yang diuji di sini
|
|
5
|
+
// TOOLBAR-nya, jadi tabelnya dimock dan `toolbarActions` dirender apa adanya.
|
|
6
|
+
vi.mock("@isi-ui7/data-table", () => ({
|
|
7
|
+
ServerDataTable: ({ toolbarActions }: { toolbarActions?: React.ReactNode }) => (
|
|
8
|
+
<div data-testid="tabel">{toolbarActions}</div>
|
|
9
|
+
),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { SharedCrudListPage } from "./crud-components";
|
|
13
|
+
import type { CrudListSchema } from "./crud-types";
|
|
14
|
+
|
|
15
|
+
const dasar: CrudListSchema = {
|
|
16
|
+
title: "Daftar",
|
|
17
|
+
apiPath: "/api/x",
|
|
18
|
+
columns: { a: { title: "A" } },
|
|
19
|
+
popupMenuItems: [],
|
|
20
|
+
addButtonLabel: "Tambah Sesuatu",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `showAddButton` — opsional, default TRUE.
|
|
25
|
+
*
|
|
26
|
+
* Ada karena sebagian modul menaruh entri "… Baru" di SIDE NAV, sehingga tombol
|
|
27
|
+
* toolbar jadi jalan KEDUA ke halaman yang sama. Dua jalan ke satu halaman harus
|
|
28
|
+
* dijaga tetap sama, dan yang satu akan tertinggal saat rutenya berubah — tanpa
|
|
29
|
+
* berbunyi sebagai galat.
|
|
30
|
+
*/
|
|
31
|
+
describe("tombol Tambah di daftar", () => {
|
|
32
|
+
it("DEFAULT tetap tampil — 109 pemakai lain nol boleh berubah", () => {
|
|
33
|
+
render(<SharedCrudListPage schema={dasar} onAdd={() => {}} onPopupClick={() => {}} />);
|
|
34
|
+
expect(screen.queryByRole("button", { name: /Tambah Sesuatu/ })).not.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("`showAddButton: true` eksplisit juga tampil", () => {
|
|
38
|
+
render(
|
|
39
|
+
<SharedCrudListPage
|
|
40
|
+
schema={{ ...dasar, showAddButton: true }}
|
|
41
|
+
onAdd={() => {}}
|
|
42
|
+
onPopupClick={() => {}}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
45
|
+
expect(screen.queryByRole("button", { name: /Tambah Sesuatu/ })).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("🔑 `showAddButton: false` MENYEMBUNYIKANNYA", () => {
|
|
49
|
+
render(
|
|
50
|
+
<SharedCrudListPage
|
|
51
|
+
schema={{ ...dasar, showAddButton: false }}
|
|
52
|
+
onAdd={() => {}}
|
|
53
|
+
onPopupClick={() => {}}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(screen.queryByRole("button", { name: /Tambah Sesuatu/ })).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("kontrol negatif — toolbarExtra TETAP dirender saat tombol Tambah disembunyikan", () => {
|
|
60
|
+
// Kalau perbaikannya diterapkan terlalu lebar (mis. seluruh blok toolbar
|
|
61
|
+
// dibungkus kondisi), aksi toolbar lain ikut hilang — dan hilangnya nol
|
|
62
|
+
// berbunyi karena tabelnya tetap tampil.
|
|
63
|
+
render(
|
|
64
|
+
<SharedCrudListPage
|
|
65
|
+
schema={{ ...dasar, showAddButton: false }}
|
|
66
|
+
onAdd={() => {}}
|
|
67
|
+
onPopupClick={() => {}}
|
|
68
|
+
toolbarExtra={<button type="button">Aksi Lain</button>}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.queryByRole("button", { name: /Aksi Lain/ })).not.toBeNull();
|
|
72
|
+
expect(screen.queryByRole("button", { name: /Tambah Sesuatu/ })).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
3
3
|
|
|
4
4
|
// Dimock supaya mode `edit`/`view` tidak menggantung di InlineLoading: tanpa
|
|
5
5
|
// `loadApiPath` yang selesai, `loading` tidak pernah kembali false dan yang
|
|
@@ -40,6 +40,27 @@ const stepper = () => screen.queryByRole("navigation");
|
|
|
40
40
|
// ketiga mode, sehingga uji ini membandingkan hal yang sama di semua mode.
|
|
41
41
|
const fieldTampil = (nama: string) => screen.queryByText(nama) !== null;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* ADA DI DOM ≠ TERLIHAT.
|
|
45
|
+
*
|
|
46
|
+
* Panel tab yang tidak aktif tetap dirender, hanya `display: none`. Jadi
|
|
47
|
+
* `queryByText` menemukannya, dan uji yang hanya bertanya "ada?" akan HIJAU
|
|
48
|
+
* untuk halaman yang tidak bisa dilihat siapa pun. Helper ini menaiki pohon ke
|
|
49
|
+
* `[role=tabpanel]` terdekat dan membaca `display`-nya, sehingga yang diukur
|
|
50
|
+
* adalah apa yang benar-benar tampil di layar.
|
|
51
|
+
*
|
|
52
|
+
* `null` = teksnya tidak ada sama sekali (beda dari "ada tapi tersembunyi").
|
|
53
|
+
*/
|
|
54
|
+
const panelTampil = (nama: string): boolean | null => {
|
|
55
|
+
const el = screen.queryByText(nama);
|
|
56
|
+
if (!el) return null;
|
|
57
|
+
const panel = el.closest('[role="tabpanel"]') as HTMLElement | null;
|
|
58
|
+
if (!panel) return true; // bukan layout bertab — terender langsung
|
|
59
|
+
return panel.style.display !== "none";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const tabs = () => screen.queryAllByRole("tab");
|
|
63
|
+
|
|
43
64
|
beforeEach(() => {
|
|
44
65
|
vi.clearAllMocks();
|
|
45
66
|
});
|
|
@@ -55,17 +76,22 @@ describe("navigasi wizard hanya pada mode create", () => {
|
|
|
55
76
|
expect(fieldTampil("Field Tiga")).toBe(false);
|
|
56
77
|
});
|
|
57
78
|
|
|
58
|
-
it("view — stepper TIDAK ada, dan
|
|
79
|
+
it("view — stepper TIDAK ada, diganti TAB, dan tiap halaman terjangkau", async () => {
|
|
59
80
|
render(<CrudSchemaPage schema={schema} mode="view" />);
|
|
60
81
|
await waitFor(() => expect(fieldTampil("Field Satu")).toBe(true));
|
|
61
82
|
|
|
62
83
|
expect(stepper(), "view tidak boleh punya navigasi langkah").toBeNull();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
expect(tabs().length, "wizard di luar create harus jadi tab, bukan datar").toBe(3);
|
|
85
|
+
|
|
86
|
+
// Inti perbaikannya: nol stepper TIDAK boleh berarti isi yang tak
|
|
87
|
+
// terjangkau. Sebelum ini `view` merender satu halaman saja sementara
|
|
88
|
+
// footernya nol tombol Lanjut — langkah 2-5 tidak bisa dibaca sama sekali.
|
|
89
|
+
expect(panelTampil("Field Satu")).toBe(true);
|
|
90
|
+
expect(panelTampil("Field Tiga"), "halaman 3 belum dibuka").toBe(false);
|
|
91
|
+
|
|
92
|
+
fireEvent.click(screen.getByRole("tab", { name: "Langkah Tiga" }));
|
|
93
|
+
expect(panelTampil("Field Tiga"), "klik tab 3 harus menampilkannya").toBe(true);
|
|
94
|
+
expect(panelTampil("Field Satu"), "tab 1 harus ikut tertutup").toBe(false);
|
|
69
95
|
});
|
|
70
96
|
|
|
71
97
|
// Ditambahkan setelah verifikasi adversarial membantah klaim "nol isi yang
|
|
@@ -79,12 +105,20 @@ describe("navigasi wizard hanya pada mode create", () => {
|
|
|
79
105
|
render(<CrudSchemaPage schema={schema} mode={mode} />);
|
|
80
106
|
await waitFor(() => expect(fieldTampil("Field Satu")).toBe(true));
|
|
81
107
|
|
|
82
|
-
expect(stepper(), "
|
|
108
|
+
expect(stepper(), "tab bukan landmark navigasi").toBeNull();
|
|
109
|
+
// Label halaman kini jadi teks tombol tab.
|
|
83
110
|
for (const l of ["Langkah Satu", "Langkah Dua", "Langkah Tiga"]) {
|
|
84
|
-
expect(
|
|
111
|
+
expect(
|
|
112
|
+
tabs().some((t) => t.textContent === l),
|
|
113
|
+
`${l} hilang dari ${mode}`,
|
|
114
|
+
).toBe(true);
|
|
85
115
|
}
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
// Sublabel TIDAK muat di tombol tab, jadi ia pindah ke dalam panel.
|
|
117
|
+
// Diperiksa terpisah justru karena itu: tempatnya berbeda dari labelnya,
|
|
118
|
+
// dan tempat yang berbeda adalah tempat yang bisa terlupa.
|
|
119
|
+
for (const [i, sub] of ["Sub Satu", "Sub Dua", "Sub Tiga"].entries()) {
|
|
120
|
+
fireEvent.click(tabs()[i]);
|
|
121
|
+
expect(panelTampil(sub), `sublabel ${sub} hilang dari ${mode}`).toBe(true);
|
|
88
122
|
}
|
|
89
123
|
},
|
|
90
124
|
);
|
|
@@ -94,19 +128,22 @@ describe("navigasi wizard hanya pada mode create", () => {
|
|
|
94
128
|
await waitFor(() => expect(fieldTampil("Field Satu")).toBe(true));
|
|
95
129
|
|
|
96
130
|
expect(stepper(), "edit tidak boleh punya navigasi langkah").toBeNull();
|
|
97
|
-
expect(
|
|
131
|
+
expect(tabs().length).toBe(3);
|
|
132
|
+
fireEvent.click(screen.getByRole("tab", { name: "Langkah Tiga" }));
|
|
133
|
+
expect(panelTampil("Field Tiga")).toBe(true);
|
|
98
134
|
|
|
99
135
|
const tombol = screen.getAllByRole("button").map((b) => b.textContent ?? "");
|
|
100
136
|
expect(tombol.some((t) => /lanjut|next/i.test(t)), `masih ada tombol Lanjut: ${tombol.join("|")}`).toBe(false);
|
|
101
137
|
expect(tombol.some((t) => /kembali|prev/i.test(t)), `masih ada tombol Kembali: ${tombol.join("|")}`).toBe(false);
|
|
102
138
|
});
|
|
103
139
|
|
|
104
|
-
// Kontrol
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
// Kontrol negatif — kini SEMAKIN perlu, bukan semakin tidak: sejak kedua
|
|
141
|
+
// layout memakai perender yang SAMA, cacat di perender itu memukul keduanya
|
|
142
|
+
// sekaligus, dan uji wizard sendirian tidak akan menunjukkan bahwa 4 layar
|
|
143
|
+
// bos7-enterprise/bos7-financing ikut rusak. `tabs` di sini punya 2 halaman
|
|
144
|
+
// (wizard 3) supaya keduanya tidak bisa saling menyamar.
|
|
145
|
+
it("kontrol negatif — layout tabs ikut sehat memakai perender bersama", async () => {
|
|
146
|
+
const skemaTabs: CrudForm<D> = {
|
|
110
147
|
...schema,
|
|
111
148
|
layout: {
|
|
112
149
|
type: "tabs",
|
|
@@ -116,11 +153,11 @@ describe("navigasi wizard hanya pada mode create", () => {
|
|
|
116
153
|
],
|
|
117
154
|
},
|
|
118
155
|
};
|
|
119
|
-
render(<CrudSchemaPage schema={
|
|
156
|
+
render(<CrudSchemaPage schema={skemaTabs} mode="view" />);
|
|
120
157
|
await waitFor(() => expect(fieldTampil("Field Satu")).toBe(true));
|
|
121
158
|
|
|
122
159
|
// Tab masih tab: tombol role=tab ada, dan hanya tab aktif yang terlihat.
|
|
123
|
-
expect(
|
|
160
|
+
expect(tabs().length, "layout tabs kehilangan tabnya").toBe(2);
|
|
124
161
|
expect(stepper()).toBeNull();
|
|
125
162
|
});
|
|
126
163
|
|