@embeddables/forms 0.2.0 → 0.2.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 CHANGED
@@ -27,7 +27,7 @@ Example:
27
27
  ```tsx
28
28
  import { EmbeddablesProvider } from '@embeddables/core/react'
29
29
  import { analytics } from '@embeddables/analytics/react'
30
- import { forms, useForm, useFormField } from '@embeddables/forms/react'
30
+ import { forms, useForm, useFormField, useFormFileUpload } from '@embeddables/forms/react'
31
31
  import { config } from './embeddables/_dist/config.ts'
32
32
 
33
33
  function SignupField() {
@@ -85,6 +85,35 @@ export function App() {
85
85
  - One live `FormInstance` per `schema.id` is shared across hooks in the same client.
86
86
  - Imperative consumers can also call `form.subscribe(listener)` to observe value and error changes.
87
87
 
88
+ ### File inputs
89
+
90
+ For `type: file` fields, use `useFormFileUpload`. It uploads through the publishable-key API, commits the returned `FormFileRef`, and exposes `inputProps` for a native `<input type="file">`.
91
+
92
+ Example:
93
+
94
+ ```tsx
95
+ function IdPhotoField() {
96
+ const { form } = useForm({ formId: 'intake' })
97
+ const { value, error, isLoading, uploadError, inputProps } = useFormFileUpload({
98
+ form,
99
+ key: 'id_photo',
100
+ })
101
+
102
+ return (
103
+ <label>
104
+ ID photo
105
+ <input {...inputProps} accept="image/jpeg,image/png" />
106
+ {isLoading && <span>Uploading…</span>}
107
+ {uploadError && <span>{uploadError}</span>}
108
+ {error?.[0]}
109
+ {value?.name && <span>{value.name}</span>}
110
+ </label>
111
+ )
112
+ }
113
+ ```
114
+
115
+ Imperative equivalent: `const ref = await form.uploadFile({ key: 'id_photo', file })` then `await form.set({ id_photo: ref })`.
116
+
88
117
  ## Quick start
89
118
 
90
119
  ```typescript
@@ -127,10 +156,15 @@ from `embeddables/_dist` are already typed.
127
156
  ## Schema
128
157
 
129
158
  Each form is one object: `id`, optional `name`, and `fields`. Supported field
130
- types: `text`, `email`, `number`, `boolean`, `select`, `multiselect`, `json`.
159
+ types: `text`, `email`, `number`, `boolean`, `select`, `multiselect`, `json`,
160
+ `file`.
131
161
 
132
162
  Declarative rules: `required`, `minLength`, `maxLength`, `min`, `max`,
133
- `pattern`, `oneOf`, and optional synchronous `validations.custom`.
163
+ `pattern`, `oneOf`, and optional synchronous `validations.custom`. File fields
164
+ also accept `validations.accept` (MIME list) and `validations.maxSize` (bytes).
165
+ Compatible field types may declare `protocolFieldId` to link a form field to a
166
+ protocol question. `json` fields cannot. `FormFileRef` is exported for typed
167
+ file values.
134
168
 
135
169
  A `select` or `multiselect` field declares its choices in a field-level `options`
136
170
  array of `{ value, label?, exclusive? }`, and the field's `type` — not the name of
@@ -159,6 +193,7 @@ on `initForms` / `forms()`.
159
193
  | `getValueByProtocolFieldId(protocolFieldId)` | Read by schema `protocolFieldId` (typed like `get()` for the backing field; `undefined` if unknown or unset) |
160
194
  | `validate({ … })` | Check values without writing or tracking |
161
195
  | `submit()` | Validate all fields, best-effort durable R2 write, and emit `form:submitted` when analytics is configured |
196
+ | `uploadFile({ key, file, fileName? })` | Upload bytes for a `type: file` field; returns `FormFileRef` (caller commits with `.set()`) |
162
197
  | `errors()` | Current validation messages |
163
198
  | `clear()` | Remove this form's stored values |
164
199
 
