diff --git a/.gitignore b/.gitignore index 8f36e4547..6857817ad 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ # production /dist +/dist-uploader # storybook /storybook-static diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d236d0b..3f640922b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added -- Added the value entered by the user in the error messages for metadata field validation errors in EMAIL and URL type fields. For example, instead of showing “Point of Contact E-mail is not a valid email address.“, we now show “Point of Contact E-mail foo is not a valid email address.” +- DVWebloader V2: A standalone file uploader build that reuses React file upload components, supporting S3 direct uploads with configurable tagging. +- Shared file upload hooks (`useFileUploadState`, `useFileUploadOperations`) for better code reuse between the main SPA and standalone uploader. +- Added the value entered by the user in the error messages for metadata field validation errors in EMAIL and URL type fields. For example, instead of showing "Point of Contact E-mail is not a valid email address.", we now show "Point of Contact E-mail foo is not a valid email address." - Contact Owner button in File Page. - Share button in File Page. - Link Collection and Link Dataset features. diff --git a/package-lock.json b/package-lock.json index ccf50a516..428d38675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.79", + "@iqss/dataverse-client-javascript": "file:../dataverse-client-javascript", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -127,6 +127,38 @@ ] } }, + "../dataverse-client-javascript": { + "name": "@iqss/dataverse-client-javascript", + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "@types/node": "^18.15.11", + "@types/turndown": "^5.0.1", + "axios": "^1.12.2", + "turndown": "^7.1.2", + "typescript": "^4.9.5" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@typescript-eslint/eslint-plugin": "5.51.0", + "@typescript-eslint/parser": "5.51.0", + "@web-std/file": "3.0.3", + "eslint": "8.33.0", + "eslint-config-prettier": "8.6.0", + "eslint-plugin-import": "2.27.5", + "eslint-plugin-jest": "27.2.1", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-simple-import-sort": "10.0.0", + "eslint-plugin-unused-imports": "2.0.0", + "husky": "9.1.7", + "jest": "^29.4.3", + "jest-environment-jsdom": "29.7.0", + "prettier": "2.8.4", + "testcontainers": "^10.11.0", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.2" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -1953,40 +1985,8 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.79", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.79/4128665172f9569fa40f60ca1c1d205f7fe8a401", - "integrity": "sha512-NfjzwOz06QJzSYAQ2eZ20tRINO49LrBaWQ9JTQNK0cLPTRdmYzUJgB2zzGzH/c/bi7LfGgfkPqO+6MH9HPFxpg==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.15.11", - "@types/turndown": "^5.0.1", - "axios": "^1.12.2", - "turndown": "^7.1.2", - "typescript": "^4.9.5" - } - }, - "node_modules/@iqss/dataverse-client-javascript/node_modules/@types/node": { - "version": "18.19.127", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.127.tgz", - "integrity": "sha512-gSjxjrnKXML/yo0BO099uPixMqfpJU0TKYjpfLU7TrtA2WWDki412Np/RSTPRil1saKBhvVVKzVx/p/6p94nVA==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@iqss/dataverse-client-javascript/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } + "resolved": "../dataverse-client-javascript", + "link": true }, "node_modules/@iqss/dataverse-design-system": { "resolved": "packages/design-system", @@ -8569,12 +8569,6 @@ "license": "MIT", "optional": true }, - "node_modules/@types/turndown": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", - "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -9670,6 +9664,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -9760,6 +9755,7 @@ "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -11017,6 +11013,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -12026,6 +12023,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -12693,6 +12691,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -13964,6 +13963,7 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, "funding": [ { "type": "individual", @@ -14039,6 +14039,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -21346,6 +21347,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -21355,6 +21357,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -24527,6 +24530,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, "license": "MIT" }, "node_modules/psl": { @@ -28414,12 +28418,6 @@ "react": ">=15.0.0" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", diff --git a/package.json b/package.json index 45cfb2198..75b4d3554 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.79", + "@iqss/dataverse-client-javascript": "file:../dataverse-client-javascript", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -64,6 +64,7 @@ "scripts": { "start": "vite --base=/spa", "build": "tsc && vite build", + "build-uploader": "vite build --config vite.config.uploader.ts && cp -r public/locales dist-uploader/ && cp src/standalone-uploader/dvwebloaderV2.html src/standalone-uploader/embeddedDvWebloader.html dist-uploader/", "build-keycloak-theme": "npm run build && keycloakify build", "preview": "vite preview", "lint": "npm run typecheck && npm run lint:eslint && npm run lint:stylelint && npm run lint:prettier", diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index d4dbc4e26..6f63dc582 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -218,6 +218,7 @@ "accordionTitle": "Upload with HTTP via your browser", "selectFileSingle": "Select file to add", "selectFileMultiple": "Select files to add", + "selectFolder": "Select folder to add", "dragDropSingle": "Drag and drop file here.", "dragDropMultiple": "Drag and drop files and/or directories here.", "cancelUpload": "Cancel upload", diff --git a/src/files/domain/models/FixityAlgorithm.ts b/src/files/domain/models/FixityAlgorithm.ts index 3f062fcde..a676439ec 100644 --- a/src/files/domain/models/FixityAlgorithm.ts +++ b/src/files/domain/models/FixityAlgorithm.ts @@ -1,4 +1,5 @@ export enum FixityAlgorithm { + NONE = 'NONE', MD5 = 'MD5', SHA1 = 'SHA-1', SHA256 = 'SHA-256', diff --git a/src/files/domain/useCases/addUploadedFiles.ts b/src/files/domain/useCases/addUploadedFiles.ts index 552455b96..1a8970998 100644 --- a/src/files/domain/useCases/addUploadedFiles.ts +++ b/src/files/domain/useCases/addUploadedFiles.ts @@ -1,8 +1,14 @@ import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for addUploadedFiles. + * Only requires the addUploadedFiles method. + */ +type AddUploadedFilesRepository = Pick + export function addUploadedFiles( - fileRepository: FileRepository, + fileRepository: AddUploadedFilesRepository, datasetId: number | string, files: UploadedFileDTO[] ): Promise { diff --git a/src/files/domain/useCases/replaceFile.ts b/src/files/domain/useCases/replaceFile.ts index 9240d84d8..29aa537dd 100644 --- a/src/files/domain/useCases/replaceFile.ts +++ b/src/files/domain/useCases/replaceFile.ts @@ -1,8 +1,14 @@ import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for replaceFile. + * Only requires the replace method. + */ +type ReplaceFileRepository = Pick + export function replaceFile( - fileRepository: FileRepository, + fileRepository: ReplaceFileRepository, fileId: number | string, newFile: UploadedFileDTO ): Promise { diff --git a/src/files/domain/useCases/uploadFile.ts b/src/files/domain/useCases/uploadFile.ts index 0b4febeac..f90919fc8 100644 --- a/src/files/domain/useCases/uploadFile.ts +++ b/src/files/domain/useCases/uploadFile.ts @@ -1,7 +1,13 @@ import { FileRepository } from '../repositories/FileRepository' +/** + * Minimal repository type for uploadFile. + * Only requires the uploadFile method. + */ +type UploadFileRepository = Pick + export function uploadFile( - fileRepository: FileRepository, + fileRepository: UploadFileRepository, datasetId: number | string, file: File, done: () => void, diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index d7d64fb7a..a112b4744 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -1,5 +1,5 @@ -import { ReplaceFileReferrer } from './replace-file/ReplaceFile' -import { EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFileMetadata' +import { ReplaceFileReferrer } from './replace-file/ReplaceFileReferrer' +import { EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFileMetadataReferrer' export enum Route { HOME = '/', diff --git a/src/sections/edit-file-metadata/EditFileMetadata.tsx b/src/sections/edit-file-metadata/EditFileMetadata.tsx index 04e11b855..7f4c1f356 100644 --- a/src/sections/edit-file-metadata/EditFileMetadata.tsx +++ b/src/sections/edit-file-metadata/EditFileMetadata.tsx @@ -12,20 +12,18 @@ import { } from '@/sections/edit-file-metadata/EditFilesList' import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { useFile } from '@/sections/file/useFile' +import { EditFileMetadataReferrer } from './EditFileMetadataReferrer' import styles from './EditFileMetadata.module.scss' +// Re-export for backwards compatibility +export { EditFileMetadataReferrer } from './EditFileMetadataReferrer' + interface EditFileMetadataProps { fileId: number fileRepository: FileRepository referrer: EditFileMetadataReferrer } -// From where the user is coming from -export enum EditFileMetadataReferrer { - DATASET = 'dataset', - FILE = 'file' -} - export const EditFileMetadata = ({ fileId, fileRepository, referrer }: EditFileMetadataProps) => { const { t: tEditFileMetadata } = useTranslation('editFileMetadata') const { t: tFiles } = useTranslation('files') diff --git a/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts b/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts new file mode 100644 index 000000000..553ecd82f --- /dev/null +++ b/src/sections/edit-file-metadata/EditFileMetadataReferrer.ts @@ -0,0 +1,8 @@ +/** + * Enum indicating where the user came from when editing file metadata. + * Extracted to its own file to avoid circular import issues. + */ +export enum EditFileMetadataReferrer { + DATASET = 'dataset', + FILE = 'file' +} diff --git a/src/sections/replace-file/ReplaceFile.tsx b/src/sections/replace-file/ReplaceFile.tsx index ea8382456..40a969631 100644 --- a/src/sections/replace-file/ReplaceFile.tsx +++ b/src/sections/replace-file/ReplaceFile.tsx @@ -9,8 +9,12 @@ import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { AppLoader } from '../shared/layout/app-loader/AppLoader' import { NotFoundPage } from '../not-found-page/NotFoundPage' import { FileUploader, OperationType } from '../shared/file-uploader/FileUploader' +import { ReplaceFileReferrer } from './ReplaceFileReferrer' import styles from './ReplaceFile.module.scss' +// Re-export for backwards compatibility +export { ReplaceFileReferrer } from './ReplaceFileReferrer' + interface ReplaceFileProps { fileRepository: FileRepository fileIdFromParams: number @@ -19,12 +23,6 @@ interface ReplaceFileProps { referrer?: ReplaceFileReferrer } -// From where the user is coming from -export enum ReplaceFileReferrer { - DATASET = 'dataset', - FILE = 'file' -} - export const ReplaceFile = ({ fileRepository, fileIdFromParams, diff --git a/src/sections/replace-file/ReplaceFileReferrer.ts b/src/sections/replace-file/ReplaceFileReferrer.ts new file mode 100644 index 000000000..357ca74cf --- /dev/null +++ b/src/sections/replace-file/ReplaceFileReferrer.ts @@ -0,0 +1,8 @@ +/** + * Enum indicating where the user came from when replacing a file. + * Extracted to its own file to avoid circular import issues. + */ +export enum ReplaceFileReferrer { + DATASET = 'dataset', + FILE = 'file' +} diff --git a/src/sections/shared/file-uploader/FileUploader.tsx b/src/sections/shared/file-uploader/FileUploader.tsx index b49a5101e..d522c4e56 100644 --- a/src/sections/shared/file-uploader/FileUploader.tsx +++ b/src/sections/shared/file-uploader/FileUploader.tsx @@ -1,6 +1,6 @@ import { File as FileModel } from '@/files/domain/models/File' import { FileRepository } from '@/files/domain/repositories/FileRepository' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { FileUploaderProvider } from './context/FileUploaderContext' import { useGetFixityAlgorithm } from './useGetFixityAlgorithm' import { FileUploaderGlobalConfig } from './context/fileUploaderReducer' diff --git a/src/sections/shared/file-uploader/FileUploaderHelper.ts b/src/sections/shared/file-uploader/FileUploaderHelper.ts index 0067fed89..f6edadee8 100644 --- a/src/sections/shared/file-uploader/FileUploaderHelper.ts +++ b/src/sections/shared/file-uploader/FileUploaderHelper.ts @@ -46,7 +46,10 @@ export class FileUploaderHelper { } public static async getChecksum(blob: Blob, algorithm: FixityAlgorithm): Promise { - if (algorithm === FixityAlgorithm.MD5) { + if (algorithm === FixityAlgorithm.NONE) { + // No checksum calculation needed + return '' + } else if (algorithm === FixityAlgorithm.MD5) { return await this.getMD5Checksum(blob) } else { return await this.getSubtleDigestChecksum(blob, algorithm) diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.module.scss b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss new file mode 100644 index 000000000..c1442ddb0 --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss @@ -0,0 +1,16 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.helper_text { + color: $dv-subtext-color; + font-size: 14px; + margin-bottom: 1rem; + + a { + color: $dv-primary-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.tsx b/src/sections/shared/file-uploader/FileUploaderPanel.tsx index 5fbb1986b..62544133a 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanel.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanel.tsx @@ -1,17 +1,21 @@ -import { useMemo } from 'react' -import { useDeepCompareEffect } from 'use-deep-compare' -import { toast } from 'react-toastify' -import { useTranslation } from 'react-i18next' +/** + * SPA File Uploader Panel + * + * This is the React Router-aware wrapper for FileUploaderPanelCore. + * It handles SPA-specific concerns: route navigation, useBlocker for unsaved changes. + */ + +import { useMemo, useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' import { useBlocker, useNavigate } from 'react-router-dom' -import { Stack } from '@iqss/dataverse-design-system' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { QueryParamKey, Route } from '@/sections/Route.enum' import { DatasetNonNumericVersionSearchParam } from '@/dataset/domain/models/Dataset' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { useFileUploaderContext } from './context/FileUploaderContext' -import FileUploadInput from './file-upload-input/FileUploadInput' -import { UploadedFilesList } from './uploaded-files-list/UploadedFilesList' +import { FileUploaderPanelCore } from './FileUploaderPanelCore' import { ConfirmLeaveModal } from './confirm-leave-modal/ConfirmLeaveModal' +import styles from './FileUploaderPanel.module.scss' interface FileUploaderPanelProps { fileRepository: FileRepository @@ -24,21 +28,15 @@ const FileUploaderPanel = ({ datasetPersistentId, referrer }: FileUploaderPanelProps) => { - const { t } = useTranslation('shared') const navigate = useNavigate() + const { t } = useTranslation('shared') const { - fileUploaderState: { - files, - isSaving, - uploadingToCancelMap, - replaceOperationInfo, - addFilesToDatasetOperationInfo - }, - uploadedFiles, + fileUploaderState: { files, isSaving, uploadingToCancelMap }, removeAllFiles } = useFileUploaderContext() + // Block navigation when there are unsaved changes const shouldBlockAwayNavigation = useMemo(() => { return Object.keys(files).length > 0 || isSaving || uploadingToCancelMap.size > 0 }, [files, isSaving, uploadingToCancelMap.size]) @@ -47,15 +45,9 @@ const FileUploaderPanel = ({ const handleConfirmLeavePage = () => { if (navigationBlocker.state === 'blocked') { - // TODO - Remove the files from the S3 bucket we need an API endpoint for this. - removeAllFiles() - - // Cancel all the uploading files if there are any if (uploadingToCancelMap.size > 0) { - uploadingToCancelMap.forEach((cancel) => { - cancel() - }) + uploadingToCancelMap.forEach((cancel) => cancel()) } navigationBlocker.proceed() } @@ -67,55 +59,59 @@ const FileUploaderPanel = ({ } } - useDeepCompareEffect(() => { - const datasetPageRedirectUrl = `${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${datasetPersistentId}&${QueryParamKey.VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` + // Navigation callbacks for the core component + const handleCancel = useCallback(() => navigate(-1), [navigate]) - // Listens to the replace operation info result and navigates to the new file page if the operation was successful - if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { - toast.success(t('fileUploader.fileReplacedSuccessfully')) + const datasetPageUrl = `${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${datasetPersistentId}&${QueryParamKey.VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` - if (referrer === ReplaceFileReferrer.DATASET) { - navigate(datasetPageRedirectUrl) - } + const handleFilesAddedSuccess = useCallback(() => { + navigate(datasetPageUrl) + }, [navigate, datasetPageUrl]) - if (referrer === ReplaceFileReferrer.FILE) { + const handleFileReplacedSuccess = useCallback( + (newFileId: number) => { + if (referrer === ReplaceFileReferrer.DATASET) { + navigate(datasetPageUrl) + } else if (referrer === ReplaceFileReferrer.FILE) { navigate( - `${Route.FILES}?id=${replaceOperationInfo.newFileIdentifier}&${QueryParamKey.DATASET_VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` + `${Route.FILES}?id=${newFileId}&${QueryParamKey.DATASET_VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` ) } - } - - // Listens to the add files to dataset operation info result and navigates to the dataset page if the operation was successful - if (addFilesToDatasetOperationInfo.success) { - toast.success(t('fileUploader.filesAddedToDatasetSuccessfully')) - navigate(datasetPageRedirectUrl) - } - }, [ - replaceOperationInfo, - addFilesToDatasetOperationInfo, - datasetPersistentId, - t, - navigate, - referrer - ]) + }, + [navigate, datasetPageUrl, referrer] + ) return ( - - - - {uploadedFiles.length > 0 && ( - +

