|
1 | | -import { Box, Button, FormControl, FormLabel, HStack, Input } from '@chakra-ui/react'; |
| 1 | +import { |
| 2 | + Box, |
| 3 | + Button, |
| 4 | + FormControl, |
| 5 | + FormLabel, |
| 6 | + HStack, |
| 7 | + Input, |
| 8 | + VisuallyHidden, |
| 9 | +} from '@chakra-ui/react'; |
2 | 10 | import { IconPlus } from '../../../assets/icons'; |
3 | | -import { FieldError, FieldValues, Path, UseFormRegisterReturn } from 'react-hook-form'; |
4 | | -import { useState } from 'react'; |
| 11 | +import { |
| 12 | + Control, |
| 13 | + FieldPath, |
| 14 | + FieldValues, |
| 15 | + useController, |
| 16 | + UseControllerProps, |
| 17 | +} from 'react-hook-form'; |
| 18 | +import { useRef, useState } from 'react'; |
5 | 19 |
|
6 | | -type FileInputProps<K extends Path<T>, T extends FieldValues> = { |
| 20 | +type FileInputProps<T extends FieldValues, K extends FieldPath<T>> = { |
7 | 21 | label: string; |
8 | | - register: UseFormRegisterReturn<K>; |
9 | | - onClear: () => void; |
10 | 22 | accept?: string | string[]; |
11 | | - error?: FieldError | undefined; |
| 23 | + name: K; |
| 24 | + control: Control<T>; |
| 25 | + rules?: UseControllerProps<T, K>['rules']; |
12 | 26 | }; |
13 | 27 |
|
| 28 | +// Forbidden file extensions. This is a front-end only, UX check, so users can |
| 29 | +// select a new file right away instead of waiting for their job to fail. It is |
| 30 | +// NOT a security check. |
| 31 | +const FORBIDDEN_EXTENSIONS = [ |
| 32 | + '.xlsx', |
| 33 | + '.xls', |
| 34 | + '.pdf', |
| 35 | + '.doc', |
| 36 | + '.docx', |
| 37 | + '.zip', |
| 38 | + '.ppt', |
| 39 | + '.pptx', |
| 40 | + '.exe', |
| 41 | +] as const; |
| 42 | + |
14 | 43 | const formatFileType = (fileType: string | undefined) => |
15 | 44 | fileType && |
16 | 45 | !fileType.includes('/') && // ignore MIME types (e.g. application/pdf) |
17 | 46 | !fileType.startsWith('.') |
18 | 47 | ? `.${fileType}` |
19 | 48 | : fileType; |
20 | 49 |
|
21 | | -export const RunwayFileInput = <K extends Path<T>, T extends FieldValues>({ |
| 50 | +export const RunwayFileInput = <T extends FieldValues, K extends FieldPath<T>>({ |
22 | 51 | label, |
23 | | - register, |
24 | | - onClear, |
25 | | - error, |
26 | 52 | accept, |
27 | | -}: FileInputProps<K, T>) => { |
28 | | - const [fileName, setFileName] = useState<string | null>(null); |
| 53 | + name, |
| 54 | + control, |
| 55 | + rules, |
| 56 | +}: FileInputProps<T, K>) => { |
| 57 | + const { field, fieldState } = useController({ name, control, rules }); |
| 58 | + const [forbiddenError, setForbiddenError] = useState<string | null>(null); |
| 59 | + const inputRef = useRef<HTMLInputElement | null>(null); |
29 | 60 |
|
30 | 61 | const acceptedFileTypes = Array.isArray(accept) |
31 | 62 | ? accept.map(formatFileType).join(',') |
32 | 63 | : formatFileType(accept); |
33 | 64 |
|
| 65 | + const fileName: string | null = field.value?.[0]?.name ?? null; |
| 66 | + |
| 67 | + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 68 | + const file = e.target.files?.[0]; |
| 69 | + if (!file) { |
| 70 | + field.onChange(null); |
| 71 | + return; |
| 72 | + } |
| 73 | + |
| 74 | + const fileNameLower = file.name.toLowerCase(); |
| 75 | + const forbiddenExtension = FORBIDDEN_EXTENSIONS.find((ext) => fileNameLower.endsWith(ext)); |
| 76 | + if (forbiddenExtension) { |
| 77 | + e.target.value = ''; |
| 78 | + field.onChange(null); |
| 79 | + setForbiddenError( |
| 80 | + `${file.name} cannot be uploaded because ${forbiddenExtension} files are not supported.${ |
| 81 | + acceptedFileTypes |
| 82 | + ? ` Expected file type${ |
| 83 | + acceptedFileTypes.split(',').length > 1 ? 's' : '' |
| 84 | + }: ${acceptedFileTypes}` |
| 85 | + : '' |
| 86 | + }` |
| 87 | + ); |
| 88 | + return; |
| 89 | + } |
| 90 | + |
| 91 | + setForbiddenError(null); |
| 92 | + field.onChange(e.target.files); |
| 93 | + }; |
| 94 | + |
| 95 | + const errorMessage = forbiddenError || fieldState.error?.message; |
| 96 | + |
34 | 97 | return ( |
35 | | - <HStack paddingY="200" gap="400" width="100%"> |
36 | | - <FormControl variant="file" padding="0" width="100%"> |
37 | | - <HStack justifyContent="flex-start" gap="400"> |
38 | | - <FormLabel |
39 | | - variant="file" |
40 | | - textColor="blue.50" |
41 | | - width="100%" |
42 | | - tabIndex={fileName ? -1 : 0} // remove file input from tab order if file is already selected |
43 | | - onKeyDown={(e) => { |
44 | | - // allow opening file input with keyboard |
45 | | - if (e.key === 'Enter' || e.key === ' ') { |
46 | | - e.preventDefault(); |
47 | | - e.currentTarget.click(); |
48 | | - } |
| 98 | + <FormControl variant="file" width="100%" paddingX="0px"> |
| 99 | + <FormLabel textColor="blue.50"> |
| 100 | + <Box as="span" textStyle="bodyLargeBold"> |
| 101 | + {label} |
| 102 | + </Box> |
| 103 | + </FormLabel> |
| 104 | + <VisuallyHidden> |
| 105 | + <Input type="file" accept={acceptedFileTypes} ref={inputRef} onChange={handleFileChange} /> |
| 106 | + </VisuallyHidden> |
| 107 | + <HStack gap="200" alignItems="baseline"> |
| 108 | + <Box textStyle="body" wordBreak="break-word" flex={1}> |
| 109 | + {errorMessage ? ( |
| 110 | + <Box as="span" textColor="pink.100"> |
| 111 | + {errorMessage} |
| 112 | + </Box> |
| 113 | + ) : fileName ? ( |
| 114 | + <Box as="span" textColor="blue.50"> |
| 115 | + {fileName} |
| 116 | + </Box> |
| 117 | + ) : ( |
| 118 | + <Box as="span" textColor="blue.50" fontStyle="italic"> |
| 119 | + no file selected |
| 120 | + </Box> |
| 121 | + )} |
| 122 | + </Box> |
| 123 | + {fileName ? ( |
| 124 | + <Button |
| 125 | + variant="unstyled" |
| 126 | + textStyle="button" |
| 127 | + textColor="green.100" |
| 128 | + padding="200" |
| 129 | + flexShrink={0} |
| 130 | + onClick={() => { |
| 131 | + setForbiddenError(null); |
| 132 | + field.onChange(null); |
| 133 | + if (inputRef.current) inputRef.current.value = ''; |
49 | 134 | }} |
50 | 135 | > |
51 | | - <HStack as="span" gap="400" justifyContent="space-between"> |
52 | | - <Box as="span" textStyle="bodyLargeBold"> |
53 | | - {label} |
54 | | - </Box> |
55 | | - {fileName ? ( |
56 | | - <Box as="span" textStyle="body"> |
57 | | - {fileName} |
58 | | - </Box> |
59 | | - ) : ( |
60 | | - <HStack |
61 | | - as="span" |
62 | | - padding="200" |
63 | | - gap="200" |
64 | | - textStyle="button" |
65 | | - layerStyle="buttonPrimary" |
66 | | - > |
67 | | - <IconPlus height={12} width={12} /> |
68 | | - <Box as="span">select file</Box> |
69 | | - </HStack> |
70 | | - )} |
71 | | - </HStack> |
72 | | - </FormLabel> |
73 | | - <Input |
74 | | - display="none" |
75 | | - type="file" |
76 | | - accept={acceptedFileTypes} |
77 | | - {...register} |
78 | | - onChange={(e) => { |
79 | | - const fileName = e.target.files?.[0]?.name; |
80 | | - setFileName(fileName ?? null); // just controls display, not form value |
81 | | - register.onChange(e); |
82 | | - }} |
83 | | - /> |
84 | | - {!!error && ( |
85 | | - <Box textStyle="body" textColor="pink.100"> |
86 | | - {error?.message} |
| 136 | + <Box as="span">—</Box> |
| 137 | + <Box as="span" ml="200"> |
| 138 | + remove file |
87 | 139 | </Box> |
88 | | - )} |
89 | | - </HStack> |
90 | | - </FormControl> |
91 | | - {fileName && onClear && ( |
92 | | - <Button |
93 | | - variant="unstyled" |
94 | | - textStyle="button" |
95 | | - textColor="green.100" |
96 | | - padding="200" |
97 | | - onClick={() => { |
98 | | - onClear(); |
99 | | - setFileName(null); |
100 | | - }} |
101 | | - > |
102 | | - <Box as="span"> —</Box> |
103 | | - <Box as="span" ml="200"> |
104 | | - remove file |
105 | | - </Box> |
106 | | - </Button> |
107 | | - )} |
108 | | - </HStack> |
| 140 | + </Button> |
| 141 | + ) : ( |
| 142 | + <Button variant="unstyled" flexShrink={0} onClick={() => inputRef.current?.click()}> |
| 143 | + <HStack as="span" padding="200" gap="200" textStyle="button" layerStyle="buttonPrimary"> |
| 144 | + <IconPlus height={12} width={12} /> |
| 145 | + <Box as="span">select file</Box> |
| 146 | + </HStack> |
| 147 | + </Button> |
| 148 | + )} |
| 149 | + </HStack> |
| 150 | + </FormControl> |
109 | 151 | ); |
110 | 152 | }; |
0 commit comments