@isi-ui7/bos7-shared 0.4.4 → 0.4.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.
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import { afterEach } from "vitest";
4
+ import { SchemaFormRenderer } from "./form-renderer";
5
+ import type { FormSection } from "./form-types";
6
+
7
+ afterEach(cleanup);
8
+
9
+ type D = Record<string, unknown>;
10
+
11
+ // Medan `detail-rows` NOL meneruskan `invalidText` sampai 2026-09-06, sementara
12
+ // saudaranya `detail-modal` SUDAH (detail-modal-field.tsx:277).
13
+ //
14
+ // 🔴 Akibatnya: sebuah aturan validasi yang keyed ke medan detail-rows
15
+ // DIHITUNG, MENAHAN SUBMIT (use-crud-form.ts:167 `if (Object.keys(errs).length)
16
+ // return`), lalu NOL PERNAH DITAMPILKAN. Petugas melihat spanduk generik
17
+ // "Check required fields" dan tabel yang bersih — pesannya sudah ditulis, sudah
18
+ // benar, dan invisible.
19
+ //
20
+ // Terukur pada Produk Pembiayaan (bos7-financing, errors.costs). Radius ledak
21
+ // lintas estate: 5 formulir yang benar-benar menyetel error pada kunci
22
+ // detail-rows (financing ×2 `costs`, treasury ×3 `items`/`items`/`mappings`);
23
+ // 12 dari 17 berkas ber-detail-rows lainnya nol menyetelnya dan karena itu nol
24
+ // dapat melihat perbedaan.
25
+
26
+ const PESAN = "Setiap baris biaya harus memilih Element Biaya";
27
+
28
+ const section: FormSection<D> = {
29
+ title: "Parameter Biaya",
30
+ fields: [
31
+ {
32
+ key: "costs",
33
+ label: "Daftar Biaya",
34
+ type: "detail-rows",
35
+ span: 12,
36
+ detailRows: {
37
+ columns: [{ key: "cost_element_id", label: "Element Biaya", type: "text" }],
38
+ newRowFactory: () => ({ cost_element_id: "" }),
39
+ },
40
+ },
41
+ ],
42
+ };
43
+
44
+ // 🔴 Menghitung SEMUA [role=alert] mengukur populasi yang salah: Carbon's
45
+ // TextInput selalu merender <span class="cds--text-input__counter-alert"> yang
46
+ // KOSONG, jadi asersi "tepat satu alert" gagal karena alasan yang nol
47
+ // berhubungan. Terukur saat menulis berkas ini — uji "galat nol bocor" berbunyi
48
+ // 2 padahal cuma satu medan bergalat.
49
+ //
50
+ // Yang dihitung: alert yang BERISI teks. Itu populasi yang pertanyaannya
51
+ // maksud.
52
+ function alertsBerteks(root: HTMLElement) {
53
+ return Array.from(root.querySelectorAll('[role="alert"]')).filter(
54
+ (el) => (el.textContent ?? "").trim() !== "",
55
+ );
56
+ }
57
+
58
+ function renderWith(errors: Record<string, string>) {
59
+ return render(
60
+ <SchemaFormRenderer<D>
61
+ mode="create"
62
+ value={{ costs: [] }}
63
+ sections={[section]}
64
+ errors={errors}
65
+ onChange={() => {}}
66
+ />,
67
+ );
68
+ }
69
+
70
+ describe("detail-rows menampilkan galat medannya", () => {
71
+ // ARAH 1 — merah sebelum perbaikan: pesannya nol pernah muncul.
72
+ it("error DISETEL ⇒ pesannya MUNCUL, dan isinya menyebut biaya", () => {
73
+ renderWith({ costs: PESAN });
74
+ const el = screen.getByText(PESAN);
75
+ expect(el).toBeTruthy();
76
+ // 🔴 Diasersi ISINYA, bukan "pesannya berubah". Generik yang diganti
77
+ // generik lain akan lulus asersi "bukan lagi Check required fields"
78
+ // sambil nol menutup AC-nya.
79
+ expect(el.textContent).toContain("baris biaya");
80
+ // Bentuk yang sama dgn DetailModalField: role=alert supaya pembaca layar
81
+ // mengumumkannya, bukan sekadar teks merah.
82
+ expect(el.getAttribute("role")).toBe("alert");
83
+ });
84
+
85
+ // ARAH 2 — dan ini yang menjaga treasury: perubahan yang cuma dapat MENAMBAH
86
+ // nol dapat meregresi. Formulir yang nol menyetel error nol boleh melihat
87
+ // apa pun yang baru.
88
+ it("error KOSONG ⇒ NOL ada yang muncul, dan nol ada elemen alert", () => {
89
+ const { container } = renderWith({});
90
+ expect(screen.queryByText(PESAN)).toBeNull();
91
+ expect(alertsBerteks(container).length).toBe(0);
92
+ });
93
+
94
+ // KONTROL POSITIF utk instrumen: tabelnya memang dirender di kedua keadaan,
95
+ // jadi "nol ada alert" di atas berarti "nol ada galat", bukan "nol ada apa-apa".
96
+ it("KONTROL: tabelnya dirender di KEDUA keadaan", () => {
97
+ const a = renderWith({ costs: PESAN });
98
+ expect(a.container.querySelector("table")).toBeTruthy();
99
+ cleanup();
100
+ const b = renderWith({});
101
+ expect(b.container.querySelector("table")).toBeTruthy();
102
+ });
103
+
104
+ // Medan LAIN di section yang sama nol boleh ikut menampilkan galat medan ini.
105
+ it("galat detail-rows nol bocor ke medan lain", () => {
106
+ const dua: FormSection<D> = {
107
+ ...section,
108
+ fields: [
109
+ ...section.fields,
110
+ { key: "catatan", label: "Catatan", type: "text", span: 6 },
111
+ ],
112
+ };
113
+ const { container } = render(
114
+ <SchemaFormRenderer<D>
115
+ mode="create"
116
+ value={{ costs: [], catatan: "" }}
117
+ sections={[dua]}
118
+ errors={{ costs: PESAN }}
119
+ onChange={() => {}}
120
+ />,
121
+ );
122
+ expect(alertsBerteks(container).length).toBe(1);
123
+ expect(alertsBerteks(container)[0].textContent).toBe(PESAN);
124
+ });
125
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import { afterEach } from "vitest";
4
+ import { SchemaFormRenderer } from "./form-renderer";
5
+ import type { FormSection } from "./form-types";
6
+
7
+ afterEach(cleanup);
8
+
9
+ type D = Record<string, unknown>;
10
+
11
+ // Medan `detail-rows` MENAMPILKAN baris yang sudah ada — dan sampai berkas ini,
12
+ // NOL ADA satu uji pun di paket ini yang mengasersinya.
13
+ //
14
+ // ── Celah yang ditutup ───────────────────────────────────────────────────────
15
+ //
16
+ // Satu-satunya uji yang pernah merender `detail-rows` dengan nilai TAK-KOSONG
17
+ // adalah `detail-modal-field.test.tsx:444` (`value={{ limits: [{kode:
18
+ // "CASH_IN"}] }}`), dan asersinya:
19
+ //
20
+ // getByText("Limit Transaksi") <- LABEL medannya
21
+ // getByRole("button", {name:"Tambah Ahli Waris"}) <- tombol medan LAIN
22
+ //
23
+ // `CASH_IN` sendiri nol pernah disentuh. Uji itu jujur — ia berjudul "tetap
24
+ // jalan berdampingan" dan memang mengukur koeksistensi. Yang salah adalah nol
25
+ // ada yang lain. `detail-rows-invalid-text.test.tsx` (2026-09-06) juga nol
26
+ // menutupnya: ia merender `value={{ costs: [] }}` — nol baris.
27
+ //
28
+ // ── Kenapa kelas ini mahal ───────────────────────────────────────────────────
29
+ //
30
+ // form-renderer.tsx:623
31
+ //
32
+ // const rows = Array.isArray(rawValue) ? rawValue : [];
33
+ //
34
+ // Fallback `[]` itu DIAM. Dan setiap konsumen `detail-rows` di estate ini
35
+ // menulis dengan semantik GANTI-SEMUA — backend menghapus seluruh anak lalu
36
+ // menyisipkan ulang apa yang dikirim. Gabungannya:
37
+ //
38
+ // nilai non-array (atau nol ter-bind) ⇒ tabel render KOSONG
39
+ // ⇒ Save mengirim []
40
+ // ⇒ ganti-semua MENGHAPUS SEMUANYA
41
+ // ⇒ dan layarnya SUKSES
42
+ //
43
+ // ── Siapa yang dijaga berkas ini ─────────────────────────────────────────────
44
+ //
45
+ // Uji yang nol menyebut konsumennya akan dihapus oleh orang yang mengira ia
46
+ // mengukur satu instans. Keempatnya:
47
+ //
48
+ // holidays bos7-enterprise calendar/_schema.ts (DeleteCalendarHolidays + Insert)
49
+ // costs bos7-financing produk pembiayaan (2 layar)
50
+ // items bos7-accounting controlling/budgets (DeleteBudgetItemsByBudget + Insert)
51
+ // bos7-treasury journal-template, pof-account-group
52
+ // mappings bos7-treasury pof
53
+ //
54
+ // ⚠️ `detail-modal` (form-renderer.tsx:737) punya fallback `[]` yang IDENTIK dan
55
+ // tetap tanpa penjaga. Di luar lingkup berkas ini — sengaja disebut, bukan
56
+ // diam-diam dilewati.
57
+
58
+ const BARIS = [
59
+ { description: "Idul Fitri", holiday_type: "NATIONAL" },
60
+ { description: "Natal", holiday_type: "NATIONAL" },
61
+ ];
62
+
63
+ const section: FormSection<D> = {
64
+ title: "Hari Libur",
65
+ fields: [
66
+ {
67
+ key: "holidays",
68
+ label: "Daftar Hari Libur",
69
+ type: "detail-rows",
70
+ span: 12,
71
+ detailRows: {
72
+ columns: [
73
+ { field: "description", header: "Keterangan", columnType: { type: "text" } },
74
+ { field: "holiday_type", header: "Tipe", columnType: { type: "text" } },
75
+ ],
76
+ newRowFactory: () => ({ description: "", holiday_type: "" }),
77
+ },
78
+ },
79
+ ],
80
+ };
81
+
82
+ function renderRows(rows: unknown, mode: "edit" | "view" = "edit") {
83
+ return render(
84
+ <SchemaFormRenderer<D>
85
+ mode={mode}
86
+ value={{ holidays: rows }}
87
+ sections={[section]}
88
+ onChange={() => {}}
89
+ />,
90
+ );
91
+ }
92
+
93
+ /** Sel-sel `<td>` sebuah tabel sebagai teks — populasi yang pertanyaannya maksud. */
94
+ function selTabel(root: HTMLElement): string[] {
95
+ return Array.from(root.querySelectorAll("tbody td")).map((td) =>
96
+ (td.textContent ?? "").trim(),
97
+ );
98
+ }
99
+
100
+ describe("detail-rows menampilkan baris yang sudah ada", () => {
101
+ // ── ARAH 1 — yang merah kalau perendernya berhenti mem-bind nilainya.
102
+ //
103
+ // 🔴 Diasersi ISI SELNYA, bukan JUMLAH BARIS. "2 baris" dapat dipenuhi dua
104
+ // baris KOSONG — tepat kelas yang sama dengan uji lama yang mengasersi label,
105
+ // satu tingkat lebih halus.
106
+ it("dua baris terisi ⇒ isi tiap selnya muncul", () => {
107
+ const { container } = renderRows(BARIS);
108
+
109
+ // Nilai yang UNIK per baris: membuktikan KEDUANYA dirender, bukan satu
110
+ // baris yang kebetulan cocok dua kali.
111
+ expect(screen.getByText("Idul Fitri")).toBeTruthy();
112
+ expect(screen.getByText("Natal")).toBeTruthy();
113
+
114
+ // Kolom kedua sengaja bernilai SAMA di kedua baris — jadi ia hanya lulus
115
+ // kalau perendernya benar-benar mengulang per baris.
116
+ expect(screen.getAllByText("NATIONAL").length).toBe(2);
117
+
118
+ // Dan keduanya harus duduk di <tr> yang BERBEDA. Tanpa ini, sebuah
119
+ // perender yang menumpuk semua nilai di satu baris tetap lulus di atas.
120
+ const barisIdul = screen.getByText("Idul Fitri").closest("tr");
121
+ const barisNatal = screen.getByText("Natal").closest("tr");
122
+ expect(barisIdul).toBeTruthy();
123
+ expect(barisNatal).toBeTruthy();
124
+ expect(barisIdul).not.toBe(barisNatal);
125
+
126
+ expect(container.querySelectorAll("tbody tr").length).toBe(2);
127
+ });
128
+
129
+ // ── ARAH 2 — KONTROL yang membedakan "menampilkan yang ada" dari
130
+ // "selalu menampilkan sesuatu".
131
+ //
132
+ // Tanpa arah ini, sebuah perender yang MENGARANG baris (mis. selalu
133
+ // menyisipkan satu baris kosong dari newRowFactory) akan lulus arah 1 dan
134
+ // nol pernah merah. Dan nol-baris BUKAN kegagalan — ia keadaan sah sebuah
135
+ // kalender tanpa hari libur.
136
+ it("KONTROL: nol baris ⇒ nol sel, dan itu BUKAN kegagalan", () => {
137
+ const { container } = renderRows([]);
138
+ expect(selTabel(container)).toEqual([]);
139
+ expect(container.querySelectorAll("tbody tr").length).toBe(0);
140
+ expect(screen.queryByText("Idul Fitri")).toBeNull();
141
+ // Tabelnya sendiri TETAP dirender — jadi "nol ada sel" di atas berarti
142
+ // "nol ada baris", bukan "medannya nol ada".
143
+ expect(container.querySelector("table")).toBeTruthy();
144
+ expect(screen.getByText("Keterangan")).toBeTruthy(); // header kolom
145
+ });
146
+
147
+ // ── ARAH 3 — jalur yang mengubah fallback diam jadi TERUKUR.
148
+ //
149
+ // `Array.isArray(rawValue) ? rawValue : []` memperlakukan nilai non-array
150
+ // sebagai nol-baris tanpa satu galat pun. Itu perilaku yang DISENGAJA
151
+ // (perender nol boleh melempar atas data cacat), tapi ia harus punya uji —
152
+ // kalau suatu hari ia berubah jadi melempar, atau jadi merender sesuatu,
153
+ // seseorang harus melihatnya di sini.
154
+ it("nilai non-array diperlakukan sebagai nol-baris, tanpa melempar", () => {
155
+ for (const buruk of [undefined, null, "Idul Fitri", 42, { description: "Idul Fitri" }]) {
156
+ const { container } = renderRows(buruk);
157
+ expect(container.querySelector("table")).toBeTruthy();
158
+ expect(container.querySelectorAll("tbody tr").length).toBe(0);
159
+ // 🔴 Ini kalimat yang harus dibaca orang berikutnya: string
160
+ // "Idul Fitri" dan objek ber-`description` TIDAK muncul. Digabung
161
+ // ganti-semua, keadaan ini = hapus senyap.
162
+ expect(screen.queryByText("Idul Fitri")).toBeNull();
163
+ cleanup();
164
+ }
165
+ });
166
+
167
+ // ── ARAH 4 — layar `view` memakai <EditableTable> yang SAMA (readOnly).
168
+ // Kalau binding-nya putus hanya di jalur readonly, checker melihat tabel
169
+ // kosong pada layar approval sementara datanya utuh.
170
+ it("mode view: baris tetap muncul (readOnly, bukan kosong)", () => {
171
+ const { container } = renderRows(BARIS, "view");
172
+ expect(screen.getByText("Idul Fitri")).toBeTruthy();
173
+ expect(screen.getByText("Natal")).toBeTruthy();
174
+ expect(container.querySelectorAll("tbody tr").length).toBe(2);
175
+ });
176
+ });
@@ -155,13 +155,36 @@ export function ringkasGalatMedan<TData extends Record<string, unknown>>(
155
155
 
156
156
  // ── Layout helpers ────────────────────────────────────────────────────────────
157
157
 
158
+ /**
159
+ * Seluruh field yang BOLEH divalidasi — section `hidden` DILEWATI, sama seperti
160
+ * `collectPageFields`.
161
+ *
162
+ * 🔴 Dulu ia nol menyaring apa pun, bahkan `hidden: true` literal. Akibatnya
163
+ * `validateAll` (Save) menuntut field yang aplikasinya sendiri sembunyikan,
164
+ * sementara `validatePage` (Next) melewatinya dengan benar — jadi operator
165
+ * lolos di setiap halaman lalu ditolak di langkah terakhir, untuk field yang
166
+ * nol ada di layar mana pun dan nol bisa diisi gestur apa pun.
167
+ *
168
+ * `mode`/`data` opsional supaya pemanggil lama tetap jalan; tanpa keduanya
169
+ * hanya `hidden: true` literal yang bisa dievaluasi — predikat fungsi
170
+ * diperlakukan TIDAK tersembunyi, arah aman untuk validasi.
171
+ *
172
+ * ⚠️ Konsekuensi yang wajib dijaga di sisi SKEMA: kalau satu key punya dua
173
+ * salinan field (satu tampil di `create`, satu di `edit`), keduanya WAJIB
174
+ * membawa `validation`. Sebelum perbaikan ini, salinan tersembunyi ikut
175
+ * divalidasi sehingga satu salinan bervalidasi terasa cukup — ketergantungan
176
+ * terbalik yang kini nol berlaku lagi. Skema yang bersandar padanya akan
177
+ * kehilangan "wajib diisi" tanpa satu galat pun.
178
+ */
158
179
  export function collectAllFields<TData extends Record<string, unknown>>(
159
180
  layout: FormLayout<TData>,
181
+ mode?: FormMode,
182
+ data?: TData,
160
183
  ): FormField<TData>[] {
161
184
  const sections = layout.type === "single-page"
162
185
  ? layout.sections
163
186
  : layout.pages.flatMap((p) => p.sections);
164
- return sections.flatMap((s) => s.fields);
187
+ return sections.filter((s) => !isSectionHidden(s, mode, data)).flatMap((s) => s.fields);
165
188
  }
166
189
 
167
190
  /**
@@ -186,12 +186,21 @@ function FieldShell({
186
186
  required,
187
187
  children,
188
188
  helperText,
189
+ invalidText,
189
190
  htmlFor,
190
191
  }: {
191
192
  label: ReactNode;
192
193
  required?: boolean;
193
194
  children: ReactNode;
194
195
  helperText?: ReactNode;
196
+ /**
197
+ * Galat tingkat-medan. Bentuknya SAMA PERSIS dgn DetailModalField
198
+ * (detail-modal-field.tsx:277) — role="alert", kelas helper, token warna
199
+ * galat, dirender DI ATAS helperText. Tipe medan itu SUDAH menampilkan
200
+ * galatnya dan `detail-rows` NOL; ini menyamakan, bukan menciptakan bentuk
201
+ * kedua.
202
+ */
203
+ invalidText?: ReactNode;
195
204
  htmlFor?: string;
196
205
  }) {
197
206
  const labelStyle = { color: "var(--cds-text-secondary, #525252)", display: "flex", alignItems: "center", gap: "0.25rem" } as const;
@@ -211,6 +220,15 @@ function FieldShell({
211
220
  <div className={UI7_FORM_VISUAL_CLASSNAMES.label} style={labelStyle}>{labelContent}</div>
212
221
  )}
213
222
  {children}
223
+ {invalidText ? (
224
+ <div
225
+ role="alert"
226
+ className={UI7_FORM_VISUAL_CLASSNAMES.helper}
227
+ style={{ color: "var(--cds-text-error, #da1e28)" }}
228
+ >
229
+ {invalidText}
230
+ </div>
231
+ ) : null}
214
232
  {helperText ? (
215
233
  <div className={UI7_FORM_VISUAL_CLASSNAMES.helper}>{helperText}</div>
216
234
  ) : null}
@@ -622,6 +640,13 @@ export function SchemaFormRenderer<TData extends Record<string, unknown>>({
622
640
  label={field.label}
623
641
  required={effectiveRequired}
624
642
  helperText={field.helperText}
643
+ // 🔴 `detail-rows` NOL meneruskan ini sampai 2026-09-06, sementara
644
+ // saudaranya `detail-modal` SUDAH. Akibatnya aturan validasi yang
645
+ // keyed ke medan detail-rows DIHITUNG, MENAHAN SUBMIT, lalu NOL
646
+ // PERNAH DITAMPILKAN — petugas melihat spanduk generik "Check
647
+ // required fields" dan tabel yang bersih. Terukur pada Produk
648
+ // Pembiayaan (errors.costs, bos7-financing#200).
649
+ invalidText={invalidText}
625
650
  >
626
651
  <EditableTable
627
652
  columns={detail.columns}
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { useCrudForm } from "./use-crud-form";
4
+ import type { CrudForm } from "../form-types";
5
+
6
+ type D = Record<string, unknown>;
7
+
8
+ /**
9
+ * `validateAll` (Save) dulu memakai `collectAllFields` TANPA mode, jadi ia
10
+ * menuntut field di section TERSEMBUNYI — sementara `validatePage` (Next)
11
+ * melewatinya dengan benar. Operator lolos di tiap halaman lalu ditolak di
12
+ * langkah terakhir, untuk field yang nol ada di layarnya.
13
+ *
14
+ * Diuji lewat PERILAKU handleSave, bukan lewat collectAllFields langsung:
15
+ * uji atas helper tetap hijau meski hook nol memakainya.
16
+ */
17
+ function schema(sembunyi: boolean | ((m: string, d: D) => boolean)): CrudForm<D> {
18
+ return {
19
+ title: { create: "c", edit: "e", view: "v" },
20
+ emptyData: { tampak: "", tersembunyi: "" } as D,
21
+ createApiPath: "/api/x",
22
+ layout: {
23
+ type: "single-page",
24
+ sections: [
25
+ { title: "tampak", fields: [
26
+ { key: "tampak", label: "Tampak", type: "text", span: 4, validation: { required: true } }] },
27
+ { title: "rahasia", hidden: sembunyi as never, fields: [
28
+ { key: "tersembunyi", label: "Tersembunyi", type: "text", span: 4, validation: { required: true } }] },
29
+ ],
30
+ },
31
+ } as unknown as CrudForm<D>;
32
+ }
33
+
34
+ async function galat(s: CrudForm<D>, isi: D) {
35
+ const { result } = renderHook(() => useCrudForm<D>({ schema: s, mode: "create" }));
36
+ act(() => { result.current.setForm(isi); });
37
+ await act(async () => { await result.current.handleSave(); });
38
+ return { pesan: String(result.current.error ?? ""), medan: result.current.fieldErrors as Record<string,string> };
39
+ }
40
+
41
+ describe("Save nol menuntut field di section tersembunyi", () => {
42
+ it("hidden literal: field tersembunyi NOL memblokir Save", async () => {
43
+ const r = await galat(schema(true), { tampak: "x", tersembunyi: "" });
44
+ expect(r.medan.tersembunyi, "field tersembunyi nol boleh dituntut").toBeUndefined();
45
+ });
46
+
47
+ it("hidden predikat: sama", async () => {
48
+ const r = await galat(schema(() => true), { tampak: "x", tersembunyi: "" });
49
+ expect(r.medan.tersembunyi).toBeUndefined();
50
+ });
51
+
52
+ it("KONTROL POSITIF: section TERLIHAT tetap dituntut", async () => {
53
+ const r = await galat(schema(true), { tampak: "", tersembunyi: "" });
54
+ expect(r.medan.tampak, "penjaga masih menggigit utk yg terlihat").toBeTruthy();
55
+ expect(r.pesan).toContain("Tampak");
56
+ });
57
+
58
+ it("KONTROL POSITIF: section yang TIDAK tersembunyi tetap dituntut", async () => {
59
+ const r = await galat(schema(false), { tampak: "x", tersembunyi: "" });
60
+ expect(r.medan.tersembunyi, "hidden=false ⇒ tetap divalidasi").toBeTruthy();
61
+ });
62
+ });
@@ -109,7 +109,7 @@ export function useCrudForm<TData extends Record<string, unknown>>({
109
109
  };
110
110
 
111
111
  const validateAll = () => {
112
- const errs = runFieldValidation(collectAllFields(schema.layout), form, mode, labels);
112
+ const errs = runFieldValidation(collectAllFields(schema.layout, mode, form), form, mode, labels);
113
113
  if (schema.validate) {
114
114
  return { ...errs, ...schema.validate(form) };
115
115
  }