Skip to content

Commit

Permalink
[RHOAIENG-11530] Add file field advanced settings for connection type…
Browse files Browse the repository at this point in the history
… fields
  • Loading branch information
jeff-phillips-18 committed Aug 29, 2024
1 parent 4a0626e commit 62f95cf
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 27 deletions.
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"react": "^18.2.0",
"react-cool-dimensions": "^2.0.5",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-redux": "^8.0.4",
"react-router": "^6.4.1",
"react-router-dom": "^6.4.1",
Expand Down
119 changes: 94 additions & 25 deletions frontend/src/concepts/connectionTypes/fields/FileFormField.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as React from 'react';
import { FileUpload } from '@patternfly/react-core';
import { ErrorCode, FileError } from 'react-dropzone';
import { FileUpload, FormHelperText, HelperText, HelperTextItem } from '@patternfly/react-core';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import { FileField } from '~/concepts/connectionTypes/types';
import { FieldProps } from '~/concepts/connectionTypes/fields/types';
import { EXTENSION_REGEX, isDuplicateExtension } from './fieldUtils';

const MAX_SIZE = 1024 * 1024; // 1 MB as bytes

const FileFormField: React.FC<FieldProps<FileField>> = ({
id,
Expand All @@ -14,31 +19,95 @@ const FileFormField: React.FC<FieldProps<FileField>> = ({
const isPreview = mode === 'preview';
const [isLoading, setIsLoading] = React.useState(false);
const [filename, setFilename] = React.useState('');
const readOnly = isPreview || field.properties.defaultReadOnly;
const [rejectedReason, setRejectedReason] = React.useState<string | undefined>();
const readOnly = isPreview || (mode !== 'default' && field.properties.defaultReadOnly);
const extensions =
field.properties.extensions?.filter(
(ext, index) =>
!isDuplicateExtension(index, field.properties.extensions || []) &&
EXTENSION_REGEX.test(ext),
) ?? [];

React.useEffect(() => {
setRejectedReason(undefined);
}, [field.properties.extensions]);

const formatString = extensions.length
? `File format must be ${extensions.slice(0, -1).join(', ')}${
extensions.length > 1
? `${extensions.length > 2 ? ',' : ''} or ${extensions[extensions.length - 1]}`
: extensions[0]
}`
: '';

const getRejectionMessage = (error?: FileError): string => {
switch (error?.code) {
case ErrorCode.FileTooLarge:
return 'File is larger than 1MB';
case ErrorCode.FileInvalidType:
return formatString;
case ErrorCode.TooManyFiles:
return 'Only a single file may be uploaded';
default:
return 'Unable to upload the file';
}
};

return (
<FileUpload
id={id}
name={id}
data-testid={dataTestId}
type="text"
isLoading={isLoading}
value={isPreview || field.properties.defaultReadOnly ? field.properties.defaultValue : value}
filename={filename}
allowEditingUploadedText
isReadOnly={readOnly}
isDisabled={readOnly}
filenamePlaceholder={readOnly ? '' : 'Drag and drop a file or upload one'}
browseButtonText="Upload"
clearButtonText="Clear"
onDataChange={isPreview || !onChange ? undefined : (e, content) => onChange(content)}
onFileInputChange={(_e, file) => setFilename(file.name)}
onReadStarted={() => {
setIsLoading(true);
}}
onReadFinished={() => {
setIsLoading(false);
}}
/>
<>
<FileUpload
id={id}
name={id}
data-testid={dataTestId}
type="text"
isLoading={isLoading}
value={
isPreview || field.properties.defaultReadOnly ? field.properties.defaultValue : value
}
filename={filename}
allowEditingUploadedText
isReadOnly={readOnly}
isDisabled={readOnly}
filenamePlaceholder={readOnly ? '' : 'Drag and drop a file or upload one'}
browseButtonText="Upload"
clearButtonText="Clear"
onDataChange={isPreview || !onChange ? undefined : (e, content) => onChange(content)}
onFileInputChange={(_e, file) => setFilename(file.name)}
isClearButtonDisabled={rejectedReason ? false : undefined}
onClearClick={() => {
if (onChange) {
onChange('');
}
setFilename('');
setRejectedReason(undefined);
}}
onReadStarted={() => {
setRejectedReason(undefined);
setIsLoading(true);
}}
onReadFinished={() => {
setIsLoading(false);
}}
dropzoneProps={{
accept: extensions.length ? { '': extensions } : undefined,
maxSize: MAX_SIZE,
onDropRejected: (reason) => {
setRejectedReason(getRejectionMessage(reason[0]?.errors?.[0]));
},
}}
/>
<FormHelperText>
<HelperText>
<HelperTextItem
icon={rejectedReason ? <ExclamationCircleIcon /> : undefined}
variant={rejectedReason ? 'error' : 'default'}
data-testid="file-form-field-helper-text"
>
{rejectedReason || formatString}
</HelperTextItem>
</HelperText>
</FormHelperText>
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('FileFormField', () => {
envVar: 'test-envVar',
properties: {
defaultValue: 'default-value',
extensions: ['.jpg', '.svg', '.png'],
},
};

Expand All @@ -24,6 +25,9 @@ describe('FileFormField', () => {
expect(screen.getByRole('button', { name: 'Upload' })).not.toBeDisabled();
expect(screen.getByRole('button', { name: 'Clear' })).not.toBeDisabled();

const helperText = screen.getByTestId('file-form-field-helper-text');
expect(helperText).toHaveTextContent('.jpg, .svg, or .png');

act(() => {
fireEvent.change(contentInput, { target: { value: 'new-value' } });
});
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/concepts/connectionTypes/fields/fieldUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const EXTENSION_REGEX = /^\.[a-zA-Z0-9]+(^.[a-zA-Z0-9]+)?$/;

export const isDuplicateExtension = (index: number, values: string[]): boolean =>
index !== 0 &&
!!values.slice(0, index).find((val) => values[index].toLowerCase() === val.toLowerCase());
8 changes: 7 additions & 1 deletion frontend/src/concepts/connectionTypes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ export type DataField<T extends ConnectionTypeFieldTypeUnion, V = string, P = {}
export type SectionField = Field<ConnectionTypeFieldType.Section | 'section'>;

export type HiddenField = DataField<ConnectionTypeFieldType.Hidden | 'hidden'>;
export type FileField = DataField<ConnectionTypeFieldType.File | 'file'>;
export type ShortTextField = DataField<ConnectionTypeFieldType.ShortText | 'short-text'>;
export type TextField = DataField<ConnectionTypeFieldType.Text | 'text'>;
export type UriField = DataField<ConnectionTypeFieldType.URI | 'uri'>;
export type FileField = DataField<
ConnectionTypeFieldType.File | 'file',
string,
{
extensions?: string[];
}
>;
export type BooleanField = DataField<
ConnectionTypeFieldType.Boolean | 'boolean',
boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConnectionTypeDataField, ConnectionTypeFieldType } from '~/concepts/con
import BooleanAdvancedPropertiesForm from '~/pages/connectionTypes/manage/advanced/BooleanAdvancedPropertiesForm';
import { AdvancedFieldProps } from '~/pages/connectionTypes/manage/advanced/types';
import NumericAdvancedPropertiesForm from '~/pages/connectionTypes/manage/advanced/NumericAdvancedPropertiesForm';
import FileUploadAdvancedPropertiesForm from '~/pages/connectionTypes/manage/advanced/FileUploadAdvancedPropertiesForm';

const CustomFieldPropertiesForm = <T extends ConnectionTypeDataField>(
props: AdvancedFieldProps<T>,
Expand All @@ -11,7 +12,7 @@ const CustomFieldPropertiesForm = <T extends ConnectionTypeDataField>(
// TODO define advanced forms
switch (props.field.type) {
case ConnectionTypeFieldType.File:
return () => null;
return FileUploadAdvancedPropertiesForm;

case ConnectionTypeFieldType.Boolean:
return BooleanAdvancedPropertiesForm;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react';
import { Button, FormGroup } from '@patternfly/react-core';
import { PlusCircleIcon } from '@patternfly/react-icons';
import { FileField } from '~/concepts/connectionTypes/types';
import {
EXTENSION_REGEX,
isDuplicateExtension,
} from '~/concepts/connectionTypes/fields/fieldUtils';
import { AdvancedFieldProps } from '~/pages/connectionTypes/manage/advanced/types';
import ExpandableFormSection from '~/components/ExpandableFormSection';
import FileUploadExtensionRow from '~/pages/connectionTypes/manage/advanced/FileUploadExtensionRow';

const FileUploadAdvancedPropertiesForm: React.FC<AdvancedFieldProps<FileField>> = ({
properties,
onChange,
onValidate,
}) => {
const displayedExtensions = React.useMemo(
() => [...(properties.extensions?.length ? [...properties.extensions] : [''])],
[properties.extensions],
);
const lastTextRef = React.useRef<HTMLInputElement | null>(null);

React.useEffect(() => {
if (!properties.extensions?.length) {
onValidate(true);
return;
}
const valid = properties.extensions.every(
(extension, i) =>
!extension ||
(EXTENSION_REGEX.test(extension) && !isDuplicateExtension(i, properties.extensions || [])),
);
onValidate(valid);
// do not run when callback changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [properties.extensions]);

return (
<ExpandableFormSection
toggleText="Advanced settings"
initExpanded={!!properties.extensions}
data-testid="advanced-settings-toggle"
>
<FormGroup label="Allow specific files" fieldId="file-types" isStack>
{displayedExtensions.map((extension, index) => (
<FileUploadExtensionRow
key={index}
extension={extension}
isDuplicate={isDuplicateExtension(index, displayedExtensions)}
textRef={index === displayedExtensions.length - 1 ? lastTextRef : undefined}
onChange={(val) => {
onChange({
...properties,
extensions: [
...displayedExtensions.slice(0, index),
val,
...displayedExtensions.slice(index + 1),
],
});
}}
onRemove={() =>
onChange({
...properties,
extensions: [
...displayedExtensions.slice(0, index),
...displayedExtensions.slice(index + 1),
],
})
}
allowRemove={displayedExtensions.length > 1 || !!displayedExtensions[0]}
/>
))}
<Button
variant="link"
data-testid="add-variable-button"
isInline
icon={<PlusCircleIcon />}
iconPosition="left"
onClick={() => {
onChange({ ...properties, extensions: [...displayedExtensions, ''] });
requestAnimationFrame(() => lastTextRef.current?.focus());
}}
>
Add file type
</Button>
</FormGroup>
</ExpandableFormSection>
);
};

export default FileUploadAdvancedPropertiesForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as React from 'react';
import {
Button,
FormHelperText,
HelperText,
HelperTextItem,
InputGroup,
InputGroupItem,
TextInput,
} from '@patternfly/react-core';
import { ExclamationCircleIcon, MinusCircleIcon } from '@patternfly/react-icons';
import { EXTENSION_REGEX } from '~/concepts/connectionTypes/fields/fieldUtils';

type FileUploadExtensionRowProps = {
extension?: string;
isDuplicate?: boolean;
onChange: (newValue: string) => void;
onRemove?: () => void;
allowRemove: boolean;
textRef?: React.RefObject<HTMLInputElement>;
};

const FileUploadExtensionRow: React.FC<FileUploadExtensionRowProps> = ({
extension,
isDuplicate,
onRemove,
allowRemove,
onChange,
textRef,
}) => {
const [isValid, setIsValid] = React.useState<boolean>(!isDuplicate);

React.useEffect(
() => setIsValid(extension ? !isDuplicate && EXTENSION_REGEX.test(extension) : true),
// only run on entry or if a duplicate, otherwise wait for blur
// eslint-disable-next-line react-hooks/exhaustive-deps
[isDuplicate],
);

return (
<>
<InputGroup data-testid="file-upload-extension-row">
<InputGroupItem isFill>
<TextInput
name="file-extension"
data-testid="file-upload-extension-row-input"
type="text"
aria-label="allowed extension"
ref={textRef}
value={extension || ''}
placeholder={extension || 'Example: .json'}
onChange={(_ev, val) => {
if (!isValid && !isDuplicate && EXTENSION_REGEX.test(val)) {
setIsValid(true);
}
onChange(val);
}}
onBlur={() => {
setIsValid(extension ? !isDuplicate && EXTENSION_REGEX.test(extension) : true);
}}
/>
</InputGroupItem>
<InputGroupItem isPlain>
<Button
variant="plain"
data-testid="file-upload-extension-row-remove"
aria-label="remove extension"
isDisabled={!allowRemove}
onClick={onRemove}
>
<MinusCircleIcon />
</Button>
</InputGroupItem>
</InputGroup>
{!isValid ? (
<FormHelperText>
<HelperText>
<HelperTextItem
icon={<ExclamationCircleIcon />}
variant="error"
data-testid="file-upload-extension-row-error"
>
{isDuplicate
? 'Extension has already been specified.'
: `Please enter a valid extension starting with '.'`}
</HelperTextItem>
</HelperText>
</FormHelperText>
) : null}
</>
);
};

export default FileUploadExtensionRow;
Loading

0 comments on commit 62f95cf

Please sign in to comment.