From 0b6100d4a4429313f6e847a37cd19ad419f3311c Mon Sep 17 00:00:00 2001 From: Bipul Adhikari Date: Tue, 26 Nov 2024 20:58:00 +0545 Subject: [PATCH] Moves upload component to mobX Signed-off-by: Bipul Adhikari --- .eslintrc.js | 5 +- package.json | 5 +- .../objects-list/ObjectListWithSidebar.tsx | 17 +- .../upload-objects/UploadSidebar.tsx | 253 +++++++++-------- .../s3-browser/upload-objects/store.ts | 93 ++++++ .../s3-browser/upload-objects/types.ts | 24 +- .../upload-component/FileUploadComponent.tsx | 266 +++++++++--------- .../upload-component/uploads.ts | 96 +++---- .../upload-status/UploadStatusBasedAlert.tsx | 82 +++--- .../upload-status/UploadStatusItem.tsx | 100 ++++--- .../upload-status/UploadStatusList.tsx | 95 +++---- .../s3-browser/upload-objects/utils.ts | 33 ++- .../abort-uploads/AbortUploadsModal.tsx | 99 ++++--- yarn.lock | 75 ++++- 14 files changed, 720 insertions(+), 523 deletions(-) create mode 100644 packages/odf/components/s3-browser/upload-objects/store.ts diff --git a/.eslintrc.js b/.eslintrc.js index a68be04bf..ebec1a28a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { 'prettier', 'plugin:react-hooks/recommended', 'airbnb', + 'plugin:mobx/recommended', ], overrides: [ { @@ -28,8 +29,9 @@ module.exports = { ecmaVersion: 12, sourceType: 'module', }, - plugins: ['react', '@typescript-eslint', 'import'], + plugins: ['react', '@typescript-eslint', 'import', 'mobx'], rules: { + 'mobx/missing-observer': 'off', // Too many false positives 'arrow-body-style': 'off', 'default-param-last': 'off', 'dot-notation': 'off', @@ -172,7 +174,6 @@ module.exports = { 'react/destructuring-assignment': 'off', 'react/jsx-filename-extension': 'off', 'react/jsx-no-bind': 'off', - 'react/jsx-pascal-case': 'off', 'react/jsx-props-no-spreading': 'off', 'react/no-array-index-key': 'off', 'react/no-unused-state': 'off', diff --git a/package.json b/package.json index 7d52b477e..09e8e0d20 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,8 @@ "js-yaml": "^3.13.1", "lodash-es": "^4.17.21", "luxon": "^3.3.0", + "mobx": "^6.13.5", + "mobx-react-lite": "^4.0.7", "murmurhash-js": "^1.0.0", "react": "^17.0.1", "react-copy-to-clipboard": "5.x", @@ -144,7 +146,7 @@ "@types/react-router": "^5.1.20", "@types/react-window": "^1.8.8", "@typescript-eslint/eslint-plugin": "^8.12.2", - "@typescript-eslint/parser": "^8.12.2", + "@typescript-eslint/parser": "^8.17.0", "comment-json": "4.x", "cypress": "^13.15.2", "cypress-multi-reporters": "^2.0.4", @@ -157,6 +159,7 @@ "eslint-plugin-jest": "^28.9.0", "eslint-plugin-jest-dom": "^5.4.0", "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-mobx": "^0.0.13", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", "eventsource": "^1.1.1", diff --git a/packages/odf/components/s3-browser/objects-list/ObjectListWithSidebar.tsx b/packages/odf/components/s3-browser/objects-list/ObjectListWithSidebar.tsx index b58cc846c..fa4cd39a4 100644 --- a/packages/odf/components/s3-browser/objects-list/ObjectListWithSidebar.tsx +++ b/packages/odf/components/s3-browser/objects-list/ObjectListWithSidebar.tsx @@ -6,7 +6,6 @@ import { useParams } from 'react-router-dom-v5-compat'; import { IAction } from '@patternfly/react-table'; import { NoobaaS3Context } from '../noobaa-context'; import UploadSidebar from '../upload-objects'; -import { UploadProgress } from '../upload-objects'; import { FileUploadComponent } from '../upload-objects'; import { ObjectsList } from './ObjectsList'; @@ -24,15 +23,8 @@ export const ObjectListWithSidebar: React.FC = ({ const [object, setObject] = React.useState(null); const [objectActions, setObjectActions] = React.useState>(); - const [uploadProgress, setUploadProgress] = React.useState( - {} - ); const [completionTime, setCompletionTime] = React.useState(); - const abortAll = React.useCallback(() => { - Object.values(uploadProgress).forEach((upload) => upload?.abort?.()); - }, [uploadProgress]); - const { bucketName } = useParams(); const { noobaaS3 } = React.useContext(NoobaaS3Context); @@ -43,6 +35,10 @@ export const ObjectListWithSidebar: React.FC = ({ closeObjectSidebar(); setUploadSidebarExpanded(true); }; + const hideUploadSidebar = () => { + closeObjectSidebar(); + setUploadSidebarExpanded(false); + }; const onRowClick = ( selectedObject: ObjectCrFormat, actionItems: React.MutableRefObject @@ -58,17 +54,14 @@ export const ObjectListWithSidebar: React.FC = ({ diff --git a/packages/odf/components/s3-browser/upload-objects/UploadSidebar.tsx b/packages/odf/components/s3-browser/upload-objects/UploadSidebar.tsx index 2ac8848c6..474359370 100644 --- a/packages/odf/components/s3-browser/upload-objects/UploadSidebar.tsx +++ b/packages/odf/components/s3-browser/upload-objects/UploadSidebar.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { DrawerHead, Status, useCustomTranslation } from '@odf/shared'; import { ResourceStatus } from '@openshift-console/dynamic-plugin-sdk'; +import { observer } from 'mobx-react-lite'; import { Trans } from 'react-i18next'; import { Alert, @@ -15,9 +16,10 @@ import { FlexItem, Title, } from '@patternfly/react-core'; -import { UploadProgress } from './types'; +import { uploadStore } from './store'; import UploadStatusList from './upload-status'; import { + getCancelledFiles, getCompletedAndTotalUploadCount, getFailedFiles, getTotalRemainingFilesAndSize, @@ -27,140 +29,141 @@ import { } from './utils'; type PanelContentProps = { - uploadProgress: UploadProgress; onClose: () => void; completionTime: number; }; -const PanelContent: React.FC = ({ - uploadProgress, - onClose, - completionTime, -}) => { - const { t } = useCustomTranslation(); - const [uploadedFiles, totalFiles] = - getCompletedAndTotalUploadCount(uploadProgress); - const failedFiles = getFailedFiles(uploadProgress); - const uploadSpeed = getUploadSpeed(uploadProgress); - const totalRemaining = getTotalRemainingFilesAndSize(uploadProgress); - const timeRemaining = getTotalTimeRemaining(uploadProgress); - const totalTimeElapsed = getTotalTimeElapsed(uploadProgress, completionTime); - const isComplete = uploadedFiles + failedFiles === totalFiles; +const PanelContent: React.FC = observer( + ({ onClose, completionTime }) => { + const uploadProgress = uploadStore.getAll; + const { t } = useCustomTranslation(); + const [uploadedFiles, totalFiles] = + getCompletedAndTotalUploadCount(uploadProgress); + const failedFiles = + getFailedFiles(uploadProgress) + getCancelledFiles(uploadProgress); + const uploadSpeed = getUploadSpeed(uploadProgress); + const totalRemaining = getTotalRemainingFilesAndSize(uploadProgress); + const timeRemaining = getTotalTimeRemaining(uploadProgress); + const totalTimeElapsed = getTotalTimeElapsed( + uploadProgress, + completionTime + ); + const isComplete = uploadedFiles + failedFiles === totalFiles; - return ( - - - - - {t('Uploads')} - - - - <span> - {t('{{uploadedFiles}} of {{totalFiles}} files uploaded', { - uploadedFiles, - totalFiles, - })} -   - {isComplete ? ( - <ResourceStatus> - <Status status={t('Complete')} title={t('Completed')} /> - </ResourceStatus> - ) : ( - <ResourceStatus> - <Status status={t('Uploading')} title={t('Ongoing')} /> - </ResourceStatus> - )} - </span> - - - {isComplete ? ( - <> - - {t('Succeeded: {{uploadedFiles}}', { uploadedFiles })} - - - {t('Failed files: {{failedFiles}}', { - failedFiles: totalFiles - uploadedFiles, - })} - - - {t('Completion time: {{totalTimeElapsed}}', { - totalTimeElapsed, - })} - - - ) : ( - <> - - {t('Total Remaining: {{totalRemaining}}', { totalRemaining })} - - - {t('Estimated time remaining: {{timeRemaining}}', { - timeRemaining, - })} - - - {t('Transfer rate: {{uploadSpeed}}', { uploadSpeed })} - - - )} - - - - - - - - - Standard uploads have a size limit of up to 5 TB in S3. - - - - - - ); -}; + return ( + + + + + {t('Uploads')} + + + + <span> + {t('{{uploadedFiles}} of {{totalFiles}} files uploaded', { + uploadedFiles, + totalFiles, + })} +   + {isComplete ? ( + <ResourceStatus> + <Status status={t('Complete')} title={t('Completed')} /> + </ResourceStatus> + ) : ( + <ResourceStatus> + <Status status={t('Uploading')} title={t('Ongoing')} /> + </ResourceStatus> + )} + </span> + + + {isComplete ? ( + <> + + {t('Succeeded: {{uploadedFiles}}', { uploadedFiles })} + + + {t('Failed files: {{failedFiles}}', { + failedFiles: totalFiles - uploadedFiles, + })} + + + {t('Completion time: {{totalTimeElapsed}}', { + totalTimeElapsed, + })} + + + ) : ( + <> + + {t('Total Remaining: {{totalRemaining}}', { totalRemaining })} + + + {t('Estimated time remaining: {{timeRemaining}}', { + timeRemaining, + })} + + + {t('Transfer rate: {{uploadSpeed}}', { uploadSpeed })} + + + )} + + + + + + + + + Standard uploads have a size limit of up to 5 TB in S3. + + + + + + ); + } +); export type UploadSidebarProps = { isExpanded: boolean; closeSidebar: () => void; - uploadProgress: UploadProgress; mainContent: React.ReactNode; completionTime: number; }; -export const UploadSidebar: React.FC = ({ - isExpanded, - closeSidebar, - uploadProgress, - mainContent: drawerContentBody, - completionTime, -}) => { - return ( - - - } - > - {drawerContentBody} - - - ); -}; +export const UploadSidebar: React.FC = observer( + ({ + isExpanded, + closeSidebar, + mainContent: drawerContentBody, + completionTime, + }) => { + return ( + + + } + > + {drawerContentBody} + + + ); + } +); diff --git a/packages/odf/components/s3-browser/upload-objects/store.ts b/packages/odf/components/s3-browser/upload-objects/store.ts new file mode 100644 index 000000000..db31f839c --- /dev/null +++ b/packages/odf/components/s3-browser/upload-objects/store.ts @@ -0,0 +1,93 @@ +import { makeAutoObservable, toJS } from 'mobx'; +import { UploadProgress, UploadStatus } from './types'; + +export class UploadStore { + uploads: Record = {}; + + constructor() { + makeAutoObservable(this); + } + + setAborter(key: string, aborter: UploadProgress['abort']) { + this.uploads[key]['abort'] = aborter; + } + + // Add a file to the map + addFile(file: UploadProgress, key: string) { + this.uploads[key] = { + ...file, + uploadState: UploadStatus.INIT_STATE, + }; + } + + updateProgress(fileId: string, loaded: number, total: number) { + if (fileId in this.uploads) { + const existingData = this.uploads[fileId]; + const update = { + loaded, + total, + startTime: existingData.startTime ?? Date.now(), + }; + if (loaded === existingData.total) { + Object.assign(update, { uploadState: UploadStatus.UPLOAD_COMPLETE }); + } else if (existingData.uploadState === UploadStatus.INIT_STATE) { + Object.assign(update, { uploadState: UploadStatus.UPLOAD_START }); + } + this.uploads[fileId] = { ...existingData, ...update }; + } + } + + set uploadCompleted(key: string) { + const existingData = this.uploads[key]; + this.uploads[key] = { + ...existingData, + uploadState: UploadStatus.UPLOAD_COMPLETE, + }; + } + + getFile(fileId: string) { + return this.uploads[fileId]; + } + + set uploadFailed(fileId: string) { + if (this.uploads[fileId]) { + const existingData = this.uploads[fileId]; + + const update = + existingData.uploadState === UploadStatus.UPLOAD_COMPLETE + ? { uploadState: UploadStatus.UPLOAD_FAILED } + : {}; + this.uploads[fileId] = { ...existingData, ...update }; + } + } + + performAbort(fileId: string) { + if (fileId in this.uploads) { + const existingData = this.uploads[fileId]; + if (existingData?.abort) { + existingData.abort(); + } + if (existingData.uploadState !== UploadStatus.UPLOAD_COMPLETE) { + const update = { uploadState: UploadStatus.UPLOAD_CANCELLED }; + this.uploads[fileId] = { ...existingData, ...update }; + } + } + } + + clearAll() { + // eslint-disable-next-line guard-for-in + for (const key in this.uploads) { + delete this.uploads[key]; + } + } + + abortAll() { + Object.values(this.uploads).forEach(({ key }) => this.performAbort(key)); + } + + get getAll() { + return toJS(this.uploads); + } +} + +export const uploadStore = new UploadStore(); diff --git a/packages/odf/components/s3-browser/upload-objects/types.ts b/packages/odf/components/s3-browser/upload-objects/types.ts index 9704bc60d..7e919d2ca 100644 --- a/packages/odf/components/s3-browser/upload-objects/types.ts +++ b/packages/odf/components/s3-browser/upload-objects/types.ts @@ -3,16 +3,18 @@ export enum UploadStatus { UPLOAD_COMPLETE, UPLOAD_START, UPLOAD_FAILED, + UPLOAD_CANCELLED, } -export type UploadProgress = { - [name: string]: { - total: number; - loaded: number; - name: string; - abort?: () => void; - uploadState?: UploadStatus; - filePath?: string; - startTime: number; - }; -}; +export interface UploadProgress extends Partial { + total?: number; + loaded?: number; + name: string; + abort?: () => void; + uploadState: UploadStatus; + filePath?: string; + startTime?: number; + key: string; +} + +export type UploadProgressBatch = Record; diff --git a/packages/odf/components/s3-browser/upload-objects/upload-component/FileUploadComponent.tsx b/packages/odf/components/s3-browser/upload-objects/upload-component/FileUploadComponent.tsx index cac536383..abd930516 100644 --- a/packages/odf/components/s3-browser/upload-objects/upload-component/FileUploadComponent.tsx +++ b/packages/odf/components/s3-browser/upload-objects/upload-component/FileUploadComponent.tsx @@ -4,159 +4,165 @@ import { getPrefix } from '@odf/core/utils'; import { FieldLevelHelp, useCustomTranslation } from '@odf/shared'; import { S3Commands } from '@odf/shared/s3'; import UploadIcon from '@patternfly/react-icons/dist/esm/icons/upload-icon'; +import * as _ from 'lodash-es'; +import { observer } from 'mobx-react-lite'; import { useDropzone } from 'react-dropzone'; import { Trans } from 'react-i18next'; import { useSearchParams } from 'react-router-dom-v5-compat'; import { Icon, FlexItem, Flex, Title, Button } from '@patternfly/react-core'; -import { UploadProgress, UploadStatus } from '../types'; +import { uploadStore } from '../store'; +import { UploadStatus } from '../types'; import { UploadStatusBasedAlert } from '../upload-status/UploadStatusBasedAlert'; -import { getCompletedTotalFailedCount } from '../utils'; import { convertFileToUploadProgress, uploadFile } from './uploads'; import './fileUploadComponent.scss'; type FileUploadComponentProps = { client: S3Commands; bucketName: string; - uploadProgress: UploadProgress; - setUploadProgress: React.Dispatch>; showSidebar: () => void; - abortAll: () => void; + hideSidebar: () => void; setCompletionTime: React.Dispatch>; triggerRefresh: () => void; }; -export const FileUploadComponent: React.FC = ({ - client, - bucketName, - uploadProgress, - setUploadProgress, - showSidebar, - abortAll, - setCompletionTime, - triggerRefresh, -}) => { - const { t } = useCustomTranslation(); - const [uploadStatus, setUploadStatus] = React.useState< - | UploadStatus.INIT_STATE - | UploadStatus.UPLOAD_COMPLETE - | UploadStatus.UPLOAD_START - >(UploadStatus.INIT_STATE); +export const FileUploadComponent: React.FC = observer( + ({ + client, + bucketName, + showSidebar, + hideSidebar, + setCompletionTime, + triggerRefresh, + }) => { + const { t } = useCustomTranslation(); + const [uploadStatus, setUploadStatus] = React.useState< + | UploadStatus.INIT_STATE + | UploadStatus.UPLOAD_COMPLETE + | UploadStatus.UPLOAD_START + >(UploadStatus.INIT_STATE); - const [searchParams] = useSearchParams(); - const foldersPath = searchParams.get(PREFIX) || ''; + const [searchParams] = useSearchParams(); + const foldersPath = searchParams.get(PREFIX) || ''; - const processFiles = React.useCallback( - async (uploadObjects: File[], setProgress) => { - try { - const completionTime = await uploadFile( - uploadObjects, - client, - bucketName, - setProgress, - foldersPath - ); - setCompletionTime(completionTime); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error uploading file', e); - } - }, - [bucketName, client, foldersPath, setCompletionTime] - ); + const processFiles = React.useCallback( + async (uploadObjects: File[]) => { + try { + const completionTime = await uploadFile( + uploadObjects, + client, + bucketName, + foldersPath, + uploadStore + ); + setCompletionTime(completionTime); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error uploading file', e); + } + }, + [bucketName, client, foldersPath, setCompletionTime] + ); - const onDrop = React.useCallback( - async (acceptedFiles: File[]) => { - const intialUploadObjects = acceptedFiles.reduce((acc, curr) => { - const name = getPrefix( - curr.webkitRelativePath || curr.name, - foldersPath - ); - acc[name] = convertFileToUploadProgress(curr); - return acc; - }, {} as UploadProgress); - setUploadProgress(intialUploadObjects); - setUploadStatus(UploadStatus.UPLOAD_START); - await processFiles(acceptedFiles, setUploadProgress); - setUploadStatus(UploadStatus.UPLOAD_COMPLETE); - triggerRefresh(); - }, - [setUploadProgress, processFiles, triggerRefresh, foldersPath] - ); + const closeAlert = React.useCallback(() => { + setUploadStatus(UploadStatus.INIT_STATE); + hideSidebar(); + uploadStore.clearAll(); + }, [hideSidebar]); - const [completedUploads, totalUploads, failedUploads] = - getCompletedTotalFailedCount(uploadProgress); + const onDrop = React.useCallback( + async (acceptedFiles: File[]) => { + closeAlert(); + acceptedFiles.forEach((file) => { + const key = getPrefix( + file.webkitRelativePath || file.name, + foldersPath + ); + uploadStore.addFile(convertFileToUploadProgress(file, key), key); + }); + if (uploadStatus !== UploadStatus.UPLOAD_COMPLETE) { + setUploadStatus(UploadStatus.UPLOAD_START); + const batches = _.chunk(acceptedFiles, 6); + for (const batch of batches) { + // eslint-disable-next-line no-await-in-loop + await processFiles(batch); + } + } + setUploadStatus(UploadStatus.UPLOAD_COMPLETE); + triggerRefresh(); + }, + [closeAlert, foldersPath, processFiles, triggerRefresh, uploadStatus] + ); - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - useFsAccessApi: false, - }); + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + useFsAccessApi: false, + }); - const closeAlert = () => { - setUploadStatus(UploadStatus.INIT_STATE); - }; + const abortAll = () => { + setUploadStatus(UploadStatus.UPLOAD_COMPLETE); + }; - return ( -
- {(uploadStatus === UploadStatus.UPLOAD_START || - uploadStatus === UploadStatus.UPLOAD_COMPLETE) && ( - - )} - - {t('Add objects')} - <FieldLevelHelp> - <Trans t={t}> - Transfer files to cloud storage, where each file (object) is stored - with a unique identifier and metadata. By default, objects are - private. To configure permissions or properties for objects in an S3 - bucket, users can use the Command Line Interface (CLI), Management - Console, or SDKs. To make objects publicly accessible or apply more - specific permissions, users can set bucket policies, use access - control lists (ACLs), or define roles based on their requirements. - </Trans> - </FieldLevelHelp> - -
- - - - - - - - - - - - - - {t('Drag and drop files/folders here.')} - + return ( +
+ {(uploadStatus === UploadStatus.UPLOAD_START || + uploadStatus === UploadStatus.UPLOAD_COMPLETE) && ( + + )} + + {t('Add objects')} + <FieldLevelHelp> + <Trans t={t}> + Transfer files to cloud storage, where each file (object) is + stored with a unique identifier and metadata. By default, objects + are private. To configure permissions or properties for objects in + an S3 bucket, users can use the Command Line Interface (CLI), + Management Console, or SDKs. To make objects publicly accessible + or apply more specific permissions, users can set bucket policies, + use access control lists (ACLs), or define roles based on their + requirements. + </Trans> + </FieldLevelHelp> + +
+ + + + + + + + + + + + + + {t('Drag and drop files/folders here.')} + + + + + + + + Standard uploads have a size limit of up to 5TB in S3. For + objects, multipart upload will upload the object in parts, + which are assembled in the bucket. + - - - - - - Standard uploads have a size limit of up to 5TB in S3. For - objects, multipart upload will upload the object in parts, - which are assembled in the bucket. - - - - - - - - + + + + + + +
-
- ); -}; + ); + } +); diff --git a/packages/odf/components/s3-browser/upload-objects/upload-component/uploads.ts b/packages/odf/components/s3-browser/upload-objects/upload-component/uploads.ts index 4f09077e1..69a114635 100644 --- a/packages/odf/components/s3-browser/upload-objects/upload-component/uploads.ts +++ b/packages/odf/components/s3-browser/upload-objects/upload-component/uploads.ts @@ -1,76 +1,72 @@ -import * as React from 'react'; -import { Upload } from '@aws-sdk/lib-storage'; import { getPrefix } from '@odf/core/utils'; import { S3Commands } from '@odf/shared/s3'; import * as _ from 'lodash-es'; +import { UploadStore } from '../store'; import { UploadProgress, UploadStatus } from '../types'; +const performUploadPromise = ( + file: File, + folderPath, + bucketName, + uploadStore: UploadStore, + client: S3Commands +) => { + const key = getPrefix(file.webkitRelativePath || file.name, folderPath); + const uploader = client.getUploader(file as File, key, bucketName); + uploadStore.setAborter(key, () => uploader.abort()); + uploader.on('httpUploadProgress', (progress) => { + uploadStore.updateProgress(progress.Key, progress.loaded, progress.total); + }); + return uploader.done().then(() => { + uploadStore.uploadCompleted = key; + }); +}; + export const uploadFile = async ( files: File[], client: S3Commands, bucketName: string, - setUploadProgress: React.Dispatch>, - folderPath: string + folderPath: string, + uploadStore: UploadStore ) => { - const performUploadPromise = ( - file: File - ): ReturnType => { - const key = getPrefix(file.webkitRelativePath || file.name, folderPath); - const uploader = client.getUploader(file, key, bucketName); - uploader.on('httpUploadProgress', (progress) => { - const isComplete = progress.total === progress.loaded; - setUploadProgress((prev) => ({ - ...prev, - [progress.Key]: { - startTime: prev[progress.Key].startTime ?? Date.now(), - name: progress.Key, - total: progress.total, - loaded: progress.loaded, - abort: () => { - uploader.abort(); - setUploadProgress((previous) => { - const clonedPrev = _.cloneDeep(previous); - clonedPrev[progress.Key].uploadState = UploadStatus.UPLOAD_FAILED; - return clonedPrev; - }); - }, - uploadState: isComplete - ? UploadStatus.UPLOAD_COMPLETE - : UploadStatus.UPLOAD_START, - }, - })); - }); - return uploader.done(); - }; - const allUploadPromise = files.map((file) => performUploadPromise(file)); + const allUploadPromise = files + .filter((file) => { + const key = getPrefix(file.webkitRelativePath || file.name, folderPath); + const item = uploadStore.getFile(key); + return item.uploadState !== UploadStatus.UPLOAD_CANCELLED; + }) + .map((file) => + performUploadPromise(file, folderPath, bucketName, uploadStore, client) + ); const settledPromises = await Promise.allSettled(allUploadPromise); settledPromises.forEach((promise, i) => { const hasFailed = promise.status === 'rejected'; - if (hasFailed) { - const key = getPrefix( - files[i].webkitRelativePath || files[i].name, - folderPath - ); - setUploadProgress((prev) => ({ - ...prev, - [key]: { - ...prev[key], - uploadState: UploadStatus.UPLOAD_FAILED, - }, - })); + const key = getPrefix( + files[i].webkitRelativePath || files[i].name, + folderPath + ); + const item = uploadStore.getFile(key); + const status = item.uploadState; + if (hasFailed && status !== UploadStatus.UPLOAD_COMPLETE) { + uploadStore.uploadFailed = key; } }); return Date.now(); }; export const convertFileToUploadProgress = ( - file: File -): UploadProgress[keyof UploadProgress] => ({ - total: file.size, + file: File, + key: string +): UploadProgress => ({ loaded: 0, uploadState: UploadStatus.INIT_STATE, abort: null, name: file.name, filePath: file.webkitRelativePath, startTime: undefined, + lastModified: 0, + webkitRelativePath: '', + size: 0, + type: '', + key, }); diff --git a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusBasedAlert.tsx b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusBasedAlert.tsx index a94d4a4c0..3a2a03e0c 100644 --- a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusBasedAlert.tsx +++ b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusBasedAlert.tsx @@ -1,56 +1,52 @@ import * as React from 'react'; import { useCustomTranslation } from '@odf/shared'; +import { observer } from 'mobx-react-lite'; import { Alert, AlertActionLink, AlertVariant } from '@patternfly/react-core'; import { InProgressIcon } from '@patternfly/react-icons'; import { AbortUploadsModal } from '../../../../modals/s3-browser/abort-uploads/AbortUploadsModal'; +import { uploadStore } from '../store'; +import { getCompletedTotalFailedCount } from '../utils'; type UploadStatusBasedAlertProps = { closeAlert: () => void; abortAll: () => void; showSidebar: () => void; - failedUploads: number; - totalUploads: number; - completedUploads: number; }; -export const UploadStatusBasedAlert: React.FC = ({ - showSidebar, - abortAll, - completedUploads, - failedUploads, - totalUploads, - closeAlert, -}) => { - const { t } = useCustomTranslation(); - const showSuccess = totalUploads === completedUploads + failedUploads; - return ( - } - title={ - showSuccess - ? t('Uploading files to the bucket is complete') - : t('Uploading files to the bucket is in progress') - } - isInline - actionLinks={ - <> - - {t('View uploads')} - - {showSuccess && ( - - {t('Dismiss')} +export const UploadStatusBasedAlert: React.FC = + observer(({ showSidebar, abortAll, closeAlert }) => { + const { t } = useCustomTranslation(); + const [completedUploads, totalUploads, failedUploads] = + getCompletedTotalFailedCount(uploadStore.getAll); + const showSuccess = totalUploads === completedUploads + failedUploads; + return ( + } + title={ + showSuccess + ? t('Uploading files to the bucket is complete') + : t('Uploading files to the bucket is in progress') + } + isInline + actionLinks={ + <> + + {t('View uploads')} - )} - {!showSuccess && } - - } - > - {t('{{completedUploads}} of {{totalUploads}} have been uploaded', { - completedUploads, - totalUploads, - })} - - ); -}; + {showSuccess && ( + + {t('Dismiss')} + + )} + {!showSuccess && } + + } + > + {t('{{completedUploads}} of {{totalUploads}} have been uploaded', { + completedUploads, + totalUploads, + })} + + ); + }); diff --git a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusItem.tsx b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusItem.tsx index 8a6013199..3a02a2973 100644 --- a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusItem.tsx +++ b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusItem.tsx @@ -1,23 +1,46 @@ import * as React from 'react'; +import { humanizeBinaryBytes } from '@odf/shared/utils'; +import { observer } from 'mobx-react-lite'; import { Button, ButtonVariant, Flex, FlexItem, Icon, - Progress, ProgressMeasureLocation, + Progress, + ProgressVariant, } from '@patternfly/react-core'; import { CloseIcon, FileIcon } from '@patternfly/react-icons'; +import { uploadStore } from '../store'; +import { UploadStatus } from '../types'; import './uploadStatusItem.scss'; type UploadStatusItemProps = { fileName: string; fileSize: string; progress: number; - onAbort?: () => void; failed: boolean; - variant: Progress['props']['variant']; + itemKey: string; +}; + +const getProgressVariant = ( + state: UploadStatus, + // For cases whjen it's complete but user presses cancel(edge case) + isComplete: boolean +): Progress['props']['variant'] => { + if (isComplete) { + return undefined; + } + switch (state) { + case UploadStatus.UPLOAD_FAILED: + case UploadStatus.UPLOAD_CANCELLED: + return ProgressVariant.danger; + case UploadStatus.UPLOAD_COMPLETE: + return ProgressVariant.success; + default: + return undefined; + } }; const FileTitle: React.FC<{ @@ -31,36 +54,41 @@ const FileTitle: React.FC<{ {size}
); -export const UploadStatusItem: React.FC = ({ - fileName, - fileSize, - progress, - onAbort, - variant, - failed, -}) => { - return ( - - - - - - - - } - /> - - - {progress !== 100 && !failed && !!onAbort && ( - - )} - - - ); -}; + +export const UploadStatusItem: React.FC = observer( + ({ fileName, failed, itemKey }) => { + const item = uploadStore.getFile(itemKey); + const onAbort = () => uploadStore.performAbort(itemKey); + const progress = (item.loaded / item.total) * 100; + const variant = getProgressVariant(item.uploadState, progress === 100); + return ( + + + + + + + + + } + /> + + + {progress !== 100 && !failed && ( + + )} + + + ); + } +); diff --git a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusList.tsx b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusList.tsx index e1d483bfc..9e4cc5549 100644 --- a/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusList.tsx +++ b/packages/odf/components/s3-browser/upload-objects/upload-status/UploadStatusList.tsx @@ -1,61 +1,62 @@ import * as React from 'react'; import { humanizeBinaryBytes } from '@odf/shared/utils'; +import { observer } from 'mobx-react-lite'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList as List } from 'react-window'; -import { Progress, ProgressVariant } from '@patternfly/react-core'; +import { UploadStore, uploadStore } from '../store'; import { UploadProgress, UploadStatus } from '../types'; import { UploadStatusItem } from './UploadStatusItem'; type UploadStatusListProps = { - progress: UploadProgress[keyof UploadProgress][]; currentWidth?: number; + uploadStore: UploadStore; }; -const getProgressVariant = ( - state: UploadStatus -): Progress['props']['variant'] => { - switch (state) { - case UploadStatus.UPLOAD_FAILED: - return ProgressVariant.danger; - case UploadStatus.UPLOAD_COMPLETE: - return ProgressVariant.success; - default: - return undefined; - } +type UploadStatusListRowProps = { + data: { + items: UploadProgress[]; + }; + index: number; + style: any; }; -const UploadStatusListRow = ({ data, index, style }) => { - const item = data[index]; - return ( -
- -
- ); -}; +const UploadStatusListRow: React.FC = observer( + ({ data: { items }, index, style }) => { + const item = uploadStore.getFile(items[index].key); + return ( +
+ +
+ ); + } +); -export const UploadStatusList: React.FC = ({ - progress, -}) => { - return ( - - {({ height, width }) => ( - - {UploadStatusListRow} - - )} - - ); -}; +export const UploadStatusList: React.FC = observer( + () => { + const items = Array.from(Object.values(uploadStore.getAll)); + return ( + + {({ height, width }) => ( + + height={height} + itemCount={items.length} + itemData={{ items }} + itemSize={100} + width={width} + > + {UploadStatusListRow} + + )} + + ); + } +); diff --git a/packages/odf/components/s3-browser/upload-objects/utils.ts b/packages/odf/components/s3-browser/upload-objects/utils.ts index cb3489a72..f63937d11 100644 --- a/packages/odf/components/s3-browser/upload-objects/utils.ts +++ b/packages/odf/components/s3-browser/upload-objects/utils.ts @@ -5,9 +5,11 @@ import { humanizeSeconds, } from '@odf/shared/utils'; import * as _ from 'lodash-es'; -import { UploadProgress, UploadStatus } from './types'; +import { UploadProgressBatch, UploadStatus } from './types'; -export const getCompletedAndTotalUploadCount = (objects: UploadProgress) => { +export const getCompletedAndTotalUploadCount = ( + objects: UploadProgressBatch +) => { const totalObjects = Object.keys(objects).length; const totalUploaded = Object.values(objects).filter( (obj) => obj.uploadState === UploadStatus.UPLOAD_COMPLETE @@ -16,7 +18,7 @@ export const getCompletedAndTotalUploadCount = (objects: UploadProgress) => { }; export const getCompletedTotalFailedCount = ( - uploadProgress: UploadProgress + uploadProgress: UploadProgressBatch ) => { const progressItems = Object.values(uploadProgress); const [completedUploads, failedUploads] = progressItems.reduce( @@ -26,7 +28,9 @@ export const getCompletedTotalFailedCount = ( acc = [acc[0] + 1, acc[1]]; return acc; } - const isFailed = curr.uploadState === UploadStatus.UPLOAD_FAILED; + const isFailed = + curr.uploadState === UploadStatus.UPLOAD_FAILED || + curr.uploadState === UploadStatus.UPLOAD_CANCELLED; if (isFailed) { acc = [acc[0], acc[1] + 1]; return acc; @@ -38,12 +42,17 @@ export const getCompletedTotalFailedCount = ( return [completedUploads, progressItems.length, failedUploads]; }; -export const getFailedFiles = (objects: UploadProgress) => +export const getFailedFiles = (objects: UploadProgressBatch) => Object.values(objects).filter( (obj) => obj.uploadState === UploadStatus.UPLOAD_FAILED ).length; -export const getUploadSpeed = (objects: UploadProgress): string => { +export const getCancelledFiles = (objects: UploadProgressBatch) => + Object.values(objects).filter( + (obj) => obj.uploadState === UploadStatus.UPLOAD_CANCELLED + ).length; + +export const getUploadSpeed = (objects: UploadProgressBatch): string => { if (_.isEmpty(objects)) return ''; const files = Object.values(objects); const uploadingFiles = files.filter( @@ -53,13 +62,14 @@ export const getUploadSpeed = (objects: UploadProgress): string => { ); const totalTimes = uploadingFiles.map((file) => Date.now() - file?.startTime); const totalUploaded = uploadingFiles.map((file) => file.loaded ?? 0); - const uploadSpeeds = totalUploaded.map((size, i) => size / totalTimes[i]); + let uploadSpeeds = totalUploaded.map((size, i) => size / totalTimes[i]); + uploadSpeeds = uploadSpeeds.filter((val) => _.isNumber(val)); const speed = _.sum(uploadSpeeds) / uploadSpeeds.length; return humanizeDecimalBytesPerSec(speed * 1000).string; }; export const getTotalRemainingFilesAndSize = ( - objects: UploadProgress + objects: UploadProgressBatch ): string => { if (_.isEmpty(objects)) return ''; const files = Object.values(objects); @@ -76,7 +86,7 @@ export const getTotalRemainingFilesAndSize = ( return `${filesCount} files (${humanizeBinaryBytes(filesSize).string})`; }; -export const getTotalTimeRemaining = (objects: UploadProgress): string => { +export const getTotalTimeRemaining = (objects: UploadProgressBatch): string => { const files = Object.values(objects); const uploadingFiles = files.filter( (item) => @@ -99,7 +109,7 @@ export const getTotalTimeRemaining = (objects: UploadProgress): string => { }; export const getTotalTimeElapsed = ( - objects: UploadProgress, + objects: UploadProgressBatch, completionTime: number ): string => { const earliestTime = Object.values(objects).reduce((acc, curr) => { @@ -107,7 +117,8 @@ export const getTotalTimeElapsed = ( return curr.startTime; } else return acc; }, Number.MAX_SAFE_INTEGER); - const fromNow = completionTime - earliestTime; + let fromNow = completionTime - earliestTime; + fromNow = fromNow < 0 ? 0 : fromNow; const minutes = humanizeMinutes(fromNow / 1000); if (minutes.value > 1) { return minutes.string; diff --git a/packages/odf/modals/s3-browser/abort-uploads/AbortUploadsModal.tsx b/packages/odf/modals/s3-browser/abort-uploads/AbortUploadsModal.tsx index f64767fd2..c7a7cc288 100644 --- a/packages/odf/modals/s3-browser/abort-uploads/AbortUploadsModal.tsx +++ b/packages/odf/modals/s3-browser/abort-uploads/AbortUploadsModal.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { ButtonBar, useCustomTranslation } from '@odf/shared'; +import { observer } from 'mobx-react-lite'; import { Trans } from 'react-i18next'; import { Button, @@ -7,54 +8,60 @@ import { Modal, ModalVariant, } from '@patternfly/react-core'; +import { uploadStore } from '../../../components/s3-browser/upload-objects/store'; type AbortUploadsModalProps = { - abortAll: () => Promise; + abortAll: () => void; }; -export const AbortUploadsModal: React.FC = ({ - abortAll, -}) => { - const { t } = useCustomTranslation(); - const [isModalOpen, setModalOpen] = React.useState(false); +export const AbortUploadsModal: React.FC = observer( + ({ abortAll }) => { + const { t } = useCustomTranslation(); + const [isModalOpen, setModalOpen] = React.useState(false); + const onAbort = () => { + abortAll(); + uploadStore.abortAll(); + setModalOpen(false); + }; - return ( - <> - - - - - - - , - ]} - > - - Are you sure you want to cancel the ongoing uploads? Any files - currently being uploaded will be stopped, and partially uploaded files - will not be saved. - - - - ); -}; + return ( + <> + + + + + + + , + ]} + > + + Are you sure you want to cancel the ongoing uploads? Any files + currently being uploaded will be stopped, and partially uploaded + files will not be saved. + + + + ); + } +); diff --git a/yarn.lock b/yarn.lock index 3686ca304..fa7dd1d40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3955,15 +3955,15 @@ natural-compare "^1.4.0" ts-api-utils "^1.3.0" -"@typescript-eslint/parser@^8.12.2": - version "8.12.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.12.2.tgz#2e8173b34e1685e918b2d571c16c906d3747bad2" - integrity sha512-MrvlXNfGPLH3Z+r7Tk+Z5moZAc0dzdVjTgUgwsdGweH7lydysQsnSww3nAmsq8blFuRD5VRlAr9YdEFw3e6PBw== - dependencies: - "@typescript-eslint/scope-manager" "8.12.2" - "@typescript-eslint/types" "8.12.2" - "@typescript-eslint/typescript-estree" "8.12.2" - "@typescript-eslint/visitor-keys" "8.12.2" +"@typescript-eslint/parser@^8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.17.0.tgz#2ee972bb12fa69ac625b85813dc8d9a5a053ff52" + integrity sha512-Drp39TXuUlD49F7ilHHCG7TTg8IkA+hxCuULdmzWYICxGXvDXmDmWEjJYZQYgf6l/TFfYNE167m7isnc3xlIEg== + dependencies: + "@typescript-eslint/scope-manager" "8.17.0" + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/typescript-estree" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" debug "^4.3.4" "@typescript-eslint/scope-manager@8.12.2": @@ -3974,6 +3974,14 @@ "@typescript-eslint/types" "8.12.2" "@typescript-eslint/visitor-keys" "8.12.2" +"@typescript-eslint/scope-manager@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.17.0.tgz#a3f49bf3d4d27ff8d6b2ea099ba465ef4dbcaa3a" + integrity sha512-/ewp4XjvnxaREtqsZjF4Mfn078RD/9GmiEAtTeLQ7yFdKnqwTOgRMSvFz4et9U5RiJQ15WTGXPLj89zGusvxBg== + dependencies: + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" + "@typescript-eslint/type-utils@8.12.2": version "8.12.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.12.2.tgz#132b0c52d45f6814e6f2e32416c7951ed480b016" @@ -3989,6 +3997,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.12.2.tgz#8d70098c0e90442495b53d0296acdca6d0f3f73c" integrity sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA== +"@typescript-eslint/types@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.17.0.tgz#ef84c709ef8324e766878834970bea9a7e3b72cf" + integrity sha512-gY2TVzeve3z6crqh2Ic7Cr+CAv6pfb0Egee7J5UAVWCpVvDI/F71wNfolIim4FE6hT15EbpZFVUj9j5i38jYXA== + "@typescript-eslint/typescript-estree@8.12.2": version "8.12.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.12.2.tgz#206df9b1cbff212aaa9401985ef99f04daa84da5" @@ -4003,6 +4016,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.17.0.tgz#40b5903bc929b1e8dd9c77db3cb52cfb199a2a34" + integrity sha512-JqkOopc1nRKZpX+opvKqnM3XUlM7LpFMD0lYxTqOTKQfCWAmxw45e3qlOCsEqEB2yuacujivudOFpCnqkBDNMw== + dependencies: + "@typescript-eslint/types" "8.17.0" + "@typescript-eslint/visitor-keys" "8.17.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + "@typescript-eslint/utils@8.12.2", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0": version "8.12.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.12.2.tgz#726cc9f49f5866605bd15bbc1768ffc15637930e" @@ -4021,6 +4048,14 @@ "@typescript-eslint/types" "8.12.2" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.17.0": + version "8.17.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.17.0.tgz#4dbcd0e28b9bf951f4293805bf34f98df45e1aa8" + integrity sha512-1Hm7THLpO6ww5QU6H/Qp+AusUUl+z/CAm3cNZZ0jQvon9yicgO7Rwd+/WWRpMKLYV6p2UvdbR27c86rzCPpreg== + dependencies: + "@typescript-eslint/types" "8.17.0" + eslint-visitor-keys "^4.2.0" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -6960,6 +6995,11 @@ eslint-plugin-jsx-a11y@^6.10.2: safe-regex-test "^1.0.3" string.prototype.includes "^2.0.1" +eslint-plugin-mobx@^0.0.13: + version "0.0.13" + resolved "https://registry.yarnpkg.com/eslint-plugin-mobx/-/eslint-plugin-mobx-0.0.13.tgz#80ea1683746de94a14e2c506f704da6142af28a2" + integrity sha512-QPFSuOnerqohu1rbRUXj75R8MoWybPKViAF63IPZ+db0mYKyC4njuJt3wyaUdzchv3aza7mNYlBN9jdF91r+Og== + eslint-plugin-react-hooks@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" @@ -7010,6 +7050,11 @@ eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + eslint@^8.57.1: version "8.57.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" @@ -10264,6 +10309,13 @@ mobx-react-lite@^3.4.0: resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== +mobx-react-lite@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.7.tgz#f4e21e18d05c811010dcb1d3007e797924c4d90b" + integrity sha512-RjwdseshK9Mg8On5tyJZHtGD+J78ZnCnRaxeQDSiciKVQDUbfZcXhmld0VMxAwvcTnPEHZySGGewm467Fcpreg== + dependencies: + use-sync-external-store "^1.2.0" + mobx-react@^7.6.0: version "7.6.0" resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-7.6.0.tgz#ebf0456728a9bd2e5c24fdcf9b36e285a222a7d6" @@ -10271,6 +10323,11 @@ mobx-react@^7.6.0: dependencies: mobx-react-lite "^3.4.0" +mobx@^6.13.5: + version "6.13.5" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.13.5.tgz#957d9df88c7f8b4baa7c6f8bdcb6d68b432a6ed5" + integrity sha512-/HTWzW2s8J1Gqt+WmUj5Y0mddZk+LInejADc79NJadrWla3rHzmRHki/mnEUH1AvOmbNTZ1BRbKxr8DSgfdjMA== + mobx@^6.9.0: version "6.10.2" resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.2.tgz#96e123deef140750360ca9a5b02a8b91fbffd4d9"