diff --git a/apps/element-storybook/.storybook/preview.tsx b/apps/element-storybook/.storybook/preview.tsx index 5639bf0e7..315d4517b 100644 --- a/apps/element-storybook/.storybook/preview.tsx +++ b/apps/element-storybook/.storybook/preview.tsx @@ -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'; @@ -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 diff --git a/packages/file-selector/package.json b/packages/file-selector/package.json index 0f0339e93..a73ca35f4 100644 --- a/packages/file-selector/package.json +++ b/packages/file-selector/package.json @@ -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:^", @@ -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", diff --git a/packages/file-selector/src/lib/Dropzone.test.tsx b/packages/file-selector/src/lib/Dropzone.test.tsx index 343b185da..04d22850f 100644 --- a/packages/file-selector/src/lib/Dropzone.test.tsx +++ b/packages/file-selector/src/lib/Dropzone.test.tsx @@ -18,7 +18,7 @@ describe('Dropzone', () => { render( - + ); diff --git a/packages/file-selector/src/lib/Dropzone.tsx b/packages/file-selector/src/lib/Dropzone.tsx index 40485b2bc..11363a44a 100644 --- a/packages/file-selector/src/lib/Dropzone.tsx +++ b/packages/file-selector/src/lib/Dropzone.tsx @@ -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', @@ -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 | ((file: Upload) => Record); - 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) => void; + /** + * Handler called when the file picker button is clicked + */ onClick?: (event: MouseEvent) => 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>; }; +// 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 | ((file: Upload) => Record); +// 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([]); + 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) => { + if (inputProps.onChange) { + inputProps.onChange(event); + } + }; + return ( @@ -171,11 +187,8 @@ export const Dropzone = ({ disabled={disabled} maxSize={maxSize} onClick={onClick} - inputProps={getInputProps({ - multiple, - accept, - onChange, - })} + inputProps={inputProps} + onChange={handleOnChange} /> diff --git a/packages/file-selector/src/lib/ErrorAlert.test.tsx b/packages/file-selector/src/lib/ErrorAlert.test.tsx new file mode 100644 index 000000000..6dc298005 --- /dev/null +++ b/packages/file-selector/src/lib/ErrorAlert.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; + +import { ErrorAlert } from './ErrorAlert'; + +describe('ErrorAlert', () => { + test('should render error message', () => { + render(); + + expect(screen.getByText('Error: file')).toBeDefined(); + }); +}); diff --git a/packages/file-selector/src/lib/ErrorAlert.tsx b/packages/file-selector/src/lib/ErrorAlert.tsx new file mode 100644 index 000000000..6718b11f5 --- /dev/null +++ b/packages/file-selector/src/lib/ErrorAlert.tsx @@ -0,0 +1,46 @@ +import { Alert, AlertTitle } from '@availity/mui-alert'; +import type { FileRejection } from 'react-dropzone'; + +const codes: Record = { + '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 ( + + + {codes[error.code] || 'Error'}: {fileName} + + {error.message} + + ); + })} + + ); +}; diff --git a/packages/file-selector/src/lib/FileList.test.tsx b/packages/file-selector/src/lib/FileList.test.tsx index 40f798826..9652e7b84 100644 --- a/packages/file-selector/src/lib/FileList.test.tsx +++ b/packages/file-selector/src/lib/FileList.test.tsx @@ -1,16 +1,50 @@ -import { render } from '@testing-library/react'; +import { screen, render, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { FileList } from './FileList'; describe('FileList', () => { - test('should render successfully', () => { + test('should render successfully', async () => { + const mockFile = new File(['file content'], 'mock.txt', { type: 'text/plain' }); + render( - { - // noop - }} - /> + + { + // noop + }} + /> + ); + + await waitFor(() => { + expect(screen.getByText('mock.txt')).toBeDefined(); + }); + }); + + test('should call onRemoveFile', async () => { + const mockRemove = jest.fn(); + const mockFile = new File(['file content'], 'mock.txt', { type: 'text/plain' }); + + render( + + { + mockRemove(id); + }} + /> + + ); + + await waitFor(() => { + expect(screen.getByText('mock.txt')).toBeDefined(); + }); + + fireEvent.click(screen.getByRole('button')); + expect(mockRemove).toHaveBeenCalled(); }); }); diff --git a/packages/file-selector/src/lib/FileList.tsx b/packages/file-selector/src/lib/FileList.tsx index 1b0fc3be7..a6336e934 100644 --- a/packages/file-selector/src/lib/FileList.tsx +++ b/packages/file-selector/src/lib/FileList.tsx @@ -1,24 +1,35 @@ -import type Upload from '@availity/upload-core'; +import type { default as Upload, UploadOptions } from '@availity/upload-core'; import { List, ListItem, ListItemText, ListItemIcon, ListItemButton } from '@availity/mui-list'; import { DeleteIcon, FileIcon } from '@availity/mui-icon'; import { Grid } from '@availity/mui-layout'; import { UploadProgressBar } from './UploadProgressBar'; import { formatBytes, getFileExtIcon } from './util'; +import { useUploadCore } from './useUploadCore'; type FileRowProps = { - /** The upload instance returned by creating a new Upload via @availity/upload-core. */ - upload: Upload; - /** Callback called when file is removed. The callback is passed the id of the file that was removed. */ - onRemoveFile: (id: string) => void; + /** The File object containing information about the uploaded file */ + file: File; + /** + * Callback function called when a file is removed + * @param id - The unique identifier of the file being removed + * @param upload - The Upload instance associated with the file + */ + onRemoveFile: (id: string, upload: Upload) => void; + /** Configuration options for the upload process */ + options: UploadOptions; }; -const FileRow = ({ upload, onRemoveFile }: FileRowProps) => { - const { ext, icon } = getFileExtIcon(upload.file.name); +const FileRow = ({ file, options, onRemoveFile }: FileRowProps) => { + const { ext, icon } = getFileExtIcon(file.name); console.log('ext, icon:', ext, icon); + const { data: upload } = useUploadCore(file, options); + + if (!upload) return null; + return ( - + <> @@ -35,35 +46,45 @@ const FileRow = ({ upload, onRemoveFile }: FileRowProps) => { - - { - onRemoveFile(upload.id); - }} - > + { + onRemoveFile(upload.id, upload); + }} + > + - + ); }; export type FileListProps = { - /** List of Upload objects */ - uploads: Upload[]; - /** Callback called when file is removed. The callback is passed the id of the file that was removed. */ - onRemoveFile: (id: string) => void; + /** + * Array of File objects to be displayed in the list + */ + files: File[]; + /** + * Callback function called when a file is removed from the list + * @param id - The unique identifier of the file being removed + * @param upload - The Upload instance associated with the file + */ + onRemoveFile: (id: string, upload: Upload) => void; + /** + * Configuration options applied to all file uploads in the list + */ + options: UploadOptions; }; -export const FileList = ({ uploads, onRemoveFile }: FileListProps) => { - if (uploads.length === 0) return null; +export const FileList = ({ files, options, onRemoveFile }: FileListProps) => { + if (files.length === 0) return null; return ( - {uploads.map((upload) => { - return ; + {files.map((file) => { + return ; })} ); diff --git a/packages/file-selector/src/lib/FilePickerBtn.test.tsx b/packages/file-selector/src/lib/FilePickerBtn.test.tsx index f2409d818..2619d34e4 100644 --- a/packages/file-selector/src/lib/FilePickerBtn.test.tsx +++ b/packages/file-selector/src/lib/FilePickerBtn.test.tsx @@ -10,7 +10,7 @@ const TestForm = () => { return ( - + ); }; diff --git a/packages/file-selector/src/lib/FilePickerBtn.tsx b/packages/file-selector/src/lib/FilePickerBtn.tsx index 1337b2d2d..a6f91472c 100644 --- a/packages/file-selector/src/lib/FilePickerBtn.tsx +++ b/packages/file-selector/src/lib/FilePickerBtn.tsx @@ -1,14 +1,30 @@ -import { ChangeEvent, MouseEvent, RefObject } from 'react'; +import { ChangeEvent, RefObject } from 'react'; import type { DropzoneInputProps } from 'react-dropzone'; import { useFormContext } from 'react-hook-form'; import { Button, ButtonProps } from '@availity/mui-button'; import { Input } from '@availity/mui-form-utils'; type FilePickerBtnProps = { + /** + * Name attribute for the input field, used by react-hook-form for form state management. + */ name: string; - maxSize?: number; + /** + * Callback function triggered when files are selected through the input. + */ + onChange: (event: ChangeEvent) => void; + /** + * Optional ID attribute for the file input element. + */ inputId?: string; + /** + * Additional props to customize the underlying input element. + */ inputProps?: DropzoneInputProps & { ref?: RefObject }; + /** + * Maximum allowed size per file in bytes. Files exceeding this size will be rejected. + */ + maxSize?: number; } & Omit; export const FilePickerBtn = ({ @@ -18,35 +34,13 @@ export const FilePickerBtn = ({ inputId, inputProps = {}, maxSize, + onChange, onClick, ...rest }: FilePickerBtnProps) => { - const { register, setValue } = useFormContext(); - - const { accept, multiple, ref, style, type: inputType, onChange } = inputProps; - - const handleOnChange = (event: ChangeEvent) => { - const { files } = event.target; - - const value: File[] = []; - if (files) { - // FileList is not iterable. Must use for loop for now - for (let i = 0; i < files.length; i++) { - if (maxSize) { - console.log('file is too big:', files[i].size > maxSize); - } - value[i] = files[i]; - } - } - - setValue(name, value); - - // if (onChange) onChange(event); - }; + const { register } = useFormContext(); - const handleOnClick = (event: MouseEvent) => { - if (onClick) onClick(event); - }; + const { accept, multiple, ref, style, type: inputType } = inputProps; const field = register(name); @@ -54,7 +48,7 @@ export const FilePickerBtn = ({ <> - diff --git a/packages/file-selector/src/lib/FilePickerInput.md b/packages/file-selector/src/lib/FilePickerInput.md deleted file mode 100644 index 6c2f96387..000000000 --- a/packages/file-selector/src/lib/FilePickerInput.md +++ /dev/null @@ -1,82 +0,0 @@ -import { ChangeEvent, RefObject, useState } from 'react'; -import { Input } from '@availity/mui-form-utils'; - -const idCounter = () => { - let id = 0; - const increment = () => (id += 1); - const generateId = () => { - return `filepicker-${increment()}`; - }; - return { - generateId, - }; -}; - -const counter = idCounter(); - -const inputSx = { display: 'none' }; - -export type FilePickerInputProps = { - /** Identifies the field and matches the validation schema. */ - name: string; - /** The file types you want to restrict uploading to. eg: ['.jpeg', '.jpg']. */ - allowedFileTypes?: `.${string}`[]; - /** id passed to the input component. It is randomly generated if not passed */ - id?: string; - /** ref passed to the input component */ - inputRef?: RefObject; - /** The maximum file size (in bytes) for a file to be uploaded. */ - maxSize?: number; - /** Indicates that the user will be allowed to select multiple files when selecting files from the OS prompt. */ - multiple?: boolean; - /** Callback when the user has selected a file or multiple files. */ - onChange?: (event: ChangeEvent) => void; -}; - -export const FilePickerInput = ({ - allowedFileTypes, - id, - inputRef, - maxSize, - multiple, - name, - onChange, -}: FilePickerInputProps) => { - const [stateId] = useState(counter.generateId()); - - const handleOnChange = (event: ChangeEvent) => { - const { files } = event.target; - - // TODO: get size of file and compare to maxSize - - const value: File[] = []; - if (files) { - // FileList is not iterable. Must use for loop for now - for (let i = 0; i < files.length; i++) { - if (maxSize) { - console.log('file is too big:', files[i].size > maxSize); - } - value[i] = files[i]; - } - } - - if (onChange) onChange(event); - }; - - const inputId = id || stateId; - - return ( - 0 ? allowedFileTypes.join(',') : undefined, - multiple, - }} - id={inputId} - onChange={handleOnChange} - /> - ); -}; diff --git a/packages/file-selector/src/lib/FileRow.md b/packages/file-selector/src/lib/FileRow.md deleted file mode 100644 index 0ad968dc1..000000000 --- a/packages/file-selector/src/lib/FileRow.md +++ /dev/null @@ -1,70 +0,0 @@ -import { Button } from 'reactstrap'; -import Icon from '@availity/icon'; -import type Upload from '@availity/upload-core'; - -import { UploadProgressBar } from './UploadProgressBar'; - -const FILE_EXT_ICONS = { - png: 'file-image', - jpg: 'file-image', - jpeg: 'file-image', - gif: 'file-image', - ppt: 'file-powerpoint', - pptx: 'file-powerpoint', - xls: 'file-excel', - xlsx: 'file-excel', - doc: 'file-word', - docx: 'file-word', - txt: 'doc-alt', - text: 'doc-alt', - zip: 'file-archive', - '7zip': 'file-archive', - xml: 'file-code', - html: 'file-code', - pdf: 'file-pdf', -} as const; - -type FileExtensionKey = keyof typeof FILE_EXT_ICONS; - -const isValidKey = (key: string): key is FileExtensionKey => { - return key ? key in FILE_EXT_ICONS : false; -}; - -export type FileRowProps = { - upload: Upload; - onRemoveFile: (id: string) => void; -}; - -export const FileRow = ({ upload, onRemoveFile }: FileRowProps) => { - const remove = () => { - onRemoveFile(upload.id); - }; - - const ext = upload.file.name.split('.').pop()?.toLowerCase() || ''; - const icon = isValidKey(ext) ? FILE_EXT_ICONS[ext] : 'doc'; - const extLabel = ext.toUpperCase(); - - return ( - - - - {extLabel} File Icon - {' '} - - -
- {upload.file.name} -
- - - - - - - - - ); -}; diff --git a/packages/file-selector/src/lib/FileSelector.stories.tsx b/packages/file-selector/src/lib/FileSelector.stories.tsx index 595ca826b..6a9aef096 100644 --- a/packages/file-selector/src/lib/FileSelector.stories.tsx +++ b/packages/file-selector/src/lib/FileSelector.stories.tsx @@ -3,16 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Paper } from '@availity/mui-paper'; -// import { FileSelector, FileSelectorProps } from './FileSelector'; - -type FileSelectorProps = { - name: string; -}; - -const FileSelector = (props: FileSelectorProps) => { - console.log(props); - return
placeholder
; -}; +import { FileSelector, FileSelectorProps } from './FileSelector'; const meta: Meta = { title: 'Components/File Selector/File Selector', @@ -47,12 +38,13 @@ export const _FileSelector: StoryObj = { ), args: { name: 'file-selector', - // allowedFileTypes: ['.txt'], - // clientId: '123', - // customerId: '456', - // bucketId: '789', - // maxSize: 1 * 1000 * 1000, // 1MB - // isCloud: true, - // multiple: true, + allowedFileTypes: [], + clientId: '123', + customerId: '456', + bucketId: '789', + retryDelays: [], + maxSize: 1 * 1024 * 1024, // 1MB + isCloud: true, + multiple: true, }, }; diff --git a/packages/file-selector/src/lib/FileSelector.tsx b/packages/file-selector/src/lib/FileSelector.tsx index 3f0061814..d60e96d41 100644 --- a/packages/file-selector/src/lib/FileSelector.tsx +++ b/packages/file-selector/src/lib/FileSelector.tsx @@ -1,45 +1,130 @@ -import { ReactNode, useState } from 'react'; +import { ChangeEvent, ReactNode, useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import type { FileRejection } from 'react-dropzone/typings/react-dropzone'; -import Upload, { Options } from '@availity/upload-core'; +import { useQueryClient } from '@tanstack/react-query'; +import Upload, { UploadOptions } from '@availity/upload-core'; +import { Button } from '@availity/mui-button'; +import { Grid } from '@availity/mui-layout'; +import { Typography } from '@availity/mui-typography'; import { Dropzone } from './Dropzone'; +import { ErrorAlert } from './ErrorAlert'; import { FileList } from './FileList'; import { FileTypesMessage } from './FileTypesMessage'; -import { useUploadCore } from './useUploadCore'; -import { Typography } from '@availity/mui-typography'; const CLOUD_URL = '/cloud/web/appl/vault/upload/v1/resumable'; export type FileSelectorProps = { + /** + * Name attribute for the form field. Used by react-hook-form for form state management + * and must be unique within the form context + */ name: string; + /** + * The ID of the bucket where files will be uploaded + */ bucketId: string; + /** + * The customer ID associated with the upload + */ customerId: string; + /** + * Regular expression pattern of allowed characters in file names + * @example "a-zA-Z0-9-_." + */ allowedFileNameCharacters?: string; + /** + * List of allowed file extensions. Each extension must start with a dot + * @example ['.pdf', '.doc', '.docx'] + * @default [] + */ allowedFileTypes?: `.${string}`[]; + /** + * Optional content to render below the file upload area + */ children?: ReactNode; + /** + * Client identifier used for upload authentication + */ clientId: string; - deliverFileOnSubmit?: boolean; - deliveryChannel?: string; + /** + * Whether the file selector is disabled + * @default false + */ disabled?: boolean; + /** + * Custom endpoint URL for file uploads. If not provided, default endpoint will be used + */ endpoint?: string; - fileDeliveryMetadata?: Record | ((file: Upload) => Record); - getDropRejectionMessages?: (rejections: FileRejection[]) => void; + /** + * Whether to use the cloud upload endpoint + * When true, uses '/cloud/web/appl/vault/upload/v1/resumable' + */ isCloud?: boolean; + /** + * Label text or element displayed above the upload area + * @default 'Upload file' + */ label?: ReactNode; + /** + * Maximum number of files that can be uploaded simultaneously + */ maxFiles?: number; + /** + * Maximum file size allowed per file in bytes + * Use Kibi or Mibibytes. eg: 1kb = 1024 bytes; 1mb = 1024kb + */ maxSize: number; + /** + * Whether multiple file selection is allowed + * @default true + */ multiple?: boolean; - onDeliveryError?: (error: unknown) => void; - onDeliverySuccess?: () => void; - onSubmit?: (values: Record) => void; - onSuccess?: (() => void)[]; - onError?: ((error: Error) => void)[]; + /** + * Callback fired when files are selected + * @param event - The change event containing the selected file(s) + */ + onChange?: (event: ChangeEvent) => void; + /** + * Callback fired when the form is submitted + * @param uploads - Array of Upload instances for the submitted files + * @param values - Object containing the form values, with files indexed by the name prop + */ + onSubmit?: (uploads: Upload[], values: Record) => void; + /** + * Callback fired when a file is successfully uploaded + */ + onSuccess?: UploadOptions['onSuccess']; + /** + * Callback fired when an error occurs during upload + */ + onError?: UploadOptions['onError']; + /** + * Array of functions to execute before file upload begins. + * Each function should return a boolean indicating whether to proceed with the upload. + * @default [] + */ onFilePreUpload?: (() => boolean)[]; - onUploadRemove?: (uploads: Upload[], removedUploadId: string) => void; - onFileDelivery?: (upload: Upload) => void; + /** + * Callback fired when a file is removed from the upload list + * @param files - Array of remaining files + * @param removedUploadId - ID of the removed upload + */ + onUploadRemove?: (files: File[], removedUploadId: string) => void; + /** + * Array of delays (in milliseconds) between upload retry attempts + */ + retryDelays?: UploadOptions['retryDelays']; }; +// Below props were removed from availity-react version. Perserving here in case needed later +// deliverFileOnSubmit?: boolean; +// deliveryChannel?: string; +// fileDeliveryMetadata?: Record | ((file: Upload) => Record); +// onDeliveryError?: (error: unknown) => void; +// onDeliverySuccess?: () => void; +// onFileDelivery?: (upload: Upload) => void; + export const FileSelector = ({ name, allowedFileNameCharacters, @@ -48,36 +133,33 @@ export const FileSelector = ({ clientId, children, customerId, - deliverFileOnSubmit = false, - deliveryChannel, disabled = false, endpoint, - fileDeliveryMetadata, - getDropRejectionMessages, isCloud, label = 'Upload file', - maxFiles = 1, + maxFiles, maxSize, multiple = true, - // onDeliveryError, - // onDeliverySuccess, + onChange, onSubmit, onSuccess, onError, onFilePreUpload = [], onUploadRemove, - onFileDelivery, + retryDelays, }: FileSelectorProps) => { - // const classes = classNames( - // className, - // metadata.touched ? 'is-touched' : 'is-untouched', - // metadata.touched && metadata.error && 'is-invalid' - // ); const [totalSize, setTotalSize] = useState(0); + const [fileRejections, setFileRejections] = useState<(FileRejection & { id: number })[]>([]); + + const client = useQueryClient(); - const methods = useForm(); + const methods = useForm({ + defaultValues: { + [name]: [] as File[], + }, + }); - const options: Options = { + const options: UploadOptions = { bucketId, customerId, clientId, @@ -86,62 +168,86 @@ export const FileSelector = ({ allowedFileNameCharacters, onError, onSuccess, + retryDelays, }; if (onFilePreUpload) options.onPreStart = onFilePreUpload; if (endpoint) options.endpoint = endpoint; if (isCloud) options.endpoint = CLOUD_URL; - const { data: uploads = [] } = useUploadCore(methods.watch(name) || [], options); + const handleOnRemoveFile = (uploadId: string, upload: Upload) => { + const prevFiles = methods.watch(name); + const newFiles = prevFiles.filter((file) => file.name !== upload.file.name); - const handleOnRemoveFile = (uploadId: string) => { - const newFiles = uploads.filter((upload) => upload.id !== uploadId); - - if (newFiles.length !== uploads.length) { - const removedFile = uploads.find((upload) => upload.id === uploadId); + if (newFiles.length !== prevFiles.length) { + const removedFile = prevFiles.find((file) => file.name === upload.file.name); methods.setValue(name, newFiles); - if (!removedFile?.error && !removedFile?.errorMessage && removedFile?.file.size) - setTotalSize(totalSize - removedFile.file.size); + if (removedFile?.size) setTotalSize(totalSize - removedFile.size); + if (onUploadRemove) onUploadRemove(newFiles, uploadId); } }; - const handleOSubmit = (values: Record) => { - if (onSubmit) onSubmit(values); + const files = methods.watch(name); + + const handleOnSubmit = (values: Record) => { + if (values[name].length === 0) return; + + const queries = client.getQueriesData(['upload']); + const uploads = []; + for (const [, data] of queries) { + if (data) uploads.push(data); + } + + if (onSubmit) onSubmit(uploads, values); + }; + + const handleRemoveRejection = (id: number) => { + const rejections = fileRejections.filter((value) => value.id !== id); + setFileRejections(rejections); }; return ( -
+ <> {label} {children} - + + {fileRejections.length > 0 + ? fileRejections.map((rejection) => ( + handleRemoveRejection(rejection.id)} + /> + )) + : null} + + {files.length > 0 && ( + + + + )}
); diff --git a/packages/file-selector/src/lib/FileTypesMessage.test.tsx b/packages/file-selector/src/lib/FileTypesMessage.test.tsx index 12999fba4..47659e77d 100644 --- a/packages/file-selector/src/lib/FileTypesMessage.test.tsx +++ b/packages/file-selector/src/lib/FileTypesMessage.test.tsx @@ -8,4 +8,10 @@ describe('FileTypesMessage', () => { expect(screen.getByText(/All file types allowed/)).toBeTruthy(); }); + + test('should show file size', () => { + render(); + + expect(screen.getByText(/Maximum file size is/)).toBeTruthy(); + }); }); diff --git a/packages/file-selector/src/lib/FileTypesMessage.tsx b/packages/file-selector/src/lib/FileTypesMessage.tsx index 6dfd974be..ed270c923 100644 --- a/packages/file-selector/src/lib/FileTypesMessage.tsx +++ b/packages/file-selector/src/lib/FileTypesMessage.tsx @@ -3,7 +3,13 @@ import { Typography } from '@availity/mui-typography'; import { formatBytes } from './util'; type FileTypesMessageProps = { + /** + * Allowed file type extensions. Each extension should be prefixed with a ".". eg: .txt, .pdf, .png + */ allowedFileTypes: `.${string}`[]; + /** + * Maximum size per file in bytes. This will be formatted. eg: 1024 * 20 = 20 KB + */ maxFileSize: number; }; diff --git a/packages/file-selector/src/lib/HeaderMessage.tsx b/packages/file-selector/src/lib/HeaderMessage.tsx index c4563a4f9..2b8fdc284 100644 --- a/packages/file-selector/src/lib/HeaderMessage.tsx +++ b/packages/file-selector/src/lib/HeaderMessage.tsx @@ -3,7 +3,13 @@ import { Typography } from '@availity/mui-typography'; import { formatBytes } from './util'; export type HeaderMessageProps = { + /** + * Maximum number of files allowed + */ maxFiles: number; + /** + * Maximum combined total size of all files + */ maxSize: number; }; diff --git a/packages/file-selector/src/lib/UploadProgressBar.test.tsx b/packages/file-selector/src/lib/UploadProgressBar.test.tsx index c8f8b8f66..73a09f24e 100644 --- a/packages/file-selector/src/lib/UploadProgressBar.test.tsx +++ b/packages/file-selector/src/lib/UploadProgressBar.test.tsx @@ -20,4 +20,21 @@ describe('UploadProgressBar', () => { expect(screen.getByText('50%')).toBeTruthy(); }); + + test('should show error message', () => { + const mockUpload: unknown = { + onProgress: [], + onError: [], + onSuccess: [], + errorMessage: 'error message', + file: { + name: 'test', + }, + percentage: 0, + }; + + render(); + + expect(screen.getByText('error message')).toBeTruthy(); + }); }); diff --git a/packages/file-selector/src/lib/UploadProgressBar.tsx b/packages/file-selector/src/lib/UploadProgressBar.tsx index dd0b92ff5..023b3e1d8 100644 --- a/packages/file-selector/src/lib/UploadProgressBar.tsx +++ b/packages/file-selector/src/lib/UploadProgressBar.tsx @@ -6,13 +6,21 @@ import { Typography } from '@availity/mui-typography'; import { WarningTriangleIcon } from '@availity/mui-icon'; export type UploadProgressBarProps = { - /** The upload instance returned by creating a new Upload via @availity/upload-core. */ + /** + * The upload instance returned by creating a new Upload via @availity/upload-core. + */ upload: Upload; - /** Callback function to hook into the onProgress within the Upload instance provided in the upload prop. */ + /** + * Callback function to hook into the onProgress within the Upload instance provided in the upload prop. + */ onProgress?: (upload: Upload) => void; - /** Callback function to hook into the onSuccess within the Upload instance provided in the upload prop. */ + /** + * Callback function to hook into the onSuccess within the Upload instance provided in the upload prop. + */ onSuccess?: (upload: Upload) => void; - /** Callback function to hook into the onError within the Upload instance provided in the upload prop. */ + /** + * Callback function to hook into the onError within the Upload instance provided in the upload prop. + */ onError?: (upload: Upload) => void; }; diff --git a/packages/file-selector/src/lib/useFileDelivery.tsx b/packages/file-selector/src/lib/useFileDelivery.tsx index 1d97ce7e6..d3dd72e6c 100644 --- a/packages/file-selector/src/lib/useFileDelivery.tsx +++ b/packages/file-selector/src/lib/useFileDelivery.tsx @@ -4,14 +4,23 @@ import Upload from '@availity/upload-core'; import { AxiosResponse } from 'axios'; export type UploadDeliveryOptions = { + /** ID of the vault bucket */ bucketId: string; + /** Client ID to be attached to the request */ clientId: string; + /** Customer ID of the organization submitting the request */ customerId: string; + /** Delivery Channel for the AvFileDeliveryApi */ deliveryChannel?: string; + /** Determine whether AvFileDeliveryApi should be automatically called or if the component should wait */ deliverFileOnSubmit?: boolean; + /** Metadata to be sent with the request. Can be an object or function that returns an object */ fileDeliveryMetadata?: Record | ((upload: Upload) => Record); + /** Callback function for when the upload succeeds */ onSuccess?: (responses: unknown[]) => void; + /** Callback function for when the upload fails */ onError?: (responses: unknown[]) => void; + /** The upload instance returned by creating a new Upload via @availity/upload-core. */ uploads: Upload[]; }; @@ -22,8 +31,8 @@ export function useFileDelivery({ deliveryChannel, deliverFileOnSubmit, fileDeliveryMetadata, - onSuccess, - onError, + // onSuccess, + // onError, uploads, }: UploadDeliveryOptions) { const errors = {}; diff --git a/packages/file-selector/src/lib/useUploadCore.tsx b/packages/file-selector/src/lib/useUploadCore.tsx index b18d30315..6ec0b4c42 100644 --- a/packages/file-selector/src/lib/useUploadCore.tsx +++ b/packages/file-selector/src/lib/useUploadCore.tsx @@ -1,22 +1,18 @@ import { useQuery } from '@tanstack/react-query'; -import Upload, { Options } from '@availity/upload-core'; +import Upload, { UploadOptions } from '@availity/upload-core'; -function startUploads(files: File[], options: Options) { - return files.map((file) => { - const upload = new Upload(file, options); +function startUpload(file: File, options: UploadOptions) { + const upload = new Upload(file, options); - upload.start(); + upload.start(); - return upload; - }); + return upload; } -export function useUploadCore(files: File[], options: Options) { - const fileNames = files.map((file) => file.name).join(','); - - const isQueryEnabled = files.length > 0; +export function useUploadCore(file: File, options: UploadOptions) { + const isQueryEnabled = !!file; - return useQuery(['upload', fileNames, options], () => startUploads(files, options), { + return useQuery(['upload', file.name, options], () => startUpload(file, options), { enabled: isQueryEnabled, retry: false, }); diff --git a/packages/file-selector/src/lib/util.ts b/packages/file-selector/src/lib/util.ts index 8e2822dd9..fba740407 100644 --- a/packages/file-selector/src/lib/util.ts +++ b/packages/file-selector/src/lib/util.ts @@ -1,7 +1,7 @@ export function formatBytes(bytes: number, decimals = 2) { if (!+bytes) return '0 Bytes'; - const k = 1000; + const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB']; diff --git a/packages/mock/package.json b/packages/mock/package.json index cbee3a866..51adef6df 100644 --- a/packages/mock/package.json +++ b/packages/mock/package.json @@ -32,6 +32,6 @@ }, "dependencies": { "msw": "^2.3.5", - "qs": "^6.12.1" + "qs": "^6.13.1" } } diff --git a/packages/mock/src/lib/handlers.ts b/packages/mock/src/lib/handlers.ts index ba5d7d38d..e357e7443 100644 --- a/packages/mock/src/lib/handlers.ts +++ b/packages/mock/src/lib/handlers.ts @@ -45,6 +45,8 @@ const delayRequest = async () => { const testFile: Blob & { name?: string } = new Blob(['testfilewithwords'], { type: 'text/plain' }); testFile.name = 'testfilewithwords.txt'; +const requestHeaders = new Map(); + export const handlers = [ // Logging http.post(routes.LOG, async () => { @@ -284,11 +286,9 @@ export const handlers = [ }, }); }), - http.head(routes.ATTACHMENTS_PATCH, async ({ params, request }) => { + http.head(routes.ATTACHMENTS_PATCH, async ({ params }) => { await delay(1000); - console.log('params, request:', params, request); - return new HttpResponse(testFile, { status: 200, headers: { @@ -309,9 +309,12 @@ export const handlers = [ }), // Attachments Cloud - http.post(routes.ATTACHMENTS_CLOUD_POST, async () => { + http.post(routes.ATTACHMENTS_CLOUD_POST, async ({ request, requestId }) => { await delay(1000); + // Save file size for patch request + requestHeaders.set(requestId, request.headers); + return new HttpResponse(null, { status: 201, headers: { @@ -319,43 +322,56 @@ export const handlers = [ 'transfer-encoding': 'chunked', 'tus-resumable': '1.0.0', 'upload-expires': 'Fri, 12 Jan 2030 15:54:39 GMT', - location: '11223344aabbccdd556677', + location: requestId, }, }); }), - http.patch(routes.ATTACHMENTS_CLOUD_PATCH, async ({ request }) => { - const reqOffset = Number(request.headers.get('upload-offset')); + + http.patch<{ location: string }>(routes.ATTACHMENTS_CLOUD_PATCH, async ({ request, params }) => { await delay(1000); + // Parse passed in offset + let reqOffset = Number(request.headers.get('upload-offset')); + reqOffset = Number.isNaN(reqOffset) ? 0 : reqOffset; + + // Get file size from previous request + let fileSize = Number(requestHeaders.get(params.location)?.get('upload-length')); + fileSize = Number.isNaN(fileSize) ? 0 : fileSize; + + // If it's the first page then return half the file size + const offset = reqOffset === 0 ? `${fileSize / 2}` : `${fileSize}`; + return new HttpResponse(null, { status: 204, headers: { 'cache-control': 'no-store', 'tus-resumable': '1.0.0', 'upload-expires': 'Fri, 12 Jan 2030 15:54:39 GMT', - 'upload-offset': reqOffset === 0 ? `${testFile.size / 2}` : `${testFile.size}`, + 'upload-offset': offset, }, }); }), - http.head(routes.ATTACHMENTS_CLOUD_PATCH, async ({ params, request }) => { + + http.head<{ bucket: string; location: string }>(routes.ATTACHMENTS_CLOUD_HEAD, async ({ params }) => { await delay(1000); + const headers = requestHeaders.get(params.location); - console.log('params, request:', params, request); + const fileSize = headers?.get('upload-length') || '0'; + const metadata = headers?.get('upload-metadata') || ''; - return new HttpResponse(testFile, { + return new HttpResponse(null, { status: 200, headers: { - 'av-scan-bytes': `${testFile.size}`, + 'av-scan-bytes': fileSize, 'av-scan-result': 'accepted', 'cache-control': 'no-store', - references: `["approved/${params.bucketId}/${params.location}"]`, - 's3-references': `["s3://path-to-vault/approved/${params.bucketId}/01234/${params.location}"]`, + references: `["approved/${params.bucket}/${params.location}"]`, + 's3-references': `["s3://path-to-vault/approved/${params.bucket}/${params.location}"]`, 'transfer-encoding': 'chunked', 'tus-resumable': '1.0.0', - 'upload-length': `${testFile.size}`, - 'upload-metadata': - 'availity-filename LnR4dAo=,availity-content-type dGV4dC9wbGFpbgo=,availity-attachment-name Ti9BCg==', - 'upload-offset': `${testFile.size}`, + 'upload-length': fileSize, + 'upload-metadata': metadata, + 'upload-offset': fileSize, 'upload-result': 'accepted', }, }); diff --git a/packages/mock/src/lib/routes.ts b/packages/mock/src/lib/routes.ts index 0828b299f..227918177 100644 --- a/packages/mock/src/lib/routes.ts +++ b/packages/mock/src/lib/routes.ts @@ -10,10 +10,12 @@ export const USERS = '/api/sdk/platform/v1/users/me'; // Aries 2 export const LOGV2 = '/ms/api/availity/internal/spc/analytics/log'; -export const ATTACHMENTS_POST = '/ms/api/availity/internal/core/vault/upload/v1/resumable/:bucketId/'; -export const ATTACHMENTS_PATCH = '/ms/api/availity/internal/core/vault/upload/v1/resumable/:bucketId/:location'; -export const ATTACHMENTS_CLOUD_POST = '/cloud/web/appl/vault/upload/v1/resumable/:bucketId/'; -export const ATTACHMENTS_CLOUD_PATCH = 'cloud/web/appl/vault/upload/v1/resumable/:bucketId/:location'; +export const ATTACHMENTS_POST = '/ms/api/availity/internal/core/vault/upload/v1/resumable/:bucket/'; +export const ATTACHMENTS_PATCH = '/ms/api/availity/internal/core/vault/upload/v1/resumable/:bucket/:location'; +export const ATTACHMENTS_HEAD = '/ms/api/availity/internal/core/vault/upload/v1/resumable/:bucket/:location'; +export const ATTACHMENTS_CLOUD_POST = '/cloud/web/appl/vault/upload/v1/resumable/:bucket/'; +export const ATTACHMENTS_CLOUD_PATCH = 'cloud/web/appl/vault/upload/v1/resumable/:bucket/:location'; +export const ATTACHMENTS_CLOUD_HEAD = 'cloud/web/appl/vault/upload/v1/resumable/:bucket/:location'; // Misc export const EXAMPLE = '/api/v1/example'; diff --git a/packages/spaces/package.json b/packages/spaces/package.json index 79679c7f9..22c0b53d4 100644 --- a/packages/spaces/package.json +++ b/packages/spaces/package.json @@ -78,7 +78,7 @@ "@availity/resolve-url": "^2.0.6", "@tanstack/react-query": "^4.36.1", "dayjs": "^1.11.10", - "qs": "^6.12.1", + "qs": "^6.13.1", "react-image": "^4.1.0", "react-markdown": "^6.0.3" } diff --git a/yarn.lock b/yarn.lock index f7d3e1144..8fdc06691 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,12 +30,12 @@ __metadata: linkType: hard "@availity/analytics-core@npm:^5.0.3": - version: 5.0.3 - resolution: "@availity/analytics-core@npm:5.0.3" + version: 5.0.4 + resolution: "@availity/analytics-core@npm:5.0.4" dependencies: uuid: ^10.0.0 yup: ^0.32.11 - checksum: 85b1613273bdf9e9abeb39118578f064c78c7d739650372969d9f5a86b4a1fe05060f4e5e511cadc8cfbf1b6a81511c1c40164d7234b309e27e6ddac25a1b79a + checksum: 73e8f7d77f1df153f639cd585468ca4aafd260b8b66b1e6482a54d1d1db5f5ff622d63cc6841b591ba230abeb2b2a8e9585dabd53dc140320ff0d7c427a526b0 languageName: node linkType: hard @@ -189,7 +189,7 @@ __metadata: resolution: "@availity/mock@workspace:packages/mock" dependencies: msw: ^2.3.5 - qs: ^6.12.1 + qs: ^6.13.1 typescript: ^5.4.5 languageName: unknown linkType: soft @@ -582,6 +582,7 @@ __metadata: resolution: "@availity/mui-file-selector@workspace:packages/file-selector" dependencies: "@availity/api-axios": ^9.0.4 + "@availity/mui-alert": "workspace:^" "@availity/mui-button": "workspace:^" "@availity/mui-divider": "workspace:^" "@availity/mui-form-utils": "workspace:^" @@ -590,15 +591,15 @@ __metadata: "@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 "@mui/material": ^5.15.15 "@tanstack/react-query": ^4.36.1 - "@types/tus-js-client": ^1.8.0 react: 18.2.0 react-dom: 18.2.0 react-dropzone: ^11.7.1 react-hook-form: ^7.51.3 tsup: ^8.0.2 + tus-js-client: 4.2.3 typescript: ^5.4.5 uuid: ^9.0.1 peerDependencies: @@ -879,7 +880,7 @@ __metadata: "@mui/material": ^5.15.15 "@tanstack/react-query": ^4.36.1 dayjs: ^1.11.10 - qs: ^6.12.1 + qs: ^6.13.1 react: 18.2.0 react-dom: 18.2.0 react-image: ^4.1.0 @@ -1136,12 +1137,13 @@ __metadata: languageName: unknown linkType: soft -"@availity/upload-core@npm:^6.1.1": - version: 6.1.1 - resolution: "@availity/upload-core@npm:6.1.1" +"@availity/upload-core@npm:7.0.0-alpha.5": + version: 7.0.0-alpha.5 + resolution: "@availity/upload-core@npm:7.0.0-alpha.5" dependencies: "@availity/resolve-url": 3.0.0 - checksum: 9694e39d9a1219a6bf4a2476ddad217d6c8ea44b74c4f551c0d83fe813dfe2d83e97ed53483d3613a012e09dd800c406762b319cc9cde5a96f720494fbaefae8 + tus-js-client: 4.2.3 + checksum: 0e26f04ab351101bdf00a9e9237c545a48c96c6e11bdb3541dfc8a7d531b9e28cbb50d545938aec7a6f3df6fa7144e248e226e5767c1c0df1ad9aa2416534a21 languageName: node linkType: hard @@ -7464,13 +7466,6 @@ __metadata: languageName: node linkType: hard -"@types/tus-js-client@npm:^1.8.0": - version: 1.8.0 - resolution: "@types/tus-js-client@npm:1.8.0" - checksum: d45b817a71ddcfafd0eb0fe112eb30d91cbaa1edbff9aea2976baee6e83da736b95e9f9d17b24c4c0f55e0f622f33159927b267e490b3126918cd727f180f9a2 - languageName: node - linkType: hard - "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.2 resolution: "@types/unist@npm:3.0.2" @@ -8838,7 +8833,7 @@ __metadata: languageName: node linkType: hard -"buffer-from@npm:^1.0.0": +"buffer-from@npm:^1.0.0, buffer-from@npm:^1.1.2": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" checksum: 0448524a562b37d4d7ed9efd91685a5b77a50672c556ea254ac9a6d30e3403a517d8981f10e565db24e8339413b43c97ca2951f10e399c6125a0d8911f5679bb @@ -9371,6 +9366,16 @@ __metadata: languageName: node linkType: hard +"combine-errors@npm:^3.0.3": + version: 3.0.3 + resolution: "combine-errors@npm:3.0.3" + dependencies: + custom-error-instance: 2.1.1 + lodash.uniqby: 4.5.0 + checksum: bd0b0d2a4020f9976b8fe8eb7d5aa855b43ecacdcb61ee1fc5664d73ff8c1d7d0bbe4dd948bea7ba1870518bfc5688b89941de7a4967659418b4664cdb02884f + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -9985,6 +9990,13 @@ __metadata: languageName: node linkType: hard +"custom-error-instance@npm:2.1.1": + version: 2.1.1 + resolution: "custom-error-instance@npm:2.1.1" + checksum: db01483864c9f4356b720b443a1f9b374758745a75199187a0ccc12505cf822bc801a0d8e3f96d727559880024f40e09667d5c08e5de0bff243c6b5ae0bd303c + languageName: node + linkType: hard + "damerau-levenshtein@npm:^1.0.8": version: 1.0.8 resolution: "damerau-levenshtein@npm:1.0.8" @@ -12137,7 +12149,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 @@ -13821,6 +13833,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^3.7.2": + version: 3.7.7 + resolution: "js-base64@npm:3.7.7" + checksum: d1b02971db9dc0fd35baecfaf6ba499731fb44fe3373e7e1d6681fbd3ba665f29e8d9d17910254ef8104e2cb8b44117fe4202d3dc54c7cafe9ba300fe5433358 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -14266,6 +14285,55 @@ __metadata: languageName: node linkType: hard +"lodash._baseiteratee@npm:~4.7.0": + version: 4.7.0 + resolution: "lodash._baseiteratee@npm:4.7.0" + dependencies: + lodash._stringtopath: ~4.8.0 + checksum: 814a7125b9e2fa7e436c4402eae842a200189e2839b56bd6cde7cd0a3628b60842f5d39a9f5dceaf8766669b2e4a17a36ce2a213d1d6a891c1bef8a6bda36ea9 + languageName: node + linkType: hard + +"lodash._basetostring@npm:~4.12.0": + version: 4.12.0 + resolution: "lodash._basetostring@npm:4.12.0" + checksum: ccaf83827f86be5c9daeb7b939f761d6a43f0de0781bc3b6772fcb8568fbcbfa1e1082c66e5e12dd23e00ac40a18349c5a793a6a552e3574cbbcb3e1545fcb4c + languageName: node + linkType: hard + +"lodash._baseuniq@npm:~4.6.0": + version: 4.6.0 + resolution: "lodash._baseuniq@npm:4.6.0" + dependencies: + lodash._createset: ~4.0.0 + lodash._root: ~3.0.0 + checksum: 8c16fe2e80716b18c2f28bbcc902768141d432b0b98e03b30a2fba6a097377fabdc8753da232568375d2aa9502dc6b3a390200aa1467d2f685a582a46a271936 + languageName: node + linkType: hard + +"lodash._createset@npm:~4.0.0": + version: 4.0.3 + resolution: "lodash._createset@npm:4.0.3" + checksum: fb4450fbf4846aa7b420837ee44400b88664e28499388b7e04b4db38adca1305915f68a245fb2a87e031e7f440b997de4f360de6dea2712952520e97c7898de1 + languageName: node + linkType: hard + +"lodash._root@npm:~3.0.0": + version: 3.0.1 + resolution: "lodash._root@npm:3.0.1" + checksum: 3e12c6f409ae13164a8db358f44a691f1e038dad4e25463802980d0ed641ed118c147b65657501c51778c885422b913264dfbe33ec0c5d676443dd630a7e685a + languageName: node + linkType: hard + +"lodash._stringtopath@npm:~4.8.0": + version: 4.8.0 + resolution: "lodash._stringtopath@npm:4.8.0" + dependencies: + lodash._basetostring: ~4.12.0 + checksum: 00663b317796333e6315ebb4e8b590e68845de10d5d25c7585751fd9d28adf3e60e1ce85a6fbb6a0d440447c841465b91877e761239e358231eed2f52f0a5472 + languageName: node + linkType: hard + "lodash.camelcase@npm:^4.3.0": version: 4.3.0 resolution: "lodash.camelcase@npm:4.3.0" @@ -14343,6 +14411,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 129c0a28cee48b348aef146f638ef8a8b197944d4e9ec26c1890c19d9bf5a5690fe11b655c77a4551268819b32d27f4206343e30c78961f60b561b8608c8c805 + languageName: node + linkType: hard + "lodash.uniq@npm:4.5.0, lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -14350,6 +14425,16 @@ __metadata: languageName: node linkType: hard +"lodash.uniqby@npm:4.5.0": + version: 4.5.0 + resolution: "lodash.uniqby@npm:4.5.0" + dependencies: + lodash._baseiteratee: ~4.7.0 + lodash._baseuniq: ~4.6.0 + checksum: 40a4fdd4c31323fcb6db91ec3124020333212ca1f13e75cc9939decdd33e8b176d204fb277be36a51a855c2c90e14d67932b3b130b2f0eedc729e4cb9cdcaed1 + languageName: node + linkType: hard + "lodash.upperfirst@npm:^4.3.1": version: 4.3.1 resolution: "lodash.upperfirst@npm:4.3.1" @@ -16601,6 +16686,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: ^4.2.4 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939 + languageName: node + linkType: hard + "property-expr@npm:^2.0.4": version: 2.0.6 resolution: "property-expr@npm:2.0.6" @@ -16676,7 +16772,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0, qs@npm:^6.12.3, qs@npm:^6.13.0": +"qs@npm:6.13.0, qs@npm:^6.12.3": version: 6.13.0 resolution: "qs@npm:6.13.0" dependencies: @@ -16685,7 +16781,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.0, qs@npm:^6.11.2, qs@npm:^6.12.1, qs@npm:^6.4.0": +"qs@npm:^6.10.0, qs@npm:^6.4.0": version: 6.12.1 resolution: "qs@npm:6.12.1" dependencies: @@ -16694,6 +16790,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.2, qs@npm:^6.13.0, qs@npm:^6.13.1": + version: 6.13.1 + resolution: "qs@npm:6.13.1" + dependencies: + side-channel: ^1.0.6 + checksum: 86c5059146955fab76624e95771031541328c171b1d63d48a7ac3b1fdffe262faf8bc5fcadc1684e6f3da3ec87a8dedc8c0009792aceb20c5e94dc34cf468bb9 + languageName: node + linkType: hard + "querystring-es3@npm:^0.2.1": version: 0.2.1 resolution: "querystring-es3@npm:0.2.1" @@ -18949,6 +19054,21 @@ __metadata: languageName: node linkType: hard +"tus-js-client@npm:4.2.3": + version: 4.2.3 + resolution: "tus-js-client@npm:4.2.3" + dependencies: + buffer-from: ^1.1.2 + combine-errors: ^3.0.3 + is-stream: ^2.0.0 + js-base64: ^3.7.2 + lodash.throttle: ^4.1.1 + proper-lockfile: ^4.1.2 + url-parse: ^1.5.7 + checksum: c2180111a443d6f5bff1923888fe40cf7490a054fb3e818bc3f16897ef0744ad2d83bcabe5f7b5d24f6e57f0d95231612c13eb71cc55d15deeff06fc6ddc0cdf + languageName: node + linkType: hard + "tween-functions@npm:^1.2.0": version: 1.2.0 resolution: "tween-functions@npm:1.2.0" @@ -19461,7 +19581,7 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.3": +"url-parse@npm:^1.5.3, url-parse@npm:^1.5.7": version: 1.5.10 resolution: "url-parse@npm:1.5.10" dependencies: