@isi-ui7/bos7-shared 0.3.5 → 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.
@@ -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
- export declare function collectPageFields<TData extends Record<string, unknown>>(layout: FormLayout<TData>, pageIndex: number): FormField<TData>[];
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 {};
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- if (layout.type === "single-page") return collectAllFields(layout);
132
- return (layout.pages[pageIndex]?.sections ?? []).flatMap((s) => s.fields);
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
+ }
@@ -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;
@@ -747,20 +748,20 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
747
748
  <div key={key} className={UI7_FORM_VISUAL_CLASSNAMES.column} style={wrapperStyle}>
748
749
  <DatePicker
749
750
  datePickerType="single"
750
- dateFormat="Y-m-d"
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"
751
755
  value={asString(rawValue)}
752
756
  onChange={([date]) =>
753
- setValue(
754
- field.key,
755
- (date ? date.toISOString().slice(0, 10) : "") as TData[keyof TData],
756
- )
757
+ setValue(field.key, toLocalISODate(date) as TData[keyof TData])
757
758
  }
758
759
  >
759
760
  <DatePickerInput
760
761
  id={key}
761
762
  labelText={mkLabel(field.label, effectiveRequired)}
762
763
  size={carbonSize}
763
- placeholder={field.placeholder ?? "YYYY-MM-DD"}
764
+ placeholder={field.placeholder ?? "DD-MM-YYYY"}
764
765
  disabled={disabled}
765
766
  invalid={Boolean(invalidText)}
766
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;
@@ -85,7 +85,16 @@ export function useCrudForm<TData extends Record<string, unknown>>({
85
85
  );
86
86
 
87
87
  const validatePage = (pageIndex: number) => {
88
- return runFieldValidation(collectPageFields(schema.layout, pageIndex), form, mode, labels);
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 = () => {