diff --git a/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/FileWidget.tsx b/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/FileWidget.tsx new file mode 100644 index 000000000..e4eda896d --- /dev/null +++ b/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/FileWidget.tsx @@ -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 { + 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; +}) { + 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 ( + + ); + } + + // otherwise, let users download file + + return ( + <> + {' '} + + {translateString(TranslatableString.PreviewLabel)} + + + ); +} + +function FilesInfo< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any +>({ + filesInfo, + registry, + preview, + onRemove, + options, +}: { + filesInfo: FileInfoType[]; + registry: Registry; + preview?: boolean; + onRemove: (index: number) => void; + options: UIOptionsType; +}) { + 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( + 'ButtonTemplates', + registry, + options + ); + + return ( +
    + {filesInfo.map((fileInfo, key) => { + const { name, size, type } = fileInfo; + const handleRemove = () => onRemove(key); + return ( +
  • + + {translateString(TranslatableString.FilesInfo, [ + name, + type, + String(size), + ])} + + {preview && ( + + fileInfo={fileInfo} + registry={registry} + /> + )} + +
  • + ); + })} +
+ ); +} + +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) { + 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) => { + 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 = ( + + ); + } + + return ( + <> +
+ {errorSvg} + + + + filesInfo={filesInfo} + onRemove={rmFile} + registry={registry} + preview={options.filePreview} + options={options} + /> +
+
+ {commonAttributes.errorMessageForField} +
+ + ); +} + +export default FileWidget; diff --git a/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/index.ts b/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/index.ts new file mode 100644 index 000000000..6e364e5c9 --- /dev/null +++ b/spiffworkflow-frontend/src/rjsf/carbon_theme/FileWidget/index.ts @@ -0,0 +1,2 @@ +export { default } from './FileWidget'; +export * from './FileWidget'; diff --git a/spiffworkflow-frontend/src/rjsf/carbon_theme/IconButton/IconButton.tsx b/spiffworkflow-frontend/src/rjsf/carbon_theme/IconButton/IconButton.tsx index 936809679..c694db993 100644 --- a/spiffworkflow-frontend/src/rjsf/carbon_theme/IconButton/IconButton.tsx +++ b/spiffworkflow-frontend/src/rjsf/carbon_theme/IconButton/IconButton.tsx @@ -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 (