@availity/mui-file-selector 1.6.6 → 1.7.1

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.
@@ -0,0 +1,253 @@
1
+ // Each exported component in the package should have its own stories file
2
+ import { useState } from 'react';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { FormProvider, useForm } from 'react-hook-form';
5
+ import { Paper } from '@availity/mui-paper';
6
+ import { Alert, AlertProps, AlertTitle } from '@availity/mui-alert';
7
+ import { Grid } from '@availity/mui-layout';
8
+ import { Button } from '@availity/mui-button';
9
+ import Collapse from '@mui/material/Collapse';
10
+
11
+ import { FileSelector2, FileSelector2Props } from './FileSelector2';
12
+
13
+ const meta: Meta<typeof FileSelector2> = {
14
+ title: 'Components/File Selector/FileSelector2',
15
+ component: FileSelector2,
16
+ tags: ['autodocs'],
17
+ args: {
18
+ name: 'file-selector-2',
19
+ allowedFileTypes: ['.txt', '.png', '.pdf'],
20
+ clientId: '123',
21
+ customerId: '456',
22
+ bucketId: '789',
23
+ uploadOptions: {
24
+ retryDelays: [],
25
+ },
26
+ maxFiles: 2,
27
+ maxSize: 1 * 1024 * 1024, // 1MB
28
+ enableDropArea: true,
29
+ isCloud: true,
30
+ multiple: true,
31
+ },
32
+ argTypes: {
33
+ customSizeMessage: {
34
+ control: 'text',
35
+ },
36
+ customTypesMessage: {
37
+ control: 'text',
38
+ },
39
+ },
40
+ };
41
+
42
+ export default meta;
43
+
44
+ const DismissableAlert = (props: AlertProps) => {
45
+ const [visible, setVisible] = useState(true);
46
+
47
+ const onClose = () => {
48
+ setVisible(false);
49
+ };
50
+
51
+ return (
52
+ <Collapse in={visible}>
53
+ <Alert onClose={onClose} {...props} />
54
+ </Collapse>
55
+ );
56
+ };
57
+
58
+ export const _FileSelector2: StoryObj<typeof FileSelector2> = {
59
+ render: (props: FileSelector2Props) => {
60
+ const methods = useForm({
61
+ defaultValues: {
62
+ [props.name]: [] as File[],
63
+ },
64
+ });
65
+
66
+ const uploads = methods.watch(props.name);
67
+
68
+ const handleOnSubmit = (values: Record<string, File[]>) => {
69
+ if (values[props.name].length === 0) return;
70
+ };
71
+
72
+ return (
73
+ <Paper sx={{ padding: '2rem' }}>
74
+ <FormProvider {...methods}>
75
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
76
+ <FileSelector2 {...props}>
77
+ <DismissableAlert severity="warning">
78
+ <AlertTitle>Make an Appeal</AlertTitle>
79
+ This is an example alert. It is not part of the component. `children` you pass to the component will
80
+ show up here.
81
+ </DismissableAlert>
82
+ </FileSelector2>
83
+ {uploads.length > 0 && (
84
+ <Grid size={{ xs: 12 }} justifyContent="end" display="flex" paddingTop={2.5}>
85
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
86
+ Submit
87
+ </Button>
88
+ </Grid>
89
+ )}
90
+ </form>
91
+ </FormProvider>
92
+ </Paper>
93
+ );
94
+ },
95
+ };
96
+
97
+ /** Set `enableDropzone` to `false` for a button only file selector. */
98
+ export const _FileSelector2ButtonOnly: StoryObj<typeof FileSelector2> = {
99
+ render: (props: FileSelector2Props) => {
100
+ const methods = useForm({
101
+ defaultValues: {
102
+ [props.name]: [] as File[],
103
+ },
104
+ });
105
+
106
+ const uploads = methods.watch(props.name);
107
+
108
+ const handleOnSubmit = (values: Record<string, File[]>) => {
109
+ if (values[props.name].length === 0) return;
110
+ };
111
+
112
+ return (
113
+ <Paper sx={{ padding: '2rem' }}>
114
+ <FormProvider {...methods}>
115
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
116
+ <FileSelector2 {...props} enableDropArea={false} />
117
+ {uploads.length > 0 && (
118
+ <Grid size={{ xs: 12 }} justifyContent="end" display="flex" paddingTop={2.5}>
119
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
120
+ Submit
121
+ </Button>
122
+ </Grid>
123
+ )}
124
+ </form>
125
+ </FormProvider>
126
+ </Paper>
127
+ );
128
+ },
129
+ };
130
+
131
+ /** Upload password protected files. _For this example, the password for any file is '1234'_ */
132
+ export const _FileSelector2Encrypted: StoryObj<typeof FileSelector2> = {
133
+ render: (props: Omit<FileSelector2Props, 'bucketId'>) => {
134
+ const methods = useForm({
135
+ defaultValues: {
136
+ [props.name]: [] as File[],
137
+ },
138
+ });
139
+
140
+ const uploads = methods.watch(props.name);
141
+
142
+ const handleOnSubmit = (values: Record<string, File[]>) => {
143
+ if (values[props.name].length === 0) return;
144
+ };
145
+
146
+ return (
147
+ <Paper sx={{ padding: '2rem' }}>
148
+ <FormProvider {...methods}>
149
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
150
+ <FileSelector2 {...props} bucketId="enc" />
151
+ {uploads.length > 0 && (
152
+ <Grid size={{ xs: 12 }} justifyContent="end" display="flex" paddingTop={2.5}>
153
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
154
+ Submit
155
+ </Button>
156
+ </Grid>
157
+ )}
158
+ </form>
159
+ </FormProvider>
160
+ </Paper>
161
+ );
162
+ },
163
+ args: {
164
+ bucketId: 'enc',
165
+ },
166
+ argTypes: {
167
+ bucketId: { control: false },
168
+ },
169
+ };
170
+
171
+ export const _FileSelector2CustomTypesMessage: StoryObj<typeof FileSelector2> = {
172
+ render: (props: FileSelector2Props) => {
173
+ const methods = useForm({
174
+ defaultValues: {
175
+ [props.name]: [] as File[],
176
+ },
177
+ });
178
+
179
+ const uploads = methods.watch(props.name);
180
+
181
+ const handleOnSubmit = (values: Record<string, File[]>) => {
182
+ if (values[props.name].length === 0) return;
183
+ };
184
+
185
+ return (
186
+ <Paper sx={{ padding: '2rem' }}>
187
+ <FormProvider {...methods}>
188
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
189
+ <FileSelector2 {...props}>
190
+ <DismissableAlert severity="warning">
191
+ <AlertTitle>Make an Appeal</AlertTitle>
192
+ This is an example alert. It is not part of the component. `children` you pass to the component will
193
+ show up here.
194
+ </DismissableAlert>
195
+ </FileSelector2>
196
+ {uploads.length > 0 && (
197
+ <Grid size={{ xs: 12 }} justifyContent="end" display="flex" paddingTop={2.5}>
198
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
199
+ Submit
200
+ </Button>
201
+ </Grid>
202
+ )}
203
+ </form>
204
+ </FormProvider>
205
+ </Paper>
206
+ );
207
+ },
208
+ args: {
209
+ customTypesMessage: 'Only cool file types allowed',
210
+ },
211
+ };
212
+
213
+ export const _FileSelector2CustomSizeMessage: StoryObj<typeof FileSelector2> = {
214
+ render: (props: FileSelector2Props) => {
215
+ const methods = useForm({
216
+ defaultValues: {
217
+ [props.name]: [] as File[],
218
+ },
219
+ });
220
+
221
+ const uploads = methods.watch(props.name);
222
+
223
+ const handleOnSubmit = (values: Record<string, File[]>) => {
224
+ if (values[props.name].length === 0) return;
225
+ };
226
+
227
+ return (
228
+ <Paper sx={{ padding: '2rem' }}>
229
+ <FormProvider {...methods}>
230
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
231
+ <FileSelector2 {...props}>
232
+ <DismissableAlert severity="warning">
233
+ <AlertTitle>Make an Appeal</AlertTitle>
234
+ This is an example alert. It is not part of the component. `children` you pass to the component will
235
+ show up here.
236
+ </DismissableAlert>
237
+ </FileSelector2>
238
+ {uploads.length > 0 && (
239
+ <Grid size={{ xs: 12 }} justifyContent="end" display="flex" paddingTop={2.5}>
240
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
241
+ Submit
242
+ </Button>
243
+ </Grid>
244
+ )}
245
+ </form>
246
+ </FormProvider>
247
+ </Paper>
248
+ );
249
+ },
250
+ args: {
251
+ customSizeMessage: 'Only huge files allowed. ',
252
+ },
253
+ };
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from 'react';
2
+ import { render } from '@testing-library/react';
3
+ import { useForm, FormProvider } from 'react-hook-form';
4
+
5
+ import { FileSelector2 } from './FileSelector2';
6
+
7
+ const TestForm = ({ children }: { children: ReactNode }) => {
8
+ const methods = useForm();
9
+
10
+ return <FormProvider {...methods}>{children}</FormProvider>;
11
+ };
12
+
13
+ describe('FileSelector2', () => {
14
+ test('should render successfully', () => {
15
+ const { getByText } = render(
16
+ <TestForm>
17
+ <FileSelector2 name="test" bucketId="test" customerId="123" clientId="test" maxSize={1000} maxFiles={1} />
18
+ </TestForm>
19
+ );
20
+
21
+ expect(getByText('Upload file')).toBeTruthy();
22
+ });
23
+ });
@@ -0,0 +1,209 @@
1
+ import { useState } from 'react';
2
+ import { useFormContext } from 'react-hook-form';
3
+ import type { FileRejection } from 'react-dropzone/typings/react-dropzone';
4
+ import type { default as Upload } from '@availity/upload-core';
5
+ import { Grid } from '@availity/mui-layout';
6
+ import { Typography } from '@availity/mui-typography';
7
+ import { Alert, AlertTitle } from '@availity/mui-alert';
8
+
9
+ import { Dropzone2 } from './Dropzone2';
10
+ import { ErrorAlert } from './ErrorAlert';
11
+ import { FileList2 } from './FileList2';
12
+ import { FileTypesMessage } from './FileTypesMessage';
13
+ import { HeaderMessage } from './HeaderMessage';
14
+ import type { Options } from './useUploadCore';
15
+
16
+ import { CLOUD_URL } from './FileSelector';
17
+ import type { FileSelectorProps } from './FileSelector';
18
+
19
+ export type FileSelector2Props = Omit<FileSelectorProps, 'onUploadRemove' | 'queryOptions'> & {
20
+ onUploadRemove?: (uploads: Upload[], removedUploadId: string) => void;
21
+ };
22
+
23
+ /**
24
+ * `<FileSelector2 />` is the future of the the `<FileSelector />`
25
+ * component. In a future major release, the `<FileSelector />` and
26
+ * `<FileSelector2 />` components will be consolidated into a single
27
+ * component.
28
+ *
29
+ * `<FileSelector2 />` removes the reliance on `@tanstack/react-query`. The
30
+ * `Upload` object can now be accessed from the form state.
31
+ */
32
+ export const FileSelector2 = ({
33
+ name,
34
+ allowedFileNameCharacters,
35
+ allowedFileTypes = [],
36
+ bucketId,
37
+ clientId,
38
+ children,
39
+ customSizeMessage,
40
+ customTypesMessage,
41
+ customerId,
42
+ customFileRow,
43
+ disabled = false,
44
+ enableDropArea = true,
45
+ endpoint,
46
+ isCloud,
47
+ label = 'Upload file',
48
+ maxFiles,
49
+ maxSize,
50
+ multiple = true,
51
+ onChange,
52
+ onDrop,
53
+ onUploadRemove,
54
+ uploadOptions,
55
+ validator,
56
+ disableRemove,
57
+ }: FileSelector2Props) => {
58
+ const [totalSize, setTotalSize] = useState(0);
59
+ const [fileRejections, setFileRejections] = useState<(FileRejection & { id: number })[]>([]);
60
+
61
+ const formMethods = useFormContext();
62
+
63
+ const options: Options = {
64
+ ...uploadOptions,
65
+ bucketId,
66
+ customerId,
67
+ clientId,
68
+ fileTypes: allowedFileTypes,
69
+ maxSize,
70
+ allowedFileNameCharacters,
71
+ };
72
+
73
+ // Endpoint is set by default in upload-core so check if it exists before passing `undefined`
74
+ if (endpoint) options.endpoint = endpoint;
75
+ // Override endpoint if using the cloud
76
+ if (isCloud) options.endpoint = CLOUD_URL;
77
+
78
+ const handleOnRemoveFile = (uploadId: string, upload: Upload) => {
79
+ const prevFiles: Upload[] = formMethods.getValues(name);
80
+ const newFiles = prevFiles.filter((prev) => prev.file.name !== upload.file.name);
81
+
82
+ if (newFiles.length !== prevFiles.length) {
83
+ const removedFile = prevFiles.find((prev) => prev.file.name === upload.file.name);
84
+
85
+ // Stop upload
86
+ try {
87
+ upload.abort();
88
+ } catch {
89
+ console.error('Encountered an issue stopping the file upload');
90
+ }
91
+
92
+ // Remove from context and cache
93
+ formMethods.setValue(name, newFiles);
94
+
95
+ if (removedFile?.file.size) setTotalSize(totalSize - removedFile.file.size);
96
+
97
+ if (onUploadRemove) onUploadRemove(newFiles, uploadId);
98
+ }
99
+ };
100
+
101
+ const uploads = (formMethods.watch(name) as Upload[]) || [];
102
+
103
+ const handleRemoveRejection = (id: number) => {
104
+ const rejections = fileRejections.filter((value) => value.id !== id);
105
+ setFileRejections(rejections);
106
+ };
107
+
108
+ const TOO_MANY_FILES_CODE = 'too-many-files';
109
+
110
+ // Extract too-many-files rejections
111
+ const tooManyFilesRejections = fileRejections.filter((rejection) =>
112
+ rejection.errors.some((error) => error.code === TOO_MANY_FILES_CODE)
113
+ );
114
+
115
+ // Extract other rejections
116
+ const otherRejections = fileRejections.filter(
117
+ (rejection) => !rejection.errors.some((error) => error.code === TOO_MANY_FILES_CODE)
118
+ );
119
+
120
+ return (
121
+ <>
122
+ {enableDropArea ? (
123
+ <>
124
+ {label ? <Typography marginBottom="4px">{label}</Typography> : null}
125
+ <Dropzone2
126
+ name={name}
127
+ allowedFileTypes={allowedFileTypes}
128
+ disabled={disabled}
129
+ enableDropArea={enableDropArea}
130
+ maxFiles={maxFiles}
131
+ maxSize={maxSize}
132
+ multiple={multiple}
133
+ onChange={onChange}
134
+ onDrop={onDrop}
135
+ setFileRejections={setFileRejections}
136
+ setTotalSize={setTotalSize}
137
+ uploadOptions={options}
138
+ validator={validator}
139
+ />
140
+ <FileTypesMessage
141
+ allowedFileTypes={allowedFileTypes}
142
+ maxFileSize={maxSize}
143
+ customSizeMessage={customSizeMessage}
144
+ customTypesMessage={customTypesMessage}
145
+ variant="caption"
146
+ />
147
+ {children}
148
+ </>
149
+ ) : (
150
+ <Grid container rowSpacing={3} flexDirection="column">
151
+ <Grid>
152
+ <HeaderMessage maxFiles={maxFiles} maxSize={maxSize} />
153
+ <FileTypesMessage
154
+ allowedFileTypes={allowedFileTypes}
155
+ customSizeMessage={customSizeMessage}
156
+ customTypesMessage={customTypesMessage}
157
+ variant="body2"
158
+ />
159
+ </Grid>
160
+ {children ? <Grid>{children}</Grid> : null}
161
+ <Grid>
162
+ <Dropzone2
163
+ name={name}
164
+ allowedFileTypes={allowedFileTypes}
165
+ disabled={disabled}
166
+ enableDropArea={enableDropArea}
167
+ maxFiles={maxFiles}
168
+ maxSize={maxSize}
169
+ multiple={multiple}
170
+ onChange={onChange}
171
+ onDrop={onDrop}
172
+ setFileRejections={setFileRejections}
173
+ setTotalSize={setTotalSize}
174
+ uploadOptions={options}
175
+ validator={validator}
176
+ />
177
+ </Grid>
178
+ </Grid>
179
+ )}
180
+ {tooManyFilesRejections.length > 0 && (
181
+ <Alert
182
+ severity="error"
183
+ onClose={() => tooManyFilesRejections.forEach((rejection) => handleRemoveRejection(rejection.id))}
184
+ >
185
+ <AlertTitle>Items not allowed.</AlertTitle>
186
+ Too many files are selected for upload, maximum {maxFiles} allowed.
187
+ </Alert>
188
+ )}
189
+
190
+ {otherRejections.length > 0 &&
191
+ otherRejections.map((rejection) => (
192
+ <ErrorAlert
193
+ key={rejection.id}
194
+ errors={rejection.errors}
195
+ fileName={rejection.file.name}
196
+ id={rejection.id}
197
+ onClose={() => handleRemoveRejection(rejection.id)}
198
+ />
199
+ ))}
200
+ <FileList2
201
+ uploads={uploads || []}
202
+ options={options}
203
+ onRemoveFile={handleOnRemoveFile}
204
+ customFileRow={customFileRow}
205
+ disableRemove={disableRemove}
206
+ />
207
+ </>
208
+ );
209
+ };
@@ -0,0 +1,16 @@
1
+ import { dedupeErrors } from './util';
2
+
3
+ describe('util', () => {
4
+ it('should remove duplicate errors', () => {
5
+ expect(
6
+ dedupeErrors([
7
+ { message: 'error 1', code: '123' },
8
+ { message: 'error 2', code: '1234' },
9
+ { message: 'error 1', code: '123' },
10
+ ])
11
+ ).toEqual([
12
+ { message: 'error 1', code: '123' },
13
+ { message: 'error 2', code: '1234' },
14
+ ]);
15
+ });
16
+ });
package/src/lib/util.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  FilePowerpointIcon,
11
11
  FileWordIcon,
