bank20baht-validator 0.2.0 → 0.3.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 CHANGED
@@ -56,6 +56,7 @@ step with the core version or the validator's `tsc` build resolves a stale copy
56
56
  - Value **shape** per field type before any validator runs — a `password`/`text` must be a string, a `number` a finite number, a `dropdown` one of its option keys — so `{"$gt":""}` from a raw API call is rejected, not treated as "non-empty".
57
57
  - **Workflow rules first** (`evaluate` from `bank20baht-ui/engine`): fields hidden by rules are excluded and smuggled values are cleared; `required` derived from rules is enforced; `setValue` results are computed.
58
58
  - Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
59
+ - **Client `submit` scripts** (spec 01 §14): re-run headlessly with the same `registry.scripts` — `g.setValue()` lands in canonical data, `g.addErrorMessage()` becomes an issue on `$` (`form_name.$` in a workflow), returning `false` blocks. `load` / `change` scripts are UI-only and never run here, so a field a client script hides is still validated: keep submit-relevant logic in rules or validators. `parseSchema` rejects a `scripts[]` entry with an unknown `on`, no `handler`, or a `change` hook on a field that does not exist.
59
60
 
60
61
  ## API
61
62
 
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, defaultValue, evaluate, getCatalog, parseDisabledEntry, parseIsoDate, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
16
+ import { applyFlags, approvalFields, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, parseDisabledEntry, parseIsoDate, 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
@@ -204,8 +204,51 @@ function formDataResult(form, input, registry) {
204
204
  issues.push({ ref: err.key, message: err.message });
205
205
  }
206
206
  }
207
+ issues.push(...submitScriptIssues(form, data, registry));
207
208
  return { valid: issues.length === 0, issues, data };
208
209
  }
210
+ /**
211
+ * Re-run the form's `submit` client scripts (spec 01 §14) headlessly — the
212
+ * same gate `<baht-form>.validate()` applies after its validators. `load` and
213
+ * `change` scripts are UI-only and never run here. `g.setValue()` writes into
214
+ * canonical `data`; UI state/message calls are accepted and discarded, except
215
+ * `addErrorMessage()`, which becomes an issue on `$` (prefixed by the form name
216
+ * inside a workflow). A handler returning `false` with no message falls back to
217
+ * the catalog's `scriptBlocked`.
218
+ */
219
+ function submitScriptIssues(form, data, registry, refPrefix = '') {
220
+ if (!hasScripts(form, 'submit'))
221
+ return [];
222
+ const messages = [];
223
+ const g = createScriptContext(form, {
224
+ getData: () => data,
225
+ setValue: (key, value) => {
226
+ data[key] = value;
227
+ },
228
+ setState: () => { },
229
+ resetState: () => { },
230
+ setFieldMessage: () => { },
231
+ addFormMessage: (m) => {
232
+ messages.push(m);
233
+ },
234
+ clearFormMessages: () => {
235
+ messages.length = 0;
236
+ },
237
+ });
238
+ const ok = runScripts({
239
+ form,
240
+ registry: registry ?? {},
241
+ g,
242
+ on: 'submit',
243
+ detail: { isLoading: false, data: { ...data } },
244
+ });
245
+ const issues = messages
246
+ .filter((m) => m.type === 'error')
247
+ .map((m) => ({ ref: `${refPrefix}$`, message: m.message }));
248
+ if (!ok && issues.length === 0)
249
+ issues.push({ ref: `${refPrefix}$`, message: t('scriptBlocked') });
250
+ return issues;
251
+ }
209
252
  /**
210
253
  * Validate a whole workflow submission — mirrors what `<baht-workflow>`
211
254
  * enforces across its steps: rules are evaluated first (`evaluate`), invisible
@@ -297,6 +340,10 @@ function workflowDataResult(workflow, input, registry) {
297
340
  issues.push({ ref, message: err.message });
298
341
  }
299
342
  }
343
+ if (hasScripts(form, 'submit')) {
344
+ const formData = (derived.data[form.form_name] ??= {});
345
+ issues.push(...submitScriptIssues(form, formData, registry, `${form.form_name}.`));
346
+ }
300
347
  }
301
348
  return { valid: issues.length === 0, issues, data: derived.data };
302
349
  }
@@ -372,6 +419,7 @@ export function parseSchema(json) {
372
419
  assertValidFields(f);
373
420
  assertValidViews(f);
374
421
  assertValidRepeaters(f);
422
+ assertValidScripts(f);
375
423
  if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
376
424
  throw new Error(`step view ${JSON.stringify(f.view)} on ${f.form_name} matches no view`);
377
425
  }
@@ -422,6 +470,7 @@ export function parseSchema(json) {
422
470
  assertValidFields(parsed);
423
471
  assertValidViews(parsed);
424
472
  assertValidRepeaters(parsed);
473
+ assertValidScripts(parsed);
425
474
  return parsed;
426
475
  }
427
476
  throw new Error('missing workflow_id/forms/rules (workflow) or form_id/form_name/fields (form)');
@@ -683,6 +732,34 @@ function assertValidRepeaters(form) {
683
732
  }
684
733
  }
685
734
  }
735
+ const SCRIPT_EVENTS = new Set(['load', 'change', 'submit']);
736
+ /** `scripts[]` (spec 01 §14): known `on`, a handler name, and a real field for `change`. */
737
+ function assertValidScripts(form) {
738
+ if (form.scripts === undefined)
739
+ return;
740
+ if (!Array.isArray(form.scripts)) {
741
+ throw new Error(`scripts must be an array on ${form.form_name}`);
742
+ }
743
+ const keys = new Set(form.fields.map((f) => f.key));
744
+ for (const s of form.scripts) {
745
+ if (!isRecord(s))
746
+ throw new Error(`invalid script on ${form.form_name}: expected an object`);
747
+ if (typeof s.on !== 'string' || !SCRIPT_EVENTS.has(s.on)) {
748
+ throw new Error(`invalid script on=${JSON.stringify(s.on)} on ${form.form_name}`);
749
+ }
750
+ if (typeof s.handler !== 'string' || s.handler === '') {
751
+ throw new Error(`script on ${form.form_name} needs a handler name`);
752
+ }
753
+ if (s.on === 'change') {
754
+ if (typeof s.field !== 'string' || s.field === '') {
755
+ throw new Error(`change script "${s.handler}" on ${form.form_name} needs a field`);
756
+ }
757
+ if (!keys.has(s.field)) {
758
+ throw new Error(`change script "${s.handler}" on ${form.form_name} targets unknown field ${JSON.stringify(s.field)}`);
759
+ }
760
+ }
761
+ }
762
+ }
686
763
  function assertValidViews(form) {
687
764
  if (form.views === undefined)
688
765
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Runtime validation for bahtui form/workflow JSON — the exact engine rules the client runs, on the server.",
6
6
  "license": "MIT",
@@ -40,12 +40,12 @@
40
40
  "test:watch": "vitest"
41
41
  },
42
42
  "devDependencies": {
43
- "bank20baht-ui": "^0.3.0",
43
+ "bank20baht-ui": "^0.5.0",
44
44
  "typescript": "^5.8.3",
45
45
  "vitest": "^3.2.4"
46
46
  },
47
47
  "peerDependencies": {
48
- "bank20baht-ui": ">=0.3.0 <0.4.0"
48
+ "bank20baht-ui": ">=0.5.0 <0.6.0"
49
49
  },
50
50
  "main": "./dist/index.js",
51
51
  "types": "./dist/index.d.ts",