@embeddables/forms 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.
@@ -32,6 +32,21 @@ var ValidatorError = class extends FormsError {
32
32
  }
33
33
  };
34
34
  //#endregion
35
+ //#region src/types/form-file.ts
36
+ function contentTypeMatchesAccept({ contentType, accept }) {
37
+ const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() || "";
38
+ return accept.some((entry) => {
39
+ const pattern = entry.trim().toLowerCase();
40
+ if (pattern.endsWith("/*")) return normalized.startsWith(pattern.slice(0, -1));
41
+ return normalized === pattern;
42
+ });
43
+ }
44
+ function isFormFileRef(value) {
45
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
46
+ const record = value;
47
+ return typeof record.file_id === "string" && typeof record.name === "string" && typeof record.content_type === "string" && typeof record.size === "number" && Number.isFinite(record.size) && (record.status === "uploading" || record.status === "done" || record.status === "error");
48
+ }
49
+ //#endregion
35
50
  //#region src/core/validation.ts
36
51
  /** Runtime counterpart to `FieldType`. Exhaustive by construction. */
37
52
  const FIELD_TYPE_PREDICATES = {
@@ -41,7 +56,8 @@ const FIELD_TYPE_PREDICATES = {
41
56
  boolean: (value) => typeof value === "boolean",
42
57
  select: (value) => typeof value === "string",
43
58
  multiselect: (value) => Array.isArray(value),
44
- json: () => true
59
+ json: () => true,
60
+ file: (value) => value === null || isFormFileRef(value)
45
61
  };
46
62
  const EMAIL_PATTERN = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?(?:\.[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?)*$/;
47
63
  /**
@@ -56,9 +72,9 @@ function validateValue({ field, value, values, pattern, validator }) {
56
72
  const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
57
73
  if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
58
74
  if (isAbsent) return messages;
59
- if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
75
+ if (!FIELD_TYPE_PREDICATES[field.value_type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.value_type) ? "an" : "a"} ${field.value_type} value`);
60
76
  if (typeof value === "string") {
61
- if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
77
+ if (field.value_type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
62
78
  if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
63
79
  if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
64
80
  if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
@@ -71,12 +87,12 @@ function validateValue({ field, value, values, pattern, validator }) {
71
87
  if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
72
88
  if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
73
89
  }
74
- if (field.type === "select" || field.type === "multiselect") {
90
+ if (field.value_type === "select" || field.value_type === "multiselect") {
75
91
  const options = field.options;
76
92
  if (options) {
77
- const allowed = new Set(options.map((option) => option.value));
78
- if (field.type === "select" && typeof value === "string" && !allowed.has(value)) messages.push(`${field.label} must be one of the allowed options`);
79
- if (field.type === "multiselect" && Array.isArray(value)) {
93
+ const allowed = new Set(options.map((option) => option.key));
94
+ if (field.value_type === "select" && typeof value === "string" && !allowed.has(value)) messages.push(`${field.label} must be one of the allowed options`);
95
+ if (field.value_type === "multiselect" && Array.isArray(value)) {
80
96
  if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) messages.push(`${field.label} has values that are not allowed options`);
81
97
  }
82
98
  }
@@ -85,6 +101,14 @@ function validateValue({ field, value, values, pattern, validator }) {
85
101
  const encoded = canonicalize(value);
86
102
  if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
87
103
  }
104
+ if (field.value_type === "file" && isFormFileRef(value)) {
105
+ if (value.status !== "done") messages.push(`${field.label} upload is not complete`);
106
+ if (rules?.maxSize !== void 0 && value.size > rules.maxSize) messages.push(`${field.label} must be at most ${rules.maxSize} bytes`);
107
+ if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
108
+ contentType: value.content_type,
109
+ accept: rules.accept
110
+ })) messages.push(`${field.label} must be one of the allowed file types`);
111
+ }
88
112
  if (!validator || messages.length > 0) return messages;
89
113
  return normalizeValidatorResult({
90
114
  result: validator({
@@ -127,7 +151,8 @@ const FIELD_TYPES$2 = [
127
151
  "boolean",
128
152
  "select",
129
153
  "multiselect",
130
- "json"
154
+ "json",
155
+ "file"
131
156
  ];
132
157
  const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
133
158
  function readDocumentFromStorage({ storage }) {
@@ -198,7 +223,7 @@ function writeFields({ storage, formKey, fields, fieldDefinitions }) {
198
223
  if (value === void 0) continue;
199
224
  nextForm[field.key] = {
200
225
  value,
201
- type: field.type,
226
+ type: field.value_type,
202
227
  label: field.label,
203
228
  ...field.registryId === void 0 ? {} : { registryId: field.registryId },
204
229
  ...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
@@ -280,7 +305,7 @@ function buildCookiePayloadFromBag({ bag, fieldDefinitions }) {
280
305
  if (value === void 0) continue;
281
306
  payload[field.key] = {
282
307
  value,
283
- type: field.type,
308
+ type: field.value_type,
284
309
  label: field.label,
285
310
  ...field.registryId === void 0 ? {} : { registryId: field.registryId },
286
311
  ...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
@@ -300,10 +325,10 @@ function isStoredField(value) {
300
325
  return true;
301
326
  }
302
327
  function entryMatchesFieldDefinition(entry, field) {
303
- if (entry.type !== field.type || entry.label !== field.label) return false;
328
+ if (entry.type !== field.value_type || entry.label !== field.label) return false;
304
329
  if ((entry.registryId ?? void 0) !== (field.registryId ?? void 0)) return false;
305
330
  if ((entry.protocolFieldId ?? void 0) !== (field.protocolFieldId ?? void 0)) return false;
306
- return FIELD_TYPE_PREDICATES[field.type](entry.value);
331
+ return FIELD_TYPE_PREDICATES[field.value_type](entry.value);
307
332
  }
308
333
  function serializeBrowserCookie(key, value) {
309
334
  const locationRef = globalThis.location;
@@ -436,7 +461,7 @@ function createNoopPersistence() {
436
461
  }
437
462
  //#endregion
438
463
  //#region src/storage/persistence-client.ts
439
- const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
464
+ const PUBLISHABLE_KEY_HEADER$1 = "x-publishable-key";
440
465
  const hcWithType = (...args) => (0, hono_client.hc)(...args);
441
466
  function withTimeout(fetchImpl, timeoutMs) {
442
467
  return async (input, init) => {
@@ -469,7 +494,7 @@ function createApiPersistence(config) {
469
494
  const root = config.baseUrl.replace(/\/+$/, "");
470
495
  const rpc = hcWithType(`${root}/forms`, {
471
496
  fetch: withTimeout(config.fetch, config.timeoutMs),
472
- headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey }
497
+ headers: { [PUBLISHABLE_KEY_HEADER$1]: config.publishableKey }
473
498
  });
474
499
  return {
475
500
  savePartial({ formKey, values }) {
@@ -498,14 +523,88 @@ function resolveDefaultPersistence(config) {
498
523
  return createApiPersistence(resolved);
499
524
  }
500
525
  //#endregion
526
+ //#region src/storage/upload-client.ts
527
+ const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
528
+ function mapUploadErrorMessage(problem, status) {
529
+ const code = problem?.code;
530
+ if (code === "validation.file_too_large") return problem?.detail ?? "Maximum upload size is 25 MiB.";
531
+ if (code === "validation.unsupported_content_type") return problem?.detail ?? "Unsupported file type.";
532
+ if (code === "validation.upload_failed") return problem?.detail ?? "Upload could not be completed.";
533
+ if (code === "service.form_uploads_not_configured") return problem?.detail ?? "File uploads are not configured for this project.";
534
+ if (code === "validation.failed") return problem?.detail ?? "Upload metadata is invalid.";
535
+ return problem?.detail ?? problem?.title ?? `File upload failed (${status}).`;
536
+ }
537
+ function uploadResultToFormFileRef(result) {
538
+ return {
539
+ file_id: result.file_id,
540
+ name: result.name,
541
+ content_type: result.content_type,
542
+ size: result.size,
543
+ status: "done",
544
+ uploaded_at: result.uploaded_at
545
+ };
546
+ }
547
+ function toUploadFetchError(error, path, timeoutMs) {
548
+ if (error instanceof FormsError) return error;
549
+ if (error instanceof Error && error.name === "AbortError") return new FormsError(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
550
+ return new FormsError(`Request to ${path} failed to reach the API`, { cause: error });
551
+ }
552
+ function toUploadDecodeError(error, path) {
553
+ return new FormsError(`Upload response from ${path} could not be decoded`, { cause: error });
554
+ }
555
+ async function uploadFormFile(config, input) {
556
+ const url = `${config.baseUrl.replace(/\/+$/, "")}/forms/v1/public/uploads`;
557
+ const formData = new FormData();
558
+ formData.append("project_id", config.projectId);
559
+ formData.append("app_user_id", config.appUserId);
560
+ formData.append("form_id", input.formId);
561
+ formData.append("field_key", input.fieldKey);
562
+ const fileName = input.fileName ?? (typeof File !== "undefined" && input.file instanceof File ? input.file.name : "upload");
563
+ formData.append("file", input.file, fileName);
564
+ const controller = new AbortController();
565
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs);
566
+ try {
567
+ const res = await config.fetch(url, {
568
+ method: "POST",
569
+ headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey },
570
+ body: formData,
571
+ signal: controller.signal
572
+ });
573
+ clearTimeout(timer);
574
+ if (!res.ok) throw new FormsError(mapUploadErrorMessage(await res.json().catch(() => null), res.status));
575
+ try {
576
+ return uploadResultToFormFileRef(await res.json());
577
+ } catch (error) {
578
+ throw toUploadDecodeError(error, url);
579
+ }
580
+ } catch (error) {
581
+ throw toUploadFetchError(error, url, config.timeoutMs);
582
+ } finally {
583
+ clearTimeout(timer);
584
+ }
585
+ }
586
+ //#endregion
501
587
  //#region src/core/analytics.ts
502
588
  /** The ingest bound on a `data:updated` entry's `value`. */
503
589
  const MAX_VALUE_LENGTH = 1024;
504
590
  /** The ingest bound on a `data:updated` entry's `label`. */
505
591
  const MAX_LABEL_LENGTH = 256;
506
- /** Maps a form field's declared type to the analytics `field:updated` class. */
507
- function mapFieldUpdatedType(type) {
508
- return type;
592
+ /** Maps a form field's declared value type to the analytics `field:updated` class. */
593
+ function mapFieldUpdatedType(valueType) {
594
+ return valueType;
595
+ }
596
+ function resolvedFieldClassification(field) {
597
+ return field.field_type ?? "user_input";
598
+ }
599
+ function isAnalyticsExcludedField(field) {
600
+ return resolvedFieldClassification(field) === "local_only";
601
+ }
602
+ function analyticsPatchEntries({ fields, patch }) {
603
+ const byKey = new Map(fields.map((field) => [field.key, field]));
604
+ return Object.entries(patch).filter(([key]) => {
605
+ const field = byKey.get(key);
606
+ return field === void 0 || !isAnalyticsExcludedField(field);
607
+ });
509
608
  }
510
609
  /**
511
610
  * Stringifies values for `data:updated` entries. `field:updated` carries the
@@ -518,9 +617,13 @@ function formatFieldValue({ value }) {
518
617
  /** One event carrying every key in one `.set()` call. */
519
618
  function buildDataUpdatedEvent({ fields, patch, formId }) {
520
619
  const byKey = new Map(fields.map((field) => [field.key, field]));
620
+ const entries = analyticsPatchEntries({
621
+ fields,
622
+ patch
623
+ });
521
624
  return {
522
625
  event_name: "data:updated",
523
- data: Object.fromEntries(Object.entries(patch).map(([key, value]) => {
626
+ data: Object.fromEntries(entries.map(([key, value]) => {
524
627
  const field = byKey.get(key);
525
628
  return [key, {
526
629
  value: formatFieldValue({
@@ -536,12 +639,15 @@ function buildDataUpdatedEvent({ fields, patch, formId }) {
536
639
  /** One `field:updated` per changed key, emitted alongside `data:updated`. */
537
640
  function buildFieldUpdatedEvents({ fields, patch, formId }) {
538
641
  const byKey = new Map(fields.map((field) => [field.key, field]));
539
- return Object.entries(patch).map(([key, value]) => {
642
+ return analyticsPatchEntries({
643
+ fields,
644
+ patch
645
+ }).map(([key, value]) => {
540
646
  const field = byKey.get(key);
541
647
  const event = {
542
648
  event_name: "field:updated",
543
649
  field_key: key,
544
- field_type: mapFieldUpdatedType(field?.type ?? "text"),
650
+ field_type: mapFieldUpdatedType(field?.value_type ?? "text"),
545
651
  form_id: formId
546
652
  };
547
653
  if (value !== void 0) event.field_value = value;
@@ -559,7 +665,7 @@ function buildFieldUpdatedEvents({ fields, patch, formId }) {
559
665
  * values passes through.
560
666
  */
561
667
  function normalizeExclusiveSelection({ next, previous, options }) {
562
- const exclusive = new Set(options.filter((option) => option.exclusive === true).map((option) => option.value));
668
+ const exclusive = new Set(options.filter((option) => option.exclusive === true).map((option) => option.key));
563
669
  if (exclusive.size === 0) return next;
564
670
  const previousValues = new Set(previous);
565
671
  const added = next.filter((entry) => !previousValues.has(entry));
@@ -577,7 +683,8 @@ const FIELD_TYPES = [
577
683
  "boolean",
578
684
  "select",
579
685
  "multiselect",
580
- "json"
686
+ "json",
687
+ "file"
581
688
  ];
582
689
  const VALIDATION_RULES = [
583
690
  "required",
@@ -587,17 +694,29 @@ const VALIDATION_RULES = [
587
694
  "max",
588
695
  "pattern",
589
696
  "oneOf",
697
+ "accept",
698
+ "maxSize",
590
699
  "custom"
591
700
  ];
592
701
  const NUMERIC_RULES = [
593
702
  "minLength",
594
703
  "maxLength",
595
704
  "min",
596
- "max"
705
+ "max",
706
+ "maxSize"
707
+ ];
708
+ const FILE_ONLY_RULES = ["accept", "maxSize"];
709
+ const FIELD_CLASSIFICATIONS = [
710
+ "user_input",
711
+ "computed",
712
+ "local_only"
597
713
  ];
598
- const OPTION_KEYS = [
599
- "value",
600
- "label",
714
+ const SNAKE_CASE_FIELD_KEY = /^[a-z][a-z0-9_]*$/;
715
+ /** Keys allowed on a raw option object before normalization. */
716
+ const OPTION_INPUT_KEYS = [
717
+ "key",
718
+ "display_text",
719
+ "display_description",
601
720
  "exclusive"
602
721
  ];
603
722
  /** The two field types whose choices are declared through a field-level `options`. */
@@ -633,82 +752,148 @@ function validateAndCompile({ schema }) {
633
752
  if (fields.length === 0) throw new SchemaError("schema.fields: must declare at least one field");
634
753
  const patterns = /* @__PURE__ */ new Map();
635
754
  const seenKeys = /* @__PURE__ */ new Set();
755
+ const compiledFields = [];
636
756
  fields.forEach((field, index) => {
637
- validateField({
757
+ compiledFields.push(compileField({
638
758
  field,
639
759
  path: `schema.fields[${index}]`,
640
760
  seenKeys,
641
761
  patterns
642
- });
762
+ }));
643
763
  });
644
764
  return {
645
765
  formKey,
646
- fields: [...fields],
766
+ fields: compiledFields,
647
767
  patterns
648
768
  };
649
769
  }
650
- function validateField({ field, path, seenKeys, patterns }) {
770
+ function compileField({ field, path, seenKeys, patterns }) {
651
771
  if (!isObjectLike(field)) throw new SchemaError(`${path}: must be an object`);
652
772
  const key = field["key"];
653
773
  if (typeof key !== "string" || key.trim() === "") throw new SchemaError(`${path}.key: must be a non-empty string`);
774
+ if (!SNAKE_CASE_FIELD_KEY.test(key)) throw new SchemaError(`${path}.key: must be lowercase snake_case`);
654
775
  if (key.length > MAX_FIELD_KEY_LENGTH) throw new SchemaError(`${path}.key: must be at most ${MAX_FIELD_KEY_LENGTH} characters (received ${key.length})`);
655
776
  if (seenKeys.has(key)) throw new SchemaError(`${path}.key: duplicate field key "${key}" in this form`);
656
777
  seenKeys.add(key);
657
778
  const label = field["label"];
658
779
  if (typeof label !== "string" || label.trim() === "") throw new SchemaError(`${path}.label: must be a non-empty string`);
659
- const type = field["type"];
660
- if (typeof type !== "string" || !FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.type: must be one of ${FIELD_TYPES.join(", ")}`);
780
+ if (field["type"] !== void 0) throw new SchemaError(`${path}.type: is not supported; use value_type`);
781
+ const valueTypeRaw = field["value_type"];
782
+ if (typeof valueTypeRaw !== "string" || valueTypeRaw.trim() === "") throw new SchemaError(`${path}.value_type: must be a non-empty string`);
783
+ const valueType = valueTypeRaw;
784
+ if (!FIELD_TYPES.includes(valueType)) throw new SchemaError(`${path}.value_type: must be one of ${FIELD_TYPES.join(", ")}`);
785
+ const displayText = field["display_text"];
786
+ if (displayText !== void 0) {
787
+ if (typeof displayText !== "string" || displayText.trim() === "") throw new SchemaError(`${path}.display_text: must be a non-empty string`);
788
+ }
789
+ const displayDescription = field["display_description"];
790
+ if (displayDescription !== void 0) {
791
+ if (typeof displayDescription !== "string" || displayDescription.trim() === "") throw new SchemaError(`${path}.display_description: must be a non-empty string`);
792
+ }
793
+ const fieldTypeRaw = field["field_type"];
794
+ let fieldType = "user_input";
795
+ if (fieldTypeRaw !== void 0) {
796
+ if (typeof fieldTypeRaw !== "string" || !FIELD_CLASSIFICATIONS.includes(fieldTypeRaw)) throw new SchemaError(`${path}.field_type: must be one of ${FIELD_CLASSIFICATIONS.join(", ")}`);
797
+ fieldType = fieldTypeRaw;
798
+ }
661
799
  const registryId = field["registryId"];
662
800
  if (registryId !== void 0 && typeof registryId !== "string") throw new SchemaError(`${path}.registryId: must be a string`);
663
801
  const protocolFieldId = field["protocolFieldId"];
664
802
  if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
665
803
  const includeInCookies = field["includeInCookies"];
666
804
  if (includeInCookies !== void 0 && typeof includeInCookies !== "boolean") throw new SchemaError(`${path}.includeInCookies: must be a boolean`);
667
- validateOptions({
805
+ const normalizedOptions = normalizeOptions({
668
806
  field,
669
- type,
807
+ valueType,
670
808
  path
671
809
  });
672
810
  const validations = field["validations"];
673
- if (validations === void 0) return;
674
- validateValidations({
675
- validations,
676
- type,
677
- path: `${path}.validations`
678
- });
679
- if (!isObjectLike(validations)) return;
680
- const pattern = validations["pattern"];
681
- if (typeof pattern !== "string") return;
682
- patterns.set(key, compilePattern({
683
- pattern,
684
- path: `${path}.validations.pattern`
685
- }));
811
+ if (validations !== void 0) {
812
+ validateValidations({
813
+ validations,
814
+ type: valueType,
815
+ path: `${path}.validations`
816
+ });
817
+ if (isObjectLike(validations)) {
818
+ const pattern = validations["pattern"];
819
+ if (typeof pattern === "string") patterns.set(key, compilePattern({
820
+ pattern,
821
+ path: `${path}.validations.pattern`
822
+ }));
823
+ }
824
+ }
825
+ const compiled = {
826
+ ...field,
827
+ key,
828
+ label,
829
+ value_type: valueType
830
+ };
831
+ delete compiled["type"];
832
+ compiled["field_type"] = fieldType;
833
+ if (displayText !== void 0) compiled["display_text"] = displayText;
834
+ if (displayDescription !== void 0) compiled["display_description"] = displayDescription;
835
+ if (normalizedOptions !== void 0) compiled["options"] = normalizedOptions;
836
+ if (validations !== void 0) compiled["validations"] = cloneValidations(validations);
837
+ return compiled;
838
+ }
839
+ function cloneValidations(validations) {
840
+ if (!isObjectLike(validations)) return validations;
841
+ const cloned = {};
842
+ for (const [ruleKey, ruleValue] of Object.entries(validations)) {
843
+ if (ruleKey === "custom") {
844
+ cloned[ruleKey] = ruleValue;
845
+ continue;
846
+ }
847
+ if (ruleKey === "oneOf" || ruleKey === "accept") {
848
+ cloned[ruleKey] = Array.isArray(ruleValue) ? [...ruleValue] : ruleValue;
849
+ continue;
850
+ }
851
+ cloned[ruleKey] = ruleValue;
852
+ }
853
+ return cloned;
686
854
  }
687
- function validateOptions({ field, type, path }) {
855
+ function normalizeOptions({ field, valueType, path }) {
688
856
  const options = field["options"];
689
- if (options === void 0) return;
690
- if (!OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.options: only a select or multiselect field may declare options`);
857
+ if (options === void 0) return void 0;
858
+ if (!OPTION_FIELD_TYPES.includes(valueType)) throw new SchemaError(`${path}.options: only a select or multiselect field may declare options`);
691
859
  if (!(Array.isArray(options) && options.length > 0)) throw new SchemaError(`${path}.options: must be a non-empty array`);
692
860
  const entries = options;
693
- const seenValues = /* @__PURE__ */ new Set();
861
+ const seenKeys = /* @__PURE__ */ new Set();
862
+ const normalized = [];
694
863
  entries.forEach((option, index) => {
695
864
  const at = `${path}.options[${index}]`;
696
865
  if (!isObjectLike(option)) throw new SchemaError(`${at}: must be an object`);
697
- for (const optionKey of Object.keys(option)) if (!OPTION_KEYS.includes(optionKey)) throw new SchemaError(`${at}.${optionKey}: unknown option key; expected one of ${OPTION_KEYS.join(", ")}`);
698
- const value = option["value"];
699
- if (typeof value !== "string" || value.trim() === "") throw new SchemaError(`${at}.value: must be a non-empty string`);
700
- const label = option["label"];
701
- if (label !== void 0 && typeof label !== "string") throw new SchemaError(`${at}.label: must be a string`);
866
+ for (const optionKey of Object.keys(option)) if (!OPTION_INPUT_KEYS.includes(optionKey)) throw new SchemaError(`${at}.${optionKey}: unknown option key; expected one of ${OPTION_INPUT_KEYS.join(", ")}`);
867
+ const optionKey = option["key"];
868
+ const displayTextRaw = option["display_text"];
869
+ const displayDescription = option["display_description"];
870
+ if (typeof optionKey !== "string" || optionKey.trim() === "") throw new SchemaError(`${at}.key: must be a non-empty string`);
871
+ if (typeof displayTextRaw !== "string" || displayTextRaw.trim() === "") throw new SchemaError(`${at}.display_text: must be a non-empty string`);
872
+ const key = optionKey;
873
+ const displayText = displayTextRaw;
874
+ if (displayDescription !== void 0) {
875
+ if (typeof displayDescription !== "string" || displayDescription.trim() === "") throw new SchemaError(`${at}.display_description: must be a non-empty string`);
876
+ }
702
877
  const exclusive = option["exclusive"];
703
878
  if (exclusive !== void 0 && typeof exclusive !== "boolean") throw new SchemaError(`${at}.exclusive: must be a boolean`);
704
- if (exclusive === true && type === "select") throw new SchemaError(`${at}.exclusive: only a multiselect field may declare an exclusive option`);
705
- if (seenValues.has(value)) throw new SchemaError(`${at}.value: duplicate option value "${value}" on this field`);
706
- seenValues.add(value);
879
+ if (exclusive !== void 0 && valueType === "select") throw new SchemaError(`${at}.exclusive: only a multiselect field may declare an exclusive option`);
880
+ if (seenKeys.has(key)) throw new SchemaError(`${at}.key: duplicate option key "${key}" on this field`);
881
+ seenKeys.add(key);
882
+ normalized.push({
883
+ key,
884
+ display_text: displayText,
885
+ ...displayDescription === void 0 ? {} : { display_description: displayDescription },
886
+ ...exclusive === void 0 ? {} : { exclusive }
887
+ });
707
888
  });
889
+ return normalized;
708
890
  }
709
891
  function validateValidations({ validations, type, path }) {
710
892
  if (!isObjectLike(validations)) throw new SchemaError(`${path}: must be an object`);
711
- for (const rule of Object.keys(validations)) if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
893
+ for (const rule of Object.keys(validations)) {
894
+ if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
895
+ if (type !== "file" && FILE_ONLY_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: is only valid for file fields`);
896
+ }
712
897
  const required = validations["required"];
713
898
  if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
714
899
  for (const rule of NUMERIC_RULES) {
@@ -724,6 +909,10 @@ function validateValidations({ validations, type, path }) {
724
909
  const oneOf = validations["oneOf"];
725
910
  if (oneOf !== void 0 && OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.oneOf: not allowed on a ${type} field; declare choices through the field's options instead`);
726
911
  if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
912
+ const accept = validations["accept"];
913
+ if (accept !== void 0 && !(Array.isArray(accept) && accept.length > 0 && accept.every((entry) => typeof entry === "string" && entry.trim() !== ""))) throw new SchemaError(`${path}.accept: must be a non-empty array of strings`);
914
+ const maxSize = validations["maxSize"];
915
+ if (maxSize !== void 0 && !(typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0)) throw new SchemaError(`${path}.maxSize: must be a positive finite number`);
727
916
  const pattern = validations["pattern"];
728
917
  if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
729
918
  const custom = validations["custom"];
@@ -957,16 +1146,22 @@ function resolveInitConfig({ core, customValidations, serverFormData }) {
957
1146
  }
958
1147
  return registry;
959
1148
  }
960
- function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, storage, persistence }) {
1149
+ function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, fetch: fetchImpl, storage, persistence }) {
961
1150
  if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
962
1151
  const registry = resolveInitConfig({
963
1152
  core,
964
1153
  customValidations,
965
1154
  serverFormData
966
1155
  });
1156
+ const uploadConfig = resolvePersistenceConfig({
1157
+ core,
1158
+ baseUrl,
1159
+ fetch: fetchImpl
1160
+ });
967
1161
  const resolvedPersistence = persistence ?? resolveDefaultPersistence({
968
1162
  core,
969
- baseUrl
1163
+ baseUrl,
1164
+ fetch: fetchImpl
970
1165
  });
971
1166
  const instances = /* @__PURE__ */ new Map();
972
1167
  return { getForm({ formId }) {
@@ -978,6 +1173,7 @@ function createFormsClient({ core, customValidations, serverFormData, analyticsI
978
1173
  analyticsInstance,
979
1174
  storage,
980
1175
  persistence: resolvedPersistence,
1176
+ uploadConfig,
981
1177
  schema: entry.schema,
982
1178
  customValidations: entry.customValidations,
983
1179
  serverFormData: entry.serverFormData,
@@ -994,7 +1190,7 @@ function mergeInitialBag({ fromStorage, serverFormData }) {
994
1190
  ...serverFormData
995
1191
  };
996
1192
  }
997
- function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations, serverFormData, projectId }) {
1193
+ function createFormInstance({ analyticsInstance, storage, persistence, uploadConfig, schema, customValidations, serverFormData, projectId }) {
998
1194
  const resolved = resolveForm({ schema: mergeCustomValidations({
999
1195
  schema,
1000
1196
  customValidations
@@ -1100,7 +1296,7 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1100
1296
  const previousBag = readBag();
1101
1297
  for (const [key, value] of Object.entries(changes)) {
1102
1298
  const field = declared.get(key);
1103
- if (!field || field.type !== "multiselect" || !field.options) continue;
1299
+ if (!field || field.value_type !== "multiselect" || !field.options) continue;
1104
1300
  if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) continue;
1105
1301
  const stored = previousBag[key];
1106
1302
  changes[key] = [...normalizeExclusiveSelection({
@@ -1186,15 +1382,22 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1186
1382
  ok: true,
1187
1383
  errors: noErrors()
1188
1384
  });
1189
- return analyticsInstance.trackEvent([buildDataUpdatedEvent({
1385
+ const dataUpdated = buildDataUpdatedEvent({
1190
1386
  fields: resolved.fields,
1191
1387
  patch: changes,
1192
1388
  formId: schema.id
1193
- }), ...buildFieldUpdatedEvents({
1389
+ });
1390
+ const fieldUpdated = buildFieldUpdatedEvents({
1194
1391
  fields: resolved.fields,
1195
1392
  patch: changes,
1196
1393
  formId: schema.id
1197
- })]).then(() => ({
1394
+ });
1395
+ const analyticsEvents = [...Object.keys(dataUpdated.data).length > 0 ? [dataUpdated] : [], ...fieldUpdated];
1396
+ if (analyticsEvents.length === 0) return Promise.resolve({
1397
+ ok: true,
1398
+ errors: noErrors()
1399
+ });
1400
+ return analyticsInstance.trackEvent(analyticsEvents).then(() => ({
1198
1401
  ok: true,
1199
1402
  errors: noErrors()
1200
1403
  })).catch((error) => ({
@@ -1228,6 +1431,11 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1228
1431
  return get(fieldKey);
1229
1432
  });
1230
1433
  const getAll = () => narrow(state.bag);
1434
+ const getFieldByKey = (key) => {
1435
+ const fieldKey = key;
1436
+ if (!declared.has(fieldKey)) return void 0;
1437
+ return declared.get(fieldKey);
1438
+ };
1231
1439
  const submit = () => {
1232
1440
  const snapshot = narrow(readBag());
1233
1441
  const values = snapshot;
@@ -1370,16 +1578,39 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1370
1578
  listeners.delete(listener);
1371
1579
  };
1372
1580
  };
1581
+ const uploadFile = async ({ key, file, fileName }) => {
1582
+ const field = declared.get(key);
1583
+ if (!field) throw new FormsError(`Unknown field: ${key}`);
1584
+ if (field.value_type !== "file") throw new FormsError(`Field "${key}" is not a file field`);
1585
+ if (!uploadConfig) throw new FormsError("File uploads require a valid publishable key and initialized Embeddables core instance.");
1586
+ const rules = field.validations;
1587
+ const contentType = file.type || "application/octet-stream";
1588
+ const byteLength = file.size;
1589
+ if (byteLength > 26214400) throw new FormsError("Maximum upload size is 25 MiB.");
1590
+ if (rules?.maxSize !== void 0 && byteLength > rules.maxSize) throw new FormsError(`${field.label} must be at most ${rules.maxSize} bytes`);
1591
+ if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
1592
+ contentType,
1593
+ accept: rules.accept
1594
+ })) throw new FormsError(`${field.label} must be one of the allowed file types`);
1595
+ return uploadFormFile(uploadConfig, {
1596
+ formId: resolved.formKey,
1597
+ fieldKey: key,
1598
+ file,
1599
+ fileName
1600
+ });
1601
+ };
1373
1602
  return {
1374
1603
  key: schema.id,
1375
1604
  set,
1376
1605
  get,
1606
+ getFieldByKey,
1377
1607
  getValueByProtocolFieldId,
1378
1608
  getAll,
1379
1609
  submit,
1380
1610
  validate,
1381
1611
  errors: () => freeze(state.errors),
1382
1612
  clear,
1613
+ uploadFile,
1383
1614
  subscribe
1384
1615
  };
1385
1616
  }
@@ -1439,4 +1670,4 @@ Object.defineProperty(exports, "seedServerFormsStorageFromCookies", {
1439
1670
  }
1440
1671
  });
1441
1672
 
1442
- //# sourceMappingURL=form-BOHdxeuO.cjs.map
1673
+ //# sourceMappingURL=form-lmr-O7j7.cjs.map