@marktiderman/genesis-forms-web 0.5.0 → 0.6.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
@@ -159,6 +159,28 @@ sends (it says "Wait for the photo to finish uploading."). Without `onUpload`, o
159
159
  `UploadContext` around the form, a photo field says photos can't be added here. `photoFileProblem`,
160
160
  `photoFileType` and `photoAccept` are the renderer's own checks, for a custom one.
161
161
 
162
+ `signedUploadHost` is an upload host for genesis-forms/server's `createUploadHandler`: for each file
163
+ it asks your endpoint for a signed upload, sends the bytes with `send`, and resolves to the
164
+ server's reference. A refusal rejects with the server's words. `url(ref)` reads a stored file
165
+ through your `readUrl`, null when it can't (for thumbnails in a staff view):
166
+
167
+ ```tsx
168
+ const host = signedUploadHost({
169
+ endpoint: `/api/upload/${slug}`,
170
+ claim: () => (editToken ? { edit: editToken } : { attemptId }), // read at each upload
171
+ // Supabase Storage; without `send`, the file is PUT to `upload.url`.
172
+ send: async (file, upload, contentType) => {
173
+ const { error } = await supabase.storage
174
+ .from(upload.bucket!)
175
+ .uploadToSignedUrl(upload.path, upload.token, file, { contentType });
176
+ if (error) throw error;
177
+ },
178
+ readUrl: async (ref) => signedReadUrl(ref), // your storage, or null
179
+ });
180
+
181
+ <Form form={feedback} onUpload={host.upload} onSubmit={send} />;
182
+ ```
183
+
162
184
  A **`calculated`** field shows the value the engine worked out from other answers, read-only, in an
163
185
  `<output>` named by the question that announces changes politely. It follows the field's `format`
164
186
  and `decimals`, and shows a dash until there is a value.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ComponentType } from 'react';
3
- import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, LinkParams, FormState, FormProgress, FieldBlock, PhotoRef } from '@marktiderman/genesis-forms';
3
+ import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, LinkParams, FormState, FormProgress, FieldBlock, PhotoRef, SignedUpload } from '@marktiderman/genesis-forms';
4
4
  import * as _marktiderman_genesis_forms_react from '@marktiderman/genesis-forms-react';
5
5
  import { RendererRegistry, FieldRendererProps, RendererEntry } from '@marktiderman/genesis-forms-react';
6
6
 
@@ -298,6 +298,8 @@ declare const defaultRegistry: _marktiderman_genesis_forms_react.RendererRegistr
298
298
  */
299
299
  interface UploadHost {
300
300
  upload(file: File, field: FieldBlock): Promise<PhotoRef>;
301
+ /** A link to read a stored file (the host's storage signs it), or null when it can't be read. */
302
+ url?(ref: PhotoRef, field?: FieldBlock): Promise<string | null>;
301
303
  }
302
304
  /** Provide it around `<Form>` (or pass `onUpload`) so `photo` fields can store files. */
303
305
  declare const UploadContext: react.Context<UploadHost | null>;
