From 3c12e39b9fe4abfef0a63517f0f55890e90399f4 Mon Sep 17 00:00:00 2001 From: MiroslavPetrik Date: Tue, 23 Jan 2024 13:58:19 +0100 Subject: [PATCH] fix(upload-atom): experimental `uploadAtom` and `fileUpload` within a list (#103) Preview version. --- src/atoms/extendFieldAtom.ts | 7 +- src/atoms/index.ts | 1 + src/atoms/upload-atom/index.ts | 1 + src/atoms/upload-atom/uploadAtom.ts | 68 +++++++ src/components/file-upload/FileUpload.tsx | 28 +++ .../file-upload/FileUploadList.stories.tsx | 170 ++++++++++++++++++ .../file-upload/SwitchUploadAtom.tsx | 30 ++++ src/components/file-upload/index.ts | 2 + src/fields/list-field/listField.ts | 11 +- src/fields/zod-field/zodField.ts | 11 +- 10 files changed, 319 insertions(+), 10 deletions(-) create mode 100644 src/atoms/upload-atom/index.ts create mode 100644 src/atoms/upload-atom/uploadAtom.ts create mode 100644 src/components/file-upload/FileUpload.tsx create mode 100644 src/components/file-upload/FileUploadList.stories.tsx create mode 100644 src/components/file-upload/SwitchUploadAtom.tsx create mode 100644 src/components/file-upload/index.ts diff --git a/src/atoms/extendFieldAtom.ts b/src/atoms/extendFieldAtom.ts index 7059e0c..46c6165 100644 --- a/src/atoms/extendFieldAtom.ts +++ b/src/atoms/extendFieldAtom.ts @@ -11,9 +11,12 @@ export const extendFieldAtom = < E extends Record, >( field: T, - atoms: E, + makeAtoms: (cfg: T extends Atom ? Config : never) => E, ) => atom((get) => { const base = get(field); - return { ...base, ...atoms }; + return { + ...base, + ...makeAtoms(base as T extends Atom ? Config : never), + }; }); diff --git a/src/atoms/index.ts b/src/atoms/index.ts index fb74521..7e72fe1 100644 --- a/src/atoms/index.ts +++ b/src/atoms/index.ts @@ -1 +1,2 @@ export * from "./list-atom"; +export * from "./upload-atom"; diff --git a/src/atoms/upload-atom/index.ts b/src/atoms/upload-atom/index.ts new file mode 100644 index 0000000..32069f7 --- /dev/null +++ b/src/atoms/upload-atom/index.ts @@ -0,0 +1 @@ +export * from "./uploadAtom"; diff --git a/src/atoms/upload-atom/uploadAtom.ts b/src/atoms/upload-atom/uploadAtom.ts new file mode 100644 index 0000000..87b5cca --- /dev/null +++ b/src/atoms/upload-atom/uploadAtom.ts @@ -0,0 +1,68 @@ +import { fieldAtom } from "form-atoms"; +import { Atom, atom } from "jotai"; + +import { ExtendFieldAtom, extendFieldAtom } from "../extendFieldAtom"; + +type UploadStatus = "loading" | "error" | "success"; + +export type UploadAtom = ExtendFieldAtom< + Value, + { + /** + * A read-only atom containing the field's upload status. + */ + uploadStatus: Atom; + } +>; + +type UploadAtomConfig = { + name?: string; +}; + +export const uploadAtom = + (upload: (file: File) => Promise) => + (file: File, config?: UploadAtomConfig): UploadAtom => { + const requestAtom = atom(async () => upload(file)); + + const field = fieldAtom({ + ...config, + value: undefined, + validate: async ({ get, set, value }) => { + if (value) { + // the file was already uploaded, the value is the response + return []; + } + + try { + const result = await get(requestAtom); + + set(get(field).value, result); + + return []; + } catch (err) { + if (typeof err !== "string") { + console.warn( + "uploadAtom: The error thrown from failed upload is not a string.", + ); + return ["Failed to upload!"]; + } else { + return [err]; + } + } + }, + }); + + return extendFieldAtom(field, ({ validateStatus }) => ({ + uploadStatus: atom((get) => { + const status = get(validateStatus); + + if (status === "validating") { + return "loading"; + } else if (status === "valid") { + return "success"; + } else { + return "error"; + } + }), + })); + }; diff --git a/src/components/file-upload/FileUpload.tsx b/src/components/file-upload/FileUpload.tsx new file mode 100644 index 0000000..24c9dc0 --- /dev/null +++ b/src/components/file-upload/FileUpload.tsx @@ -0,0 +1,28 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect } from "react"; +import { RenderProp } from "react-render-prop-type"; + +import { UploadAtom } from "../../atoms"; + +type ChildrenProps = { + isLoading: boolean; + isError: boolean; + isSuccess: boolean; +}; + +type Props = { field: UploadAtom } & RenderProp; + +export function FileUpload({ field, children }: Props) { + const atoms = useAtomValue(field); + const validate = useSetAtom(atoms.validate); + const status = useAtomValue(atoms.uploadStatus); + + // runs the upload + useEffect(() => validate(), []); + + return children({ + isLoading: status === "loading", + isError: status === "error", + isSuccess: status === "success", + }); +} diff --git a/src/components/file-upload/FileUploadList.stories.tsx b/src/components/file-upload/FileUploadList.stories.tsx new file mode 100644 index 0000000..83975bf --- /dev/null +++ b/src/components/file-upload/FileUploadList.stories.tsx @@ -0,0 +1,170 @@ +import { FieldAtom, fieldAtom, useFieldValue } from "form-atoms"; + +import { FileUpload } from "./FileUpload"; +import { SwitchUploadAtom } from "./SwitchUploadAtom"; +import { uploadAtom } from "../../atoms"; +import { StringField, listField, stringField } from "../../fields"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; +import { formStory, meta } from "../../scenarios/StoryForm"; +import { List, RemoveButtonProps } from "../list"; + +export default { + ...meta, + title: "components/FileUpload", +}; + +let id = 3; + +const fakeUploadAtom = uploadAtom( + () => + new Promise((resolve, reject) => { + setTimeout(() => { + if (Math.random() > 0.3) { + resolve(`https://picsum.photos/id/${id++}/100/100`); + } else { + reject("Simulated error message as passed to Promise.reject()."); + } + }, 2000); + }), +); + +const fileList = listField({ + value: [ + { + id: "1", + url: "https://picsum.photos/id/1/100/100", + }, + { + id: "2", + url: "https://picsum.photos/id/2/100/100", + }, + ], + builder: ({ url, id }) => ({ + // the ID must be optional, to permit submit newly uploaded files to the server + id: stringField({ value: id }).optional(), + // the URL is only a fieldAtom, not zodField, as the uploadAtom extends only the fieldAtom + url: fieldAtom({ value: url }), + }), +}); + +const Image = ({ url }: { url: FieldAtom }) => { + const value = useFieldValue(url); + + return ( + + ); +}; + +const IdMessage = ({ id }: { id: StringField }) => { + const value = useFieldValue(id); + + return value ? ( + + I'm already stored with id: {value} + + ) : ( + New url ready to be stored in DB (after form submit). + ); +}; + +export const FileUploadList = formStory({ + parameters: { + docs: { + description: { + story: + "Here we use custom `uploadAtom()` which handles `File` upload from the client side. The form will eventually be submitted with the uploaded URL.", + }, + }, + }, + args: { + fields: { + fileList, + }, + children: ({ fields }) => ( + ( + { + const files = Array.from(event.currentTarget.files ?? []); + + files.forEach((file) => + add({ + id: stringField().optional(), + url: fakeUploadAtom(file), + }), + ); + }} + /> + )} + RemoveButton={RemoveButton} + > + {({ fields, RemoveButton }) => { + return ( +
+ + {({ isUpload, field }) => { + return isUpload ? ( + + {({ isLoading, isSuccess, isError }) => ( +
+ {isLoading ? ( + <> +

+ Please wait... +

+ + ) : isSuccess ? ( +

+ + Done. + +

+ ) : isError ? ( + <> +

+ Failed to upload. Use the{" "} + FieldErrors component to display + the reason thrown from your upload{" "} + action: + +

+ + ) : ( + <> + )} +
+ )} +
+ ) : ( +

+ + +

+ ); + }} +
+
+ +
+
+ ); + }} +
+ ), + }, +}); + +const RemoveButton = ({ remove }: RemoveButtonProps) => ( + +); diff --git a/src/components/file-upload/SwitchUploadAtom.tsx b/src/components/file-upload/SwitchUploadAtom.tsx new file mode 100644 index 0000000..a5ad478 --- /dev/null +++ b/src/components/file-upload/SwitchUploadAtom.tsx @@ -0,0 +1,30 @@ +import { FieldAtom } from "form-atoms"; +import { useAtomValue } from "jotai"; +import { RenderProp } from "react-render-prop-type"; + +import { UploadAtom } from "../../atoms"; + +export function useIsUploadAtom( + field: FieldAtom, +): field is UploadAtom { + const atoms = useAtomValue(field); + + return !!(atoms as any).uploadStatus; +} + +type UploadProps = { isUpload: true; field: UploadAtom }; + +type RegularProps = { isUpload: false; field: FieldAtom }; + +export const SwitchUploadAtom = ({ + field, + children, +}: { field: FieldAtom } & RenderProp< + UploadProps | RegularProps +>) => { + if (useIsUploadAtom(field)) { + return <>{children({ isUpload: true, field })}; + } else { + return <>{children({ isUpload: false, field })}; + } +}; diff --git a/src/components/file-upload/index.ts b/src/components/file-upload/index.ts new file mode 100644 index 0000000..270b661 --- /dev/null +++ b/src/components/file-upload/index.ts @@ -0,0 +1,2 @@ +export * from "./FileUpload"; +export * from "./SwitchUploadAtom"; diff --git a/src/fields/list-field/listField.ts b/src/fields/list-field/listField.ts index f1345d7..8f4f46e 100644 --- a/src/fields/list-field/listField.ts +++ b/src/fields/list-field/listField.ts @@ -76,16 +76,19 @@ export const listField = < optionalSchema: optionalSchema ?? z.array(z.any()), }); - const listFieldAtom = extendFieldAtom(listAtom({ ...config, validate }), { - required: requiredAtom, - }) as unknown as RequiredListField; + const listFieldAtom = extendFieldAtom( + listAtom({ ...config, validate }), + () => ({ + required: requiredAtom, + }), + ) as unknown as RequiredListField; listFieldAtom.optional = (readRequired: ReadRequired = () => false) => { const { validate, requiredAtom } = makeOptional(readRequired); const optionalZodFieldAtom = extendFieldAtom( listAtom({ ...config, validate }), - { required: requiredAtom }, + () => ({ required: requiredAtom }), ) as OptionalListField; optionalZodFieldAtom.optional = () => optionalZodFieldAtom; diff --git a/src/fields/zod-field/zodField.ts b/src/fields/zod-field/zodField.ts index e907551..30d9454 100644 --- a/src/fields/zod-field/zodField.ts +++ b/src/fields/zod-field/zodField.ts @@ -63,16 +63,19 @@ export function zodField< optionalSchema, }); - const zodFieldAtom = extendFieldAtom(fieldAtom({ ...config, validate }), { - required: requiredAtom, - }) as unknown as RequiredZodField; + const zodFieldAtom = extendFieldAtom( + fieldAtom({ ...config, validate }), + () => ({ + required: requiredAtom, + }), + ) as unknown as RequiredZodField; zodFieldAtom.optional = (readRequired: ReadRequired = () => false) => { const { validate, requiredAtom } = makeOptional(readRequired); const optionalZodFieldAtom = extendFieldAtom( fieldAtom({ ...config, validate }), - { required: requiredAtom }, + () => ({ required: requiredAtom }), ) as OptionalZodField; optionalZodFieldAtom.optional = () => optionalZodFieldAtom;