From eec8b64baea2e4f51ca7928898ca26bd2b616259 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 25 Apr 2025 22:37:28 -0700 Subject: [PATCH] refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165) Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability. --- app/src/media/api/MediaApi.ts | 2 +- app/src/ui/client/api/use-entity.ts | 8 +- app/src/ui/elements/media/Dropzone.tsx | 466 ++++++------------ .../ui/elements/media/DropzoneContainer.tsx | 51 +- app/src/ui/elements/media/DropzoneInner.tsx | 276 +++++++++++ app/src/ui/elements/media/dropzone-state.ts | 42 ++ app/src/ui/elements/media/helper.spec.ts | 63 +++ app/src/ui/elements/media/helper.ts | 23 + app/src/ui/elements/media/index.ts | 24 +- app/src/ui/hooks/use-render-count.ts | 7 + .../ui/modules/data/components/EntityForm.tsx | 3 + app/src/ui/routes/media/media.index.tsx | 2 +- .../test/tests/dropzone-element-test.tsx | 73 ++- app/vite.dev.ts | 6 +- docs/usage/elements.mdx | 7 +- 15 files changed, 664 insertions(+), 389 deletions(-) create mode 100644 app/src/ui/elements/media/DropzoneInner.tsx create mode 100644 app/src/ui/elements/media/dropzone-state.ts create mode 100644 app/src/ui/elements/media/helper.spec.ts create mode 100644 app/src/ui/hooks/use-render-count.ts diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index ceff6a4c..956f2aa4 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -48,7 +48,7 @@ export class MediaApi extends ModuleApi { return (await res.blob()) as File; } - getFileUploadUrl(file?: FileWithPath): string { + getFileUploadUrl(file?: { path: string }): string { if (!file) return this.getUrl("/upload"); return this.getUrl(`/upload/${file.path}`); } diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index b7e253d3..9bfdd807 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -112,8 +112,8 @@ export const useEntityQuery = < ...options, }); - const mutateAll = async () => { - const entityKey = makeKey(api, entity as string); + const mutateFn = async (id?: PrimaryFieldType) => { + const entityKey = makeKey(api, entity as string, id); return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, { revalidate: true, }); @@ -126,7 +126,7 @@ export const useEntityQuery = < // mutate all keys of entity by default if (options?.revalidateOnMutate !== false) { - await mutateAll(); + await mutateFn(); } return res; }; @@ -135,7 +135,7 @@ export const useEntityQuery = < return { ...swr, ...mapped, - mutate: mutateAll, + mutate: mutateFn, mutateRaw: swr.mutate, api, key, diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 57d6c48f..bc2cb080 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -1,20 +1,21 @@ import type { DB } from "core"; import { type ComponentPropsWithRef, - type ComponentPropsWithoutRef, + createContext, type ReactNode, type RefObject, - memo, + useCallback, + useContext, useEffect, + useMemo, useRef, useState, } from "react"; -import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb"; -import { twMerge } from "tailwind-merge"; -import { IconButton } from "ui/components/buttons/IconButton"; -import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { type FileWithPath, useDropzone } from "./use-dropzone"; -import { formatNumber } from "core/utils"; +import { checkMaxReached } from "./helper"; +import { DropzoneInner } from "./DropzoneInner"; +import { createDropzoneStore } from "ui/elements/media/dropzone-state"; +import { useStore } from "zustand"; export type FileState = { body: FileWithPath | string; @@ -29,27 +30,23 @@ export type FileState = { export type FileStateWithData = FileState & { data: DB["media"] }; export type DropzoneRenderProps = { + store: ReturnType; wrapperRef: RefObject; inputProps: ComponentPropsWithRef<"input">; - state: { - files: FileState[]; - isOver: boolean; - isOverAccepted: boolean; - showPlaceholder: boolean; - }; actions: { - uploadFile: (file: FileState) => Promise; - deleteFile: (file: FileState) => Promise; + uploadFile: (file: { path: string }) => Promise; + deleteFile: (file: { path: string }) => Promise; openFileInput: () => void; }; - onClick?: (file: FileState) => void; + showPlaceholder: boolean; + onClick?: (file: { path: string }) => void; footer?: ReactNode; dropzoneProps: Pick; }; export type DropzoneProps = { - getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string }; - handleDelete: (file: FileState) => Promise; + getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string }; + handleDelete: (file: { path: string }) => Promise; initialItems?: FileState[]; flow?: "start" | "end"; maxItems?: number; @@ -57,7 +54,7 @@ export type DropzoneProps = { overwrite?: boolean; autoUpload?: boolean; onRejected?: (files: FileWithPath[]) => void; - onDeleted?: (file: FileState) => void; + onDeleted?: (file: { path: string }) => void; onUploaded?: (files: FileStateWithData[]) => void; onClick?: (file: FileState) => void; placeholder?: { @@ -65,7 +62,7 @@ export type DropzoneProps = { text?: string; }; footer?: ReactNode; - children?: (props: DropzoneRenderProps) => ReactNode; + children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode); }; function handleUploadError(e: unknown) { @@ -94,30 +91,21 @@ export function Dropzone({ onClick, footer, }: DropzoneProps) { - const [files, setFiles] = useState(initialItems); - const [uploading, setUploading] = useState(false); + const store = useRef(createDropzoneStore()).current; + const files = useStore(store, (state) => state.files); + const setFiles = useStore(store, (state) => state.setFiles); + const getFilesLength = useStore(store, (state) => state.getFilesLength); + const setUploading = useStore(store, (state) => state.setUploading); + const setIsOver = useStore(store, (state) => state.setIsOver); + const uploading = useStore(store, (state) => state.uploading); + const setFileState = useStore(store, (state) => state.setFileState); + const removeFile = useStore(store, (state) => state.removeFile); const inputRef = useRef(null); - const [isOverAccepted, setIsOverAccepted] = useState(false); - function isMaxReached(added: number): boolean { - if (!maxItems) { - console.log("maxItems is undefined, never reached"); - return false; - } - - const current = files.length; - const remaining = maxItems - current; - console.log("isMaxReached", { added, current, remaining, maxItems, overwrite }); - - // if overwrite is set, but added is bigger than max items - if (overwrite) { - console.log("added > maxItems, stop?", added > maxItems); - return added > maxItems; - } - console.log("remaining > added, stop?", remaining > added); - // or remaining doesn't suffice, stop - return added > remaining; - } + useEffect(() => { + // @todo: potentially keep pending ones + setFiles(() => initialItems); + }, [initialItems.length]); function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean { const items = Array.isArray(i) ? i : [i]; @@ -135,31 +123,41 @@ export function Dropzone({ }); } - const { isOver, handleFileInputChange, ref } = useDropzone({ + const { handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { + console.log("onDropped", newFiles); if (!isAllowed(newFiles)) return; - let to_drop = 0; const added = newFiles.length; - if (maxItems) { - if (isMaxReached(added)) { - if (onRejected) { - onRejected(newFiles); - } else { - console.warn("maxItems reached"); + // Check max files using the current state, not a stale closure + setFiles((currentFiles) => { + let to_drop = 0; + + if (maxItems) { + const $max = checkMaxReached({ + maxItems, + overwrite, + added, + current: currentFiles.length, + }); + + if ($max.reject) { + if (onRejected) { + onRejected(newFiles); + } else { + console.warn("maxItems reached"); + } + + // Return current state unchanged if rejected + return currentFiles; } - return; + to_drop = $max.to_drop; } - to_drop = added; - } - - console.log("files", newFiles, { to_drop }); - setFiles((prev) => { // drop amount calculated - const _prev = prev.slice(to_drop); + const _prev = currentFiles.slice(to_drop); // prep new files const currentPaths = _prev.map((f) => f.path); @@ -175,24 +173,35 @@ export function Dropzone({ progress: 0, })); - return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - }); + const updatedFiles = + flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; - if (autoUpload) { - setUploading(true); - } + if (autoUpload && filteredFiles.length > 0) { + // Schedule upload for the next tick to ensure state is updated + setTimeout(() => setUploading(true), 0); + } + + return updatedFiles; + }); }, onOver: (items) => { if (!isAllowed(items)) { - setIsOverAccepted(false); + setIsOver(true, false); return; } - const max_reached = isMaxReached(items.length); - setIsOverAccepted(!max_reached); + const current = getFilesLength(); + const $max = checkMaxReached({ + maxItems, + overwrite, + added: items.length, + current, + }); + console.log("--files in onOver", current, $max); + setIsOver(true, !$max.reject); }, onLeave: () => { - setIsOverAccepted(false); + setIsOver(false, false); }, }); @@ -223,40 +232,6 @@ export function Dropzone({ } }, [uploading]); - function setFileState(path: string, state: FileState["state"], progress?: number) { - setFiles((prev) => - prev.map((f) => { - //console.log("compare", f.path, path, f.path === path); - if (f.path === path) { - return { - ...f, - state, - progress: progress ?? f.progress, - }; - } - return f; - }), - ); - } - - function replaceFileState(prevPath: string, newState: Partial) { - setFiles((prev) => - prev.map((f) => { - if (f.path === prevPath) { - return { - ...f, - ...newState, - }; - } - return f; - }), - ); - } - - function removeFileFromState(path: string) { - setFiles((prev) => prev.filter((f) => f.path !== path)); - } - function uploadFileProgress(file: FileState): Promise { return new Promise((resolve, reject) => { if (!file.body) { @@ -273,7 +248,7 @@ export function Dropzone({ return; } - const uploadInfo = getUploadInfo(file.body); + const uploadInfo = getUploadInfo({ path: file.body.path! }); console.log("dropzone:uploadInfo", uploadInfo); const { url, headers, method = "POST" } = uploadInfo; @@ -322,7 +297,7 @@ export function Dropzone({ state: "uploaded", }; - replaceFileState(file.path, newState); + setFileState(file.path, newState.state); resolve({ ...response, ...file, ...newState }); } catch (e) { setFileState(file.path, "uploaded", 1); @@ -349,7 +324,7 @@ export function Dropzone({ }); } - async function deleteFile(file: FileState) { + const deleteFile = useCallback(async (file: FileState) => { console.log("deleteFile", file); switch (file.state) { case "uploaded": @@ -358,232 +333,97 @@ export function Dropzone({ console.log('setting state to "deleting"', file); setFileState(file.path, "deleting"); await handleDelete(file); - removeFileFromState(file.path); + removeFile(file.path); onDeleted?.(file); } break; } - } + }, []); - async function uploadFile(file: FileState) { + const uploadFile = useCallback(async (file: FileState) => { const result = await uploadFileProgress(file); onUploaded?.([result]); - } + }, []); - const openFileInput = () => inputRef.current?.click(); - const showPlaceholder = Boolean( - placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems), + const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]); + const showPlaceholder = useMemo( + () => + Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)), + [placeholder, maxItems, files.length], ); - const renderProps: DropzoneRenderProps = { - wrapperRef: ref, - inputProps: { - ref: inputRef, - type: "file", - multiple: !maxItems || maxItems > 1, - onChange: handleFileInputChange, - }, - state: { - files, - isOver, - isOverAccepted, + const renderProps = useMemo( + () => ({ + store, + wrapperRef: ref, + inputProps: { + ref: inputRef, + type: "file", + multiple: !maxItems || maxItems > 1, + onChange: handleFileInputChange, + }, showPlaceholder, - }, - actions: { - uploadFile, - deleteFile, - openFileInput, - }, - dropzoneProps: { - maxItems, - placeholder, - autoUpload, - flow, - }, - onClick, - footer, - }; + actions: { + uploadFile, + deleteFile, + openFileInput, + }, + dropzoneProps: { + maxItems, + placeholder, + autoUpload, + flow, + }, + onClick, + footer, + }), + [maxItems, flow, placeholder, autoUpload, footer], + ) as unknown as DropzoneRenderProps; - return children ? children(renderProps) : ; + return ( + + {children ? ( + typeof children === "function" ? ( + children(renderProps) + ) : ( + children + ) + ) : ( + + )} + + ); } -const DropzoneInner = ({ - wrapperRef, - inputProps, - state: { files, isOver, isOverAccepted, showPlaceholder }, - actions: { uploadFile, deleteFile, openFileInput }, - dropzoneProps: { placeholder, flow }, - onClick, - footer, -}: DropzoneRenderProps) => { - const Placeholder = showPlaceholder && ( - - ); +const DropzoneContext = createContext(undefined!); - async function uploadHandler(file: FileState) { - try { - return await uploadFile(file); - } catch (e) { - handleUploadError(e); - } - } +export function useDropzoneContext() { + return useContext(DropzoneContext); +} - return ( -
-
- -
-
-
- {flow === "start" && Placeholder} - {files.map((file) => ( - - ))} - {flow === "end" && Placeholder} - {footer} -
-
-
- ); +export const useDropzoneState = () => { + const { store } = useDropzoneContext(); + const files = useStore(store, (state) => state.files); + const isOver = useStore(store, (state) => state.isOver); + const isOverAccepted = useStore(store, (state) => state.isOverAccepted); + + return { + files, + isOver, + isOverAccepted, + }; }; -const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { - return ( -
- {text} -
- ); -}; - -export type PreviewComponentProps = { - file: FileState; - fallback?: (props: { file: FileState }) => ReactNode; - className?: string; - onClick?: () => void; - onTouchStart?: () => void; -}; - -const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => { - if (file.type.startsWith("image/")) { - return ; - } - - if (file.type.startsWith("video/")) { - return ; - } - - return fallback ? fallback({ file }) : null; -}; -export const PreviewWrapperMemoized = memo( - Wrapper, - (prev, next) => prev.file.path === next.file.path, -); - -type PreviewProps = { - file: FileState; - handleUpload: (file: FileState) => Promise; - handleDelete: (file: FileState) => Promise; - onClick?: (file: FileState) => void; -}; -const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => { - const dropdownItems = [ - file.state === "uploaded" && - typeof file.body === "string" && { - label: "Open", - icon: TbExternalLink, - onClick: () => { - window.open(file.body as string, "_blank"); - }, - }, - ["initial", "uploaded"].includes(file.state) && { - label: "Delete", - destructive: true, - icon: TbTrash, - onClick: () => handleDelete(file), - }, - ["initial", "pending"].includes(file.state) && { - label: "Upload", - icon: TbUpload, - onClick: () => handleUpload(file), - }, - ] satisfies (DropdownItem | boolean)[]; - - return ( -
{ - if (onClick) { - onClick(file); - } - }} - > -
- - - -
- {file.state === "uploading" && ( -
-
-
- )} -
- -
-
-

{file.name}

-
- {file.type} - {formatNumber.fileSize(file.size)} -
-
-
- ); -}; - -const ImagePreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"img">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return ; -}; - -const VideoPreview = ({ - file, - ...props -}: { file: FileState } & ComponentPropsWithoutRef<"video">) => { - const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); - return