@coherent.js/forms 1.1.0 → 2.0.0-rc.0
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/README.md +162 -243
- package/dist/csrf.js +57 -0
- package/dist/csrf.js.map +7 -0
- package/dist/form-builder.js +483 -27
- package/dist/form-builder.js.map +3 -3
- package/dist/form-hydration.js +317 -222
- package/dist/form-hydration.js.map +3 -3
- package/dist/index.js +675 -555
- package/dist/index.js.map +4 -4
- package/dist/validation.js +376 -61
- package/dist/validation.js.map +3 -3
- package/dist/validators.js +388 -294
- package/dist/validators.js.map +4 -4
- package/package.json +6 -3
- package/types/csrf.d.ts +45 -0
- package/types/index.d.ts +185 -41
package/dist/form-builder.js
CHANGED
|
@@ -1,5 +1,404 @@
|
|
|
1
1
|
// src/form-builder.js
|
|
2
2
|
import { render as renderToHTML } from "@coherent.js/core";
|
|
3
|
+
|
|
4
|
+
// src/patterns.js
|
|
5
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
6
|
+
var EMAIL_MAX_LENGTH = 254;
|
|
7
|
+
function isEmailShaped(value) {
|
|
8
|
+
return typeof value === "string" && value.length <= EMAIL_MAX_LENGTH && EMAIL_PATTERN.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/rules.js
|
|
12
|
+
var BUILTIN = /* @__PURE__ */ Symbol("coherent.forms.builtin");
|
|
13
|
+
var DESCRIPTORS = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
var HELPER_NAMES = /* @__PURE__ */ new Set(["get", "compose", "debounce", "cancellable", "when", "chain"]);
|
|
15
|
+
var isEmpty = (value) => value === null || value === void 0 || value === "";
|
|
16
|
+
var isOptionsObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof RegExp);
|
|
17
|
+
function argsFromString(params, kind, text) {
|
|
18
|
+
if (text === void 0 || text.trim() === "") return [];
|
|
19
|
+
if (params.length === 0) return [text.trim()];
|
|
20
|
+
if (kind === "list") {
|
|
21
|
+
return [text.split(",").map((part) => part.trim()).filter(Boolean)];
|
|
22
|
+
}
|
|
23
|
+
if (kind === "regexp") {
|
|
24
|
+
try {
|
|
25
|
+
return [new RegExp(text)];
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const comma = text.indexOf(",");
|
|
31
|
+
const raw = (comma === -1 ? text : text.slice(0, comma)).trim();
|
|
32
|
+
const message = comma === -1 ? "" : text.slice(comma + 1).trim();
|
|
33
|
+
let arg = raw;
|
|
34
|
+
if (kind === "number") {
|
|
35
|
+
arg = Number(raw);
|
|
36
|
+
if (raw === "" || Number.isNaN(arg)) return null;
|
|
37
|
+
}
|
|
38
|
+
return message ? [arg, message] : [arg];
|
|
39
|
+
}
|
|
40
|
+
function defineRule(name, { params = [], stringArg = "string", valid, message }) {
|
|
41
|
+
const fromOptions = (options = {}) => {
|
|
42
|
+
const config = { message: options.message };
|
|
43
|
+
for (const [key, ...aliases] of params) {
|
|
44
|
+
config[key] = [key, ...aliases].map((alias) => options[alias]).find((v) => v !== void 0);
|
|
45
|
+
}
|
|
46
|
+
return config;
|
|
47
|
+
};
|
|
48
|
+
const check = (value, config, formData) => {
|
|
49
|
+
if (valid(value, config, formData || {})) return null;
|
|
50
|
+
return config.message || builtin.message || message(config);
|
|
51
|
+
};
|
|
52
|
+
const factory = (...args) => {
|
|
53
|
+
const config = { message: args[params.length] };
|
|
54
|
+
params.forEach(([key], index) => {
|
|
55
|
+
config[key] = args[index];
|
|
56
|
+
});
|
|
57
|
+
const rule = (value, formData, _translator, allValues) => check(value, config, allValues ?? formData);
|
|
58
|
+
DESCRIPTORS.set(rule, { name, args });
|
|
59
|
+
return rule;
|
|
60
|
+
};
|
|
61
|
+
const isDirectCall = (args) => {
|
|
62
|
+
if (args.length >= 2) {
|
|
63
|
+
return params.length === 0 || isOptionsObject(args[1]);
|
|
64
|
+
}
|
|
65
|
+
if (args.length === 1 && params.length === 0) {
|
|
66
|
+
const [arg] = args;
|
|
67
|
+
return arg === "" || arg !== void 0 && arg !== null && typeof arg !== "string";
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
function builtin(...args) {
|
|
72
|
+
if (isDirectCall(args)) {
|
|
73
|
+
const [value, options, , allValues] = args;
|
|
74
|
+
return check(value, fromOptions(isOptionsObject(options) ? options : {}), allValues);
|
|
75
|
+
}
|
|
76
|
+
return factory(...args);
|
|
77
|
+
}
|
|
78
|
+
Object.defineProperty(builtin, "name", { value: name });
|
|
79
|
+
builtin[BUILTIN] = {
|
|
80
|
+
name,
|
|
81
|
+
factory,
|
|
82
|
+
argsFromString: (text) => argsFromString(params, stringArg, text)
|
|
83
|
+
};
|
|
84
|
+
return builtin;
|
|
85
|
+
}
|
|
86
|
+
var toNumber = (value) => typeof value === "number" ? value : Number(value);
|
|
87
|
+
function testRegExp(regex, value) {
|
|
88
|
+
if (!(regex instanceof RegExp)) return true;
|
|
89
|
+
regex.lastIndex = 0;
|
|
90
|
+
return regex.test(String(value));
|
|
91
|
+
}
|
|
92
|
+
function fileMatchesType(file, allowedTypes) {
|
|
93
|
+
const fileType = file.type;
|
|
94
|
+
const fileExt = file.name ? file.name.split(".").pop().toLowerCase() : "";
|
|
95
|
+
return allowedTypes.some((type) => {
|
|
96
|
+
if (type.startsWith(".")) {
|
|
97
|
+
return fileExt === type.slice(1).toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
if (type.includes("/")) {
|
|
100
|
+
if (type.endsWith("/*")) {
|
|
101
|
+
return fileType.startsWith(type.replace("/*", "/"));
|
|
102
|
+
}
|
|
103
|
+
return fileType === type;
|
|
104
|
+
}
|
|
105
|
+
return fileExt === type.toLowerCase();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
var validators = {
|
|
109
|
+
required: defineRule("required", {
|
|
110
|
+
valid: (value) => !isEmpty(value),
|
|
111
|
+
message: () => "This field is required"
|
|
112
|
+
}),
|
|
113
|
+
email: defineRule("email", {
|
|
114
|
+
valid: (value) => isEmpty(value) || isEmailShaped(value),
|
|
115
|
+
message: () => "Invalid email address"
|
|
116
|
+
}),
|
|
117
|
+
url: defineRule("url", {
|
|
118
|
+
valid: (value) => {
|
|
119
|
+
if (isEmpty(value)) return true;
|
|
120
|
+
try {
|
|
121
|
+
new URL(value);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
message: () => "Invalid URL"
|
|
128
|
+
}),
|
|
129
|
+
minLength: defineRule("minLength", {
|
|
130
|
+
params: [["min", "minLength"]],
|
|
131
|
+
stringArg: "number",
|
|
132
|
+
valid: (value, { min }) => !value || !(value.length < (min ?? 0)),
|
|
133
|
+
message: ({ min }) => `Minimum length is ${min}`
|
|
134
|
+
}),
|
|
135
|
+
maxLength: defineRule("maxLength", {
|
|
136
|
+
params: [["max", "maxLength"]],
|
|
137
|
+
stringArg: "number",
|
|
138
|
+
valid: (value, { max }) => !value || !(value.length > (max ?? Infinity)),
|
|
139
|
+
message: ({ max }) => `Maximum length is ${max}`
|
|
140
|
+
}),
|
|
141
|
+
// Empty values pass (combine with `required`); a non-numeric value fails.
|
|
142
|
+
min: defineRule("min", {
|
|
143
|
+
params: [["min"]],
|
|
144
|
+
stringArg: "number",
|
|
145
|
+
valid: (value, { min }) => {
|
|
146
|
+
if (isEmpty(value)) return true;
|
|
147
|
+
const number = toNumber(value);
|
|
148
|
+
return !Number.isNaN(number) && !(number < (min ?? -Infinity));
|
|
149
|
+
},
|
|
150
|
+
message: ({ min }) => `Minimum value is ${min}`
|
|
151
|
+
}),
|
|
152
|
+
max: defineRule("max", {
|
|
153
|
+
params: [["max"]],
|
|
154
|
+
stringArg: "number",
|
|
155
|
+
valid: (value, { max }) => {
|
|
156
|
+
if (isEmpty(value)) return true;
|
|
157
|
+
const number = toNumber(value);
|
|
158
|
+
return !Number.isNaN(number) && !(number > (max ?? Infinity));
|
|
159
|
+
},
|
|
160
|
+
message: ({ max }) => `Maximum value is ${max}`
|
|
161
|
+
}),
|
|
162
|
+
pattern: defineRule("pattern", {
|
|
163
|
+
params: [["pattern", "regex"]],
|
|
164
|
+
stringArg: "regexp",
|
|
165
|
+
valid: (value, { pattern }) => isEmpty(value) || testRegExp(pattern, value),
|
|
166
|
+
message: () => "Invalid format"
|
|
167
|
+
}),
|
|
168
|
+
/** Equal to another field, even when empty */
|
|
169
|
+
matches: defineRule("matches", {
|
|
170
|
+
params: [["field", "fieldName"]],
|
|
171
|
+
valid: (value, { field }, formData) => value === formData[field],
|
|
172
|
+
message: () => "Fields do not match"
|
|
173
|
+
}),
|
|
174
|
+
/** Equal to another field; an empty value passes */
|
|
175
|
+
match: defineRule("match", {
|
|
176
|
+
params: [["field", "fieldName"]],
|
|
177
|
+
valid: (value, { field }, formData) => !value || value === formData[field],
|
|
178
|
+
message: ({ field }) => `Must match ${field}`
|
|
179
|
+
}),
|
|
180
|
+
oneOf: defineRule("oneOf", {
|
|
181
|
+
params: [["options", "values"]],
|
|
182
|
+
stringArg: "list",
|
|
183
|
+
valid: (value, { options }) => !value || !Array.isArray(options) || options.includes(value),
|
|
184
|
+
message: () => "Invalid option"
|
|
185
|
+
}),
|
|
186
|
+
custom: defineRule("custom", {
|
|
187
|
+
params: [["validator", "fn"]],
|
|
188
|
+
valid: (value, { validator }, formData) => typeof validator !== "function" || Boolean(validator(value, formData)),
|
|
189
|
+
message: () => "Validation failed"
|
|
190
|
+
}),
|
|
191
|
+
number: defineRule("number", {
|
|
192
|
+
valid: (value) => isEmpty(value) || !Number.isNaN(Number(value)),
|
|
193
|
+
message: () => "Must be a valid number"
|
|
194
|
+
}),
|
|
195
|
+
integer: defineRule("integer", {
|
|
196
|
+
valid: (value) => isEmpty(value) || Number.isInteger(Number(value)),
|
|
197
|
+
message: () => "Must be a whole number"
|
|
198
|
+
}),
|
|
199
|
+
phone: defineRule("phone", {
|
|
200
|
+
valid: (value) => !value || /^[\d\s\-+()]+$/.test(value) && value.replace(/\D/g, "").length >= 10,
|
|
201
|
+
message: () => "Please enter a valid phone number"
|
|
202
|
+
}),
|
|
203
|
+
date: defineRule("date", {
|
|
204
|
+
valid: (value) => !value || !Number.isNaN(new Date(value).getTime()),
|
|
205
|
+
message: () => "Please enter a valid date"
|
|
206
|
+
}),
|
|
207
|
+
alpha: defineRule("alpha", {
|
|
208
|
+
valid: (value) => !value || /^[a-zA-Z]+$/.test(value),
|
|
209
|
+
message: () => "Must contain only letters"
|
|
210
|
+
}),
|
|
211
|
+
alphanumeric: defineRule("alphanumeric", {
|
|
212
|
+
valid: (value) => !value || /^[a-zA-Z0-9]+$/.test(value),
|
|
213
|
+
message: () => "Must contain only letters and numbers"
|
|
214
|
+
}),
|
|
215
|
+
uppercase: defineRule("uppercase", {
|
|
216
|
+
valid: (value) => !value || value === String(value).toUpperCase(),
|
|
217
|
+
message: () => "Must be uppercase"
|
|
218
|
+
}),
|
|
219
|
+
fileType: defineRule("fileType", {
|
|
220
|
+
params: [["accept", "types"]],
|
|
221
|
+
stringArg: "list",
|
|
222
|
+
valid: (value, { accept }) => !value || value.type === void 0 || fileMatchesType(value, accept || []),
|
|
223
|
+
message: ({ accept }) => `File type must be one of: ${(accept || []).join(", ")}`
|
|
224
|
+
}),
|
|
225
|
+
fileSize: defineRule("fileSize", {
|
|
226
|
+
params: [["maxSize"]],
|
|
227
|
+
stringArg: "number",
|
|
228
|
+
valid: (value, { maxSize }) => !value || value.size === void 0 || !(value.size > (maxSize ?? Infinity)),
|
|
229
|
+
message: ({ maxSize }) => `File size must be less than ${((maxSize ?? Infinity) / (1024 * 1024)).toFixed(2)}MB`
|
|
230
|
+
}),
|
|
231
|
+
fileExtension: defineRule("fileExtension", {
|
|
232
|
+
params: [["extensions"]],
|
|
233
|
+
stringArg: "list",
|
|
234
|
+
valid: (value, { extensions }) => {
|
|
235
|
+
if (!value) return true;
|
|
236
|
+
const fileName = value.name || value;
|
|
237
|
+
const ext = `.${String(fileName).split(".").pop().toLowerCase()}`;
|
|
238
|
+
return (extensions || []).some((allowed) => ext === allowed.toLowerCase());
|
|
239
|
+
},
|
|
240
|
+
message: ({ extensions }) => `File extension must be one of: ${(extensions || []).join(", ")}`
|
|
241
|
+
}),
|
|
242
|
+
/** A registered validator or built-in by name */
|
|
243
|
+
get: (name) => Object.hasOwn(validators, name) ? validators[name] : void 0,
|
|
244
|
+
/** Combine validators into one returning the first error */
|
|
245
|
+
compose: (validatorList) => {
|
|
246
|
+
const rules = validatorList.map(resolveValidator);
|
|
247
|
+
return (value, options, translator, allValues) => {
|
|
248
|
+
for (const rule of rules) {
|
|
249
|
+
const error = rule ? rule(value, options, translator, allValues) : null;
|
|
250
|
+
if (error) {
|
|
251
|
+
return error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
/** Debounce an async validator */
|
|
258
|
+
debounce: (validator, delay = 300) => {
|
|
259
|
+
let timeoutId;
|
|
260
|
+
return (value) => {
|
|
261
|
+
return new Promise((resolve) => {
|
|
262
|
+
clearTimeout(timeoutId);
|
|
263
|
+
timeoutId = setTimeout(async () => {
|
|
264
|
+
const result = await validator(value);
|
|
265
|
+
resolve(result);
|
|
266
|
+
}, delay);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
/** Cancellable async validator */
|
|
271
|
+
cancellable: (validator) => {
|
|
272
|
+
let abortController;
|
|
273
|
+
const wrapped = async (value) => {
|
|
274
|
+
if (abortController) {
|
|
275
|
+
abortController.abort();
|
|
276
|
+
}
|
|
277
|
+
abortController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
278
|
+
try {
|
|
279
|
+
return await validator(value, abortController ? abortController.signal : null);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error.name === "AbortError") {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
wrapped.cancel = () => {
|
|
288
|
+
if (abortController) {
|
|
289
|
+
abortController.abort();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
return wrapped;
|
|
293
|
+
},
|
|
294
|
+
/** Run `validator` only when `condition` holds */
|
|
295
|
+
when: (condition, validator) => {
|
|
296
|
+
const rule = resolveValidator(validator);
|
|
297
|
+
return (value, options = {}, translator, allValues = {}) => {
|
|
298
|
+
const context = options.min !== void 0 || options.max !== void 0 ? allValues : options;
|
|
299
|
+
const shouldValidate = typeof condition === "function" ? condition(value, context) : condition;
|
|
300
|
+
if (!shouldValidate) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
return rule ? rule(value, options, translator, allValues) : null;
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
/** Validator chain builder */
|
|
307
|
+
chain: (options = {}) => {
|
|
308
|
+
const validatorList = [];
|
|
309
|
+
const stopOnFirstError = options.stopOnFirstError !== false;
|
|
310
|
+
const direct = (name) => (opts) => {
|
|
311
|
+
validatorList.push((v, o, t, a) => validators[name](v, opts || o || {}, t, a));
|
|
312
|
+
return chain;
|
|
313
|
+
};
|
|
314
|
+
const chain = {
|
|
315
|
+
required: direct("required"),
|
|
316
|
+
email: direct("email"),
|
|
317
|
+
minLength: direct("minLength"),
|
|
318
|
+
maxLength: direct("maxLength"),
|
|
319
|
+
custom: (fn, message) => {
|
|
320
|
+
validatorList.push((v, o, t, a) => {
|
|
321
|
+
const result = fn(v, a);
|
|
322
|
+
return result === null || result === true || result === void 0 ? null : message || result;
|
|
323
|
+
});
|
|
324
|
+
return chain;
|
|
325
|
+
},
|
|
326
|
+
validate: (value, opts, translator, allValues) => {
|
|
327
|
+
if (stopOnFirstError) {
|
|
328
|
+
for (const validator of validatorList) {
|
|
329
|
+
const error = validator(value, opts, translator, allValues);
|
|
330
|
+
if (error) {
|
|
331
|
+
return error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const errors = [];
|
|
337
|
+
for (const validator of validatorList) {
|
|
338
|
+
const error = validator(value, opts, translator, allValues);
|
|
339
|
+
if (error) {
|
|
340
|
+
errors.push(error);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return errors.length > 0 ? errors : null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
return chain;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
function lookup(name) {
|
|
350
|
+
const found = Object.hasOwn(validators, name) && !HELPER_NAMES.has(name) ? validators[name] : null;
|
|
351
|
+
return typeof found === "function" ? found : null;
|
|
352
|
+
}
|
|
353
|
+
function resolveString(entry) {
|
|
354
|
+
const exact = lookup(entry);
|
|
355
|
+
const colon = exact ? -1 : entry.indexOf(":");
|
|
356
|
+
const name = colon === -1 ? entry : entry.slice(0, colon).trim();
|
|
357
|
+
const found = exact ?? lookup(name);
|
|
358
|
+
if (!found) return null;
|
|
359
|
+
const text = colon === -1 ? void 0 : entry.slice(colon + 1);
|
|
360
|
+
if (!found[BUILTIN]) {
|
|
361
|
+
return text === void 0 ? { name, args: [], validator: found } : null;
|
|
362
|
+
}
|
|
363
|
+
const args = found[BUILTIN].argsFromString(text);
|
|
364
|
+
if (!args) return null;
|
|
365
|
+
return { name: found[BUILTIN].name, args, validator: found[BUILTIN].factory(...args) };
|
|
366
|
+
}
|
|
367
|
+
function resolveValidator(entry) {
|
|
368
|
+
if (typeof entry === "string") {
|
|
369
|
+
return resolveString(entry)?.validator ?? null;
|
|
370
|
+
}
|
|
371
|
+
if (typeof entry !== "function") return null;
|
|
372
|
+
return entry[BUILTIN] ? entry[BUILTIN].factory() : entry;
|
|
373
|
+
}
|
|
374
|
+
function serializeArg(arg) {
|
|
375
|
+
if (arg instanceof RegExp) return { $regexp: [arg.source, arg.flags] };
|
|
376
|
+
return arg;
|
|
377
|
+
}
|
|
378
|
+
function isSerializable(arg) {
|
|
379
|
+
if (arg === void 0 || arg === null) return true;
|
|
380
|
+
if (arg instanceof RegExp) return true;
|
|
381
|
+
if (Array.isArray(arg)) return arg.every(isSerializable);
|
|
382
|
+
return ["string", "number", "boolean"].includes(typeof arg);
|
|
383
|
+
}
|
|
384
|
+
function describeValidator(entry) {
|
|
385
|
+
let descriptor;
|
|
386
|
+
if (typeof entry === "string") {
|
|
387
|
+
descriptor = resolveString(entry);
|
|
388
|
+
} else if (typeof entry === "function") {
|
|
389
|
+
descriptor = entry[BUILTIN] ? { name: entry[BUILTIN].name, args: [] } : DESCRIPTORS.get(entry);
|
|
390
|
+
}
|
|
391
|
+
if (!descriptor || !descriptor.args.every(isSerializable)) return null;
|
|
392
|
+
const args = [...descriptor.args];
|
|
393
|
+
while (args.length > 0 && args[args.length - 1] === void 0) args.pop();
|
|
394
|
+
return { name: descriptor.name, args: args.map(serializeArg) };
|
|
395
|
+
}
|
|
396
|
+
function serializeValidators(validatorList) {
|
|
397
|
+
const specs = (validatorList || []).map(describeValidator).filter(Boolean);
|
|
398
|
+
return specs.length > 0 ? JSON.stringify(specs) : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/form-builder.js
|
|
3
402
|
var DEFAULT_CLASS_NAMES = {
|
|
4
403
|
/** Wrapper around label, control and error */
|
|
5
404
|
field: "form-field",
|
|
@@ -32,10 +431,11 @@ function safeAttributes(attributes) {
|
|
|
32
431
|
}
|
|
33
432
|
return safe;
|
|
34
433
|
}
|
|
434
|
+
var DEFAULT_CSRF_FIELD_NAME = "_csrf";
|
|
35
435
|
function joinClasses(...names) {
|
|
36
436
|
return names.filter(Boolean).join(" ");
|
|
37
437
|
}
|
|
38
|
-
var FormBuilder = class {
|
|
438
|
+
var FormBuilder = class _FormBuilder {
|
|
39
439
|
constructor(options = {}) {
|
|
40
440
|
this.options = {
|
|
41
441
|
validateOnChange: true,
|
|
@@ -218,8 +618,7 @@ var FormBuilder = class {
|
|
|
218
618
|
}
|
|
219
619
|
if (value) {
|
|
220
620
|
if (field.type === "email") {
|
|
221
|
-
|
|
222
|
-
if (!emailRegex.test(value)) {
|
|
621
|
+
if (!isEmailShaped(value)) {
|
|
223
622
|
const error = "Please enter a valid email address";
|
|
224
623
|
this.errors[name] = error;
|
|
225
624
|
return error;
|
|
@@ -247,8 +646,9 @@ var FormBuilder = class {
|
|
|
247
646
|
return error;
|
|
248
647
|
}
|
|
249
648
|
}
|
|
250
|
-
for (const
|
|
251
|
-
const
|
|
649
|
+
for (const entry of field.validators || []) {
|
|
650
|
+
const validator = resolveValidator(entry);
|
|
651
|
+
const error = validator ? validator(value, this.values) : null;
|
|
252
652
|
if (error) {
|
|
253
653
|
this.errors[name] = error;
|
|
254
654
|
return error;
|
|
@@ -328,20 +728,59 @@ var FormBuilder = class {
|
|
|
328
728
|
touch(name) {
|
|
329
729
|
this.touched[name] = true;
|
|
330
730
|
}
|
|
731
|
+
/**
|
|
732
|
+
* A copy of this form's definition — fields, groups, options and handlers —
|
|
733
|
+
* with fresh values, errors and touched state.
|
|
734
|
+
*
|
|
735
|
+
* A FormBuilder holds the state of one submission. On a server, keep the
|
|
736
|
+
* shared definition at module scope and fork it per request, so one user's
|
|
737
|
+
* submitted values and errors never render into another user's page.
|
|
738
|
+
*/
|
|
739
|
+
fork() {
|
|
740
|
+
const copy = new _FormBuilder({ ...this.options });
|
|
741
|
+
for (const [name, config] of this.fields) {
|
|
742
|
+
copy.fields.set(name, config);
|
|
743
|
+
}
|
|
744
|
+
for (const [name, group] of this.groups) {
|
|
745
|
+
copy.groups.set(name, group);
|
|
746
|
+
}
|
|
747
|
+
copy.values = { ...this.initialValues };
|
|
748
|
+
copy.initialValues = { ...this.initialValues };
|
|
749
|
+
copy.submitHandler = this.submitHandler;
|
|
750
|
+
copy.errorHandler = this.errorHandler;
|
|
751
|
+
return copy;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* The values, errors and touched state a render uses.
|
|
755
|
+
*
|
|
756
|
+
* Passing any of `values`, `errors` or `touched` to buildForm renders from
|
|
757
|
+
* those alone — the instance's own state is neither read nor changed — so a
|
|
758
|
+
* shared builder can render per-request state. Values are merged over the
|
|
759
|
+
* fields' default values, and fields with an error count as touched unless
|
|
760
|
+
* `touched` is given.
|
|
761
|
+
*/
|
|
762
|
+
resolveRenderState(options = {}) {
|
|
763
|
+
const { values, errors, touched } = options;
|
|
764
|
+
if (values === void 0 && errors === void 0 && touched === void 0) {
|
|
765
|
+
return { values: this.values, errors: this.errors, touched: this.touched };
|
|
766
|
+
}
|
|
767
|
+
const renderErrors = errors || {};
|
|
768
|
+
return {
|
|
769
|
+
values: { ...this.initialValues, ...values },
|
|
770
|
+
errors: renderErrors,
|
|
771
|
+
touched: touched || Object.fromEntries(Object.keys(renderErrors).map((name) => [name, true]))
|
|
772
|
+
};
|
|
773
|
+
}
|
|
331
774
|
/**
|
|
332
775
|
* Build input component with validation metadata for hydration
|
|
333
776
|
*/
|
|
334
|
-
buildInput(name, classNames = this.resolveClassNames()) {
|
|
777
|
+
buildInput(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
335
778
|
const field = this.fields.get(name);
|
|
336
779
|
if (!field) return null;
|
|
337
|
-
const value =
|
|
338
|
-
const error =
|
|
339
|
-
const isTouched =
|
|
340
|
-
const
|
|
341
|
-
if (typeof v === "function") return v.name || "custom";
|
|
342
|
-
if (typeof v === "string") return v;
|
|
343
|
-
return null;
|
|
344
|
-
}).filter(Boolean).join(",");
|
|
780
|
+
const value = state.values[name] || "";
|
|
781
|
+
const error = state.errors[name];
|
|
782
|
+
const isTouched = state.touched[name];
|
|
783
|
+
const validatorSpec = serializeValidators(field.validators);
|
|
345
784
|
const controlClass = joinClasses(
|
|
346
785
|
classNames.control,
|
|
347
786
|
field.className,
|
|
@@ -366,8 +805,8 @@ var FormBuilder = class {
|
|
|
366
805
|
inputProps.required = true;
|
|
367
806
|
inputProps["data-required"] = "true";
|
|
368
807
|
}
|
|
369
|
-
if (
|
|
370
|
-
inputProps["data-validators"] =
|
|
808
|
+
if (validatorSpec) {
|
|
809
|
+
inputProps["data-validators"] = validatorSpec;
|
|
371
810
|
}
|
|
372
811
|
if (field.type === "textarea" || field.type === "select") {
|
|
373
812
|
const { type: _type, value: _value, ...rest } = inputProps;
|
|
@@ -409,9 +848,9 @@ var FormBuilder = class {
|
|
|
409
848
|
/**
|
|
410
849
|
* Build error component
|
|
411
850
|
*/
|
|
412
|
-
buildError(name, classNames = this.resolveClassNames()) {
|
|
413
|
-
const error =
|
|
414
|
-
const isTouched =
|
|
851
|
+
buildError(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
852
|
+
const error = state.errors[name];
|
|
853
|
+
const isTouched = state.touched[name];
|
|
415
854
|
if (!error || !isTouched) return null;
|
|
416
855
|
const props = { id: `${name}-error`, role: "alert", text: error };
|
|
417
856
|
if (classNames.error) props.className = classNames.error;
|
|
@@ -426,14 +865,14 @@ var FormBuilder = class {
|
|
|
426
865
|
/**
|
|
427
866
|
* Build complete field component
|
|
428
867
|
*/
|
|
429
|
-
buildField(name, classNames = this.resolveClassNames()) {
|
|
868
|
+
buildField(name, classNames = this.resolveClassNames(), state = this.resolveRenderState()) {
|
|
430
869
|
const field = this.fields.get(name);
|
|
431
870
|
if (!field) return null;
|
|
432
871
|
const children = [
|
|
433
872
|
this.buildLabel(name, classNames),
|
|
434
|
-
this.buildInput(name, classNames)
|
|
873
|
+
this.buildInput(name, classNames, state)
|
|
435
874
|
];
|
|
436
|
-
const error = this.buildError(name, classNames);
|
|
875
|
+
const error = this.buildError(name, classNames, state);
|
|
437
876
|
if (error) {
|
|
438
877
|
children.push(error);
|
|
439
878
|
}
|
|
@@ -443,14 +882,31 @@ var FormBuilder = class {
|
|
|
443
882
|
}
|
|
444
883
|
/**
|
|
445
884
|
* Build entire form
|
|
885
|
+
*
|
|
886
|
+
* `options.values`, `options.errors` and `options.touched` render
|
|
887
|
+
* per-request state without touching the builder's own (see
|
|
888
|
+
* resolveRenderState). `options.csrfToken` adds a hidden `_csrf` input (the
|
|
889
|
+
* name is `options.csrfFieldName`); create the token with
|
|
890
|
+
* `@coherent.js/forms/csrf`.
|
|
446
891
|
*/
|
|
447
892
|
buildForm(options = {}) {
|
|
448
|
-
const
|
|
893
|
+
const { values: _values, errors: _errors, touched: _touched, ...formOptions } = options;
|
|
894
|
+
const settings = { ...this.options, ...formOptions };
|
|
449
895
|
const classNames = this.resolveClassNames(options.classNames);
|
|
896
|
+
const state = this.resolveRenderState(options);
|
|
450
897
|
const fields = [];
|
|
898
|
+
if (settings.csrfToken !== void 0 && settings.csrfToken !== null && settings.csrfToken !== "") {
|
|
899
|
+
fields.push({
|
|
900
|
+
input: {
|
|
901
|
+
type: "hidden",
|
|
902
|
+
name: settings.csrfFieldName || DEFAULT_CSRF_FIELD_NAME,
|
|
903
|
+
value: String(settings.csrfToken)
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
451
907
|
for (const [name] of this.fields) {
|
|
452
|
-
if (!this.isFieldVisible(name)) continue;
|
|
453
|
-
fields.push(this.buildField(name, classNames));
|
|
908
|
+
if (!this.isFieldVisible(name, state.values)) continue;
|
|
909
|
+
fields.push(this.buildField(name, classNames, state));
|
|
454
910
|
}
|
|
455
911
|
if (settings.submitButton !== false) {
|
|
456
912
|
const button = { type: "submit", text: settings.submitText || "Submit" };
|
|
@@ -512,13 +968,13 @@ var FormBuilder = class {
|
|
|
512
968
|
/**
|
|
513
969
|
* Check if a field is visible
|
|
514
970
|
*/
|
|
515
|
-
isFieldVisible(name) {
|
|
971
|
+
isFieldVisible(name, values = this.values) {
|
|
516
972
|
const field = this.fields.get(name);
|
|
517
973
|
if (!field) return false;
|
|
518
974
|
if (field.visible === false) return false;
|
|
519
975
|
const showCondition = field.showWhen || field.showIf;
|
|
520
976
|
if (showCondition) {
|
|
521
|
-
return showCondition(
|
|
977
|
+
return showCondition(values);
|
|
522
978
|
}
|
|
523
979
|
return true;
|
|
524
980
|
}
|