@availity/mui-file-selector 0.1.3 → 0.2.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,14 +1,30 @@
1
- import { ChangeEvent, MouseEvent, RefObject } from 'react';
1
+ import { ChangeEvent, RefObject } from 'react';
2
2
  import type { DropzoneInputProps } from 'react-dropzone';
3
3
  import { useFormContext } 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
7
  type FilePickerBtnProps = {
8
+ /**
9
+ * Name attribute for the input field, used by react-hook-form for form state management.
10
+ */
8
11
  name: string;
9
- maxSize?: number;
12
+ /**
13
+ * Callback function triggered when files are selected through the input.
14
+ */
15
+ onChange: (event: ChangeEvent<HTMLInputElement>) => void;
16
+ /**
17
+ * Optional ID attribute for the file input element.
18
+ */
10
19
  inputId?: string;
20
+ /**
21
+ * Additional props to customize the underlying input element.
22
+ */
11
23
  inputProps?: DropzoneInputProps & { ref?: RefObject<HTMLInputElement> };
24
+ /**
25
+ * Maximum allowed size per file in bytes. Files exceeding this size will be rejected.
26
+ */
27
+ maxSize?: number;
12
28
  } & Omit<ButtonProps, 'onChange'>;
13
29
 
14
30
  export const FilePickerBtn = ({
@@ -18,35 +34,13 @@ export const FilePickerBtn = ({
18
34
  inputId,
19
35
  inputProps = {},
20
36
  maxSize,
37
+ onChange,
21
38
  onClick,
22
39
  ...rest
23
40
  }: FilePickerBtnProps) => {
24
- const { register, setValue } = useFormContext();
25
-
26
- const { accept, multiple, ref, style, type: inputType, onChange } = inputProps;
27
-
28
- const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
29
- const { files } = event.target;
30
-
31
- const value: File[] = [];
32
- if (files) {
33
- // FileList is not iterable. Must use for loop for now
34
- for (let i = 0; i < files.length; i++) {
35
- if (maxSize) {
36
- console.log('file is too big:', files[i].size > maxSize);
37
- }
38
- value[i] = files[i];
39
- }
40
- }
41
-
42
- setValue(name, value);
43
-
44
- // if (onChange) onChange(event);
45
- };
41
+ const { register } = useFormContext();
46
42
 
47
- const handleOnClick = (event: MouseEvent<HTMLButtonElement>) => {
48
- if (onClick) onClick(event);
49
- };
43
+ const { accept, multiple, ref, style, type: inputType } = inputProps;
50
44
 
51
45
  const field = register(name);
52
46
 
@@ -54,7 +48,7 @@ export const FilePickerBtn = ({
54
48
  <>
55
49
  <Input
56
50
  {...field}
57
- onChange={handleOnChange}
51
+ onChange={onChange}
58
52
  value=""
59
53
  inputRef={ref}
60
54
  type={inputType}
@@ -66,7 +60,7 @@ export const FilePickerBtn = ({
66
60
  }}
67
61
  id={inputId}
68
62
  />
69
- <Button color={color} {...rest} onClick={handleOnClick} fullWidth={false}>
63
+ <Button color={color} {...rest} onClick={onClick} fullWidth={false}>
70
64
  {children}
71
65
  </Button>
72
66
  </>
@@ -3,16 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react';
3
3
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
4
  import { Paper } from '@availity/mui-paper';
5
5
 
6
- // import { FileSelector, FileSelectorProps } from './FileSelector';
7
-
8
- type FileSelectorProps = {
9
- name: string;
10
- };
11
-
12
- const FileSelector = (props: FileSelectorProps) => {
13
- console.log(props);
14
- return <div>placeholder</div>;
15
- };
6
+ import { FileSelector, FileSelectorProps } from './FileSelector';
16
7
 
17
8
  const meta: Meta<typeof FileSelector> = {
18
9
  title: 'Components/File Selector/File Selector',
@@ -47,12 +38,13 @@ export const _FileSelector: StoryObj<typeof FileSelector> = {
47
38
  ),
48
39
  args: {
49
40
  name: 'file-selector',
50
- // allowedFileTypes: ['.txt'],
51
- // clientId: '123',
52
- // customerId: '456',
53
- // bucketId: '789',
54
- // maxSize: 1 * 1000 * 1000, // 1MB
55
- // isCloud: true,
56
- // multiple: true,
41
+ allowedFileTypes: [],
42
+ clientId: '123',
43
+ customerId: '456',
44
+ bucketId: '789',
45
+ retryDelays: [],
46
+ maxSize: 1 * 1024 * 1024, // 1MB
47
+ isCloud: true,
48
+ multiple: true,
57
49
  },
58
50
  };
@@ -1,45 +1,130 @@
1
- import { ReactNode, useState } from 'react';
1
+ import { ChangeEvent, ReactNode, useState } from 'react';
2
2
  import { useForm, FormProvider } from 'react-hook-form';
3
3
  import type { FileRejection } from 'react-dropzone/typings/react-dropzone';
4
- import Upload, { Options } from '@availity/upload-core';
4
+ import { useQueryClient } from '@tanstack/react-query';
5
+ import Upload, { UploadOptions } from '@availity/upload-core';
6
+ import { Button } from '@availity/mui-button';
7
+ import { Grid } from '@availity/mui-layout';
8
+ import { Typography } from '@availity/mui-typography';
5
9
 
6
10
  import { Dropzone } from './Dropzone';
11
+ import { ErrorAlert } from './ErrorAlert';
7
12
  import { FileList } from './FileList';
8
13
  import { FileTypesMessage } from './FileTypesMessage';
9
- import { useUploadCore } from './useUploadCore';
10
- import { Typography } from '@availity/mui-typography';
11
14
 
12
15
  const CLOUD_URL = '/cloud/web/appl/vault/upload/v1/resumable';
13
16
 
14
17
  export type FileSelectorProps = {
18
+ /**
19
+ * Name attribute for the form field. Used by react-hook-form for form state management
20
+ * and must be unique within the form context
21
+ */
15
22
  name: string;
23
+ /**
24
+ * The ID of the bucket where files will be uploaded
25
+ */
16
26
  bucketId: string;
27
+ /**
28
+ * The customer ID associated with the upload
29
+ */
17
30
  customerId: string;
31
+ /**
32
+ * Regular expression pattern of allowed characters in file names
33
+ * @example "a-zA-Z0-9-_."
34
+ */
18
35
  allowedFileNameCharacters?: string;
36
+ /**
37
+ * List of allowed file extensions. Each extension must start with a dot
38
+ * @example ['.pdf', '.doc', '.docx']
39
+ * @default []
40
+ */
19
41
  allowedFileTypes?: `.${string}`[];
42
+ /**
43
+ * Optional content to render below the file upload area
44
+ */
20
45
  children?: ReactNode;
46
+ /**
47
+ * Client identifier used for upload authentication
48
+ */
21
49
  clientId: string;
22
- deliverFileOnSubmit?: boolean;
23
- deliveryChannel?: string;
50
+ /**
51
+ * Whether the file selector is disabled
52
+ * @default false
53
+ */
24
54
  disabled?: boolean;
55
+ /**
56
+ * Custom endpoint URL for file uploads. If not provided, default endpoint will be used
57
+ */
25
58
  endpoint?: string;
26
- fileDeliveryMetadata?: Record<string, unknown> | ((file: Upload) => Record<string, unknown>);
27
- getDropRejectionMessages?: (rejections: FileRejection[]) => void;
59
+ /**
60
+ * Whether to use the cloud upload endpoint
61
+ * When true, uses '/cloud/web/appl/vault/upload/v1/resumable'
62
+ */
28
63
  isCloud?: boolean;
64
+ /**
65
+ * Label text or element displayed above the upload area
66
+ * @default 'Upload file'
67
+ */
29
68
  label?: ReactNode;
69
+ /**
70
+ * Maximum number of files that can be uploaded simultaneously
71
+ */
30
72
  maxFiles?: number;
73
+ /**
74
+ * Maximum file size allowed per file in bytes
75
+ * Use Kibi or Mibibytes. eg: 1kb = 1024 bytes; 1mb = 1024kb
76
+ */
31
77
  maxSize: number;
78
+ /**
79
+ * Whether multiple file selection is allowed
80
+ * @default true
81
+ */
32
82
  multiple?: boolean;
33
- onDeliveryError?: (error: unknown) => void;
34
- onDeliverySuccess?: () => void;
35
- onSubmit?: (values: Record<string, unknown>) => void;
36
- onSuccess?: (() => void)[];
37
- onError?: ((error: Error) => void)[];
83
+ /**
84
+ * Callback fired when files are selected
85
+ * @param event - The change event containing the selected file(s)
86
+ */
87
+ onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
88
+ /**
89
+ * Callback fired when the form is submitted
90
+ * @param uploads - Array of Upload instances for the submitted files
91
+ * @param values - Object containing the form values, with files indexed by the name prop
92
+ */
93
+ onSubmit?: (uploads: Upload[], values: Record<string, File[]>) => void;
94
+ /**
95
+ * Callback fired when a file is successfully uploaded
96
+ */
97
+ onSuccess?: UploadOptions['onSuccess'];
98
+ /**
99
+ * Callback fired when an error occurs during upload
100
+ */
101
+ onError?: UploadOptions['onError'];
102
+ /**
103
+ * Array of functions to execute before file upload begins.
104
+ * Each function should return a boolean indicating whether to proceed with the upload.
105
+ * @default []
106
+ */
38
107
  onFilePreUpload?: (() => boolean)[];
39
- onUploadRemove?: (uploads: Upload[], removedUploadId: string) => void;
40
- onFileDelivery?: (upload: Upload) => void;
108
+ /**
109
+ * Callback fired when a file is removed from the upload list
110
+ * @param files - Array of remaining files
111
+ * @param removedUploadId - ID of the removed upload
112
+ */
113
+ onUploadRemove?: (files: File[], removedUploadId: string) => void;
114
+ /**
115
+ * Array of delays (in milliseconds) between upload retry attempts
116
+ */
117
+ retryDelays?: UploadOptions['retryDelays'];
41
118
  };
42
119
 
120
+ // Below props were removed from availity-react version. Perserving here in case needed later
121
+ // deliverFileOnSubmit?: boolean;
122
+ // deliveryChannel?: string;
123
+ // fileDeliveryMetadata?: Record<string, unknown> | ((file: Upload) => Record<string, unknown>);
124
+ // onDeliveryError?: (error: unknown) => void;
125
+ // onDeliverySuccess?: () => void;
126
+ // onFileDelivery?: (upload: Upload) => void;
127
+
43
128
  export const FileSelector = ({
44
129
  name,
45
130
  allowedFileNameCharacters,
@@ -48,36 +133,33 @@ export const FileSelector = ({
48
133
  clientId,
49
134
  children,
50
135
  customerId,
51
- deliverFileOnSubmit = false,
52
- deliveryChannel,
53
136
  disabled = false,
54
137
  endpoint,
55
- fileDeliveryMetadata,
56
- getDropRejectionMessages,
57
138
  isCloud,
58
139
  label = 'Upload file',
59
- maxFiles = 1,
140
+ maxFiles,
60
141
  maxSize,
61
142
  multiple = true,
62
- // onDeliveryError,
63
- // onDeliverySuccess,
143
+ onChange,
64
144
  onSubmit,
65
145
  onSuccess,
66
146
  onError,
67
147
  onFilePreUpload = [],
68
148
  onUploadRemove,
69
- onFileDelivery,
149
+ retryDelays,
70
150
  }: FileSelectorProps) => {
71
- // const classes = classNames(
72
- // className,
73
- // metadata.touched ? 'is-touched' : 'is-untouched',
74
- // metadata.touched && metadata.error && 'is-invalid'
75
- // );
76
151
  const [totalSize, setTotalSize] = useState(0);
152
+ const [fileRejections, setFileRejections] = useState<(FileRejection & { id: number })[]>([]);
153
+
154
+ const client = useQueryClient();
77
155
 
78
- const methods = useForm();
156
+ const methods = useForm({
157
+ defaultValues: {
158
+ [name]: [] as File[],
159
+ },
160
+ });
79
161
 
80
- const options: Options = {
162
+ const options: UploadOptions = {
81
163
  bucketId,
82
164
  customerId,
83
165
  clientId,
@@ -86,62 +168,86 @@ export const FileSelector = ({
86
168
  allowedFileNameCharacters,
87
169
  onError,
88
170
  onSuccess,
171
+ retryDelays,
89
172
  };
90
173
 
91
174
  if (onFilePreUpload) options.onPreStart = onFilePreUpload;
92
175
  if (endpoint) options.endpoint = endpoint;
93
176
  if (isCloud) options.endpoint = CLOUD_URL;
94
177
 
95
- const { data: uploads = [] } = useUploadCore(methods.watch(name) || [], options);
178
+ const handleOnRemoveFile = (uploadId: string, upload: Upload) => {
179
+ const prevFiles = methods.watch(name);
180
+ const newFiles = prevFiles.filter((file) => file.name !== upload.file.name);
96
181
 
97
- const handleOnRemoveFile = (uploadId: string) => {
98
- const newFiles = uploads.filter((upload) => upload.id !== uploadId);
99
-
100
- if (newFiles.length !== uploads.length) {
101
- const removedFile = uploads.find((upload) => upload.id === uploadId);
182
+ if (newFiles.length !== prevFiles.length) {
183
+ const removedFile = prevFiles.find((file) => file.name === upload.file.name);
102
184
 
103
185
  methods.setValue(name, newFiles);
104
186
 
105
- if (!removedFile?.error && !removedFile?.errorMessage && removedFile?.file.size)
106
- setTotalSize(totalSize - removedFile.file.size);
187
+ if (removedFile?.size) setTotalSize(totalSize - removedFile.size);
188
+
107
189
  if (onUploadRemove) onUploadRemove(newFiles, uploadId);
108
190
  }
109
191
  };
110
192
 
111
- const handleOSubmit = (values: Record<string, unknown>) => {
112
- if (onSubmit) onSubmit(values);
193
+ const files = methods.watch(name);
194
+
195
+ const handleOnSubmit = (values: Record<string, File[]>) => {
196
+ if (values[name].length === 0) return;
197
+
198
+ const queries = client.getQueriesData<Upload>(['upload']);
199
+ const uploads = [];
200
+ for (const [, data] of queries) {
201
+ if (data) uploads.push(data);
202
+ }
203
+
204
+ if (onSubmit) onSubmit(uploads, values);
205
+ };
206
+
207
+ const handleRemoveRejection = (id: number) => {
208
+ const rejections = fileRejections.filter((value) => value.id !== id);
209
+ setFileRejections(rejections);
113
210
  };
114
211
 
115
212
  return (
116
213
  <FormProvider {...methods}>
117
- <form onSubmit={methods.handleSubmit(handleOSubmit)}>
214
+ <form onSubmit={methods.handleSubmit(handleOnSubmit)}>
118
215
  <>
119
216
  <Typography>{label}</Typography>
120
217
  <Dropzone
121
218
  name={name}
122
- allowedFileNameCharacters={allowedFileNameCharacters}
123
219
  allowedFileTypes={allowedFileTypes}
124
- bucketId={bucketId}
125
- clientId={clientId}
126
- customerId={customerId}
127
- deliverFileOnSubmit={deliverFileOnSubmit}
128
- deliveryChannel={deliveryChannel}
129
220
  disabled={disabled}
130
- endpoint={endpoint}
131
- fileDeliveryMetadata={fileDeliveryMetadata}
132
- getDropRejectionMessages={getDropRejectionMessages}
133
- isCloud={isCloud}
221
+ maxFiles={maxFiles}
134
222
  maxSize={maxSize}
135
223
  multiple={multiple}
136
- // onDeliveryError={onDeliveryError}
137
- // onDeliverySuccess={onDeliverySuccess}
138
- onFilePreUpload={onFilePreUpload}
139
- onFileDelivery={onFileDelivery}
224
+ onChange={onChange}
225
+ setFileRejections={setFileRejections}
226
+ setTotalSize={setTotalSize}
140
227
  />
141
228
  <FileTypesMessage allowedFileTypes={allowedFileTypes} maxFileSize={maxSize} />
142
229
  </>
143
230
  {children}
144
- <FileList uploads={uploads} onRemoveFile={handleOnRemoveFile} />
231
+
232
+ {fileRejections.length > 0
233
+ ? fileRejections.map((rejection) => (
234
+ <ErrorAlert
235
+ key={rejection.id}
236
+ errors={rejection.errors}
237
+ fileName={rejection.file.name}
238
+ id={rejection.id}
239
+ onClose={() => handleRemoveRejection(rejection.id)}
240
+ />
241
+ ))
242
+ : null}
243
+ <FileList files={files} options={options} onRemoveFile={handleOnRemoveFile} />
244
+ {files.length > 0 && (
245
+ <Grid xs={12} justifyContent="end" display="flex" paddingTop={2.5}>
246
+ <Button type="submit" sx={{ marginLeft: 'auto', marginRight: 0 }}>
247
+ Submit
248
+ </Button>
249
+ </Grid>
250
+ )}
145
251
  </form>
146
252
  </FormProvider>
147
253
  );
@@ -8,4 +8,10 @@ describe('FileTypesMessage', () => {
8
8
 
9
9
  expect(screen.getByText(/All file types allowed/)).toBeTruthy();
10
10
  });
11
+
12
+ test('should show file size', () => {
13
+ render(<FileTypesMessage allowedFileTypes={[]} maxFileSize={1000} />);
14
+
15
+ expect(screen.getByText(/Maximum file size is/)).toBeTruthy();
16
+ });
11
17
  });
@@ -3,7 +3,13 @@ import { Typography } from '@availity/mui-typography';
3
3
  import { formatBytes } from './util';
4
4
 
5
5
  type FileTypesMessageProps = {
6
+ /**
7
+ * Allowed file type extensions. Each extension should be prefixed with a ".". eg: .txt, .pdf, .png
8
+ */
6
9
  allowedFileTypes: `.${string}`[];
10
+ /**
11
+ * Maximum size per file in bytes. This will be formatted. eg: 1024 * 20 = 20 KB
12
+ */
7
13
  maxFileSize: number;
8
14
  };
9
15
 
@@ -3,7 +3,13 @@ import { Typography } from '@availity/mui-typography';
3
3
  import { formatBytes } from './util';
4
4
 
5
5
  export type HeaderMessageProps = {
6
+ /**
7
+ * Maximum number of files allowed
8
+ */
6
9
  maxFiles: number;
10
+ /**
11
+ * Maximum combined total size of all files
12
+ */
7
13
  maxSize: number;
8
14
  };
9
15
 
@@ -20,4 +20,21 @@ describe('UploadProgressBar', () => {
20
20
 
21
21
  expect(screen.getByText('50%')).toBeTruthy();
22
22
  });
23
+
24
+ test('should show error message', () => {
25
+ const mockUpload: unknown = {
26
+ onProgress: [],
27
+ onError: [],
28
+ onSuccess: [],
29
+ errorMessage: 'error message',
30
+ file: {
31
+ name: 'test',
32
+ },
33
+ percentage: 0,
34
+ };
35
+
36
+ render(<UploadProgressBar upload={mockUpload as Upload} />);
37
+
38
+ expect(screen.getByText('error message')).toBeTruthy();
39
+ });
23
40
  });
@@ -6,13 +6,21 @@ import { Typography } from '@availity/mui-typography';
6
6
  import { WarningTriangleIcon } from '@availity/mui-icon';
7
7
 
8
8
  export type UploadProgressBarProps = {
9
- /** The upload instance returned by creating a new Upload via @availity/upload-core. */
9
+ /**
10
+ * The upload instance returned by creating a new Upload via @availity/upload-core.
11
+ */
10
12
  upload: Upload;
11
- /** Callback function to hook into the onProgress within the Upload instance provided in the upload prop. */
13
+ /**
14
+ * Callback function to hook into the onProgress within the Upload instance provided in the upload prop.
15
+ */
12
16
  onProgress?: (upload: Upload) => void;
13
- /** Callback function to hook into the onSuccess within the Upload instance provided in the upload prop. */
17
+ /**
18
+ * Callback function to hook into the onSuccess within the Upload instance provided in the upload prop.
19
+ */
14
20
  onSuccess?: (upload: Upload) => void;
15
- /** Callback function to hook into the onError within the Upload instance provided in the upload prop. */
21
+ /**
22
+ * Callback function to hook into the onError within the Upload instance provided in the upload prop.
23
+ */
16
24
  onError?: (upload: Upload) => void;
17
25
  };
18
26
 
@@ -4,14 +4,23 @@ import Upload from '@availity/upload-core';
4
4
  import { AxiosResponse } from 'axios';
5
5
 
6
6
  export type UploadDeliveryOptions = {
7
+ /** ID of the vault bucket */
7
8
  bucketId: string;
9
+ /** Client ID to be attached to the request */
8
10
  clientId: string;
11
+ /** Customer ID of the organization submitting the request */
9
12
  customerId: string;
13
+ /** Delivery Channel for the AvFileDeliveryApi */
10
14
  deliveryChannel?: string;
15
+ /** Determine whether AvFileDeliveryApi should be automatically called or if the component should wait */
11
16
  deliverFileOnSubmit?: boolean;
17
+ /** Metadata to be sent with the request. Can be an object or function that returns an object */
12
18
  fileDeliveryMetadata?: Record<string, unknown> | ((upload: Upload) => Record<string, unknown>);
19
+ /** Callback function for when the upload succeeds */
13
20
  onSuccess?: (responses: unknown[]) => void;
21
+ /** Callback function for when the upload fails */
14
22
  onError?: (responses: unknown[]) => void;
23
+ /** The upload instance returned by creating a new Upload via @availity/upload-core. */
15
24
  uploads: Upload[];
16
25
  };
17
26
 
@@ -22,8 +31,8 @@ export function useFileDelivery({
22
31
  deliveryChannel,
23
32
  deliverFileOnSubmit,
24
33
  fileDeliveryMetadata,
25
- onSuccess,
26
- onError,
34
+ // onSuccess,
35
+ // onError,
27
36
  uploads,
28
37
  }: UploadDeliveryOptions) {
29
38
  const errors = {};
@@ -1,22 +1,18 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
- import Upload, { Options } from '@availity/upload-core';
2
+ import Upload, { UploadOptions } from '@availity/upload-core';
3
3
 
4
- function startUploads(files: File[], options: Options) {
5
- return files.map((file) => {
6
- const upload = new Upload(file, options);
4
+ function startUpload(file: File, options: UploadOptions) {
5
+ const upload = new Upload(file, options);
7
6
 
8
- upload.start();
7
+ upload.start();
9
8
 
10
- return upload;
11
- });
9
+ return upload;
12
10
  }
13
11
 
14
- export function useUploadCore(files: File[], options: Options) {
15
- const fileNames = files.map((file) => file.name).join(',');
16
-
17
- const isQueryEnabled = files.length > 0;
12
+ export function useUploadCore(file: File, options: UploadOptions) {
13
+ const isQueryEnabled = !!file;
18
14
 
19
- return useQuery(['upload', fileNames, options], () => startUploads(files, options), {
15
+ return useQuery(['upload', file.name, options], () => startUpload(file, options), {
20
16
  enabled: isQueryEnabled,
21
17
  retry: false,
22
18
  });
package/src/lib/util.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export function formatBytes(bytes: number, decimals = 2) {
2
2
  if (!+bytes) return '0 Bytes';
3
3
 
4
- const k = 1000;
4
+ const k = 1024;
5
5
  const dm = decimals < 0 ? 0 : decimals;
6
6
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
7
7