Skip to content

Commit

Permalink
fix(upload-atom): experimental uploadAtom and fileUpload within a…
Browse files Browse the repository at this point in the history
… list (#103)

Preview version.
  • Loading branch information
MiroslavPetrik authored Jan 23, 2024
1 parent a8e00b3 commit 3c12e39
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 10 deletions.
7 changes: 5 additions & 2 deletions src/atoms/extendFieldAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ export const extendFieldAtom = <
E extends Record<string, unknown>,
>(
field: T,
atoms: E,
makeAtoms: (cfg: T extends Atom<infer Config> ? Config : never) => E,
) =>
atom((get) => {
const base = get(field);
return { ...base, ...atoms };
return {
...base,
...makeAtoms(base as T extends Atom<infer Config> ? Config : never),
};
});
1 change: 1 addition & 0 deletions src/atoms/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./list-atom";
export * from "./upload-atom";
1 change: 1 addition & 0 deletions src/atoms/upload-atom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./uploadAtom";
68 changes: 68 additions & 0 deletions src/atoms/upload-atom/uploadAtom.ts
Original file line number Diff line number Diff line change
@@ -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<Value> = ExtendFieldAtom<
Value,
{
/**
* A read-only atom containing the field's upload status.
*/
uploadStatus: Atom<UploadStatus>;
}
>;

type UploadAtomConfig = {
name?: string;
};

export const uploadAtom =
<Value>(upload: (file: File) => Promise<Value>) =>
(file: File, config?: UploadAtomConfig): UploadAtom<Value> => {
const requestAtom = atom(async () => upload(file));

const field = fieldAtom<Value | undefined>({
...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<UploadStatus>((get) => {
const status = get(validateStatus);

if (status === "validating") {
return "loading";
} else if (status === "valid") {
return "success";
} else {
return "error";
}
}),
}));
};
28 changes: 28 additions & 0 deletions src/components/file-upload/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -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<Value> = { field: UploadAtom<Value> } & RenderProp<ChildrenProps>;

export function FileUpload<Value>({ field, children }: Props<Value>) {
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",
});
}
170 changes: 170 additions & 0 deletions src/components/file-upload/FileUploadList.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<string>((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<string> }) => {
const value = useFieldValue(url);

return (
<img width={100} height={100} style={{ marginRight: 20 }} src={value} />
);
};

const IdMessage = ({ id }: { id: StringField }) => {
const value = useFieldValue(id);

return value ? (
<span>
I'm already stored with <strong>id: {value}</strong>
</span>
) : (
<span>New url ready to be stored in DB (after form submit).</span>
);
};

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 }) => (
<List
field={fields.fileList}
AddButton={({ add }) => (
<input
type="file"
multiple
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);

files.forEach((file) =>
add({
id: stringField().optional(),
url: fakeUploadAtom(file),
}),
);
}}
/>
)}
RemoveButton={RemoveButton}
>
{({ fields, RemoveButton }) => {
return (
<div
style={{
display: "grid",
gridGap: 16,
gridTemplateColumns: "auto min-content",
}}
>
<SwitchUploadAtom field={fields.url}>
{({ isUpload, field }) => {
return isUpload ? (
<FileUpload field={field}>
{({ isLoading, isSuccess, isError }) => (
<div>
{isLoading ? (
<>
<p>
Please wait... <progress />
</p>
</>
) : isSuccess ? (
<p>
<Image url={fields.url} />
<ins>Done. </ins>
<IdMessage id={fields.id} />
</p>
) : isError ? (
<>
<p>
Failed to upload. Use the{" "}
<code>FieldErrors</code> component to display
the reason thrown from your <code>upload</code>{" "}
action:
<PicoFieldErrors field={fields.url} />
</p>
</>
) : (
<></>
)}
</div>
)}
</FileUpload>
) : (
<p>
<Image url={fields.url} />
<IdMessage id={fields.id} />
</p>
);
}}
</SwitchUploadAtom>
<div>
<RemoveButton />
</div>
</div>
);
}}
</List>
),
},
});

const RemoveButton = ({ remove }: RemoveButtonProps) => (
<button type="button" className="outline secondary" onClick={remove}>
Remove
</button>
);
30 changes: 30 additions & 0 deletions src/components/file-upload/SwitchUploadAtom.tsx
Original file line number Diff line number Diff line change
@@ -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<any>,
): field is UploadAtom<any> {
const atoms = useAtomValue(field);

return !!(atoms as any).uploadStatus;
}

type UploadProps<Value> = { isUpload: true; field: UploadAtom<Value> };

type RegularProps<Value> = { isUpload: false; field: FieldAtom<Value> };

export const SwitchUploadAtom = <Value,>({
field,
children,
}: { field: FieldAtom<Value> } & RenderProp<
UploadProps<Value> | RegularProps<Value>
>) => {
if (useIsUploadAtom(field)) {
return <>{children({ isUpload: true, field })}</>;
} else {
return <>{children({ isUpload: false, field })}</>;
}
};
2 changes: 2 additions & 0 deletions src/components/file-upload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./FileUpload";
export * from "./SwitchUploadAtom";
11 changes: 7 additions & 4 deletions src/fields/list-field/listField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fields>;
const listFieldAtom = extendFieldAtom(
listAtom({ ...config, validate }),
() => ({
required: requiredAtom,
}),
) as unknown as RequiredListField<Fields>;

listFieldAtom.optional = (readRequired: ReadRequired = () => false) => {
const { validate, requiredAtom } = makeOptional(readRequired);

const optionalZodFieldAtom = extendFieldAtom(
listAtom({ ...config, validate }),
{ required: requiredAtom },
() => ({ required: requiredAtom }),
) as OptionalListField<Fields>;

optionalZodFieldAtom.optional = () => optionalZodFieldAtom;
Expand Down
11 changes: 7 additions & 4 deletions src/fields/zod-field/zodField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,19 @@ export function zodField<
optionalSchema,
});

const zodFieldAtom = extendFieldAtom(fieldAtom({ ...config, validate }), {
required: requiredAtom,
}) as unknown as RequiredZodField<Schema, OptSchema>;
const zodFieldAtom = extendFieldAtom(
fieldAtom({ ...config, validate }),
() => ({
required: requiredAtom,
}),
) as unknown as RequiredZodField<Schema, OptSchema>;

zodFieldAtom.optional = (readRequired: ReadRequired = () => false) => {
const { validate, requiredAtom } = makeOptional(readRequired);

const optionalZodFieldAtom = extendFieldAtom(
fieldAtom({ ...config, validate }),
{ required: requiredAtom },
() => ({ required: requiredAtom }),
) as OptionalZodField<Schema, OptSchema>;

optionalZodFieldAtom.optional = () => optionalZodFieldAtom;
Expand Down

0 comments on commit 3c12e39

Please sign in to comment.