From 5743b9937c1ae35fba78622da56dfa1c18cf30aa Mon Sep 17 00:00:00 2001 From: Felix Beceic Date: Thu, 18 Jan 2024 14:57:07 +0100 Subject: [PATCH] #178 Add spinner and upload percentage to DragZone --- libs/theme/src/defaultTheme.tsx | 8 +- libs/upload/src/DragZone.tsx | 103 +++++-- libs/upload/src/index.tsx | 2 +- .../formik-elements/DragZoneField.stories.tsx | 74 ++++- storybook/src/upload/DragZone.stories.tsx | 256 ++++++++++++++---- 5 files changed, 371 insertions(+), 72 deletions(-) diff --git a/libs/theme/src/defaultTheme.tsx b/libs/theme/src/defaultTheme.tsx index d416e13..0a81199 100644 --- a/libs/theme/src/defaultTheme.tsx +++ b/libs/theme/src/defaultTheme.tsx @@ -862,9 +862,13 @@ const defaultComponentConfig = { }, iconSize: 7, iconColor: `text-slate-400 group-hover:text-primary`, - customUploadDropZoneDescriptionContainer: "text-center space-y-1", + customUploadDropZoneDescriptionContainer: "flex flex-col text-center items-center space-y-1", + loading: { + master: "relative flex justify-center items-center text-slate-600 mx-auto my-px", + percentage: "absolute top-1.5 text-body text-xs", + }, customUploadDropZoneTitle: { - master: "flex", + master: "flex cursor-pointer", fontSize: "text-base", fontWeight: "font-medium", color: "text-body-light", diff --git a/libs/upload/src/DragZone.tsx b/libs/upload/src/DragZone.tsx index 3ffa4c1..1e1a6e1 100644 --- a/libs/upload/src/DragZone.tsx +++ b/libs/upload/src/DragZone.tsx @@ -17,17 +17,17 @@ import React from "react"; -import {UploadyContext, useItemErrorListener, useItemFinishListener, useItemStartListener} from "@rpldy/uploady"; +import { UploadyContext, useItemProgressListener, useItemFinishListener, useItemStartListener } from "@rpldy/uploady"; import UploadDropZone from "@rpldy/upload-drop-zone"; import { Field, FieldProps } from "@tiller-ds/form-elements"; +import { LoadingIcon } from "@tiller-ds/icons"; import { ComponentTokens, cx, TokenProps, useIcon, useTokens } from "@tiller-ds/theme"; import UploadyWrapper, { UploadyWrapperProps } from "./UploadyWrapper"; import { UseFileUpload, File, defaultUploadResponseMapper } from "./useFileUpload"; import { BatchItem } from "@rpldy/shared"; -import {isObject} from "lodash"; export type DragZoneProps = { /** @@ -77,7 +77,18 @@ export type DragZoneProps = { * Function fired when the component is clicked. */ onClick?: () => void; + /** + * Custom upload icon that overrides the default icon. + */ uploadIcon?: React.ReactElement; + /** + * Enables loading via a custom loader. + * + * If set to `null` no loader will be displayed on file upload. + * + * @param {number} uploadPercentage Percentage retrieved from the loader on file upload. + */ + loader?: ((uploadPercentage: number) => React.ReactNode) | null; } & Omit & Omit & DragZoneTokensProps; @@ -115,12 +126,20 @@ type CustomUploadDropZoneContainerProps = { /** * `withCredentials` flag on fetch requests for uploading */ - withCredentials?: boolean + withCredentials?: boolean; /** * Custom additional styling applied to the component. */ className?: string; + /** + * Enables loading via a custom loader. + * + * If set to `null` no loader will be displayed on file upload. + * + * @param {number} uploadPercentage Percentage retrieved from the loader on file upload. + */ + loader?: ((uploadPercentage: number) => React.ReactNode) | null; }; type CustomUploadDropZoneProps = { @@ -131,6 +150,8 @@ type CustomUploadDropZoneProps = { uploadActive?: boolean; uploadIcon?: React.ReactElement; className?: string; + uploadPercentage?: number; + loader?: ((uploadPercentage: number) => React.ReactNode) | null; } & TokenProps<"DragZone">; export default function DragZone({ @@ -148,6 +169,7 @@ export default function DragZone({ uploadIcon, withCredentials, className, + loader = (uploadPercentage: number) => , ...props }: DragZoneProps) { const tokens = useTokens("DragZone", props.tokens); @@ -173,6 +195,7 @@ export default function DragZone({ mapUploadResponse={mapUploadResponse} uploadIcon={uploadIcon} className={className} + loader={loader} /> @@ -183,31 +206,45 @@ export default function DragZone({ ); } -function CustomUploadDropZoneContainer({ hook, ...props }: CustomUploadDropZoneContainerProps) { +function CustomUploadDropZoneContainer({ + hook, + loader, + ...props +}: CustomUploadDropZoneContainerProps) { const uploady = React.useContext(UploadyContext); const [originalFileName, setOriginalFileName] = React.useState(""); const [uploadActive, setUploadActive] = React.useState(false); + const [uploadPercentage, setUploadPercentage] = React.useState(0); const onClick = React.useCallback(async () => { uploady.showFileUpload(); }, [uploady]); useItemStartListener((item) => { + setUploadActive(true); + setUploadPercentage(0); setOriginalFileName(item.file.name); }); + useItemProgressListener((item) => { + setUploadPercentage(item.completed); + }); + useItemFinishListener((item) => { const file: T = props.mapUploadResponse(item, originalFileName); - setUploadActive(true); hook?.onUploadedFiles(file); + setUploadActive(false); }); - React.useEffect(() => { - if (hook?.uploadedFiles.length === 0) setUploadActive(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hook.uploadedFiles]); - - return ; + return ( + + ); } function CustomUploadDropZone({ @@ -218,6 +255,8 @@ function CustomUploadDropZone({ onClick, uploadIcon, className, + loader, + uploadPercentage, ...props }: CustomUploadDropZoneProps) { const tokens = useTokens("DragZone", props.tokens); @@ -239,25 +278,53 @@ function CustomUploadDropZone({ tokens.customUploadDropZoneTitle.master, tokens.customUploadDropZoneTitle.fontSize, tokens.customUploadDropZoneTitle.fontWeight, - tokens.customUploadDropZoneTitle.color + tokens.customUploadDropZoneTitle.color, ); const customUploadDropZoneSubtitleClassName = cx( tokens.customUploadDropZoneSubtitle.fontSize, - tokens.customUploadDropZoneSubtitle.color + tokens.customUploadDropZoneSubtitle.color, ); - const uploadZoneIcon = useIcon("file", uploadIcon, { size: tokens.iconSize, className: `mx-auto ${tokens.iconColor}`}) + const uploadZoneIcon = useIcon("file", uploadIcon, { + size: tokens.iconSize, + className: `mx-auto ${tokens.iconColor}`, + }); return (
- {uploadZoneIcon} -
- -
+ {!uploadActive || !loader + ? uploadZoneIcon + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + loader(uploadPercentage!)} +

