Skip to content

Commit 4d506e2

Browse files
edandylyticsclaude
andauthored
App/edfial 382 forbidden file types (#27)
* first pass forbidden file types * adjust alignment of file input * remove unnecessary MIME validation * update error message to be more helpful * fix width issue * refactor file input layout for better UX - Move label to its own row - Swap select/remove button in same location to reduce visual jumping - Display filename below label with inline remove (x) button - Error message wraps properly below filename Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * redesign file input layout to separate display and action zones Split the file input into a stable two-zone layout: left side always shows status text (placeholder/filename/error), right side always shows an action button (select file/remove file). This eliminates the jarring swap between interactive and display elements in the same space. Also fix IconX to use currentColor instead of hardcoded green fill. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * replace x icon with text remove file button Restores the "— remove file" text button style for better parallelism with the "select file" button on the opposite side. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * clean up file input types, error message, and alignment - Use discriminated union to enforce setError/clearErrors are passed together or not at all - Fix trailing space in forbidden file error message - Switch to baseline alignment for text button layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * make setError and clearErrors required props These are needed for the forbidden file UX to work — without them a rejected file is silently discarded with no feedback. Every call site already passes both, so remove the optionality and the runtime guards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix accessibility: use proper label/button semantics FormLabel now wraps the label text and hidden input, correctly associating them for screen readers. The select file trigger is a real Button that programmatically clicks the input via ref, instead of a label element misused as a button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor file input to use controller pattern RunwayFileInput now uses useController internally instead of accepting register/onClear/setError/clearErrors from the caller. Props collapse from 7 to 5 (label, accept, name, control, rules). fileName is derived from field.value instead of local state. Forbidden-file error remains as local state for immediate feedback without affecting form-level validation mode. Native input value is cleared on remove to ensure re-selection of the same file triggers onChange. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * switch back to old var name * fix select label association for accessibility react-select renders custom divs, not a native <select>, so Chakra's automatic FormLabel/FormControl id wiring doesn't apply. Pass inputId to the Select component and match it with htmlFor on the label so browsers and screen readers can associate them correctly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ad8a896 commit 4d506e2

4 files changed

Lines changed: 144 additions & 109 deletions

File tree

app/fe/src/app/Pages/Jobs/JobCreatePage.tsx

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,9 @@ export const JobCreatePage = () => {
5656
const postJob = jobQueries.post();
5757
const startJob = jobQueries.start();
5858
const [isSaving, setIsSaving] = useState(false);
59-
const [error, setError] = useState<string | null>(null);
59+
const [formError, setFormError] = useState<string | null>(null);
6060

61-
const {
62-
control,
63-
watch,
64-
handleSubmit,
65-
register,
66-
setValue,
67-
reset,
68-
formState: { errors },
69-
} = useForm<IJobForm>();
61+
const { control, watch, handleSubmit, reset } = useForm<IJobForm>();
7062

7163
const requiredFileFields = useFieldArray({ control, name: 'requiredFiles' });
7264
const supplementaryFileFields = useFieldArray({ control, name: 'supplementaryFiles' });
@@ -86,13 +78,13 @@ export const JobCreatePage = () => {
8678

8779
const handleError = (msg: string) => {
8880
setIsSaving(false);
89-
setError(msg);
81+
setFormError(msg);
9082
reset(undefined, { keepValues: true });
9183
};
9284

9385
const submit = handleSubmit((data, e) => {
9486
setIsSaving(true);
95-
setError(null);
87+
setFormError(null);
9688
postJob.mutate(
9789
{ entity: formDataToDto(data) },
9890
{
@@ -213,7 +205,7 @@ export const JobCreatePage = () => {
213205
return (
214206
<FormLayout title="load a new assessment" backLink="/assessments">
215207
<chakra.form width="100%" height="100%" onSubmit={submit}>
216-
<VStack height="100%" width="100%" gap={error ? '500' : '800'}>
208+
<VStack height="100%" width="100%" gap={formError ? '500' : '800'}>
217209
<VStack width="100%" gap="500" alignItems="flex-start" flexGrow={1}>
218210
<FormSection maxW="24rem">
219211
<RunwaySelect
@@ -271,17 +263,15 @@ export const JobCreatePage = () => {
271263
width="100%"
272264
gap="800"
273265
>
274-
<FormSection heading="required files" width="max-content">
266+
<FormSection heading="required files" width="24rem">
275267
{requiredFileFields.fields.map((field, ix) => (
276268
<RunwayFileInput
277269
key={field.id}
278270
label={field.name}
279271
accept={field.fileType}
280-
register={register(`requiredFiles.${ix}.fileInput`, {
281-
required: `${field.name} is required`,
282-
})}
283-
onClear={() => setValue(`requiredFiles.${ix}.fileInput`, null)}
284-
error={errors.requiredFiles?.[ix]?.fileInput}
272+
name={`requiredFiles.${ix}.fileInput`}
273+
control={control}
274+
rules={{ required: `${field.name} is required` }}
285275
/>
286276
))}
287277
</FormSection>
@@ -294,22 +284,22 @@ export const JobCreatePage = () => {
294284
)}
295285
</HStack>
296286

297-
<FormSection heading="supplementary files" width="max-content">
287+
<FormSection heading="supplementary files" width="24rem">
298288
{supplementaryFileFields.fields.map((field, ix) => (
299289
<RunwayFileInput
300290
key={field.id}
301291
label={field.name}
302-
register={register(`supplementaryFiles.${ix}.fileInput`)}
303-
onClear={() => setValue(`supplementaryFiles.${ix}.fileInput`, null)}
304292
accept={field.fileType}
293+
name={`supplementaryFiles.${ix}.fileInput`}
294+
control={control}
305295
/>
306296
))}
307297
</FormSection>
308298
</VStack>
309299
</Box>
310300
</VStack>
311301
<VStack alignItems={'flex-end'} width="100%" gap="300">
312-
{error && <RunwayErrorBox message={error} />}
302+
{formError && <RunwayErrorBox message={formError} />}
313303
<RunwayBottomButtonRow
314304
backPath="/assessments"
315305
rightText="submit"
Lines changed: 126 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,152 @@
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';
210
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';
519

6-
type FileInputProps<K extends Path<T>, T extends FieldValues> = {
20+
type FileInputProps<T extends FieldValues, K extends FieldPath<T>> = {
721
label: string;
8-
register: UseFormRegisterReturn<K>;
9-
onClear: () => void;
1022
accept?: string | string[];
11-
error?: FieldError | undefined;
23+
name: K;
24+
control: Control<T>;
25+
rules?: UseControllerProps<T, K>['rules'];
1226
};
1327

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+
1443
const formatFileType = (fileType: string | undefined) =>
1544
fileType &&
1645
!fileType.includes('/') && // ignore MIME types (e.g. application/pdf)
1746
!fileType.startsWith('.')
1847
? `.${fileType}`
1948
: fileType;
2049

21-
export const RunwayFileInput = <K extends Path<T>, T extends FieldValues>({
50+
export const RunwayFileInput = <T extends FieldValues, K extends FieldPath<T>>({
2251
label,
23-
register,
24-
onClear,
25-
error,
2652
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);
2960

3061
const acceptedFileTypes = Array.isArray(accept)
3162
? accept.map(formatFileType).join(',')
3263
: formatFileType(accept);
3364

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+
3497
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 = '';
49134
}}
50135
>
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">&mdash;</Box>
137+
<Box as="span" ml="200">
138+
remove file
87139
</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"> &mdash;</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>
109151
);
110152
};

app/fe/src/app/components/Form/RunwaySelect.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FormControl, FormLabel } from '@chakra-ui/react';
22
import { Select } from 'chakra-react-select';
33
import { FieldPath, FieldValues, UseControllerReturn } from 'react-hook-form';
4+
import { useId } from 'react';
45

56
type SharedSelectProps = {
67
label: string;
@@ -31,10 +32,12 @@ export const RunwaySelect = <T extends FieldValues, K extends FieldPath<T> = Fie
3132
}: SelectWithController<T, K> | SelectWithField<T, K>) => {
3233
const { field, fieldState } = 'controller' in rest ? rest.controller : rest;
3334
const value = options?.find((option) => option.value === field.value);
35+
const inputId = useId();
3436
return (
3537
<FormControl isInvalid={fieldState.invalid} paddingX="0px">
36-
<FormLabel paddingX="200">{label}</FormLabel>
38+
<FormLabel htmlFor={inputId} paddingX="200">{label}</FormLabel>
3739
<Select
40+
inputId={inputId}
3841
value={value}
3942
onChange={(e) => field.onChange(e?.value)}
4043
onBlur={field.onBlur}

app/fe/src/assets/icons/IconX.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export const IconX = () => (
22
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
33
<path
44
d="M9.47157 1.26112C9.73192 1.00077 9.73192 0.578659 9.47157 0.318309C9.21122 0.0579595 8.78911 0.0579595 8.52876 0.318309L5.00016 3.8469L1.47157 0.318309C1.21122 0.0579595 0.789108 0.0579595 0.528758 0.318309C0.268409 0.578659 0.268409 1.00077 0.528758 1.26112L4.05735 4.78971L0.528758 8.31831C0.268409 8.57866 0.268409 9.00077 0.528758 9.26112C0.789108 9.52147 1.21122 9.52147 1.47157 9.26112L5.00016 5.73252L8.52876 9.26112C8.78911 9.52147 9.21122 9.52147 9.47157 9.26112C9.73192 9.00077 9.73192 8.57866 9.47157 8.31831L5.94297 4.78971L9.47157 1.26112Z"
5-
fill="#87E9DA"
5+
fill="currentColor"
66
/>
77
</svg>
88
);

0 commit comments

Comments
 (0)