Skip to content

Commit

Permalink
Merge pull request #577 from Availity/feat/file-selector
Browse files Browse the repository at this point in the history
feat(mui-file-selector): add support for dropping files and fix removing
  • Loading branch information
jordan-a-young authored Dec 16, 2024
2 parents 037ac8b + cbcc042 commit 23a7b69
Show file tree
Hide file tree
Showing 27 changed files with 718 additions and 465 deletions.
3 changes: 2 additions & 1 deletion apps/element-storybook/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import { themes } from '@storybook/theming';
import { Preview } from '@storybook/react';
import { Title, Subtitle, Description, Primary, Controls, Stories, useOf } from '@storybook/blocks';
Expand Down Expand Up @@ -37,7 +38,7 @@ const preview: Preview = {
theme: themes.light,
source: {
excludeDecorators: true,
type: 'code'
type: 'code',
},
page: () => {
// https://github.com/storybookjs/storybook/blob/next/code/ui/blocks/src/blocks/DocsPage.tsx
Expand Down
5 changes: 3 additions & 2 deletions packages/file-selector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
},
"dependencies": {
"@availity/api-axios": "^9.0.4",
"@availity/mui-alert": "workspace:^",
"@availity/mui-button": "workspace:^",
"@availity/mui-divider": "workspace:^",
"@availity/mui-form-utils": "workspace:^",
Expand All @@ -41,15 +42,15 @@
"@availity/mui-list": "workspace:^",
"@availity/mui-progress": "workspace:^",
"@availity/mui-typography": "workspace:^",
"@availity/upload-core": "^6.1.1",
"@availity/upload-core": "7.0.0-alpha.5",
"@tanstack/react-query": "^4.36.1",
"react-dropzone": "^11.7.1",
"react-hook-form": "^7.51.3",
"tus-js-client": "4.2.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@mui/material": "^5.15.15",
"@types/tus-js-client": "^1.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"tsup": "^8.0.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/file-selector/src/lib/Dropzone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('Dropzone', () => {
render(
<QueryClientProvider client={client}>
<TestForm>
<Dropzone name="test" bucketId="test" customerId="123" clientId="test" maxSize={1000} />
<Dropzone name="test" maxSize={1000} setTotalSize={jest.fn()} />
</TestForm>
</QueryClientProvider>
);
Expand Down
231 changes: 122 additions & 109 deletions packages/file-selector/src/lib/Dropzone.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { ChangeEvent, MouseEvent, useCallback, useState } from 'react';
import { useDropzone, FileRejection, DropEvent } from 'react-dropzone';
import { v4 as uuid } from 'uuid';
import { Dispatch, MouseEvent, useCallback, ChangeEvent } from 'react';
import { useDropzone, FileRejection } from 'react-dropzone';
import { Divider } from '@availity/mui-divider';
import { CloudDownloadIcon } from '@availity/mui-icon';
import { Box, Stack } from '@availity/mui-layout';
import { Typography } from '@availity/mui-typography';
import Upload, { Options } from '@availity/upload-core';

import { FilePickerBtn } from './FilePickerBtn';
import { useFormContext } from 'react-hook-form';

const outerBoxStyles = {
backgroundColor: 'background.canvas',
Expand All @@ -21,141 +20,158 @@ const innerBoxStyles = {
height: '100%',
};

const CLOUD_URL = '/cloud/web/appl/vault/upload/v1/resumable';
/** Counter for creating unique id */
const createCounter = () => {
let id = 0;
const increment = () => (id += 1);
return {
id,
increment,
};
};

const counter = createCounter();

export type DropzoneProps = {
/**
* Name given to the input field. Used by react-hook-form
*/
name: string;
bucketId: string;
clientId: string;
customerId: string;
allowedFileNameCharacters?: string;
/**
* List of allowed file extensions (e.g. ['.pdf', '.doc']). Each extension must start with a dot
*/
allowedFileTypes?: `.${string}`[];
deliverFileOnSubmit?: boolean;
deliveryChannel?: string;
/**
* Whether the dropzone is disabled
*/
disabled?: boolean;
endpoint?: string;
fileDeliveryMetadata?: Record<string, unknown> | ((file: Upload) => Record<string, unknown>);
getDropRejectionMessages?: (fileRejectsions: FileRejection[]) => void;
isCloud?: boolean;
/**
* Maximum number of files that can be uploaded
*/
maxFiles?: number;
/**
* Maximum size of each file in bytes
*/
maxSize?: number;
/**
* Whether multiple file selection is allowed
*/
multiple?: boolean;
/**
* Handler called when the file input's value changes
*/
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
/**
* Handler called when the file picker button is clicked
*/
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
// onDeliveryError?: (responses: unknown[]) => void;
// onDeliverySuccess?: (responses: unknown[]) => void;
onFileDelivery?: (upload: Upload) => void;
onFilePreUpload?: ((upload: Upload) => boolean)[];
/**
* Callback to handle rejected files that don't meet validation criteria
*/
setFileRejections?: (fileRejections: (FileRejection & { id: number })[]) => void;
/**
* Callback to update the total size of all uploaded files
*/
setTotalSize: Dispatch<React.SetStateAction<number>>;
};

// The types below were props used in the availity-react implementation.
// Perserving this here in case it needs to be added back
// deliverFileOnSubmit?: boolean;
// deliveryChannel?: string;
// fileDeliveryMetadata?: Record<string, unknown> | ((file: Upload) => Record<string, unknown>);
// onDeliveryError?: (responses: unknown[]) => void;
// onDeliverySuccess?: (responses: unknown[]) => void;
// onFileDelivery?: (upload: Upload) => void;

export const Dropzone = ({
allowedFileNameCharacters,
allowedFileTypes = [],
bucketId,
clientId,
customerId,
deliveryChannel,
// deliverFileOnSubmit,
fileDeliveryMetadata,
disabled,
endpoint,
getDropRejectionMessages,
isCloud,
maxFiles,
maxSize,
multiple,
name,
onChange,
onClick,
onFilePreUpload,
onFileDelivery,
setFileRejections,
setTotalSize,
}: DropzoneProps) => {
const [totalSize, setTotalSize] = useState(0);
const [files, setFiles] = useState<Upload[]>([]);
const { setValue, watch } = useFormContext();

const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: FileRejection[], dropEvent: DropEvent) => {
// Do something with the files
console.log('Dropzone acceptedFiles:', acceptedFiles);
console.log('Dropzone fileRejections:', fileRejections);
console.log('Dropzone dropEvent:', dropEvent);

// Verify we have not exceeded max number of files
if (maxFiles && acceptedFiles.length > maxFiles) {
acceptedFiles.slice(0, Math.max(9, maxFiles));
const validator = useCallback(
(file: File) => {
const previous: File[] = watch(name) ?? [];

const isDuplicate = previous.some((prev) => prev.name === file.name);
if (isDuplicate) {
return {
code: 'duplicate-name',
message: 'A file with this name already exists',
};
}

const uploads = acceptedFiles.map((file) => {
const options: Options = {
bucketId,
customerId,
clientId,
fileTypes: allowedFileTypes,
maxSize,
allowedFileNameCharacters,
const hasMaxFiles = maxFiles && previous.length >= maxFiles;
if (hasMaxFiles) {
return {
code: 'too-many-files',
message: `Too many files. You may only upload ${maxFiles} file(s).`,
};
}

if (onFilePreUpload) options.onPreStart = onFilePreUpload;
if (endpoint) options.endpoint = endpoint;
if (isCloud) options.endpoint = CLOUD_URL;

const upload = new Upload(file, options);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
upload.id = `${upload.id}-${uuid()}`;

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (file.dropRejectionMessage) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
upload.errorMessage = file.dropRejectionMessage;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
} else if (maxSize && totalSize + newFilesTotalSize + upload.file.size > maxSize) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
upload.errorMessage = 'Total documents size is too large';
} else {
upload.start();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
newFilesTotalSize += upload.file.size;
}
if (onFileDelivery) {
onFileDelivery(upload);
} else if (deliveryChannel && fileDeliveryMetadata) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// upload.onSuccess.push(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// if (upload?.references?.[0]) {
// allow form to revalidate when upload is complete
// setFieldTouched(name, true);
// deliver upon upload complete, not form submit
// if (!deliverFileOnSubmit) {
// callFileDelivery(upload);
// }
// }
// });
}
return null;
},
[maxFiles]
);

const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: (FileRejection & { id: number })[]) => {
let newSize = 0;
for (const file of acceptedFiles) {
newSize += file.size;
}

setTotalSize((prev) => prev + newSize);

return upload;
});
const previous = watch(name) ?? [];

// Set uploads somewhere. state?
setFiles(files);
// Set accepted files to form context
setValue(name, previous.concat(acceptedFiles));

if (getDropRejectionMessages) getDropRejectionMessages(fileRejections);
if (fileRejections.length > 0) {
for (const rejection of fileRejections) {
rejection.id = counter.increment();
}
}

if (setFileRejections) setFileRejections(fileRejections);
},
[getDropRejectionMessages]
[setFileRejections]
);

const { getRootProps, getInputProps } = useDropzone({ onDrop });

const accept = allowedFileTypes.join(',');

const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize,
maxFiles,
disabled,
multiple,
accept,
validator,
});

const inputProps = getInputProps({
multiple,
accept,
onChange,
});

const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (inputProps.onChange) {
inputProps.onChange(event);
}
};

