@availity/mui-file-selector 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@availity/mui-file-selector",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "Availity MUI file-selector Component - part of the @availity/element design system",
5
5
  "keywords": [
6
6
  "react",
@@ -40,21 +40,20 @@
40
40
  "publish:canary": "yarn npm publish --access public --tag canary"
41
41
  },
42
42
  "dependencies": {
43
- "@availity/api-axios": "^10.0.3",
43
+ "@availity/api-axios": "^11.0.0",
44
44
  "@availity/mui-alert": "^1.0.6",
45
45
  "@availity/mui-button": "^1.1.4",
46
46
  "@availity/mui-divider": "^1.0.2",
47
- "@availity/mui-form-utils": "^1.3.1",
47
+ "@availity/mui-form-utils": "^1.3.2",
48
48
  "@availity/mui-icon": "^1.1.0",
49
49
  "@availity/mui-layout": "^1.0.2",
50
- "@availity/mui-list": "^1.0.6",
50
+ "@availity/mui-list": "^1.0.7",
51
51
  "@availity/mui-progress": "^1.0.3",
52
52
  "@availity/mui-typography": "^1.0.2",
53
- "@availity/upload-core": "^7.1.1",
54
- "@tanstack/react-query": "^4.36.1",
53
+ "@availity/upload-core": "^8.0.0",
55
54
  "react-dropzone": "^11.7.1",
56
55
  "react-hook-form": "^7.55.0",
57
- "tus-js-client": "4.2.3",
56
+ "tus-js-client": "4.3.1",
58
57
  "uuid": "^9.0.1"
59
58
  },
