@formwright/core 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { signal, computed, untrack, batch } from './chunk-EZUHEI5F.js';
2
- export { batch, computed, effect, isTracking, signal, untrack } from './chunk-EZUHEI5F.js';
1
+ import { signal, computed, effect, untrack, batch } from './chunk-O4DUMDBU.js';
2
+ export { batch, computed, effect, isTracking, signal, untrack } from './chunk-O4DUMDBU.js';
3
3
  import { parseSchema } from '@formwright/schema';
4
4
 
5
5
  // src/conditions.ts
@@ -90,35 +90,42 @@ function isEmpty(value) {
90
90
  return value === void 0 || value === null || value === "";
91
91
  }
92
92
  function compileValidator(schema) {
93
+ const overrides = schema.messages ?? {};
94
+ const msg = (rule, fallback) => overrides[rule] ?? (typeof schema.message === "string" ? schema.message : fallback);
93
95
  return (value) => {
94
- const msg = (fallback) => typeof schema.message === "string" ? schema.message : fallback;
95
96
  if (isEmpty(value)) {
96
- return schema.required ? msg("This field is required") : null;
97
+ return schema.required ? msg("required", "This field is required") : null;
97
98
  }
98
99
  if (schema.kind === "string" || schema.format) {
99
100
  const str = String(value);
100
101
  if (schema.minLength !== void 0 && str.length < schema.minLength) {
101
- return msg(`Must be at least ${schema.minLength} characters`);
102
+ return msg("minLength", `Must be at least ${schema.minLength} characters`);
102
103
  }
103
104
  if (schema.maxLength !== void 0 && str.length > schema.maxLength) {
104
- return msg(`Must be at most ${schema.maxLength} characters`);
105
+ return msg("maxLength", `Must be at most ${schema.maxLength} characters`);
105
106
  }
106
107
  if (schema.pattern !== void 0 && !new RegExp(schema.pattern).test(str)) {
107
- return msg("Invalid format");
108
+ return msg("pattern", "Invalid format");
108
109
  }
109
- if (schema.format === "email" && !EMAIL.test(str)) return msg("Enter a valid email");
110
- if (schema.format === "url" && !URL.test(str)) return msg("Enter a valid URL");
111
- if (schema.format === "uuid" && !UUID.test(str)) return msg("Enter a valid UUID");
110
+ if (schema.format === "email" && !EMAIL.test(str))
111
+ return msg("format", "Enter a valid email");
112
+ if (schema.format === "url" && !URL.test(str)) return msg("format", "Enter a valid URL");
113
+ if (schema.format === "uuid" && !UUID.test(str)) return msg("format", "Enter a valid UUID");
112
114
  }
113
115
  if (schema.kind === "number") {
114
116
  const num = Number(value);
115
- if (Number.isNaN(num)) return msg("Must be a number");
116
- if (schema.min !== void 0 && num < schema.min) return msg(`Must be \u2265 ${schema.min}`);
117
- if (schema.max !== void 0 && num > schema.max) return msg(`Must be \u2264 ${schema.max}`);
117
+ if (Number.isNaN(num)) return msg("type", "Must be a number");
118
+ if (schema.min !== void 0 && num < schema.min)
119
+ return msg("min", `Must be \u2265 ${schema.min}`);
120
+ if (schema.max !== void 0 && num > schema.max)
121
+ return msg("max", `Must be \u2264 ${schema.max}`);
118
122
  }
119
123
  return null;
120
124
  };
121
125
  }
126
+ function requiredMessage(schema) {
127
+ return schema?.messages?.required ?? (typeof schema?.message === "string" ? schema.message : "This field is required");
128
+ }
122
129
 
123
130
  // src/providers.ts
124
131
  function isProviderRef(value) {
@@ -158,10 +165,14 @@ function defaultValueFor(type) {
158
165
  return "";
159
166
  }
160
167
  }
