From deb698c829c8633fd9abdab70cd61ad42387eef7 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 23 Jul 2024 16:38:31 +0200 Subject: [PATCH] fix: intercept react-butterfiles and use a patched implementation --- packages/app/package.json | 2 + packages/app/src/react-butterfiles/Files.tsx | 272 ++++++++++++++++++ packages/app/src/react-butterfiles/index.ts | 3 + .../src/react-butterfiles/utils/generateId.ts | 3 + .../utils/readFileContent.ts | 14 + .../bundling/app/config/webpack.config.js | 2 + packages/project-utils/package.json | 1 + 7 files changed, 297 insertions(+) create mode 100644 packages/app/src/react-butterfiles/Files.tsx create mode 100644 packages/app/src/react-butterfiles/index.ts create mode 100644 packages/app/src/react-butterfiles/utils/generateId.ts create mode 100644 packages/app/src/react-butterfiles/utils/readFileContent.ts diff --git a/packages/app/package.json b/packages/app/package.json index d81d64c7121..71e3a1beecd 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -33,9 +33,11 @@ "apollo-link-http-common": "^0.2.16", "apollo-utilities": "^1.3.4", "boolean": "^3.0.1", + "bytes": "^3.0.0", "graphql": "^15.7.2", "invariant": "^2.2.4", "lodash": "^4.17.21", + "minimatch": "^5.1.0", "nanoid": "^3.3.7", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/packages/app/src/react-butterfiles/Files.tsx b/packages/app/src/react-butterfiles/Files.tsx new file mode 100644 index 00000000000..98dc46db8d3 --- /dev/null +++ b/packages/app/src/react-butterfiles/Files.tsx @@ -0,0 +1,272 @@ +import React from "react"; +import bytes from "bytes"; +import minimatch from "minimatch"; +import { readFileContent } from "./utils/readFileContent"; +import { generateId } from "./utils/generateId"; + +export type SelectedFile = { + id: string; + name: string; + type: string; + size: number; + src: { + file: File; + base64: string | null; + }; +}; + +export type FileError = { + id: string; + type: + | "unsupportedFileType" + | "maxSizeExceeded" + | "multipleMaxSizeExceeded" + | "multipleMaxCountExceeded" + | "multipleNotAllowed"; + index?: number; + file?: SelectedFile | File; + multipleFileSize?: number; + multipleMaxSize?: number; + multipleMaxCount?: number; + multipleCount?: number; +}; + +export type BrowseFilesParams = { + onSuccess?: (files: SelectedFile[]) => void; + onError?: (errors: FileError[], files: SelectedFile[]) => void; +}; + +export type RenderPropParams = { + browseFiles: (params: BrowseFilesParams) => void; + getDropZoneProps: (additionalProps: any) => any; + getLabelProps: (additionalProps: any) => any; + validateFiles: (files: SelectedFile[] | File[]) => FileError[]; +}; + +export type FilesRules = { + accept: string[]; + multiple: boolean; + maxSize: string; + multipleMaxSize: string; + multipleMaxCount: number | null; + convertToBase64: boolean; + onSuccess?: (files: SelectedFile[]) => void; + onError?: (errors: FileError[], files: SelectedFile[]) => void; +}; + +export type Props = FilesRules & { + children: (params: RenderPropParams) => React.ReactNode; + id?: string; +}; + +export class Files extends React.Component { + static defaultProps = { + accept: [], + multiple: false, + maxSize: "2mb", + multipleMaxSize: "10mb", + multipleMaxCount: null, + convertToBase64: false + }; + + input: HTMLInputElement | null = null; + browseFilesPassedParams: BrowseFilesParams | null = null; + id: string = generateId(); + + validateFiles = (files: SelectedFile[] | File[]): FileError[] => { + const { multiple, multipleMaxSize, multipleMaxCount, accept, maxSize } = this.props; + + const errors: FileError[] = []; + let multipleFileSize = 0; + + if (!multiple && files.length > 1) { + errors.push({ + id: generateId(), + type: "multipleNotAllowed" + }); + + return errors; + } + + for (let index = 0; index < files.length; index++) { + const file = files[index]; + + if ( + Array.isArray(accept) && + accept.length && + !accept.some(type => minimatch(file.type, type)) + ) { + errors.push({ + id: generateId(), + index, + file, + type: "unsupportedFileType" + }); + } else if (maxSize) { + if (file.size > bytes(maxSize)) { + errors.push({ + id: generateId(), + index, + file, + type: "maxSizeExceeded" + }); + } + } + + if (multiple) { + multipleFileSize += file.size; + } + } + + if (multiple) { + if (multipleMaxSize && multipleFileSize > bytes(multipleMaxSize)) { + errors.push({ + id: generateId(), + type: "multipleMaxSizeExceeded", + multipleFileSize, + multipleMaxSize: bytes(multipleMaxSize) + }); + } + + if (multipleMaxCount && files.length > multipleMaxCount) { + errors.push({ + id: generateId(), + type: "multipleMaxCountExceeded", + multipleCount: files.length, + multipleMaxCount + }); + } + } + + return errors; + }; + + processSelectedFiles = async (eventFiles: Array) => { + if (eventFiles.length === 0) { + return; + } + + const { convertToBase64, onSuccess, onError } = this.props; + const { browseFilesPassedParams } = this; + const callbacks = { + onSuccess, + onError + }; + + if (browseFilesPassedParams && browseFilesPassedParams.onSuccess) { + callbacks.onSuccess = browseFilesPassedParams.onSuccess; + } + + if (browseFilesPassedParams && browseFilesPassedParams.onError) { + callbacks.onError = browseFilesPassedParams.onError; + } + + const files: SelectedFile[] = [...eventFiles].map(file => { + return { + id: generateId(), + name: file.name, + type: file.type, + size: file.size, + src: { + file, + base64: null + } + }; + }); + + const errors = this.validateFiles(files); + + if (errors.length) { + callbacks.onError && callbacks.onError(errors, files); + } else { + if (convertToBase64) { + for (let i = 0; i < files.length; i++) { + const file = files[i].src.file; + files[i].src.base64 = await readFileContent(file); + } + } + + callbacks.onSuccess && callbacks.onSuccess(files); + } + + // Reset the browseFiles arguments. + if (this.input) { + this.input.value = ""; + } + this.browseFilesPassedParams = null; + }; + + /** + * Extracted into a separate method just for testing purposes. + */ + onDropFilesHandler = async ({ e, onSuccess, onError }: any) => { + this.browseFilesPassedParams = { onSuccess, onError }; + e.dataTransfer && + e.dataTransfer.files && + (await this.processSelectedFiles(e.dataTransfer.files)); + }; + + /** + * Extracted into a separate method just for testing purposes. + */ + browseFilesHandler = ({ onSuccess, onError }: any) => { + this.browseFilesPassedParams = { onSuccess, onError }; + this.input && this.input.click(); + }; + + override render() { + const { multiple, accept, id } = this.props; + return ( + + {this.props.children({ + getLabelProps: (props: any) => { + return { + ...props, + htmlFor: id || this.id + }; + }, + validateFiles: this.validateFiles, + browseFiles: ({ onSuccess, onError }: BrowseFilesParams = {}) => { + this.browseFilesHandler({ onSuccess, onError }); + }, + getDropZoneProps: ({ + onSuccess, + onError, + onDragOver, + onDrop, + ...rest + }: any = {}) => { + return { + ...rest, + onDragOver: (e: DragEvent) => { + e.preventDefault(); + typeof onDragOver === "function" && onDragOver(); + }, + onDrop: async (e: DragEvent) => { + e.preventDefault(); + typeof onDrop === "function" && onDrop(); + this.onDropFilesHandler({ e, onSuccess, onError }); + } + }; + } + })} + + { + if (ref) { + this.input = ref; + } + }} + accept={accept.join(",")} + style={{ display: "none" }} + type="file" + multiple={multiple} + onChange={e => + this.processSelectedFiles((e.target.files as any as Array) ?? []) + } + /> + + ); + } +} diff --git a/packages/app/src/react-butterfiles/index.ts b/packages/app/src/react-butterfiles/index.ts new file mode 100644 index 00000000000..c69f9dff0b6 --- /dev/null +++ b/packages/app/src/react-butterfiles/index.ts @@ -0,0 +1,3 @@ +import { Files } from "./Files"; + +export default Files; diff --git a/packages/app/src/react-butterfiles/utils/generateId.ts b/packages/app/src/react-butterfiles/utils/generateId.ts new file mode 100644 index 00000000000..b4187fc34cc --- /dev/null +++ b/packages/app/src/react-butterfiles/utils/generateId.ts @@ -0,0 +1,3 @@ +export const generateId = () => { + return "_" + Math.random().toString(36).substr(2, 9); +}; diff --git a/packages/app/src/react-butterfiles/utils/readFileContent.ts b/packages/app/src/react-butterfiles/utils/readFileContent.ts new file mode 100644 index 00000000000..ec645d3a45d --- /dev/null +++ b/packages/app/src/react-butterfiles/utils/readFileContent.ts @@ -0,0 +1,14 @@ +export const readFileContent = async (file: File) => { + return new Promise((resolve, reject) => { + const reader = new window.FileReader(); + reader.onload = function (e) { + if (e.target) { + resolve(e.target.result as string); + } else { + reject(`Unable to read file contents!`); + } + }; + + reader.readAsDataURL(file); + }); +}; diff --git a/packages/project-utils/bundling/app/config/webpack.config.js b/packages/project-utils/bundling/app/config/webpack.config.js index 2771beec31a..90d2b74d8e3 100644 --- a/packages/project-utils/bundling/app/config/webpack.config.js +++ b/packages/project-utils/bundling/app/config/webpack.config.js @@ -223,6 +223,8 @@ module.exports = function (webpackEnv, { paths, options }) { "react-dom$": require.resolve("react-dom/profiling"), "scheduler/tracing": require.resolve("scheduler/tracing-profiling") }), + // This is a temporary fix, until we sort out the `react-butterfiles` dependency. + "react-butterfiles": require.resolve("@webiny/app/react-butterfiles"), ...(modules.webpackAliases || {}) }, fallback: { diff --git a/packages/project-utils/package.json b/packages/project-utils/package.json index efadcd12ebd..b8a96f6b931 100644 --- a/packages/project-utils/package.json +++ b/packages/project-utils/package.json @@ -98,6 +98,7 @@ "src": [ "!!raw-loader!", "@material/base", + "@webiny/app", "@webiny/api", "@webiny/tasks", "@webiny/handler",