60
59
  "devDependencies": {
package/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export * from './lib/Dropzone';
2
+ export * from './lib/Dropzone2';
2
3
  export * from './lib/ErrorAlert';
3
4
  export * from './lib/FileList';
5
+ export * from './lib/FileList2';
4
6
  export * from './lib/FilePickerBtn';
5
7
  export * from './lib/FileSelector';
8
+ export * from './lib/FileSelector2';
6
9
  export * from './lib/FileTypesMessage';
7
10
  export * from './lib/HeaderMessage';
8
11
  export * from './lib/UploadProgressBar';
@@ -12,7 +12,7 @@ import { Typography } from '@availity/mui-typography';
12
12
 
13
13
  import { FilePickerBtn } from './FilePickerBtn';
14
14
 
15
- const outerBoxStyles = {
15
+ export const outerBoxStyles = {
16
16
  backgroundColor: 'background.secondary',
17
17
  border: '1px dotted',
18
18
  borderColor: 'secondary.light',
@@ -24,13 +24,13 @@ const outerBoxStyles = {
24
24
  },
25
25
  };
26
26
 
27
- const innerBoxStyles = {
27
+ export const innerBoxStyles = {
28
28
  width: '100%',
29
29
  height: '100%',
30
30
  };
31
31
 
32
32
  /** Counter for creating unique id */
33
- const createCounter = () => {
33
+ export const createCounter = () => {
34
34
  let id = 0;
35
35
  const increment = () => (id += 1);
36
36
  return {
@@ -96,7 +96,7 @@ export type DropzoneProps = {
96
96
  validator?: (file: File) => FileError | FileError[] | null;
97
97
  };
98
98
 
99
- const DropzoneContainer = styled(Box, { name: 'AvDropzoneContainer', slot: 'root' })({
99
+ export const DropzoneContainer = styled(Box, { name: 'AvDropzoneContainer', slot: 'root' })({
100
100
  '.MuiDivider-root': {
101
101
  width: '196px',
102
102
  marginLeft: 'auto',
@@ -0,0 +1,28 @@
1
+ import { ReactNode } from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { useForm, FormProvider } from 'react-hook-form';
4
+
5
+ import { Dropzone2 } from './Dropzone2';
6
+
7
+ const TestForm = ({ children }: { children: ReactNode }) => {
8
+ const methods = useForm();
9
+
10
+ return <FormProvider {...methods}>{children}</FormProvider>;
11
+ };
12
+
13
+ describe('Dropzone', () => {
14
+ test('should render successfully', () => {
15
+ render(
16
+ <TestForm>
17
+ <Dropzone2
18
+ name="test"
19
+ maxSize={1000}
20
+ setTotalSize={jest.fn()}
21
+ uploadOptions={{ customerId: '123', clientId: 'test', bucketId: 'abc' }}
22
+ />
23
+ </TestForm>
24
+ );
25
+
26
+ expect(screen.getByText('Drag and Drop Files Here')).toBeTruthy();
27
+ });
28
+ });
@@ -0,0 +1,244 @@
1
+ import { useCallback } from 'react';
2
+ import type { MouseEvent } from 'react';
3
+ import { useDropzone } from 'react-dropzone';
4
+ import type { DropEvent, FileError, FileRejection } from 'react-dropzone';
5
+ import { useFormContext } from 'react-hook-form';
6
+ import { Divider } from '@availity/mui-divider';
7
+ import { CloudUploadIcon, PlusIcon } from '@availity/mui-icon';
8
+ import { Box, Stack } from '@availity/mui-layout';
9
+ import { Typography } from '@availity/mui-typography';
10
+ import Upload from '@availity/upload-core';
11
+ import type { UploadOptions } from '@availity/upload-core';
12
+ import type { OnSuccessPayload } from 'tus-js-client';
13
+
14
+ import { FilePickerBtn } from './FilePickerBtn';
15
+ import { dedupeErrors } from './util';
16
+ import { createCounter, DropzoneContainer, innerBoxStyles, outerBoxStyles } from './Dropzone';
17
+ import type { DropzoneProps } from './Dropzone';
18
+
19
+ const counter = createCounter();
20
+
21
+ export type Dropzone2Props = DropzoneProps & {
22
+ uploadOptions: UploadOptions;
23
+ };
24
+
25
+ type Options = {
26
+ onError?: (error: Error) => void;
27
+ onSuccess?: (response: OnSuccessPayload) => void;
28
+ onProgress?: () => void;
29
+ onChunkComplete?: (chunkSize: number, bytesAccepted: number, bytesTotal: number | null) => void;
30
+ } & UploadOptions;
31
+
32
+ async function startUpload(file: File, options: Options) {
33
+ const { onSuccess, onError, onProgress, onChunkComplete, ...uploadOptions } = options;
34
+ const upload = new Upload(file, uploadOptions);
35
+
36
+ await upload.generateId();
37
+
38
+ if (onSuccess) upload.onSuccess.push(onSuccess);
39
+ if (onError) upload.onError.push(onError);
40
+ if (onProgress) upload.onProgress.push(onProgress);
41
+ if (onChunkComplete) upload.onChunkComplete.push(onChunkComplete);
42
+
43
+ upload.start();
44
+
45
+ return upload;
46
+ }
47
+
48
+ /**
49
+ * `<Dropzone2 />` is the future of the the `<Dropzone />` component. In a
50
+ * future release, the `<Dropzone />` and `<Dropzone2 />` components will be
51
+ * consolidated into a single component.
52
+ *
53
+ * `<Dropzone2 />` adds the `uploadOptions` prop that previously existed on
54
+ * `<FileSelector />`.
55
+ */
56
+ export const Dropzone2 = ({
57
+ allowedFileTypes = [],
58
+ disabled,
59
+ enableDropArea = true,
60
+ maxFiles,
61
+ maxSize,
62
+ multiple,
63
+ name,
64
+ onChange,
65
+ onClick,
66
+ onDrop,
67
+ setFileRejections,
68
+ setTotalSize,
69
+ uploadOptions,
70
+ validator,
71
+ }: Dropzone2Props) => {
72
+ const { getValues, setValue, watch } = useFormContext();
73
+
74
+ const accept = allowedFileTypes.join(',');
75
+
76
+ const handleValidation = useCallback(
77
+ (file: File) => {
78
+ const previous: Upload[] = watch(name) ?? [];
79
+ const errors: FileError[] = [];
80
+
81
+ const isDuplicate = previous.some((prev) => prev.file.name === file.name);
82
+ if (isDuplicate) {
83
+ errors.push({
84
+ code: 'duplicate-name',
85
+ message: 'A file with this name already exists',
86
+ });
87
+ }
88
+
89
+ const hasMaxFiles = maxFiles && previous.length >= maxFiles;
90
+ if (hasMaxFiles) {
91
+ errors.push({
92
+ code: 'too-many-files',
93
+ message: `Too many files. You may only upload ${maxFiles} file(s).`,
94
+ });
95
+ }
96
+
97
+ if (validator) {
98
+ const validatorErrors = validator(file);
99
+ if (validatorErrors) {
100
+ if (Array.isArray(validatorErrors)) {
101
+ errors.push(...validatorErrors);
102
+ } else {
103
+ errors.push(validatorErrors);
104
+ }
105
+ }
106
+ }
107
+
108
+ return errors.length > 0 ? dedupeErrors(errors) : null;
109
+ },
110
+ [maxFiles, validator]
111
+ );
112
+
113
+ const handleOnDrop = useCallback(
114
+ async (acceptedFiles: File[], fileRejections: (FileRejection & { id: number })[], event: DropEvent) => {
115
+ let newSize = 0;
116
+ for (const file of acceptedFiles) {
117
+ newSize += file.size;
118
+ }
119
+
120
+ setTotalSize((prev) => prev + newSize);
121
+
122
+ const previous = watch(name) ?? [];
123
+
124
+ // Set accepted files to form context
125
+ const uploads = acceptedFiles.map((file) => startUpload(file, uploadOptions));
126
+ setValue(name, previous.concat(await Promise.all(uploads)));
127
+
128
+ if (fileRejections.length > 0) {
129
+ const TOO_MANY_FILES_CODE = 'too-many-files';
130
+ let hasTooManyFiles = false;
131
+
132
+ fileRejections = fileRejections.reduce(
133
+ (acc, rejection) => {
134
+ const isTooManyFiles = rejection.errors.some((error) => error.code === TOO_MANY_FILES_CODE);
135
+
136
+ if (isTooManyFiles) {
137
+ // Only add the first too-many-files rejection
138
+ if (!hasTooManyFiles) {
139
+ hasTooManyFiles = true;
140
+ acc.push({
141
+ ...rejection,
142
+ id: counter.increment(),
143
+ });
144
+ }
145
+ } else {
146
+ // Add all other rejection types normally
147
+ acc.push({
148
+ ...rejection,
149
+ id: counter.increment(),
150
+ });
151
+ }
152
+
153
+ return acc;
154
+ },
155
+ [] as Array<(typeof fileRejections)[0] & { id: number }>
156
+ );
157
+ }
158
+
159
+ if (setFileRejections) setFileRejections(fileRejections);
160
+ if (onDrop) onDrop(acceptedFiles, fileRejections, event);
161
+ },
162
+ [setFileRejections]
163
+ );
164
+
165
+ const { getRootProps, getInputProps } = useDropzone({
166
+ onDrop: handleOnDrop,
167
+ maxSize,
168
+ maxFiles,
169
+ disabled,
170
+ multiple,
171
+ accept,
172
+ validator: handleValidation,
173
+ });
174
+
175
+ const inputProps = getInputProps({
176
+ multiple,
177
+ accept,
178
+ onChange,
179
+ });
180
+
181
+ // Remove role and tabIndex for accessibility
182
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
183
+ const { role, tabIndex, ...rootProps } = getRootProps();
184
+
185
+ const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
186
+ if (inputProps.onChange) {
187
+ inputProps.onChange(event);
188
+ }
189
+ };
190
+
191
+ const handleOnClick = (event: MouseEvent<HTMLButtonElement>) => {
192
+ if (!enableDropArea && rootProps.onClick) rootProps.onClick(event);
193
+ if (onClick) onClick;
194
+ };
195
+
196
+ const getFieldValue = () => {
197
+ const field = getValues();
198
+ return field[name] || [];
199
+ };
200
+
201
+ const hasFiles = getFieldValue().length > 0;
202
+
203
+ return enableDropArea ? (
204
+ <DropzoneContainer sx={outerBoxStyles} {...rootProps}>
205
+ <Box sx={innerBoxStyles}>
206
+ <Stack spacing={2} alignItems="center" justifyContent="center">
207
+ <>
208
+ <CloudUploadIcon fontSize="xlarge" color="secondary" />
209
+ <Typography variant="subtitle2" fontWeight="700">
210
+ Drag and Drop Files Here
211
+ </Typography>
212
+ <Divider flexItem>
213
+ <Typography variant="subtitle2">OR</Typography>
214
+ </Divider>
215
+ <FilePickerBtn
216
+ name={name}
217
+ color="primary"
218
+ disabled={disabled}
219
+ maxSize={maxSize}
220
+ onClick={onClick}
221
+ inputProps={inputProps}
222
+ onChange={handleOnChange}
223
+ >
224
+ Browse Files
225
+ </FilePickerBtn>
226
+ </>
227
+ </Stack>
228
+ </Box>
229
+ </DropzoneContainer>
230
+ ) : (
231
+ <FilePickerBtn
232
+ name={name}
233
+ color="tertiary"
234
+ disabled={disabled}
235
+ maxSize={maxSize}
236
+ onClick={handleOnClick}
237
+ inputProps={inputProps}
238
+ onChange={handleOnChange}
239
+ startIcon={<PlusIcon />}
240
+ >
241
+ {hasFiles ? 'Add More Files' : 'Add File(s)'}
242
+ </FilePickerBtn>
243
+ );
244
+ };
@@ -0,0 +1,73 @@
1
+ import { screen, render, fireEvent, waitFor } from '@testing-library/react';
2
+
3
+ import { FileList2 } from './FileList2';
4
+ import Upload from '@availity/upload-core';
5
+
6
+ const options = { bucketId: '123', customerId: '123', clientId: '123' };
7
+
8
+ describe('FileList2', () => {
9
+ test('should render successfully', async () => {
10
+ const mockFile = new File(['file content'], 'mock.txt', { type: 'text/plain' });
11
+ const upload = new Upload(mockFile, options);
12
+
13
+ render(
14
+ <FileList2
15
+ uploads={[upload]}
16
+ options={options}
17
+ onRemoveFile={() => {
18
+ // noop
19
+ }}
20
+ />
21
+ );
22
+
23
+ await waitFor(() => {
24
+ expect(screen.getByText('mock.txt')).toBeDefined();
25
+ });
26
+ });
27
+
28
+ test('should call onRemoveFile', async () => {
29
+ const mockRemove = jest.fn();
30
+ const mockFile = new File(['file content'], 'mock.txt', { type: 'text/plain' });
31
+ const upload = new Upload(mockFile, options);
32
+
33
+ render(
34
+ <FileList2
35
+ uploads={[upload]}
36
+ options={options}
37
+ onRemoveFile={(id) => {
38
+ mockRemove(id);
39
+ }}
40
+ />
41
+ );
42
+
43
+ await waitFor(() => {
44
+ expect(screen.getByText('mock.txt')).toBeDefined();
45
+ });
46
+
47
+ fireEvent.click(screen.getByLabelText('remove file'));
48
+ expect(mockRemove).toHaveBeenCalled();
49
+ });
50
+
51
+ test('should not display remove button when disableRemove is set to true', async () => {
52
+ const mockRemove = jest.fn();
53
+ const mockFile = new File(['file content'], 'mock.txt', { type: 'text/plain' });
54
+ const upload = new Upload(mockFile, options);
55
+
56
+ render(
57
+ <FileList2
58
+ uploads={[upload]}
59
+ options={options}
60
+ onRemoveFile={(id) => {
61
+ mockRemove(id);
62
+ }}
63
+ disableRemove
64
+ />
65
+ );
66
+
67
+ await waitFor(() => {
68
+ expect(screen.getByText('mock.txt')).toBeDefined();
69
+ });
70
+
71
+ expect(() => screen.getByLabelText('remove file')).toThrow();
72
+ });
73
+ });
@@ -0,0 +1,118 @@
1
+ import type { default as Upload } from '@availity/upload-core';
2
+ import { List, ListItem, ListItemText, ListItemIcon } from '@availity/mui-list';
3
+ import { IconButton } from '@availity/mui-button';
4
+ import { DeleteIcon } from '@availity/mui-icon';
5
+ import { Grid } from '@availity/mui-layout';
6
+ import { Divider } from '@availity/mui-divider';
7
+
8
+ import { UploadProgressBar } from './UploadProgressBar';
9
+ import { formatBytes, getFileExtIcon } from './util';
10
+
11
+ import type { FileListProps, FileRowProps } from './FileList';
12
+
13
+ export type FileRow2Props = Omit<FileRowProps, 'file' | 'queryOptions'> & {
14
+ /**
15
+ * The File object containing information about the uploaded file
16
+ * */
17
+ upload: Upload;
18
+ };
19
+
20
+ /**
21
+ * `<FileRow2 />` is the future of the the `<FileRow />` component. In a
22
+ * future release, the `<FileRow />` and `<FileRow2 />` components will be
23
+ * consolidated into a single component.
24
+ *
25
+ * `<FileRow2 />` replaces the `file` prop with the `upload` prop and
26
+ * removes the `queryOptions` prop.
27
+ */
28
+ export const FileRow2 = ({
29
+ upload,
30
+ options,
31
+ onRemoveFile,
32
+ customFileRow: CustomRow,
33
+ disableRemove = false,
34
+ }: FileRow2Props) => {
35
+ const Icon = getFileExtIcon(upload.file.name);
36
+
37
+ if (!upload) return null;
38
+
39
+ if (CustomRow) return <CustomRow upload={upload} options={options} onRemoveFile={onRemoveFile} />;
40
+
41
+ return (
42
+ <ListItem
43
+ disableGutters
44
+ secondaryAction={
45
+ !disableRemove && (
46
+ <IconButton
47
+ title="remove file"
48
+ edge="end"
49
+ onClick={() => {
50
+ onRemoveFile(upload.id, upload);
51
+ }}
52
+ >
53
+ <DeleteIcon />
54
+ </IconButton>
55
+ )
56
+ }
57
+ >
58
+ <Grid container spacing={2} alignItems="center" justifyContent="space-between" width="100%" flexWrap="wrap">
59
+ <Grid size={{ xs: 'auto' }}>
60
+ <ListItemIcon sx={{ minWidth: '1.5rem' }}>
61
+ <Icon />
62
+ </ListItemIcon>
63
+ </Grid>
64
+ <Grid size={{ xs: 4 }} sx={{ minWidth: '8rem' }}>
65
+ <ListItemText sx={{ wordBreak: 'break-all' }}>{upload.trimFileName(upload.file.name)}</ListItemText>
66
+ </Grid>
67
+ <Grid size={{ xs: 2 }} sx={{ minWidth: '3rem' }}>
68
+ <ListItemText sx={{ textAlign: 'end' }}>{formatBytes(upload.file.size)}</ListItemText>
69
+ </Grid>
70
+ <Grid size={{ xs: 'grow' }} sx={{ minWidth: '6rem' }}>
71
+ <UploadProgressBar upload={upload} />
72
+ </Grid>
73
+ </Grid>
74
+ <Divider />
75
+ </ListItem>
76
+ );
77
+ };
78
+
79
+ export type FileList2Props = Omit<FileListProps, 'files'> & {
80
+ /**
81
+ * Array of File objects to be displayed in the list
82
+ */
83
+ uploads: Upload[];
84
+ } & Omit<FileRow2Props, 'upload'>;
85
+
86
+ /**
87
+ * `<FileList2 />` is the future of the the `<FileList />` component. In a
88
+ * future release, the `<FileList />` and `<FileList2 />` components will
89
+ * be consolidated into a single component.
90
+ *
91
+ * `<FileList2 />` replaces the `files` prop with the `uploads` prop.
92
+ */
93
+ export const FileList2 = ({
94
+ uploads,
95
+ options,
96
+ onRemoveFile,
97
+ customFileRow,
98
+ disableRemove,
99
+ }: FileList2Props): JSX.Element | null => {
100
+ if (uploads.length === 0) return null;
101
+
102
+ return (
103
+ <List>
104
+ {uploads.map((upload) => {
105
+ return (
106
+ <FileRow2
107
+ key={upload.id}
108
+ upload={upload}
109
+ options={options}
110
+ onRemoveFile={onRemoveFile}
111
+ customFileRow={customFileRow}
112
+ disableRemove={disableRemove}
113
+ />
114
+ );
115
+ })}
116
+ </List>
117
+ );
118
+ };
@@ -15,7 +15,7 @@ import { FileTypesMessage } from './FileTypesMessage';
15
15
  import { HeaderMessage } from './HeaderMessage';
16
16
  import type { Options, UploadQueryOptions } from './useUploadCore';
17
17
 
18
- const CLOUD_URL = '/cloud/web/appl/vault/upload/v1/resumable';
18
+ export const CLOUD_URL = '/cloud/web/appl/vault/upload/v1/resumable';
19
19
 
20
20
  export type FileSelectorProps = {
21
21
  /**