{subtitle}

); } + +type DragZoneLoaderProps = { + uploadPercentage?: number; + spinner?: boolean; + percentage?: boolean; +} & TokenProps<"DragZone">; + +export function DragZoneLoader({ uploadPercentage, spinner = true, percentage = true, ...props }: DragZoneLoaderProps) { + const tokens = useTokens("DragZone", props.tokens); + + if (!spinner && !percentage) { + console.warn( + "DragZoneLoader is not configured to show spinner and/or percentage. " + + "At least one of these props needs to be enabled in order for the component to display something.", + ); + } + + return ( +
+ {spinner && } + {percentage && {uploadPercentage}%} +
+ ); +} diff --git a/libs/upload/src/index.tsx b/libs/upload/src/index.tsx index 3c1615d..97e1afe 100644 --- a/libs/upload/src/index.tsx +++ b/libs/upload/src/index.tsx @@ -27,6 +27,6 @@ export type File = InternalFile; export { default as useFileUpload } from "./useFileUpload"; -export { default as DragZone } from "./DragZone"; +export { default as DragZone, DragZoneLoader } from "./DragZone"; export { default as FileList } from "./FileList"; export { default as UploadButton } from "./UploadButton"; diff --git a/storybook/src/formik-elements/DragZoneField.stories.tsx b/storybook/src/formik-elements/DragZoneField.stories.tsx index 0d589f5..ec88eec 100644 --- a/storybook/src/formik-elements/DragZoneField.stories.tsx +++ b/storybook/src/formik-elements/DragZoneField.stories.tsx @@ -22,7 +22,7 @@ import { withDesign } from "storybook-addon-designs"; import { IconButton } from "@tiller-ds/core"; import { DragZoneField } from "@tiller-ds/formik-elements"; import { Intl } from "@tiller-ds/intl"; -import { FileList, useFileUpload } from "@tiller-ds/upload"; +import { DragZoneLoader, FileList, useFileUpload } from "@tiller-ds/upload"; import { Icon } from "@tiller-ds/icons"; import { UPLOADER_EVENTS } from "@rpldy/uploader"; @@ -84,6 +84,40 @@ export const Simple = () => { ); }; +export const WithSpinnerOnly = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload(); + + return ( + } + loader={() => } + /> + ); +}; + +export const WithNoLoader = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload(); + + return ( + } + loader={null} + /> + ); +}; + export const WithSubtitle = () => { // incl-code // hook initialization @@ -153,6 +187,44 @@ export const WithFileList = () => { ); }; +export const WithFileListAndCustomLoader = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload(); + + return ( +
+ } + loader={(percentage) => ( + Uploading... {percentage}% + )} + /> + + {(file, helpers) => ( + + {file.name} + + } + label={} + onClick={() => { + return helpers.deleteFile(file); + }} + /> + + + )} + +
+ ); +}; + export const WithSingleFileUpload = () => { // incl-code // hook initialization diff --git a/storybook/src/upload/DragZone.stories.tsx b/storybook/src/upload/DragZone.stories.tsx index 54676f0..0ac68c7 100644 --- a/storybook/src/upload/DragZone.stories.tsx +++ b/storybook/src/upload/DragZone.stories.tsx @@ -22,7 +22,7 @@ import { withDesign } from "storybook-addon-designs"; import { IconButton } from "@tiller-ds/core"; import { Icon } from "@tiller-ds/icons"; import { Intl } from "@tiller-ds/intl"; -import { DragZone, FileList, useFileUpload, File } from "@tiller-ds/upload"; +import { DragZone, FileList, useFileUpload, File, DragZoneLoader } from "@tiller-ds/upload"; import { beautifySource, useMockSender } from "../utils"; @@ -48,47 +48,112 @@ export default { export const Simple = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - /> + <> + } + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + + ); +}; + +export const WithSpinnerOnly = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload([], true); + + return ( + <> + } + loader={() => } + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + + ); +}; + +export const WithPercentageOnly = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload([], true); + + return ( + <> + } + loader={() => } + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + + ); +}; + +export const WithNoLoader = () => { + // incl-code + // hook initialization + const useFileUploadHook = useFileUpload([], true); + + return ( + <> + } + loader={null} + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; export const WithSubtitle = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - subtitle={} - /> + <> + } + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; export const WithMultipleFiles = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - /> + <> + } + allowMultiple={true} + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; @@ -101,7 +166,7 @@ export const WithFileList = () => { ]; // hook initialization - const useFileUploadHook = useFileUpload(initialFiles); + const useFileUploadHook = useFileUpload(initialFiles, true); return (
@@ -132,58 +197,110 @@ export const WithFileList = () => { ); }; +export const WithFileListAndCustomLoader = () => { + // incl-code + // files list + const initialFiles: File[] = [ + { id: "1", name: "test1.pdf", status: "finished" }, + { id: "2", name: "test2.pdf", status: "finished" }, + ]; + + // hook initialization + const useFileUploadHook = useFileUpload(initialFiles, true); + + return ( +
+ } + loader={(percentage) => ( + Uploading... {percentage}% + )} + /> + + {(file, helpers) => ( + + {file.name} + + } + label={} + onClick={() => { + return helpers.deleteFile(file); + }} + /> + + + )} + +
+ ); +}; + export const WithLabel = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - label={} - /> + <> + } + label={} + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; export const WithHelp = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - help={} - /> + <> + } + help={} + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; export const WithError = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( - } - error={} - /> + <> + } + error={} + /> + Uploaded: {useFileUploadHook.uploadedFiles.map((file) => file.originalFileName).join(", ") || "None"} + ); }; export const Disabled = () => { // incl-code // hook initialization - const useFileUploadHook = useFileUpload(); + const useFileUploadHook = useFileUpload([], true); return ( { /> ); }; + +export const WithManualFileTracking = () => { + // incl-code + // hook initialization + const fileUploadHookValue = useFileUpload(); + const [uploadedFileIds, setUploadedFileIds] = React.useState([]); + + React.useEffect(() => { + fileUploadHookValue.onUpdateCallback((added, removed) => { + setUploadedFileIds((oldFileIds) => { + let newFileIds = [...oldFileIds]; + if (added !== undefined) { + if (typeof added === "string") { + newFileIds.push(added); + } + } + if (removed !== undefined) { + newFileIds = newFileIds.filter((id) => id !== removed); + } + // update internal file id list with correct updated list + fileUploadHookValue.onUploadedFileIds(newFileIds); + return newFileIds; + }); + }); + }, []); + + return ( + <> + } + allowMultiple={true} + /> + Uploaded ids: {uploadedFileIds.join(", ") || "None"} + + ); +};