@timeax/form-palette 0.0.1
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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
// src/core/core-provider.tsx
|
|
2
|
+
// noinspection JSConstantReassignment,JSUnusedGlobalSymbols,GrazieInspection
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { CoreContextReact } from "@/core/context";
|
|
7
|
+
import { mapZodError } from "@/core/errors/map-zod";
|
|
8
|
+
import { mapErrorBag } from "@/core/errors/map-error-bag";
|
|
9
|
+
import { getAdapter, localAdapter } from "@/core/adapter-registry";
|
|
10
|
+
import { FieldRegistry } from "@/core/registry/field-registry";
|
|
11
|
+
|
|
12
|
+
import type { z } from "zod";
|
|
13
|
+
import type { AdapterKey, AdapterResult, Method } from "@/schema/adapter";
|
|
14
|
+
import type {
|
|
15
|
+
CoreContext,
|
|
16
|
+
CoreProps,
|
|
17
|
+
Dict,
|
|
18
|
+
InferFromSchema,
|
|
19
|
+
SubmitEvent,
|
|
20
|
+
ValuesResult,
|
|
21
|
+
} from "@/schema/core";
|
|
22
|
+
import type { ButtonRef, Field } from "@/schema/field";
|
|
23
|
+
|
|
24
|
+
type Props<
|
|
25
|
+
V extends Dict,
|
|
26
|
+
S extends z.ZodType | undefined,
|
|
27
|
+
K extends AdapterKey,
|
|
28
|
+
> = CoreProps<V, S, K> & {
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─────────────────────────────────────────────────────────────
|
|
33
|
+
// Internal helpers (generic utils)
|
|
34
|
+
// ─────────────────────────────────────────────────────────────
|
|
35
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
|
|
42
|
+
// NaN === NaN
|
|
43
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
44
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
48
|
+
if (a.length !== b.length) return false;
|
|
49
|
+
for (let i = 0; i < a.length; i++) {
|
|
50
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
56
|
+
const aKeys = Object.keys(a);
|
|
57
|
+
const bKeys = Object.keys(b);
|
|
58
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
59
|
+
for (const key of aKeys) {
|
|
60
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
61
|
+
if (!deepEqual((a as any)[key], (b as any)[key])) return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// ─────────────────────────────────────────────────────────────
|
|
69
|
+
// CoreProvider
|
|
70
|
+
// ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* CoreProvider: owns the form/core runtime state and implements CoreContext.
|
|
74
|
+
*
|
|
75
|
+
* - Tracks all inputs in a single store (inputsRef)
|
|
76
|
+
* - Supports:
|
|
77
|
+
* - named inputs via `name`
|
|
78
|
+
* - bound inputs via `bindId`
|
|
79
|
+
* - grouped inputs via `groupId`
|
|
80
|
+
* - Manages errors + uncaught messages
|
|
81
|
+
* - Builds values snapshots (including bucket values)
|
|
82
|
+
* - Orchestrates submission via the adapter registry
|
|
83
|
+
*/
|
|
84
|
+
export function CoreProvider<
|
|
85
|
+
V extends Dict,
|
|
86
|
+
S extends z.ZodType | undefined,
|
|
87
|
+
K extends AdapterKey = "local",
|
|
88
|
+
>(props: Props<V, S, K>) {
|
|
89
|
+
type Values = InferFromSchema<S, V>;
|
|
90
|
+
|
|
91
|
+
// Single input store: FieldRegistry
|
|
92
|
+
const registryRef = React.useRef<FieldRegistry>(new FieldRegistry());
|
|
93
|
+
|
|
94
|
+
// bucket, errors, button
|
|
95
|
+
const bucketRef = React.useRef<Dict>({});
|
|
96
|
+
const uncaughtRef = React.useRef<string[]>([]);
|
|
97
|
+
const buttonRef = React.useRef<ButtonRef | null>(null);
|
|
98
|
+
const activeButtonNameRef = React.useRef<string | null>(null);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Original snapshot used for "dirty" checks.
|
|
102
|
+
* Lazily captured on first dirty-check.
|
|
103
|
+
*/
|
|
104
|
+
const originalRef = React.useRef<Values | null>(null);
|
|
105
|
+
|
|
106
|
+
// latest props
|
|
107
|
+
const propsRef = React.useRef(props);
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
propsRef.current = props;
|
|
110
|
+
}, [props]);
|
|
111
|
+
|
|
112
|
+
const adapterKey = (props.adapter ?? "local") as AdapterKey;
|
|
113
|
+
const schema = props.schema;
|
|
114
|
+
|
|
115
|
+
let context!: CoreContext<Values>;
|
|
116
|
+
|
|
117
|
+
// ─────────────────────────────────────────────────────────
|
|
118
|
+
// Common helpers
|
|
119
|
+
// ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function fetchAllNamedFields(): Field[] {
|
|
122
|
+
return registryRef.current.getAllNamed();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearFieldErrors() {
|
|
126
|
+
for (const field of fetchAllNamedFields()) {
|
|
127
|
+
const anyField = field as any;
|
|
128
|
+
if (typeof anyField.setError === "function") {
|
|
129
|
+
anyField.setError(undefined);
|
|
130
|
+
} else if ("error" in anyField) {
|
|
131
|
+
anyField.error = undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function findFieldForErrorKey(key: string): Field | undefined {
|
|
137
|
+
if (!key) return undefined;
|
|
138
|
+
return fetchAllNamedFields().find((f) => {
|
|
139
|
+
const raw = f.name;
|
|
140
|
+
if (!raw) return false;
|
|
141
|
+
const trimmed = raw.trim();
|
|
142
|
+
if (!trimmed) return false;
|
|
143
|
+
|
|
144
|
+
const base = trimmed.replace(/\[]$/, "");
|
|
145
|
+
if (key === base || key === trimmed) return true;
|
|
146
|
+
|
|
147
|
+
const sharedKey = (f as any).shared as string | undefined;
|
|
148
|
+
if (!sharedKey) return false;
|
|
149
|
+
|
|
150
|
+
const sharedBase = `${sharedKey}.${base}`;
|
|
151
|
+
const sharedRaw = `${sharedKey}.${trimmed}`;
|
|
152
|
+
return key === sharedBase || key === sharedRaw;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setFieldError(name: string, message: string) {
|
|
157
|
+
const field = findFieldForErrorKey(name);
|
|
158
|
+
if (field) {
|
|
159
|
+
const anyField = field as any;
|
|
160
|
+
if (typeof anyField.setError === "function") {
|
|
161
|
+
anyField.setError(message);
|
|
162
|
+
} else {
|
|
163
|
+
anyField.error = message;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
uncaughtRef.current.push(message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collect values from inputs into a Values object.
|
|
172
|
+
*
|
|
173
|
+
* Semantics:
|
|
174
|
+
* - `name="tags[]"` ⇒ `values.tags: unknown[]`
|
|
175
|
+
* - `shared="profile", name="first_name"` ⇒ `values.profile.first_name`
|
|
176
|
+
* - bucketRef.current is merged in and overridden by live field values.
|
|
177
|
+
* - `exceptions` can hide keys (e.g. ["password", "profile.ssn"])
|
|
178
|
+
*/
|
|
179
|
+
function collectValues(): Values {
|
|
180
|
+
const exceptions = propsRef.current.exceptions ?? [];
|
|
181
|
+
const list: Dict = {};
|
|
182
|
+
const shared: Dict<Dict> = {};
|
|
183
|
+
|
|
184
|
+
for (const item of fetchAllNamedFields()) {
|
|
185
|
+
const rawName = item.name;
|
|
186
|
+
if (!rawName) continue;
|
|
187
|
+
|
|
188
|
+
const trimmed = rawName.trim();
|
|
189
|
+
if (!trimmed) continue;
|
|
190
|
+
|
|
191
|
+
const isArray = trimmed.endsWith("[]");
|
|
192
|
+
const base = trimmed.replace(/\[]$/, "");
|
|
193
|
+
const sharedKey = (item as any).shared as string | undefined;
|
|
194
|
+
|
|
195
|
+
const target = sharedKey
|
|
196
|
+
? (shared[sharedKey] ?? (shared[sharedKey] = {}))
|
|
197
|
+
: list;
|
|
198
|
+
|
|
199
|
+
const fullPath = sharedKey ? `${sharedKey}.${base}` : base;
|
|
200
|
+
if (
|
|
201
|
+
exceptions.includes(trimmed) ||
|
|
202
|
+
exceptions.includes(base) ||
|
|
203
|
+
exceptions.includes(fullPath)
|
|
204
|
+
) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const anyField = item as any;
|
|
209
|
+
const val =
|
|
210
|
+
typeof anyField.getValue === "function"
|
|
211
|
+
? (anyField.getValue() as unknown)
|
|
212
|
+
: (anyField.value as unknown);
|
|
213
|
+
|
|
214
|
+
if (isArray) {
|
|
215
|
+
const existing = target[base];
|
|
216
|
+
if (Array.isArray(existing)) {
|
|
217
|
+
target[base] = [...existing, val];
|
|
218
|
+
} else if (typeof existing === "undefined") {
|
|
219
|
+
target[base] = [val];
|
|
220
|
+
} else {
|
|
221
|
+
target[base] = [existing, val];
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
target[base] = val;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const fromFields: Dict = { ...list, ...shared };
|
|
229
|
+
const merged: Dict = {
|
|
230
|
+
...bucketRef.current,
|
|
231
|
+
...fromFields,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return merged as Values;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateInternal(report: boolean = false): boolean {
|
|
238
|
+
let valid = true;
|
|
239
|
+
|
|
240
|
+
if (report) {
|
|
241
|
+
uncaughtRef.current = [];
|
|
242
|
+
clearFieldErrors();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// field-level
|
|
246
|
+
for (const field of fetchAllNamedFields()) {
|
|
247
|
+
const anyField = field as any;
|
|
248
|
+
if (typeof anyField.validate === "function") {
|
|
249
|
+
const ok = anyField.validate(report);
|
|
250
|
+
if (!ok) valid = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// schema-level
|
|
255
|
+
if (schema) {
|
|
256
|
+
try {
|
|
257
|
+
schema.parse(collectValues());
|
|
258
|
+
} catch (err: unknown) {
|
|
259
|
+
valid = false;
|
|
260
|
+
|
|
261
|
+
if (report && err && typeof err === "object") {
|
|
262
|
+
const anyErr = err as any;
|
|
263
|
+
if (anyErr.issues) {
|
|
264
|
+
const { fieldErrors, uncaught } = mapZodError(anyErr);
|
|
265
|
+
for (const [name, message] of Object.entries(
|
|
266
|
+
fieldErrors
|
|
267
|
+
)) {
|
|
268
|
+
setFieldError(name, message);
|
|
269
|
+
}
|
|
270
|
+
if (uncaught.length) {
|
|
271
|
+
uncaughtRef.current.push(...uncaught);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return valid;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─────────────────────────────────────────────────────────
|
|
282
|
+
// Submission
|
|
283
|
+
// ─────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
async function submitWithAdapter(
|
|
286
|
+
method: Method,
|
|
287
|
+
route: string,
|
|
288
|
+
extra?: Partial<Values>,
|
|
289
|
+
ignoreForm?: boolean,
|
|
290
|
+
autoErr: boolean = true,
|
|
291
|
+
autoRun: boolean = true
|
|
292
|
+
): Promise<AdapterResult<any> | undefined> {
|
|
293
|
+
const currentProps = propsRef.current;
|
|
294
|
+
|
|
295
|
+
// active button + loading
|
|
296
|
+
const btn = buttonRef.current as any;
|
|
297
|
+
const activeName = activeButtonNameRef.current;
|
|
298
|
+
const isActiveButton =
|
|
299
|
+
!!btn && typeof btn === "object" && btn.name === activeName;
|
|
300
|
+
|
|
301
|
+
const setButtonLoading = (loading: boolean) => {
|
|
302
|
+
if (!isActiveButton) return;
|
|
303
|
+
if (typeof btn.setLoading === "function") {
|
|
304
|
+
btn.setLoading(loading);
|
|
305
|
+
} else if ("loading" in btn) {
|
|
306
|
+
btn.loading = loading;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
setButtonLoading(true);
|
|
311
|
+
|
|
312
|
+
let finished = false;
|
|
313
|
+
const finish = () => {
|
|
314
|
+
if (finished) return;
|
|
315
|
+
finished = true;
|
|
316
|
+
setButtonLoading(false);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
if (!ignoreForm) {
|
|
320
|
+
const ok = validateInternal(true);
|
|
321
|
+
if (!ok) {
|
|
322
|
+
finish();
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let submissionValues: Values = {
|
|
328
|
+
...collectValues(),
|
|
329
|
+
...(extra ?? {}),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const event: SubmitEvent<Values> = {
|
|
333
|
+
preventDefault() {
|
|
334
|
+
this.continue = false;
|
|
335
|
+
},
|
|
336
|
+
editData(cb) {
|
|
337
|
+
const result = cb(submissionValues);
|
|
338
|
+
if (result) {
|
|
339
|
+
submissionValues = result;
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
setRoute(newRoute: string) {
|
|
343
|
+
route = newRoute;
|
|
344
|
+
},
|
|
345
|
+
setMethod(newMethod: Method) {
|
|
346
|
+
method = newMethod;
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
button: buttonRef.current ?? undefined,
|
|
350
|
+
get formData() {
|
|
351
|
+
return submissionValues;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
form: context,
|
|
355
|
+
continue: true,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
if (currentProps.onSubmit) {
|
|
359
|
+
try {
|
|
360
|
+
await currentProps.onSubmit(event as any);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
// host blew up: end this submit cycle
|
|
363
|
+
finish();
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!event.continue) {
|
|
369
|
+
finish();
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const factory =
|
|
374
|
+
getAdapter(adapterKey) ??
|
|
375
|
+
(localAdapter as unknown as (cfg: any) => AdapterResult<any>);
|
|
376
|
+
|
|
377
|
+
const adapter = factory({
|
|
378
|
+
method,
|
|
379
|
+
url: route,
|
|
380
|
+
data: submissionValues,
|
|
381
|
+
callbacks: {
|
|
382
|
+
onSuccess(ok: unknown) {
|
|
383
|
+
const maybe = propsRef.current.onSubmitted;
|
|
384
|
+
if (maybe) {
|
|
385
|
+
void maybe(context, ok as any, () => {
|
|
386
|
+
finish();
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
onError(err: unknown) {
|
|
391
|
+
if (!autoErr || !err || typeof err !== "object") {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const anyErr = err as any;
|
|
396
|
+
if (anyErr.errors && typeof anyErr.errors === "object") {
|
|
397
|
+
const { fieldErrors, uncaught } = mapErrorBag(
|
|
398
|
+
anyErr.errors
|
|
399
|
+
);
|
|
400
|
+
for (const [name, message] of Object.entries(
|
|
401
|
+
fieldErrors
|
|
402
|
+
)) {
|
|
403
|
+
setFieldError(name, message);
|
|
404
|
+
}
|
|
405
|
+
if (uncaught.length) {
|
|
406
|
+
uncaughtRef.current.push(...uncaught);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
onFinish() {
|
|
411
|
+
const maybe = propsRef.current.onFinish;
|
|
412
|
+
if (maybe) {
|
|
413
|
+
maybe(context);
|
|
414
|
+
}
|
|
415
|
+
finish();
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
if (autoRun) {
|
|
421
|
+
try {
|
|
422
|
+
await adapter.send();
|
|
423
|
+
} catch {
|
|
424
|
+
// errors flow via callbacks; adapter may still call onFinish
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return adapter;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// No separate inputs view: expose registry directly via context.inputs
|
|
432
|
+
|
|
433
|
+
// ─────────────────────────────────────────────────────────
|
|
434
|
+
// CoreContext implementation
|
|
435
|
+
// ─────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
context = {
|
|
438
|
+
values(): Values {
|
|
439
|
+
return collectValues();
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
submit(): ValuesResult<Values> {
|
|
443
|
+
const valid = validateInternal(true);
|
|
444
|
+
const vals = collectValues();
|
|
445
|
+
return { values: vals, valid };
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
getBind(id: string): Field | undefined {
|
|
449
|
+
return registryRef.current.getByBind(id);
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
validate(report?: boolean): boolean {
|
|
453
|
+
return validateInternal(report);
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
addField(field: Field): void {
|
|
457
|
+
// Normalise name
|
|
458
|
+
const rawName = field.name ?? "";
|
|
459
|
+
(field as any).name = rawName.trim();
|
|
460
|
+
|
|
461
|
+
// hydrate from valueBag before registering
|
|
462
|
+
const { valueBag, valueFeed } = propsRef.current;
|
|
463
|
+
const trimmed = (field.name ?? "").trim();
|
|
464
|
+
const hasName = !!trimmed;
|
|
465
|
+
const isArray = hasName && trimmed.endsWith("[]");
|
|
466
|
+
const base = hasName ? trimmed.replace(/\[]$/, "") : "";
|
|
467
|
+
const sharedKey = (field as any).shared as string | undefined;
|
|
468
|
+
|
|
469
|
+
if (valueBag && !(field as any).ignore && hasName) {
|
|
470
|
+
const sourceRoot: any =
|
|
471
|
+
sharedKey && (valueBag as any)[sharedKey]
|
|
472
|
+
? (valueBag as any)[sharedKey]
|
|
473
|
+
: valueBag;
|
|
474
|
+
|
|
475
|
+
let value: unknown = undefined;
|
|
476
|
+
|
|
477
|
+
if (sourceRoot && typeof sourceRoot === "object") {
|
|
478
|
+
if (isArray && Array.isArray(sourceRoot[base])) {
|
|
479
|
+
const siblings = fetchAllNamedFields().filter((f) => {
|
|
480
|
+
const rn = (f.name ?? "").trim();
|
|
481
|
+
return (
|
|
482
|
+
rn === trimmed &&
|
|
483
|
+
((f as any).shared as string | undefined) ===
|
|
484
|
+
sharedKey
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
const idx = siblings.length;
|
|
488
|
+
value = (sourceRoot[base] as unknown[])[idx];
|
|
489
|
+
} else {
|
|
490
|
+
value = sourceRoot[base];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let hydrated: unknown = value;
|
|
495
|
+
if (valueFeed) {
|
|
496
|
+
const maybe = valueFeed(
|
|
497
|
+
base as keyof Values,
|
|
498
|
+
value as any,
|
|
499
|
+
context as any
|
|
500
|
+
);
|
|
501
|
+
if (typeof maybe !== "undefined") {
|
|
502
|
+
hydrated = maybe;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (typeof hydrated !== "undefined") {
|
|
507
|
+
const anyField = field as any;
|
|
508
|
+
if (typeof anyField.setValue === "function") {
|
|
509
|
+
anyField.setValue(hydrated);
|
|
510
|
+
} else {
|
|
511
|
+
anyField.value = hydrated;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// finally register into the single store (name/bindId/groupId)
|
|
517
|
+
registryRef.current.add(field);
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
// Expose registry view as inputs (delegates to FieldRegistry instance)
|
|
521
|
+
inputs: registryRef.current,
|
|
522
|
+
|
|
523
|
+
// Also expose raw list of fields for compatibility is defined later as a getter
|
|
524
|
+
|
|
525
|
+
bucket: bucketRef.current,
|
|
526
|
+
|
|
527
|
+
error(
|
|
528
|
+
nameOrBag: string | Record<string, string>,
|
|
529
|
+
maybeMsg?: string
|
|
530
|
+
): void {
|
|
531
|
+
if (typeof nameOrBag === "string") {
|
|
532
|
+
if (!maybeMsg) return;
|
|
533
|
+
setFieldError(nameOrBag, maybeMsg);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const { fieldErrors, uncaught } = mapErrorBag(nameOrBag);
|
|
538
|
+
for (const [name, message] of Object.entries(fieldErrors)) {
|
|
539
|
+
setFieldError(name, message);
|
|
540
|
+
}
|
|
541
|
+
if (uncaught.length) {
|
|
542
|
+
uncaughtRef.current.push(...uncaught);
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
controlButton(): void {
|
|
547
|
+
const { activateButtonOnChange } = propsRef.current;
|
|
548
|
+
if (!activateButtonOnChange) return;
|
|
549
|
+
|
|
550
|
+
const btn = buttonRef.current as any;
|
|
551
|
+
const activeName = activeButtonNameRef.current;
|
|
552
|
+
|
|
553
|
+
// If there is no active button or it doesn't match, nothing to control.
|
|
554
|
+
if (!btn || btn.name !== activeName) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Capture original snapshot lazily.
|
|
559
|
+
if (!originalRef.current) {
|
|
560
|
+
originalRef.current = collectValues();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const current = collectValues();
|
|
564
|
+
const original = originalRef.current!;
|
|
565
|
+
|
|
566
|
+
const dirty = !deepEqual(original, current);
|
|
567
|
+
|
|
568
|
+
const setDisabled = (disabled: boolean) => {
|
|
569
|
+
if (typeof btn.setDisabled === "function") {
|
|
570
|
+
btn.setDisabled(disabled);
|
|
571
|
+
} else if ("disabled" in btn) {
|
|
572
|
+
btn.disabled = disabled;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Dirty ⇒ enable button, clean ⇒ disable button
|
|
577
|
+
setDisabled(!dirty);
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
isDirty() {
|
|
581
|
+
if (!originalRef.current) {
|
|
582
|
+
originalRef.current = collectValues();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const current = collectValues();
|
|
586
|
+
const original = originalRef.current!;
|
|
587
|
+
|
|
588
|
+
return !deepEqual(original, current);
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
async prepare(
|
|
592
|
+
type: Method,
|
|
593
|
+
route: string,
|
|
594
|
+
extra?: Partial<Values>,
|
|
595
|
+
ignoreForm?: boolean,
|
|
596
|
+
autoErr?: boolean
|
|
597
|
+
): Promise<AdapterResult<any> | undefined> {
|
|
598
|
+
return submitWithAdapter(
|
|
599
|
+
type,
|
|
600
|
+
route,
|
|
601
|
+
extra,
|
|
602
|
+
ignoreForm,
|
|
603
|
+
autoErr,
|
|
604
|
+
false
|
|
605
|
+
);
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
persist(
|
|
609
|
+
data: Partial<Values>,
|
|
610
|
+
feed?: (name: string, value: unknown, original: unknown) => unknown
|
|
611
|
+
): void {
|
|
612
|
+
const seen: Record<string, number> = {};
|
|
613
|
+
const root = data as any;
|
|
614
|
+
|
|
615
|
+
const useFeed =
|
|
616
|
+
feed ||
|
|
617
|
+
(propsRef.current.valueFeed
|
|
618
|
+
? (
|
|
619
|
+
name: string,
|
|
620
|
+
value: unknown,
|
|
621
|
+
original: unknown
|
|
622
|
+
): unknown => {
|
|
623
|
+
const vf = propsRef.current.valueFeed!;
|
|
624
|
+
const maybe = vf(
|
|
625
|
+
name as keyof Values,
|
|
626
|
+
value as any,
|
|
627
|
+
context as any
|
|
628
|
+
);
|
|
629
|
+
return typeof maybe === "undefined"
|
|
630
|
+
? original
|
|
631
|
+
: maybe;
|
|
632
|
+
}
|
|
633
|
+
: undefined);
|
|
634
|
+
|
|
635
|
+
for (const field of fetchAllNamedFields()) {
|
|
636
|
+
const rawName = field.name;
|
|
637
|
+
if (!rawName) continue;
|
|
638
|
+
if ((field as any).ignore) continue;
|
|
639
|
+
|
|
640
|
+
const trimmed = rawName.trim();
|
|
641
|
+
if (!trimmed) continue;
|
|
642
|
+
|
|
643
|
+
const isArray = trimmed.endsWith("[]");
|
|
644
|
+
const base = trimmed.replace(/\[]$/, "");
|
|
645
|
+
const sharedKey = (field as any).shared as string | undefined;
|
|
646
|
+
const key = sharedKey ? `${sharedKey}.${base}` : base;
|
|
647
|
+
|
|
648
|
+
let value: unknown = undefined;
|
|
649
|
+
|
|
650
|
+
if (sharedKey) {
|
|
651
|
+
const group = root[sharedKey];
|
|
652
|
+
if (group && typeof group === "object") {
|
|
653
|
+
if (isArray && Array.isArray(group[base])) {
|
|
654
|
+
const idx = seen[key] ?? 0;
|
|
655
|
+
value = (group[base] as unknown[])[idx];
|
|
656
|
+
seen[key] = idx + 1;
|
|
657
|
+
} else {
|
|
658
|
+
value = group[base];
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
if (isArray && Array.isArray(root[base])) {
|
|
663
|
+
const idx = seen[key] ?? 0;
|
|
664
|
+
value = (root[base] as unknown[])[idx];
|
|
665
|
+
seen[key] = idx + 1;
|
|
666
|
+
} else {
|
|
667
|
+
value = root[base];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const anyField = field as any;
|
|
672
|
+
const original =
|
|
673
|
+
typeof anyField.getValue === "function"
|
|
674
|
+
? anyField.getValue()
|
|
675
|
+
: anyField.value;
|
|
676
|
+
|
|
677
|
+
let next = value;
|
|
678
|
+
if (useFeed) {
|
|
679
|
+
const maybe = useFeed(base, value, original);
|
|
680
|
+
if (typeof maybe === "undefined") {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
next = maybe;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (typeof anyField.setValue === "function") {
|
|
687
|
+
anyField.setValue(next);
|
|
688
|
+
} else {
|
|
689
|
+
anyField.value = next;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (propsRef.current.onUpdate) {
|
|
694
|
+
propsRef.current.onUpdate(collectValues());
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
setValue(name: string, value: unknown): void {
|
|
699
|
+
if (!name) return;
|
|
700
|
+
|
|
701
|
+
let sharedKey: string | undefined;
|
|
702
|
+
let base = name;
|
|
703
|
+
|
|
704
|
+
if (name.includes(".")) {
|
|
705
|
+
const [group, field] = name.split(".", 2);
|
|
706
|
+
sharedKey = group;
|
|
707
|
+
base = field;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const targetField = fetchAllNamedFields().find((f) => {
|
|
711
|
+
const raw = (f.name ?? "").trim();
|
|
712
|
+
if (!raw) return false;
|
|
713
|
+
|
|
714
|
+
const isArray = raw.endsWith("[]");
|
|
715
|
+
const rawBase = raw.replace(/\[]$/, "");
|
|
716
|
+
const fShared = (f as any).shared as string | undefined;
|
|
717
|
+
|
|
718
|
+
const sameGroup = fShared === sharedKey;
|
|
719
|
+
const sameName =
|
|
720
|
+
raw === name ||
|
|
721
|
+
rawBase === base ||
|
|
722
|
+
`${fShared}.${rawBase}` === name;
|
|
723
|
+
|
|
724
|
+
return (!sharedKey || sameGroup) && sameName && !isArray;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
if (targetField) {
|
|
728
|
+
const anyField = targetField as any;
|
|
729
|
+
if (typeof anyField.setValue === "function") {
|
|
730
|
+
anyField.setValue(value);
|
|
731
|
+
} else {
|
|
732
|
+
anyField.value = value;
|
|
733
|
+
}
|
|
734
|
+
} else {
|
|
735
|
+
bucketRef.current[name] = value;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (propsRef.current.onUpdate) {
|
|
739
|
+
propsRef.current.onUpdate(collectValues());
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
go(data?: Partial<Values>, ignoreForm?: boolean): void {
|
|
744
|
+
void submitWithAdapter("post", "", data, ignoreForm, true, true);
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
reset(inputs: string[]): void {
|
|
748
|
+
if (!inputs.length) return;
|
|
749
|
+
|
|
750
|
+
for (const field of fetchAllNamedFields()) {
|
|
751
|
+
const raw = field.name;
|
|
752
|
+
if (!raw) continue;
|
|
753
|
+
if (!inputs.includes(raw)) continue;
|
|
754
|
+
|
|
755
|
+
const anyField = field as any;
|
|
756
|
+
if (typeof anyField.reset === "function") {
|
|
757
|
+
anyField.reset();
|
|
758
|
+
} else if (typeof anyField.setValue === "function") {
|
|
759
|
+
anyField.setValue(undefined);
|
|
760
|
+
} else {
|
|
761
|
+
anyField.value = undefined;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
set button(btn: ButtonRef) {
|
|
767
|
+
buttonRef.current = btn;
|
|
768
|
+
},
|
|
769
|
+
|
|
770
|
+
async forceSubmit(): Promise<void> {
|
|
771
|
+
await submitWithAdapter("post", "", undefined, false, true, true);
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
get fields(): Field[] {
|
|
775
|
+
return fetchAllNamedFields();
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
get props() {
|
|
779
|
+
const { formRef, valueBag, ...rest } = propsRef.current;
|
|
780
|
+
return rest as any;
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
setActiveButton(name: string): void {
|
|
784
|
+
activeButtonNameRef.current = name;
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
getUncaught(): readonly string[] {
|
|
788
|
+
return uncaughtRef.current;
|
|
789
|
+
},
|
|
790
|
+
} as CoreContext<Values>;
|
|
791
|
+
|
|
792
|
+
// formRef exposure
|
|
793
|
+
React.useEffect(() => {
|
|
794
|
+
if (!props.formRef) return;
|
|
795
|
+
|
|
796
|
+
props.formRef.current = context;
|
|
797
|
+
return () => {
|
|
798
|
+
if (props.formRef) {
|
|
799
|
+
props.formRef.current = null;
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
803
|
+
}, [context]);
|
|
804
|
+
|
|
805
|
+
// init hook once
|
|
806
|
+
React.useEffect(() => {
|
|
807
|
+
if (props.init) {
|
|
808
|
+
props.init(context);
|
|
809
|
+
}
|
|
810
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
811
|
+
}, []);
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
<CoreContextReact.Provider value={context as any}>
|
|
815
|
+
{props.children}
|
|
816
|
+
</CoreContextReact.Provider>
|
|
817
|
+
);
|
|
818
|
+
}
|