168
+ var uidSeq = 0;
161
169
  var FieldState = class {
162
170
  /** Discriminant for the {@link FieldNode} union (leaf vs group/collection). */
163
171
  kind = "field";
164
172
  id;
173
+ /** A globally-unique DOM id for the control (collection rows reuse `id`, so this must be unique). */
174
+ domId = `fw-${(uidSeq++).toString(36)}`;
175
+ /** The field's schema — mutable at runtime via {@link patchSchema}. */
165
176
  schema;
166
177
  value;
167
178
  error;
@@ -169,7 +180,10 @@ var FieldState = class {
169
180
  visible;
170
181
  enabled;
171
182
  required;
183
+ /** Bumps whenever the schema is patched — renderers re-render the field on change. */
184
+ revision;
172
185
  validator;
186
+ rev = signal(0);
173
187
  constructor(schema, initial, getValue) {
174
188
  this.id = schema.id;
175
189
  this.schema = schema;
@@ -177,15 +191,29 @@ var FieldState = class {
177
191
  this.error = signal(null);
178
192
  this.touched = signal(false);
179
193
  this.validator = schema.validation ? compileValidator(schema.validation) : null;
180
- this.visible = computed(() => evaluateCondition(schema.visibleWhen, getValue, true));
181
- this.enabled = computed(() => evaluateCondition(schema.enabledWhen, getValue, true));
194
+ this.revision = this.rev;
195
+ this.visible = computed(() => {
196
+ this.rev.get();
197
+ return evaluateCondition(this.schema.visibleWhen, getValue, true);
198
+ });
199
+ this.enabled = computed(() => {
200
+ this.rev.get();
201
+ return evaluateCondition(this.schema.enabledWhen, getValue, true);
202
+ });
182
203
  this.required = computed(() => {
183
- if (schema.requiredWhen !== void 0) {
184
- return evaluateCondition(schema.requiredWhen, getValue, false);
204
+ this.rev.get();
205
+ if (this.schema.requiredWhen !== void 0) {
206
+ return evaluateCondition(this.schema.requiredWhen, getValue, false);
185
207
  }
186
- return schema.validation?.required ?? false;
208
+ return this.schema.validation?.required ?? false;
187
209
  });
188
210
  }
211
+ /** Merge a partial schema in at runtime (change type, label, options, validation, …). */
212
+ patchSchema(partial) {
213
+ this.schema = { ...this.schema, ...partial };
214
+ this.validator = this.schema.validation ? compileValidator(this.schema.validation) : null;
215
+ this.rev.update((n) => n + 1);
216
+ }
189
217
  /** Run validation, store and return the error (or null). Hidden fields never error. */
190
218
  validate() {
191
219
  if (!this.visible.peek()) {
@@ -194,7 +222,7 @@ var FieldState = class {
194
222
  }
195
223
  let result = null;
196
224
  if (this.required.peek() && isEmpty2(this.value.peek())) {
197
- result = "This field is required";
225
+ result = requiredMessage(this.schema.validation);
198
226
  } else if (this.validator) {
199
227
  result = this.validator(this.value.peek());
200
228
  }
@@ -212,12 +240,20 @@ function isEmpty2(value) {
212
240
  }
213
241
 
214
242
  // src/nodes.ts
243
+ var PRESENTATIONAL = /* @__PURE__ */ new Set(["heading", "separator", "paragraph"]);
244
+ function isPresentational(type) {
245
+ return PRESENTATIONAL.has(type);
246
+ }
215
247
  function nodeValue(node) {
216
248
  return node.value.get();
217
249
  }
218
250
  function collectValues(nodes) {
219
251
  const out = {};
220
- for (const node of nodes) out[node.id] = nodeValue(node);
252
+ for (const node of nodes) {
253
+ if (node.schema.omit || isPresentational(node.schema.type)) continue;
254
+ if (!node.visible.get()) continue;
255
+ out[node.id] = nodeValue(node);
256
+ }
221
257
  return out;
222
258
  }
223
259
  function buildNodes(schemas, scope, initial) {
@@ -376,15 +412,28 @@ var Form = class {
376
412
  rootByName;
377
413
  listeners = /* @__PURE__ */ new Map();
378
414
  disposeRenderer = null;
415
+ disposePersist = null;
379
416
  constructor(schema, initialValues = {}, options = {}) {
380
417
  this.schema = parseSchema(schema);
381
418
  this.options = options;
382
419
  this.initialValues = initialValues;
383
- const tree = buildTree(this.schema.fields, initialValues);
420
+ const fields = this.schema.locales?.length ? expandLocalized(this.schema.fields, this.schema.locales) : this.schema.fields;
421
+ const seed = loadPersisted(options.persistKey, initialValues);
422
+ const tree = buildTree(fields, seed);
384
423
  this.tree = tree.nodes;
385
424
  this.rootByName = tree.byName;
386
- this.order = this.schema.fields.map((f) => f.id);
425
+ this.order = fields.map((f) => f.id);
387
426
  this.values = computed(() => collectTree(this.tree));
427
+ if (options.persistKey) {
428
+ const key = options.persistKey;
429
+ this.disposePersist = effect(() => {
430
+ const v = this.values.get();
431
+ try {
432
+ localStorage.setItem(key, JSON.stringify(v));
433
+ } catch {
434
+ }
435
+ });
436
+ }
388
437
  this.initialSnapshot = JSON.stringify(untrack(() => this.values.peek()));
389
438
  this.isDirty = computed(() => JSON.stringify(this.values.get()) !== this.initialSnapshot);
390
439
  this.isValid = computed(() => {
@@ -415,12 +464,22 @@ var Form = class {
415
464
  setFieldValue(field, value) {
416
465
  field.value.set(value);
417
466
  field.touched.set(true);
418
- if (field.error.peek() !== null) field.validate();
467
+ field.validate();
419
468
  this.emit("change", { id: field.id, value });
420
469
  }
421
470
  setError(id, error) {
422
471
  this.field(id)?.error.set(error);
423
472
  }
473
+ /** Patch one field's schema at runtime (change type, label, options, validation, …). */
474
+ setFieldSchema(path, partial) {
475
+ this.field(path)?.patchSchema(partial);
476
+ }
477
+ /** Patch many fields' schemas at once: `form.patch({ state: { type: "text" }, … })`. */
478
+ patch(updates) {
479
+ batch(() => {
480
+ for (const [path, partial] of Object.entries(updates)) this.setFieldSchema(path, partial);
481
+ });
482
+ }
424
483
  setErrors(errors) {
425
484
  batch(() => {
426
485
  for (const [id, error] of Object.entries(errors)) this.setError(id, error);
@@ -442,27 +501,38 @@ var Form = class {
442
501
  return ok;
443
502
  });
444
503
  }
445
- /** Run the submission pipeline: validate → transform → send → onSuccess/onError. */
446
- async submit() {
504
+ /**
505
+ * Run the submission pipeline: validate → transform → send → onSuccess/onError.
506
+ * Pass an inline `transform` to shape the final payload, e.g.
507
+ * `form.submit((values) => ({ ...values, source: "web" }))`.
508
+ *
509
+ * Always **resolves** with a {@link SubmitResult} — never throws — so you can
510
+ * handle both outcomes from the API in one place:
511
+ * `const res = await form.submit(); res.ok ? res.data : res.error`.
512
+ */
513
+ async submit(transform) {
447
514
  if (!this.validate()) {
448
- const error = new FormValidationError(this.collectErrors());
515
+ const errors = this.collectErrors();
516
+ const error = new FormValidationError(errors);
449
517
  this.runErrorHandler(error);
450
518
  this.emit("error", error);
451
- throw error;
519
+ return { ok: false, error, errors };
452
520
  }
453
521
  this.submitting.set(true);
454
522
  const values = untrack(() => this.values.peek());
455
- const payload = this.applyTransform(values);
523
+ const named = this.applyTransform(values);
524
+ const payload = transform ? transform(named, this) : named;
456
525
  this.emit("submit", payload);
457
526
  try {
458
- const result = await this.send(payload);
459
- this.runSuccessHandler(result);
460
- this.emit("success", result);
461
- return result;
527
+ const data = await this.send(payload);
528
+ this.clearPersisted();
529
+ this.runSuccessHandler(data);
530
+ this.emit("success", data);
531
+ return { ok: true, data };
462
532
  } catch (error) {
463
533
  this.runErrorHandler(error);
464
534
  this.emit("error", error);
465
- throw error;
535
+ return { ok: false, error };
466
536
  } finally {
467
537
  this.submitting.set(false);
468
538
  }
@@ -472,6 +542,13 @@ var Form = class {
472
542
  resetNodes(this.tree, values);
473
543
  });
474
544
  }
545
+ /** Trigger a named form action: runs its handler (from options) and emits "action". */
546
+ action(name) {
547
+ const def = this.schema.actions?.find((a) => a.name === name);
548
+ const handler = def?.handler ? this.options.handlers?.[def.handler] : void 0;
549
+ handler?.(this);
550
+ this.emit("action", { name });
551
+ }
475
552
  /** Mount into a host element using the given renderer (or the registered default). */
476
553
  mount(host, renderer = defaultRenderer) {
477
554
  if (!renderer) {
@@ -486,8 +563,19 @@ var Form = class {
486
563
  destroy() {
487
564
  this.disposeRenderer?.();
488
565
  this.disposeRenderer = null;
566
+ this.disposePersist?.();
567
+ this.disposePersist = null;
489
568
  this.listeners.clear();
490
569
  }
570
+ /** Remove the cached draft from `localStorage` (called on a successful submit). */
571
+ clearPersisted() {
572
+ const key = this.options.persistKey;
573
+ if (!key || typeof localStorage === "undefined") return;
574
+ try {
575
+ localStorage.removeItem(key);
576
+ } catch {
577
+ }
578
+ }
491
579
  // ---- events -------------------------------------------------------------
492
580
  on(event, listener) {
493
581
  let set = this.listeners.get(event);
@@ -549,6 +637,54 @@ var FormValidationError = class extends Error {
549
637
  function collectTree(tree) {
550
638
  return collectValues(tree);
551
639
  }
640
+ function loadPersisted(key, initial) {
641
+ if (!key || typeof localStorage === "undefined") return initial;
642
+ try {
643
+ const saved = localStorage.getItem(key);
644
+ if (saved) return { ...initial, ...JSON.parse(saved) };
645
+ } catch {
646
+ }
647
+ return initial;
648
+ }
649
+ function expandLocalized(fields, locales) {
650
+ return fields.map((f) => {
651
+ if (f.localized) {
652
+ const leafType = f.type === "group" || f.type === "collection" ? "text" : f.type;
653
+ const child = (loc) => {
654
+ const c = { id: loc, type: leafType, label: loc };
655
+ if (f.placeholder !== void 0) c["placeholder"] = f.placeholder;
656
+ if (f.validation !== void 0) c["validation"] = f.validation;
657
+ if (f.options !== void 0) c["options"] = f.options;
658
+ if (f.widget !== void 0) c["widget"] = f.widget;
659
+ if (f.tooltip !== void 0) c["tooltip"] = f.tooltip;
660
+ return c;
661
+ };
662
+ const group = {
663
+ id: f.id,
664
+ type: "group",
665
+ // Keep the `localized` flag so the renderer shows ONE input + a language
666
+ // switcher (instead of one input per locale). Value stays `{ en, ar }`.
667
+ localized: true,
668
+ fields: locales.map(child)
669
+ };
670
+ if (f.defaultLocale !== void 0) group["defaultLocale"] = f.defaultLocale;
671
+ for (const key of [
672
+ "label",
673
+ "visibleWhen",
674
+ "enabledWhen",
675
+ "class",
676
+ "classes",
677
+ "help",
678
+ "tooltip"
679
+ ]) {
680
+ if (f[key] !== void 0) group[key] = f[key];
681
+ }
682
+ return group;
683
+ }
684
+ if (f.fields) return { ...f, fields: expandLocalized(f.fields, locales) };
685
+ return f;
686
+ });
687
+ }
552
688
  function collectLeaves(tree) {
553
689
  const out = /* @__PURE__ */ new Map();
554
690
  const walk = (nodes, prefix) => {
@@ -580,6 +716,6 @@ function resolveLeaf(tree, rootByName, path) {
580
716
  return node && node.kind === "field" ? node : void 0;
581
717
  }
582
718
 
583
- export { CollectionNode, FieldState, Form, FormValidationError, GroupNode, buildTree, compileValidator, defaultValueFor, eachLeaf, evaluateCondition, isProviderRef, referencedFields, resolve, resolveQuery, setDefaultRenderer };
719
+ export { CollectionNode, FieldState, Form, FormValidationError, GroupNode, buildTree, compileValidator, defaultValueFor, eachLeaf, evaluateCondition, isPresentational, isProviderRef, referencedFields, resolve, resolveQuery, setDefaultRenderer };
584
720
  //# sourceMappingURL=index.js.map
585
721
  //# sourceMappingURL=index.js.map