baublet/w8mngr

View on GitHub
client/components/Forms/Upload.tsx

Summary

Maintainability
D
2 days
Test Coverage
import cx from "classnames";
import React from "react";

import { filterFalsyKeys } from "../../../shared";
import {
  GetUploadDataDocument,
  GetUploadDataQueryResult,
  GetUploadDataQueryVariables,
} from "../../generated";
import { apolloClientService } from "../../helpers/apolloClientService";
import { uploadFiles } from "../../helpers/uploadFiles";
import { DeleteIconButton } from "../Button/DeleteIconButton";
import { ButtonSpinnerIcon } from "../Loading/ButtonSpinner";
import { PrimaryLoader } from "../Loading/Primary";

const allWatchEvents = [
  "drag",
  "dragstart",
  "dragend",
  "dragover",
  "dragenter",
  "dragleave",
  "drop",
];
const overWatchEvents = ["dragover", "dragenter"];
const outWatchEvents = ["dragleave", "dragend", "drop"];
const imageAcceptsTypes = {
  IMAGE: "image/*",
  ANY: undefined,
} as const;

type DroppedFile = {
  id: string;
  name: string;
  size: number;
  type: string;
  previewUrl?: string;
  smallUrl?: string;
  uploadedPublicId?: string;
  file: File;
  uploadId?: string;
};

