@10x-media/form-builder 0.1.0-beta.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +1434 -0
- package/dist/constants-B-BUfetP.d.ts +288 -0
- package/dist/exports/client.d.ts +21 -0
- package/dist/exports/client.js +320 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/i18n.d.ts +138 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/react.d.ts +550 -0
- package/dist/exports/react.js +1564 -0
- package/dist/exports/react.js.map +1 -0
- package/dist/exports/rsc.d.ts +16 -0
- package/dist/exports/rsc.js +55 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/exports/types.d.ts +5 -0
- package/dist/exports/types.js +1 -0
- package/dist/fieldTypes-B8jkNRob.d.ts +188 -0
- package/dist/fieldTypes-CzhhJXjg.js +88 -0
- package/dist/fieldTypes-CzhhJXjg.js.map +1 -0
- package/dist/index-DfFYGbVF.d.ts +481 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1921 -0
- package/dist/index.js.map +1 -0
- package/dist/keys-N5xGiUsh.js +132 -0
- package/dist/keys-N5xGiUsh.js.map +1 -0
- package/dist/registry-CoCyhtvB.js +282 -0
- package/dist/registry-CoCyhtvB.js.map +1 -0
- package/dist/resolver-OeQyVH2N.js +665 -0
- package/dist/resolver-OeQyVH2N.js.map +1 -0
- package/dist/translations-edMqq_2e.js +152 -0
- package/dist/translations-edMqq_2e.js.map +1 -0
- package/dist/types-DsJ_6kGJ.d.ts +23 -0
- package/package.json +125 -0
- package/styles/form-builder.css +108 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { t as keys } from "./keys-N5xGiUsh.js";
|
|
2
|
+
import { s as labelFor } from "./registry-CoCyhtvB.js";
|
|
3
|
+
import { transformWhereQuery } from "payload/shared";
|
|
4
|
+
//#region src/recall/interpolate.ts
|
|
5
|
+
const TOKEN = /\{\{\s*([^{}|]+?)\s*(?:\|([^{}]*?))?\}\}/g;
|
|
6
|
+
/**
|
|
7
|
+
* Replace `{{ name }}` and `{{ name|fallback }}` tokens. `resolve` returns the recalled value (''
|
|
8
|
+
* when absent); the fallback (or '') is substituted when `resolve` yields ''. Token-free input is
|
|
9
|
+
* returned unchanged, so this is a no-op for ordinary text.
|
|
10
|
+
*/
|
|
11
|
+
const interpolate = (template, resolve) => template.replace(TOKEN, (_match, rawName, rawFallback) => {
|
|
12
|
+
const value = resolve(rawName.trim());
|
|
13
|
+
if (value !== "") return value;
|
|
14
|
+
return rawFallback === void 0 ? "" : rawFallback.trim();
|
|
15
|
+
});
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/events/noopSink.ts
|
|
18
|
+
const noopEventSink = { emit: () => Promise.resolve() };
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/spam/constants.ts
|
|
21
|
+
/** Default honeypot decoy field name. Plausible to bots, unlikely to collide with a real field name. */
|
|
22
|
+
const DEFAULT_HONEYPOT_FIELD = "confirm_email";
|
|
23
|
+
/** Reserved `values` entry carrying a captcha token from the client. */
|
|
24
|
+
const CAPTCHA_TOKEN_KEY = "__fb_captcha";
|
|
25
|
+
/** `req.context` key under which the spam guard stashes the resolved identity for upload-ownership verification. */
|
|
26
|
+
const IDENTITY_CONTEXT_KEY = "formBuilderSpamIdentity";
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/presentations/defaults.ts
|
|
29
|
+
const DEFAULT_PRESENTATION_NAME = "page";
|
|
30
|
+
const defaultPresentationDescriptors = {
|
|
31
|
+
page: {
|
|
32
|
+
name: "page",
|
|
33
|
+
label: keys.presentationPage,
|
|
34
|
+
surface: "page",
|
|
35
|
+
density: "comfortable"
|
|
36
|
+
},
|
|
37
|
+
inline: {
|
|
38
|
+
name: "inline",
|
|
39
|
+
label: keys.presentationInline,
|
|
40
|
+
surface: "inline",
|
|
41
|
+
density: "comfortable"
|
|
42
|
+
},
|
|
43
|
+
modal: {
|
|
44
|
+
name: "modal",
|
|
45
|
+
label: keys.presentationModal,
|
|
46
|
+
surface: "overlay",
|
|
47
|
+
density: "comfortable",
|
|
48
|
+
dismissOnSuccess: true
|
|
49
|
+
},
|
|
50
|
+
drawer: {
|
|
51
|
+
name: "drawer",
|
|
52
|
+
label: keys.presentationDrawer,
|
|
53
|
+
surface: "overlay",
|
|
54
|
+
density: "comfortable",
|
|
55
|
+
dismissOnSuccess: true
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/calc/evaluate.ts
|
|
60
|
+
const MAX_DEPTH = 64;
|
|
61
|
+
const toNumber$1 = (value) => {
|
|
62
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
63
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
64
|
+
const n = Number(value);
|
|
65
|
+
return Number.isFinite(n) ? n : 0;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
};
|
|
69
|
+
const finite = (n) => Number.isFinite(n) ? n : 0;
|
|
70
|
+
const FUNCTIONS = {
|
|
71
|
+
min: (a) => a.length ? Math.min(...a) : 0,
|
|
72
|
+
max: (a) => a.length ? Math.max(...a) : 0,
|
|
73
|
+
round: (a) => Math.round(a[0] ?? 0),
|
|
74
|
+
abs: (a) => Math.abs(a[0] ?? 0),
|
|
75
|
+
ceil: (a) => Math.ceil(a[0] ?? 0),
|
|
76
|
+
floor: (a) => Math.floor(a[0] ?? 0)
|
|
77
|
+
};
|
|
78
|
+
const evalNode = (expr, answers, depth) => {
|
|
79
|
+
if (depth > MAX_DEPTH || expr == null || typeof expr !== "object") return 0;
|
|
80
|
+
switch (expr.type) {
|
|
81
|
+
case "lit": return finite(toNumber$1(expr.value));
|
|
82
|
+
case "ref": return toNumber$1(answers[expr.field]);
|
|
83
|
+
case "neg": return finite(-evalNode(expr.operand, answers, depth + 1));
|
|
84
|
+
case "op": {
|
|
85
|
+
const l = evalNode(expr.left, answers, depth + 1);
|
|
86
|
+
const r = evalNode(expr.right, answers, depth + 1);
|
|
87
|
+
switch (expr.op) {
|
|
88
|
+
case "+": return finite(l + r);
|
|
89
|
+
case "-": return finite(l - r);
|
|
90
|
+
case "*": return finite(l * r);
|
|
91
|
+
case "/": return r === 0 ? 0 : finite(l / r);
|
|
92
|
+
case "%": return r === 0 ? 0 : finite(l % r);
|
|
93
|
+
default: return 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
case "fn": {
|
|
97
|
+
const fn = FUNCTIONS[expr.fn];
|
|
98
|
+
if (!fn) return 0;
|
|
99
|
+
return finite(fn(Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, answers, depth + 1)) : []));
|
|
100
|
+
}
|
|
101
|
+
case "weight": {
|
|
102
|
+
const weights = expr.weights ?? {};
|
|
103
|
+
const value = answers[expr.field];
|
|
104
|
+
return finite((Array.isArray(value) ? value : value == null || value === "" ? [] : [value]).reduce((sum, v) => sum + toNumber$1(weights[String(v)]), 0));
|
|
105
|
+
}
|
|
106
|
+
default: return 0;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */
|
|
110
|
+
const evaluateCalc = (expr, answers) => {
|
|
111
|
+
if (!expr) return 0;
|
|
112
|
+
return evalNode(expr, answers, 0);
|
|
113
|
+
};
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/calc/computeCalcFields.ts
|
|
116
|
+
/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */
|
|
117
|
+
const calcExpressionOf = (field) => {
|
|
118
|
+
const expr = field.expression;
|
|
119
|
+
return expr !== null && typeof expr === "object" && !Array.isArray(expr) ? expr : void 0;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Returns answers with every calc field's value derived from its expression,
|
|
123
|
+
* folded in declaration order so a calc may reference an earlier calc's result.
|
|
124
|
+
* Identity when no field carries an expression.
|
|
125
|
+
*/
|
|
126
|
+
const computeCalcFields = (fields, answers) => {
|
|
127
|
+
let next = answers;
|
|
128
|
+
for (const field of fields) {
|
|
129
|
+
const expr = calcExpressionOf(field);
|
|
130
|
+
if (expr) next = {
|
|
131
|
+
...next,
|
|
132
|
+
[field.name]: evaluateCalc(expr, next)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return next;
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/conditions/evaluate.ts
|
|
139
|
+
const isNil = (value) => value === null || value === void 0;
|
|
140
|
+
const toNumber = (value) => {
|
|
141
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
142
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
143
|
+
const next = Number(value);
|
|
144
|
+
return Number.isNaN(next) ? void 0 : next;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const toTime = (value) => {
|
|
148
|
+
if (value instanceof Date) return value.getTime();
|
|
149
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
150
|
+
const time = Date.parse(value);
|
|
151
|
+
return Number.isNaN(time) ? void 0 : time;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
/** Strict, then numeric, then string equality, mirroring Payload's coerce-by-type-then-compare. */
|
|
155
|
+
const valuesEqual = (answer, value) => {
|
|
156
|
+
if (answer === value) return true;
|
|
157
|
+
if (isNil(answer) || isNil(value)) return false;
|
|
158
|
+
const a = toNumber(answer);
|
|
159
|
+
const b = toNumber(value);
|
|
160
|
+
if (a !== void 0 && b !== void 0) return a === b;
|
|
161
|
+
return String(answer) === String(value);
|
|
162
|
+
};
|
|
163
|
+
const toList = (value) => {
|
|
164
|
+
if (Array.isArray(value)) return value;
|
|
165
|
+
if (typeof value === "string") return value.split(",").map((entry) => entry.trim());
|
|
166
|
+
return [value];
|
|
167
|
+
};
|
|
168
|
+
const ordered = (answer, value, compare) => {
|
|
169
|
+
const a = toNumber(answer) ?? toTime(answer);
|
|
170
|
+
const b = toNumber(value) ?? toTime(value);
|
|
171
|
+
if (a === void 0 || b === void 0) return false;
|
|
172
|
+
return compare(a, b);
|
|
173
|
+
};
|
|
174
|
+
/** Case-insensitive: every space-separated word in `value` is a substring of `answer` (Payload `like`). */
|
|
175
|
+
const everyWord = (answer, value) => {
|
|
176
|
+
const haystack = String(answer).toLowerCase();
|
|
177
|
+
return String(value).toLowerCase().split(" ").filter((word) => word.length > 0).every((word) => haystack.includes(word));
|
|
178
|
+
};
|
|
179
|
+
const evaluateOperator = (operator, answer, value) => {
|
|
180
|
+
switch (operator) {
|
|
181
|
+
case "equals": return isNil(value) ? isNil(answer) : valuesEqual(answer, value);
|
|
182
|
+
case "not_equals": return isNil(value) ? !isNil(answer) : isNil(answer) || !valuesEqual(answer, value);
|
|
183
|
+
case "in": return toList(value).some((entry) => valuesEqual(answer, entry));
|
|
184
|
+
case "not_in": return isNil(answer) || !toList(value).some((entry) => valuesEqual(answer, entry));
|
|
185
|
+
case "exists": {
|
|
186
|
+
const present = !isNil(answer) && answer !== "";
|
|
187
|
+
return value === true || value === "true" ? present : !present;
|
|
188
|
+
}
|
|
189
|
+
case "greater_than": return ordered(answer, value, (a, b) => a > b);
|
|
190
|
+
case "greater_than_equal": return ordered(answer, value, (a, b) => a >= b);
|
|
191
|
+
case "less_than": return ordered(answer, value, (a, b) => a < b);
|
|
192
|
+
case "less_than_equal": return ordered(answer, value, (a, b) => a <= b);
|
|
193
|
+
case "like": return isNil(answer) ? false : everyWord(answer, value);
|
|
194
|
+
case "not_like": return isNil(answer) ? true : !everyWord(answer, value);
|
|
195
|
+
case "contains": return isNil(answer) ? false : String(answer).toLowerCase().includes(String(value).toLowerCase());
|
|
196
|
+
default: return false;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Evaluate one `Where` node: every top-level key is ANDed. `or` is a disjunction of sub-conditions
|
|
201
|
+
* (an empty `or` matches), `and` is a conjunction, and any other key is a field path whose
|
|
202
|
+
* `{ operator: value }` constraints are ANDed. Recurses, so nested `or`/`and` groups are supported.
|
|
203
|
+
*/
|
|
204
|
+
const evaluateWhere = (where, answers) => Object.entries(where).every(([key, constraint]) => {
|
|
205
|
+
if (key === "or") {
|
|
206
|
+
const groups = Array.isArray(constraint) ? constraint : [];
|
|
207
|
+
return groups.length === 0 || groups.some((group) => evaluateWhere(group, answers));
|
|
208
|
+
}
|
|
209
|
+
if (key === "and") return (Array.isArray(constraint) ? constraint : []).every((group) => evaluateWhere(group, answers));
|
|
210
|
+
if (constraint == null || typeof constraint !== "object" || Array.isArray(constraint)) return false;
|
|
211
|
+
return Object.entries(constraint).every(([operator, value]) => evaluateOperator(operator, answers[key], value));
|
|
212
|
+
});
|
|
213
|
+
/**
|
|
214
|
+
* Evaluate a serializable `Where`-shaped condition against a flat map of (already coerced) form answers.
|
|
215
|
+
* An absent or empty condition matches (returns true). Operator semantics mirror Payload's query
|
|
216
|
+
* adapters: coerce then compare; `not_equals`/`not_in` are null-inclusive; `exists` treats `''`/absent
|
|
217
|
+
* as not-existing; `like` is case-insensitive and space-splits (every word must be a substring), and
|
|
218
|
+
* `not_like` is its logical negation (at least one word absent); `contains` is a single case-insensitive
|
|
219
|
+
* substring; comma-delimited `in`/`not_in` string values are split and trimmed. Geo (`near`/`within`/
|
|
220
|
+
* `intersects`) and `all` are out of scope and evaluate to false. Isomorphic: no `req`/DB access, so the
|
|
221
|
+
* renderer reuses it client-side.
|
|
222
|
+
*/
|
|
223
|
+
const evaluateCondition = (where, answers) => {
|
|
224
|
+
if (!where || Object.keys(where).length === 0) return true;
|
|
225
|
+
return evaluateWhere(transformWhereQuery(where), answers);
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/validation/message.ts
|
|
229
|
+
/** Replace `{key}` placeholders with `String(vars[key])`; unknown placeholders are left as-is. */
|
|
230
|
+
const resolveMessage = (template, vars) => template.replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(vars, key) ? String(vars[key]) : match);
|
|
231
|
+
/**
|
|
232
|
+
* Race a (possibly async) rule against a deadline so a hung server rule cannot stall the submit path.
|
|
233
|
+
* On timeout the `fallback` is resolved (the engine treats a timed-out rule as a non-blocking skip).
|
|
234
|
+
*/
|
|
235
|
+
const withTimeout = (promise, ms, fallback) => Promise.race([promise, new Promise((resolve) => {
|
|
236
|
+
setTimeout(() => resolve(fallback), ms);
|
|
237
|
+
})]);
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/validation/runValidation.ts
|
|
240
|
+
const RULE_TIMEOUT_MS = 5e3;
|
|
241
|
+
const isEmpty = (value) => value == null || value === "" || Array.isArray(value) && value.length === 0;
|
|
242
|
+
/** Build the per-instance message resolver: custom override (a literal template) or the localized default. */
|
|
243
|
+
const makeMessage = (instance, defaultMessageKey, t) => {
|
|
244
|
+
const { blockType: _blockType, message: _message, severity: _severity, ...params } = instance;
|
|
245
|
+
const template = typeof instance.message === "string" && instance.message.length > 0 ? instance.message : t(defaultMessageKey);
|
|
246
|
+
return (vars = {}) => resolveMessage(template, {
|
|
247
|
+
...params,
|
|
248
|
+
...vars
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
const toIssue = (result, fallbackSeverity) => {
|
|
252
|
+
if (result === true) return null;
|
|
253
|
+
if (typeof result === "string") return {
|
|
254
|
+
message: result,
|
|
255
|
+
severity: fallbackSeverity
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
message: result.message,
|
|
259
|
+
severity: result.severity ?? fallbackSeverity
|
|
260
|
+
};
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* The one validation engine, run per field on client and server. Order: required, the field type's
|
|
264
|
+
* intrinsic validator, then each declarative rule instance. Server-only rules are skipped in client
|
|
265
|
+
* mode; every rule is wrapped in try/catch + a timeout so a bad rule cannot crash or stall the submit
|
|
266
|
+
* path (a thrown or timed-out rule fails open, logged on the server). Only `error`-severity issues
|
|
267
|
+
* block submission; the caller filters severity.
|
|
268
|
+
*/
|
|
269
|
+
const runValidation = async (input) => {
|
|
270
|
+
const { field, fieldDefinition, value, fieldType, ruleRegistry, answers, locale, t, operation, event, mode, req, payload, formId, timeoutMs } = input;
|
|
271
|
+
const timeout = timeoutMs ?? RULE_TIMEOUT_MS;
|
|
272
|
+
const errors = [];
|
|
273
|
+
if (field.required && isEmpty(value)) return { errors: [{
|
|
274
|
+
message: t(keys.validationRequired),
|
|
275
|
+
severity: "error"
|
|
276
|
+
}] };
|
|
277
|
+
if (isEmpty(value)) return { errors: [] };
|
|
278
|
+
if (fieldDefinition?.validate) try {
|
|
279
|
+
const ran = fieldDefinition.validate({
|
|
280
|
+
value,
|
|
281
|
+
config: field,
|
|
282
|
+
siblingData: answers,
|
|
283
|
+
data: answers,
|
|
284
|
+
locale,
|
|
285
|
+
t
|
|
286
|
+
});
|
|
287
|
+
const result = ran instanceof Promise ? await withTimeout(ran, timeout, true) : ran;
|
|
288
|
+
if (result !== true) errors.push({
|
|
289
|
+
message: result,
|
|
290
|
+
severity: "error"
|
|
291
|
+
});
|
|
292
|
+
} catch (error) {
|
|
293
|
+
req?.payload?.logger?.error?.({
|
|
294
|
+
err: error,
|
|
295
|
+
field: field.name
|
|
296
|
+
}, "form-builder intrinsic field validator threw");
|
|
297
|
+
}
|
|
298
|
+
if (fieldDefinition?.schema) try {
|
|
299
|
+
const outcome = await fieldDefinition.schema["~standard"].validate(value);
|
|
300
|
+
if (outcome.issues) for (const issue of outcome.issues) errors.push({
|
|
301
|
+
message: issue.message,
|
|
302
|
+
severity: "error"
|
|
303
|
+
});
|
|
304
|
+
} catch (error) {
|
|
305
|
+
req?.payload?.logger?.error?.({
|
|
306
|
+
err: error,
|
|
307
|
+
field: field.name
|
|
308
|
+
}, "form-builder field schema threw");
|
|
309
|
+
}
|
|
310
|
+
const instances = Array.isArray(field.validations) ? field.validations : [];
|
|
311
|
+
for (const instance of instances) {
|
|
312
|
+
const rule = ruleRegistry.get(instance.blockType);
|
|
313
|
+
if (!rule) continue;
|
|
314
|
+
if (mode === "client" && rule.client === false) continue;
|
|
315
|
+
const message = makeMessage(instance, rule.defaultMessage, t);
|
|
316
|
+
const fallbackSeverity = instance.severity ?? rule.severity ?? "error";
|
|
317
|
+
try {
|
|
318
|
+
const ran = rule.validate({
|
|
319
|
+
value,
|
|
320
|
+
params: instance,
|
|
321
|
+
siblingData: answers,
|
|
322
|
+
data: answers,
|
|
323
|
+
field,
|
|
324
|
+
fieldType,
|
|
325
|
+
operation,
|
|
326
|
+
event,
|
|
327
|
+
locale,
|
|
328
|
+
message,
|
|
329
|
+
req: mode === "server" ? req : void 0,
|
|
330
|
+
payload: mode === "server" ? payload : void 0,
|
|
331
|
+
formId: mode === "server" ? formId : void 0
|
|
332
|
+
});
|
|
333
|
+
const issue = toIssue(ran instanceof Promise ? await withTimeout(ran, timeout, true) : ran, fallbackSeverity);
|
|
334
|
+
if (issue) errors.push(issue);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
req?.payload?.logger?.error?.({
|
|
337
|
+
err: error,
|
|
338
|
+
rule: rule.type
|
|
339
|
+
}, "form-builder validation rule threw");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { errors };
|
|
343
|
+
};
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/validation/defineValidationRule.ts
|
|
346
|
+
/**
|
|
347
|
+
* Define a validation rule type once: its `params` become a Payload `Field[]` in the per-field
|
|
348
|
+
* constraint list, its typed `validate` runs in the one engine on client and server. Built-in rules
|
|
349
|
+
* use this same primitive, so custom rules are never second-class.
|
|
350
|
+
*/
|
|
351
|
+
const defineValidationRule = (rule) => rule;
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/validation/builtin/email.ts
|
|
354
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
355
|
+
const emailRule = defineValidationRule({
|
|
356
|
+
type: "email",
|
|
357
|
+
label: keys.ruleEmail,
|
|
358
|
+
appliesTo: [
|
|
359
|
+
"text",
|
|
360
|
+
"textarea",
|
|
361
|
+
"email"
|
|
362
|
+
],
|
|
363
|
+
defaultMessage: keys.ruleEmailMessage,
|
|
364
|
+
validate: ({ value, message }) => value == null || value === "" || EMAIL_PATTERN.test(value) ? true : message()
|
|
365
|
+
});
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/validation/builtin/matchesField.ts
|
|
368
|
+
const matchesFieldRule = defineValidationRule({
|
|
369
|
+
type: "matchesField",
|
|
370
|
+
label: keys.ruleMatchesField,
|
|
371
|
+
params: [{
|
|
372
|
+
name: "field",
|
|
373
|
+
type: "text",
|
|
374
|
+
required: true,
|
|
375
|
+
label: labelFor(keys.ruleParamField)
|
|
376
|
+
}],
|
|
377
|
+
defaultMessage: keys.ruleMatchesFieldMessage,
|
|
378
|
+
validate: ({ value, params, siblingData, message }) => value === siblingData[params.field] ? true : message()
|
|
379
|
+
});
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/validation/builtin/max.ts
|
|
382
|
+
const maxRule = defineValidationRule({
|
|
383
|
+
type: "max",
|
|
384
|
+
label: keys.ruleMax,
|
|
385
|
+
appliesTo: ["number"],
|
|
386
|
+
params: [{
|
|
387
|
+
name: "max",
|
|
388
|
+
type: "number",
|
|
389
|
+
required: true,
|
|
390
|
+
label: labelFor(keys.ruleParamMax)
|
|
391
|
+
}],
|
|
392
|
+
defaultMessage: keys.ruleMaxMessage,
|
|
393
|
+
validate: ({ value, params, message }) => value == null || value <= params.max ? true : message({ max: params.max })
|
|
394
|
+
});
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/validation/builtin/maxLength.ts
|
|
397
|
+
const maxLengthRule = defineValidationRule({
|
|
398
|
+
type: "maxLength",
|
|
399
|
+
label: keys.ruleMaxLength,
|
|
400
|
+
appliesTo: [
|
|
401
|
+
"text",
|
|
402
|
+
"textarea",
|
|
403
|
+
"email"
|
|
404
|
+
],
|
|
405
|
+
params: [{
|
|
406
|
+
name: "max",
|
|
407
|
+
type: "number",
|
|
408
|
+
required: true,
|
|
409
|
+
min: 0,
|
|
410
|
+
label: labelFor(keys.ruleParamMax)
|
|
411
|
+
}],
|
|
412
|
+
defaultMessage: keys.ruleMaxLengthMessage,
|
|
413
|
+
validate: ({ value, params, message }) => value == null || value === "" || value.length <= params.max ? true : message({ max: params.max })
|
|
414
|
+
});
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/validation/builtin/min.ts
|
|
417
|
+
const minRule = defineValidationRule({
|
|
418
|
+
type: "min",
|
|
419
|
+
label: keys.ruleMin,
|
|
420
|
+
appliesTo: ["number"],
|
|
421
|
+
params: [{
|
|
422
|
+
name: "min",
|
|
423
|
+
type: "number",
|
|
424
|
+
required: true,
|
|
425
|
+
label: labelFor(keys.ruleParamMin)
|
|
426
|
+
}],
|
|
427
|
+
defaultMessage: keys.ruleMinMessage,
|
|
428
|
+
validate: ({ value, params, message }) => value == null || value >= params.min ? true : message({ min: params.min })
|
|
429
|
+
});
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/validation/builtin/minLength.ts
|
|
432
|
+
const minLengthRule = defineValidationRule({
|
|
433
|
+
type: "minLength",
|
|
434
|
+
label: keys.ruleMinLength,
|
|
435
|
+
appliesTo: [
|
|
436
|
+
"text",
|
|
437
|
+
"textarea",
|
|
438
|
+
"email"
|
|
439
|
+
],
|
|
440
|
+
params: [{
|
|
441
|
+
name: "min",
|
|
442
|
+
type: "number",
|
|
443
|
+
required: true,
|
|
444
|
+
min: 0,
|
|
445
|
+
label: labelFor(keys.ruleParamMin)
|
|
446
|
+
}],
|
|
447
|
+
defaultMessage: keys.ruleMinLengthMessage,
|
|
448
|
+
validate: ({ value, params, message }) => value == null || value === "" || value.length >= params.min ? true : message({ min: params.min })
|
|
449
|
+
});
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/validation/builtin/notAlreadySubmitted.ts
|
|
452
|
+
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
453
|
+
const SCAN_LIMIT = 500;
|
|
454
|
+
/**
|
|
455
|
+
* Server-only async exemplar: rejects a value already present for the same form. Scans up to
|
|
456
|
+
* `SCAN_LIMIT` recent submissions in JS (the values live in a json column, so a portable DB query is
|
|
457
|
+
* not available); a future phase can add an indexed dedup column for scale.
|
|
458
|
+
*/
|
|
459
|
+
const notAlreadySubmittedRule = defineValidationRule({
|
|
460
|
+
type: "notAlreadySubmitted",
|
|
461
|
+
label: keys.ruleNotAlreadySubmitted,
|
|
462
|
+
client: false,
|
|
463
|
+
defaultMessage: keys.ruleNotAlreadySubmittedMessage,
|
|
464
|
+
validate: async ({ value, field, payload, req, formId, message }) => {
|
|
465
|
+
if (value == null || value === "" || !payload || formId == null) return true;
|
|
466
|
+
return (await payload.find({
|
|
467
|
+
collection: FORM_SUBMISSIONS_SLUG,
|
|
468
|
+
where: { form: { equals: formId } },
|
|
469
|
+
limit: SCAN_LIMIT,
|
|
470
|
+
depth: 0,
|
|
471
|
+
req
|
|
472
|
+
})).docs.some((doc) => {
|
|
473
|
+
return (doc.values ?? []).some((entry) => entry.field === field.name && entry.value === value);
|
|
474
|
+
}) ? message() : true;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/validation/builtin/oneOf.ts
|
|
479
|
+
const oneOfRule = defineValidationRule({
|
|
480
|
+
type: "oneOf",
|
|
481
|
+
label: keys.ruleOneOf,
|
|
482
|
+
appliesTo: [
|
|
483
|
+
"text",
|
|
484
|
+
"textarea",
|
|
485
|
+
"select"
|
|
486
|
+
],
|
|
487
|
+
params: [{
|
|
488
|
+
name: "values",
|
|
489
|
+
type: "array",
|
|
490
|
+
label: labelFor(keys.ruleParamValues),
|
|
491
|
+
fields: [{
|
|
492
|
+
name: "value",
|
|
493
|
+
type: "text",
|
|
494
|
+
required: true
|
|
495
|
+
}]
|
|
496
|
+
}],
|
|
497
|
+
defaultMessage: keys.ruleOneOfMessage,
|
|
498
|
+
validate: ({ value, params, message }) => {
|
|
499
|
+
if (value == null || value === "") return true;
|
|
500
|
+
const allowed = (params.values ?? []).map((entry) => entry.value);
|
|
501
|
+
if (allowed.length === 0) return true;
|
|
502
|
+
return allowed.includes(value) ? true : message();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/validation/builtin/pattern.ts
|
|
507
|
+
const tryRegExp = (pattern, flags) => {
|
|
508
|
+
try {
|
|
509
|
+
return new RegExp(pattern, flags);
|
|
510
|
+
} catch {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const patternRule = defineValidationRule({
|
|
515
|
+
type: "pattern",
|
|
516
|
+
label: keys.rulePattern,
|
|
517
|
+
appliesTo: [
|
|
518
|
+
"text",
|
|
519
|
+
"textarea",
|
|
520
|
+
"email"
|
|
521
|
+
],
|
|
522
|
+
params: [{
|
|
523
|
+
name: "pattern",
|
|
524
|
+
type: "text",
|
|
525
|
+
required: true,
|
|
526
|
+
label: labelFor(keys.ruleParamPattern)
|
|
527
|
+
}, {
|
|
528
|
+
name: "flags",
|
|
529
|
+
type: "text",
|
|
530
|
+
label: labelFor(keys.ruleParamFlags)
|
|
531
|
+
}],
|
|
532
|
+
defaultMessage: keys.rulePatternMessage,
|
|
533
|
+
validate: ({ value, params, message }) => {
|
|
534
|
+
if (value == null || value === "") return true;
|
|
535
|
+
const regex = tryRegExp(params.pattern, params.flags);
|
|
536
|
+
if (!regex) return true;
|
|
537
|
+
return regex.test(value) ? true : message();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/validation/builtin/url.ts
|
|
542
|
+
const isUrl = (value) => {
|
|
543
|
+
try {
|
|
544
|
+
const url = new URL(value);
|
|
545
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
546
|
+
} catch {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/validation/builtin/index.ts
|
|
552
|
+
const defaultValidationRules = [
|
|
553
|
+
minLengthRule,
|
|
554
|
+
maxLengthRule,
|
|
555
|
+
minRule,
|
|
556
|
+
maxRule,
|
|
557
|
+
patternRule,
|
|
558
|
+
emailRule,
|
|
559
|
+
defineValidationRule({
|
|
560
|
+
type: "url",
|
|
561
|
+
label: keys.ruleUrl,
|
|
562
|
+
appliesTo: ["text", "textarea"],
|
|
563
|
+
defaultMessage: keys.ruleUrlMessage,
|
|
564
|
+
validate: ({ value, message }) => value == null || value === "" || isUrl(value) ? true : message()
|
|
565
|
+
}),
|
|
566
|
+
oneOfRule,
|
|
567
|
+
matchesFieldRule,
|
|
568
|
+
notAlreadySubmittedRule
|
|
569
|
+
];
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/validation/registry.ts
|
|
572
|
+
const buildRuleRegistry = (rules) => {
|
|
573
|
+
const registry = /* @__PURE__ */ new Map();
|
|
574
|
+
for (const rule of rules) registry.set(rule.type, rule);
|
|
575
|
+
return registry;
|
|
576
|
+
};
|
|
577
|
+
/** Resolve the active rule registry from the built-in defaults and the plugin `rules` option. */
|
|
578
|
+
const resolveValidationRules = (defaults, config = {}) => {
|
|
579
|
+
const registry = buildRuleRegistry(defaults);
|
|
580
|
+
for (const [type, option] of Object.entries(config)) if (option === false) registry.delete(type);
|
|
581
|
+
else if (option === true) {} else registry.set(type, {
|
|
582
|
+
...option,
|
|
583
|
+
type
|
|
584
|
+
});
|
|
585
|
+
return registry;
|
|
586
|
+
};
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/prefill/valuesFromSearchParams.ts
|
|
589
|
+
const TRUTHY = new Set([
|
|
590
|
+
"true",
|
|
591
|
+
"1",
|
|
592
|
+
"on",
|
|
593
|
+
"yes"
|
|
594
|
+
]);
|
|
595
|
+
const coerce = (kind, raw, all) => {
|
|
596
|
+
switch (kind) {
|
|
597
|
+
case "number": {
|
|
598
|
+
const n = Number(raw);
|
|
599
|
+
return Number.isNaN(n) ? void 0 : n;
|
|
600
|
+
}
|
|
601
|
+
case "boolean": return TRUTHY.has(raw.toLowerCase());
|
|
602
|
+
case "text[]": return all;
|
|
603
|
+
default: return raw;
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
/**
|
|
607
|
+
* Map URL/query params to typed initial values for KNOWN fields only, coerced by each field's value
|
|
608
|
+
* kind. Unknown, denied, or un-allowed params are ignored; invalid numbers are dropped. Prefilled
|
|
609
|
+
* values are never trusted -- they still validate on submit. Pass the result to `<Form initialValues>`.
|
|
610
|
+
*/
|
|
611
|
+
const valuesFromSearchParams = (params, fields, registry, options = {}) => {
|
|
612
|
+
const isSearch = params instanceof URLSearchParams;
|
|
613
|
+
const paramKeys = isSearch ? [...new Set(params.keys())] : Object.keys(params);
|
|
614
|
+
const byName = new Map(fields.map((field) => [field.name, field]));
|
|
615
|
+
const result = {};
|
|
616
|
+
for (const param of paramKeys) {
|
|
617
|
+
const fieldName = options.map?.[param] ?? param;
|
|
618
|
+
if (options.allow && !options.allow.includes(fieldName)) continue;
|
|
619
|
+
if (options.deny?.includes(fieldName)) continue;
|
|
620
|
+
const field = byName.get(fieldName);
|
|
621
|
+
const definition = field && registry.get(field.blockType);
|
|
622
|
+
if (!field || !definition) continue;
|
|
623
|
+
const all = isSearch ? params.getAll(param) : [params[param]];
|
|
624
|
+
const value = coerce(definition.value, all[0] ?? "", all);
|
|
625
|
+
if (value !== void 0) result[fieldName] = value;
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
};
|
|
629
|
+
//#endregion
|
|
630
|
+
//#region src/recall/resolver.ts
|
|
631
|
+
/** Derive a value->label map from a select-like field instance's `options`. */
|
|
632
|
+
const optionLabelsFor = (field) => {
|
|
633
|
+
const options = field.options;
|
|
634
|
+
if (!Array.isArray(options)) return;
|
|
635
|
+
const map = {};
|
|
636
|
+
for (const option of options) if (option && typeof option.value === "string") map[option.value] = option.label ?? option.value;
|
|
637
|
+
return Object.keys(map).length > 0 ? map : void 0;
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* Build a recall resolver: `{{name}}` -> the field's current value, formatted via its type's `format`
|
|
641
|
+
* (so a select shows its option label, a checkbox Yes/No, a date localized). Missing field or empty
|
|
642
|
+
* value -> ''. Pure and isomorphic; the renderer and server templates share it.
|
|
643
|
+
*/
|
|
644
|
+
const buildRecallResolver = (args) => {
|
|
645
|
+
const byName = new Map(args.fields.map((field) => [field.name, field]));
|
|
646
|
+
return (name) => {
|
|
647
|
+
const field = byName.get(name);
|
|
648
|
+
if (!field) return "";
|
|
649
|
+
const value = args.values[name];
|
|
650
|
+
if (value == null || value === "" || Array.isArray(value) && value.length === 0) return "";
|
|
651
|
+
const definition = args.registry.get(field.blockType);
|
|
652
|
+
if (definition?.format) return definition.format({
|
|
653
|
+
value,
|
|
654
|
+
config: {},
|
|
655
|
+
optionLabels: optionLabelsFor(field),
|
|
656
|
+
locale: args.locale,
|
|
657
|
+
t: args.t
|
|
658
|
+
});
|
|
659
|
+
return Array.isArray(value) ? value.join(", ") : String(value);
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
//#endregion
|
|
663
|
+
export { IDENTITY_CONTEXT_KEY as _, resolveValidationRules as a, runValidation as c, computeCalcFields as d, evaluateCalc as f, DEFAULT_HONEYPOT_FIELD as g, CAPTCHA_TOKEN_KEY as h, buildRuleRegistry as i, evaluateCondition as l, defaultPresentationDescriptors as m, optionLabelsFor as n, defaultValidationRules as o, DEFAULT_PRESENTATION_NAME as p, valuesFromSearchParams as r, defineValidationRule as s, buildRecallResolver as t, calcExpressionOf as u, noopEventSink as v, interpolate as y };
|
|
664
|
+
|
|
665
|
+
//# sourceMappingURL=resolver-OeQyVH2N.js.map
|