@vectoriox/iox-ui 4.30.2 → 4.31.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/fesm2022/vectoriox-iox-ui-forms-core.mjs +4 -1
- package/fesm2022/vectoriox-iox-ui-forms-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +4 -1
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/package.json +1 -1
- package/src/forms-core/collect.ts +147 -0
- package/src/forms-core/form.model.ts +139 -0
- package/src/forms-core/messages.ts +82 -0
- package/src/forms-core/public-api.ts +17 -0
- package/src/forms-core/runtime.ts +121 -0
- package/src/forms-core/validate.ts +232 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// The validator. ONE implementation, run by the builder canvas, the SSR engine and `iox-cms`.
|
|
2
|
+
//
|
|
3
|
+
// Client-side validation is UX; server-side validation is truth. Because both call this function,
|
|
4
|
+
// a visitor can never be told a value is fine and then have the server reject it, and a hand-crafted
|
|
5
|
+
// POST can never bypass a rule the author configured.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ControlType,
|
|
9
|
+
FieldSpec,
|
|
10
|
+
FormSpec,
|
|
11
|
+
IMPLIED_PATTERN,
|
|
12
|
+
PATTERN_PRESETS,
|
|
13
|
+
PatternPreset,
|
|
14
|
+
RuleName,
|
|
15
|
+
ValidationRule,
|
|
16
|
+
} from './form.model';
|
|
17
|
+
import { PATTERN_MESSAGE_KEYS, RULE_MESSAGE_KEYS } from './messages';
|
|
18
|
+
|
|
19
|
+
export interface FieldError {
|
|
20
|
+
/** i18n key, or the author's own override string when they set one. */
|
|
21
|
+
messageKey: string;
|
|
22
|
+
/** Interpolation values (`{ n: 8 }`, `{ field: 'Email' }`). */
|
|
23
|
+
params?: Record<string, any>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FieldResult extends Partial<FieldError> {
|
|
27
|
+
valid: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FormResult {
|
|
31
|
+
valid: boolean;
|
|
32
|
+
/** Keyed by field name. Empty when valid. */
|
|
33
|
+
errors: Record<string, FieldError>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A file, as either runtime hands it over. Kept structural so this file imports no DOM types. */
|
|
37
|
+
interface FileLike {
|
|
38
|
+
name?: string;
|
|
39
|
+
size?: number;
|
|
40
|
+
type?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate one field's value.
|
|
45
|
+
*
|
|
46
|
+
* Order is deliberate and is part of the contract:
|
|
47
|
+
* 1. `required` (or `accepted` for a checkbox) — the only rules an EMPTY value can fail.
|
|
48
|
+
* 2. Everything else, in the order the author arranged them, but ONLY for a non-empty value.
|
|
49
|
+
*
|
|
50
|
+
* That second point is what stops an optional "Phone" field from shouting "enter a valid phone
|
|
51
|
+
* number" at someone who simply left it blank — the single most common validation-UX bug.
|
|
52
|
+
* The FIRST failure wins: a visitor fixes one thing at a time.
|
|
53
|
+
*/
|
|
54
|
+
export function validateField(
|
|
55
|
+
value: unknown,
|
|
56
|
+
spec: FieldSpec,
|
|
57
|
+
form: Record<string, unknown> = {},
|
|
58
|
+
): FieldResult {
|
|
59
|
+
const rules = effectiveRules(spec);
|
|
60
|
+
const empty = isEmpty(value, spec.control);
|
|
61
|
+
|
|
62
|
+
for (const rule of rules) {
|
|
63
|
+
const appliesToEmpty = rule.name === RuleName.Required || rule.name === RuleName.Accepted;
|
|
64
|
+
if (empty && !appliesToEmpty) continue;
|
|
65
|
+
|
|
66
|
+
const failure = check(rule, value, spec, form);
|
|
67
|
+
if (failure) {
|
|
68
|
+
return {
|
|
69
|
+
valid: false,
|
|
70
|
+
messageKey: rule.message ?? failure.messageKey,
|
|
71
|
+
params: failure.params,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { valid: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate a whole submission. Every field is checked (not short-circuited at the first bad one) so
|
|
80
|
+
* the visitor sees every problem at once instead of fixing them one reload at a time.
|
|
81
|
+
*/
|
|
82
|
+
export function validateForm(
|
|
83
|
+
values: Record<string, unknown>,
|
|
84
|
+
spec: FormSpec,
|
|
85
|
+
): FormResult {
|
|
86
|
+
const errors: Record<string, FieldError> = {};
|
|
87
|
+
for (const field of spec.fields ?? []) {
|
|
88
|
+
const result = validateField(values?.[field.name], field, values ?? {});
|
|
89
|
+
if (!result.valid) {
|
|
90
|
+
errors[field.name] = { messageKey: result.messageKey!, params: result.params };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { valid: Object.keys(errors).length === 0, errors };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The name of the first field with an error, in spec order — what the browser should focus. */
|
|
97
|
+
export function firstInvalidField(spec: FormSpec, errors: Record<string, FieldError>): string | null {
|
|
98
|
+
for (const field of spec.fields ?? []) if (errors[field.name]) return field.name;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── internals ───────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The author's rules, plus the ones the field's own configuration implies: `required` from the
|
|
106
|
+
* switch (rather than making the author add a rule for the commonest case) and the pattern implied
|
|
107
|
+
* by an Email/Tel/Url control. An explicitly declared rule of the same name always wins, so an
|
|
108
|
+
* author who sets their own `pattern` on an Email field is not fighting an invisible second one.
|
|
109
|
+
*/
|
|
110
|
+
function effectiveRules(spec: FieldSpec): ValidationRule[] {
|
|
111
|
+
const declared = spec.rules ?? [];
|
|
112
|
+
const has = (name: RuleName) => declared.some(r => r.name === name);
|
|
113
|
+
const implied: ValidationRule[] = [];
|
|
114
|
+
|
|
115
|
+
if (spec.required && !has(RuleName.Required)) {
|
|
116
|
+
// A checkbox's "required" means TICKED, which is a different message and a different check.
|
|
117
|
+
implied.push(spec.control === ControlType.Checkbox
|
|
118
|
+
? { name: RuleName.Accepted }
|
|
119
|
+
: { name: RuleName.Required });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const preset = IMPLIED_PATTERN[spec.control];
|
|
123
|
+
if (preset && !has(RuleName.Pattern)) {
|
|
124
|
+
implied.push({ name: RuleName.Pattern, params: { preset } });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return [...implied, ...declared];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Empty means "the visitor gave us nothing" — per control type, not a blanket falsy check. */
|
|
131
|
+
function isEmpty(value: unknown, control: ControlType): boolean {
|
|
132
|
+
if (value === null || value === undefined) return true;
|
|
133
|
+
if (control === ControlType.Checkbox) return value === false;
|
|
134
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
135
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function check(
|
|
140
|
+
rule: ValidationRule,
|
|
141
|
+
value: unknown,
|
|
142
|
+
spec: FieldSpec,
|
|
143
|
+
form: Record<string, unknown>,
|
|
144
|
+
): FieldError | null {
|
|
145
|
+
const fail = (messageKey: string, params?: Record<string, any>): FieldError => ({ messageKey, params });
|
|
146
|
+
const defaultKey = RULE_MESSAGE_KEYS[rule.name];
|
|
147
|
+
const n = Number(rule.params?.['n']);
|
|
148
|
+
|
|
149
|
+
switch (rule.name) {
|
|
150
|
+
case RuleName.Required:
|
|
151
|
+
return isEmpty(value, spec.control) ? fail(defaultKey) : null;
|
|
152
|
+
|
|
153
|
+
case RuleName.Accepted:
|
|
154
|
+
return value === true ? null : fail(defaultKey);
|
|
155
|
+
|
|
156
|
+
case RuleName.MinLength:
|
|
157
|
+
return text(value).length < n ? fail(defaultKey, { n }) : null;
|
|
158
|
+
|
|
159
|
+
case RuleName.MaxLength:
|
|
160
|
+
return text(value).length > n ? fail(defaultKey, { n }) : null;
|
|
161
|
+
|
|
162
|
+
case RuleName.Min: {
|
|
163
|
+
const num = numeric(value);
|
|
164
|
+
return num === null || num < n ? fail(defaultKey, { n }) : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case RuleName.Max: {
|
|
168
|
+
const num = numeric(value);
|
|
169
|
+
return num === null || num > n ? fail(defaultKey, { n }) : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case RuleName.Pattern: {
|
|
173
|
+
const regex = patternFor(rule);
|
|
174
|
+
if (!regex) return null; // an unparsable custom regex must not block a submission
|
|
175
|
+
if (regex.test(text(value))) return null;
|
|
176
|
+
const preset = rule.params?.['preset'] as string | undefined;
|
|
177
|
+
return fail((preset && PATTERN_MESSAGE_KEYS[preset]) || defaultKey);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case RuleName.Matches: {
|
|
181
|
+
const other = String(rule.params?.['field'] ?? '');
|
|
182
|
+
return text(value) === text(form?.[other]) ? null : fail(defaultKey, { field: other });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case RuleName.FileType: {
|
|
186
|
+
const accepted = list(rule.params?.['types']).map(t => t.toLowerCase().replace(/^\./, ''));
|
|
187
|
+
if (!accepted.length) return null;
|
|
188
|
+
const bad = files(value).some(f => {
|
|
189
|
+
const ext = (f.name ?? '').split('.').pop()?.toLowerCase() ?? '';
|
|
190
|
+
const mime = (f.type ?? '').toLowerCase();
|
|
191
|
+
return !accepted.some(t => t === ext || mime === t || mime.startsWith(t + '/'));
|
|
192
|
+
});
|
|
193
|
+
return bad ? fail(defaultKey, { types: accepted.join(', ') }) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
case RuleName.FileSize: {
|
|
197
|
+
const maxBytes = n * 1024 * 1024;
|
|
198
|
+
return files(value).some(f => (f.size ?? 0) > maxBytes) ? fail(defaultKey, { n }) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
default:
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function patternFor(rule: ValidationRule): RegExp | null {
|
|
207
|
+
const preset = rule.params?.['preset'] as PatternPreset | undefined;
|
|
208
|
+
if (preset && PATTERN_PRESETS[preset]) return PATTERN_PRESETS[preset];
|
|
209
|
+
const custom = rule.params?.['regex'];
|
|
210
|
+
if (typeof custom !== 'string' || !custom) return null;
|
|
211
|
+
// A malformed regex is the AUTHOR's mistake; failing open is the only safe reading, because
|
|
212
|
+
// failing closed would make a live form permanently unsubmittable for every visitor.
|
|
213
|
+
try { return new RegExp(custom); } catch { return null; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const text = (value: unknown): string =>
|
|
217
|
+
value === null || value === undefined ? '' : String(value).trim();
|
|
218
|
+
|
|
219
|
+
function numeric(value: unknown): number | null {
|
|
220
|
+
const parsed = typeof value === 'number' ? value : parseFloat(text(value));
|
|
221
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const list = (value: unknown): string[] =>
|
|
225
|
+
Array.isArray(value) ? value.map(String)
|
|
226
|
+
: typeof value === 'string' ? value.split(',').map(s => s.trim()).filter(Boolean)
|
|
227
|
+
: [];
|
|
228
|
+
|
|
229
|
+
const files = (value: unknown): FileLike[] =>
|
|
230
|
+
Array.isArray(value) ? value as FileLike[]
|
|
231
|
+
: value && typeof value === 'object' ? [value as FileLike]
|
|
232
|
+
: [];
|