+ + ) + }} /> - )} +

+ -
+ ) } diff --git a/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx new file mode 100644 index 000000000..5a42c524f --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx @@ -0,0 +1,99 @@ +/** + * Core File Uploader Panel + * + * This is the shared core component used by both the SPA (via FileUploaderPanel) + * and standalone mode (DVWebloader V2). It contains all the UI and logic, + * but delegates navigation/blocking behavior to the parent via callbacks. + */ + +import { useEffect } from 'react' +import { useDeepCompareEffect } from 'use-deep-compare' +import { toast } from 'react-toastify' +import { useTranslation } from 'react-i18next' +import { Stack } from '@iqss/dataverse-design-system' +import { useFileUploaderContext } from './context/FileUploaderContext' +import FileUploadInput from './file-upload-input/FileUploadInput' +import { UploadedFilesList } from './uploaded-files-list/UploadedFilesList' +import { UploaderFileRepository } from './types' + +export interface FileUploaderPanelCoreProps { + fileRepository: UploaderFileRepository + datasetPersistentId: string + /** Called when user clicks Cancel */ + onCancel: () => void + /** Called when files are successfully added to dataset */ + onFilesAddedSuccess: () => void + /** Called when file is successfully replaced (for replace mode) */ + onFileReplacedSuccess?: (newFileId: number) => void + /** + * Called to register a cleanup function that should be invoked before leaving. + * The parent can use this with useBlocker (SPA) or beforeunload (standalone). + */ + onRegisterUnsavedChangesCheck?: (hasUnsavedChanges: () => boolean) => void +} + +export const FileUploaderPanelCore = ({ + fileRepository, + datasetPersistentId, + onCancel, + onFilesAddedSuccess, + onFileReplacedSuccess, + onRegisterUnsavedChangesCheck +}: FileUploaderPanelCoreProps) => { + const { t } = useTranslation('shared') + + const { + fileUploaderState: { + files, + isSaving, + uploadingToCancelMap, + replaceOperationInfo, + addFilesToDatasetOperationInfo + }, + uploadedFiles + } = useFileUploaderContext() + + // Register the unsaved changes check with parent + useEffect(() => { + if (onRegisterUnsavedChangesCheck) { + onRegisterUnsavedChangesCheck(() => { + return Object.keys(files).length > 0 || isSaving || uploadingToCancelMap.size > 0 + }) + } + }, [files, isSaving, uploadingToCancelMap.size, onRegisterUnsavedChangesCheck]) + + // Handle successful operations + useDeepCompareEffect(() => { + // Handle replace file success + if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { + toast.success(t('fileUploader.fileReplacedSuccessfully')) + onFileReplacedSuccess?.(replaceOperationInfo.newFileIdentifier) + } + + // Handle add files success + if (addFilesToDatasetOperationInfo.success) { + toast.success(t('fileUploader.filesAddedToDatasetSuccessfully')) + onFilesAddedSuccess() + } + }, [ + replaceOperationInfo, + addFilesToDatasetOperationInfo, + t, + onFilesAddedSuccess, + onFileReplacedSuccess + ]) + + return ( + + + + {uploadedFiles.length > 0 && ( + + )} + + ) +} diff --git a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx index e24995e0c..30f360963 100644 --- a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx +++ b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx @@ -1,28 +1,24 @@ -import { ChangeEventHandler, DragEventHandler, memo, useRef, useState } from 'react' +import { ChangeEventHandler, DragEventHandler, memo, useCallback, useRef, useState } from 'react' import { Accordion, Button, Card, ProgressBar } from '@iqss/dataverse-design-system' import { ExclamationTriangle, Plus, XLg } from 'react-bootstrap-icons' -import { Trans, useTranslation } from 'react-i18next' -import { Semaphore } from 'async-mutex' +import { useTranslation } from 'react-i18next' import { toast } from 'react-toastify' import cn from 'classnames' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import MimeTypeDisplay from '@/files/domain/models/FileTypeToFriendlyTypeMap' -import { uploadFile } from '@/files/domain/useCases/uploadFile' import { useFileUploaderContext } from '../context/FileUploaderContext' -import { FileUploadState, FileUploadStatus } from '../context/fileUploaderReducer' +import { FileUploadStatus } from '../context/fileUploaderReducer' import { OperationType } from '../FileUploader' import { FileUploaderHelper } from '../FileUploaderHelper' +import { useFileUploadOperations } from '../useFileUploadOperations' import { SwalModal } from '../../swal-modal/SwalModal' +import { UploaderFileRepository } from '../types' import styles from './FileUploadInput.module.scss' type FileUploadInputProps = { - fileRepository: FileRepository + fileRepository: UploaderFileRepository datasetPersistentId: string } -const limit = 6 -const semaphore = new Semaphore(limit) - const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInputProps) => { const { fileUploaderState, @@ -42,6 +38,7 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu const { t } = useTranslation('shared') const inputRef = useRef(null) + const folderInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) @@ -54,81 +51,56 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu const canKeepUploading = operationType === OperationType.ADD_FILES_TO_DATASET ? true : totalFiles === 0 - const onFileUploadFailed = (file: File) => { - removeUploadingToCancel(FileUploaderHelper.getFileKey(file)) - semaphore.release(1) - } - - const onFileUploadFinished = async (file: File) => { - const fileKey = FileUploaderHelper.getFileKey(file) - - try { - const checksumValue = await FileUploaderHelper.getChecksum(file, checksumAlgorithm) - updateFile(fileKey, { checksumValue }) - } finally { - removeUploadingToCancel(fileKey) - semaphore.release(1) - } - } - - const uploadOneFile = async (file: File) => { - if (FileUploaderHelper.isDS_StoreFile(file)) { - toast.info(t('fileUploader.fileUploadSkipped.dsStore')) - return - } - - if ( - operationType === OperationType.REPLACE_FILE && - originalFile.metadata.type.value !== file.type - ) { - const shouldContinue = await requestFileTypeDifferentConfirmation( - originalFile.metadata.type.value, - file.type - ) - - if (!shouldContinue) { - // Reset the file input, otherwise in case user cancels but then tries to upload the same file again, the input will not trigger the change event - if (inputRef.current) { - inputRef.current.value = '' + // File type validation for replace operation + const validateBeforeUpload = useCallback( + async (file: File): Promise => { + if ( + operationType === OperationType.REPLACE_FILE && + originalFile.metadata.type.value !== file.type + ) { + const shouldContinue = await requestFileTypeDifferentConfirmation( + originalFile.metadata.type.value, + file.type + ) + + if (!shouldContinue) { + // Reset the file input + if (inputRef.current) { + inputRef.current.value = '' + } + return false } - // Stop the upload process for this file - return } - } - // File already uploaded - if (getFileByKey(FileUploaderHelper.getFileKey(file))) { - const fileInfo = getFileByKey(FileUploaderHelper.getFileKey(file)) as FileUploadState - toast.info( - t('fileUploader.fileUploadSkipped.alreadyUploaded', { fileName: fileInfo.fileName }) - ) + return true + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- requestFileTypeDifferentConfirmation is stable within the component + [operationType, originalFile] + ) - return + // Use the shared upload operations hook + const { uploadOneFile, handleDroppedItems } = useFileUploadOperations({ + fileRepository, + datasetPersistentId, + checksumAlgorithm, + addFile, + updateFile, + getFileByKey, + addUploadingToCancel, + removeUploadingToCancel, + validateBeforeUpload, + onFileSkipped: (reason, file) => { + if (reason === 'ds_store') { + toast.info(t('fileUploader.fileUploadSkipped.dsStore')) + } else if (reason === 'already_uploaded') { + const fileInfo = getFileByKey(FileUploaderHelper.getFileKey(file)) + if (fileInfo) { + toast.info( + t('fileUploader.fileUploadSkipped.alreadyUploaded', { fileName: fileInfo.fileName }) + ) + } + } } - - await semaphore.acquire(1) - - const fileKey = FileUploaderHelper.getFileKey(file) - - addFile(file) - - const cancelFunction = uploadFile( - fileRepository, - datasetPersistentId, - file, - () => { - updateFile(fileKey, { status: FileUploadStatus.DONE }) - void onFileUploadFinished(file) - }, - () => { - updateFile(fileKey, { status: FileUploadStatus.FAILED }) - onFileUploadFailed(file) - }, - (now) => updateFile(fileKey, { progress: now }), - (storageId) => updateFile(fileKey, { storageId }) - ) - - addUploadingToCancel(fileKey, cancelFunction) - } + }) const handleInputFileChange: ChangeEventHandler = (event) => { const filesArray = Array.from(event.target.files || []) @@ -144,33 +116,18 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu } } - // waiting on the possibility to test folder drop: https://github.com/cypress-io/cypress/issues/19696 - const addFromDir = (dir: FileSystemDirectoryEntry) => { - /* istanbul ignore next */ - const reader = dir.createReader() - - reader.readEntries((entries) => { - entries.forEach((entry) => { - if (entry.isFile) { - const fse = entry as FileSystemFileEntry - fse.file((file) => { - const fileWithPath = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(fileWithPath, 'webkitRelativePath', { - value: entry.fullPath, - writable: true - }) - - void uploadOneFile(fileWithPath) - }) - } else if (entry.isDirectory) { - addFromDir(entry as FileSystemDirectoryEntry) - } - }) - }) + const handleFolderInputChange: ChangeEventHandler = (event) => { + const filesArray = Array.from(event.target.files || []) + + if (filesArray && filesArray.length > 0) { + for (const file of filesArray) { + void uploadOneFile(file) + } + } + + if (folderInputRef.current) { + folderInputRef.current.value = '' + } } const handleDropFiles: DragEventHandler = (event) => { @@ -186,6 +143,7 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu } const droppedItems = event.dataTransfer.items + const droppedFiles = event.dataTransfer.files if (droppedItems.length > 0) { if (operationType === OperationType.REPLACE_FILE && droppedItems.length > 1) { @@ -193,16 +151,7 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu return } - Array.from(droppedItems).forEach((droppedFile) => { - if (droppedFile.webkitGetAsEntry()?.isDirectory) { - addFromDir(droppedFile.webkitGetAsEntry() as FileSystemDirectoryEntry) - } else if (droppedFile.webkitGetAsEntry()?.isFile) { - const fse = droppedFile.webkitGetAsEntry() as FileSystemFileEntry - fse.file((file) => { - void uploadOneFile(file) - }) - } - }) + handleDroppedItems(droppedItems, droppedFiles) } } @@ -247,22 +196,6 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu return (
-

- - ) - }} - /> -

- {t('fileUploader.accordionTitle')} @@ -278,6 +211,15 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu ? t('fileUploader.selectFileMultiple') : t('fileUploader.selectFileSingle')} + {operationType === OperationType.ADD_FILES_TO_DATASET && ( + + )}