@isi-ui7/bos7-shared 0.3.4 → 0.3.6
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/form-renderer-utils.d.ts +25 -1
- package/dist/form-rules.d.ts +17 -0
- package/dist/form-types.d.ts +13 -0
- package/package.json +1 -1
- package/src/date-and-hidden-validation.test.ts +140 -0
- package/src/form-renderer-utils.ts +51 -3
- package/src/form-renderer.tsx +14 -6
- package/src/form-rules.ts +42 -0
- package/src/form-section-hidden.test.tsx +87 -0
- package/src/form-types.ts +13 -0
- package/src/workflow/use-crud-form.ts +10 -1
|
@@ -5,6 +5,30 @@ export declare function resolveApiPath(path: ApiPath, params: Record<string, str
|
|
|
5
5
|
type ValidationI18n = Pick<BosSharedLabels, "validRequired" | "validMinLength" | "validMaxLength" | "validMin" | "validMax" | "validPattern">;
|
|
6
6
|
export declare function runFieldValidation<TData extends Record<string, unknown>>(fields: FormField<TData>[], data: TData, mode: FormMode, i18n?: Partial<ValidationI18n>): Partial<Record<keyof TData, string>>;
|
|
7
7
|
export declare function collectAllFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>): FormField<TData>[];
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Field yang BOLEH divalidasi pada satu halaman.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ Section yang `hidden` DILEWATI. Tanpa ini, field wajib di panel yang belum
|
|
12
|
+
* terbuka ikut divalidasi — dan operator melihat "wajib diisi" untuk field yang
|
|
13
|
+
* tidak ada di layarnya, tanpa cara memperbaikinya.
|
|
14
|
+
*
|
|
15
|
+
* `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya,
|
|
16
|
+
* hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
|
|
17
|
+
* diperlakukan sebagai TIDAK tersembunyi, arah aman untuk validasi.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectPageFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>, pageIndex: number, mode?: FormMode, data?: TData): FormField<TData>[];
|
|
9
20
|
export declare function getLayoutPageCount<TData extends Record<string, unknown>>(layout: FormLayout<TData>): number;
|
|
21
|
+
/**
|
|
22
|
+
* Tanggal yang dipilih → "YYYY-MM-DD" menurut kalender LOKAL.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ Ada karena `date.toISOString().slice(0,10)` MENGGESER tanggal satu hari
|
|
25
|
+
* bagi siapa pun di timur UTC. `new Date(1990,0,1)` di WIB (UTC+7) menjadi
|
|
26
|
+
* "1989-12-31T17:00:00Z" — tanggal lahir yang diketik operator tersimpan
|
|
27
|
+
* mundur sehari, tanpa satu pun galat, di setiap field tanggal di setiap modul.
|
|
28
|
+
*
|
|
29
|
+
* Komponen lokal dipakai apa adanya: pemilih tanggal memilih HARI di kalender
|
|
30
|
+
* pengguna, bukan sebuah titik waktu. Mengubahnya ke UTC memperlakukannya
|
|
31
|
+
* sebagai titik waktu, dan di situlah pergeserannya lahir.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toLocalISODate(date: Date | undefined | null): string;
|
|
10
34
|
export {};
|
package/dist/form-rules.d.ts
CHANGED
|
@@ -7,6 +7,23 @@ export declare const Rules: {
|
|
|
7
7
|
readonly nik: (msg?: string) => FieldValidatorFn;
|
|
8
8
|
readonly kodePos: (msg?: string) => FieldValidatorFn;
|
|
9
9
|
readonly phone: (rule: PhoneRule, msg?: string) => FieldValidatorFn;
|
|
10
|
+
/**
|
|
11
|
+
* Tanggal yang tersimpan harus berupa tanggal KALENDER yang sungguh ada.
|
|
12
|
+
*
|
|
13
|
+
* Nilai yang disimpan berformat ISO (YYYY-MM-DD) meski ditampilkan
|
|
14
|
+
* DD-MM-YYYY — aturan ini memeriksa nilai simpannya.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Memeriksa hari-nya kembali, bukan hanya `new Date(v)` tidak NaN.
|
|
17
|
+
* `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret alih-alih
|
|
18
|
+
* menolak, sehingga tanggal yang tidak ada lolos dan tersimpan sebagai
|
|
19
|
+
* tanggal LAIN — tanpa galat, dan tanpa cara mengetahuinya kemudian.
|
|
20
|
+
*/
|
|
21
|
+
readonly date: (msg?: string) => FieldValidatorFn;
|
|
22
|
+
/**
|
|
23
|
+
* Tanggal tidak boleh di masa depan — untuk tanggal lahir dan sejenisnya.
|
|
24
|
+
* `now` bisa disuntik agar bisa diuji tanpa bergantung jam mesin.
|
|
25
|
+
*/
|
|
26
|
+
readonly dateNotFuture: (msg?: string, now?: () => Date) => FieldValidatorFn;
|
|
10
27
|
readonly regex: (pattern: RegExp, msg: string) => FieldValidatorFn;
|
|
11
28
|
};
|
|
12
29
|
export declare function buildValidator<TData extends Record<string, unknown>>(crossField: (form: TData) => Partial<Record<keyof TData, string>>): (form: TData) => Partial<Record<keyof TData, string>>;
|
package/dist/form-types.d.ts
CHANGED
|
@@ -165,6 +165,19 @@ export type FormSection<TData extends Record<string, unknown>> = {
|
|
|
165
165
|
mode: FormMode;
|
|
166
166
|
disabled: boolean;
|
|
167
167
|
}) => ReactNode;
|
|
168
|
+
/**
|
|
169
|
+
* Sembunyikan SELURUH section — judul, deskripsi, dan isinya.
|
|
170
|
+
*
|
|
171
|
+
* Bentuknya sengaja sama persis dengan `FormField.hidden` supaya tidak ada
|
|
172
|
+
* dua idiom untuk hal yang sama.
|
|
173
|
+
*
|
|
174
|
+
* Ada karena menyembunyikan tiap field satu per satu TIDAK cukup: judul dan
|
|
175
|
+
* deskripsi section tetap terender, meninggalkan kepala panel kosong yang
|
|
176
|
+
* terlihat seperti bug — dan pada form yang panelnya memang harus terkunci
|
|
177
|
+
* dulu, kepala kosong itu justru mengiklankan keberadaan bagian yang belum
|
|
178
|
+
* boleh diisi.
|
|
179
|
+
*/
|
|
180
|
+
hidden?: boolean | ((mode: FormMode, data: TData) => boolean);
|
|
168
181
|
};
|
|
169
182
|
export type FormPageDef<TData extends Record<string, unknown>> = {
|
|
170
183
|
label: string;
|
package/package.json
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toLocalISODate, collectPageFields, runFieldValidation } from "./form-renderer-utils";
|
|
3
|
+
import { Rules } from "./form-rules";
|
|
4
|
+
import type { FormLayout, FormSection } from "./form-types";
|
|
5
|
+
|
|
6
|
+
type D = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// toLocalISODate — pergeseran tanggal satu hari
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
describe("toLocalISODate", () => {
|
|
12
|
+
it("TIDAK menggeser tanggal bagi zona di timur UTC", () => {
|
|
13
|
+
// Ini bug yang sesungguhnya: `date.toISOString().slice(0,10)` mengubah
|
|
14
|
+
// tanggal ke UTC. Di WIB (UTC+7), 1 Januari pukul 00:00 lokal adalah
|
|
15
|
+
// 31 Desember pukul 17:00 UTC — dan tanggal lahir yang diketik operator
|
|
16
|
+
// tersimpan MUNDUR SEHARI, di setiap field tanggal di setiap modul.
|
|
17
|
+
const picked = new Date(1990, 0, 1); // 1 Jan 1990, waktu lokal
|
|
18
|
+
expect(toLocalISODate(picked)).toBe("1990-01-01");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("kontrol: cara LAMA memang menggeser — membuktikan tes ini mengukur sesuatu", () => {
|
|
22
|
+
const picked = new Date(1990, 0, 1);
|
|
23
|
+
const lama = picked.toISOString().slice(0, 10);
|
|
24
|
+
const offsetMenit = picked.getTimezoneOffset(); // negatif di timur UTC
|
|
25
|
+
if (offsetMenit < 0) {
|
|
26
|
+
expect(lama).not.toBe("1990-01-01");
|
|
27
|
+
} else {
|
|
28
|
+
// Di UTC atau barat UTC cara lama kebetulan benar — itulah sebabnya bug
|
|
29
|
+
// ini bisa lolos di mesin pengembang yang zonanya berbeda.
|
|
30
|
+
expect(lama).toBe("1990-01-01");
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tengah tahun & akhir bulan tetap utuh", () => {
|
|
35
|
+
expect(toLocalISODate(new Date(2026, 7, 15))).toBe("2026-08-15");
|
|
36
|
+
expect(toLocalISODate(new Date(2024, 1, 29))).toBe("2024-02-29"); // kabisat
|
|
37
|
+
expect(toLocalISODate(new Date(2026, 11, 31))).toBe("2026-12-31");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("nilai kosong / tak sah → string kosong, bukan lempar", () => {
|
|
41
|
+
expect(toLocalISODate(undefined)).toBe("");
|
|
42
|
+
expect(toLocalISODate(null)).toBe("");
|
|
43
|
+
expect(toLocalISODate(new Date("bukan tanggal"))).toBe("");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Rules.date — tanggal kalender yang sungguh ada
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
describe("Rules.date", () => {
|
|
51
|
+
const r = Rules.date();
|
|
52
|
+
|
|
53
|
+
it("menerima tanggal sah", () => {
|
|
54
|
+
expect(r("1990-01-01", {})).toBeUndefined();
|
|
55
|
+
expect(r("2024-02-29", {})).toBeUndefined(); // kabisat
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("MENOLAK tanggal yang tidak ada di kalender", () => {
|
|
59
|
+
// `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret
|
|
60
|
+
// alih-alih menolak. Tanpa pemeriksaan balik, tanggal itu lolos dan
|
|
61
|
+
// tersimpan sebagai tanggal LAIN.
|
|
62
|
+
expect(r("2026-02-31", {})).toBeTruthy();
|
|
63
|
+
expect(r("2026-13-01", {})).toBeTruthy();
|
|
64
|
+
expect(r("2023-02-29", {})).toBeTruthy(); // 2023 bukan kabisat
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("menolak bentuk yang bukan ISO", () => {
|
|
68
|
+
expect(r("01-01-1990", {})).toBeTruthy();
|
|
69
|
+
expect(r("1990/01/01", {})).toBeTruthy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("kosong lolos — kewajiban diurus `required`", () => {
|
|
73
|
+
expect(r("", {})).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("Rules.dateNotFuture", () => {
|
|
78
|
+
const now = () => new Date(2026, 7, 15);
|
|
79
|
+
const r = Rules.dateNotFuture(undefined, now);
|
|
80
|
+
|
|
81
|
+
it("hari ini masih boleh", () => {
|
|
82
|
+
expect(r("2026-08-15", {})).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
it("kemarin boleh", () => {
|
|
85
|
+
expect(r("2026-08-14", {})).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it("besok DITOLAK", () => {
|
|
88
|
+
expect(r("2026-08-16", {})).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// collectPageFields — section tersembunyi
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
const terbuka: FormSection<D> = {
|
|
96
|
+
title: "Terbuka",
|
|
97
|
+
fields: [{ key: "a", label: "A", type: "text", span: 4, validation: { required: true } }],
|
|
98
|
+
};
|
|
99
|
+
const tertutup: FormSection<D> = {
|
|
100
|
+
title: "Tertutup",
|
|
101
|
+
fields: [{ key: "b", label: "B", type: "text", span: 4, validation: { required: true } }],
|
|
102
|
+
hidden: (_m, d) => d.buka !== true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const layout: FormLayout<D> = {
|
|
106
|
+
type: "wizard",
|
|
107
|
+
pages: [{ label: "Satu", sections: [terbuka, tertutup] }],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe("collectPageFields menghormati section.hidden", () => {
|
|
111
|
+
it("field di section TERSEMBUNYI tidak ikut divalidasi", () => {
|
|
112
|
+
// Inti keluhan: operator melihat "wajib diisi" untuk field yang tidak ada
|
|
113
|
+
// di layarnya, tanpa cara memperbaikinya.
|
|
114
|
+
const fields = collectPageFields(layout, 0, "create", { buka: false });
|
|
115
|
+
expect(fields.map((f) => String(f.key))).toEqual(["a"]);
|
|
116
|
+
|
|
117
|
+
const errs = runFieldValidation(fields, { buka: false }, "create");
|
|
118
|
+
expect(errs.b).toBeUndefined();
|
|
119
|
+
expect(errs.a).toBeTruthy(); // yang tampil tetap divalidasi
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("field ikut divalidasi begitu section-nya terbuka", () => {
|
|
123
|
+
const fields = collectPageFields(layout, 0, "create", { buka: true });
|
|
124
|
+
expect(fields.map((f) => String(f.key)).sort()).toEqual(["a", "b"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("tanpa mode/data, predikat fungsi dianggap TIDAK tersembunyi", () => {
|
|
128
|
+
// Arah aman: pemanggil lama yang belum meneruskan konteks tetap
|
|
129
|
+
// memvalidasi segalanya, bukan diam-diam melewatkan field wajib.
|
|
130
|
+
expect(collectPageFields(layout, 0).map((f) => String(f.key)).sort()).toEqual(["a", "b"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("hidden: true literal tetap dilewati tanpa konteks", () => {
|
|
134
|
+
const l: FormLayout<D> = {
|
|
135
|
+
type: "wizard",
|
|
136
|
+
pages: [{ label: "S", sections: [terbuka, { ...tertutup, hidden: true }] }],
|
|
137
|
+
};
|
|
138
|
+
expect(collectPageFields(l, 0).map((f) => String(f.key))).toEqual(["a"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiPath, FieldValidation, FormField, FormLayout, FormMode } from "./form-types";
|
|
1
|
+
import type { ApiPath, FieldValidation, FormField, FormLayout, FormMode, FormSection} from "./form-types";
|
|
2
2
|
import type { BosSharedLabels } from "./i18n";
|
|
3
3
|
|
|
4
4
|
// Non-React fallback — callers (React components) should always pass i18n from useBosSharedI18n().
|
|
@@ -124,12 +124,40 @@ export function collectAllFields<TData extends Record<string, unknown>>(
|
|
|
124
124
|
return sections.flatMap((s) => s.fields);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Field yang BOLEH divalidasi pada satu halaman.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ Section yang `hidden` DILEWATI. Tanpa ini, field wajib di panel yang belum
|
|
131
|
+
* terbuka ikut divalidasi — dan operator melihat "wajib diisi" untuk field yang
|
|
132
|
+
* tidak ada di layarnya, tanpa cara memperbaikinya.
|
|
133
|
+
*
|
|
134
|
+
* `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya,
|
|
135
|
+
* hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
|
|
136
|
+
* diperlakukan sebagai TIDAK tersembunyi, arah aman untuk validasi.
|
|
137
|
+
*/
|
|
127
138
|
export function collectPageFields<TData extends Record<string, unknown>>(
|
|
128
139
|
layout: FormLayout<TData>,
|
|
129
140
|
pageIndex: number,
|
|
141
|
+
mode?: FormMode,
|
|
142
|
+
data?: TData,
|
|
130
143
|
): FormField<TData>[] {
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
const visible = (sections: FormSection<TData>[]) =>
|
|
145
|
+
sections.filter((s) => !isSectionHidden(s, mode, data)).flatMap((s) => s.fields);
|
|
146
|
+
|
|
147
|
+
if (layout.type === "single-page") return visible(layout.sections);
|
|
148
|
+
return visible(layout.pages[pageIndex]?.sections ?? []);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isSectionHidden<TData extends Record<string, unknown>>(
|
|
152
|
+
section: FormSection<TData>,
|
|
153
|
+
mode?: FormMode,
|
|
154
|
+
data?: TData,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (typeof section.hidden === "function") {
|
|
157
|
+
if (mode === undefined || data === undefined) return false;
|
|
158
|
+
return section.hidden(mode, data);
|
|
159
|
+
}
|
|
160
|
+
return section.hidden === true;
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
export function getLayoutPageCount<TData extends Record<string, unknown>>(
|
|
@@ -137,3 +165,23 @@ export function getLayoutPageCount<TData extends Record<string, unknown>>(
|
|
|
137
165
|
): number {
|
|
138
166
|
return layout.type === "single-page" ? 1 : layout.pages.length;
|
|
139
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Tanggal yang dipilih → "YYYY-MM-DD" menurut kalender LOKAL.
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ Ada karena `date.toISOString().slice(0,10)` MENGGESER tanggal satu hari
|
|
173
|
+
* bagi siapa pun di timur UTC. `new Date(1990,0,1)` di WIB (UTC+7) menjadi
|
|
174
|
+
* "1989-12-31T17:00:00Z" — tanggal lahir yang diketik operator tersimpan
|
|
175
|
+
* mundur sehari, tanpa satu pun galat, di setiap field tanggal di setiap modul.
|
|
176
|
+
*
|
|
177
|
+
* Komponen lokal dipakai apa adanya: pemilih tanggal memilih HARI di kalender
|
|
178
|
+
* pengguna, bukan sebuah titik waktu. Mengubahnya ke UTC memperlakukannya
|
|
179
|
+
* sebagai titik waktu, dan di situlah pergeserannya lahir.
|
|
180
|
+
*/
|
|
181
|
+
export function toLocalISODate(date: Date | undefined | null): string {
|
|
182
|
+
if (!date || Number.isNaN(date.getTime())) return "";
|
|
183
|
+
const y = date.getFullYear();
|
|
184
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
185
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
186
|
+
return `${y}-${m}-${d}`;
|
|
187
|
+
}
|
package/src/form-renderer.tsx
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import type { Ui7FormDensity, Ui7FormWidth } from "./style-contract";
|
|
33
33
|
import { useBosSharedI18n } from "./i18n";
|
|
34
34
|
import type { FormField, FormMode, FormSection } from "./form-types";
|
|
35
|
+
import { toLocalISODate } from "./form-renderer-utils";
|
|
35
36
|
|
|
36
37
|
export type SchemaFormRendererProps<TData extends Record<string, unknown>> = {
|
|
37
38
|
mode: FormMode;
|
|
@@ -484,6 +485,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
484
485
|
style={{ display: "grid", gap: dt.sectionGap }}
|
|
485
486
|
>
|
|
486
487
|
{sections.map((section, sectionIndex) => {
|
|
488
|
+
// Section tersembunyi mengembalikan null — React tidak merender apa
|
|
489
|
+
// pun untuknya, termasuk judul & deskripsinya.
|
|
490
|
+
const sectionHidden =
|
|
491
|
+
typeof section.hidden === "function"
|
|
492
|
+
? section.hidden(mode, value)
|
|
493
|
+
: section.hidden === true;
|
|
494
|
+
if (sectionHidden) return null;
|
|
487
495
|
const cols = section.columns ?? 1;
|
|
488
496
|
const defaultSpan = Math.floor(12 / cols);
|
|
489
497
|
|
|
@@ -740,20 +748,20 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
|
|
|
740
748
|
<div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
|
|
741
749
|
<DatePicker
|
|
742
750
|
datePickerType="single"
|
|
743
|
-
|
|
751
|
+
// Tampil DD-MM-YYYY (konvensi setempat); nilai yang
|
|
752
|
+
// DISIMPAN tetap ISO. Dua hal berbeda: yang satu untuk
|
|
753
|
+
// dibaca manusia, yang satu kontrak dgn backend.
|
|
754
|
+
dateFormat="d-m-Y"
|
|
744
755
|
value={asString(rawValue)}
|
|
745
756
|
onChange={([date]) =>
|
|
746
|
-
setValue(
|
|
747
|
-
field.key,
|
|
748
|
-
(date ? date.toISOString().slice(0, 10) : "") as TData[keyof TData],
|
|
749
|
-
)
|
|
757
|
+
setValue(field.key, toLocalISODate(date) as TData[keyof TData])
|
|
750
758
|
}
|
|
751
759
|
>
|
|
752
760
|
<DatePickerInput
|
|
753
761
|
id={key}
|
|
754
762
|
labelText={mkLabel(field.label, effectiveRequired)}
|
|
755
763
|
size={carbonSize}
|
|
756
|
-
placeholder={field.placeholder ?? "
|
|
764
|
+
placeholder={field.placeholder ?? "DD-MM-YYYY"}
|
|
757
765
|
disabled={disabled}
|
|
758
766
|
invalid={Boolean(invalidText)}
|
|
759
767
|
invalidText={invalidText}
|
package/src/form-rules.ts
CHANGED
|
@@ -41,6 +41,48 @@ export const Rules = {
|
|
|
41
41
|
v && typeof v === "string" && !isValidPhoneInput(v, rule) ? m : undefined;
|
|
42
42
|
},
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Tanggal yang tersimpan harus berupa tanggal KALENDER yang sungguh ada.
|
|
46
|
+
*
|
|
47
|
+
* Nilai yang disimpan berformat ISO (YYYY-MM-DD) meski ditampilkan
|
|
48
|
+
* DD-MM-YYYY — aturan ini memeriksa nilai simpannya.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Memeriksa hari-nya kembali, bukan hanya `new Date(v)` tidak NaN.
|
|
51
|
+
* `new Date("2026-02-31")` di sebagian mesin MENGGULUNG jadi 3 Maret alih-alih
|
|
52
|
+
* menolak, sehingga tanggal yang tidak ada lolos dan tersimpan sebagai
|
|
53
|
+
* tanggal LAIN — tanpa galat, dan tanpa cara mengetahuinya kemudian.
|
|
54
|
+
*/
|
|
55
|
+
date(msg?: string): FieldValidatorFn {
|
|
56
|
+
const m = msg ?? "Tanggal tidak valid";
|
|
57
|
+
return (v) => {
|
|
58
|
+
if (!v || typeof v !== "string") return undefined;
|
|
59
|
+
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
60
|
+
if (!mm) return m;
|
|
61
|
+
const [y, mo, d] = [Number(mm[1]), Number(mm[2]), Number(mm[3])];
|
|
62
|
+
const dt = new Date(y, mo - 1, d);
|
|
63
|
+
const sama =
|
|
64
|
+
dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d;
|
|
65
|
+
return sama ? undefined : m;
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tanggal tidak boleh di masa depan — untuk tanggal lahir dan sejenisnya.
|
|
71
|
+
* `now` bisa disuntik agar bisa diuji tanpa bergantung jam mesin.
|
|
72
|
+
*/
|
|
73
|
+
dateNotFuture(msg?: string, now?: () => Date): FieldValidatorFn {
|
|
74
|
+
const m = msg ?? "Tanggal tidak boleh melebihi hari ini";
|
|
75
|
+
return (v) => {
|
|
76
|
+
if (!v || typeof v !== "string") return undefined;
|
|
77
|
+
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
78
|
+
if (!mm) return undefined; // bentuk salah ditangani Rules.date
|
|
79
|
+
const today = (now ? now() : new Date());
|
|
80
|
+
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
81
|
+
const dt = new Date(Number(mm[1]), Number(mm[2]) - 1, Number(mm[3]));
|
|
82
|
+
return dt.getTime() > t.getTime() ? m : undefined;
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
|
|
44
86
|
regex(pattern: RegExp, msg: string): FieldValidatorFn {
|
|
45
87
|
return (v) =>
|
|
46
88
|
v && typeof v === "string" && !pattern.test(v) ? msg : undefined;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } 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
|
+
// `FormSection.hidden` — menyembunyikan SELURUH section, bukan hanya field-nya.
|
|
9
|
+
//
|
|
10
|
+
// Menyembunyikan tiap field satu per satu tidak cukup: judul dan deskripsi
|
|
11
|
+
// section tetap terender, meninggalkan kepala panel kosong. Pada form yang
|
|
12
|
+
// panelnya memang harus terkunci dulu, kepala kosong itu justru mengiklankan
|
|
13
|
+
// bagian yang belum boleh diisi.
|
|
14
|
+
|
|
15
|
+
const locked: FormSection<D> = {
|
|
16
|
+
title: "Data Diri",
|
|
17
|
+
description: "Terbuka setelah verifikasi",
|
|
18
|
+
fields: [{ key: "nama", label: "Nama Lengkap", type: "text", span: 4 }],
|
|
19
|
+
hidden: (_mode, data) => data.verified !== true,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const always: FormSection<D> = {
|
|
23
|
+
title: "Verifikasi Identitas",
|
|
24
|
+
fields: [{ key: "nik", label: "Nomor Identitas", type: "text", span: 4 }],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function renderWith(value: D) {
|
|
28
|
+
return render(
|
|
29
|
+
<SchemaFormRenderer<D>
|
|
30
|
+
mode="create"
|
|
31
|
+
value={value}
|
|
32
|
+
sections={[always, locked]}
|
|
33
|
+
onChange={() => {}}
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("FormSection.hidden", () => {
|
|
39
|
+
it("menyembunyikan JUDUL dan DESKRIPSI, bukan cuma field-nya", () => {
|
|
40
|
+
renderWith({ verified: false });
|
|
41
|
+
expect(screen.queryByText("Data Diri")).toBeNull();
|
|
42
|
+
expect(screen.queryByText("Terbuka setelah verifikasi")).toBeNull();
|
|
43
|
+
expect(screen.queryByLabelText("Nama Lengkap")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("menampilkan section ketika predikatnya false", () => {
|
|
47
|
+
renderWith({ verified: true });
|
|
48
|
+
expect(screen.getByText("Data Diri")).toBeTruthy();
|
|
49
|
+
expect(screen.getByLabelText("Nama Lengkap")).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("KONTROL: section tetangga tanpa hidden tidak ikut terpengaruh", () => {
|
|
53
|
+
// Kalau kontrol ini ikut gagal, yang diukur bukan `hidden` melainkan
|
|
54
|
+
// perenderan section secara umum.
|
|
55
|
+
renderWith({ verified: false });
|
|
56
|
+
expect(screen.getByText("Verifikasi Identitas")).toBeTruthy();
|
|
57
|
+
expect(screen.getByLabelText("Nomor Identitas")).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("menerima boolean, bukan hanya fungsi — bentuknya sama dgn FormField.hidden", () => {
|
|
61
|
+
render(
|
|
62
|
+
<SchemaFormRenderer<D>
|
|
63
|
+
mode="create"
|
|
64
|
+
value={{}}
|
|
65
|
+
sections={[{ title: "Selalu Tersembunyi", fields: [], hidden: true }]}
|
|
66
|
+
onChange={() => {}}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
expect(screen.queryByText("Selalu Tersembunyi")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("predikat menerima mode — section bisa disembunyikan hanya saat create", () => {
|
|
73
|
+
const s: FormSection<D> = {
|
|
74
|
+
title: "Hanya Saat Edit",
|
|
75
|
+
fields: [],
|
|
76
|
+
hidden: (mode) => mode === "create",
|
|
77
|
+
};
|
|
78
|
+
const { unmount } = render(
|
|
79
|
+
<SchemaFormRenderer<D> mode="create" value={{}} sections={[s]} onChange={() => {}} />,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.queryByText("Hanya Saat Edit")).toBeNull();
|
|
82
|
+
unmount();
|
|
83
|
+
|
|
84
|
+
render(<SchemaFormRenderer<D> mode="edit" value={{}} sections={[s]} onChange={() => {}} />);
|
|
85
|
+
expect(screen.getByText("Hanya Saat Edit")).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
});
|
package/src/form-types.ts
CHANGED
|
@@ -177,6 +177,19 @@ export type FormSection<TData extends Record<string, unknown>> = {
|
|
|
177
177
|
mode: FormMode;
|
|
178
178
|
disabled: boolean;
|
|
179
179
|
}) => ReactNode;
|
|
180
|
+
/**
|
|
181
|
+
* Sembunyikan SELURUH section — judul, deskripsi, dan isinya.
|
|
182
|
+
*
|
|
183
|
+
* Bentuknya sengaja sama persis dengan `FormField.hidden` supaya tidak ada
|
|
184
|
+
* dua idiom untuk hal yang sama.
|
|
185
|
+
*
|
|
186
|
+
* Ada karena menyembunyikan tiap field satu per satu TIDAK cukup: judul dan
|
|
187
|
+
* deskripsi section tetap terender, meninggalkan kepala panel kosong yang
|
|
188
|
+
* terlihat seperti bug — dan pada form yang panelnya memang harus terkunci
|
|
189
|
+
* dulu, kepala kosong itu justru mengiklankan keberadaan bagian yang belum
|
|
190
|
+
* boleh diisi.
|
|
191
|
+
*/
|
|
192
|
+
hidden?: boolean | ((mode: FormMode, data: TData) => boolean);
|
|
180
193
|
};
|
|
181
194
|
|
|
182
195
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
@@ -85,7 +85,16 @@ export function useCrudForm<TData extends Record<string, unknown>>({
|
|
|
85
85
|
);
|
|
86
86
|
|
|
87
87
|
const validatePage = (pageIndex: number) => {
|
|
88
|
-
|
|
88
|
+
// mode & form DIWAJIBKAN di sini: tanpanya collectPageFields tidak bisa
|
|
89
|
+
// mengevaluasi predikat section.hidden, dan field di panel yang belum
|
|
90
|
+
// terbuka ikut divalidasi — operator melihat "wajib diisi" untuk field yang
|
|
91
|
+
// tidak ada di layarnya.
|
|
92
|
+
return runFieldValidation(
|
|
93
|
+
collectPageFields(schema.layout, pageIndex, mode, form),
|
|
94
|
+
form,
|
|
95
|
+
mode,
|
|
96
|
+
labels,
|
|
97
|
+
);
|
|
89
98
|
};
|
|
90
99
|
|
|
91
100
|
const validateAll = () => {
|