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:
parent
1f6ca8b727
commit
b301af190c
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export { default } from './FileWidget';
|
||||
export * from './FileWidget';
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue