rjsf-fileupload-error-indicator (#1661)

* added formatting to filewidget when it gives an error w/ burnettk

* use the mui icon button for the file remove rjsf button w/ burnettk

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2024-06-03 13:25:14 -04:00 committed by GitHub
parent 1f6ca8b727
commit b301af190c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 347 additions and 3 deletions

View File

@ -0,0 +1,311 @@
import { ChangeEvent, useCallback, useMemo } from 'react';
import {
dataURItoBlob,
FormContextType,
getTemplate,
Registry,
RJSFSchema,
StrictRJSFSchema,
TranslatableString,
UIOptionsType,
WidgetProps,
} from '@rjsf/utils';
import Markdown from 'markdown-to-jsx';
import { FileUploader } from '@carbon/react';
import { getCommonAttributes } from '../../helpers';
function addNameToDataURL(dataURL: string, name: string) {
if (dataURL === null) {
return null;
}
return dataURL.replace(';base64', `;name=${encodeURIComponent(name)};base64`);
}
type FileInfoType = {
dataURL?: string | null;
name: string;
size: number;
type: string;
};
function processFile(file: File): Promise<FileInfoType> {
const { name, size, type } = file;
return new Promise((resolve, reject) => {
const reader = new window.FileReader();
reader.onerror = reject;
reader.onload = (event) => {
if (typeof event.target?.result === 'string') {
resolve({
dataURL: addNameToDataURL(event.target.result, name),
name,
size,
type,
});
} else {
resolve({
dataURL: null,
name,
size,
type,
});
}
};
reader.readAsDataURL(file);
});
}
function processFiles(files: FileList) {
return Promise.all(Array.from(files).map(processFile));
}
function FileInfoPreview<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>({
fileInfo,
registry,
}: {
fileInfo: FileInfoType;
registry: Registry<T, S, F>;
}) {
const { translateString } = registry;
const { dataURL, type, name } = fileInfo;
if (!dataURL) {
return null;
}
// If type is JPEG or PNG then show image preview.
// Originally, any type of image was supported, but this was changed into a whitelist
// since SVGs and animated GIFs are also images, which are generally considered a security risk.
if (['image/jpeg', 'image/png'].includes(type)) {
return (
<img
src={dataURL}
style={{ maxWidth: '100%' }}
className="file-preview"
/>
);
}
// otherwise, let users download file
return (
<>
{' '}
<a download={`preview-${name}`} href={dataURL} className="file-download">
{translateString(TranslatableString.PreviewLabel)}
</a>
</>
);
}
function FilesInfo<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>({
filesInfo,
registry,
preview,
onRemove,
options,
}: {
filesInfo: FileInfoType[];
registry: Registry<T, S, F>;
preview?: boolean;
onRemove: (index: number) => void;
options: UIOptionsType<T, S, F>;
}) {
if (filesInfo.length === 0) {
return null;
}
const { translateString } = registry;
// MuiRemoveButton doesn't exist on the correct component type but we add that manually so we know it is there
const { MuiRemoveButton } = getTemplate<any, any, any>(
'ButtonTemplates',
registry,
options
);
return (
<ul className="file-info">
{filesInfo.map((fileInfo, key) => {
const { name, size, type } = fileInfo;
const handleRemove = () => onRemove(key);
return (
<li key={key}>
<Markdown>
{translateString(TranslatableString.FilesInfo, [
name,
type,
String(size),
])}
</Markdown>
{preview && (
<FileInfoPreview<T, S, F>
fileInfo={fileInfo}
registry={registry}
/>
)}
<MuiRemoveButton onClick={handleRemove} registry={registry} />
</li>
);
})}
</ul>
);
}
function extractFileInfo(dataURLs: string[]): FileInfoType[] {
return dataURLs.reduce((acc, dataURL) => {
if (!dataURL) {
return acc;
}
try {
const { blob, name } = dataURItoBlob(dataURL);
return [
...acc,
{
dataURL,
name: name,
size: blob.size,
type: blob.type,
},
];
} catch (e) {
// Invalid dataURI, so just ignore it.
return acc;
}
}, [] as FileInfoType[]);
}
/**
* The `FileWidget` is a widget for rendering file upload fields.
* It is typically used with a string property with data-url format.
*/
function FileWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: WidgetProps<T, S, F>) {
const {
disabled,
id,
label,
multiple,
onChange,
options,
rawErrors,
readonly,
registry,
required,
schema,
uiSchema,
value,
} = props;
const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>(
'BaseInputTemplate',
registry,
options
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) {
return;
}
// Due to variances in themes, dealing with multiple files for the array case now happens one file at a time.
// This is because we don't pass `multiple` into the `BaseInputTemplate` anymore. Instead, we deal with the single
// file in each event and concatenate them together ourselves
processFiles(event.target.files).then((filesInfoEvent) => {
const newValue = filesInfoEvent.map((fileInfo) => fileInfo.dataURL);
if (multiple) {
onChange(value.concat(newValue[0]));
} else {
onChange(newValue[0]);
}
});
},
[multiple, value, onChange]
);
const commonAttributes = getCommonAttributes(
label,
schema,
uiSchema,
rawErrors
);
const filesInfo = useMemo(
() => extractFileInfo(Array.isArray(value) ? value : [value]),
[value]
);
const rmFile = useCallback(
(index: number) => {
if (multiple) {
const newValue = value.filter((_: any, i: number) => i !== index);
onChange(newValue);
} else {
onChange(undefined);
}
},
[multiple, value, onChange]
);
let wrapperProps = null;
let errorSvg = null;
if (commonAttributes.invalid) {
wrapperProps = { 'data-invalid': true };
errorSvg = (
<svg
focusable="false"
preserveAspectRatio="xMidYMid meet"
fill="currentColor"
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
className="cds--text-input__invalid-icon"
>
<path d="M8,1C4.2,1,1,4.2,1,8s3.2,7,7,7s7-3.1,7-7S11.9,1,8,1z M7.5,4h1v5h-1C7.5,9,7.5,4,7.5,4z M8,12.2 c-0.4,0-0.8-0.4-0.8-0.8s0.3-0.8,0.8-0.8c0.4,0,0.8,0.4,0.8,0.8S8.4,12.2,8,12.2z"></path>
<path
d="M7.5,4h1v5h-1C7.5,9,7.5,4,7.5,4z M8,12.2c-0.4,0-0.8-0.4-0.8-0.8s0.3-0.8,0.8-0.8 c0.4,0,0.8,0.4,0.8,0.8S8.4,12.2,8,12.2z"
data-icon-path="inner-path"
opacity="0"
></path>
</svg>
);
}
return (
<>
<div className="cds--text-input__field-wrapper" {...wrapperProps}>
{errorSvg}
<BaseInputTemplate
{...props}
disabled={disabled || readonly}
type="file"
required={value ? false : required} // this turns off HTML required validation when a value exists
onChangeOverride={handleChange}
value=""
accept={options.accept ? String(options.accept) : undefined}
/>
<span role="alert" className="cds--text-input__counter-alert"></span>
<FilesInfo<T, S, F>
filesInfo={filesInfo}
onRemove={rmFile}
registry={registry}
preview={options.filePreview}
options={options}
/>
</div>
<div id={`${id}-error-msg`} className="cds--form-requirement">
{commonAttributes.errorMessageForField}
</div>
</>
);
}
export default FileWidget;

View File

@ -0,0 +1,2 @@
export { default } from './FileWidget';
export * from './FileWidget';

View File

@ -7,6 +7,9 @@ import {
StrictRJSFSchema,
} from '@rjsf/utils';
import RemoveIcon from '@mui/icons-material/Remove';
import { IconButton as MuiIconButton } from '@mui/material';
import { Add, TrashCan, ArrowUp, ArrowDown } from '@carbon/icons-react';
export default function IconButton<
@ -34,6 +37,9 @@ export default function IconButton<
if (icon === 'arrow-down') {
carbonIcon = ArrowDown;
}
if (icon === 'core-remove') {
carbonIcon = RemoveIcon;
}
return (
<Button
@ -94,3 +100,20 @@ export function RemoveButton<
/>
);
}
export function MuiRemoveButton<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: IconButtonProps<T, S, F>) {
return (
<MuiIconButton
title="Remove"
{...props}
color="warning"
aria-label="Remove"
>
<RemoveIcon />
</MuiIconButton>
);
}

View File

@ -4,7 +4,12 @@ import ArrayFieldTemplate from '../ArrayFieldTemplate';
import BaseInputTemplate from '../BaseInputTemplate';
import DescriptionField from '../DescriptionField';
import ErrorList from '../ErrorList';
import { MoveDownButton, MoveUpButton, RemoveButton } from '../IconButton';
import {
MuiRemoveButton,
MoveDownButton,
MoveUpButton,
RemoveButton,
} from '../IconButton';
import FieldErrorTemplate from '../FieldErrorTemplate';
import FieldHelpTemplate from '../FieldHelpTemplate';
import FieldTemplate from '../FieldTemplate';
@ -19,6 +24,7 @@ export default {
BaseInputTemplate,
ButtonTemplates: {
AddButton,
MuiRemoveButton,
MoveDownButton,
MoveUpButton,
RemoveButton,

View File

@ -1,7 +1,8 @@
import CheckboxWidget from '../CheckboxWidget/CheckboxWidget';
import CheckboxesWidget from '../CheckboxesWidget/CheckboxesWidget';
import DateWidget from '../DateWidget/DateWidget';
import DateTimeWidget from '../DateTimeWidget/DateTimeWidget';
import DateWidget from '../DateWidget/DateWidget';
import FileWidget from '../FileWidget/FileWidget';
import RadioWidget from '../RadioWidget/RadioWidget';
import RangeWidget from '../RangeWidget/RangeWidget';
import SelectWidget from '../SelectWidget/SelectWidget';
@ -10,8 +11,9 @@ import TextareaWidget from '../TextareaWidget/TextareaWidget';
export default {
CheckboxWidget,
CheckboxesWidget,
DateWidget,
DateTimeWidget,
DateWidget,
FileWidget,
RadioWidget,
RangeWidget,
SelectWidget,