@availity/mui-file-selector 1.0.0 → 1.1.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.
@@ -1,8 +1,12 @@
1
- import { Dispatch, MouseEvent, useCallback, ChangeEvent } from 'react';
2
- import { useDropzone, FileRejection } from 'react-dropzone';
1
+ import { useCallback } from 'react';
2
+ import type { Dispatch, MouseEvent } from 'react';
3
+ import { styled } from '@mui/material/styles';
4
+ import MuiBox from '@mui/material/Box';
5
+ import { useDropzone } from 'react-dropzone';
6
+ import type { DropEvent, FileError, FileRejection } from 'react-dropzone';
3
7
  import { useFormContext } from 'react-hook-form';
4
8
  import { Divider } from '@availity/mui-divider';
5
- import { CloudUploadIcon } from '@availity/mui-icon';
9
+ import { CloudUploadIcon, PlusIcon } from '@availity/mui-icon';
6
10
  import { Box, Stack } from '@availity/mui-layout';
7
11
  import { Typography } from '@availity/mui-typography';
8
12
 
@@ -50,6 +54,10 @@ export type DropzoneProps = {
50
54
  * Whether the dropzone is disabled
51
55
  */
52
56
  disabled?: boolean;
57
+ /**
58
+ * Whether to enable the dropzone area
59
+ */
60
+ enableDropArea?: boolean;
53
61
  /**
54
62
  * Maximum number of files that can be uploaded
55
63
  */
@@ -65,11 +73,15 @@ export type DropzoneProps = {
65
73
  /**
66
74
  * Handler called when the file input's value changes
67
75
  */
68
- onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
76
+ onChange?: (event: DropEvent) => void;
69
77
  /**
70
78
  * Handler called when the file picker button is clicked
71
79
  */
72
80
  onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
81
+ /**
82
+ * More sophisticated version of "onChange". This is the recommend function to use for changes to the form state
83
+ */
84
+ onDrop?: (acceptedFiles: File[], fileRejections: (FileRejection & { id: number })[], event: DropEvent) => void;
73
85
  /**
74
86
  * Callback to handle rejected files that don't meet validation criteria
75
87
  */
@@ -78,58 +90,76 @@ export type DropzoneProps = {
78
90
  * Callback to update the total size of all uploaded files
79
91
  */
80
92
  setTotalSize: Dispatch<React.SetStateAction<number>>;
93
+ /**
94
+ * Validation function used for custom validation that is not covered with the other props
95
+ * */
96
+ validator?: (file: File) => FileError | FileError[] | null;
81
97
  };
82
98
 
83
- // The types below were props used in the availity-react implementation.
84
- // Perserving this here in case it needs to be added back
85
- // deliverFileOnSubmit?: boolean;
86
- // deliveryChannel?: string;
87
- // fileDeliveryMetadata?: Record<string, unknown> | ((file: Upload) => Record<string, unknown>);
88
- // onDeliveryError?: (responses: unknown[]) => void;
89
- // onDeliverySuccess?: (responses: unknown[]) => void;
90
- // onFileDelivery?: (upload: Upload) => void;
99
+ const DropzoneContainer = styled(Box, { name: 'AvDropzoneContainer', slot: 'root' })({
100
+ '.MuiDivider-root': {
101
+ width: '196px',
102
+ marginLeft: 'auto',
103
+ marginRight: 'auto',
104
+ },
105
+ }) as typeof MuiBox;
91
106
 
92
107
  export const Dropzone = ({
93
108
  allowedFileTypes = [],
94
109
  disabled,
110
+ enableDropArea = true,
95
111
  maxFiles,
96
112
  maxSize,
97
113
  multiple,
98
114
  name,
99
115
  onChange,
100
116
  onClick,
117
+ onDrop,
101
118
  setFileRejections,
102
119
  setTotalSize,
120
+ validator,
103
121
  }: DropzoneProps) => {
104
- const { setValue, watch } = useFormContext();
122
+ const { getValues, setValue, watch } = useFormContext();
105
123
 
106
- const validator = useCallback(
124
+ const handleValidation = useCallback(
107
125
  (file: File) => {
108
126
  const previous: File[] = watch(name) ?? [];
127
+ const errors: FileError[] = [];
109
128
 
110
129
  const isDuplicate = previous.some((prev) => prev.name === file.name);
111
130
  if (isDuplicate) {
112
- return {
131
+ errors.push({
113
132
  code: 'duplicate-name',
114
133
  message: 'A file with this name already exists',
115
- };
134
+ });
116
135
  }
117
136
 
118
137
  const hasMaxFiles = maxFiles && previous.length >= maxFiles;
119
138
  if (hasMaxFiles) {
120
- return {
139
+ errors.push({
121
140
  code: 'too-many-files',
122
141
  message: `Too many files. You may only upload ${maxFiles} file(s).`,
123
- };
142
+ });
124
143
  }
125
144
 
126
- return null;
145
+ if (validator) {
146
+ const validatorErrors = validator(file);
147
+ if (validatorErrors) {
148
+ if (Array.isArray(validatorErrors)) {
149
+ errors.push(...validatorErrors);
150
+ } else {
151
+ errors.push(validatorErrors);
152
+ }
153
+ }
154
+ }
155
+
156
+ return errors.length > 0 ? errors : null;
127
157
  },
128
- [maxFiles]
158
+ [maxFiles, validator]
129
159
  );
130
160
 
131
- const onDrop = useCallback(
132
- (acceptedFiles: File[], fileRejections: (FileRejection & { id: number })[]) => {
161
+ const handleOnDrop = useCallback(
162
+ (acceptedFiles: File[], fileRejections: (FileRejection & { id: number })[], event: DropEvent) => {
133
163
  let newSize = 0;
134
164
  for (const file of acceptedFiles) {
135
165
  newSize += file.size;
@@ -149,6 +179,7 @@ export const Dropzone = ({
149
179
  }
150
180
 
151
181
  if (setFileRejections) setFileRejections(fileRejections);
182
+ if (onDrop) onDrop(acceptedFiles, fileRejections, event);
152
183
  },
153
184
  [setFileRejections]
154
185
  );
@@ -156,13 +187,13 @@ export const Dropzone = ({
156
187
  const accept = allowedFileTypes.join(',');
157
188
 
158
189
  const { getRootProps, getInputProps } = useDropzone({
159
- onDrop,
190
+ onDrop: handleOnDrop,
160
191
  maxSize,
161
192
  maxFiles,
162
193
  disabled,
163
194
  multiple,
164
195
  accept,
165
- validator,
196
+ validator: handleValidation,
166
197
  });
167
198
 
168
199
  const inputProps = getInputProps({
@@ -171,18 +202,30 @@ export const Dropzone = ({
171
202
  onChange,
172
203
  });
173
204
 
205
+ // Remove role and tabIndex for accessibility
206
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
207
+ const { role, tabIndex, ...rootProps } = getRootProps();
208
+
174
209
  const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
175
210
  if (inputProps.onChange) {
176
211
  inputProps.onChange(event);
177
212
  }
178
213
  };
179
214
 
180
- // Remove role and tabIndex for accessibility
181
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
182
- const { role, tabIndex, ...rootProps } = getRootProps();
215
+ const handleOnClick = (event: MouseEvent<HTMLButtonElement>) => {
216
+ if (!enableDropArea && rootProps.onClick) rootProps.onClick(event);
217
+ if (onClick) onClick;
218
+ };
219
+
220
+ const getFieldValue = () => {
221
+ const field = getValues();
222
+ return field[name] || [];
223
+ };
224
+
225
+ const hasFiles = getFieldValue().length > 0;
183
226
 
184
- return (
185
- <Box sx={outerBoxStyles} {...rootProps}>
227
+ return enableDropArea ? (
228
+ <DropzoneContainer sx={outerBoxStyles} {...rootProps}>
186
229
  <Box sx={innerBoxStyles}>
187
230
  <Stack spacing={2} alignItems="center" justifyContent="center">
188
231
  <>
@@ -190,7 +233,9 @@ export const Dropzone = ({
190
233
  <Typography variant="subtitle2" fontWeight="700">
191
234
  Drag and Drop Files Here
192
235
  </Typography>
193
- <Divider>OR</Divider>
236
+ <Divider flexItem>
237
+ <Typography variant="subtitle2">OR</Typography>
238
+ </Divider>
194
239
  <FilePickerBtn
195
240
  name={name}
196
241
  color="primary"
@@ -199,10 +244,25 @@ export const Dropzone = ({
199
244
  onClick={onClick}
200
245
  inputProps={inputProps}
201
246
  onChange={handleOnChange}
202
- />
247
+ >
248
+ Browse Files
249
+ </FilePickerBtn>
203
250
  </>
204
251
  </Stack>
205
252
  </Box>
206
- </Box>
253
+ </DropzoneContainer>
254
+ ) : (
255
+ <FilePickerBtn
256
+ name={name}
257
+ color="tertiary"
258
+ disabled={disabled}
259
+ maxSize={maxSize}
260
+ onClick={handleOnClick}
261
+ inputProps={inputProps}
262
+ onChange={handleOnChange}
263
+ startIcon={<PlusIcon />}
264
+ >
265
+ {hasFiles ? 'Add More Files' : 'Add File(s)'}
266
+ </FilePickerBtn>
207
267
  );
208
268
  };
@@ -44,7 +44,31 @@ describe('FileList', () => {
44
44
  expect(screen.getByText('mock.txt')).toBeDefined();
45
45
  });
46
46
 
47
- fireEvent.click(screen.getByRole('button'));
47
+ fireEvent.click(screen.getByLabelText('remove file'));
48
48
  expect(mockRemove).toHaveBeenCalled();
49
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
+
55
+ render(
56
+ <QueryClientProvider client={new QueryClient()}>
57
+ <FileList
58
+ files={[mockFile]}
59
+ options={{ bucketId: '123', customerId: '123', clientId: '123' }}
60
+ onRemoveFile={(id) => {
61
+ mockRemove(id);
62
+ }}
63
+ disableRemove
64
+ />
65
+ </QueryClientProvider>
66
+ );
67
+
68
+ await waitFor(() => {
69
+ expect(screen.getByText('mock.txt')).toBeDefined();
70
+ });
71
+
72
+ expect(() => screen.getByLabelText('remove file')).toThrow();
73
+ });
50
74
  });
@@ -7,10 +7,13 @@ import { Divider } from '@availity/mui-divider';
7
7
 
8
8
  import { UploadProgressBar } from './UploadProgressBar';
9
9
  import { formatBytes, getFileExtIcon } from './util';
10
- import { useUploadCore, Options } from './useUploadCore';
10
+ import { useUploadCore } from './useUploadCore';
11
+ import type { Options, UploadQueryOptions } from './useUploadCore';
11
12
 
12
- type FileRowProps = {
13
- /** The File object containing information about the uploaded file */
13
+ export type FileRowProps = {
14
+ /**
15
+ * The File object containing information about the uploaded file
16
+ * */
14
17
  file: File;
15
18
  /**
16
19
  * Callback function called when a file is removed
@@ -18,29 +21,57 @@ type FileRowProps = {
18
21
  * @param upload - The Upload instance associated with the file
19
22
  */
20
23
  onRemoveFile: (id: string, upload: Upload) => void;
21
- /** Configuration options for the upload process */
24
+ /**
25
+ * Configuration options for the upload call
26
+ * */
22
27
  options: Options;
28
+ /**
29
+ * Query options from `react-query` for the upload call
30
+ * */
31
+ queryOptions?: UploadQueryOptions;
32
+ customFileRow?: React.ElementType<{
33
+ upload?: Upload;
34
+ options: Options;
35
+ onRemoveFile: (id: string, upload: Upload) => void;
36
+ }>;
37
+ /**
38
+ * Whether the remove button is disabled
39
+ * @default false
40
+ */
41
+ disableRemove?: boolean;
23
42
  };
24
43
 
25
- const FileRow = ({ file, options, onRemoveFile }: FileRowProps) => {
44
+ export const FileRow = ({
45
+ file,
46
+ options,
47
+ onRemoveFile,
48
+ queryOptions,
49
+ customFileRow: CustomRow,
50
+ disableRemove = false,
51
+ }: FileRowProps) => {
26
52
  const Icon = getFileExtIcon(file.name);
27
53
 
28
- const { data: upload } = useUploadCore(file, options);
54
+ const { data: upload } = useUploadCore(file, options, queryOptions);
55
+
56
+ if (CustomRow) return <CustomRow upload={upload} options={options} onRemoveFile={onRemoveFile} />;
29
57
 
30
58
  if (!upload) return null;
31
59
 
32
60
  return (
33
61
  <ListItem
62
+ disableGutters
34
63
  secondaryAction={
35
- <IconButton
36
- title="remove file"
37
- edge="end"
38
- onClick={() => {
39
- onRemoveFile(upload.id, upload);
40
- }}
41
- >
42
- <DeleteIcon />
43
- </IconButton>
64
+ !disableRemove && (
65
+ <IconButton
66
+ title="remove file"
67
+ edge="end"
68
+ onClick={() => {
69
+ onRemoveFile(upload.id, upload);
70
+ }}
71
+ >
72
+ <DeleteIcon />
73
+ </IconButton>
74
+ )
44
75
  }
45
76
  >
46
77
  <Grid container spacing={2} alignItems="center" justifyContent="space-between" width="100%">
@@ -69,25 +100,32 @@ export type FileListProps = {
69
100
  * Array of File objects to be displayed in the list
70
101
  */
71
102
  files: File[];
72
- /**
73
- * Callback function called when a file is removed from the list
74
- * @param id - The unique identifier of the file being removed
75
- * @param upload - The Upload instance associated with the file
76
- */
77
- onRemoveFile: (id: string, upload: Upload) => void;
78
- /**
79
- * Configuration options applied to all file uploads in the list
80
- */
81
- options: Options;
82
- };
103
+ } & Omit<FileRowProps, 'file'>;
83
104
 
84
- export const FileList = ({ files, options, onRemoveFile }: FileListProps) => {
105
+ export const FileList = ({
106
+ files,
107
+ options,
108
+ onRemoveFile,
109
+ queryOptions,
110
+ customFileRow,
111
+ disableRemove,
112
+ }: FileListProps): JSX.Element | null => {
85
113
  if (files.length === 0) return null;
86
114
 
87
115
  return (
88
116
  <List>
89
117
  {files.map((file) => {
90
- return <FileRow key={file.name} file={file} options={options} onRemoveFile={onRemoveFile} />;
118
+ return (
119
+ <FileRow
120
+ key={file.name}
121
+ file={file}
122
+ options={options}
123
+ onRemoveFile={onRemoveFile}
124
+ queryOptions={queryOptions}
125
+ customFileRow={customFileRow}
126
+ disableRemove={disableRemove}
127
+ />
128
+ );
91
129
  })}
92
130
  </List>
93
131
  );
@@ -10,7 +10,9 @@ const TestForm = () => {
10
10
 
11
11
  return (
12
12
  <FormProvider {...methods}>
13
- <FilePickerBtn name="test" inputProps={{ ref }} onChange={jest.fn()} />
13
+ <FilePickerBtn name="test" inputProps={{ ref }} onChange={jest.fn()}>
14
+ Browse Files
15
+ </FilePickerBtn>
14
16
  </FormProvider>
15
17
  );
16
18
  };
@@ -1,10 +1,10 @@
1
- import { ChangeEvent, RefObject } from 'react';
1
+ import type { ChangeEvent, RefObject } from 'react';
2
2
  import type { DropzoneInputProps } from 'react-dropzone';
3
- import { useFormContext } from 'react-hook-form';
3
+ import { Controller } from 'react-hook-form';
4
4
  import { Button, ButtonProps } from '@availity/mui-button';
5
5
  import { Input } from '@availity/mui-form-utils';
6
6
 
7
- type FilePickerBtnProps = {
7
+ export type FilePickerBtnProps = {
8
8
  /**
9
9
  * Name attribute for the input field, used by react-hook-form for form state management.
10
10
  */
@@ -29,7 +29,7 @@ type FilePickerBtnProps = {
29
29
 
30
30
  export const FilePickerBtn = ({
31
31
  name,
32
- children = 'Browse Files',
32
+ children,
33
33
  color,
34
34
  inputId,
35
35
  inputProps = {},
@@ -38,27 +38,29 @@ export const FilePickerBtn = ({
38
38
  onClick,
39
39
  ...rest
40
40
  }: FilePickerBtnProps) => {
41
- const { register } = useFormContext();
42
-
43
41
  const { accept, multiple, ref, style, type: inputType } = inputProps;
44
42
 
45
- const field = register(name);
46
-
47
43
  return (
48
44
  <>
49
- <Input
50
- {...field}
51
- onChange={onChange}
52
- value=""
53
- inputRef={ref}
54
- type={inputType}
55
- sx={style}
56
- inputProps={{
57
- accept,
58
- size: maxSize ?? undefined,
59
- multiple,
60
- }}
61
- id={inputId}
45
+ <Controller
46
+ name={name}
47
+ defaultValue={[]}
48
+ render={({ field }) => (
49
+ <Input
50
+ {...field}
51
+ onChange={onChange}
52
+ value={[]}
53
+ inputRef={ref}
54
+ type={inputType}
55
+ sx={style}
56
+ inputProps={{
57
+ accept,
58
+ size: maxSize ?? undefined,
59
+ multiple,
60
+ }}
61
+ id={inputId}
62
+ />
63
+ )}
62
64
  />
63
65
  <Button color={color} {...rest} onClick={onClick} fullWidth={false}>
64
66
  {children}
@@ -1,7 +1,14 @@
1
1
  // Each exported component in the package should have its own stories file
2
+ import { useState } from 'react';
2
3
  import type { Meta, StoryObj } from '@storybook/react';
3
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { FormProvider, useForm } from 'react-hook-form';
5
+ import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
6
+ import type { default as Upload } from '@availity/upload-core';
4
7
  import { Paper } from '@availity/mui-paper';
8
+ import { Alert, AlertProps, AlertTitle } from '@availity/mui-alert';
9
+ import { Grid } from '@availity/mui-layout';
10
+ import { Button } from '@availity/mui-button';
11
+ import Collapse from '@mui/material/Collapse';
5
12
 
6
13
  import { FileSelector, FileSelectorProps } from './FileSelector';
7
14
 
@@ -30,22 +37,77 @@ const meta: Meta<typeof FileSelector> = {
30
37
 
31
38
  export default meta;
32
39
 
40
+ const DismissableAlert = (props: AlertProps) => {
41
+ const [visible, setVisible] = useState(true);
42
+
43
+ const onClose = () => {
44
+ setVisible(false);
45
+ };
46
+
47
+ return (
48
+ <Collapse in={visible}>
49
+ <Alert onClose={onClose} {...props} />
50
+ </Collapse>
51
+ );
52
+ };
53
+
33
54
  export const _FileSelector: StoryObj<typeof FileSelector> = {
34
55
  render: (props: FileSelectorProps) => {
56
+ const methods = useForm({
57
+ defaultValues: {
58
+ [props.name]: [] as File[],
59
+ },
60
+ });
61
+
62
+ const client = useQueryClient();
63
+
64
+ const files = methods.watch(props.name);
65
+
66
+ const handleOnSubmit = (values: Record<string, File[]>) => {
67
+ if (values[props.name].length === 0) return;
68
+
69
+ const queries = client.getQueriesData<Upload>(['upload']);
70
+ const uploads = [];
71
+ for (const [, data] of queries) {
72
+ if (data) uploads.push(data);
73
+ }
74
+ };
75
+
35
76
  return (
36
77
  <Paper sx={{ padding: '2rem' }}>
37
- <FileSelector {...props} />
78
+ <FormProvider {...methods}>
79
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
80
+ <FileSelector {...props}>
81
+ <DismissableAlert severity="warning">
82
+ <AlertTitle>Make an Appeal</AlertTitle>
83
+ This is an example alert. It is not part of the component. `children` you pass to the component will
84
+ show up here.
85
+ </DismissableAlert>
86
+ </FileSelector>
87
+ {files.length > 0 && (
88
+ <Grid xs={12} justifyContent="end" display="flex" paddingTop={2.5}>
89
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
90
+ Submit
91
+ </Button>
92
+ </Grid>
93
+ )}
94
+ </form>
95
+ </FormProvider>
38
96
  </Paper>
39
97
  );
40
98
  },
41
99
  args: {
42
100
  name: 'file-selector',
43
- allowedFileTypes: [],
101
+ allowedFileTypes: ['.txt', '.png', '.pdf'],
44
102
  clientId: '123',
45
103
  customerId: '456',
46
104
  bucketId: '789',
47
- retryDelays: [],
105
+ uploadOptions: {
106
+ retryDelays: [],
107
+ },
108
+ maxFiles: 5,
48
109
  maxSize: 1 * 1024 * 1024, // 1MB
110
+ enableDropArea: true,
49
111
  isCloud: true,
50
112
  multiple: true,
51
113
  },
@@ -1,14 +1,24 @@
1
+ import type { ReactNode } from 'react';
1
2
  import { render } from '@testing-library/react';
3
+ import { useForm, FormProvider } from 'react-hook-form';
2
4
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
5
 
4
6
  import { FileSelector } from './FileSelector';
5
7
 
8
+ const TestForm = ({ children }: { children: ReactNode }) => {
9
+ const methods = useForm();
10
+
11
+ return <FormProvider {...methods}>{children}</FormProvider>;
12
+ };
13
+
6
14
  describe('FileSelector', () => {
7
15
  test('should render successfully', () => {
8
16
  const client = new QueryClient();
9
17
  const { getByText } = render(
10
18
  <QueryClientProvider client={client}>
11
- <FileSelector name="test" bucketId="test" customerId="123" clientId="test" maxSize={1000} />
19
+ <TestForm>
20
+ <FileSelector name="test" bucketId="test" customerId="123" clientId="test" maxSize={1000} maxFiles={1} />
21
+ </TestForm>
12
22
  </QueryClientProvider>
13
23
  );
14
24