12
12
  } from '@availity/mui-icon';
13
+ import type { FileError } from 'react-dropzone';
13
14
 
14
15
  export function formatBytes(bytes: number, decimals = 2) {
15
16
  if (!+bytes) return '0 Bytes';
@@ -22,7 +23,6 @@ export function formatBytes(bytes: number, decimals = 2) {
22
23
 
23
24
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
24
25
  }
25
-
26
26
  export const FILE_EXT_ICONS = {
27
27
  png: FileImageIcon,
28
28
  jpg: FileImageIcon,
@@ -48,9 +48,20 @@ export type FileExtensionKey = keyof typeof FILE_EXT_ICONS;
48
48
 
49
49
  export const isValidKey = (key: string): key is FileExtensionKey => (key ? key in FILE_EXT_ICONS : false);
50
50
 
51
+ export const getFileExtension = (fileName: string) => fileName.split('.').pop()?.toLowerCase() || '';
52
+
51
53
  export const getFileExtIcon = (fileName: string) => {
52
- const ext = fileName.split('.').pop()?.toLowerCase() || '';
53
- const icon = isValidKey(ext) ? FILE_EXT_ICONS[ext] : FileIcon;
54
+ const ext = getFileExtension(fileName);
55
+ return isValidKey(ext) ? FILE_EXT_ICONS[ext] : FileIcon;
56
+ };
57
+
58
+ export const dedupeErrors = (errors: FileError[]) => {
59
+ const dedupedErrors = errors.reduce((acc, error) => {
60
+ if (!acc.find((err) => err.code === error.code)) {
61
+ acc.push(error);
62
+ }
63
+ return acc;
64
+ }, [] as FileError[]);
54
65
 
55
- return icon;
66
+ return dedupedErrors;
56
67
  };