bank20baht-validator 0.3.6 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +106 -11
  3. 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.5 <0.9** (the engine and the `FieldType` union come from there). In a workspace that also builds `bank20baht-ui`, keep the peer range in
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 shape = shapeIssues(field, data[field.key], field.key);
208
- if (shape.length > 0) {
209
- issues.push(...shape);
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.6",
3
+ "version": "0.4.0",
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.9.0",
44
+ "bank20baht-ui": "^0.11.0",
45
45
  "typescript": "^5.8.3",
46
46
  "vitest": "^3.2.4"
47
47
  },
48
48
  "peerDependencies": {
49
- "bank20baht-ui": ">=0.5.0 <0.10.0"
49
+ "bank20baht-ui": ">=0.11.0 <0.12.0"
50
50
  },
51
51
  "main": "./dist/index.js",
52
52
  "types": "./dist/index.d.ts",