export function Upload({
  limit = 1,
  bodyDrop = true,
  type = "IMAGE",
  aspectRatio = "1/1",
  onChange,
  placeholder = (
    <>
      <strong>Choose a file</strong>
      <span> or drag it here</span>
    </>
  ),
  defaultSelectedUploadIds = [],
}: {
  limit?: number;
  bodyDrop?: boolean;
  type?: "IMAGE" | "ANY";
  placeholder?: JSX.Element;
  aspectRatio?: string;
  onChange: (selectedUploadIds: string[]) => void;
  defaultSelectedUploadIds?: string[];
}) {
  const dragAndDropSupported = React.useMemo(supportsDragAndDrop, []);
  const dropWatchElementRef = React.useRef<HTMLFormElement | null>(null);
  const [isDragging, setIsDragging] = React.useState(false);
  const [droppedFiles, setDroppedFiles] = React.useState<DroppedFile[]>([]);
  const [selectedUploadIds, setSelectedUploadIds] = React.useState<string[]>(
    defaultSelectedUploadIds
  );
  const [loading, setLoading] = React.useState(
    defaultSelectedUploadIds.length > 0
  );

  const selectHandlers = React.useMemo(
    () => new Map<string, (e: React.ChangeEvent<HTMLInputElement>) => void>(),
    []
  );
  const getSelectHandler = React.useCallback((fileId: string) => {
    const handler = selectHandlers.get(fileId);
    if (handler) {
      return handler;
    }

    const createdHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
      setDroppedFiles((droppedFiles) => {
        const file = droppedFiles.find((file) => file.id === fileId);
        const uploadId = file?.uploadId;
        if (!file || !uploadId) {
          return droppedFiles;
        }
        setSelectedUploadIds((ids) => {
          if (event.target.checked) {
            if (ids.includes(uploadId)) {
              return ids;
            }
            return [...ids, uploadId];
          }
          if (!ids.includes(uploadId)) {
            return ids;
          }
          return ids.filter((id) => id !== uploadId);
        });
        return droppedFiles;
      });
    };
    selectHandlers.set(fileId, createdHandler);

    return createdHandler;
  }, []);

  const onAllDragEventsFunction = React.useCallback(
    (event: Pick<Event, "stopPropagation" | "preventDefault">) => {
      event.stopPropagation();
      event.preventDefault();
    },
    []
  );
  const onDragOver = React.useCallback(() => {
    setIsDragging(true);
  }, []);
  const onDragOut = React.useCallback(() => {
    setIsDragging(false);
  }, []);
  const onDrop = React.useCallback((e: any) => {
    if (e?.dataTransfer?.files) {
      setFiles({
        filesDropped: e.dataTransfer.files,
        limit,
        setDroppedFiles,
        setSelectedUploadIds,
      });
    }
  }, []);

  const uploadAreaBackgroundImage = React.useMemo(() => {
    if (type !== "IMAGE") {
      return undefined;
    }
    if (limit > 1) {
      return undefined;
    }
    if (droppedFiles.length === 0) {
      return undefined;
    }
    if (selectedUploadIds.length === 0) {
      return undefined;
    }
    const selectedId = selectedUploadIds[0];
    if (!selectedId) {
      return undefined;
    }
    const selectedFile = droppedFiles.find(
      (file) => file.uploadId === selectedId
    );
    if (!selectedFile) {
      return undefined;
    }
    return selectedFile.smallUrl;
  }, [droppedFiles, selectedUploadIds, limit, type]);

  const deleteUpload = React.useCallback((id: string, uploadId?: string) => {
    setSelectedUploadIds((uploadIds) =>
      uploadIds.filter((id) => id !== id && id !== uploadId)
    );
    setDroppedFiles((files) => files.filter((file) => file.id !== id));
  }, []);

  useLoadInitialData({
    initialSelectedUploadIds: defaultSelectedUploadIds,
    setDroppedFiles,
    setSelectedUploadIds,
    setLoading,
  });

  React.useEffect(() => {
    onChange(selectedUploadIds);
  }, [selectedUploadIds]);

  React.useEffect(() => {
    if (droppedFiles.length === 0) {
      return;
    }

    const unUploadedFile = droppedFiles.filter(
      (file) => file.uploadId === undefined
    );

    if (unUploadedFile.length === 0) {
      return;
    }

    const interval = setTimeout(async () => {
      const results = await uploadFiles({
        files: droppedFiles,
      });
      setDroppedFiles((files) => {
        const copiedFiles: typeof files = [];
        const newIdsToSelect: string[] = [];
        for (const file of files) {
          const upload = results.find((result) => file.id === result.id);
          if (upload) {
            file.previewUrl = upload.previewUrl;
            file.smallUrl = upload.smallUrl;
            file.uploadedPublicId = upload.publicId;
            file.uploadId = upload.uploadId;
            newIdsToSelect.push(upload.uploadId);
          }
          copiedFiles.push(file);
        }
        setSelectedUploadIds((ids) =>
          [...ids, ...newIdsToSelect].slice(0, limit)
        );
        return copiedFiles;
      });
    }, 500);
    return () => clearTimeout(interval);
  }, [droppedFiles]);

  React.useEffect(() => {
    const element = bodyDrop ? document.body : dropWatchElementRef.current;
    if (element) {
      element.addEventListener("drop", onDrop);
      for (const event of allWatchEvents) {
        element.addEventListener(event, onAllDragEventsFunction);
      }
      for (const event of overWatchEvents) {
        element.addEventListener(event, onDragOver);
      }
      for (const event of outWatchEvents) {
        element.addEventListener(event, onDragOut);
      }
    }
    return () => {
      if (element) {
        element.removeEventListener("drop", onDrop);
        for (const event of allWatchEvents) {
          element.removeEventListener(event, onAllDragEventsFunction);
        }
        for (const event of overWatchEvents) {
          element.removeEventListener(event, onDragOver);
        }
        for (const event of outWatchEvents) {
          element.removeEventListener(event, onDragOut);
        }
      }
    };
  }, [bodyDrop]);

  if (loading) {
    return (
      <div className="text-purple-700">
        <PrimaryLoader />
      </div>
    );
  }

  return (
    <>
      <div
        className={cx(
          "p-2 bg-emerald-50 bg-opacity-25 hover:bg-opacity-50 rounded",
          {
            "bg-emerald-100": isDragging,
          }
        )}
        style={{
          backgroundImage: "url(" + uploadAreaBackgroundImage + ")",
          backgroundPosition: "center",
          backgroundSize: "cover",
          backgroundRepeat: "no-repeat",
          aspectRatio,
        }}
      >
        <form
          className={cx({
            "border-4 border-dashed border-purple-900 p-2 w-full h-full":
              dragAndDropSupported,
            "border-opacity-10": !isDragging,
            "border-opacity-20": isDragging,
            "border-purple-50": uploadAreaBackgroundImage,
            "border-purple-900": !uploadAreaBackgroundImage,
          })}
          method="post"
          action=""
          encType="multipart/form-data"
          ref={dropWatchElementRef}
        >
          <div className="h-full w-full flex relative justify-center items-center">
            <input
              className={cx("opacity-0 absolute inset-0 w-full cursor-pointer")}
              type="file"
              name="files[]"
              id="file"
              data-multiple-caption="{count} files selected"
              multiple={limit > 1}
              accept={imageAcceptsTypes[type]}
              onChange={(e) => {
                setFiles({
                  filesDropped: e.target.files,
                  limit,
                  setDroppedFiles,
                  setSelectedUploadIds,
                });
              }}
            />
            <label
              htmlFor="file"
              className={cx("text-sm text-center block", {
                "z-0 opacity-0": uploadAreaBackgroundImage,
              })}
            >
              <div>{placeholder}</div>
              <div className="text-xs text-opacity-50 text-emerald-700 mt-2">
                Click or drag to upload
              </div>
            </label>
          </div>
        </form>
      </div>
      <div className="flex flex-col gap-2">
        {droppedFiles.map((file) => {
          const isSelected = selectedUploadIds.includes(
            file.uploadId as string
          );
          return (
            <div
              className={cx(
                "py-1 flex justify-between gap-2 items-center hover:opacity-100 mt-2 border border-transparent rounded cursor-pointer overflow-hidden",
                {
                  "opacity-25 pointer-events-none": !file.previewUrl,
                  "opacity-75": file.previewUrl,
                  "border-purple-500 border-opacity-50 bg-purple-50 bg-opacity-50":
                    isSelected,
                }
              )}
              key={file.id}
            >
              <div style={{ maxWidth: "75%" }}>
                <label
                  htmlFor={file.id}
                  className="flex gap-2 items-center pointer-cursor pl-2"
                >
                  <input
                    type="checkbox"
                    name={file.id}
                    id={file.id}
                    className="hidden"
                    checked={isSelected}
                    onChange={getSelectHandler(file.id)}
                  />
                  {file.previewUrl ? (
                    <UploadPreview url={file.previewUrl} />
                  ) : (
                    <ButtonSpinnerIcon />
                  )}
                  <div className="text-xs flex-grow truncate break-words max-w-full">
                    {file.name}
                  </div>
                </label>
              </div>
              <div style={{ maxWidth: "25%" }}>
                <DeleteIconButton
                  onClick={() => deleteUpload(file.id, file.uploadedPublicId)}
                  className="hover:bg-purple-600 focus:bg-purple-600 rounded-r-none"
                />
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
}

function useLoadInitialData({
  initialSelectedUploadIds,
  setDroppedFiles,
  setSelectedUploadIds,
  setLoading,
}: {
  initialSelectedUploadIds: string[];
  setDroppedFiles: React.Dispatch<React.SetStateAction<DroppedFile[]>>;
  setSelectedUploadIds: React.Dispatch<React.SetStateAction<string[]>>;
  setLoading: React.Dispatch<React.SetStateAction<boolean>>;
}) {
  React.useEffect(() => {
    const client = window.w8mngrServiceContainer.get(apolloClientService);

    if (initialSelectedUploadIds.length === 0) {
      setLoading(false);
      return;
    }

    setLoading(true);

    Promise.all(
      initialSelectedUploadIds.map(async (id) => {
        const result = await client.mutate<
          GetUploadDataQueryResult["data"],
          GetUploadDataQueryVariables
        >({
          mutation: GetUploadDataDocument,
          variables: {
            input: {
              id,
            },
          },
        });

        const uploadData = result.data?.upload;

        if (!uploadData) {
          console.error("Upload not found...", { id });
          setSelectedUploadIds((uploadIds) =>
            uploadIds.filter((id) => id !== id)
          );
          return;
        }

        setDroppedFiles((droppedFiles) => [
          ...droppedFiles,
          {
            file: {} as any,
            id: uploadData.id,
            name: uploadData.id,
            size: 0,
            type: "ANY",
            previewUrl: uploadData.preview,
            uploadId: uploadData.id,
            smallUrl: uploadData.small,
            uploadedPublicId: uploadData.publicId,
          },
        ]);
      })
    )
      .then(() => setLoading(false))
      .catch(() => setLoading(false));
  }, [initialSelectedUploadIds.join("/")]);
}

function supportsDragAndDrop() {
  const div = document.createElement("div");
  return (
    ("draggable" in div || ("ondragstart" in div && "ondrop" in div)) &&
    "FormData" in window &&
    "FileReader" in window
  );
}

function setFiles({
  filesDropped,
  limit,
  setDroppedFiles,
  setSelectedUploadIds,
}: {
  limit: number;
  filesDropped: FileList | null;
  setDroppedFiles: React.Dispatch<React.SetStateAction<DroppedFile[]>>;
  setSelectedUploadIds: React.Dispatch<React.SetStateAction<string[]>>;
}) {
  if (!filesDropped) {
    setDroppedFiles([]);
    return;
  }

  if (limit === 1) {
    setSelectedUploadIds([]);
  }

  const filesToSet: DroppedFile[] = [];
  const filesArray = Array.from(filesDropped);

  for (const file of filesArray) {
    const id = idFromFile(file);
    filesToSet.push({
      id,
      name: file.name,
      size: file.size,
      type: file.type,
      file,
    });
  }
  setDroppedFiles((files) => {
    const filesCopy = limit === 1 && files.length > 0 ? [] : files.slice(0);
    const allFileIds = filesCopy.map((file) => file.id).join(" - ");
    for (const file of filesToSet) {
      if (limit === filesCopy.length) {
        break;
      }
      if (allFileIds.includes(file.id)) {
        continue;
      }
      filesCopy.push(file);
    }
    return filesCopy;
  });
  setSelectedUploadIds((ids) => {
    return filterFalsyKeys(
      [...ids, ...filesToSet.map((file) => file.uploadId)].slice(0, limit)
    );
  });
}

function idFromFile(file: File): string {
  return `${file.name}-${file.size}-${file.lastModified}`;
}

function UploadPreview({ url }: { url: string }) {
  return (
    <div className="inline-block w-8 h-8" style={{ aspectRatio: "1" }}>
      <img src={url} title="Upload preview" className="w-full h-full" />
    </div>
  );
}