return (
<Box sx={outerBoxStyles} {...getRootProps()}>
<Box sx={innerBoxStyles}>
Expand All @@ -171,11 +187,8 @@ export const Dropzone = ({
disabled={disabled}
maxSize={maxSize}
onClick={onClick}
inputProps={getInputProps({
multiple,
accept,
onChange,
})}
inputProps={inputProps}
onChange={handleOnChange}
/>
</>
</Stack>
Expand Down
11 changes: 11 additions & 0 deletions packages/file-selector/src/lib/ErrorAlert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';

import { ErrorAlert } from './ErrorAlert';

describe('ErrorAlert', () => {
test('should render error message', () => {
render(<ErrorAlert id={0} errors={[{ code: 'test', message: 'example' }]} fileName="file" onClose={jest.fn()} />);

expect(screen.getByText('Error: file')).toBeDefined();
});
});
46 changes: 46 additions & 0 deletions packages/file-selector/src/lib/ErrorAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Alert, AlertTitle } from '@availity/mui-alert';
import type { FileRejection } from 'react-dropzone';

const codes: Record<string, string> = {
'file-too-large': 'File exceeds maximum size',
'file-invalid-type': 'File has an invalid type',
'file-too-small': 'File is smaller than minimum size',
'too-many-file': 'Too many files',
'duplicate-name': 'Duplicate file selected',
};

export type ErrorAlertProps = {
/**
* Array of file rejection errors
*/
errors: FileRejection['errors'];
/**
* Name of the file that encountered errors
*/
fileName: string;
/**
* Unique identifier for the error alert
*/
id: number;

onClose: () => void;
};

export const ErrorAlert = ({ errors, fileName, id, onClose }: ErrorAlertProps) => {
if (errors.length === 0) return null;

return (
<>
{errors.map((error) => {
return (
<Alert severity="error" onClose={onClose} key={`${id}-${error.code}`}>
<AlertTitle>
{codes[error.code] || 'Error'}: {fileName}
</AlertTitle>
{error.message}
</Alert>
);
})}
</>
);
};
Loading

0 comments on commit 23a7b69

Please sign in to comment.