@@ -320,6 +322,48 @@ declare function photoFileProblem(field: FieldBlock, file: {
320
322
  /** The `accept` attribute for a field: its MIME types, plus HEIC's file names for browsers that give it no type. */
321
323
  declare function photoAccept(field: FieldBlock): string;
322
324
 
325
+ /**
326
+ * Who is uploading: a new response's visit (`attemptId`, a UUID the page keeps for the
327
+ * visit), or an edit link's token while changing a sent response.
328
+ */
329
+ type UploadClaim = {
330
+ attemptId: string;
331
+ } | {
332
+ edit: string;
333
+ };
334
+ interface SignedUploadHostOptions {
335
+ /** The app's upload endpoint (`createUploadHandler`'s route), or one per field. */
336
+ endpoint: string | ((field: FieldBlock) => string);
337
+ /** Who is uploading, read at each upload. */
338
+ claim: UploadClaim | (() => UploadClaim);
339
+ /**
340
+ * Sends the bytes where the server signed. Default: PUT the file to `upload.url`
341
+ * (with its type), which a store that signs plain URLs sets. Supabase Storage:
342
+ * `supabase.storage.from(upload.bucket).uploadToSignedUrl(upload.path, upload.token, file, { contentType })`.
343
+ * A throw (or rejection) is the upload failing.
344
+ */
345
+ send?: ((file: File, upload: SignedUpload, contentType: string) => Promise<void>) | undefined;
346
+ /**
347
+ * A link to read a stored file, for thumbnails and staff views: the app's storage signs
348
+ * it. Null when the reader can't open it. Without it, `url` resolves null.
349
+ */
350
+ readUrl?: ((ref: PhotoRef, field?: FieldBlock) => Promise<string | null>) | undefined;
351
+ /** Default the global `fetch`. */
352
+ fetch?: typeof fetch | undefined;
353
+ }
354
+ /** An `UploadHost` that always has `url`. */
355
+ interface SignedUploadHost extends UploadHost {
356
+ url(ref: PhotoRef, field?: FieldBlock): Promise<string | null>;
357
+ }
358
+ /**
359
+ * The browser half of the upload handshake (genesis-forms/server's `createUploadHandler`):
360
+ * for each file it asks `endpoint` for a signed upload (`UploadRequest` as JSON), sends
361
+ * the bytes with `send`, and resolves to the reference the server handed back, which is
362
+ * all the answer keeps. A refusal rejects with the server's words. Pass it as `<Form
363
+ * onUpload={host.upload}>` or in an `UploadContext`.
364
+ */
365
+ declare function signedUploadHost(options: SignedUploadHostOptions): SignedUploadHost;
366
+
323
367
  /**
324
368
  * The video or audio a form is about, as the host app plays it. `media-notes`
325
369
  * fields read it: with `currentTime` the add row starts at the current moment
@@ -370,4 +414,4 @@ declare function hideQueryOnLoad(win: HiddenQueryWindow, isReload: boolean, now?
370
414
  /** Whether this page load was a reload (Navigation Timing), in the browser; false elsewhere. */
371
415
  declare const wasReload: () => boolean;
372
416
 
373
- export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, HIDDEN_QUERY_KEEP_MS, type HeaderSlotProps, type HiddenQueryWindow, type MediaTime, MediaTimeContext, type NavigationSlotProps, PROGRESS_DELAY_MS, type ProgressSlotProps, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
417
+ export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, HIDDEN_QUERY_KEEP_MS, type HeaderSlotProps, type HiddenQueryWindow, type MediaTime, MediaTimeContext, type NavigationSlotProps, PROGRESS_DELAY_MS, type ProgressSlotProps, type SignedUploadHost, type SignedUploadHostOptions, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, type UploadClaim, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, signedUploadHost, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ function FieldChrome({ props, children, className }) {
34
34
  const description = field.description ? props.engine.text(field.description) : void 0;
35
35
  const questionSize = pageFocus ? "text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl" : pageClassic ? "text-lg font-medium leading-snug" : "text-base";
36
36
  const body = /* @__PURE__ */ jsxs(Fragment, { children: [
37
- description ? /* @__PURE__ */ jsx(FormDescription, { id: `${inputId}-desc`, className: cn(pageFocus && "text-base sm:text-lg text-pretty"), children: description }) : null,
37
+ description ? /* @__PURE__ */ jsx(FormDescription, { id: `${inputId}-desc`, className: cn("whitespace-pre-line", pageFocus && "text-base sm:text-lg text-pretty"), children: description }) : null,
38
38
  pageFocus ? /* @__PURE__ */ jsx("div", { className: "pt-6 sm:pt-8", children }) : children,
39
39
  pageFocus ? (
40
40
  // A calm note under the answer, Typeform style; the question keeps its colour.
@@ -1976,7 +1976,7 @@ function DefaultHeader({ title, description, image, requiredNote, className }) {
1976
1976
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
1977
1977
  image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
1978
1978
  /* @__PURE__ */ jsx("h1", { className: "text-sm font-medium text-muted-foreground", children: title }),
1979
- description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null,
1979
+ description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground whitespace-pre-line", children: description }) : null,
1980
1980
  requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote }) : null
1981
1981
  ] });
1982
1982
  }
@@ -1993,7 +1993,7 @@ function DefaultHeader({ title, description, image, requiredNote, className }) {
1993
1993
  image ? /* @__PURE__ */ jsx(HeaderImage, { image }) : null,
1994
1994
  /* @__PURE__ */ jsxs("div", { className: cn("px-5 py-6 sm:px-8 sm:py-8", image && "border-t-8 border-t-primary"), children: [
1995
1995
  /* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
1996
- description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null,
1996
+ description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty whitespace-pre-line", children: description }) : null,
1997
1997
  requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "mt-4" }) : null
1998
1998
  ] })
1999
1999
  ]
@@ -2003,7 +2003,7 @@ function DefaultHeader({ title, description, image, requiredNote, className }) {
2003
2003
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
2004
2004
  image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
2005
2005
  /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: title }),
2006
- description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null,
2006
+ description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground whitespace-pre-line", children: description }) : null,
2007
2007
  requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "pt-1" }) : null
2008
2008
  ] });
2009
2009
  }
@@ -2166,13 +2166,13 @@ function DefaultEnding({ title, description, actions, className }) {
2166
2166
  children: title
2167
2167
  }
2168
2168
  ),
2169
- description ? /* @__PURE__ */ jsx("p", { className: "max-w-md text-lg text-muted-foreground text-pretty", children: description }) : null,
2169
+ description ? /* @__PURE__ */ jsx("p", { className: "max-w-md text-lg text-muted-foreground text-pretty whitespace-pre-line", children: description }) : null,
2170
2170
  actionRow
2171
2171
  ] });
2172
2172
  }
2173
2173
  return /* @__PURE__ */ jsxs("div", { className: cn("space-y-2 py-8 text-center", className), children: [
2174
2174
  /* @__PURE__ */ jsx("h2", { tabIndex: -1, "data-gf-focus": "", className: cn("rounded-sm text-2xl font-semibold", focusRing), children: title }),
2175
- description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null,
2175
+ description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground whitespace-pre-line", children: description }) : null,
2176
2176
  actionRow
2177
2177
  ] });
2178
2178
  }
@@ -2658,6 +2658,44 @@ function FormBody({
2658
2658
  }
2659
2659
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
2660
2660
 
2661
+ // src/signed-upload.ts
2662
+ async function putToUrl(file, upload, contentType, fetcher) {
2663
+ if (!upload.url) throw new Error("The server signed no URL to send the file to; pass `send`.");
2664
+ const res = await fetcher(upload.url, { method: "PUT", headers: { "content-type": contentType }, body: file });
2665
+ if (!res.ok) throw new Error(`The file wasn't stored (${res.status}).`);
2666
+ }
2667
+ function signedUploadHost(options) {
2668
+ const fetcher = (...args) => (options.fetch ?? fetch)(...args);
2669
+ return {
2670
+ async upload(file, field) {
2671
+ const type = photoFileType(file);
2672
+ const claim = typeof options.claim === "function" ? options.claim() : options.claim;
2673
+ const endpoint = typeof options.endpoint === "function" ? options.endpoint(field) : options.endpoint;
2674
+ const request = { fieldId: field.id, name: file.name, type, size: file.size, ...claim };
2675
+ const response = await fetcher(endpoint, {
2676
+ method: "POST",
2677
+ headers: { "content-type": "application/json" },
2678
+ body: JSON.stringify(request)
2679
+ });
2680
+ const body = await response.json().catch(() => ({}));
2681
+ if (!response.ok || !body.ref || !body.upload) {
2682
+ throw new Error(typeof body.error === "string" ? body.error : `The photo wasn't accepted (${response.status}).`);
2683
+ }
2684
+ if (options.send) await options.send(file, body.upload, type);
2685
+ else await putToUrl(file, body.upload, type, fetcher);
2686
+ return body.ref;
2687
+ },
2688
+ async url(ref, field) {
2689
+ if (!options.readUrl) return null;
2690
+ try {
2691
+ return await options.readUrl(ref, field);
2692
+ } catch {
2693
+ return null;
2694
+ }
2695
+ }
2696
+ };
2697
+ }
2698
+
2661
2699
  // src/clean-url.ts
2662
2700
  var KEY_PREFIX = "form-query:";
2663
2701
  var HIDDEN_QUERY_KEEP_MS = 30 * 60 * 1e3;
@@ -2689,6 +2727,6 @@ function nativeReplaceState(history) {
2689
2727
  }
2690
2728
  var wasReload = () => typeof performance !== "undefined" && performance.getEntriesByType?.("navigation")[0]?.type === "reload";
2691
2729
 
2692
- export { CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, FieldChrome, Form, HIDDEN_QUERY_KEEP_MS, MediaTimeContext, PROGRESS_DELAY_MS, SourceTextContext, UPLOAD_WAIT_MESSAGE, UploadContext, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
2730
+ export { CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, FieldChrome, Form, HIDDEN_QUERY_KEEP_MS, MediaTimeContext, PROGRESS_DELAY_MS, SourceTextContext, UPLOAD_WAIT_MESSAGE, UploadContext, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, signedUploadHost, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
2693
2731
  //# sourceMappingURL=index.js.map
2694
2732
  //# sourceMappingURL=index.js.map