@@ -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
  /**
@@ -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.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 }) {
@@ -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) => 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,6 +523,67 @@ 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;
@@ -577,7 +663,8 @@ const FIELD_TYPES = [
577
663
  "boolean",
578
664
  "select",
579
665
  "multiselect",
580
- "json"
666
+ "json",
667
+ "file"
581
668
  ];
582
669
  const VALIDATION_RULES = [
583
670
  "required",
@@ -587,14 +674,18 @@ const VALIDATION_RULES = [
587
674
  "max",
588
675
  "pattern",
589
676
  "oneOf",
677
+ "accept",
678
+ "maxSize",
590
679
  "custom"
591
680
  ];
592
681
  const NUMERIC_RULES = [
593
682
  "minLength",
594
683
  "maxLength",
595
684
  "min",
596
- "max"
685
+ "max",
686
+ "maxSize"
597
687
  ];
688
+ const FILE_ONLY_RULES = ["accept", "maxSize"];
598
689
  const OPTION_KEYS = [
599
690
  "value",
600
691
  "label",
@@ -708,7 +799,10 @@ function validateOptions({ field, type, path }) {
708
799
  }
709
800
  function validateValidations({ validations, type, path }) {
710
801
  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(", ")}`);
802
+ for (const rule of Object.keys(validations)) {
803
+ if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
804
+ if (type !== "file" && FILE_ONLY_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: is only valid for file fields`);
805
+ }
712
806
  const required = validations["required"];
713
807
  if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
714
808
  for (const rule of NUMERIC_RULES) {
@@ -724,6 +818,10 @@ function validateValidations({ validations, type, path }) {
724
818
  const oneOf = validations["oneOf"];
725
819
  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
820
  if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
821
+ const accept = validations["accept"];
822
+ 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`);
823
+ const maxSize = validations["maxSize"];
824
+ if (maxSize !== void 0 && !(typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0)) throw new SchemaError(`${path}.maxSize: must be a positive finite number`);
727
825
  const pattern = validations["pattern"];
728
826
  if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
729
827
  const custom = validations["custom"];
@@ -957,16 +1055,22 @@ function resolveInitConfig({ core, customValidations, serverFormData }) {
957
1055
  }
958
1056
  return registry;
959
1057
  }
960
- function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, storage, persistence }) {
1058
+ function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, fetch: fetchImpl, storage, persistence }) {
961
1059
  if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
962
1060
  const registry = resolveInitConfig({
963
1061
  core,
964
1062
  customValidations,
965
1063
  serverFormData
966
1064
  });
1065
+ const uploadConfig = resolvePersistenceConfig({
1066
+ core,
1067
+ baseUrl,
1068
+ fetch: fetchImpl
1069
+ });
967
1070
  const resolvedPersistence = persistence ?? resolveDefaultPersistence({
968
1071
  core,
969
- baseUrl
1072
+ baseUrl,
1073
+ fetch: fetchImpl
970
1074
  });
971
1075
  const instances = /* @__PURE__ */ new Map();
972
1076
  return { getForm({ formId }) {
@@ -978,6 +1082,7 @@ function createFormsClient({ core, customValidations, serverFormData, analyticsI
978
1082
  analyticsInstance,
979
1083
  storage,
980
1084
  persistence: resolvedPersistence,
1085
+ uploadConfig,
981
1086
  schema: entry.schema,
982
1087
  customValidations: entry.customValidations,
983
1088
  serverFormData: entry.serverFormData,
@@ -994,7 +1099,7 @@ function mergeInitialBag({ fromStorage, serverFormData }) {
994
1099
  ...serverFormData
995
1100
  };
996
1101
  }
997
- function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations, serverFormData, projectId }) {
1102
+ function createFormInstance({ analyticsInstance, storage, persistence, uploadConfig, schema, customValidations, serverFormData, projectId }) {
998
1103
  const resolved = resolveForm({ schema: mergeCustomValidations({
999
1104
  schema,
1000
1105
  customValidations
@@ -1370,6 +1475,27 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1370
1475
  listeners.delete(listener);
1371
1476
  };
1372
1477
  };
1478
+ const uploadFile = async ({ key, file, fileName }) => {
1479
+ const field = declared.get(key);
1480
+ if (!field) throw new FormsError(`Unknown field: ${key}`);
1481
+ if (field.type !== "file") throw new FormsError(`Field "${key}" is not a file field`);
1482
+ if (!uploadConfig) throw new FormsError("File uploads require a valid publishable key and initialized Embeddables core instance.");
1483
+ const rules = field.validations;
1484
+ const contentType = file.type || "application/octet-stream";
1485
+ const byteLength = file.size;
1486
+ if (byteLength > 26214400) throw new FormsError("Maximum upload size is 25 MiB.");
1487
+ if (rules?.maxSize !== void 0 && byteLength > rules.maxSize) throw new FormsError(`${field.label} must be at most ${rules.maxSize} bytes`);
1488
+ if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
1489
+ contentType,
1490
+ accept: rules.accept
1491
+ })) throw new FormsError(`${field.label} must be one of the allowed file types`);
1492
+ return uploadFormFile(uploadConfig, {
1493
+ formId: resolved.formKey,
1494
+ fieldKey: key,
1495
+ file,
1496
+ fileName
1497
+ });
1498
+ };
1373
1499
  return {
1374
1500
  key: schema.id,
1375
1501
  set,
@@ -1380,10 +1506,11 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
1380
1506
  validate,
1381
1507
  errors: () => freeze(state.errors),
1382
1508
  clear,
1509
+ uploadFile,
1383
1510
  subscribe
1384
1511
  };
1385
1512
  }
1386
1513
  //#endregion
1387
1514
  export { FORM_DATA_KEY as a, SchemaError as c, seedServerFormsStorageFromCookies as i, ValidatorError as l, initForms as n, createMemoryFormsStorage as o, createNoopPersistence as r, FormsError as s, createFormsClient as t };
1388
1515
 
1389
- //# sourceMappingURL=form-Bt5pwVP6.js.map
1516
+ //# sourceMappingURL=form-7wmC3G_q.js.map