bank20baht-validator 0.3.7 → 0.4.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/README.md +1 -1
- package/dist/index.js +106 -11
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
|
|
|
46
46
|
|
|
47
47
|
## Compatibility
|
|
48
48
|
|
|
49
|
-
Peers on `bank20baht-ui` **>=0.
|
|
49
|
+
Peers on `bank20baht-ui` **>=0.11 <0.12** (the engine and the `FieldType` union come from there; see `peerDependencies`). In a workspace that also builds `bank20baht-ui`, keep the peer range in
|
|
50
50
|
step with the core version or the validator's `tsc` build resolves a stale copy from npm.
|
|
51
51
|
|
|
52
52
|
## What it enforces
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// every value must have the shape the UI control could actually emit before
|
|
14
14
|
// the authored validators run.
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
|
-
import { applyFlags, approvalFields, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, parseDisabledEntry, parseIsoDate, parseIsoDateTime, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
|
|
16
|
+
import { applyFlags, approvalFields, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
|
|
17
17
|
/**
|
|
18
18
|
* Run `fn` with the catalog switched to `locale`, restoring the previous
|
|
19
19
|
* catalog after. The engine catalog is module-global; validation is fully
|
|
@@ -51,6 +51,7 @@ const FIELD_TYPES = new Set([
|
|
|
51
51
|
'file',
|
|
52
52
|
'label',
|
|
53
53
|
'repeater',
|
|
54
|
+
'tags',
|
|
54
55
|
]);
|
|
55
56
|
export function isWorkflowDefinition(schema) {
|
|
56
57
|
return (isRecord(schema) &&
|
|
@@ -108,6 +109,24 @@ function multiChoiceIssue(value, optionKeys) {
|
|
|
108
109
|
return t('pattern');
|
|
109
110
|
return shapeMessage(!optionKeys || value.every((v) => optionKeys.includes(v)));
|
|
110
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* tags: an array of strings — free text, unlike dropdown/multiselect. Only
|
|
114
|
+
* `allow_new: false` restricts chips to the static `options` list, matched
|
|
115
|
+
* case-insensitively against the (already-trimmed) tag; `options_from` is
|
|
116
|
+
* skipped exactly like a dropdown's dynamic options (`optionKeys === null`
|
|
117
|
+
* here too — the server cannot resolve it, so it trusts the client that did).
|
|
118
|
+
*/
|
|
119
|
+
function tagsShapeIssue(field, value, optionKeys) {
|
|
120
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
121
|
+
return t('pattern');
|
|
122
|
+
if (field.allow_new === false && optionKeys) {
|
|
123
|
+
const allowed = new Set(optionKeys.map((k) => k.toLowerCase()));
|
|
124
|
+
// judged on the canonical value: a blank entry is dropped, not "not allowed"
|
|
125
|
+
const bad = normalizeTagsValue(value).some((v) => !allowed.has(v.toLowerCase()));
|
|
126
|
+
return shapeMessage(!bad);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
111
130
|
function fileIssue(field, value) {
|
|
112
131
|
if (field.multiple)
|
|
113
132
|
return shapeMessage(Array.isArray(value) && value.every(isRecord));
|
|
@@ -137,6 +156,8 @@ function typeIssue(field, value) {
|
|
|
137
156
|
return choiceIssue(value, optionKeys);
|
|
138
157
|
case 'multiselect':
|
|
139
158
|
return multiChoiceIssue(value, optionKeys);
|
|
159
|
+
case 'tags':
|
|
160
|
+
return tagsShapeIssue(field, value, optionKeys);
|
|
140
161
|
case 'file':
|
|
141
162
|
return fileIssue(field, value);
|
|
142
163
|
case 'repeater':
|
|
@@ -181,6 +202,85 @@ function shapeIssues(field, value, ref) {
|
|
|
181
202
|
export function validateFormData(form, input, registry, locale) {
|
|
182
203
|
return withLocale(locale, () => formDataResult(form, input, registry));
|
|
183
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* A tags value as the client emits it: trimmed, case-insensitive deduped
|
|
207
|
+
* (first spelling kept, a static option's key spelling when it matches one),
|
|
208
|
+
* `[]` for missing. A wrong-shaped value passes
|
|
209
|
+
* through untouched — `shapeIssues` owns rejecting it (and it runs AFTER
|
|
210
|
+
* rule evaluation, which also reads this).
|
|
211
|
+
*/
|
|
212
|
+
function canonicalTags(field, v) {
|
|
213
|
+
if (v === null || v === undefined)
|
|
214
|
+
return [];
|
|
215
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
|
|
216
|
+
return v;
|
|
217
|
+
// static options only: options_from can't be resolved here, so its
|
|
218
|
+
// suggestions keep the spelling the client sent (the client already mapped them)
|
|
219
|
+
return normalizeTagsValue(v, field.options_from ? undefined : field.options);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Canonical value for one field: `tags` via `canonicalTags` (the same
|
|
223
|
+
* transform `validateField` applies internally when checking it, applied
|
|
224
|
+
* here too so the persisted `data` matches byte-for-byte); `repeater`
|
|
225
|
+
* recurses one row deep to normalise any `tags` COLUMN the same way (a
|
|
226
|
+
* repeater never nests, spec 01 §10). Every other type passes through.
|
|
227
|
+
* Only own keys are ever assigned, so a `__proto__` key stays a plain
|
|
228
|
+
* data property.
|
|
229
|
+
*/
|
|
230
|
+
function normalizeCanonicalValue(field, raw) {
|
|
231
|
+
if (field.type === 'tags')
|
|
232
|
+
return canonicalTags(field, raw);
|
|
233
|
+
if (field.type !== 'repeater' || !Array.isArray(raw))
|
|
234
|
+
return raw;
|
|
235
|
+
return raw.map((row) => {
|
|
236
|
+
if (!isRecord(row))
|
|
237
|
+
return row;
|
|
238
|
+
const next = { ...row };
|
|
239
|
+
for (const sub of field.fields ?? []) {
|
|
240
|
+
if (sub.type === 'tags' && Object.hasOwn(next, sub.key)) {
|
|
241
|
+
next[sub.key] = canonicalTags(sub, next[sub.key]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return next;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Copy of the workflow input with every tags value (and tags column)
|
|
249
|
+
* canonical — rules and transitions must read tags the way the client emits
|
|
250
|
+
* them, else `[" urgent "]` dodges a `contains "urgent"` rule.
|
|
251
|
+
*/
|
|
252
|
+
function withNormalizedTags(workflow, input) {
|
|
253
|
+
const out = { ...input };
|
|
254
|
+
for (const form of workflow.forms) {
|
|
255
|
+
const values = Object.hasOwn(input, form.form_name) ? input[form.form_name] : undefined;
|
|
256
|
+
if (!isRecord(values))
|
|
257
|
+
continue;
|
|
258
|
+
const next = { ...values };
|
|
259
|
+
for (const field of form.fields) {
|
|
260
|
+
if (Object.hasOwn(next, field.key)) {
|
|
261
|
+
next[field.key] = normalizeCanonicalValue(field, next[field.key]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
out[form.form_name] = next;
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* One field's shape check + canonical value + declarative/custom validators.
|
|
270
|
+
* Junk (wrong shape) never reaches canonical data — it resets to the
|
|
271
|
+
* descriptor default.
|
|
272
|
+
*/
|
|
273
|
+
function validatedFieldData(field, raw, registry, data) {
|
|
274
|
+
const shape = shapeIssues(field, raw, field.key);
|
|
275
|
+
if (shape.length > 0)
|
|
276
|
+
return { value: defaultValue(field), issues: shape };
|
|
277
|
+
const value = normalizeCanonicalValue(field, raw);
|
|
278
|
+
const issues = validateField(field, value, registry, data).map((err) => ({
|
|
279
|
+
ref: err.key,
|
|
280
|
+
message: err.message,
|
|
281
|
+
}));
|
|
282
|
+
return { value, issues };
|
|
283
|
+
}
|
|
184
284
|
function formDataResult(form, input, registry) {
|
|
185
285
|
if (!isRecord(input)) {
|
|
186
286
|
return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
|
|
@@ -204,15 +304,9 @@ function formDataResult(form, input, registry) {
|
|
|
204
304
|
data[field.key] = Object.hasOwn(input, field.key) ? input[field.key] : defaultValue(field);
|
|
205
305
|
}
|
|
206
306
|
for (const field of fields) {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
data[field.key] = defaultValue(field); // junk never reaches canonical data
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
for (const err of validateField(field, data[field.key], registry, data)) {
|
|
214
|
-
issues.push({ ref: err.key, message: err.message });
|
|
215
|
-
}
|
|
307
|
+
const { value, issues: fieldIssues } = validatedFieldData(field, data[field.key], registry, data);
|
|
308
|
+
data[field.key] = value;
|
|
309
|
+
issues.push(...fieldIssues);
|
|
216
310
|
}
|
|
217
311
|
issues.push(...submitScriptIssues(form, data, registry));
|
|
218
312
|
return { valid: issues.length === 0, issues, data };
|
|
@@ -321,6 +415,7 @@ function workflowFieldIssues(form, field, derived, registry) {
|
|
|
321
415
|
formData[field.key] = defaultValue(field); // junk never reaches canonical data
|
|
322
416
|
return shape;
|
|
323
417
|
}
|
|
418
|
+
formData[field.key] = normalizeCanonicalValue(field, formData[field.key]);
|
|
324
419
|
const required = state ? state.required : !!field.required;
|
|
325
420
|
const descriptor = required === !!field.required ? field : { ...field, required };
|
|
326
421
|
return validateField(descriptor, formData[field.key], registry, formData).map((err) => ({
|
|
@@ -360,7 +455,7 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
360
455
|
const rejection = workflowInputRejection(input);
|
|
361
456
|
if (rejection)
|
|
362
457
|
return rejection;
|
|
363
|
-
const derived = evaluate(workflow, input);
|
|
458
|
+
const derived = evaluate(workflow, withNormalizedTags(workflow, input));
|
|
364
459
|
// Schema-integrity errors (unknown rule targets) belong at parseSchema /
|
|
365
460
|
// store time — the client never surfaces them, so failing submissions on
|
|
366
461
|
// them would reject data the client happily emits. Only data-dependent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime validation for bahtui form/workflow JSON \u2014 the exact engine rules the client runs, on the server.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"bank20baht-ui": "^0.
|
|
44
|
+
"bank20baht-ui": "^0.12.0",
|
|
45
45
|
"typescript": "^5.8.3",
|
|
46
46
|
"vitest": "^3.2.4"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"bank20baht-ui": ">=0.
|
|
49
|
+
"bank20baht-ui": ">=0.11.0 <0.13.0"
|
|
50
50
|
},
|
|
51
51
|
"main": "./dist/index.js",
|
|
52
52
|
"types": "./dist/index.d.ts",
|