From e3ca7df6b30a02cf2cb5fd1f162f72b31291fa15 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 5 Dec 2025 16:02:11 +0100 Subject: [PATCH 001/110] feat: add standalone DVWebloader V2 uploader bundle --- .gitignore | 1 + CHANGELOG.md | 4 +- package-lock.json | 92 ++-- package.json | 3 +- src/files/domain/models/FixityAlgorithm.ts | 1 + src/files/domain/useCases/addUploadedFiles.ts | 8 +- src/files/domain/useCases/replaceFile.ts | 8 +- src/files/domain/useCases/uploadFile.ts | 8 +- src/sections/Route.enum.ts | 4 +- .../edit-file-metadata/EditFileMetadata.tsx | 10 +- .../EditFileMetadataReferrer.ts | 8 + src/sections/replace-file/ReplaceFile.tsx | 10 +- .../replace-file/ReplaceFileReferrer.ts | 8 + .../shared/file-uploader/FileUploader.tsx | 2 +- .../file-uploader/FileUploaderHelper.ts | 5 +- .../file-uploader/FileUploaderPanel.tsx | 96 ++-- .../file-uploader/FileUploaderPanelCore.tsx | 99 +++++ .../file-upload-input/FileUploadInput.tsx | 171 +++---- src/sections/shared/file-uploader/types.ts | 24 + .../uploaded-files-list/UploadedFilesList.tsx | 17 +- .../useAddUploadedFilesToDataset.ts | 22 +- .../file-uploader/useFileUploadOperations.ts | 252 +++++++++++ .../file-uploader/useFileUploadState.ts | 203 +++++++++ .../file-uploader/useGetFixityAlgorithm.tsx | 5 +- .../shared/file-uploader/useReplaceFile.ts | 33 +- .../StandaloneFileRepository.ts | 130 ++++++ .../StandaloneFileUploaderPanel.tsx | 72 +++ src/standalone-uploader/config.ts | 106 +++++ src/standalone-uploader/dvwebloaderV2.html | 13 + src/standalone-uploader/index.tsx | 183 ++++++++ src/standalone-uploader/standalone.scss | 78 ++++ .../UploadedFilesList.stories.tsx | 1 + .../useFileUploadOperations.spec.tsx | 206 +++++++++ .../file-uploader/useFileUploadState.spec.tsx | 420 ++++++++++++++++++ vite.config.uploader.ts | 65 +++ 35 files changed, 2103 insertions(+), 265 deletions(-) create mode 100644 src/sections/edit-file-metadata/EditFileMetadataReferrer.ts create mode 100644 src/sections/replace-file/ReplaceFileReferrer.ts create mode 100644 src/sections/shared/file-uploader/FileUploaderPanelCore.tsx create mode 100644 src/sections/shared/file-uploader/types.ts create mode 100644 src/sections/shared/file-uploader/useFileUploadOperations.ts create mode 100644 src/sections/shared/file-uploader/useFileUploadState.ts create mode 100644 src/standalone-uploader/StandaloneFileRepository.ts create mode 100644 src/standalone-uploader/StandaloneFileUploaderPanel.tsx create mode 100644 src/standalone-uploader/config.ts create mode 100644 src/standalone-uploader/dvwebloaderV2.html create mode 100644 src/standalone-uploader/index.tsx create mode 100644 src/standalone-uploader/standalone.scss create mode 100644 tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx create mode 100644 tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx create mode 100644 vite.config.uploader.ts 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..10412c3e5 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 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/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.tsx b/src/sections/shared/file-uploader/FileUploaderPanel.tsx index 5fbb1986b..2fc94e90a 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanel.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanel.tsx @@ -1,16 +1,18 @@ -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 { 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' interface FileUploaderPanelProps { @@ -24,21 +26,14 @@ const FileUploaderPanel = ({ datasetPersistentId, referrer }: FileUploaderPanelProps) => { - const { t } = useTranslation('shared') const navigate = useNavigate() 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 +42,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 +56,44 @@ 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..5c60bdcf4 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 { 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, @@ -54,81 +50,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,35 +115,6 @@ 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 handleDropFiles: DragEventHandler = (event) => { event.preventDefault() event.stopPropagation() @@ -193,16 +135,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) } } diff --git a/src/sections/shared/file-uploader/types.ts b/src/sections/shared/file-uploader/types.ts new file mode 100644 index 000000000..327186b69 --- /dev/null +++ b/src/sections/shared/file-uploader/types.ts @@ -0,0 +1,24 @@ +/** + * Minimal File Repository Types + * + * These types define the minimal interface needed by the file uploader components. + * This allows the uploader to work with both the full FileRepository (SPA mode) + * and a partial implementation (standalone mode). + */ + +import { FileRepository } from '@/files/domain/repositories/FileRepository' + +/** + * Minimal file repository interface needed by the uploader components. + * Standalone mode only implements these methods. + */ +export type UploaderFileRepository = Pick< + FileRepository, + 'uploadFile' | 'addUploadedFiles' | 'getFixityAlgorithm' +> + +/** + * Extended file repository interface that includes replace functionality. + * Used by components that support file replacement (SPA mode only). + */ +export type FullUploaderFileRepository = UploaderFileRepository & Pick diff --git a/src/sections/shared/file-uploader/uploaded-files-list/UploadedFilesList.tsx b/src/sections/shared/file-uploader/uploaded-files-list/UploadedFilesList.tsx index 8786c6494..5db4b46a8 100644 --- a/src/sections/shared/file-uploader/uploaded-files-list/UploadedFilesList.tsx +++ b/src/sections/shared/file-uploader/uploaded-files-list/UploadedFilesList.tsx @@ -1,6 +1,5 @@ import { KeyboardEvent, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' import { useDeepCompareEffect } from 'use-deep-compare' import { FormProvider, useFieldArray, useForm } from 'react-hook-form' import { @@ -17,9 +16,9 @@ import { useReplaceFile } from '../useReplaceFile' import { useAddUploadedFilesToDataset } from '../useAddUploadedFilesToDataset' import { UploadedFileRow } from './uploaded-file-row/UploadedFileRow' import { useFileUploaderContext } from '../context/FileUploaderContext' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import { FileUploadStatus, UploadedFile } from '../context/fileUploaderReducer' import { OperationType } from '../FileUploader' +import { UploaderFileRepository } from '../types' import styles from './UploadedFilesList.module.scss' export interface FilesListFormData { @@ -27,16 +26,22 @@ export interface FilesListFormData { } interface UploadedFilesListProps { - fileRepository: FileRepository + fileRepository: UploaderFileRepository datasetPersistentId: string + /** + * Cancel handler. Required - typically navigates back. + * In SPA mode: use `() => navigate(-1)` from React Router's useNavigate + * In standalone mode: use `() => window.history.back()` or redirect to dataset + */ + onCancel: () => void } export const UploadedFilesList = ({ fileRepository, - datasetPersistentId + datasetPersistentId, + onCancel }: UploadedFilesListProps) => { const { t } = useTranslation('shared') - const navigate = useNavigate() const { fileUploaderState: { @@ -130,7 +135,7 @@ export const UploadedFilesList = ({ }) } - const handleCancel = () => navigate(-1) + const handleCancel = () => onCancel() const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Enter') return diff --git a/src/sections/shared/file-uploader/useAddUploadedFilesToDataset.ts b/src/sections/shared/file-uploader/useAddUploadedFilesToDataset.ts index 1b1bba053..77092d802 100644 --- a/src/sections/shared/file-uploader/useAddUploadedFilesToDataset.ts +++ b/src/sections/shared/file-uploader/useAddUploadedFilesToDataset.ts @@ -1,19 +1,25 @@ import { toast } from 'react-toastify' import { useTranslation } from 'react-i18next' -import { UploadedFileDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { addUploadedFiles } from '@/files/domain/useCases/addUploadedFiles' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import { UploadedFileDTOMapper } from '@/files/infrastructure/mappers/UploadedFileDTOMapper' import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' import { useFileUploaderContext } from './context/FileUploaderContext' import { UploadedFile } from './context/fileUploaderReducer' +import { UploaderFileRepository } from './types' + +// WriteError type for error handling - avoid importing from client library due to CommonJS issues with local linked package +interface WriteErrorLike { + reason?: string + message?: string +} interface UseAddUploadedFilesToDatasetReturn { submitUploadedFilesToDataset: (uploadedFiles: UploadedFile[]) => Promise } export const useAddUploadedFilesToDataset = ( - fileRepository: FileRepository, + fileRepository: UploaderFileRepository, datasetPersistentId: string ): UseAddUploadedFilesToDatasetReturn => { const { setIsSaving, setAddFilesToDatasetOperationInfo, removeAllFiles } = @@ -42,9 +48,13 @@ export const useAddUploadedFilesToDataset = ( removeAllFiles() setAddFilesToDatasetOperationInfo({ success: true }) - } catch (err: WriteError | unknown) { - if (err instanceof WriteError) { - const error = new JSDataverseWriteErrorHandler(err) + } catch (err: unknown) { + // Check if error has reason property (WriteError-like) + const writeError = err as WriteErrorLike + if (writeError && (writeError.reason || writeError.message)) { + // Cast to any to satisfy JSDataverseWriteErrorHandler which expects WriteError + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const error = new JSDataverseWriteErrorHandler(writeError as any) const formattedError = error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() diff --git a/src/sections/shared/file-uploader/useFileUploadOperations.ts b/src/sections/shared/file-uploader/useFileUploadOperations.ts new file mode 100644 index 000000000..cbd5b8d11 --- /dev/null +++ b/src/sections/shared/file-uploader/useFileUploadOperations.ts @@ -0,0 +1,252 @@ +/** + * useFileUploadOperations - Shared hook for file upload operations + * + * This hook provides the core upload logic (uploading files, handling directories, + * computing checksums) that can be shared between the main SPA and standalone uploader. + */ + +import { useCallback, useRef } from 'react' +import { Semaphore } from 'async-mutex' +import { uploadFile } from '@/files/domain/useCases/uploadFile' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' +import { FileUploaderHelper } from './FileUploaderHelper' +import { FileUploadStatus } from './useFileUploadState' +import { UploaderFileRepository } from './types' + +export const CONCURRENT_UPLOADS_LIMIT = 6 + +export interface FileUploadOperationsConfig { + fileRepository: UploaderFileRepository + datasetPersistentId: string + checksumAlgorithm: FixityAlgorithm + // Callbacks for state updates - compatible with both context and hook-based state + addFile: (file: File) => void + updateFile: ( + key: string, + updates: { + status?: FileUploadStatus + progress?: number + storageId?: string + checksumValue?: string + } + ) => void + getFileByKey: (key: string) => { status: string } | undefined + addUploadingToCancel: (key: string, cancel: () => void) => void + removeUploadingToCancel: (key: string) => void + // Optional callbacks for notifications + onFileSkipped?: (reason: 'ds_store' | 'already_uploaded', file: File) => void + onUploadCanceled?: (fileName: string) => void + // Optional callback for pre-upload validation (e.g., file type check for replace) + validateBeforeUpload?: (file: File) => Promise +} + +export interface FileUploadOperations { + /** Upload a single file */ + uploadOneFile: (file: File) => Promise + /** Recursively upload files from a directory */ + addFromDir: (dir: FileSystemDirectoryEntry) => void + /** Handle dropped items (files or directories) */ + handleDroppedItems: (items: DataTransferItemList) => void + /** Retry a failed upload */ + retryUpload: (file: File) => Promise + /** The semaphore used to limit concurrent uploads */ + semaphore: Semaphore +} + +/** + * Hook that provides file upload operations. + * Manages the upload process, directory traversal, and checksum calculation. + */ +export function useFileUploadOperations(config: FileUploadOperationsConfig): FileUploadOperations { + const { + fileRepository, + datasetPersistentId, + checksumAlgorithm, + addFile, + updateFile, + getFileByKey, + addUploadingToCancel, + removeUploadingToCancel, + onFileSkipped, + validateBeforeUpload + } = config + + // Use a ref to persist semaphore across renders + const semaphoreRef = useRef(new Semaphore(CONCURRENT_UPLOADS_LIMIT)) + + const onFileUploadFailed = useCallback( + (file: File) => { + removeUploadingToCancel(FileUploaderHelper.getFileKey(file)) + semaphoreRef.current.release(1) + }, + [removeUploadingToCancel] + ) + + const onFileUploadFinished = useCallback( + async (file: File) => { + const fileKey = FileUploaderHelper.getFileKey(file) + + try { + // Skip checksum calculation if algorithm is NONE + if (checksumAlgorithm !== FixityAlgorithm.NONE) { + const checksumValue = await FileUploaderHelper.getChecksum(file, checksumAlgorithm) + updateFile(fileKey, { checksumValue }) + } + } finally { + removeUploadingToCancel(fileKey) + semaphoreRef.current.release(1) + } + }, + [checksumAlgorithm, updateFile, removeUploadingToCancel] + ) + + const uploadOneFile = useCallback( + async (file: File) => { + // Skip .DS_Store files + if (FileUploaderHelper.isDS_StoreFile(file)) { + onFileSkipped?.('ds_store', file) + return + } + + // Check if file already uploaded + const fileKey = FileUploaderHelper.getFileKey(file) + if (getFileByKey(fileKey)) { + onFileSkipped?.('already_uploaded', file) + return + } + + // Run optional pre-upload validation + if (validateBeforeUpload) { + const shouldContinue = await validateBeforeUpload(file) + if (!shouldContinue) { + return + } + } + + await semaphoreRef.current.acquire(1) + + 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) + }, + [ + fileRepository, + datasetPersistentId, + addFile, + updateFile, + getFileByKey, + addUploadingToCancel, + onFileUploadFinished, + onFileUploadFailed, + onFileSkipped, + validateBeforeUpload + ] + ) + + const addFromDir = useCallback( + (dir: FileSystemDirectoryEntry) => { + 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) + } + }) + }) + }, + [uploadOneFile] + ) + + const handleDroppedItems = useCallback( + (items: DataTransferItemList) => { + Array.from(items).forEach((item) => { + const entry = item.webkitGetAsEntry() + if (entry?.isDirectory) { + addFromDir(entry as FileSystemDirectoryEntry) + } else if (entry?.isFile) { + const fse = entry as FileSystemFileEntry + fse.file((file) => { + void uploadOneFile(file) + }) + } + }) + }, + [addFromDir, uploadOneFile] + ) + + const retryUpload = useCallback( + async (file: File) => { + const fileKey = FileUploaderHelper.getFileKey(file) + // Reset status to uploading before retry + updateFile(fileKey, { status: FileUploadStatus.UPLOADING, progress: 0 }) + + await semaphoreRef.current.acquire(1) + + 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) + }, + [ + fileRepository, + datasetPersistentId, + updateFile, + addUploadingToCancel, + onFileUploadFinished, + onFileUploadFailed + ] + ) + + return { + uploadOneFile, + addFromDir, + handleDroppedItems, + retryUpload, + semaphore: semaphoreRef.current + } +} diff --git a/src/sections/shared/file-uploader/useFileUploadState.ts b/src/sections/shared/file-uploader/useFileUploadState.ts new file mode 100644 index 000000000..3031db361 --- /dev/null +++ b/src/sections/shared/file-uploader/useFileUploadState.ts @@ -0,0 +1,203 @@ +/** + * useFileUploadState - Shared hook for managing file upload state + * + * This hook provides the core state management logic for file uploads, + * usable by both the main SPA (via context) and the standalone uploader. + */ + +import { useState, useMemo, useCallback } from 'react' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' +import { FileUploaderHelper } from './FileUploaderHelper' + +// Re-export from reducer for backward compatibility, but define our own minimal enum +// for the standalone uploader that doesn't need the REMOVED status +export enum FileUploadStatus { + UPLOADING = 'uploading', + DONE = 'done', + FAILED = 'failed' +} + +export interface FileUploadState { + key: string + progress: number + status: FileUploadStatus + fileName: string + fileDir: string + fileType: string + fileSizeString: string + fileSize: number + fileLastModified: number + description: string + tags: string[] + restricted: boolean + storageId?: string + checksumValue?: string + checksumAlgorithm: FixityAlgorithm +} + +export type UploadedFile = FileUploadState & { storageId: string; checksumValue: string } + +/** + * Interface for the file upload state and actions. + * This is what the useFileUploadState hook returns. + */ +export interface FileUploadStateActions { + /** All files keyed by their unique file key */ + files: Record + /** Files that have completed upload and have both storageId and checksumValue */ + uploadedFiles: UploadedFile[] + /** Files that are still uploading or have failed */ + uploadingFilesInProgress: FileUploadState[] + /** True if any file is currently uploading */ + anyFileUploading: boolean + /** Map of file keys to cancel functions for in-progress uploads */ + uploadingToCancelMap: Map void> + /** True while saving files to the dataset */ + isSaving: boolean + setIsSaving: (isSaving: boolean) => void + addFile: ( + file: File, + checksumAlgorithm: FixityAlgorithm, + defaults?: Partial + ) => void + updateFile: (key: string, updates: Partial) => void + removeFile: (key: string) => void + removeAllFiles: () => void + getFileByKey: (key: string) => FileUploadState | undefined + addUploadingToCancel: (key: string, cancel: () => void) => void + removeUploadingToCancel: (key: string) => void + /** Reset all state to initial values */ + reset: () => void +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` +} + +/** + * Hook that manages file upload state. + * Can be used standalone or integrated with React Context. + */ +export function useFileUploadState(): FileUploadStateActions { + const [files, setFiles] = useState>({}) + const [uploadingToCancelMap, setUploadingToCancelMap] = useState void>>( + new Map() + ) + const [isSaving, setIsSaving] = useState(false) + + // Computed values + const uploadedFiles = useMemo(() => { + return Object.values(files).filter( + (f): f is UploadedFile => + f.status === FileUploadStatus.DONE && !!f.storageId && !!f.checksumValue + ) + }, [files]) + + const uploadingFilesInProgress = useMemo(() => { + return Object.values(files).filter((file) => file.status !== FileUploadStatus.DONE) + }, [files]) + + const anyFileUploading = useMemo(() => { + return Object.values(files).some((file) => file.status === FileUploadStatus.UPLOADING) + }, [files]) + + // Actions + const addFile = useCallback( + (file: File, checksumAlgorithm: FixityAlgorithm, defaults?: Partial) => { + const fileKey = FileUploaderHelper.getFileKey(file) + const fileDir = file.webkitRelativePath + ? file.webkitRelativePath.substring(0, file.webkitRelativePath.lastIndexOf('/')) + : '' + + setFiles((prev) => { + if (prev[fileKey]) return prev // Already exists + + return { + ...prev, + [fileKey]: { + key: fileKey, + progress: 0, + status: FileUploadStatus.UPLOADING, + fileName: file.name, + fileDir: defaults?.fileDir ?? fileDir, + fileType: file.type, + fileSizeString: formatFileSize(file.size), + fileSize: file.size, + fileLastModified: file.lastModified, + description: defaults?.description ?? '', + tags: defaults?.tags ?? [], + restricted: defaults?.restricted ?? false, + checksumAlgorithm + } + } + }) + }, + [] + ) + + const updateFile = useCallback((key: string, updates: Partial) => { + setFiles((prev) => { + if (!prev[key]) return prev + return { + ...prev, + [key]: { ...prev[key], ...updates } + } + }) + }, []) + + const removeFile = useCallback((key: string) => { + setFiles((prev) => { + const newFiles = { ...prev } + delete newFiles[key] + return newFiles + }) + }, []) + + const removeAllFiles = useCallback(() => { + setFiles({}) + }, []) + + const getFileByKey = useCallback((key: string) => files[key], [files]) + + const addUploadingToCancel = useCallback((key: string, cancel: () => void) => { + setUploadingToCancelMap((prev) => new Map(prev).set(key, cancel)) + }, []) + + const removeUploadingToCancel = useCallback((key: string) => { + setUploadingToCancelMap((prev) => { + const newMap = new Map(prev) + newMap.delete(key) + return newMap + }) + }, []) + + const reset = useCallback(() => { + // Cancel all in-progress uploads before resetting + uploadingToCancelMap.forEach((cancel) => cancel()) + setFiles({}) + setUploadingToCancelMap(new Map()) + setIsSaving(false) + }, [uploadingToCancelMap]) + + return { + files, + uploadedFiles, + uploadingFilesInProgress, + anyFileUploading, + uploadingToCancelMap, + isSaving, + setIsSaving, + addFile, + updateFile, + removeFile, + removeAllFiles, + getFileByKey, + addUploadingToCancel, + removeUploadingToCancel, + reset + } +} diff --git a/src/sections/shared/file-uploader/useGetFixityAlgorithm.tsx b/src/sections/shared/file-uploader/useGetFixityAlgorithm.tsx index ad283e81a..f729b0086 100644 --- a/src/sections/shared/file-uploader/useGetFixityAlgorithm.tsx +++ b/src/sections/shared/file-uploader/useGetFixityAlgorithm.tsx @@ -2,7 +2,10 @@ import { useEffect, useState } from 'react' import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' import { FileRepository } from '@/files/domain/repositories/FileRepository' -export const useGetFixityAlgorithm = (fileRepository: FileRepository) => { +/** Minimal interface for fixity algorithm fetching */ +type FixityAlgorithmProvider = Pick + +export const useGetFixityAlgorithm = (fileRepository: FixityAlgorithmProvider) => { const [fixityAlgorithm, setFixityAlgorithm] = useState(FixityAlgorithm.MD5) const [isLoadingFixityAlgorithm, setIsLoadingFixityAlgorithm] = useState(true) const [errorLoadingFixityAlgorithm, setErrorLoadingFixityAlgorithm] = useState(false) diff --git a/src/sections/shared/file-uploader/useReplaceFile.ts b/src/sections/shared/file-uploader/useReplaceFile.ts index 5b4f7357c..6291907a8 100644 --- a/src/sections/shared/file-uploader/useReplaceFile.ts +++ b/src/sections/shared/file-uploader/useReplaceFile.ts @@ -1,22 +1,39 @@ import { toast } from 'react-toastify' import { useTranslation } from 'react-i18next' -import { UploadedFileDTO, WriteError } from '@iqss/dataverse-client-javascript' +import { UploadedFileDTO } from '@iqss/dataverse-client-javascript' import { replaceFile } from '@/files/domain/useCases/replaceFile' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import { UploadedFileDTOMapper } from '@/files/infrastructure/mappers/UploadedFileDTOMapper' import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' import { useFileUploaderContext } from './context/FileUploaderContext' import { UploadedFile } from './context/fileUploaderReducer' +import { UploaderFileRepository, FullUploaderFileRepository } from './types' + +// WriteError type for error handling - avoid importing from client library due to CommonJS issues +interface WriteErrorLike { + reason?: string + message?: string +} interface UseReplaceFileReturn { submitReplaceFile: (originalFileID: number, file: UploadedFile) => Promise } -export const useReplaceFile = (fileRepository: FileRepository): UseReplaceFileReturn => { +/** Type guard to check if repository supports replace */ +function hasReplaceMethod(repo: UploaderFileRepository): repo is FullUploaderFileRepository { + return 'replace' in repo && typeof repo.replace === 'function' +} + +export const useReplaceFile = (fileRepository: UploaderFileRepository): UseReplaceFileReturn => { const { setIsSaving, setReplaceOperationInfo, removeAllFiles } = useFileUploaderContext() const { t } = useTranslation('shared') const submitReplaceFile = async (originalFileID: number, newFileInfo: UploadedFile) => { + // Check if replace is supported + if (!hasReplaceMethod(fileRepository)) { + toast.error('File replacement is not supported in standalone mode') + return + } + setIsSaving(true) const newFileDTO: UploadedFileDTO = UploadedFileDTOMapper.toUploadedFileDTO( @@ -37,9 +54,13 @@ export const useReplaceFile = (fileRepository: FileRepository): UseReplaceFileRe removeAllFiles() setReplaceOperationInfo({ success: true, newFileIdentifier }) - } catch (err: WriteError | unknown) { - if (err instanceof WriteError) { - const error = new JSDataverseWriteErrorHandler(err) + } catch (err: unknown) { + // Check if error has reason property (WriteError-like) + const writeError = err as WriteErrorLike + if (writeError && (writeError.reason || writeError.message)) { + // Cast to any to satisfy JSDataverseWriteErrorHandler which expects WriteError + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const error = new JSDataverseWriteErrorHandler(writeError as any) const formattedError = error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() diff --git a/src/standalone-uploader/StandaloneFileRepository.ts b/src/standalone-uploader/StandaloneFileRepository.ts new file mode 100644 index 000000000..e1976d891 --- /dev/null +++ b/src/standalone-uploader/StandaloneFileRepository.ts @@ -0,0 +1,130 @@ +/** + * Standalone File Repository + * + * A simplified file repository for the standalone uploader that doesn't depend on + * the main app's config.js. It only implements the methods needed for uploading files. + */ + +import { + uploadFile as jsUploadFile, + addUploadedFilesToDataset, + UploadedFileDTO +} from '@iqss/dataverse-client-javascript' +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' + +export class StandaloneFileRepository + implements Pick +{ + private siteUrl: string + + constructor(siteUrl: string) { + this.siteUrl = siteUrl + } + + async uploadFile( + datasetId: string | number, + fileHolder: { file: File }, + progress: (now: number) => void, + abortController: AbortController, + getStorageId: (storageId: string) => void + ): Promise { + const storageId = await jsUploadFile.execute( + datasetId, + fileHolder.file, + progress, + abortController + ) + getStorageId(storageId) + } + + async addUploadedFiles( + datasetId: string | number, + uploadedFileDTOs: UploadedFileDTO[] + ): Promise { + await addUploadedFilesToDataset.execute(datasetId, uploadedFileDTOs) + } + + async getFixityAlgorithm(): Promise { + try { + const response = await fetch(`${this.siteUrl}/api/files/fixityAlgorithm`) + if (!response.ok) { + console.warn('Could not fetch fixity algorithm, defaulting to MD5') + return FixityAlgorithm.MD5 + } + const data = (await response.json()) as { data?: { message?: string } } + const algorithm: string = data?.data?.message || 'MD5' + + // Map the string to FixityAlgorithm enum + switch (algorithm.toUpperCase()) { + case 'MD5': + return FixityAlgorithm.MD5 + case 'SHA-1': + case 'SHA1': + return FixityAlgorithm.SHA1 + case 'SHA-256': + case 'SHA256': + return FixityAlgorithm.SHA256 + case 'SHA-512': + case 'SHA512': + return FixityAlgorithm.SHA512 + default: + return FixityAlgorithm.MD5 + } + } catch (error) { + console.warn('Error fetching fixity algorithm:', error) + return FixityAlgorithm.MD5 + } + } + + // These methods are not used by the standalone uploader but are required by the interface + // They will throw if called + getById(): Promise { + throw new Error('Not implemented in standalone mode') + } + getByDatasetPersistentId(): Promise { + throw new Error('Not implemented in standalone mode') + } + getByDatasetPersistentIdAndVersion(): Promise { + throw new Error('Not implemented in standalone mode') + } + getFilesCountInfoByDatasetPersistentId(): Promise { + throw new Error('Not implemented in standalone mode') + } + getFilesTotalDownloadSizeByDatasetPersistentId(): Promise { + throw new Error('Not implemented in standalone mode') + } + getMultipleFileDownloadUrl(): never { + throw new Error('Not implemented in standalone mode') + } + getUserPermissionsById(): Promise { + throw new Error('Not implemented in standalone mode') + } + getDataTablesById(): Promise { + throw new Error('Not implemented in standalone mode') + } + getFileCitation(): Promise { + throw new Error('Not implemented in standalone mode') + } + deleteFile(): Promise { + throw new Error('Not implemented in standalone mode') + } + replaceFile(): Promise { + throw new Error('Not implemented in standalone mode') + } + restrictFile(): Promise { + throw new Error('Not implemented in standalone mode') + } + updateMetadata(): Promise { + throw new Error('Not implemented in standalone mode') + } + getVersionSummaries(): Promise { + throw new Error('Not implemented in standalone mode') + } + updateTabularTags(): Promise { + throw new Error('Not implemented in standalone mode') + } + updateFileCategories(): Promise { + throw new Error('Not implemented in standalone mode') + } +} diff --git a/src/standalone-uploader/StandaloneFileUploaderPanel.tsx b/src/standalone-uploader/StandaloneFileUploaderPanel.tsx new file mode 100644 index 000000000..862184587 --- /dev/null +++ b/src/standalone-uploader/StandaloneFileUploaderPanel.tsx @@ -0,0 +1,72 @@ +/** + * Standalone File Uploader Panel + * + * A thin wrapper around FileUploaderPanelCore for standalone mode (DVWebloader V2). + * Handles standalone-specific concerns: beforeunload warning, redirect to JSF pages. + */ + +import { useEffect, useCallback } from 'react' +import { UploaderFileRepository } from '@/sections/shared/file-uploader/types' +import { useFileUploaderContext } from '@/sections/shared/file-uploader/context/FileUploaderContext' +import { FileUploaderPanelCore } from '@/sections/shared/file-uploader/FileUploaderPanelCore' + +interface StandaloneFileUploaderPanelProps { + fileRepository: UploaderFileRepository + datasetPersistentId: string + siteUrl: string +} + +export const StandaloneFileUploaderPanel = ({ + fileRepository, + datasetPersistentId, + siteUrl +}: StandaloneFileUploaderPanelProps) => { + const { + fileUploaderState: { files, isSaving, uploadingToCancelMap } + } = useFileUploaderContext() + + // Warn before leaving page if there are unsaved changes + useEffect(() => { + const hasUnsavedChanges = + Object.keys(files).length > 0 || isSaving || uploadingToCancelMap.size > 0 + + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault() + e.returnValue = '' + return '' + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [files, isSaving, uploadingToCancelMap.size]) + + // Build the dataset page URL (JSF page) + const getDatasetUrl = useCallback(() => { + return `${siteUrl}/dataset.xhtml?persistentId=${encodeURIComponent( + datasetPersistentId + )}&version=DRAFT` + }, [siteUrl, datasetPersistentId]) + + // Navigation callbacks + const handleCancel = useCallback(() => { + window.location.href = getDatasetUrl() + }, [getDatasetUrl]) + + const handleFilesAddedSuccess = useCallback(() => { + // Small delay to let toast show before redirect + setTimeout(() => { + window.location.href = getDatasetUrl() + }, 1500) + }, [getDatasetUrl]) + + return ( + + ) +} diff --git a/src/standalone-uploader/config.ts b/src/standalone-uploader/config.ts new file mode 100644 index 000000000..4cf403082 --- /dev/null +++ b/src/standalone-uploader/config.ts @@ -0,0 +1,106 @@ +/** + * Standalone Uploader Configuration + * + * Parses URL parameters for the standalone file uploader. + * Compatible with DVWebloader v1 URL params: + * - siteUrl: Base URL of the Dataverse instance + * - datasetPid: Persistent ID of the dataset + * - key: API key for authentication + * - dvLocale: Optional locale code (e.g., 'en', 'de') + * - useS3Tagging: Optional, set to 'false' to disable S3 tagging (for S3-compatible storage that doesn't support tagging) + * - maxRetries: Optional, maximum number of retries for multipart upload parts (default: 3) + * - uploadTimeoutMs: Optional, timeout in milliseconds for file upload operations (default: 0 = unlimited) + * - disableMD5Checksum: Optional, set to 'true' to disable MD5 checksum calculation + */ + +export interface StandaloneUploaderConfig { + siteUrl: string + datasetPid: string + apiKey: string + dvLocale: string + /** Whether to use S3 object tagging. Set to false for S3-compatible storage that doesn't support tagging. Default: true */ + useS3Tagging: boolean + /** Maximum number of retries for multipart upload parts. Default: 3 */ + maxRetries: number + /** Timeout in milliseconds for file upload operations. 0 means unlimited. Default: 0 (unlimited) */ + uploadTimeoutMs: number + /** Whether to disable MD5 checksum calculation. Default: false */ + disableMD5Checksum: boolean +} + +export interface ConfigResult { + ok: true + config: StandaloneUploaderConfig +} + +export interface ConfigError { + ok: false + error: string + missingParams: string[] +} + +export type ConfigParseResult = ConfigResult | ConfigError + +/** + * Parse URL parameters and return configuration for the standalone uploader. + */ +export function parseUrlConfig(): ConfigParseResult { + const queryParams = new URLSearchParams(window.location.search) + + const siteUrl = queryParams.get('siteUrl') + const datasetPid = queryParams.get('datasetPid') + const apiKey = queryParams.get('key') + const dvLocale = queryParams.get('dvLocale') || 'en' + + // Parse useS3Tagging - default to true (enabled), only false if explicitly set to 'false' + const useS3TaggingParam = queryParams.get('useS3Tagging') + const useS3Tagging = useS3TaggingParam !== 'false' + + // Parse maxRetries - default to 3 + const maxRetriesParam = queryParams.get('maxRetries') + const maxRetries = maxRetriesParam ? parseInt(maxRetriesParam, 10) : 3 + + // Parse uploadTimeoutMs - default to 0 (unlimited) + const uploadTimeoutMsParam = queryParams.get('uploadTimeoutMs') + const uploadTimeoutMs = uploadTimeoutMsParam ? parseInt(uploadTimeoutMsParam, 10) : 0 + + // Parse disableMD5Checksum - default to false + const disableMD5ChecksumParam = queryParams.get('disableMD5Checksum') + const disableMD5Checksum = disableMD5ChecksumParam === 'true' + + const missingParams: string[] = [] + + if (!siteUrl) missingParams.push('siteUrl') + if (!datasetPid) missingParams.push('datasetPid') + if (!apiKey) missingParams.push('key') + + if (missingParams.length > 0) { + return { + ok: false, + error: `Missing required URL parameters: ${missingParams.join(', ')}`, + missingParams + } + } + + return { + ok: true, + config: { + siteUrl: siteUrl as string, + datasetPid: datasetPid as string, + apiKey: apiKey as string, + dvLocale, + useS3Tagging, + maxRetries, + uploadTimeoutMs, + disableMD5Checksum + } + } +} + +/** + * Extract the dataset ID from either a persistent ID or numeric ID. + * The API accepts both formats. + */ +export function getDatasetIdentifier(datasetPid: string): string { + return datasetPid +} diff --git a/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html new file mode 100644 index 000000000..a6efcb5c2 --- /dev/null +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -0,0 +1,13 @@ + + + + + + Dataverse WebLoader V2 + + + + +
+ + diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx new file mode 100644 index 000000000..37f4a398f --- /dev/null +++ b/src/standalone-uploader/index.tsx @@ -0,0 +1,183 @@ +/** + * Standalone Uploader Entry Point + * + * This is the entry point for the standalone DVWebloader V2 bundle. + * It initializes React, i18n, and the API client, then mounts the uploader. + * + * This standalone version reuses the SPA's FileUploader components to avoid code duplication. + */ + +import { createRoot } from 'react-dom/client' +import { StrictMode } from 'react' +import i18next from 'i18next' +import { initReactI18next } from 'react-i18next' +import I18NextHttpBackend from 'i18next-http-backend' +import { ApiConfig, FilesConfig } from '@iqss/dataverse-client-javascript' +import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dist/core/infra/repositories/ApiConfig' +import { ToastContainer } from 'react-toastify' +import { parseUrlConfig } from './config' +import { StandaloneFileUploaderPanel } from './StandaloneFileUploaderPanel' +import { StandaloneFileRepository } from './StandaloneFileRepository' +import { FileUploaderProvider } from '@/sections/shared/file-uploader/context/FileUploaderContext' +import { FileUploaderGlobalConfig } from '@/sections/shared/file-uploader/context/fileUploaderReducer' +import { OperationType, StorageType } from '@/sections/shared/file-uploader/FileUploader' +import { LoadingConfigSpinner } from '@/sections/shared/file-uploader/loading-config-spinner/LoadingConfigSpinner' +import { useGetFixityAlgorithm } from '@/sections/shared/file-uploader/useGetFixityAlgorithm' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' + +// Import design system styles - use relative path for build compatibility +import '../../packages/design-system/dist/style.css' +import 'bootstrap/dist/css/bootstrap.min.css' +import 'react-toastify/dist/ReactToastify.css' +import './standalone.scss' + +// Error display component +function ConfigErrorDisplay({ error, missingParams }: { error: string; missingParams: string[] }) { + return ( +
+

Configuration Error

+

{error}

+ {missingParams.length > 0 && ( +
+

Expected URL format:

+ + ?siteUrl=https://your-dataverse.edu&datasetPid=doi:10.5072/FK2/XXXXX&key=your-api-key + +
+ )} +
+ ) +} + +// Standalone uploader wrapper that loads fixity algorithm and provides context +interface StandaloneUploaderWrapperProps { + fileRepository: StandaloneFileRepository + datasetPersistentId: string + siteUrl: string + disableMD5Checksum?: boolean +} + +function StandaloneUploaderWrapper({ + fileRepository, + datasetPersistentId, + siteUrl, + disableMD5Checksum +}: StandaloneUploaderWrapperProps) { + const { fixityAlgorithm: fetchedAlgorithm, isLoadingFixityAlgorithm } = + useGetFixityAlgorithm(fileRepository) + + // If checksum is disabled, use NONE. Otherwise use the fetched algorithm. + const fixityAlgorithm = disableMD5Checksum ? FixityAlgorithm.NONE : fetchedAlgorithm + + if (isLoadingFixityAlgorithm) { + return + } + + const initialConfig: FileUploaderGlobalConfig = { + storageType: 'S3' as StorageType, + operationType: OperationType.ADD_FILES_TO_DATASET, + checksumAlgorithm: fixityAlgorithm + } + + return ( + + + + ) +} + +// Initialize the application +async function init() { + const container = document.getElementById('root') + if (!container) { + console.error('Root element not found') + return + } + + const root = createRoot(container) + + // Parse URL configuration + const configResult = parseUrlConfig() + + if (!configResult.ok) { + root.render( + + + + ) + return + } + + const config = configResult.config + + // Initialize the API client with API key authentication + ApiConfig.init(`${config.siteUrl}/api/v1`, DataverseApiAuthMechanism.API_KEY, config.apiKey) + + // Configure file upload settings + // These are critical for S3-compatible storage that may not support all S3 features + FilesConfig.init({ + // useS3Tagging: Set to false for MinIO/S3-compatible storage without tagging support + useS3Tagging: config.useS3Tagging, + // maxMultipartRetries: Number of retry attempts for multipart upload failures + maxMultipartRetries: config.maxRetries, + // fileUploadTimeoutMs: Timeout for upload operations (0 = use axios default) + fileUploadTimeoutMs: config.uploadTimeoutMs || undefined + }) + + // Determine the base path for loading translation files + // In standalone mode, translations are bundled or loaded from the same origin + const basePath = window.location.pathname.substring( + 0, + window.location.pathname.lastIndexOf('/') + 1 + ) + + // Initialize i18next for translations + await i18next + .use(initReactI18next) + .use(I18NextHttpBackend) + .init({ + lng: config.dvLocale || 'en', + fallbackLng: 'en', + supportedLngs: ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'uk'], + lowerCaseLng: true, + ns: ['shared'], + defaultNS: 'shared', + returnNull: false, + backend: { + loadPath: `${basePath}locales/{{lng}}/{{ns}}.json` + } + }) + + // Create standalone file repository (doesn't depend on config.js) + const fileRepository = new StandaloneFileRepository(config.siteUrl) + + // Render the uploader with context provider + root.render( + +
+ +
+

Upload Files

+

+ Uploading to dataset: {config.datasetPid} +

+
+ +
+
+ ) +} + +// Start the application +init().catch((error) => { + console.error('Failed to initialize standalone uploader:', error) +}) diff --git a/src/standalone-uploader/standalone.scss b/src/standalone-uploader/standalone.scss new file mode 100644 index 000000000..8f350468c --- /dev/null +++ b/src/standalone-uploader/standalone.scss @@ -0,0 +1,78 @@ +/** + * Standalone Uploader Base Styles + */ + +// Reset and base styles for standalone mode +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: #f5f5f5; + min-height: 100vh; +} + +#root { + min-height: 100vh; +} + +.standalone-uploader-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.standalone-uploader-header { + margin-bottom: 2rem; + + h1 { + margin: 0 0 0.5rem 0; + font-size: 1.75rem; + color: #333; + } +} + +.standalone-uploader-dataset-info { + margin: 0; + color: #666; + + code { + background-color: #f8f9fa; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: monospace; + font-size: 0.9rem; + } +} + +.standalone-error { + max-width: 600px; + margin: 2rem auto; + padding: 2rem; + background-color: #fff5f5; + border: 1px solid #dc3545; + border-radius: 8px; + text-align: center; + + h1 { + color: #dc3545; + margin-bottom: 1rem; + font-size: 1.5rem; + } + + p { + color: #721c24; + margin-bottom: 1rem; + } + + code { + display: block; + background-color: #f8f9fa; + padding: 1rem; + border-radius: 4px; + font-family: monospace; + text-align: left; + overflow-x: auto; + font-size: 0.85rem; + word-break: break-all; + } +} diff --git a/src/stories/shared/file-uploader/UploadedFilesList.stories.tsx b/src/stories/shared/file-uploader/UploadedFilesList.stories.tsx index 5c95ed48d..5dd0d7cf3 100644 --- a/src/stories/shared/file-uploader/UploadedFilesList.stories.tsx +++ b/src/stories/shared/file-uploader/UploadedFilesList.stories.tsx @@ -69,6 +69,7 @@ export const Default: Story = { console.log('Cancel clicked')} /> ) diff --git a/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx b/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx new file mode 100644 index 000000000..f956e564b --- /dev/null +++ b/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx @@ -0,0 +1,206 @@ +import { act, renderHook } from '@testing-library/react' +import { + useFileUploadOperations, + FileUploadOperationsConfig, + CONCURRENT_UPLOADS_LIMIT +} from '@/sections/shared/file-uploader/useFileUploadOperations' +import { FileUploadStatus } from '@/sections/shared/file-uploader/useFileUploadState' +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' +import { FileMockRepository } from '@/stories/file/FileMockRepository' + +describe('useFileUploadOperations', () => { + const createMockFile = (name: string, size = 1024): File => { + const content = new Array(size).fill('x').join('') + return new File([content], name, { type: 'text/plain', lastModified: Date.now() }) + } + + const createConfig = ( + overrides: Partial = {} + ): FileUploadOperationsConfig => ({ + fileRepository: new FileMockRepository() as unknown as FileRepository, + datasetPersistentId: 'doi:10.5072/FK2/TEST', + checksumAlgorithm: FixityAlgorithm.MD5, + addFile: cy.stub(), + updateFile: cy.stub(), + getFileByKey: cy.stub().returns(undefined), + addUploadingToCancel: cy.stub(), + removeUploadingToCancel: cy.stub(), + ...overrides + }) + + describe('constants', () => { + it('should have CONCURRENT_UPLOADS_LIMIT defined', () => { + expect(CONCURRENT_UPLOADS_LIMIT).to.equal(6) + }) + }) + + describe('uploadOneFile', () => { + it('should skip .DS_Store files', async () => { + const onFileSkipped = cy.stub() + const addFile = cy.stub() + const config = createConfig({ onFileSkipped, addFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const dsStoreFile = createMockFile('.DS_Store') + + await act(async () => { + await result.current.uploadOneFile(dsStoreFile) + }) + + expect(onFileSkipped).to.have.been.calledWith('ds_store', dsStoreFile) + expect(addFile).to.not.have.been.called + }) + + it('should skip already uploaded files', async () => { + const onFileSkipped = cy.stub() + const addFile = cy.stub() + const getFileByKey = cy.stub().returns({ status: FileUploadStatus.DONE }) + const config = createConfig({ onFileSkipped, addFile, getFileByKey }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.uploadOneFile(mockFile) + }) + + expect(onFileSkipped).to.have.been.calledWith('already_uploaded', mockFile) + expect(addFile).to.not.have.been.called + }) + + it('should call addFile for new files', async () => { + const addFile = cy.stub() + const config = createConfig({ addFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.uploadOneFile(mockFile) + }) + + expect(addFile).to.have.been.calledWith(mockFile) + }) + + it('should call addUploadingToCancel with cancel function', async () => { + const addUploadingToCancel = cy.stub() + const config = createConfig({ addUploadingToCancel }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.uploadOneFile(mockFile) + }) + + expect(addUploadingToCancel).to.have.been.called + const [key, cancelFn] = addUploadingToCancel.firstCall.args + expect(key).to.be.a('string') + expect(cancelFn).to.be.a('function') + }) + + it('should run validateBeforeUpload if provided', async () => { + const validateBeforeUpload = cy.stub().resolves(true) + const addFile = cy.stub() + const config = createConfig({ validateBeforeUpload, addFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.uploadOneFile(mockFile) + }) + + expect(validateBeforeUpload).to.have.been.calledWith(mockFile) + expect(addFile).to.have.been.called + }) + + it('should not upload if validateBeforeUpload returns false', async () => { + const validateBeforeUpload = cy.stub().resolves(false) + const addFile = cy.stub() + const config = createConfig({ validateBeforeUpload, addFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.uploadOneFile(mockFile) + }) + + expect(validateBeforeUpload).to.have.been.calledWith(mockFile) + expect(addFile).to.not.have.been.called + }) + }) + + describe('semaphore', () => { + it('should return a semaphore for concurrent upload control', () => { + const config = createConfig() + const { result } = renderHook(() => useFileUploadOperations(config)) + + expect(result.current.semaphore).to.exist + expect(result.current.semaphore.acquire).to.be.a('function') + expect(result.current.semaphore.release).to.be.a('function') + }) + }) + + describe('retryUpload', () => { + it('should reset file status to uploading before retry', async () => { + const updateFile = cy.stub() + const config = createConfig({ updateFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.retryUpload(mockFile) + }) + + // First call should be to reset status + expect(updateFile.firstCall.args[1]).to.deep.include({ + status: FileUploadStatus.UPLOADING, + progress: 0 + }) + }) + + it('should register cancel function for retry', async () => { + const addUploadingToCancel = cy.stub() + const config = createConfig({ addUploadingToCancel }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const mockFile = createMockFile('test.txt') + + await act(async () => { + await result.current.retryUpload(mockFile) + }) + + expect(addUploadingToCancel).to.have.been.called + }) + }) + + describe('handleDroppedItems', () => { + it('should be a function', () => { + const config = createConfig() + const { result } = renderHook(() => useFileUploadOperations(config)) + + expect(result.current.handleDroppedItems).to.be.a('function') + }) + }) + + describe('addFromDir', () => { + it('should be a function', () => { + const config = createConfig() + const { result } = renderHook(() => useFileUploadOperations(config)) + + expect(result.current.addFromDir).to.be.a('function') + }) + }) +}) diff --git a/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx b/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx new file mode 100644 index 000000000..e7fd4fc42 --- /dev/null +++ b/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx @@ -0,0 +1,420 @@ +import { act, renderHook } from '@testing-library/react' +import { + useFileUploadState, + FileUploadStatus, + formatFileSize +} from '@/sections/shared/file-uploader/useFileUploadState' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' + +describe('useFileUploadState', () => { + const createMockFile = (name: string, size = 1024): File => { + const content = new Array(size).fill('x').join('') + return new File([content], name, { type: 'text/plain', lastModified: Date.now() }) + } + + describe('formatFileSize', () => { + it('should format 0 bytes correctly', () => { + expect(formatFileSize(0)).to.equal('0 Bytes') + }) + + it('should format bytes correctly', () => { + expect(formatFileSize(500)).to.equal('500 Bytes') + }) + + it('should format KB correctly', () => { + expect(formatFileSize(1024)).to.equal('1 KB') + expect(formatFileSize(2048)).to.equal('2 KB') + }) + + it('should format MB correctly', () => { + expect(formatFileSize(1024 * 1024)).to.equal('1 MB') + expect(formatFileSize(1.5 * 1024 * 1024)).to.equal('1.5 MB') + }) + + it('should format GB correctly', () => { + expect(formatFileSize(1024 * 1024 * 1024)).to.equal('1 GB') + }) + + it('should format TB correctly', () => { + expect(formatFileSize(1024 * 1024 * 1024 * 1024)).to.equal('1 TB') + }) + }) + + describe('initial state', () => { + it('should return empty files initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.files).to.deep.equal({}) + }) + + it('should return empty uploadedFiles initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.uploadedFiles).to.deep.equal([]) + }) + + it('should return empty uploadingFilesInProgress initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.uploadingFilesInProgress).to.deep.equal([]) + }) + + it('should return false for anyFileUploading initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.anyFileUploading).to.equal(false) + }) + + it('should return empty uploadingToCancelMap initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.uploadingToCancelMap.size).to.equal(0) + }) + + it('should return false for isSaving initially', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.isSaving).to.equal(false) + }) + }) + + describe('addFile', () => { + it('should add a file to the state', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const filesKeys = Object.keys(result.current.files) + expect(filesKeys).to.have.length(1) + + const addedFile = Object.values(result.current.files)[0] + expect(addedFile.fileName).to.equal('test.txt') + expect(addedFile.status).to.equal(FileUploadStatus.UPLOADING) + expect(addedFile.progress).to.equal(0) + expect(addedFile.checksumAlgorithm).to.equal(FixityAlgorithm.MD5) + }) + + it('should set anyFileUploading to true after adding a file', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + expect(result.current.anyFileUploading).to.equal(true) + }) + + it('should not add duplicate files with the same key', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const filesKeys = Object.keys(result.current.files) + expect(filesKeys).to.have.length(1) + }) + + it('should use custom defaults when provided', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5, { + description: 'Custom description', + tags: ['tag1', 'tag2'], + restricted: true, + fileDir: 'custom/path' + }) + }) + + const addedFile = Object.values(result.current.files)[0] + expect(addedFile.description).to.equal('Custom description') + expect(addedFile.tags).to.deep.equal(['tag1', 'tag2']) + expect(addedFile.restricted).to.equal(true) + expect(addedFile.fileDir).to.equal('custom/path') + }) + }) + + describe('updateFile', () => { + it('should update file properties', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { progress: 50 }) + }) + + expect(result.current.files[fileKey].progress).to.equal(50) + }) + + it('should update status to DONE', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { + status: FileUploadStatus.DONE, + storageId: 'storage-123', + checksumValue: 'abc123' + }) + }) + + expect(result.current.files[fileKey].status).to.equal(FileUploadStatus.DONE) + expect(result.current.anyFileUploading).to.equal(false) + }) + + it('should not update non-existent file', () => { + const { result } = renderHook(() => useFileUploadState()) + + const initialFiles = { ...result.current.files } + + act(() => { + result.current.updateFile('non-existent-key', { progress: 50 }) + }) + + expect(result.current.files).to.deep.equal(initialFiles) + }) + + it('should add file to uploadedFiles when status is DONE with storageId and checksumValue', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { + status: FileUploadStatus.DONE, + storageId: 'storage-123', + checksumValue: 'abc123' + }) + }) + + expect(result.current.uploadedFiles).to.have.length(1) + expect(result.current.uploadedFiles[0].storageId).to.equal('storage-123') + expect(result.current.uploadedFiles[0].checksumValue).to.equal('abc123') + }) + }) + + describe('removeFile', () => { + it('should remove a file from the state', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.removeFile(fileKey) + }) + + expect(Object.keys(result.current.files)).to.have.length(0) + }) + }) + + describe('removeAllFiles', () => { + it('should remove all files from the state', () => { + const { result } = renderHook(() => useFileUploadState()) + + act(() => { + result.current.addFile(createMockFile('test1.txt'), FixityAlgorithm.MD5) + result.current.addFile(createMockFile('test2.txt'), FixityAlgorithm.MD5) + }) + + expect(Object.keys(result.current.files)).to.have.length(2) + + act(() => { + result.current.removeAllFiles() + }) + + expect(Object.keys(result.current.files)).to.have.length(0) + }) + }) + + describe('getFileByKey', () => { + it('should return file by key', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + const file = result.current.getFileByKey(fileKey) + + expect(file?.fileName).to.equal('test.txt') + }) + + it('should return undefined for non-existent key', () => { + const { result } = renderHook(() => useFileUploadState()) + + const file = result.current.getFileByKey('non-existent-key') + + expect(file).to.be.undefined + }) + }) + + describe('uploadingToCancelMap', () => { + it('should add cancel function to map', () => { + const { result } = renderHook(() => useFileUploadState()) + const cancelFn = cy.stub() + + act(() => { + result.current.addUploadingToCancel('file-key', cancelFn) + }) + + expect(result.current.uploadingToCancelMap.size).to.equal(1) + expect(result.current.uploadingToCancelMap.get('file-key')).to.equal(cancelFn) + }) + + it('should remove cancel function from map', () => { + const { result } = renderHook(() => useFileUploadState()) + const cancelFn = cy.stub() + + act(() => { + result.current.addUploadingToCancel('file-key', cancelFn) + }) + + act(() => { + result.current.removeUploadingToCancel('file-key') + }) + + expect(result.current.uploadingToCancelMap.size).to.equal(0) + }) + }) + + describe('isSaving', () => { + it('should set isSaving state', () => { + const { result } = renderHook(() => useFileUploadState()) + + expect(result.current.isSaving).to.equal(false) + + act(() => { + result.current.setIsSaving(true) + }) + + expect(result.current.isSaving).to.equal(true) + + act(() => { + result.current.setIsSaving(false) + }) + + expect(result.current.isSaving).to.equal(false) + }) + }) + + describe('reset', () => { + it('should reset all state to initial values', () => { + const { result } = renderHook(() => useFileUploadState()) + const cancelFn = cy.stub() + + act(() => { + result.current.addFile(createMockFile('test.txt'), FixityAlgorithm.MD5) + result.current.addUploadingToCancel('file-key', cancelFn) + result.current.setIsSaving(true) + }) + + expect(Object.keys(result.current.files)).to.have.length(1) + expect(result.current.uploadingToCancelMap.size).to.equal(1) + expect(result.current.isSaving).to.equal(true) + + act(() => { + result.current.reset() + }) + + expect(Object.keys(result.current.files)).to.have.length(0) + expect(result.current.uploadingToCancelMap.size).to.equal(0) + expect(result.current.isSaving).to.equal(false) + }) + + it('should call cancel functions when resetting', () => { + const { result } = renderHook(() => useFileUploadState()) + const cancelFn = cy.stub() + + act(() => { + result.current.addUploadingToCancel('file-key', cancelFn) + }) + + act(() => { + result.current.reset() + }) + + expect(cancelFn).to.have.been.called + }) + }) + + describe('uploadingFilesInProgress', () => { + it('should include uploading files', () => { + const { result } = renderHook(() => useFileUploadState()) + + act(() => { + result.current.addFile(createMockFile('test.txt'), FixityAlgorithm.MD5) + }) + + expect(result.current.uploadingFilesInProgress).to.have.length(1) + }) + + it('should include failed files', () => { + const { result } = renderHook(() => useFileUploadState()) + + act(() => { + result.current.addFile(createMockFile('test.txt'), FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { status: FileUploadStatus.FAILED }) + }) + + expect(result.current.uploadingFilesInProgress).to.have.length(1) + expect(result.current.uploadingFilesInProgress[0].status).to.equal(FileUploadStatus.FAILED) + }) + + it('should exclude completed files', () => { + const { result } = renderHook(() => useFileUploadState()) + + act(() => { + result.current.addFile(createMockFile('test.txt'), FixityAlgorithm.MD5) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { + status: FileUploadStatus.DONE, + storageId: 'storage-123', + checksumValue: 'abc123' + }) + }) + + expect(result.current.uploadingFilesInProgress).to.have.length(0) + }) + }) +}) diff --git a/vite.config.uploader.ts b/vite.config.uploader.ts new file mode 100644 index 000000000..292093227 --- /dev/null +++ b/vite.config.uploader.ts @@ -0,0 +1,65 @@ +/** + * Vite Configuration for Standalone DVWebloader V2 Bundle + * + * This configuration builds the file uploader as a standalone bundle + * that can be used independently from the main Dataverse SPA. + */ + +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' +import * as path from 'path' + +export default defineConfig({ + plugins: [ + react(), + // Inject CSS into the JS bundle so we only have a single file to load + cssInjectedByJsPlugin() + ], + // Don't copy public folder contents + publicDir: false, + // Optimize deps to properly handle the local linked CommonJS package + optimizeDeps: { + include: ['@iqss/dataverse-client-javascript'] + }, + build: { + outDir: 'dist-uploader', + emptyOutDir: true, + // Target modern browsers for smaller bundle size + target: 'es2020', + // Force CommonJS interop for linked packages + commonjsOptions: { + include: [/node_modules/, /dataverse-client-javascript/], + transformMixedEsModules: true + }, + rollupOptions: { + input: path.resolve(__dirname, 'src/standalone-uploader/index.tsx'), + output: { + // Single entry file + entryFileNames: 'dvwebloader-v2.js', + // Inline all chunks into the main bundle + inlineDynamicImports: true, + // Asset file naming + assetFileNames: 'assets/[name].[ext]' + } + }, + // Copy translation files to dist + copyPublicDir: false, + // Increase chunk size warning limit since we're bundling everything + chunkSizeWarningLimit: 2000, + // Enable minification + minify: 'esbuild', + // Generate sourcemaps for debugging + sourcemap: true + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + '@tests': path.resolve(__dirname, 'tests') + } + }, + define: { + // Define production mode + 'process.env.NODE_ENV': '"production"' + } +}) From 806a25492abb6ad064381a1d39735355ec3599da Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 5 Dec 2025 16:48:20 +0100 Subject: [PATCH 002/110] feat: enhance standalone uploader configuration with window variables support --- src/standalone-uploader/config.ts | 55 ++++++++++++++-------- src/standalone-uploader/dvwebloaderV2.html | 9 ++++ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/standalone-uploader/config.ts b/src/standalone-uploader/config.ts index 4cf403082..b561a22e7 100644 --- a/src/standalone-uploader/config.ts +++ b/src/standalone-uploader/config.ts @@ -1,18 +1,38 @@ /** * Standalone Uploader Configuration * - * Parses URL parameters for the standalone file uploader. - * Compatible with DVWebloader v1 URL params: + * Configuration can be set in two ways: + * 1. Window variables (set via script tag in HTML before the bundle loads): + * - window.dvWebloaderConfig = { useS3Tagging: false, maxRetries: 5, ... } + * 2. URL parameters (for siteUrl, datasetPid, key, dvLocale) + * + * URL Parameters (passed by Dataverse): * - siteUrl: Base URL of the Dataverse instance * - datasetPid: Persistent ID of the dataset * - key: API key for authentication * - dvLocale: Optional locale code (e.g., 'en', 'de') - * - useS3Tagging: Optional, set to 'false' to disable S3 tagging (for S3-compatible storage that doesn't support tagging) - * - maxRetries: Optional, maximum number of retries for multipart upload parts (default: 3) - * - uploadTimeoutMs: Optional, timeout in milliseconds for file upload operations (default: 0 = unlimited) - * - disableMD5Checksum: Optional, set to 'true' to disable MD5 checksum calculation + * + * Window config options (set in HTML): + * - useS3Tagging: Set to false to disable S3 tagging (default: true) + * - maxRetries: Maximum retries for multipart upload parts (default: 3) + * - uploadTimeoutMs: Timeout in ms for uploads, 0 = unlimited (default: 0) + * - disableMD5Checksum: Set to true to skip checksum calculation (default: false) */ +/** Window config interface for type safety */ +interface DvWebloaderWindowConfig { + useS3Tagging?: boolean + maxRetries?: number + uploadTimeoutMs?: number + disableMD5Checksum?: boolean +} + +declare global { + interface Window { + dvWebloaderConfig?: DvWebloaderWindowConfig + } +} + export interface StandaloneUploaderConfig { siteUrl: string datasetPid: string @@ -42,31 +62,28 @@ export interface ConfigError { export type ConfigParseResult = ConfigResult | ConfigError /** - * Parse URL parameters and return configuration for the standalone uploader. + * Parse URL parameters and window config, return configuration for the standalone uploader. */ export function parseUrlConfig(): ConfigParseResult { const queryParams = new URLSearchParams(window.location.search) + const windowConfig = window.dvWebloaderConfig || {} const siteUrl = queryParams.get('siteUrl') const datasetPid = queryParams.get('datasetPid') const apiKey = queryParams.get('key') const dvLocale = queryParams.get('dvLocale') || 'en' - // Parse useS3Tagging - default to true (enabled), only false if explicitly set to 'false' - const useS3TaggingParam = queryParams.get('useS3Tagging') - const useS3Tagging = useS3TaggingParam !== 'false' + // Parse useS3Tagging - window config takes precedence, then default to true + const useS3Tagging = windowConfig.useS3Tagging ?? true - // Parse maxRetries - default to 3 - const maxRetriesParam = queryParams.get('maxRetries') - const maxRetries = maxRetriesParam ? parseInt(maxRetriesParam, 10) : 3 + // Parse maxRetries - window config takes precedence, then default to 3 + const maxRetries = windowConfig.maxRetries ?? 3 - // Parse uploadTimeoutMs - default to 0 (unlimited) - const uploadTimeoutMsParam = queryParams.get('uploadTimeoutMs') - const uploadTimeoutMs = uploadTimeoutMsParam ? parseInt(uploadTimeoutMsParam, 10) : 0 + // Parse uploadTimeoutMs - window config takes precedence, then default to 0 (unlimited) + const uploadTimeoutMs = windowConfig.uploadTimeoutMs ?? 0 - // Parse disableMD5Checksum - default to false - const disableMD5ChecksumParam = queryParams.get('disableMD5Checksum') - const disableMD5Checksum = disableMD5ChecksumParam === 'true' + // Parse disableMD5Checksum - window config takes precedence, then default to false + const disableMD5Checksum = windowConfig.disableMD5Checksum ?? false const missingParams: string[] = [] diff --git a/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html index a6efcb5c2..475ad1cde 100644 --- a/src/standalone-uploader/dvwebloaderV2.html +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -4,6 +4,15 @@ Dataverse WebLoader V2 + + From 06dfa4dfbb16a47568a937784d156e1d2575ba86 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 5 Dec 2025 17:02:53 +0100 Subject: [PATCH 003/110] feat: enhance standalone uploader UI with improved layout and dataset display --- src/standalone-uploader/dvwebloaderV2.html | 68 +++++++++++++++++++++- src/standalone-uploader/index.tsx | 6 -- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html index 475ad1cde..573a7795a 100644 --- a/src/standalone-uploader/dvwebloaderV2.html +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -13,10 +13,76 @@ disableMD5Checksum: false // Calculate MD5 checksums }; + -
+
+ + + +
+ + +
+ + diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx index 37f4a398f..55f7e638c 100644 --- a/src/standalone-uploader/index.tsx +++ b/src/standalone-uploader/index.tsx @@ -160,12 +160,6 @@ async function init() {
-
-

Upload Files

-

- Uploading to dataset: {config.datasetPid} -

-
Date: Fri, 5 Dec 2025 19:45:46 +0100 Subject: [PATCH 004/110] feat: add helper text to standalone file uploader and update build script --- package.json | 2 +- .../FileUploaderPanel.module.scss | 16 ++++++++++ .../file-uploader/FileUploaderPanel.tsx | 18 +++++++++++ .../file-upload-input/FileUploadInput.tsx | 18 +---------- .../StandaloneFileUploaderPanel.module.scss | 16 ++++++++++ .../StandaloneFileUploaderPanel.tsx | 32 +++++++++++++++---- .../embeddedDvWebloader.html | 29 +++++++++++++++++ 7 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 src/sections/shared/file-uploader/FileUploaderPanel.module.scss create mode 100644 src/standalone-uploader/StandaloneFileUploaderPanel.module.scss create mode 100644 src/standalone-uploader/embeddedDvWebloader.html diff --git a/package.json b/package.json index 10412c3e5..75b4d3554 100644 --- a/package.json +++ b/package.json @@ -64,7 +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 dist-uploader/", + "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/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 2fc94e90a..62544133a 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanel.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanel.tsx @@ -6,6 +6,7 @@ */ import { useMemo, useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' import { useBlocker, useNavigate } from 'react-router-dom' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { QueryParamKey, Route } from '@/sections/Route.enum' @@ -14,6 +15,7 @@ import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer import { useFileUploaderContext } from './context/FileUploaderContext' import { FileUploaderPanelCore } from './FileUploaderPanelCore' import { ConfirmLeaveModal } from './confirm-leave-modal/ConfirmLeaveModal' +import styles from './FileUploaderPanel.module.scss' interface FileUploaderPanelProps { fileRepository: FileRepository @@ -27,6 +29,7 @@ const FileUploaderPanel = ({ referrer }: FileUploaderPanelProps) => { const navigate = useNavigate() + const { t } = useTranslation('shared') const { fileUploaderState: { files, isSaving, uploadingToCancelMap }, @@ -80,6 +83,21 @@ const FileUploaderPanel = ({ return ( <> +

+ + ) + }} + /> +

-

- - ) - }} - /> -

- {t('fileUploader.accordionTitle')} diff --git a/src/standalone-uploader/StandaloneFileUploaderPanel.module.scss b/src/standalone-uploader/StandaloneFileUploaderPanel.module.scss new file mode 100644 index 000000000..c1442ddb0 --- /dev/null +++ b/src/standalone-uploader/StandaloneFileUploaderPanel.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/standalone-uploader/StandaloneFileUploaderPanel.tsx b/src/standalone-uploader/StandaloneFileUploaderPanel.tsx index 862184587..57887cbae 100644 --- a/src/standalone-uploader/StandaloneFileUploaderPanel.tsx +++ b/src/standalone-uploader/StandaloneFileUploaderPanel.tsx @@ -6,9 +6,11 @@ */ import { useEffect, useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' import { UploaderFileRepository } from '@/sections/shared/file-uploader/types' import { useFileUploaderContext } from '@/sections/shared/file-uploader/context/FileUploaderContext' import { FileUploaderPanelCore } from '@/sections/shared/file-uploader/FileUploaderPanelCore' +import styles from './StandaloneFileUploaderPanel.module.scss' interface StandaloneFileUploaderPanelProps { fileRepository: UploaderFileRepository @@ -21,6 +23,7 @@ export const StandaloneFileUploaderPanel = ({ datasetPersistentId, siteUrl }: StandaloneFileUploaderPanelProps) => { + const { t } = useTranslation('shared') const { fileUploaderState: { files, isSaving, uploadingToCancelMap } } = useFileUploaderContext() @@ -62,11 +65,28 @@ export const StandaloneFileUploaderPanel = ({ }, [getDatasetUrl]) return ( - + <> +

+ + ) + }} + /> +

+ + ) } diff --git a/src/standalone-uploader/embeddedDvWebloader.html b/src/standalone-uploader/embeddedDvWebloader.html new file mode 100644 index 000000000..01f8d1c1a --- /dev/null +++ b/src/standalone-uploader/embeddedDvWebloader.html @@ -0,0 +1,29 @@ + + + + + + Upload Files + + + + + + + +
+ + From c1b0c4bbe0292831572db3b6a97ece0333742001 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Fri, 5 Dec 2025 20:43:48 +0100 Subject: [PATCH 005/110] feat: improve iframe dynamic resizing and adjust body padding in standalone uploader --- .../embeddedDvWebloader.html | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/standalone-uploader/embeddedDvWebloader.html b/src/standalone-uploader/embeddedDvWebloader.html index 01f8d1c1a..28aa47c36 100644 --- a/src/standalone-uploader/embeddedDvWebloader.html +++ b/src/standalone-uploader/embeddedDvWebloader.html @@ -14,14 +14,35 @@ }; +
From 9d42cdc2f413397a5df93a385a1f465579ce48f6 Mon Sep 17 00:00:00 2001 From: Eryk Kullikowski Date: Mon, 8 Dec 2025 12:22:36 +0100 Subject: [PATCH 006/110] feat: add folder selection support in file uploader --- public/locales/en/shared.json | 1 + .../file-upload-input/FileUploadInput.tsx | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) 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/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx index 724c9fc00..220dbe523 100644 --- a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx +++ b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx @@ -38,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) @@ -115,6 +116,20 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu } } + 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) => { event.preventDefault() event.stopPropagation() @@ -195,6 +210,15 @@ const FileUploadInput = ({ fileRepository, datasetPersistentId }: FileUploadInpu ? t('fileUploader.selectFileMultiple') : t('fileUploader.selectFileSingle')} + {operationType === OperationType.ADD_FILES_TO_DATASET && ( + + )} @@ -114,19 +112,8 @@ async function init() { const config = configResult.config - // Initialize the API client with API key authentication - ApiConfig.init(`${config.siteUrl}/api/v1`, DataverseApiAuthMechanism.API_KEY, config.apiKey) - - // Configure file upload settings - // These are critical for S3-compatible storage that may not support all S3 features - FilesConfig.init({ - // useS3Tagging: Set to false for MinIO/S3-compatible storage without tagging support - useS3Tagging: config.useS3Tagging, - // maxMultipartRetries: Number of retry attempts for multipart upload failures - maxMultipartRetries: config.maxRetries, - // fileUploadTimeoutMs: Timeout for upload operations (0 = use axios default) - fileUploadTimeoutMs: config.uploadTimeoutMs || undefined - }) + // Initialize the API client with session cookie authentication + ApiConfig.init(`${config.siteUrl}/api/v1`, DataverseApiAuthMechanism.SESSION_COOKIE) // Determine the base path for loading translation files // In standalone mode, translations are bundled or loaded from the same origin From eebab9d060e7c68ed3f76a88606e10d3667414ec Mon Sep 17 00:00:00 2001 From: ErykKul Date: Mon, 4 May 2026 13:53:14 +0200 Subject: [PATCH 011/110] Replace iframe embed with direct JS embed for standalone uploader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uploader no longer requires an iframe or a host HTML page. JSF (or any page) can embed it with a single script tag:
Changes: - config.ts: replace URL-param parsing with window.dvUploaderConfig; add rootElementId and localesPath options; remove unused getDatasetIdentifier helper - index.tsx: mount to config.rootElementId (default 'dv-uploader'); derive i18n locales path from config.localesPath or siteUrl default; show inline error if required config is missing - dvwebloaderV2.html: simplified demo page showing window config usage; falls back to URL params for siteUrl/datasetPid for easy testing - embeddedDvWebloader.html: removed (iframe resize messaging no longer needed) - package.json: remove embeddedDvWebloader.html from build-uploader copy Auth: session cookie (JSESSIONID) — no API key required. Requires DATAVERSE_FEATURE_API_SESSION_AUTH on the Dataverse instance. --- package.json | 2 +- src/standalone-uploader/config.ts | 107 +++++------------- src/standalone-uploader/dvwebloaderV2.html | 98 ++++------------ .../embeddedDvWebloader.html | 48 -------- src/standalone-uploader/index.tsx | 87 +++++--------- 5 files changed, 81 insertions(+), 261 deletions(-) delete mode 100644 src/standalone-uploader/embeddedDvWebloader.html diff --git a/package.json b/package.json index 2c0670186..a29b687a6 100644 --- a/package.json +++ b/package.json @@ -64,7 +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-uploader": "vite build --config vite.config.uploader.ts && cp -r public/locales dist-uploader/ && cp src/standalone-uploader/dvwebloaderV2.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/src/standalone-uploader/config.ts b/src/standalone-uploader/config.ts index c50751d15..5e24b3747 100644 --- a/src/standalone-uploader/config.ts +++ b/src/standalone-uploader/config.ts @@ -1,93 +1,42 @@ /** * Standalone Uploader Configuration * - * Configuration can be set in two ways: - * 1. Window variables (set via script tag in HTML before the bundle loads): - * - window.dvWebloaderConfig = { disableMD5Checksum: true } - * 2. URL parameters (for siteUrl, datasetPid, dvLocale) + * Set window.dvUploaderConfig before loading the script: * - * URL Parameters (passed by Dataverse): - * - siteUrl: Base URL of the Dataverse instance - * - datasetPid: Persistent ID of the dataset - * - dvLocale: Optional locale code (e.g., 'en', 'de') + * + * + *
* - * Window config options (set in HTML): - * - disableMD5Checksum: Set to true to skip checksum calculation (default: false) + * Authentication is via the browser's JSESSIONID session cookie. + * DATAVERSE_FEATURE_API_SESSION_AUTH must be enabled on the Dataverse instance. */ -/** Window config interface for type safety */ -interface DvWebloaderWindowConfig { +export interface DvUploaderConfig { + /** Base URL of the Dataverse instance, e.g. https://demo.dataverse.org */ + siteUrl: string + /** Persistent ID of the dataset to upload files into */ + datasetPid: string + /** Locale code for translations. Default: 'en' */ + locale?: string + /** + * URL template for translation files. + * Default: `{siteUrl}/dvwebloader/locales/{{lng}}/{{ns}}.json` + */ + localesPath?: string + /** ID of the DOM element to mount into. Default: 'dv-uploader' */ + rootElementId?: string + /** Skip MD5 checksum calculation. Default: false */ disableMD5Checksum?: boolean } declare global { interface Window { - dvWebloaderConfig?: DvWebloaderWindowConfig + dvUploaderConfig?: DvUploaderConfig } } - -export interface StandaloneUploaderConfig { - siteUrl: string - datasetPid: string - dvLocale: string - /** Whether to disable MD5 checksum calculation. Default: false */ - disableMD5Checksum: boolean -} - -export interface ConfigResult { - ok: true - config: StandaloneUploaderConfig -} - -export interface ConfigError { - ok: false - error: string - missingParams: string[] -} - -export type ConfigParseResult = ConfigResult | ConfigError - -/** - * Parse URL parameters and window config, return configuration for the standalone uploader. - */ -export function parseUrlConfig(): ConfigParseResult { - const queryParams = new URLSearchParams(window.location.search) - const windowConfig = window.dvWebloaderConfig || {} - - const siteUrl = queryParams.get('siteUrl') - const datasetPid = queryParams.get('datasetPid') - const dvLocale = queryParams.get('dvLocale') || 'en' - - const disableMD5Checksum = windowConfig.disableMD5Checksum ?? false - - const missingParams: string[] = [] - - if (!siteUrl) missingParams.push('siteUrl') - if (!datasetPid) missingParams.push('datasetPid') - - if (missingParams.length > 0) { - return { - ok: false, - error: `Missing required URL parameters: ${missingParams.join(', ')}`, - missingParams - } - } - - return { - ok: true, - config: { - siteUrl: siteUrl as string, - datasetPid: datasetPid as string, - dvLocale, - disableMD5Checksum - } - } -} - -/** - * Extract the dataset ID from either a persistent ID or numeric ID. - * The API accepts both formats. - */ -export function getDatasetIdentifier(datasetPid: string): string { - return datasetPid -} diff --git a/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html index 8c2d17f41..0f20c24cc 100644 --- a/src/standalone-uploader/dvwebloaderV2.html +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -3,86 +3,34 @@ - Dataverse WebLoader V2 - + DVWebloader V2 — Demo + - - -
- - - -
- - -
- - +
diff --git a/src/standalone-uploader/embeddedDvWebloader.html b/src/standalone-uploader/embeddedDvWebloader.html deleted file mode 100644 index d41b1adb1..000000000 --- a/src/standalone-uploader/embeddedDvWebloader.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - Upload Files - - - - - - - -
- - diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx index 4db076ce5..659724393 100644 --- a/src/standalone-uploader/index.tsx +++ b/src/standalone-uploader/index.tsx @@ -1,12 +1,3 @@ -/** - * Standalone Uploader Entry Point - * - * This is the entry point for the standalone DVWebloader V2 bundle. - * It initializes React, i18n, and the API client, then mounts the uploader. - * - * This standalone version reuses the SPA's FileUploader components to avoid code duplication. - */ - import { createRoot } from 'react-dom/client' import { StrictMode } from 'react' import i18next from 'i18next' @@ -15,7 +6,6 @@ import I18NextHttpBackend from 'i18next-http-backend' import { ApiConfig } from '@iqss/dataverse-client-javascript' import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dist/core/infra/repositories/ApiConfig' import { ToastContainer } from 'react-toastify' -import { parseUrlConfig } from './config' import { StandaloneFileUploaderPanel } from './StandaloneFileUploaderPanel' import { StandaloneFileRepository } from './StandaloneFileRepository' import { FileUploaderProvider } from '@/sections/shared/file-uploader/context/FileUploaderContext' @@ -25,46 +15,27 @@ import { LoadingConfigSpinner } from '@/sections/shared/file-uploader/loading-co import { useGetFixityAlgorithm } from '@/sections/shared/file-uploader/useGetFixityAlgorithm' import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' -// Import design system styles - use relative path for build compatibility import '../../packages/design-system/dist/style.css' import 'bootstrap/dist/css/bootstrap.min.css' import 'react-toastify/dist/ReactToastify.css' import './standalone.scss' -// Error display component -function ConfigErrorDisplay({ error, missingParams }: { error: string; missingParams: string[] }) { - return ( -
-

Configuration Error

-

{error}

- {missingParams.length > 0 && ( -
-

Expected URL format:

- ?siteUrl=https://your-dataverse.edu&datasetPid=doi:10.5072/FK2/XXXXX -
- )} -
- ) -} - -// Standalone uploader wrapper that loads fixity algorithm and provides context -interface StandaloneUploaderWrapperProps { +interface WrapperProps { fileRepository: StandaloneFileRepository datasetPersistentId: string siteUrl: string disableMD5Checksum?: boolean } -function StandaloneUploaderWrapper({ +function UploaderWrapper({ fileRepository, datasetPersistentId, siteUrl, disableMD5Checksum -}: StandaloneUploaderWrapperProps) { +}: WrapperProps) { const { fixityAlgorithm: fetchedAlgorithm, isLoadingFixityAlgorithm } = useGetFixityAlgorithm(fileRepository) - // If checksum is disabled, use NONE. Otherwise use the fetched algorithm. const fixityAlgorithm = disableMD5Checksum ? FixityAlgorithm.NONE : fetchedAlgorithm if (isLoadingFixityAlgorithm) { @@ -88,66 +59,67 @@ function StandaloneUploaderWrapper({ ) } -// Initialize the application async function init() { - const container = document.getElementById('root') + const config = window.dvUploaderConfig + const rootElementId = config?.rootElementId ?? 'dv-uploader' + + const container = document.getElementById(rootElementId) if (!container) { - console.error('Root element not found') + console.error(`[dvUploader] Mount element #${rootElementId} not found`) return } const root = createRoot(container) - // Parse URL configuration - const configResult = parseUrlConfig() + const missingFields: string[] = [] + if (!config) missingFields.push('siteUrl', 'datasetPid') + else { + if (!config.siteUrl) missingFields.push('siteUrl') + if (!config.datasetPid) missingFields.push('datasetPid') + } - if (!configResult.ok) { + if (missingFields.length > 0 || !config) { root.render( - +
+

+ dvUploader: missing required config: {missingFields.join(', ')} +

+

+ Set window.dvUploaderConfig before loading the script. +

+
) return } - const config = configResult.config - - // Initialize the API client with session cookie authentication ApiConfig.init(`${config.siteUrl}/api/v1`, DataverseApiAuthMechanism.SESSION_COOKIE) - // Determine the base path for loading translation files - // In standalone mode, translations are bundled or loaded from the same origin - const basePath = window.location.pathname.substring( - 0, - window.location.pathname.lastIndexOf('/') + 1 - ) + const localesPath = + config.localesPath ?? `${config.siteUrl}/dvwebloader/locales/{{lng}}/{{ns}}.json` - // Initialize i18next for translations await i18next .use(initReactI18next) .use(I18NextHttpBackend) .init({ - lng: config.dvLocale || 'en', + lng: config.locale ?? 'en', fallbackLng: 'en', supportedLngs: ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'uk'], lowerCaseLng: true, ns: ['shared'], defaultNS: 'shared', returnNull: false, - backend: { - loadPath: `${basePath}locales/{{lng}}/{{ns}}.json` - } + backend: { loadPath: localesPath } }) - // Create standalone file repository (doesn't depend on config.js) const fileRepository = new StandaloneFileRepository(config.siteUrl) - // Render the uploader with context provider root.render(
- { - console.error('Failed to initialize standalone uploader:', error) + console.error('[dvUploader] init failed:', error) }) From fc181432b2c801b53d8519d9bc336148403131b7 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Mon, 4 May 2026 17:14:04 +0200 Subject: [PATCH 012/110] Build reusable uploader component bundles --- dev-env/docker-compose-dev.yml | 2 + dev-env/nginx.conf | 6 +++ src/standalone-uploader/config.ts | 2 +- src/standalone-uploader/dvwebloaderV2.html | 4 +- vite.config.uploader.ts | 43 ++++++++++++++++------ 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/dev-env/docker-compose-dev.yml b/dev-env/docker-compose-dev.yml index aea9b2704..723028b80 100644 --- a/dev-env/docker-compose-dev.yml +++ b/dev-env/docker-compose-dev.yml @@ -13,6 +13,7 @@ services: volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./docker-dev-volumes/nginx/logs:/var/log/nginx/ + - ../dist-uploader:/usr/share/nginx/html/dvwebloader:ro dev_frontend: container_name: 'dev_frontend' @@ -50,6 +51,7 @@ services: DATAVERSE_FEATURE_API_BEARER_AUTH_USE_BUILTIN_USER_ON_ID_MATCH: 1 DATAVERSE_FEATURE_API_SESSION_AUTH: 1 DATAVERSE_FEATURE_API_SESSION_AUTH_HARDENING: 1 + DATAVERSE_FEATURE_REACT_UPLOADER: 1 DATAVERSE_AUTH_OIDC_ENABLED: 1 DATAVERSE_AUTH_OIDC_CLIENT_ID: test DATAVERSE_AUTH_OIDC_CLIENT_SECRET: 94XHrfNRwXsjqTqApRrwWmhDLDHpIYV8 diff --git a/dev-env/nginx.conf b/dev-env/nginx.conf index a2beab769..bf1720bf5 100644 --- a/dev-env/nginx.conf +++ b/dev-env/nginx.conf @@ -75,5 +75,11 @@ http { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; } + + # Static reusable frontend components built with `npm run build-uploader`. + location /dvwebloader/ { + alias /usr/share/nginx/html/dvwebloader/; + try_files $uri =404; + } } } diff --git a/src/standalone-uploader/config.ts b/src/standalone-uploader/config.ts index 5e24b3747..9ab312d08 100644 --- a/src/standalone-uploader/config.ts +++ b/src/standalone-uploader/config.ts @@ -10,7 +10,7 @@ * locale: 'en' // optional, default 'en' * } * - * + * *
* * Authentication is via the browser's JSESSIONID session cookie. diff --git a/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html index 0f20c24cc..b1fff3bb0 100644 --- a/src/standalone-uploader/dvwebloaderV2.html +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -15,7 +15,7 @@ locale: 'en' } - + Authentication uses the browser JSESSIONID session cookie. DATAVERSE_FEATURE_API_SESSION_AUTH must be enabled. @@ -28,7 +28,7 @@ locale: new URLSearchParams(window.location.search).get('dvLocale') || 'en' } - +
diff --git a/vite.config.uploader.ts b/vite.config.uploader.ts index 292093227..b38ac6fa7 100644 --- a/vite.config.uploader.ts +++ b/vite.config.uploader.ts @@ -1,8 +1,9 @@ /** - * Vite Configuration for Standalone DVWebloader V2 Bundle + * Vite Configuration for Reusable Dataverse Frontend Components * - * This configuration builds the file uploader as a standalone bundle - * that can be used independently from the main Dataverse SPA. + * This configuration builds reusable components as standalone ESM entry points. + * Shared dependencies are emitted as chunks so additional components can reuse + * React, i18n, and common libraries instead of bundling them repeatedly. */ import { defineConfig } from 'vite' @@ -33,20 +34,38 @@ export default defineConfig({ transformMixedEsModules: true }, rollupOptions: { - input: path.resolve(__dirname, 'src/standalone-uploader/index.tsx'), + input: { + 'dv-uploader': path.resolve(__dirname, 'src/standalone-uploader/index.tsx') + }, output: { - // Single entry file - entryFileNames: 'dvwebloader-v2.js', - // Inline all chunks into the main bundle - inlineDynamicImports: true, - // Asset file naming - assetFileNames: 'assets/[name].[ext]' + entryFileNames: 'reusable-components/[name].js', + chunkFileNames: 'reusable-components/chunks/[name]-[hash].js', + assetFileNames: 'reusable-components/assets/[name].[ext]', + manualChunks(id) { + if ( + id.includes('/src/sections/shared/') || + id.includes('/src/files/') || + id.includes('/src/dataset/') || + id.includes('/packages/design-system/') + ) { + return 'dataverse-shared' + } + if (!id.includes('node_modules')) { + return + } + if (id.includes('react') || id.includes('scheduler')) { + return 'react' + } + if (id.includes('i18next') || id.includes('react-i18next')) { + return 'i18n' + } + return 'vendor' + } } }, // Copy translation files to dist copyPublicDir: false, - // Increase chunk size warning limit since we're bundling everything - chunkSizeWarningLimit: 2000, + chunkSizeWarningLimit: 1000, // Enable minification minify: 'esbuild', // Generate sourcemaps for debugging From 8a09a250ad74c39c22bf51104afee2fb54124f7c Mon Sep 17 00:00:00 2001 From: ErykKul Date: Mon, 4 May 2026 18:59:15 +0200 Subject: [PATCH 013/110] Polish: CSS isolation, modular chunk hints, dev-env build prereq Embedded styles can no longer leak page-level resets into the host JSF page: html/body/#root rules and the page-only container layout move into a separate standalone-page.scss that ships alongside the demo HTML but is not included in the bundle. The React tree now mounts inside a dv-uploader-root wrapper so future CSS scoping (PostCSS prefix or Shadow DOM) has a stable hook. Also tightens the manualChunks regex so the react chunk holds only react/react-dom/scheduler instead of every node_modules path that contains the substring "react" (react-bootstrap, react-router-dom, react-toastify, etc.). The result is better cache reuse across future reusable components. run-env.sh now builds dist-uploader on demand so nginx never mounts an empty directory. --- dev-env/run-env.sh | 8 +++ package.json | 2 +- src/standalone-uploader/dvwebloaderV2.html | 10 +++- src/standalone-uploader/index.tsx | 18 ++++--- src/standalone-uploader/standalone-page.scss | 28 ++++++++++ src/standalone-uploader/standalone.scss | 55 ++++++-------------- vite.config.uploader.ts | 13 +++-- 7 files changed, 80 insertions(+), 54 deletions(-) create mode 100644 src/standalone-uploader/standalone-page.scss diff --git a/dev-env/run-env.sh b/dev-env/run-env.sh index f8014c429..c6411cdff 100755 --- a/dev-env/run-env.sh +++ b/dev-env/run-env.sh @@ -10,6 +10,14 @@ export DATAVERSE_BOOTSTRAP_TIMEOUT="10m" echo "INFO - Setting up Dataverse on image tag ${DATAVERSE_IMAGE_TAG}..." +# nginx mounts ../dist-uploader to serve the reusable React components at +# /dvwebloader/. If that directory is missing the JSF react-uploader feature +# will 404. Build it now if it isn't there yet — it's cheap if up to date. +if [ ! -f "../dist-uploader/reusable-components/dv-uploader.js" ]; then + echo "INFO - dist-uploader bundle missing; running 'npm run build-uploader'..." + (cd .. && npm run build-uploader) +fi + echo "INFO - Removing current environment if exists..." ./rm-env.sh diff --git a/package.json b/package.json index a29b687a6..b8745fb2e 100644 --- a/package.json +++ b/package.json @@ -64,7 +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 dist-uploader/", + "build-uploader": "vite build --config vite.config.uploader.ts && cp -r public/locales dist-uploader/ && cp src/standalone-uploader/dvwebloaderV2.html dist-uploader/ && sass --no-source-map src/standalone-uploader/standalone-page.scss dist-uploader/standalone-page.css", "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/src/standalone-uploader/dvwebloaderV2.html b/src/standalone-uploader/dvwebloaderV2.html index b1fff3bb0..682708283 100644 --- a/src/standalone-uploader/dvwebloaderV2.html +++ b/src/standalone-uploader/dvwebloaderV2.html @@ -17,9 +17,15 @@ + The host page is responsible for its own page-level styling. The bundle + below only ships component styles scoped to `.dv-uploader-root`. The + page styles loaded here (`standalone-page.css`) are for this demo only + and are NOT part of the embedded bundle. + Authentication uses the browser JSESSIONID session cookie. DATAVERSE_FEATURE_API_SESSION_AUTH must be enabled. --> + -
+
+
+
diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx index 659724393..c35461486 100644 --- a/src/standalone-uploader/index.tsx +++ b/src/standalone-uploader/index.tsx @@ -81,13 +81,15 @@ async function init() { if (missingFields.length > 0 || !config) { root.render( -
-

- dvUploader: missing required config: {missingFields.join(', ')} -

-

- Set window.dvUploaderConfig before loading the script. -

+
+
+

+ dvUploader: missing required config: {missingFields.join(', ')} +

+

+ Set window.dvUploaderConfig before loading the script. +

+
) @@ -117,7 +119,7 @@ async function init() { root.render( -
+
+ * (copied to the dist directory by the `build-uploader` script). + */ + +html, +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; + background-color: #f5f5f5; + min-height: 100vh; +} + +.standalone-uploader-page { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; +} diff --git a/src/standalone-uploader/standalone.scss b/src/standalone-uploader/standalone.scss index 8925bd122..f89fcb6d7 100644 --- a/src/standalone-uploader/standalone.scss +++ b/src/standalone-uploader/standalone.scss @@ -1,49 +1,24 @@ /** - * Standalone Uploader Base Styles + * Standalone Uploader Embedded Styles + * + * These styles are bundled into the JS module loaded by JSF (and the demo page). + * They MUST be safe to inject into a host page — no html/body/#root rules, + * no resets that would override the host page chrome. + * + * Page-level styles (background, font, viewport-fill) belong in + * `standalone-page.scss`, which is only loaded by the standalone demo HTML. */ -// Reset and base styles for standalone mode -html, -body { +// Wrapper applied to the React render root. Used as a future hook for +// CSS scoping (e.g. PostCSS prefix or Shadow DOM) to limit Bootstrap 5 +// reset rules to within the embedded component. +.dv-uploader-root { + // Reset margin/padding so the host container controls layout. margin: 0; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, - sans-serif; - background-color: #f5f5f5; - min-height: 100vh; -} - -#root { - min-height: 100vh; -} - -.standalone-uploader-container { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; -} - -.standalone-uploader-header { - margin-bottom: 2rem; - - h1 { - margin: 0 0 0.5rem; - font-size: 1.75rem; - color: #333; - } -} -.standalone-uploader-dataset-info { - margin: 0; - color: #666; - - code { - background-color: #f8f9fa; - padding: 0.2rem 0.4rem; - border-radius: 4px; - font-family: monospace; - font-size: 0.9rem; - } + // Let React content size naturally inside whatever JSF panel hosts it. + width: 100%; } .standalone-error { diff --git a/vite.config.uploader.ts b/vite.config.uploader.ts index b38ac6fa7..dec413d31 100644 --- a/vite.config.uploader.ts +++ b/vite.config.uploader.ts @@ -53,12 +53,17 @@ export default defineConfig({ if (!id.includes('node_modules')) { return } - if (id.includes('react') || id.includes('scheduler')) { - return 'react' - } - if (id.includes('i18next') || id.includes('react-i18next')) { + // i18next first — `react-i18next` would otherwise match the broader + // react chunk below. + if (/\/node_modules\/(i18next|react-i18next|i18next-http-backend)\//.test(id)) { return 'i18n' } + // Match only the React core packages so we don't sweep in unrelated + // packages whose names happen to include "react" (react-bootstrap, + // react-router-dom, react-toastify, etc.). + if (/\/node_modules\/(react|react-dom|scheduler)\//.test(id)) { + return 'react' + } return 'vendor' } } From f28e5211bc8f547f5f001042b841431d9fac6584 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Mon, 4 May 2026 20:48:17 +0200 Subject: [PATCH 014/110] Polish standalone uploader for review --- .tool-versions | 1 + CHANGELOG.md | 2 +- package-lock.json | 2589 ++--------------- package.json | 2 +- .../file-uploader/FileUploaderPanelCore.tsx | 37 +- .../context/FileUploaderContext.tsx | 5 +- .../context/fileUploaderReducer.ts | 11 +- .../file-uploader/useFileUploadOperations.ts | 72 +- .../file-uploader/useFileUploadState.ts | 20 +- .../StandaloneFileRepository.ts | 64 +- .../fileUploaderReducer.spec.tsx | 5 +- .../useFileUploadOperations.spec.tsx | 75 + .../file-uploader/useFileUploadState.spec.tsx | 21 + 13 files changed, 385 insertions(+), 2519 deletions(-) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..668b21d92 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 22.21.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f640922b..0bf3b144d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - 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." +- 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 b12d00e68..ac1a014d2 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": "file:../dataverse-client-javascript", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.5a9f204", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -128,38 +128,6 @@ ] } }, - "../dataverse-client-javascript": { - "name": "@iqss/dataverse-client-javascript", - "version": "2.2.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", @@ -263,93 +231,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -359,21 +240,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", @@ -404,20 +270,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", @@ -427,59 +279,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -507,22 +306,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helpers": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", @@ -551,127 +334,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", - "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -727,23 +389,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", @@ -808,1020 +453,85 @@ "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -1830,16 +540,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -1848,15 +556,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-regex": { + "node_modules/@babel/plugin-syntax-typescript": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { @@ -1866,103 +572,30 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.3.tgz", - "integrity": "sha512-ySZypNLAIH1ClygLDQzVMoGQRViATnkHkYYV6TcNDz+8+jwZCdsguGvsb3EY5d9wyWyhmF1iSuFM0Yh5XPnqSA==", + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/compat-data": "^7.29.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1971,22 +604,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -3337,8 +1954,39 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "resolved": "../dataverse-client-javascript", - "link": true + "version": "2.2.0-pr403.5a9f204", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-pr403.5a9f204/9ca8523e924dd5f677e9b5e8f817b1f6bfea6d5a", + "integrity": "sha512-7YWSSJcBtvENtVfOr+y0IldZXzfj36eynX9ywXFKurEs+fOG6q6EtukH9tniFqBfXZUCgpeaNmjHfMJz01/DxA==", + "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.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "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" + } }, "node_modules/@iqss/dataverse-design-system": { "resolved": "packages/design-system", @@ -4813,18 +3461,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -7040,6 +5676,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7053,6 +5690,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7066,6 +5704,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7079,6 +5718,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7092,6 +5732,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7105,6 +5746,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7118,6 +5760,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7131,6 +5774,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7144,6 +5788,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7157,6 +5802,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7170,6 +5816,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7183,6 +5830,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7196,6 +5844,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7209,6 +5858,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7222,6 +5872,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7235,6 +5886,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7248,6 +5900,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7261,6 +5914,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7274,6 +5928,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7287,6 +5942,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7300,6 +5956,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7313,6 +5970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8815,6 +7473,7 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", @@ -9595,34 +8254,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -9937,7 +8573,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", - "dev": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -10430,198 +9065,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", @@ -10702,7 +9145,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -10722,20 +9165,6 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -10783,6 +9212,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -10840,20 +9270,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -10959,6 +9375,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, "license": "MIT", "dependencies": { "default-require-extensions": "^3.0.0" @@ -10999,6 +9416,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -11252,7 +9670,6 @@ "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": { @@ -11343,7 +9760,6 @@ "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", @@ -11373,154 +9789,6 @@ "@babel/core": "^7.8.0" } }, - "node_modules/babel-loader": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", - "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-loader/node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/babel-loader/node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/babel-loader/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -11588,51 +9856,6 @@ "dev": true, "license": "MIT" }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/babel-plugin-styled-components": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz", @@ -12028,7 +10251,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/byte-size": { @@ -12136,6 +10359,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, "license": "MIT", "dependencies": { "hasha": "^5.0.0", @@ -12151,6 +10375,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -12166,6 +10391,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -12460,17 +10686,6 @@ } } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -12503,6 +10718,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12804,7 +11020,6 @@ "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" @@ -12840,14 +11055,6 @@ "dev": true, "license": "ISC" }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -12862,6 +11069,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, "license": "MIT" }, "node_modules/compare-func": { @@ -13090,21 +11298,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -13227,6 +11420,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -13618,6 +11812,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13751,6 +11946,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, "license": "MIT", "dependencies": { "strip-bom": "^4.0.0" @@ -13833,7 +12029,6 @@ "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" @@ -14274,12 +12469,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14290,6 +12487,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14309,21 +12507,6 @@ "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -14497,14 +12680,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -14521,7 +12696,6 @@ "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", @@ -14568,6 +12742,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -15253,17 +13428,6 @@ "dev": true, "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -15597,6 +13761,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, "license": "MIT", "dependencies": { "commondir": "^1.0.1", @@ -15614,6 +13779,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -15801,7 +13967,6 @@ "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", @@ -15877,7 +14042,6 @@ "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", @@ -15894,6 +14058,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, "funding": [ { "type": "github", @@ -16076,6 +14241,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -16546,14 +14712,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -16861,6 +15019,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, "license": "MIT", "dependencies": { "is-stream": "^2.0.0", @@ -16877,6 +15036,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=8" @@ -16979,6 +15139,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, "license": "MIT" }, "node_modules/html-parse-stringify": { @@ -17288,6 +15449,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -17713,6 +15875,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17965,6 +16128,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18039,6 +16203,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, "license": "MIT" }, "node_modules/is-unc-path": { @@ -18161,6 +16326,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isstream": { @@ -18174,6 +16340,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -18183,6 +16350,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "append-transform": "^2.0.0" @@ -18235,6 +16403,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, "license": "ISC", "dependencies": { "archy": "^1.0.0", @@ -18252,6 +16421,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -18261,6 +16431,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -18273,6 +16444,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -18282,6 +16454,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -18296,6 +16469,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", @@ -18310,6 +16484,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -21961,21 +20136,6 @@ "node": ">=8" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -21998,18 +20158,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.get": { @@ -22190,6 +20343,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -22205,6 +20359,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -23194,7 +21349,6 @@ "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" @@ -23204,7 +21358,6 @@ "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" @@ -23535,6 +21688,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -23850,6 +22004,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, "license": "MIT", "dependencies": { "process-on-spawn": "^1.0.0" @@ -24299,6 +22454,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", @@ -24340,6 +22496,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -24351,12 +22508,14 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, "license": "MIT" }, "node_modules/nyc/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -24370,6 +22529,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -24384,6 +22544,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -24404,6 +22565,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.7.5", @@ -24419,6 +22581,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -24431,6 +22594,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -24446,6 +22610,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -24461,6 +22626,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -24473,6 +22639,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -24485,6 +22652,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -24494,6 +22662,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -24508,12 +22677,14 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, "license": "ISC" }, "node_modules/nyc/node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^6.0.0", @@ -24536,6 +22707,7 @@ "version": "18.1.3", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -25008,6 +23180,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, "license": "ISC", "dependencies": { "graceful-fs": "^4.1.15", @@ -25545,6 +23718,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -25664,6 +23838,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "license": "MIT", "dependencies": { "find-up": "^4.0.0" @@ -25676,6 +23851,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -25689,6 +23865,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -25701,6 +23878,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -25716,6 +23894,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -25797,6 +23976,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -26018,6 +24198,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.1.0.tgz", "integrity": "sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==", + "dev": true, "license": "MIT", "dependencies": { "fromentries": "^1.2.0" @@ -26349,7 +24530,6 @@ "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": { @@ -26493,6 +24673,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -26594,6 +24775,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -27184,28 +25366,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -27239,51 +25399,11 @@ "url": "https://github.com/sponsors/mysticatea" } }, - "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, "license": "ISC", "dependencies": { "es6-error": "^4.0.1" @@ -27390,6 +25510,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -27408,6 +25529,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, "license": "ISC" }, "node_modules/requireindex": { @@ -27566,6 +25688,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -27582,6 +25705,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -27602,6 +25726,7 @@ "version": "4.52.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -27780,7 +25905,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -27817,49 +25942,10 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -27875,6 +25961,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -27934,6 +26021,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -27946,6 +26034,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -28040,6 +26129,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/sigstore": { @@ -28189,6 +26279,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -28235,6 +26326,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^2.0.0", @@ -28252,6 +26344,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -28265,6 +26358,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -28274,6 +26368,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^6.0.0" @@ -28515,6 +26610,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -28632,6 +26728,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -28658,6 +26755,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -29413,21 +27511,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -29543,114 +27626,6 @@ "node": ">=4" } }, - "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", - "devOptional": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, - "license": "MIT", - "peer": true - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -30360,6 +28335,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "license": "MIT", "dependencies": { "is-typedarray": "^1.0.0" @@ -30442,53 +28418,11 @@ "react": ">=15.0.0" } }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } + "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", @@ -31060,6 +28994,7 @@ "version": "5.4.20", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -31235,6 +29170,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31251,6 +29187,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31267,6 +29204,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31283,6 +29221,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31299,6 +29238,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31315,6 +29255,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31331,6 +29272,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31347,6 +29289,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31363,6 +29306,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31379,6 +29323,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31395,6 +29340,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31411,6 +29357,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31427,6 +29374,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31443,6 +29391,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31459,6 +29408,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31475,6 +29425,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31491,6 +29442,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31507,6 +29459,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31523,6 +29476,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31539,6 +29493,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31555,6 +29510,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31571,6 +29527,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31587,6 +29544,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -31600,6 +29558,7 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -31814,21 +29773,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -31855,66 +29799,6 @@ "node": ">=12" } }, - "node_modules/webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", - "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -31922,17 +29806,6 @@ "dev": true, "license": "MIT" }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -31987,6 +29860,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -32067,6 +29941,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, "license": "ISC" }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index b8745fb2e..c8ea579ae 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": "file:../dataverse-client-javascript", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.5a9f204", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", diff --git a/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx index 5a42c524f..d4b9345bf 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx @@ -1,12 +1,3 @@ -/** - * 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' @@ -25,11 +16,6 @@ export interface FileUploaderPanelCoreProps { 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 = ({ @@ -37,40 +23,21 @@ export const FileUploaderPanelCore = ({ datasetPersistentId, onCancel, onFilesAddedSuccess, - onFileReplacedSuccess, - onRegisterUnsavedChangesCheck + onFileReplacedSuccess }: FileUploaderPanelCoreProps) => { const { t } = useTranslation('shared') const { - fileUploaderState: { - files, - isSaving, - uploadingToCancelMap, - replaceOperationInfo, - addFilesToDatasetOperationInfo - }, + fileUploaderState: { 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() diff --git a/src/sections/shared/file-uploader/context/FileUploaderContext.tsx b/src/sections/shared/file-uploader/context/FileUploaderContext.tsx index fa981a2e1..ad1c5d464 100644 --- a/src/sections/shared/file-uploader/context/FileUploaderContext.tsx +++ b/src/sections/shared/file-uploader/context/FileUploaderContext.tsx @@ -9,6 +9,7 @@ import { ReplaceOperationInfo, AddFilesToDatasetOperationInfo } from './fileUploaderReducer' +import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' export interface FileUploaderContextValue { fileUploaderState: FileUploaderState @@ -88,7 +89,9 @@ export const FileUploaderProvider = ({ children, initialConfig }: FileUploaderPr () => Object.values(fileUploaderState.files).filter( (file): file is FileUploadState & { storageId: string; checksumValue: string } => - file.status === FileUploadStatus.DONE && !!file.storageId && !!file.checksumValue + file.status === FileUploadStatus.DONE && + !!file.storageId && + (file.checksumAlgorithm === FixityAlgorithm.NONE || file.checksumValue !== undefined) ), [fileUploaderState.files] ) diff --git a/src/sections/shared/file-uploader/context/fileUploaderReducer.ts b/src/sections/shared/file-uploader/context/fileUploaderReducer.ts index 6b57baeeb..09cfbddfa 100644 --- a/src/sections/shared/file-uploader/context/fileUploaderReducer.ts +++ b/src/sections/shared/file-uploader/context/fileUploaderReducer.ts @@ -150,14 +150,17 @@ export const fileUploaderReducer = ( case 'ADD_UPLOADING_TO_CANCEL': { const { key, cancel } = action - state.uploadingToCancelMap.set(key, cancel) - return state + return { + ...state, + uploadingToCancelMap: new Map(state.uploadingToCancelMap).set(key, cancel) + } } case 'REMOVE_UPLOADING_TO_CANCEL': { const { key } = action - state.uploadingToCancelMap.delete(key) - return state + const uploadingToCancelMap = new Map(state.uploadingToCancelMap) + uploadingToCancelMap.delete(key) + return { ...state, uploadingToCancelMap } } case 'SET_REPLACE_OPERATION_INFO': { diff --git a/src/sections/shared/file-uploader/useFileUploadOperations.ts b/src/sections/shared/file-uploader/useFileUploadOperations.ts index 56c3fd595..6afc85db5 100644 --- a/src/sections/shared/file-uploader/useFileUploadOperations.ts +++ b/src/sections/shared/file-uploader/useFileUploadOperations.ts @@ -1,10 +1,3 @@ -/** - * useFileUploadOperations - Shared hook for file upload operations - * - * This hook provides the core upload logic (uploading files, handling directories, - * computing checksums) that can be shared between the main SPA and standalone uploader. - */ - import { useCallback, useRef } from 'react' import { Semaphore } from 'async-mutex' import { uploadFile } from '@/files/domain/useCases/uploadFile' @@ -33,10 +26,7 @@ export interface FileUploadOperationsConfig { getFileByKey: (key: string) => { status: string } | undefined addUploadingToCancel: (key: string, cancel: () => void) => void removeUploadingToCancel: (key: string) => void - // Optional callbacks for notifications onFileSkipped?: (reason: 'ds_store' | 'already_uploaded', file: File) => void - onUploadCanceled?: (fileName: string) => void - // Optional callback for pre-upload validation (e.g., file type check for replace) validateBeforeUpload?: (file: File) => Promise } @@ -53,10 +43,6 @@ export interface FileUploadOperations { semaphore: Semaphore } -/** - * Hook that provides file upload operations. - * Manages the upload process, directory traversal, and checksum calculation. - */ export function useFileUploadOperations(config: FileUploadOperationsConfig): FileUploadOperations { const { fileRepository, @@ -71,7 +57,6 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil validateBeforeUpload } = config - // Use a ref to persist semaphore across renders const semaphoreRef = useRef(new Semaphore(CONCURRENT_UPLOADS_LIMIT)) const onFileUploadFailed = useCallback( @@ -87,8 +72,9 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil const fileKey = FileUploaderHelper.getFileKey(file) try { - // Skip checksum calculation if algorithm is NONE - if (checksumAlgorithm !== FixityAlgorithm.NONE) { + if (checksumAlgorithm === FixityAlgorithm.NONE) { + updateFile(fileKey, { checksumValue: '' }) + } else { const checksumValue = await FileUploaderHelper.getChecksum(file, checksumAlgorithm) updateFile(fileKey, { checksumValue }) } @@ -102,20 +88,17 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil const uploadOneFile = useCallback( async (file: File) => { - // Skip .DS_Store files if (FileUploaderHelper.isDS_StoreFile(file)) { onFileSkipped?.('ds_store', file) return } - // Check if file already uploaded const fileKey = FileUploaderHelper.getFileKey(file) if (getFileByKey(fileKey)) { onFileSkipped?.('already_uploaded', file) return } - // Run optional pre-upload validation if (validateBeforeUpload) { const shouldContinue = await validateBeforeUpload(file) if (!shouldContinue) { @@ -162,29 +145,37 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil const addFromDir = useCallback( (dir: FileSystemDirectoryEntry) => { const reader = dir.createReader() + const readNextBatch = () => { + reader.readEntries((entries) => { + if (entries.length === 0) { + return + } - 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 - }) + 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.startsWith('/') ? entry.fullPath.slice(1) : entry.fullPath, - writable: true - }) + Object.defineProperty(fileWithPath, 'webkitRelativePath', { + value: entry.fullPath.startsWith('/') ? entry.fullPath.slice(1) : entry.fullPath, + writable: true + }) - void uploadOneFile(fileWithPath) - }) - } else if (entry.isDirectory) { - addFromDir(entry as FileSystemDirectoryEntry) - } + void uploadOneFile(fileWithPath) + }) + } else if (entry.isDirectory) { + addFromDir(entry as FileSystemDirectoryEntry) + } + }) + readNextBatch() }) - }) + } + + readNextBatch() }, [uploadOneFile] ) @@ -202,7 +193,6 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil handledViaEntry = true const fse = entry as FileSystemFileEntry fse.file((file) => { - // Create a new File with webkitRelativePath set for consistency const fileWithPath = new File([file], file.name, { type: file.type, lastModified: file.lastModified @@ -216,7 +206,6 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil } }) - // Fallback for browsers where webkitGetAsEntry() returns null (e.g., Firefox in some cases) if (!handledViaEntry && fallbackFiles && fallbackFiles.length > 0) { Array.from(fallbackFiles).forEach((file) => { void uploadOneFile(file) @@ -229,7 +218,6 @@ export function useFileUploadOperations(config: FileUploadOperationsConfig): Fil const retryUpload = useCallback( async (file: File) => { const fileKey = FileUploaderHelper.getFileKey(file) - // Reset status to uploading before retry updateFile(fileKey, { status: FileUploadStatus.UPLOADING, progress: 0 }) await semaphoreRef.current.acquire(1) diff --git a/src/sections/shared/file-uploader/useFileUploadState.ts b/src/sections/shared/file-uploader/useFileUploadState.ts index 3031db361..b8d8a4585 100644 --- a/src/sections/shared/file-uploader/useFileUploadState.ts +++ b/src/sections/shared/file-uploader/useFileUploadState.ts @@ -1,21 +1,9 @@ -/** - * useFileUploadState - Shared hook for managing file upload state - * - * This hook provides the core state management logic for file uploads, - * usable by both the main SPA (via context) and the standalone uploader. - */ - import { useState, useMemo, useCallback } from 'react' import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' import { FileUploaderHelper } from './FileUploaderHelper' +import { FileUploadStatus } from './context/fileUploaderReducer' -// Re-export from reducer for backward compatibility, but define our own minimal enum -// for the standalone uploader that doesn't need the REMOVED status -export enum FileUploadStatus { - UPLOADING = 'uploading', - DONE = 'done', - FAILED = 'failed' -} +export { FileUploadStatus } from './context/fileUploaderReducer' export interface FileUploadState { key: string @@ -93,7 +81,9 @@ export function useFileUploadState(): FileUploadStateActions { const uploadedFiles = useMemo(() => { return Object.values(files).filter( (f): f is UploadedFile => - f.status === FileUploadStatus.DONE && !!f.storageId && !!f.checksumValue + f.status === FileUploadStatus.DONE && + !!f.storageId && + (f.checksumAlgorithm === FixityAlgorithm.NONE || f.checksumValue !== undefined) ) }, [files]) diff --git a/src/standalone-uploader/StandaloneFileRepository.ts b/src/standalone-uploader/StandaloneFileRepository.ts index e1976d891..2ca3dcdcf 100644 --- a/src/standalone-uploader/StandaloneFileRepository.ts +++ b/src/standalone-uploader/StandaloneFileRepository.ts @@ -1,21 +1,12 @@ -/** - * Standalone File Repository - * - * A simplified file repository for the standalone uploader that doesn't depend on - * the main app's config.js. It only implements the methods needed for uploading files. - */ - import { uploadFile as jsUploadFile, addUploadedFilesToDataset, UploadedFileDTO } from '@iqss/dataverse-client-javascript' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import { FixityAlgorithm } from '@/files/domain/models/FixityAlgorithm' +import { UploaderFileRepository } from '@/sections/shared/file-uploader/types' -export class StandaloneFileRepository - implements Pick -{ +export class StandaloneFileRepository implements UploaderFileRepository { private siteUrl: string constructor(siteUrl: string) { @@ -76,55 +67,4 @@ export class StandaloneFileRepository return FixityAlgorithm.MD5 } } - - // These methods are not used by the standalone uploader but are required by the interface - // They will throw if called - getById(): Promise { - throw new Error('Not implemented in standalone mode') - } - getByDatasetPersistentId(): Promise { - throw new Error('Not implemented in standalone mode') - } - getByDatasetPersistentIdAndVersion(): Promise { - throw new Error('Not implemented in standalone mode') - } - getFilesCountInfoByDatasetPersistentId(): Promise { - throw new Error('Not implemented in standalone mode') - } - getFilesTotalDownloadSizeByDatasetPersistentId(): Promise { - throw new Error('Not implemented in standalone mode') - } - getMultipleFileDownloadUrl(): never { - throw new Error('Not implemented in standalone mode') - } - getUserPermissionsById(): Promise { - throw new Error('Not implemented in standalone mode') - } - getDataTablesById(): Promise { - throw new Error('Not implemented in standalone mode') - } - getFileCitation(): Promise { - throw new Error('Not implemented in standalone mode') - } - deleteFile(): Promise { - throw new Error('Not implemented in standalone mode') - } - replaceFile(): Promise { - throw new Error('Not implemented in standalone mode') - } - restrictFile(): Promise { - throw new Error('Not implemented in standalone mode') - } - updateMetadata(): Promise { - throw new Error('Not implemented in standalone mode') - } - getVersionSummaries(): Promise { - throw new Error('Not implemented in standalone mode') - } - updateTabularTags(): Promise { - throw new Error('Not implemented in standalone mode') - } - updateFileCategories(): Promise { - throw new Error('Not implemented in standalone mode') - } } diff --git a/tests/component/sections/shared/file-uploader/fileUploaderReducer.spec.tsx b/tests/component/sections/shared/file-uploader/fileUploaderReducer.spec.tsx index 387930da3..994d9cba4 100644 --- a/tests/component/sections/shared/file-uploader/fileUploaderReducer.spec.tsx +++ b/tests/component/sections/shared/file-uploader/fileUploaderReducer.spec.tsx @@ -205,13 +205,15 @@ describe('fileUploaderReducer', () => { }) expect(state.uploadingToCancelMap.size).to.equal(1) + expect(state.uploadingToCancelMap).to.not.equal(initialStateAddFiles.uploadingToCancelMap) }) }) describe('REMOVE_UPLOADING_TO_CANCEL', () => { it('should remove a cancel function', () => { + const previousMap = new Map([['file.txt', () => {}]]) const state = fileUploaderReducer( - { ...initialStateAddFiles, uploadingToCancelMap: new Map([['file.txt', () => {}]]) }, + { ...initialStateAddFiles, uploadingToCancelMap: previousMap }, { type: 'REMOVE_UPLOADING_TO_CANCEL', key: 'file.txt' @@ -219,6 +221,7 @@ describe('fileUploaderReducer', () => { ) expect(state.uploadingToCancelMap).to.not.have.property('file.txt') + expect(state.uploadingToCancelMap).to.not.equal(previousMap) }) }) diff --git a/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx b/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx index 1d8113dea..1e5aa29ff 100644 --- a/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx +++ b/tests/component/sections/shared/file-uploader/useFileUploadOperations.spec.tsx @@ -15,6 +15,25 @@ describe('useFileUploadOperations', () => { return new File([content], name, { type: 'text/plain', lastModified: Date.now() }) } + const createFileEntry = (file: File, fullPath: string): FileSystemFileEntry => + ({ + isFile: true, + isDirectory: false, + fullPath, + file: (successCallback: (file: File) => void) => successCallback(file) + } as FileSystemFileEntry) + + const createDirectoryEntry = (batches: FileSystemEntry[][]): FileSystemDirectoryEntry => + ({ + isFile: false, + isDirectory: true, + createReader: () => ({ + readEntries: (successCallback: (entries: FileSystemEntry[]) => void) => { + successCallback(batches.shift() ?? []) + } + }) + } as FileSystemDirectoryReader) + const createConfig = ( overrides: Partial = {} ): FileUploadOperationsConfig => ({ @@ -104,6 +123,40 @@ describe('useFileUploadOperations', () => { expect(cancelFn).to.be.a('function') }) + it('should set an empty checksum when checksum calculation is disabled', async () => { + const updateFile = cy.stub() + const fileRepository = { + uploadFile: cy + .stub() + .callsFake( + ( + _datasetId: string, + _fileHolder: { file: File }, + _progress: (now: number) => void, + _abortController: AbortController, + getStorageId: (storageId: string) => void + ) => { + getStorageId('storage-1') + return Promise.resolve() + } + ) + } as unknown as FileRepository + const config = createConfig({ + fileRepository, + checksumAlgorithm: FixityAlgorithm.NONE, + updateFile + }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + await act(async () => { + await result.current.uploadOneFile(createMockFile('test.txt')) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(updateFile).to.have.been.calledWith('test.txt', { checksumValue: '' }) + }) + it('should run validateBeforeUpload if provided', async () => { const validateBeforeUpload = cy.stub().resolves(true) const addFile = cy.stub() @@ -203,5 +256,27 @@ describe('useFileUploadOperations', () => { expect(result.current.addFromDir).to.be.a('function') }) + + it('should read all directory entry batches', async () => { + const addFile = cy.stub() + const config = createConfig({ addFile }) + + const { result } = renderHook(() => useFileUploadOperations(config)) + + const firstFile = createMockFile('first.txt') + const secondFile = createMockFile('second.txt') + const directory = createDirectoryEntry([ + [createFileEntry(firstFile, '/folder/first.txt')], + [createFileEntry(secondFile, '/folder/second.txt')], + [] + ]) + + await act(async () => { + result.current.addFromDir(directory) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(addFile).to.have.been.calledTwice + }) }) }) diff --git a/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx b/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx index e7fd4fc42..d526fe4f6 100644 --- a/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx +++ b/tests/component/sections/shared/file-uploader/useFileUploadState.spec.tsx @@ -216,6 +216,27 @@ describe('useFileUploadState', () => { expect(result.current.uploadedFiles[0].storageId).to.equal('storage-123') expect(result.current.uploadedFiles[0].checksumValue).to.equal('abc123') }) + + it('should add file to uploadedFiles when checksum calculation is disabled', () => { + const { result } = renderHook(() => useFileUploadState()) + const mockFile = createMockFile('test.txt') + + act(() => { + result.current.addFile(mockFile, FixityAlgorithm.NONE) + }) + + const fileKey = Object.keys(result.current.files)[0] + + act(() => { + result.current.updateFile(fileKey, { + status: FileUploadStatus.DONE, + storageId: 'storage-123', + checksumValue: '' + }) + }) + + expect(result.current.uploadedFiles).to.have.length(1) + }) }) describe('removeFile', () => { From c907098358e30ab9b1125d44b4901157f3f2c935 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:12:22 +0200 Subject: [PATCH 015/110] Restore version 0.3.1 and drop drive-by edits from develop merge The merge of develop (42f55335a) accidentally rolled back the v0.3.1 release entry in CHANGELOG.md and the corresponding package.json version bump. Those should not have been part of this branch's diff. Also reverts two unrelated edits the merge brought in: - public/config.js: banner URL changed from /modern/ to /spa/. - @types/turndown was added as a devDependency; not needed (turndown is already a runtime dep without typings, and tsc passes without the @types package). Adds a TODO comment in src/standalone-uploader/index.tsx noting that DataverseApiAuthMechanism is currently deep-imported from the SDK's internal path; once the SDK prerelease that re-exports it from core/index.ts is published, the import can move to the package's public surface. --- CHANGELOG.md | 8 ++++++-- package.json | 3 +-- public/config.js | 2 +- src/standalone-uploader/index.tsx | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfbf8f52..7022c1536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,18 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Changed -- Added pagination to the Versions tabs on Dataset and File pages so version summaries are loaded and displayed one page at a time. - ### Fixed ### Removed --- +## [v0.3.1] -- 2026-04-30 + +- Added pagination to the Versions tabs on Dataset and File pages so version summaries are loaded and displayed one page at a time. + +--- + ## [v0.3.0] -- 2026-04-24 ### Added diff --git a/package.json b/package.json index aa5d322cf..6ea00877b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "node": ">=22 <23" }, "name": "dataverse-frontend", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "private": true, "workspaces": { @@ -134,7 +134,6 @@ "@types/chai-as-promised": "7.1.5", "@types/node-sass": "4.11.3", "@types/sinon": "10.0.13", - "@types/turndown": "^5.0.6", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@vitejs/plugin-react": "4.3.1", diff --git a/public/config.js b/public/config.js index 7c214c9fb..b2b9d2087 100644 --- a/public/config.js +++ b/public/config.js @@ -7,7 +7,7 @@ window.__APP_CONFIG__ = { backendUrl: 'http://localhost:8000', // Optional banner shown at the top of the app when set. Basic HTML markup is supported. bannerMessage: - "You are using the new Dataverse Modern version. This is an early release and some features from the original site are not yet available. Please see the Project Roadmap for details.", + "You are using the new Dataverse Modern version. This is an early release and some features from the original site are not yet available. Please see the Project Roadmap for details.", // OIDC provider settings oidc: { clientId: 'test', diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx index c35461486..52cd05c6a 100644 --- a/src/standalone-uploader/index.tsx +++ b/src/standalone-uploader/index.tsx @@ -4,6 +4,8 @@ import i18next from 'i18next' import { initReactI18next } from 'react-i18next' import I18NextHttpBackend from 'i18next-http-backend' import { ApiConfig } from '@iqss/dataverse-client-javascript' +// TODO: switch to `import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript'` +// once the SDK prerelease for #403 is republished — the public re-export is in `core/index.ts`. import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dist/core/infra/repositories/ApiConfig' import { ToastContainer } from 'react-toastify' import { StandaloneFileUploaderPanel } from './StandaloneFileUploaderPanel' From 2e27e26ab393f003e5c6321c74b8989f99a690ca Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:19:42 +0200 Subject: [PATCH 016/110] Add file tree domain models, repository, and use cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDD domain layer for the new tree view (#6691). Lives next to the existing FileRepository and follows the same layout conventions: - models: FileTreeItem (FileTreeFolder | FileTreeFile + type guards), FileTreePage (response shape with opaque nextCursor, effective order/include, optional approximateCount). - repositories: FileTreeRepository.getNode(...) — single-page lookup. - use cases: - getFileTreeNode: thin error-mapping wrapper. - enumerateFileTreeFiles: recursive enumerator used at download time. Re-pages each folder via the same repository contract, returns a deduplicated flat list of descendant files. Lazy by design — no pre-fetch on mount. Plus FileTreeItemMother / FileTreePageMother and a unit spec for the enumerator covering paginated walking and overlapping-paths dedup. --- src/files/domain/models/FileTreeItem.ts | 33 ++++++++ src/files/domain/models/FileTreePage.ts | 22 ++++++ .../domain/repositories/FileTreeRepository.ts | 18 +++++ .../domain/useCases/enumerateFileTreeFiles.ts | 50 ++++++++++++ src/files/domain/useCases/getFileTreeNode.ts | 11 +++ .../files/domain/models/FileTreeItemMother.ts | 34 +++++++++ .../files/domain/models/FileTreePageMother.ts | 20 +++++ .../useCases/enumerateFileTreeFiles.spec.ts | 76 +++++++++++++++++++ 8 files changed, 264 insertions(+) create mode 100644 src/files/domain/models/FileTreeItem.ts create mode 100644 src/files/domain/models/FileTreePage.ts create mode 100644 src/files/domain/repositories/FileTreeRepository.ts create mode 100644 src/files/domain/useCases/enumerateFileTreeFiles.ts create mode 100644 src/files/domain/useCases/getFileTreeNode.ts create mode 100644 tests/component/files/domain/models/FileTreeItemMother.ts create mode 100644 tests/component/files/domain/models/FileTreePageMother.ts create mode 100644 tests/component/files/domain/useCases/enumerateFileTreeFiles.spec.ts diff --git a/src/files/domain/models/FileTreeItem.ts b/src/files/domain/models/FileTreeItem.ts new file mode 100644 index 000000000..07b93a2c3 --- /dev/null +++ b/src/files/domain/models/FileTreeItem.ts @@ -0,0 +1,33 @@ +import { FileAccess } from './FileAccess' + +export enum FileTreeItemType { + FOLDER = 'folder', + FILE = 'file' +} + +export interface FileTreeFolder { + type: FileTreeItemType.FOLDER + name: string + path: string + counts?: { files: number; folders: number } +} + +export interface FileTreeFile { + type: FileTreeItemType.FILE + id: number + name: string + path: string + size: number + contentType?: string + access?: FileAccess + checksum?: { type: string; value: string } + downloadUrl: string +} + +export type FileTreeItem = FileTreeFolder | FileTreeFile + +export const isFileTreeFolder = (item: FileTreeItem): item is FileTreeFolder => + item.type === FileTreeItemType.FOLDER + +export const isFileTreeFile = (item: FileTreeItem): item is FileTreeFile => + item.type === FileTreeItemType.FILE diff --git a/src/files/domain/models/FileTreePage.ts b/src/files/domain/models/FileTreePage.ts new file mode 100644 index 000000000..cbd89eb09 --- /dev/null +++ b/src/files/domain/models/FileTreePage.ts @@ -0,0 +1,22 @@ +import { FileTreeItem } from './FileTreeItem' + +export enum FileTreeInclude { + ALL = 'all', + FOLDERS = 'folders', + FILES = 'files' +} + +export enum FileTreeOrder { + NAME_AZ = 'NameAZ', + NAME_ZA = 'NameZA' +} + +export interface FileTreePage { + path: string + items: FileTreeItem[] + nextCursor: string | null + limit: number + order: FileTreeOrder + include: FileTreeInclude + approximateCount?: number +} diff --git a/src/files/domain/repositories/FileTreeRepository.ts b/src/files/domain/repositories/FileTreeRepository.ts new file mode 100644 index 000000000..d44db5477 --- /dev/null +++ b/src/files/domain/repositories/FileTreeRepository.ts @@ -0,0 +1,18 @@ +import { FileTreePage, FileTreeInclude, FileTreeOrder } from '../models/FileTreePage' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +export interface GetFileTreeNodeParams { + datasetPersistentId: string + datasetVersion: DatasetVersion + path?: string + limit?: number + cursor?: string + include?: FileTreeInclude + order?: FileTreeOrder + includeDeaccessioned?: boolean + originals?: boolean +} + +export interface FileTreeRepository { + getNode: (params: GetFileTreeNodeParams) => Promise +} diff --git a/src/files/domain/useCases/enumerateFileTreeFiles.ts b/src/files/domain/useCases/enumerateFileTreeFiles.ts new file mode 100644 index 000000000..48a04369b --- /dev/null +++ b/src/files/domain/useCases/enumerateFileTreeFiles.ts @@ -0,0 +1,50 @@ +import { FileTreeFile, isFileTreeFile, isFileTreeFolder } from '../models/FileTreeItem' +import { FileTreeRepository } from '../repositories/FileTreeRepository' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +export interface EnumerateFileTreeFilesParams { + datasetPersistentId: string + datasetVersion: DatasetVersion + paths: string[] + limit?: number + signal?: AbortSignal +} + +export async function enumerateFileTreeFiles( + repository: FileTreeRepository, + params: EnumerateFileTreeFilesParams +): Promise { + const { datasetPersistentId, datasetVersion, paths, limit = 500, signal } = params + const collected: FileTreeFile[] = [] + const seen = new Set() + const queue = [...paths] + + while (queue.length > 0) { + if (signal?.aborted) { + throw new Error('Enumeration aborted') + } + const path = queue.shift() as string + let cursor: string | undefined + do { + const page = await repository.getNode({ + datasetPersistentId, + datasetVersion, + path, + limit, + cursor + }) + for (const item of page.items) { + if (isFileTreeFile(item)) { + if (!seen.has(item.id)) { + seen.add(item.id) + collected.push(item) + } + } else if (isFileTreeFolder(item)) { + queue.push(item.path) + } + } + cursor = page.nextCursor ?? undefined + } while (cursor) + } + return collected +} diff --git a/src/files/domain/useCases/getFileTreeNode.ts b/src/files/domain/useCases/getFileTreeNode.ts new file mode 100644 index 000000000..9ac4a2cba --- /dev/null +++ b/src/files/domain/useCases/getFileTreeNode.ts @@ -0,0 +1,11 @@ +import { FileTreePage } from '../models/FileTreePage' +import { FileTreeRepository, GetFileTreeNodeParams } from '../repositories/FileTreeRepository' + +export function getFileTreeNode( + repository: FileTreeRepository, + params: GetFileTreeNodeParams +): Promise { + return repository.getNode(params).catch(() => { + throw new Error('There was an error getting the file tree node') + }) +} diff --git a/tests/component/files/domain/models/FileTreeItemMother.ts b/tests/component/files/domain/models/FileTreeItemMother.ts new file mode 100644 index 000000000..9cd530d04 --- /dev/null +++ b/tests/component/files/domain/models/FileTreeItemMother.ts @@ -0,0 +1,34 @@ +import { + FileTreeFile, + FileTreeFolder, + FileTreeItemType +} from '../../../../../src/files/domain/models/FileTreeItem' + +export class FileTreeFolderMother { + static create(props: Partial & { name: string; path: string }): FileTreeFolder { + return { + type: FileTreeItemType.FOLDER, + name: props.name, + path: props.path, + counts: props.counts ?? { files: 0, folders: 0 } + } + } +} + +export class FileTreeFileMother { + static create( + props: Partial & { id: number; name: string; path: string } + ): FileTreeFile { + return { + type: FileTreeItemType.FILE, + id: props.id, + name: props.name, + path: props.path, + size: props.size ?? 1024, + contentType: props.contentType ?? 'text/plain', + access: props.access, + checksum: props.checksum, + downloadUrl: props.downloadUrl ?? `/api/access/datafile/${props.id}` + } + } +} diff --git a/tests/component/files/domain/models/FileTreePageMother.ts b/tests/component/files/domain/models/FileTreePageMother.ts new file mode 100644 index 000000000..37ccb5c63 --- /dev/null +++ b/tests/component/files/domain/models/FileTreePageMother.ts @@ -0,0 +1,20 @@ +import { + FileTreeInclude, + FileTreeOrder, + FileTreePage +} from '../../../../../src/files/domain/models/FileTreePage' +import { FileTreeItem } from '../../../../../src/files/domain/models/FileTreeItem' + +export class FileTreePageMother { + static create(props: Partial & { items: FileTreeItem[] }): FileTreePage { + return { + path: props.path ?? '', + items: props.items, + nextCursor: props.nextCursor ?? null, + limit: props.limit ?? 100, + order: props.order ?? FileTreeOrder.NAME_AZ, + include: props.include ?? FileTreeInclude.ALL, + approximateCount: props.approximateCount ?? props.items.length + } + } +} diff --git a/tests/component/files/domain/useCases/enumerateFileTreeFiles.spec.ts b/tests/component/files/domain/useCases/enumerateFileTreeFiles.spec.ts new file mode 100644 index 000000000..8953e22ba --- /dev/null +++ b/tests/component/files/domain/useCases/enumerateFileTreeFiles.spec.ts @@ -0,0 +1,76 @@ +import { enumerateFileTreeFiles } from '../../../../../src/files/domain/useCases/enumerateFileTreeFiles' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../../../../src/files/domain/repositories/FileTreeRepository' +import { FileTreePage } from '../../../../../src/files/domain/models/FileTreePage' +import { DatasetVersionMother } from '../../../dataset/domain/models/DatasetMother' +import { FileTreeFileMother, FileTreeFolderMother } from '../models/FileTreeItemMother' +import { FileTreePageMother } from '../models/FileTreePageMother' + +class FakeRepo implements FileTreeRepository { + constructor(private readonly pages: Map) {} + getNode(params: GetFileTreeNodeParams): Promise { + const queue = this.pages.get(params.path ?? '') + if (!queue || queue.length === 0) { + return Promise.reject(new Error(`No mock for path "${params.path ?? ''}"`)) + } + if (params.cursor) { + const idx = queue.findIndex((p, i) => i > 0 && queue[i - 1].nextCursor === params.cursor) + if (idx === -1) { + return Promise.reject(new Error('Bad cursor')) + } + return Promise.resolve(queue[idx]) + } + return Promise.resolve(queue[0]) + } +} + +const datasetVersion = DatasetVersionMother.create() + +describe('enumerateFileTreeFiles', () => { + it('walks paginated children and recursive folders to produce a flat file list', async () => { + const sub = FileTreeFolderMother.create({ name: 'sub', path: 'data/sub' }) + const fileA = FileTreeFileMother.create({ id: 1, name: 'a.txt', path: 'data/a.txt' }) + const fileB = FileTreeFileMother.create({ id: 2, name: 'b.txt', path: 'data/b.txt' }) + const fileC = FileTreeFileMother.create({ id: 3, name: 'c.txt', path: 'data/sub/c.txt' }) + + const dataPages = [ + FileTreePageMother.create({ path: 'data', items: [sub, fileA], nextCursor: 'p2' }), + FileTreePageMother.create({ path: 'data', items: [fileB], nextCursor: null }) + ] + const subPages = [FileTreePageMother.create({ path: 'data/sub', items: [fileC] })] + + const repo = new FakeRepo( + new Map([ + ['data', dataPages], + ['data/sub', subPages] + ]) + ) + + const files = await enumerateFileTreeFiles(repo, { + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + paths: ['data'] + }) + + expect(files.map((f) => f.id).sort()).to.deep.equal([1, 2, 3]) + }) + + it('deduplicates files reachable via overlapping starting paths', async () => { + const fileA = FileTreeFileMother.create({ id: 9, name: 'a.txt', path: 'data/a.txt' }) + const fileB = FileTreeFileMother.create({ id: 10, name: 'b.txt', path: 'shared/b.txt' }) + const repo = new FakeRepo( + new Map([ + ['data', [FileTreePageMother.create({ path: 'data', items: [fileA] })]], + ['shared', [FileTreePageMother.create({ path: 'shared', items: [fileA, fileB] })]] + ]) + ) + const files = await enumerateFileTreeFiles(repo, { + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + paths: ['data', 'shared'] + }) + expect(files.length).to.equal(2) + }) +}) From 30a0d15cf19d637a0f6de1c5874ba3a71a69b298 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:21:23 +0200 Subject: [PATCH 017/110] Add tree repository implementations (JSDataverse + previews fallback) Two FileTreeRepository implementations on the infrastructure side: - FileTreeJSDataverseRepository: calls the new dataset-version tree endpoint (GET /api/datasets/{id}/versions/{versionId}/tree) via axios. Carries a TODO marker noting the planned switch to the SDK helper (listDatasetTreeNode) once the matching SDK prerelease is published; the wire format is already aligned. On 404/405/501 the repository transparently falls back to the in-memory previews adapter so the SPA stays usable in mixed-version deployments. - FileTreeFromPreviewsRepository: synthesises a tree from the existing FilePreview[] returned by FileRepository.getAllByDatasetPersistentIdWithCount. Groups previews by directoryLabel, applies include/order, paginates with an opaque "mem:" cursor in memory. Cached per (persistentId, version). Folder counts track distinct subfolder names rather than per-file occurrences. Tests cover root grouping, immediate children, include filter, cursor pagination stability, descending order, invalid-cursor rejection, caching, and the corrected folder-count semantics. --- .../FileTreeFromPreviewsRepository.ts | 226 +++++++++++++++++ .../FileTreeJSDataverseRepository.ts | 184 ++++++++++++++ .../FileTreeFromPreviewsRepository.spec.ts | 238 ++++++++++++++++++ 3 files changed, 648 insertions(+) create mode 100644 src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts create mode 100644 src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts create mode 100644 tests/component/files/infrastructure/repositories/FileTreeFromPreviewsRepository.spec.ts diff --git a/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts new file mode 100644 index 000000000..72036753e --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts @@ -0,0 +1,226 @@ +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + FileTreeItemType +} from '../../domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../domain/repositories/FileTreeRepository' +import { FileRepository } from '../../domain/repositories/FileRepository' +import { FilePreview } from '../../domain/models/FilePreview' +import { FilePaginationInfo } from '../../domain/models/FilePaginationInfo' +import { FileCriteria } from '../../domain/models/FileCriteria' +import { DatasetVersion } from '../../../dataset/domain/models/Dataset' + +const PAGE_SIZE = 1000 + +/** + * Tree-shaped view of an existing dataset file listing. + * + * This adapter is used while the dedicated paginated tree endpoint + * (`GET /api/datasets/{id}/versions/{versionId}/tree`) is not deployed yet + * on the target Dataverse instance. It pulls the dataset file previews via + * the existing `FileRepository` (paginating internally), groups them by + * `directoryLabel`, and exposes the same `FileTreeRepository` contract that + * the new endpoint will satisfy. + * + * Once the endpoint and SDK helper land, the SPA can swap this for a thin + * `FileTreeJSDataverseRepository` that calls the SDK directly. The UI does + * not need to change. + */ +export class FileTreeFromPreviewsRepository implements FileTreeRepository { + private cache = new Map() + + constructor( + private readonly fileRepository: FileRepository, + private readonly accessApiBase: string = '/api/access/datafile' + ) {} + + async getNode(params: GetFileTreeNodeParams): Promise { + const path = normalizePath(params.path) + const order = params.order ?? FileTreeOrder.NAME_AZ + const include = params.include ?? FileTreeInclude.ALL + const limit = clampLimit(params.limit) + const previews = await this.loadAllPreviews(params.datasetPersistentId, params.datasetVersion) + const items = collectImmediateChildren(previews, path, order, include, this.accessApiBase) + const offset = parseCursor(params.cursor) + const slice = items.slice(offset, offset + limit) + const nextCursor = offset + limit < items.length ? encodeCursor(offset + limit) : null + return { + path, + items: slice, + nextCursor, + limit, + order, + include, + approximateCount: items.length + } + } + + private async loadAllPreviews( + persistentId: string, + datasetVersion: DatasetVersion + ): Promise { + const key = `${persistentId}::${datasetVersion.number.toString()}` + const cached = this.cache.get(key) + if (cached) { + return cached + } + const all: FilePreview[] = [] + let page = 1 + let total = Number.POSITIVE_INFINITY + const criteria = new FileCriteria() + while (all.length < total) { + const pageInfo = new FilePaginationInfo(page, PAGE_SIZE, total === Infinity ? 0 : total) + const result = await this.fileRepository.getAllByDatasetPersistentIdWithCount( + persistentId, + datasetVersion, + pageInfo, + criteria + ) + total = result.totalFilesCount + all.push(...result.files) + if (result.files.length < PAGE_SIZE) { + break + } + page += 1 + } + this.cache.set(key, all) + return all + } +} + +const CURSOR_PREFIX = 'mem:' + +function clampLimit(limit?: number): number { + if (!limit || limit <= 0) { + return 100 + } + if (limit > 1000) { + return 1000 + } + return Math.floor(limit) +} + +function parseCursor(cursor?: string): number { + if (!cursor) { + return 0 + } + if (!cursor.startsWith(CURSOR_PREFIX)) { + throw new Error('Invalid cursor') + } + const offset = Number.parseInt(cursor.slice(CURSOR_PREFIX.length), 10) + if (!Number.isFinite(offset) || offset < 0) { + throw new Error('Invalid cursor') + } + return offset +} + +function encodeCursor(offset: number): string { + return `${CURSOR_PREFIX}${offset}` +} + +export function normalizePath(input?: string): string { + if (!input) { + return '' + } + return input.replace(/\/+/g, '/').replace(/^\/+/, '').replace(/\/+$/, '') +} + +interface FolderAccumulator { + name: string + path: string + fileCount: number + subfolderNames: Set +} + +function collectImmediateChildren( + previews: FilePreview[], + path: string, + order: FileTreeOrder, + include: FileTreeInclude, + accessApiBase: string +): FileTreeItem[] { + const folders = new Map() + const files: FileTreeFile[] = [] + const prefix = path === '' ? '' : `${path}/` + for (const preview of previews) { + const directory = (preview.metadata.directory ?? '').replace(/^\/+|\/+$/g, '') + if (path !== '' && directory !== path && !directory.startsWith(prefix)) { + continue + } + if (directory === path) { + files.push(buildFile(preview, path, accessApiBase)) + continue + } + const remainder = directory.slice(prefix.length) + const segment = remainder.split('/')[0] + if (!segment) { + continue + } + const folderPath = prefix + segment + let entry = folders.get(folderPath) + if (!entry) { + entry = { + name: segment, + path: folderPath, + fileCount: 0, + subfolderNames: new Set() + } + folders.set(folderPath, entry) + } + entry.fileCount += 1 + if (directory !== folderPath) { + // Track distinct subfolder names (the next path segment after this folder). + const sub = directory.slice(folderPath.length + 1).split('/')[0] + if (sub) { + entry.subfolderNames.add(sub) + } + } + } + + const folderItems: FileTreeFolder[] = Array.from(folders.values()).map((f) => ({ + type: FileTreeItemType.FOLDER, + name: f.name, + path: f.path, + counts: { files: f.fileCount, folders: f.subfolderNames.size } + })) + + sortByName(folderItems, order) + sortByName(files, order) + + if (include === FileTreeInclude.FOLDERS) { + return folderItems + } + if (include === FileTreeInclude.FILES) { + return files + } + return [...folderItems, ...files] +} + +function buildFile(preview: FilePreview, parentPath: string, accessApiBase: string): FileTreeFile { + return { + type: FileTreeItemType.FILE, + id: preview.id, + name: preview.name, + path: parentPath === '' ? preview.name : `${parentPath}/${preview.name}`, + size: preview.metadata.size.toBytes(), + contentType: preview.metadata.type.value, + access: preview.access, + checksum: preview.metadata.checksum + ? { + type: preview.metadata.checksum.algorithm, + value: preview.metadata.checksum.value + } + : undefined, + downloadUrl: `${accessApiBase}/${preview.id}` + } +} + +function sortByName(items: T[], order: FileTreeOrder): void { + const dir = order === FileTreeOrder.NAME_ZA ? -1 : 1 + items.sort((a, b) => dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) +} diff --git a/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts new file mode 100644 index 000000000..5be34c8cf --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts @@ -0,0 +1,184 @@ +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../domain/repositories/FileTreeRepository' +import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage' +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + FileTreeItemType +} from '../../domain/models/FileTreeItem' +import { axiosInstance } from '@/axiosInstance' +import { requireAppConfig } from '../../../config' +import { FileTreeFromPreviewsRepository } from './FileTreeFromPreviewsRepository' +import { FileRepository } from '../../domain/repositories/FileRepository' + +interface RawFolder { + type: 'folder' + name: string + path: string + counts?: { files: number; folders: number } +} + +interface RawFile { + type: 'file' + id: number + name: string + path: string + size: number + contentType?: string + access?: 'public' | 'restricted' | 'embargoed' + checksum?: { type: string; value: string } + downloadUrl: string +} + +interface RawTreeResponse { + path: string + items: (RawFolder | RawFile)[] + nextCursor: string | null + limit: number + order: string + include: string + approximateCount?: number +} + +/** + * Calls the dedicated tree endpoint + * `GET /api/datasets/{id}/versions/{versionId}/tree`. When the endpoint is not + * available on the target instance the repository falls back to the in-memory + * `FileTreeFromPreviewsRepository` so the SPA stays usable in mixed-version + * deployments. + * + * TODO: replace the inline `axiosInstance.get` call with + * `listDatasetTreeNode` from `@iqss/dataverse-client-javascript` once the + * SDK prerelease that ships those helpers is published. The wire format is + * already aligned with the SDK's `transformTreeResponseToFileTreePage`. + */ +export class FileTreeJSDataverseRepository implements FileTreeRepository { + private fallback?: FileTreeFromPreviewsRepository + private endpointUnavailable = false + + constructor(private readonly fileRepository?: FileRepository) {} + + async getNode(params: GetFileTreeNodeParams): Promise { + if (this.endpointUnavailable && this.fallback) { + return this.fallback.getNode(params) + } + try { + const versionId = encodeURIComponent(params.datasetVersion.number.toString()) + const persistentId = encodeURIComponent(params.datasetPersistentId) + const search = buildQuery(params) + const url = `${ + FileTreeJSDataverseRepository.baseUrl + }/api/datasets/:persistentId/versions/${versionId}/tree?persistentId=${persistentId}${ + search ? `&${search}` : '' + }` + const response = await axiosInstance.get<{ data: RawTreeResponse } | RawTreeResponse>(url, { + withCredentials: true + }) + const payload = unwrap(response.data) + return mapResponse(payload, params) + } catch (error) { + if (this.fileRepository && isEndpointMissing(error)) { + this.endpointUnavailable = true + if (!this.fallback) { + this.fallback = new FileTreeFromPreviewsRepository(this.fileRepository) + } + return this.fallback.getNode(params) + } + throw error + } + } + + static get baseUrl(): string { + return requireAppConfig().backendUrl + } +} + +function buildQuery(params: GetFileTreeNodeParams): string { + const out: string[] = [] + if (params.path) out.push(`path=${encodeURIComponent(params.path)}`) + if (params.limit !== undefined) out.push(`limit=${params.limit}`) + if (params.cursor) out.push(`cursor=${encodeURIComponent(params.cursor)}`) + if (params.include) out.push(`include=${params.include}`) + if (params.order) out.push(`order=${params.order}`) + if (params.includeDeaccessioned) out.push('includeDeaccessioned=true') + if (params.originals) out.push('originals=true') + return out.join('&') +} + +function unwrap(value: { data: T } | T): T { + if (value && typeof value === 'object' && 'data' in (value as Record)) { + return (value as { data: T }).data + } + return value as T +} + +function mapResponse(raw: RawTreeResponse, params: GetFileTreeNodeParams): FileTreePage { + const items: FileTreeItem[] = raw.items.map((item) => + item.type === 'folder' ? mapFolder(item) : mapFile(item) + ) + return { + path: raw.path, + items, + nextCursor: raw.nextCursor, + limit: raw.limit, + order: parseOrder(raw.order, params.order), + include: parseInclude(raw.include, params.include), + approximateCount: raw.approximateCount + } +} + +function mapFolder(item: RawFolder): FileTreeFolder { + return { + type: FileTreeItemType.FOLDER, + name: item.name, + path: item.path, + counts: item.counts + } +} + +function mapFile(item: RawFile): FileTreeFile { + return { + type: FileTreeItemType.FILE, + id: item.id, + name: item.name, + path: item.path, + size: item.size, + contentType: item.contentType, + access: item.access + ? { + restricted: item.access !== 'public', + latestVersionRestricted: item.access !== 'public', + canBeRequested: item.access === 'restricted', + requested: false + } + : undefined, + checksum: item.checksum, + downloadUrl: item.downloadUrl + } +} + +function parseOrder(value: string, fallback?: FileTreeOrder): FileTreeOrder { + if (value === FileTreeOrder.NAME_AZ || value === FileTreeOrder.NAME_ZA) { + return value + } + return fallback ?? FileTreeOrder.NAME_AZ +} + +function parseInclude(value: string, fallback?: FileTreeInclude): FileTreeInclude { + if ( + value === FileTreeInclude.ALL || + value === FileTreeInclude.FOLDERS || + value === FileTreeInclude.FILES + ) { + return value + } + return fallback ?? FileTreeInclude.ALL +} + +function isEndpointMissing(error: unknown): boolean { + const status = (error as { response?: { status?: number } })?.response?.status + return status === 404 || status === 405 || status === 501 +} diff --git a/tests/component/files/infrastructure/repositories/FileTreeFromPreviewsRepository.spec.ts b/tests/component/files/infrastructure/repositories/FileTreeFromPreviewsRepository.spec.ts new file mode 100644 index 000000000..9556f88da --- /dev/null +++ b/tests/component/files/infrastructure/repositories/FileTreeFromPreviewsRepository.spec.ts @@ -0,0 +1,238 @@ +import { + FileTreeFromPreviewsRepository, + normalizePath +} from '../../../../../src/files/infrastructure/repositories/FileTreeFromPreviewsRepository' +import { FileRepository } from '../../../../../src/files/domain/repositories/FileRepository' +import { FilePreviewMother } from '../../domain/models/FilePreviewMother' +import { FileMetadataMother } from '../../domain/models/FileMetadataMother' +import { FileSize, FileSizeUnit } from '../../../../../src/files/domain/models/FileMetadata' +import { DatasetVersionMother } from '../../../dataset/domain/models/DatasetMother' +import { FileTreeInclude, FileTreeOrder } from '../../../../../src/files/domain/models/FileTreePage' +import { + isFileTreeFile, + isFileTreeFolder +} from '../../../../../src/files/domain/models/FileTreeItem' + +const datasetVersion = DatasetVersionMother.create() + +function makePreviewWithDirectory(id: number, name: string, directory?: string, sizeBytes = 1024) { + return FilePreviewMother.create({ + id, + name, + metadata: FileMetadataMother.create({ + directory, + size: new FileSize(sizeBytes, FileSizeUnit.BYTES) + }) + }) +} + +class FakeFileRepository implements FileRepository { + public callCount = 0 + constructor(private readonly previews: ReturnType[]) {} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + getAllByDatasetPersistentIdWithCount() { + this.callCount += 1 + return Promise.resolve({ + files: this.previews, + totalFilesCount: this.previews.length + }) + } + getAllByDatasetPersistentId(): never { + throw new Error('not used') + } + getFilesCountInfoByDatasetPersistentId(): never { + throw new Error('not used') + } + getFilesTotalDownloadSizeByDatasetPersistentId(): never { + throw new Error('not used') + } + getFileVersionSummaries(): never { + throw new Error('not used') + } + getById(): never { + throw new Error('not used') + } + getMultipleFileDownloadUrl(): never { + throw new Error('not used') + } + getFileDownloadUrl(): never { + throw new Error('not used') + } + uploadFile(): never { + throw new Error('not used') + } + addUploadedFiles(): never { + throw new Error('not used') + } + delete(): never { + throw new Error('not used') + } + replace(): never { + throw new Error('not used') + } + getFixityAlgorithm(): never { + throw new Error('not used') + } + updateMetadata(): never { + throw new Error('not used') + } + restrict(): never { + throw new Error('not used') + } + updateTabularTags(): never { + throw new Error('not used') + } + updateCategories(): never { + throw new Error('not used') + } +} + +describe('FileTreeFromPreviewsRepository', () => { + it('groups previews by their first-level directory at root', async () => { + const previews = [ + makePreviewWithDirectory(1, 'top.txt'), + makePreviewWithDirectory(2, 'a.txt', 'data'), + makePreviewWithDirectory(3, 'b.txt', 'data/raw'), + makePreviewWithDirectory(4, 'c.txt', 'docs') + ] + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const page = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion + }) + const folderNames = page.items.filter(isFileTreeFolder).map((f) => f.name) + const fileNames = page.items.filter(isFileTreeFile).map((f) => f.name) + expect(folderNames).to.deep.equal(['data', 'docs']) + expect(fileNames).to.deep.equal(['top.txt']) + }) + + it('counts distinct subfolders and all descendant files on each folder item', async () => { + const previews = [ + makePreviewWithDirectory(1, 'a.txt', 'data/sub1'), + makePreviewWithDirectory(2, 'b.txt', 'data/sub1'), + makePreviewWithDirectory(3, 'c.txt', 'data/sub2'), + makePreviewWithDirectory(4, 'd.txt', 'data') + ] + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const page = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion + }) + const dataFolder = page.items.filter(isFileTreeFolder).find((f) => f.name === 'data') + expect(dataFolder).to.not.equal(undefined) + expect(dataFolder?.counts).to.deep.equal({ files: 4, folders: 2 }) + }) + + it('lists files inside a folder by exact path match only', async () => { + const previews = [ + makePreviewWithDirectory(1, 'a.txt', 'data'), + makePreviewWithDirectory(2, 'sub.txt', 'data/sub'), + makePreviewWithDirectory(3, 'unrelated.txt', 'docs') + ] + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const page = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + path: 'data' + }) + const names = page.items.map((i) => i.name) + expect(names).to.deep.equal(['sub', 'a.txt']) + }) + + it('honors include filter for folders or files only', async () => { + const previews = [ + makePreviewWithDirectory(1, 'top.txt'), + makePreviewWithDirectory(2, 'inside.txt', 'data') + ] + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const onlyFolders = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + include: FileTreeInclude.FOLDERS + }) + expect(onlyFolders.items.every(isFileTreeFolder)).to.equal(true) + + const onlyFiles = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + include: FileTreeInclude.FILES + }) + expect(onlyFiles.items.every(isFileTreeFile)).to.equal(true) + }) + + it('paginates with an opaque cursor and returns the same total via approximateCount', async () => { + const previews = Array.from({ length: 7 }).map((_, i) => + makePreviewWithDirectory(i + 1, `f${i + 1}.txt`) + ) + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const first = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + limit: 3 + }) + expect(first.items).to.have.length(3) + expect(first.nextCursor).to.not.equal(null) + + const second = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + limit: 3, + cursor: first.nextCursor as string + }) + expect(second.items).to.have.length(3) + expect(second.nextCursor).to.not.equal(null) + + const third = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + limit: 3, + cursor: second.nextCursor as string + }) + expect(third.items).to.have.length(1) + expect(third.nextCursor).to.equal(null) + }) + + it('supports descending order', async () => { + const previews = [makePreviewWithDirectory(1, 'a.txt'), makePreviewWithDirectory(2, 'b.txt')] + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository(previews)) + const page = await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + order: FileTreeOrder.NAME_ZA + }) + expect(page.items.map((i) => i.name)).to.deep.equal(['b.txt', 'a.txt']) + }) + + it('rejects invalid cursors', async () => { + const repo = new FileTreeFromPreviewsRepository(new FakeFileRepository([])) + let thrown: unknown + try { + await repo.getNode({ + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + cursor: 'not-a-real-cursor' + }) + } catch (error) { + thrown = error + } + expect((thrown as Error).message).to.match(/cursor/i) + }) + + it('caches previews per (persistentId, version)', async () => { + const previews = [makePreviewWithDirectory(1, 'a.txt')] + const fileRepo = new FakeFileRepository(previews) + const repo = new FileTreeFromPreviewsRepository(fileRepo) + await repo.getNode({ datasetPersistentId: 'doi:10.5072/FK2/AAA', datasetVersion }) + await repo.getNode({ datasetPersistentId: 'doi:10.5072/FK2/AAA', datasetVersion, path: '' }) + expect(fileRepo.callCount).to.equal(1) + }) +}) + +describe('normalizePath', () => { + it('strips leading and trailing slashes and collapses repeats', () => { + expect(normalizePath('/data//sub///')).to.equal('data/sub') + expect(normalizePath('')).to.equal('') + expect(normalizePath(undefined)).to.equal('') + expect(normalizePath('/foo')).to.equal('foo') + }) +}) From b7f0c42b97b15bfae878589735fd6a17cdb6ee9c Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:21:58 +0200 Subject: [PATCH 018/110] Add lazy tree view, table/tree toggle, and locale strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPA presentation layer for the files tree view (#6691, dataverse-frontend#622, dataverse-frontend#117). New section under src/sections/dataset/dataset-files/files-tree/: - FilesTree: virtualised lazy tree. Computes visible rows from scrollTop/clientHeight; only the slice in the viewport renders. Falls back to a fixed height when ResizeObserver is missing (test envs). - FilesTreeRow / FilesTreeHeader / FilesTreeCheckbox: row primitives with custom tri-state checkbox (none/partial/all). - format.ts: byte/count formatters for the row size + count cells. - icons/FilesTreeIcons.tsx: small inline SVG glyphs (no new deps). - useFileTree: per-folder fetch + cache + nextCursor paging. - useFileTreeSelection: path-keyed three-set selection model. Folder selection is logical — descendants are not enumerated until download time. A deselect-override set captures per-file unchecks inside a logically selected folder. - useFileTreeFlatten: turns the per-folder cache into the visible row list (incl. inline loading/error/load-more rows). - useFileTreeDownload: at download time uses enumerateFileTreeFiles to expand selected folders into concrete file IDs, then delegates to the existing requestSignedDownloadUrlFromAccessApi flow. No new server contract. New section under src/sections/dataset/dataset-files/files-view-toggle/: - FilesViewToggle: Table ↔ Tree toggle backed by the ?view=tree URL query parameter (bookmarkable). DatasetFiles.tsx and DatasetFilesScrollable.tsx render the toggle above the table/tree and switch between FilesTable / FilesTree based on the URL state. CSS-Modules class for the toggle layout (no inline styles). Adds tree.* and view.toggle.* keys to public/locales/en/files.json. Cypress component tests cover: tri-state selection logic, lazy expand, visible-row flattening (incl. load-more/loading/error rows), the toggle's URL-driven state, and FilesTree mounting against a fake repository for loading / error / empty / populated states. --- public/locales/en/files.json | 46 ++ .../dataset-files/DatasetFiles.module.scss | 5 + .../dataset/dataset-files/DatasetFiles.tsx | 100 +++- .../DatasetFilesScrollable.module.scss | 6 + .../dataset-files/DatasetFilesScrollable.tsx | 46 +- .../files-tree/FilesTree.module.scss | 371 +++++++++++++++ .../dataset-files/files-tree/FilesTree.tsx | 426 ++++++++++++++++++ .../files-tree/FilesTreeCheckbox.tsx | 40 ++ .../files-tree/FilesTreeHeader.tsx | 18 + .../dataset-files/files-tree/FilesTreeRow.tsx | 152 +++++++ .../dataset-files/files-tree/format.ts | 25 + .../files-tree/icons/FilesTreeIcons.tsx | 122 +++++ .../dataset-files/files-tree/useFileTree.ts | 242 ++++++++++ .../files-tree/useFileTreeDownload.ts | 188 ++++++++ .../files-tree/useFileTreeFlatten.ts | 95 ++++ .../files-tree/useFileTreeSelection.ts | 304 +++++++++++++ .../FilesViewToggle.module.scss | 42 ++ .../files-view-toggle/FilesViewToggle.tsx | 68 +++ .../files-tree/FilesTree.spec.tsx | 167 +++++++ .../files-tree/useFileTreeFlatten.spec.tsx | 96 ++++ .../files-tree/useFileTreeSelection.spec.tsx | 84 ++++ .../FilesViewToggle.spec.tsx | 35 ++ 22 files changed, 2675 insertions(+), 3 deletions(-) create mode 100644 src/sections/dataset/dataset-files/DatasetFiles.module.scss create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTree.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/format.ts create mode 100644 src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/useFileTree.ts create mode 100644 src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts create mode 100644 src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts create mode 100644 src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts create mode 100644 src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss create mode 100644 src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx create mode 100644 tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx create mode 100644 tests/component/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.spec.tsx create mode 100644 tests/component/sections/dataset/dataset-files/files-tree/useFileTreeSelection.spec.tsx create mode 100644 tests/component/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.spec.tsx diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 447cccfbe..4fc07484e 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -1,6 +1,52 @@ { "files": "Files", "filesLoading": "Files loading spinner symbol", + "view": { + "toggle": { + "label": "Files view selector", + "table": "Table", + "tree": "Tree" + } + }, + "tree": { + "head": { + "name": "Name", + "size": "Size", + "count": "Files", + "actions": "Actions" + }, + "row": { + "expandFolder": "Expand {{name}}", + "collapseFolder": "Collapse {{name}}", + "selectFolder": "Select folder {{name}}", + "selectFile": "Select file {{name}}", + "downloadFolder": "Download folder {{name}}", + "downloadFile": "Download file {{name}}" + }, + "selection": { + "none": "No files selected", + "fileCount_one": "file", + "fileCount_other": "files", + "includesFolders": "folders included", + "clear": "Clear" + }, + "download": { + "button": "Download zip", + "enumerating": "Listing files…", + "preparing": "Preparing zip…" + }, + "state": { + "loading": "Loading file index…", + "loadingFolder": "Loading folder…", + "loadingMore": "Loading…", + "loadMore": "Load more", + "error": "Couldn't load file index", + "retry": "Retry", + "empty": "This dataset has no files", + "noMatches": "No files match \"{{query}}\"" + } + }, + "errorUnkownGetFilesFromDataset": "There was an error getting the files total download size", "errorUnkownGetFilesCountInfo": "There was an error getting the files count info", "errorUnkownGetFilesTotalDownloadSize": "There was an error getting the files total download size", diff --git a/src/sections/dataset/dataset-files/DatasetFiles.module.scss b/src/sections/dataset/dataset-files/DatasetFiles.module.scss new file mode 100644 index 000000000..afafe8012 --- /dev/null +++ b/src/sections/dataset/dataset-files/DatasetFiles.module.scss @@ -0,0 +1,5 @@ +.view-toggle-row { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} diff --git a/src/sections/dataset/dataset-files/DatasetFiles.tsx b/src/sections/dataset/dataset-files/DatasetFiles.tsx index 4a63331b0..2703904c4 100644 --- a/src/sections/dataset/dataset-files/DatasetFiles.tsx +++ b/src/sections/dataset/dataset-files/DatasetFiles.tsx @@ -1,5 +1,6 @@ +import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import { FileRepository } from '../../../files/domain/repositories/FileRepository' -import { useState } from 'react' import { FilesTable } from './files-table/FilesTable' import { FileCriteriaForm } from './file-criteria-form/FileCriteriaForm' import { FileCriteria } from '../../../files/domain/models/FileCriteria' @@ -8,20 +9,83 @@ import { PaginationControls } from '../../shared/pagination/PaginationControls' import { DatasetVersion } from '../../../dataset/domain/models/Dataset' import { FilePaginationInfo } from '../../../files/domain/models/FilePaginationInfo' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FilesTree } from './files-tree/FilesTree' +import { FilesViewToggle, FilesViewMode } from './files-view-toggle/FilesViewToggle' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeJSDataverseRepository } from '@/files/infrastructure/repositories/FileTreeJSDataverseRepository' +import styles from './DatasetFiles.module.scss' interface DatasetFilesProps { filesRepository: FileRepository datasetPersistentId: string datasetVersion: DatasetVersion datasetRepository: DatasetRepository + fileTreeRepository?: FileTreeRepository } +const VIEW_PARAM = 'view' + export function DatasetFiles({ filesRepository, datasetPersistentId, datasetVersion, - datasetRepository + datasetRepository, + fileTreeRepository }: DatasetFilesProps) { + const [searchParams, setSearchParams] = useSearchParams() + const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const setView = (next: FilesViewMode) => { + const updated = new URLSearchParams(searchParams) + if (next === 'tree') { + updated.set(VIEW_PARAM, 'tree') + } else { + updated.delete(VIEW_PARAM) + } + setSearchParams(updated, { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + + return view === 'tree' ? ( + + ) : ( + + ) +} + +interface DatasetFilesTableViewProps { + filesRepository: FileRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + datasetRepository: DatasetRepository + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode +} + +function DatasetFilesTableView({ + filesRepository, + datasetPersistentId, + datasetVersion, + datasetRepository, + onChangeView, + view +}: DatasetFilesTableViewProps) { const [paginationInfo, setPaginationInfo] = useState(new FilePaginationInfo()) const [criteria, setCriteria] = useState(new FileCriteria()) const { files, isLoading, filesCountInfo, filesTotalDownloadSize } = useFiles( @@ -40,6 +104,9 @@ export function DatasetFiles({ onCriteriaChange={setCriteria} filesCountInfo={filesCountInfo} /> +
+ +
) } + +interface DatasetFilesTreeViewProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode +} + +function DatasetFilesTreeView({ + treeRepository, + datasetPersistentId, + datasetVersion, + onChangeView, + view +}: DatasetFilesTreeViewProps) { + return ( + <> +
+ +
+ + + ) +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss b/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss index f6fb9013c..bd8e4dca8 100644 --- a/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss +++ b/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss @@ -22,3 +22,9 @@ margin-bottom: 0; } } + +.view-toggle-row { + display: flex; + justify-content: flex-end; + margin-bottom: 0.5rem; +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx index 4b65605d0..74a8a4dd1 100644 --- a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx +++ b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx @@ -1,5 +1,6 @@ import cn from 'classnames' import { useMemo, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' import useInfiniteScroll, { UseInfiniteScrollHookRefCallback } from 'react-infinite-scroll-hook' import { Alert } from '@iqss/dataverse-design-system' import { FileRepository } from '../../../files/domain/repositories/FileRepository' @@ -14,6 +15,10 @@ import { FilesTableScrollable } from './files-table/FilesTableScrollable' import { FileCriteriaForm } from './file-criteria-form/FileCriteriaForm' import { FilesContext } from '@/sections/file/FilesContext' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FilesTree } from './files-tree/FilesTree' +import { FilesViewToggle, FilesViewMode } from './files-view-toggle/FilesViewToggle' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeJSDataverseRepository } from '@/files/infrastructure/repositories/FileTreeJSDataverseRepository' import styles from './DatasetFilesScrollable.module.scss' interface DatasetFilesScrollableProps { @@ -22,8 +27,11 @@ interface DatasetFilesScrollableProps { datasetVersion: DatasetVersion datasetRepository: DatasetRepository canUpdateDataset?: boolean + fileTreeRepository?: FileTreeRepository } +const VIEW_PARAM = 'view' + export type SentryRef = UseInfiniteScrollHookRefCallback export function DatasetFilesScrollable({ @@ -31,12 +39,30 @@ export function DatasetFilesScrollable({ datasetPersistentId, datasetVersion, canUpdateDataset, - datasetRepository + datasetRepository, + fileTreeRepository }: DatasetFilesScrollableProps) { const scrollableContainerRef = useRef(null) const criteriaContainerRef = useRef(null) const criteriaContainerSize = useObserveElementSize(criteriaContainerRef) + const [searchParams, setSearchParams] = useSearchParams() + const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const setView = (next: FilesViewMode) => { + const updated = new URLSearchParams(searchParams) + if (next === 'tree') { + updated.set(VIEW_PARAM, 'tree') + } else { + updated.delete(VIEW_PARAM) + } + setSearchParams(updated, { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + const [paginationInfo, setPaginationInfo] = useState( () => new FilePaginationInfo() ) @@ -154,6 +180,21 @@ export function DatasetFilesScrollable({ ) } + if (view === 'tree') { + return ( +
+
+ +
+ +
+ ) + } + return (
+
+ +
{ + if (ids.length === 0) { + return + } + const url = await requestSignedDownloadUrlFromAccessApi({ + accessRepository, + fileIds: ids, + guestbookResponse: EMPTY_GUESTBOOK_RESPONSE, + format: FileDownloadMode.ORIGINAL + }) + await downloadFromSignedUrl(url) + toast.success(t('actions.optionsMenu.guestbookCollectModal.downloadStarted')) + }, + [accessRepository, t] + ) + + const download = useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onDownloadFileIds, + onError: () => toast.error(t('actions.optionsMenu.guestbookCollectModal.downloadError')) + }) + + const visibleRows = useFileTreeFlatten({ + nodes: tree.nodes, + expanded: tree.expanded, + query + }) + + const containerRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewportH, setViewportH] = useState(fallbackHeight) + + useLayoutEffect(() => { + if (typeof window === 'undefined') { + return + } + const el = containerRef.current + if (!el) { + return + } + const update = () => { + const measured = el.clientHeight + if (measured > 0) { + setViewportH(measured) + } + } + update() + if (typeof ResizeObserver === 'undefined') { + return + } + const ro = new ResizeObserver(update) + ro.observe(el) + return () => ro.disconnect() + }, []) + + useEffect(() => { + for (const row of visibleRows) { + if (row.kind === 'item' && isFileTreeFile(row.node)) { + selection.registerFile(row.node) + } + } + }, [selection, visibleRows]) + + const totalRows = visibleRows.length + const startIdx = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) + const endIdx = Math.min(totalRows, Math.ceil((scrollTop + viewportH) / rowHeight) + OVERSCAN) + const slice = useMemo(() => visibleRows.slice(startIdx, endIdx), [endIdx, startIdx, visibleRows]) + + const onScroll = (event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + } + + const handleDownloadOne = useCallback( + (item: FileTreeFile | FileTreeFolder) => { + void download.downloadNode(item) + }, + [download] + ) + + const handleToggleExpansion = useCallback( + async (folder: FileTreeFolder) => { + await tree.toggleExpanded(folder.path) + }, + [tree] + ) + + const handleToggleSelectionFolder = useCallback( + (folder: FileTreeFolder) => { + const known = tree.visibleKnownChildren(folder.path) + selection.toggleFolder(folder, known) + }, + [selection, tree] + ) + + const handleToggleSelectionFile = useCallback( + (file: FileTreeFile) => { + selection.toggleFile(file) + }, + [selection] + ) + + const isInitialLoad = !tree.rootNode.loaded && tree.rootNode.loading + + if (isInitialLoad) { + return ( +
+ +
+
+
+ +
+
{t('tree.state.loading')}
+
+
+
+ ) + } + + if (tree.rootNode.error && tree.rootNode.items.length === 0) { + return ( +
+ +
+
+
+ +
+
{t('tree.state.error')}
+
{tree.rootNode.error}
+ +
+
+
+ ) + } + + if (totalRows === 0) { + return ( +
+ + +
+
+
+ +
+
{query ? t('tree.state.noMatches', { query }) : t('tree.state.empty')}
+
+
+
+ ) + } + + return ( +
+ + +
+
+ {slice.map((row, sliceIndex) => { + const absoluteIndex = startIdx + sliceIndex + const top = absoluteIndex * rowHeight + if (row.kind === 'loading') { + return ( + + {t('tree.state.loadingFolder')} + + ) + } + if (row.kind === 'error') { + return ( + + {row.error}{' '} + + + ) + } + if (row.kind === 'load-more') { + const node = tree.nodes.get(row.path) + return ( + + + + ) + } + const item = row.node + const knownChildren = isFileTreeFolder(item) ? tree.visibleKnownChildren(item.path) : [] + const selectionState = isFileTreeFile(item) + ? selection.fileState(item) + : selection.folderState(item, knownChildren) + const expanded = isFileTreeFolder(item) ? tree.expanded.has(item.path) : undefined + return ( + void handleToggleExpansion(item) : undefined + } + onToggleSelection={() => { + if (isFileTreeFile(item)) { + handleToggleSelectionFile(item) + } else { + handleToggleSelectionFolder(item) + } + }} + onDownload={() => handleDownloadOne(item)} + datasetVersionNumber={datasetVersion.number} + /> + ) + })} +
+
+
+ ) +} + +interface FilesTreeToolbarProps { + selection: ReturnType + download: ReturnType + disableDownload?: boolean +} + +function FilesTreeToolbar({ selection, download, disableDownload }: FilesTreeToolbarProps) { + const { t } = useTranslation('files') + const { count, bytes, hasLogicalFolders } = selection.totals + const downloadable = !disableDownload && (count > 0 || hasLogicalFolders) + const enumerating = download.progress.status === 'enumerating' + const requesting = download.progress.status === 'requesting' + + return ( +
+
+ + {count === 0 && !hasLogicalFolders ? ( + {t('tree.selection.none')} + ) : ( + <> + {count.toLocaleString()}{' '} + {t('tree.selection.fileCount', { count })} + {hasLogicalFolders && ( + <> + · + {t('tree.selection.includesFolders')} + + )} + {bytes > 0 && ( + <> + · + {formatBytes(bytes)} + + )} + + )} + +
+
+ + +
+
+ ) +} + +interface RowMessageProps { + top: number + height: number + depth: number + className: string + children: React.ReactNode +} + +function RowMessage({ top, height, depth, className, children }: RowMessageProps) { + const indent: CSSProperties = { + paddingLeft: 14 + depth * 18 + 30, + top, + height + } + return ( +
+ {children} +
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx new file mode 100644 index 000000000..cc47ddaaa --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx @@ -0,0 +1,40 @@ +import { KeyboardEvent, MouseEvent } from 'react' +import cn from 'classnames' +import styles from './FilesTree.module.scss' +import { SelectionState } from './useFileTreeSelection' + +interface FilesTreeCheckboxProps { + state: SelectionState + onToggle: () => void + label: string + testId?: string +} + +export function FilesTreeCheckbox({ state, onToggle, label, testId }: FilesTreeCheckboxProps) { + const handleClick = (event: MouseEvent) => { + event.stopPropagation() + onToggle() + } + const handleKey = (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + onToggle() + } + } + return ( + + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx new file mode 100644 index 000000000..18daf2877 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from 'react-i18next' +import styles from './FilesTree.module.scss' + +export function FilesTreeHeader() { + const { t } = useTranslation('files') + return ( +
+
{t('tree.head.name', 'Name')}
+
+ {t('tree.head.size', 'Size')} +
+
+ {t('tree.head.count', 'Files')} +
+
+
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx new file mode 100644 index 000000000..c419fbb3b --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx @@ -0,0 +1,152 @@ +import { CSSProperties, MouseEvent } from 'react' +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { FileTreeFile, FileTreeFolder, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { DatasetVersionNumber } from '@/dataset/domain/models/Dataset' +import { QueryParamKey, Route } from '@/sections/Route.enum' +import { FilesTreeCheckbox } from './FilesTreeCheckbox' +import { + ChevronIcon, + DownloadIcon, + FileIcon, + FolderIcon, + FolderOpenIcon +} from './icons/FilesTreeIcons' +import styles from './FilesTree.module.scss' +import { SelectionState } from './useFileTreeSelection' +import { formatBytes, formatCount } from './format' + +interface FilesTreeRowProps { + depth: number + top: number + height: number + item: FileTreeFile | FileTreeFolder + selectionState: SelectionState + expanded?: boolean + onToggleSelection: () => void + onToggleExpansion?: () => void + onDownload: () => void + datasetVersionNumber: DatasetVersionNumber +} + +const INDENT_BASE = 14 +const INDENT_PER_LEVEL = 18 + +export function FilesTreeRow({ + depth, + top, + height, + item, + selectionState, + expanded, + onToggleSelection, + onToggleExpansion, + onDownload, + datasetVersionNumber +}: FilesTreeRowProps) { + const { t } = useTranslation('files') + const isFile = isFileTreeFile(item) + const indent: CSSProperties = { + paddingLeft: INDENT_BASE + depth * INDENT_PER_LEVEL + } + const rowStyle: CSSProperties = { top, height } + const Icon = isFile ? FileIcon : expanded ? FolderOpenIcon : FolderIcon + + const handleRowClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (target.closest('a, button, [role="checkbox"]')) { + return + } + onToggleSelection() + } + + const handleTwistyClick = (event: MouseEvent) => { + event.stopPropagation() + onToggleExpansion?.() + } + + return ( +
+
+ {isFile ? ( + + ) : ( + + )} + + + + + + {isFile ? ( + + {item.name} + + ) : ( + {item.name} + )} + +
+
{isFile ? formatBytes(item.size) : ''}
+
+ {!isFile && item.counts ? formatCount(item.counts.files) : ''} +
+
+ +
+
+ ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/format.ts b/src/sections/dataset/dataset-files/files-tree/format.ts new file mode 100644 index 000000000..23b0a384d --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/format.ts @@ -0,0 +1,25 @@ +export function formatBytes(input: number | undefined): string { + if (input === undefined || input === null || Number.isNaN(input)) { + return '' + } + if (input < 1024) { + return `${input} B` + } + if (input < 1024 * 1024) { + return `${(input / 1024).toFixed(1)} KB` + } + if (input < 1024 * 1024 * 1024) { + return `${(input / (1024 * 1024)).toFixed(1)} MB` + } + return `${(input / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + +export function formatCount(input: number | undefined): string { + if (input === undefined || input === null || Number.isNaN(input)) { + return '' + } + if (input < 1000) { + return input.toString() + } + return `${(input / 1000).toFixed(1)}k` +} diff --git a/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx b/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx new file mode 100644 index 000000000..11d9940af --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/icons/FilesTreeIcons.tsx @@ -0,0 +1,122 @@ +/** + * Inline SVG glyphs used by the tree view rows and toolbar. + * + * Kept inline (rather than pulling another icon set) because the row icons + * are rendered in volume during virtualization and we want to avoid a font + * round-trip or a heavier icon component for each row. + */ +export function FolderIcon() { + return ( + + + + ) +} + +export function FolderOpenIcon() { + return ( + + + + + ) +} + +export function FileIcon() { + return ( + + + + + ) +} + +export function ChevronIcon() { + return ( + + + + ) +} + +export function DownloadIcon() { + return ( + + + + ) +} + +export function WarnIcon() { + return ( + + + + + ) +} + +export function EmptyIcon() { + return ( + + + + ) +} + +export function SpinnerIcon() { + return ( + + + + + + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTree.ts b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts new file mode 100644 index 000000000..b5ba6c778 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeFile, FileTreeItem, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder } from '@/files/domain/models/FileTreePage' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' + +export interface FolderNode { + path: string + items: FileTreeItem[] + nextCursor: string | null + loading: boolean + error?: string + loaded: boolean +} + +export interface UseFileTreeArgs { + repository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + pageSize?: number + order?: FileTreeOrder + include?: FileTreeInclude +} + +export interface UseFileTreeApi { + rootNode: FolderNode + nodes: ReadonlyMap + expanded: ReadonlySet + toggleExpanded: (path: string) => Promise + expand: (path: string) => Promise + collapse: (path: string) => void + loadMore: (path: string) => Promise + refresh: (path?: string) => Promise + registerKnownFile: (file: FileTreeFile) => void + knownFiles: ReadonlyMap + visibleKnownChildren: (path: string) => FileTreeItem[] +} + +const ROOT = '' + +export function useFileTree({ + repository, + datasetPersistentId, + datasetVersion, + pageSize = 200, + order = FileTreeOrder.NAME_AZ, + include = FileTreeInclude.ALL +}: UseFileTreeArgs): UseFileTreeApi { + const [nodes, setNodes] = useState>(() => new Map()) + const [expanded, setExpanded] = useState>(() => new Set([ROOT])) + const knownFilesRef = useRef>(new Map()) + const inFlight = useRef>>(new Map()) + const versionKey = `${datasetPersistentId}::${datasetVersion.number.toString()}::${order}::${include}` + const previousKey = useRef(versionKey) + + const setNode = useCallback((path: string, updater: (prev: FolderNode) => FolderNode) => { + setNodes((prev) => { + const next = new Map(prev) + const current = prev.get(path) ?? { + path, + items: [], + nextCursor: null, + loading: false, + loaded: false + } + next.set(path, updater(current)) + return next + }) + }, []) + + const fetchPage = useCallback( + async (path: string, cursor?: string) => { + const params: GetFileTreeNodeParams = { + datasetPersistentId, + datasetVersion, + path, + limit: pageSize, + cursor, + order, + include + } + setNode(path, (prev) => ({ ...prev, loading: true, error: undefined })) + try { + const page = await repository.getNode(params) + for (const item of page.items) { + if (isFileTreeFile(item)) { + knownFilesRef.current.set(item.path, item) + } + } + setNode(path, (prev) => ({ + ...prev, + items: cursor ? [...prev.items, ...page.items] : page.items, + nextCursor: page.nextCursor, + loading: false, + loaded: true, + error: undefined + })) + } catch (error) { + setNode(path, (prev) => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : String(error) + })) + } + }, + [datasetPersistentId, datasetVersion, include, order, pageSize, repository, setNode] + ) + + const ensureLoaded = useCallback( + (path: string): Promise => { + const existing = nodes.get(path) + if (existing && existing.loaded && !existing.error) { + return Promise.resolve() + } + const pending = inFlight.current.get(path) + if (pending) { + return pending + } + const promise = fetchPage(path).finally(() => { + inFlight.current.delete(path) + }) + inFlight.current.set(path, promise) + return promise + }, + [fetchPage, nodes] + ) + + useEffect(() => { + if (previousKey.current !== versionKey) { + previousKey.current = versionKey + setNodes(new Map()) + setExpanded(new Set([ROOT])) + knownFilesRef.current = new Map() + inFlight.current.clear() + } + void ensureLoaded(ROOT) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [versionKey]) + + const expand = useCallback( + async (path: string) => { + setExpanded((prev) => { + if (prev.has(path)) { + return prev + } + const next = new Set(prev) + next.add(path) + return next + }) + await ensureLoaded(path) + }, + [ensureLoaded] + ) + + const collapse = useCallback((path: string) => { + setExpanded((prev) => { + if (!prev.has(path)) { + return prev + } + const next = new Set(prev) + next.delete(path) + return next + }) + }, []) + + const toggleExpanded = useCallback( + async (path: string) => { + if (expanded.has(path)) { + collapse(path) + } else { + await expand(path) + } + }, + [collapse, expand, expanded] + ) + + const loadMore = useCallback( + async (path: string) => { + const existing = nodes.get(path) + if (!existing || !existing.nextCursor || existing.loading) { + return + } + await fetchPage(path, existing.nextCursor) + }, + [fetchPage, nodes] + ) + + const refresh = useCallback( + async (path?: string) => { + const target = path ?? ROOT + setNodes((prev) => { + const next = new Map(prev) + next.delete(target) + return next + }) + await fetchPage(target) + }, + [fetchPage] + ) + + const registerKnownFile = useCallback((file: FileTreeFile) => { + knownFilesRef.current.set(file.path, file) + }, []) + + const visibleKnownChildren = useCallback( + (path: string): FileTreeItem[] => { + const out: FileTreeItem[] = [] + for (const node of nodes.values()) { + if (path === '' || node.path === path || node.path.startsWith(`${path}/`)) { + out.push(...node.items) + } + } + return out + }, + [nodes] + ) + + const rootNode: FolderNode = nodes.get(ROOT) ?? { + path: ROOT, + items: [], + nextCursor: null, + loading: true, + loaded: false + } + + return { + rootNode, + nodes, + expanded, + toggleExpanded, + expand, + collapse, + loadMore, + refresh, + registerKnownFile, + knownFiles: knownFilesRef.current, + visibleKnownChildren + } +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts new file mode 100644 index 000000000..87a013011 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts @@ -0,0 +1,188 @@ +import { useCallback, useState } from 'react' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeFile, FileTreeFolder, isFileTreeFile } from '@/files/domain/models/FileTreeItem' +import { enumerateFileTreeFiles } from '@/files/domain/useCases/enumerateFileTreeFiles' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' +import { FileTreeSelection } from './useFileTreeSelection' + +export interface DownloadProgress { + status: 'idle' | 'enumerating' | 'requesting' | 'success' | 'error' + enumeratedCount: number + message?: string +} + +export interface UseFileTreeDownloadArgs { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + selection: FileTreeSelection + onError?: (error: unknown) => void + /** + * Caller decides how to actually trigger the download for an array of file + * IDs (e.g. signed-URL flow with guestbook handling). The hook only owns the + * enumeration step. + */ + onDownloadFileIds: (ids: number[]) => Promise +} + +export interface UseFileTreeDownloadApi { + progress: DownloadProgress + downloadSelection: () => Promise + downloadNode: (node: FileTreeFile | FileTreeFolder) => Promise + reset: () => void +} + +export function useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onError, + onDownloadFileIds +}: UseFileTreeDownloadArgs): UseFileTreeDownloadApi { + const [progress, setProgress] = useState({ + status: 'idle', + enumeratedCount: 0 + }) + + const reset = useCallback(() => { + setProgress({ status: 'idle', enumeratedCount: 0 }) + }, []) + + const downloadFileIds = useCallback( + async (ids: number[]) => { + if (ids.length === 0) { + return + } + setProgress({ status: 'requesting', enumeratedCount: ids.length }) + try { + await onDownloadFileIds(ids) + setProgress({ status: 'success', enumeratedCount: ids.length }) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: ids.length, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [onDownloadFileIds, onError] + ) + + const collectExplicitFiles = useCallback((): FileTreeFile[] => { + const out: FileTreeFile[] = [] + for (const path of selection.selectedFilePaths) { + const file = lookupFile(selection.filesById, path) + if (file) { + out.push(file) + } + } + return out + }, [selection]) + + const downloadSelection = useCallback(async () => { + const explicit = collectExplicitFiles() + const folderPaths = Array.from(selection.selectedFolderPaths) + if (explicit.length === 0 && folderPaths.length === 0) { + return + } + + let enumerated: FileTreeFile[] = [] + if (folderPaths.length > 0) { + setProgress({ status: 'enumerating', enumeratedCount: 0 }) + try { + enumerated = await enumerateFileTreeFiles(treeRepository, { + datasetPersistentId, + datasetVersion, + paths: folderPaths + }) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: 0, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + return + } + } + + const merged = mergeFiles(explicit, enumerated, selection.deselectedFilePaths) + await downloadFileIds(merged.map((f) => f.id)) + }, [ + collectExplicitFiles, + datasetPersistentId, + datasetVersion, + downloadFileIds, + onError, + selection.deselectedFilePaths, + selection.selectedFolderPaths, + treeRepository + ]) + + const downloadNode = useCallback( + async (node: FileTreeFile | FileTreeFolder) => { + if (isFileTreeFile(node)) { + await downloadFileIds([node.id]) + return + } + setProgress({ status: 'enumerating', enumeratedCount: 0 }) + try { + const files = await enumerateFileTreeFiles(treeRepository, { + datasetPersistentId, + datasetVersion, + paths: [node.path] + }) + await downloadFileIds(files.map((f) => f.id)) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: 0, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [datasetPersistentId, datasetVersion, downloadFileIds, onError, treeRepository] + ) + + return { progress, downloadSelection, downloadNode, reset } +} + +function lookupFile(filesById: Map, path: string): FileTreeFile | undefined { + for (const file of filesById.values()) { + if (file.path === path) { + return file + } + } + return undefined +} + +function mergeFiles( + explicit: FileTreeFile[], + enumerated: FileTreeFile[], + deselected: ReadonlySet +): FileTreeFile[] { + const seen = new Set() + const out: FileTreeFile[] = [] + for (const file of explicit) { + if (deselected.has(file.path)) { + continue + } + if (!seen.has(file.id)) { + seen.add(file.id) + out.push(file) + } + } + for (const file of enumerated) { + if (deselected.has(file.path)) { + continue + } + if (!seen.has(file.id)) { + seen.add(file.id) + out.push(file) + } + } + return out +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts new file mode 100644 index 000000000..cae493c0b --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import { FolderNode } from './useFileTree' +import { FileTreeItem, isFileTreeFile, isFileTreeFolder } from '@/files/domain/models/FileTreeItem' + +export type VisibleRow = + | { kind: 'item'; depth: number; node: FileTreeItem } + | { kind: 'loading'; depth: number; path: string } + | { kind: 'error'; depth: number; path: string; error: string } + | { kind: 'load-more'; depth: number; path: string } + +const ROOT = '' + +export interface UseFileTreeFlattenArgs { + nodes: ReadonlyMap + expanded: ReadonlySet + query?: string +} + +export function useFileTreeFlatten({ + nodes, + expanded, + query +}: UseFileTreeFlattenArgs): VisibleRow[] { + return useMemo(() => buildVisibleRows(nodes, expanded, query), [nodes, expanded, query]) +} + +export function buildVisibleRows( + nodes: ReadonlyMap, + expanded: ReadonlySet, + query?: string +): VisibleRow[] { + const rows: VisibleRow[] = [] + const root = nodes.get(ROOT) + if (!root) { + return rows + } + walk(root, 0) + return rows + + function walk(folder: FolderNode, depth: number): void { + if (folder.loading && folder.items.length === 0) { + rows.push({ kind: 'loading', depth, path: folder.path }) + return + } + if (folder.error && folder.items.length === 0) { + rows.push({ kind: 'error', depth, path: folder.path, error: folder.error }) + return + } + for (const item of folder.items) { + if (query && !matches(item, query, nodes)) { + continue + } + rows.push({ kind: 'item', depth, node: item }) + if (isFileTreeFolder(item)) { + const isOpen = expanded.has(item.path) || Boolean(query) + if (isOpen) { + const sub = nodes.get(item.path) + if (sub) { + walk(sub, depth + 1) + } else { + rows.push({ kind: 'loading', depth: depth + 1, path: item.path }) + } + } + } + } + if (folder.nextCursor) { + rows.push({ kind: 'load-more', depth, path: folder.path }) + } + if (folder.error && folder.items.length > 0) { + rows.push({ kind: 'error', depth, path: folder.path, error: folder.error }) + } + } +} + +function matches( + item: FileTreeItem, + query: string, + nodes: ReadonlyMap +): boolean { + const lowered = query.trim().toLowerCase() + if (!lowered) { + return true + } + if (isFileTreeFile(item)) { + return item.name.toLowerCase().includes(lowered) + } + if (item.name.toLowerCase().includes(lowered)) { + return true + } + const sub = nodes.get(item.path) + if (!sub) { + return false + } + return sub.items.some((child) => matches(child, query, nodes)) +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts new file mode 100644 index 000000000..f6c95143b --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts @@ -0,0 +1,304 @@ +import { useCallback, useMemo, useState } from 'react' +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + isFileTreeFile +} from '@/files/domain/models/FileTreeItem' + +export type SelectionState = 'all' | 'partial' | 'none' + +export interface FileTreeSelectionTotals { + count: number + bytes: number + hasLogicalFolders: boolean +} + +/** + * Selection state for the lazy file tree. + * + * Three sets cooperate: + * + * - `selectedFolderPaths` — folders the user explicitly checked. Implies + * "all descendants are logically selected" without enumerating them. + * - `selectedFilePaths` — individual files checked when no ancestor folder + * is selected. + * - `deselectedFilePaths` — individual files unchecked within a folder that + * is in `selectedFolderPaths` (or under a selected ancestor). + * + * The component never enumerates an unvisited subtree; the download flow + * walks the tree API to expand selected folders into concrete file IDs. + */ +export interface FileTreeSelection { + selectedFilePaths: ReadonlySet + selectedFolderPaths: ReadonlySet + deselectedFilePaths: ReadonlySet + totals: FileTreeSelectionTotals + fileState: (file: FileTreeFile) => SelectionState + folderState: (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => SelectionState + toggleFile: (file: FileTreeFile) => void + toggleFolder: (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => void + clear: () => void + filesById: Map + registerFile: (file: FileTreeFile) => void +} + +const isStrictlyUnder = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}/`) + +const hasSelectedAncestor = (path: string, selectedFolders: ReadonlySet): boolean => { + for (const folder of selectedFolders) { + if (isStrictlyUnder(path, folder)) { + return true + } + } + return false +} + +export function useFileTreeSelection(): FileTreeSelection { + const [selectedFilePaths, setSelectedFilePaths] = useState>(() => new Set()) + const [selectedFolderPaths, setSelectedFolderPaths] = useState>(() => new Set()) + const [deselectedFilePaths, setDeselectedFilePaths] = useState>(() => new Set()) + const [filesById] = useState>(() => new Map()) + + const registerFile = useCallback( + (file: FileTreeFile) => { + filesById.set(file.id, file) + }, + [filesById] + ) + + const isFileLogicallySelected = useCallback( + (path: string): boolean => { + if (deselectedFilePaths.has(path)) { + return false + } + if (selectedFilePaths.has(path)) { + return true + } + return hasSelectedAncestor(path, selectedFolderPaths) + }, + [deselectedFilePaths, selectedFilePaths, selectedFolderPaths] + ) + + const fileState = useCallback( + (file: FileTreeFile): SelectionState => (isFileLogicallySelected(file.path) ? 'all' : 'none'), + [isFileLogicallySelected] + ) + + const folderState = useCallback( + (folder: FileTreeFolder, knownChildren: FileTreeItem[]): SelectionState => { + const explicitlySelected = selectedFolderPaths.has(folder.path) + const ancestorSelected = hasSelectedAncestor(folder.path, selectedFolderPaths) + const logicallySelected = explicitlySelected || ancestorSelected + + const knownFilesUnder = knownChildren.filter( + (child): child is FileTreeFile => + isFileTreeFile(child) && + (child.path === `${folder.path}/${child.name}` || + isStrictlyUnder(child.path, folder.path)) + ) + + if (logicallySelected) { + const someDeselected = knownFilesUnder.some((file) => deselectedFilePaths.has(file.path)) + return someDeselected ? 'partial' : 'all' + } + + if (knownChildren.length === 0) { + return 'none' + } + + const nestedFolderSelected = Array.from(selectedFolderPaths).some((other) => + isStrictlyUnder(other, folder.path) + ) + const someFileSelected = knownFilesUnder.some((file) => isFileLogicallySelected(file.path)) + + if (!nestedFolderSelected && !someFileSelected) { + return 'none' + } + + const allFilesSelected = + knownFilesUnder.length > 0 && + knownFilesUnder.every((file) => isFileLogicallySelected(file.path)) + + // If we know about subfolders but none are logically selected and + // not every visited file is selected, the folder is partial. + if (allFilesSelected && !nestedFolderSelected) { + return 'all' + } + if (allFilesSelected && nestedFolderSelected) { + // descendant folder selection covers some unvisited paths; + // we cannot honestly call this 'all' so partial is correct. + return 'partial' + } + return 'partial' + }, + [deselectedFilePaths, isFileLogicallySelected, selectedFolderPaths] + ) + + const toggleFile = useCallback( + (file: FileTreeFile) => { + filesById.set(file.id, file) + const ancestorSelected = hasSelectedAncestor(file.path, selectedFolderPaths) + if (ancestorSelected) { + const next = new Set(deselectedFilePaths) + if (next.has(file.path)) { + next.delete(file.path) + } else { + next.add(file.path) + } + setDeselectedFilePaths(next) + return + } + const next = new Set(selectedFilePaths) + if (next.has(file.path)) { + next.delete(file.path) + } else { + next.add(file.path) + } + setSelectedFilePaths(next) + }, + [deselectedFilePaths, filesById, selectedFilePaths, selectedFolderPaths] + ) + + const toggleFolder = useCallback( + (folder: FileTreeFolder, knownChildren: FileTreeItem[]) => { + const explicitlySelected = selectedFolderPaths.has(folder.path) + const ancestorSelected = hasSelectedAncestor(folder.path, selectedFolderPaths) + const state = folderState(folder, knownChildren) + + if (state === 'all' && explicitlySelected) { + // Deselect this folder and any nested artifacts under it. + const nextFolders = new Set(selectedFolderPaths) + const nextFiles = new Set(selectedFilePaths) + const nextDeselected = new Set(deselectedFilePaths) + nextFolders.delete(folder.path) + for (const other of Array.from(nextFolders)) { + if (isStrictlyUnder(other, folder.path)) { + nextFolders.delete(other) + } + } + for (const path of Array.from(nextFiles)) { + if (path === folder.path || isStrictlyUnder(path, folder.path)) { + nextFiles.delete(path) + } + } + for (const path of Array.from(nextDeselected)) { + if (isStrictlyUnder(path, folder.path)) { + nextDeselected.delete(path) + } + } + setSelectedFolderPaths(nextFolders) + setSelectedFilePaths(nextFiles) + setDeselectedFilePaths(nextDeselected) + return + } + + if (ancestorSelected) { + // We're inside an already-logically-selected branch; flip the + // deselect overrides for every known descendant file under this + // folder. + const nextDeselected = new Set(deselectedFilePaths) + const knownFiles = collectKnownFilesUnder(folder, knownChildren) + const allDeselected = + knownFiles.length > 0 && knownFiles.every((f) => nextDeselected.has(f.path)) + for (const file of knownFiles) { + if (allDeselected) { + nextDeselected.delete(file.path) + } else { + nextDeselected.add(file.path) + } + } + setDeselectedFilePaths(nextDeselected) + return + } + + // 'partial' or 'none' on a folder without selected ancestors -> select-all logically. + const nextFolders = new Set(selectedFolderPaths) + nextFolders.add(folder.path) + // Folding nested explicitly-selected folders into the parent. + for (const other of Array.from(nextFolders)) { + if (other !== folder.path && isStrictlyUnder(other, folder.path)) { + nextFolders.delete(other) + } + } + const nextFiles = new Set(selectedFilePaths) + const nextDeselected = new Set(deselectedFilePaths) + for (const path of Array.from(nextFiles)) { + if (path === folder.path || isStrictlyUnder(path, folder.path)) { + nextFiles.delete(path) + } + } + for (const path of Array.from(nextDeselected)) { + if (isStrictlyUnder(path, folder.path)) { + nextDeselected.delete(path) + } + } + setSelectedFolderPaths(nextFolders) + setSelectedFilePaths(nextFiles) + setDeselectedFilePaths(nextDeselected) + }, + [deselectedFilePaths, folderState, selectedFilePaths, selectedFolderPaths] + ) + + const clear = useCallback(() => { + setSelectedFilePaths(new Set()) + setSelectedFolderPaths(new Set()) + setDeselectedFilePaths(new Set()) + }, []) + + const totals = useMemo(() => { + let count = 0 + let bytes = 0 + for (const path of selectedFilePaths) { + const file = findFileByPath(filesById, path) + count += 1 + if (file) { + bytes += file.size + } + } + return { + count, + bytes, + hasLogicalFolders: selectedFolderPaths.size > 0 + } + }, [filesById, selectedFilePaths, selectedFolderPaths.size]) + + return { + selectedFilePaths, + selectedFolderPaths, + deselectedFilePaths, + totals, + fileState, + folderState, + toggleFile, + toggleFolder, + clear, + filesById, + registerFile + } +} + +function collectKnownFilesUnder( + folder: FileTreeFolder, + knownChildren: FileTreeItem[] +): FileTreeFile[] { + const out: FileTreeFile[] = [] + for (const child of knownChildren) { + if (isFileTreeFile(child) && isStrictlyUnder(child.path, folder.path)) { + out.push(child) + } + } + return out +} + +function findFileByPath( + filesById: Map, + path: string +): FileTreeFile | undefined { + for (const file of filesById.values()) { + if (file.path === path) { + return file + } + } + return undefined +} diff --git a/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss new file mode 100644 index 000000000..21d608ac2 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss @@ -0,0 +1,42 @@ +.toggle { + display: inline-flex; + align-items: center; + border: 1px solid var(--bs-border-color); + border-radius: 4px; + overflow: hidden; + background-color: var(--bs-white); +} + +.toggle-button { + background: transparent; + border: none; + padding: 4px 12px; + font-size: 13px; + color: var(--bs-secondary-color); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--bs-font-monospace); +} + +.toggle-button:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.toggle-button-active { + background-color: var(--bs-primary); + color: var(--bs-white); +} + +.toggle-button:not(.toggle-button-active):hover { + background-color: rgba(0, 0, 0, 0.04); + color: var(--bs-body-color); +} + +.toggle-divider { + width: 1px; + align-self: stretch; + background-color: var(--bs-border-color); +} diff --git a/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx new file mode 100644 index 000000000..9d738d2a7 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx @@ -0,0 +1,68 @@ +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +import styles from './FilesViewToggle.module.scss' + +export type FilesViewMode = 'table' | 'tree' + +interface FilesViewToggleProps { + view: FilesViewMode + onChange: (view: FilesViewMode) => void +} + +const TableIcon = () => ( + + + + +) + +const TreeIcon = () => ( + + + + + + +) + +export function FilesViewToggle({ view, onChange }: FilesViewToggleProps) { + const { t } = useTranslation('files') + return ( +
+ + + +
+ ) +} diff --git a/tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx new file mode 100644 index 000000000..aa18ddb5c --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx @@ -0,0 +1,167 @@ +import { ReactNode } from 'react' +import { FilesTree } from '../../../../../../src/sections/dataset/dataset-files/files-tree/FilesTree' +import { FileTreeRepository } from '../../../../../../src/files/domain/repositories/FileTreeRepository' +import { FileTreePage } from '../../../../../../src/files/domain/models/FileTreePage' +import { AccessRepository } from '@/access/domain/repositories/AccessRepository' +import { AccessRepositoryProvider } from '@/sections/access/AccessRepositoryProvider' +import { DatasetVersionMother } from '../../../../dataset/domain/models/DatasetMother' +import { + FileTreeFileMother, + FileTreeFolderMother +} from '../../../../files/domain/models/FileTreeItemMother' +import { FileTreePageMother } from '../../../../files/domain/models/FileTreePageMother' + +const datasetVersion = DatasetVersionMother.create() + +class FakeTreeRepository implements FileTreeRepository { + private pages: Map + public calls: { path: string; cursor?: string }[] = [] + + constructor(pages: Record) { + this.pages = new Map(Object.entries(pages)) + } + + getNode(params: { path?: string; cursor?: string }): Promise { + const path = params.path ?? '' + this.calls.push({ path, cursor: params.cursor }) + const page = this.pages.get(path) + if (!page) { + return Promise.reject(new Error(`No mock page for path "${path}"`)) + } + return Promise.resolve(page) + } +} + +const accessRepository = { + getDatasetDownloadCount: cy.stub().resolves(0), + submitGuestbookForDatafileDownload: cy.stub().resolves('https://example.org/zip'), + submitGuestbookForDatafilesDownload: cy.stub().resolves('https://example.org/zip'), + submitGuestbookForDatasetDownload: cy.stub().resolves('https://example.org/zip') +} as unknown as AccessRepository + +function withAccess(children: ReactNode) { + return ( + {children} + ) +} + +describe('FilesTree', () => { + it('renders a loading state and then the root items', () => { + const root = FileTreePageMother.create({ + path: '', + items: [ + FileTreeFolderMother.create({ name: 'data', path: 'data' }), + FileTreeFileMother.create({ id: 1, name: 'README.md', path: 'README.md' }) + ] + }) + const repo = new FakeTreeRepository({ '': root }) + + cy.customMount( + withAccess( + + ) + ) + + cy.findByTestId('files-tree').should('exist') + cy.findByText('data').should('exist') + cy.findByText('README.md').should('exist') + }) + + it('lazy-loads a folder when its twisty is clicked', () => { + const root = FileTreePageMother.create({ + path: '', + items: [FileTreeFolderMother.create({ name: 'data', path: 'data' })] + }) + const dataPage = FileTreePageMother.create({ + path: 'data', + items: [FileTreeFileMother.create({ id: 10, name: 'inside.txt', path: 'data/inside.txt' })] + }) + const repo = new FakeTreeRepository({ '': root, data: dataPage }) + + cy.customMount( + withAccess( + + ) + ) + + cy.findByText('data').should('exist') + cy.findByLabelText(/Expand data/i).click() + cy.findByText('inside.txt').should('exist') + cy.then(() => { + expect(repo.calls.find((c) => c.path === 'data')).to.exist + }) + }) + + it('updates the selection summary when a file is checked', () => { + const root = FileTreePageMother.create({ + path: '', + items: [ + FileTreeFileMother.create({ + id: 7, + name: 'big.bin', + path: 'big.bin', + size: 2048 + }) + ] + }) + const repo = new FakeTreeRepository({ '': root }) + + cy.customMount( + withAccess( + + ) + ) + + cy.findByTestId('files-tree-checkbox-big.bin').click() + cy.findByTestId('files-tree-selection-summary').should('contain.text', '1') + cy.findByTestId('files-tree-download-button').should('not.be.disabled') + }) + + it('renders an error state on root failure with a retry button', () => { + class FailingRepo implements FileTreeRepository { + getNode() { + return Promise.reject(new Error('boom')) + } + } + cy.customMount( + withAccess( + + ) + ) + cy.findByTestId('files-tree-error').should('exist') + cy.findByText(/Couldn't load file index/i).should('exist') + }) + + it('renders empty state when no files are present', () => { + const repo = new FakeTreeRepository({ + '': FileTreePageMother.create({ path: '', items: [] }) + }) + cy.customMount( + withAccess( + + ) + ) + cy.findByTestId('files-tree-empty').should('exist') + cy.findByText(/no files/i).should('exist') + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.spec.tsx new file mode 100644 index 000000000..526cc8a90 --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeFlatten.spec.tsx @@ -0,0 +1,96 @@ +import { buildVisibleRows } from '../../../../../../src/sections/dataset/dataset-files/files-tree/useFileTreeFlatten' +import { + FileTreeFileMother, + FileTreeFolderMother +} from '../../../../files/domain/models/FileTreeItemMother' +import { FolderNode } from '../../../../../../src/sections/dataset/dataset-files/files-tree/useFileTree' + +const folder = (path: string, items: FolderNode['items']): FolderNode => ({ + path, + items, + nextCursor: null, + loading: false, + loaded: true +}) + +describe('buildVisibleRows', () => { + it('flattens only opened folders', () => { + const dataFolder = FileTreeFolderMother.create({ name: 'data', path: 'data' }) + const fileTop = FileTreeFileMother.create({ id: 1, name: 'top.txt', path: 'top.txt' }) + const inner = FileTreeFileMother.create({ id: 2, name: 'inner.txt', path: 'data/inner.txt' }) + + const nodes = new Map([ + ['', folder('', [dataFolder, fileTop])], + ['data', folder('data', [inner])] + ]) + const expanded = new Set(['']) + + const rows = buildVisibleRows(nodes, expanded) + expect(rows.map((r) => (r.kind === 'item' ? r.node.path : r.kind))).to.deep.equal([ + 'data', + 'top.txt' + ]) + + const rowsExpanded = buildVisibleRows(nodes, new Set(['', 'data'])) + expect(rowsExpanded.map((r) => (r.kind === 'item' ? r.node.path : r.kind))).to.deep.equal([ + 'data', + 'data/inner.txt', + 'top.txt' + ]) + }) + + it('emits a load-more row when nextCursor is set', () => { + const dataFolder = FileTreeFolderMother.create({ name: 'data', path: 'data' }) + const node: FolderNode = { + path: '', + items: [dataFolder], + nextCursor: 'mem:1', + loading: false, + loaded: true + } + const rows = buildVisibleRows(new Map([['', node]]), new Set([''])) + expect(rows[rows.length - 1].kind).to.equal('load-more') + }) + + it('emits an error row when error is present and no items', () => { + const node: FolderNode = { + path: '', + items: [], + nextCursor: null, + loading: false, + loaded: true, + error: 'boom' + } + const rows = buildVisibleRows(new Map([['', node]]), new Set([''])) + expect(rows[0]).to.deep.include({ kind: 'error', error: 'boom' }) + }) + + it('emits a loading row for a folder pending children', () => { + const dataFolder = FileTreeFolderMother.create({ name: 'data', path: 'data' }) + const node: FolderNode = { + path: '', + items: [dataFolder], + nextCursor: null, + loading: false, + loaded: true + } + const rows = buildVisibleRows(new Map([['', node]]), new Set(['', 'data'])) + expect(rows.find((r) => r.kind === 'loading')?.path).to.equal('data') + }) + + it('filters by query, opens matching folders implicitly', () => { + const dataFolder = FileTreeFolderMother.create({ name: 'data', path: 'data' }) + const inner = FileTreeFileMother.create({ id: 2, name: 'wanted.txt', path: 'data/wanted.txt' }) + const noise = FileTreeFileMother.create({ id: 3, name: 'noise.txt', path: 'data/noise.txt' }) + const nodes = new Map([ + ['', folder('', [dataFolder])], + ['data', folder('data', [inner, noise])] + ]) + + const rows = buildVisibleRows(nodes, new Set(), 'wanted') + const items = rows + .filter((r) => r.kind === 'item') + .map((r) => (r.kind === 'item' ? r.node.path : '')) + expect(items).to.deep.equal(['data', 'data/wanted.txt']) + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeSelection.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeSelection.spec.tsx new file mode 100644 index 000000000..a601f8146 --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeSelection.spec.tsx @@ -0,0 +1,84 @@ +import { renderHook, act } from '@testing-library/react' +import { useFileTreeSelection } from '../../../../../../src/sections/dataset/dataset-files/files-tree/useFileTreeSelection' +import { + FileTreeFileMother, + FileTreeFolderMother +} from '../../../../files/domain/models/FileTreeItemMother' + +const folderData = FileTreeFolderMother.create({ name: 'data', path: 'data' }) +const fileA = FileTreeFileMother.create({ id: 1, name: 'a.txt', path: 'data/a.txt', size: 1000 }) +const fileB = FileTreeFileMother.create({ id: 2, name: 'b.txt', path: 'data/b.txt', size: 2000 }) +const fileTopLevel = FileTreeFileMother.create({ + id: 3, + name: 'top.txt', + path: 'top.txt', + size: 100 +}) + +describe('useFileTreeSelection', () => { + it('starts with no selection', () => { + const { result } = renderHook(() => useFileTreeSelection()) + expect(result.current.totals.count).to.equal(0) + expect(result.current.totals.bytes).to.equal(0) + expect(result.current.fileState(fileA)).to.equal('none') + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('none') + }) + + it('toggles a single file in and out of selection', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFile(fileTopLevel)) + expect(result.current.fileState(fileTopLevel)).to.equal('all') + expect(result.current.totals.count).to.equal(1) + expect(result.current.totals.bytes).to.equal(100) + act(() => result.current.toggleFile(fileTopLevel)) + expect(result.current.fileState(fileTopLevel)).to.equal('none') + expect(result.current.totals.count).to.equal(0) + }) + + it('marks a folder as logically selected without enumerating descendants', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFolder(folderData, [])) + expect(result.current.folderState(folderData, [])).to.equal('all') + expect(result.current.fileState(fileA)).to.equal('all') + expect(result.current.fileState(fileB)).to.equal('all') + expect(result.current.totals.hasLogicalFolders).to.equal(true) + }) + + it('flips folder to partial when an inner file is deselected', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFolder(folderData, [fileA, fileB])) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('all') + act(() => result.current.toggleFile(fileA)) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('partial') + expect(result.current.fileState(fileA)).to.equal('none') + expect(result.current.fileState(fileB)).to.equal('all') + }) + + it('reports a folder as all when every visited child file is checked individually', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFile(fileA)) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('partial') + act(() => result.current.toggleFile(fileB)) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('all') + }) + + it('clears all sets', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFolder(folderData, [fileA, fileB])) + act(() => result.current.toggleFile(fileA)) + act(() => result.current.clear()) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('none') + expect(result.current.fileState(fileA)).to.equal('none') + expect(result.current.fileState(fileB)).to.equal('none') + expect(result.current.totals.count).to.equal(0) + expect(result.current.totals.hasLogicalFolders).to.equal(false) + }) + + it('deselecting a logical folder removes nested explicit selections', () => { + const { result } = renderHook(() => useFileTreeSelection()) + act(() => result.current.toggleFolder(folderData, [fileA, fileB])) + act(() => result.current.toggleFolder(folderData, [fileA, fileB])) + expect(result.current.folderState(folderData, [fileA, fileB])).to.equal('none') + expect(result.current.totals.hasLogicalFolders).to.equal(false) + }) +}) diff --git a/tests/component/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.spec.tsx b/tests/component/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.spec.tsx new file mode 100644 index 000000000..94df30a86 --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.spec.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import { + FilesViewToggle, + FilesViewMode +} from '../../../../../../src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle' + +function Harness({ initial }: { initial: FilesViewMode }) { + const [view, setView] = useState(initial) + return ( + <> + +
{view}
+ + ) +} + +describe('FilesViewToggle', () => { + it('reflects the active view', () => { + cy.customMount() + cy.findByTestId('files-view-toggle-table').should('have.attr', 'aria-selected', 'true') + cy.findByTestId('files-view-toggle-tree').should('have.attr', 'aria-selected', 'false') + }) + + it('switches to tree view on click', () => { + cy.customMount() + cy.findByTestId('files-view-toggle-tree').click() + cy.findByTestId('harness-current').should('have.text', 'tree') + }) + + it('switches back to table view on click', () => { + cy.customMount() + cy.findByTestId('files-view-toggle-table').click() + cy.findByTestId('harness-current').should('have.text', 'table') + }) +}) From bd2185b8fffde0d61286360f6ab32c9c740af0e3 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:22:17 +0200 Subject: [PATCH 019/110] Add docs/reusable-components.md (frontend half of the dual-mode contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New developer guide for building React components that run in BOTH the SPA and the legacy JSF UI. Covers: - Why dual-mode (avoid two implementations during the JSF→SPA migration). - The contract every reusable component must follow: standalone entry, typed window config, shared core component, repository adapter, session-cookie auth, single-file CSS injection. - Build pipeline (vite.config.uploader.ts → reusable-components/ + shared chunks) and how to add a new entry. - Authentication / CSRF prerequisites. - CSS isolation strategy and the known Bootstrap 3 vs 5 caveat. - Adding a new reusable component (greenfield) and extracting one from an existing SPA section. - Currently shipped: dv-uploader (and tree-view planned). - Test conventions and versioning rules. Cross-links to the backend half in dataverse/doc/Architecture/ reusable_frontend_components.md. --- docs/reusable-components.md | 214 ++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/reusable-components.md diff --git a/docs/reusable-components.md b/docs/reusable-components.md new file mode 100644 index 000000000..95e764e51 --- /dev/null +++ b/docs/reusable-components.md @@ -0,0 +1,214 @@ +# Reusable Components + +How to build, ship, and consume Dataverse frontend components that work in **both** the React SPA and the legacy JSF UI. + +This document is the **frontend** half of the contract. The matching backend half — JSF feature flags, JSF mount points, nginx hosting — lives in [`dataverse/doc/Architecture/reusable_frontend_components.md`](https://github.com/IQSS/dataverse/blob/develop/doc/Architecture/reusable_frontend_components.md). Read both before changing the contract. + +- [Why dual-mode](#why-dual-mode) +- [The contract](#the-contract) +- [Build pipeline](#build-pipeline) +- [Authentication](#authentication) +- [CSS isolation](#css-isolation) +- [Adding a new reusable component](#adding-a-new-reusable-component) +- [Making an existing SPA component reusable](#making-an-existing-spa-component-reusable) +- [Currently shipped components](#currently-shipped-components) +- [Testing reusable components](#testing-reusable-components) +- [Versioning and breaking changes](#versioning-and-breaking-changes) + +## Why dual-mode + +Dataverse is multi-year migrating from JSF to a React SPA. Some pages are SPA, many are still JSF, and a few are mixed. We don't want two implementations of the same feature; we want one React component that runs in both places. The pattern: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ One React component (built once) │ +│ │ +│ Mounts via window.Config in JSF (direct mount) │ +│ Mounts via React props in the SPA (no config object) │ +└─────────────────────────────────────────────────────────────┘ +``` + +The bundle is loaded as a regular ` + +``` + +Feature flag (server-side): `dataverse.feature.react-uploader`. + +### Tree view (in development — `#6691`) + +Will follow the same pattern. The SPA section already exists at `src/sections/dataset/dataset-files/files-tree/`; the standalone wrapper is M3 in [`dataverse-context/tree_view_plan.md`](../../dataverse-context/tree_view_plan.md). Until the JSF mount lands, the tree view is SPA-only and is reached via `?view=tree` on the dataset page. + +## Testing reusable components + +- **SPA tests run as Cypress component tests** under `tests/component/...`, using `cy.customMount` so the React tree gets the same `Router`, `I18nextProvider`, `ThemeProvider`, and `ExternalToolsProvider` it would in production. +- **Standalone wrapper tests** mount the standalone component with a stubbed `window.`. Verify the inline error path (config missing) explicitly — JSF callers cannot see thrown exceptions. +- **Unit-test transformers and config parsers** in plain TypeScript files under `tests/component//...spec.ts`. No Cypress for pure-TS code. +- **Storybook** stories may be added for components that benefit from visual review. Not required. +- **Coverage threshold** is 95% on `src/sections/**/*.{ts,tsx}` (`.nycrc.json`). Reusable components count. + +When in doubt about a test, look at: + +- `tests/component/sections/shared/file-uploader/FileUploaderPanelCore.spec.tsx` +- `tests/component/sections/dataset/dataset-files/files-tree/FilesTree.spec.tsx` + +## Versioning and breaking changes + +The reusable bundle is consumed cross-repo: + +- The SPA and the standalone bundle move together (same git tag). +- Dataverse's `dvwebloader` directory is **served from the SPA build output**. There is no separate package version on the JSF side beyond the file path it loads. +- A breaking change to a config interface is a coordinated change across `dataverse-frontend` and `dataverse` (the JSF page that sets `window.`). + +Rules of thumb: + +- **Add fields**, never remove. The host might be on an older JSF page. +- **Default to no-op** when a config field is unrecognised. Don't throw; log a `console.warn`. +- **Bump the file name (`dv-uploader.v2.js`) only on truly breaking changes** — a renamed config field, a removed mount path. Otherwise you fork the integration permanently. +- **Document the new shape in this file** and in the matching backend doc on the same PR. Reviewers should see both halves. From 119e6def8641e82f64b0d5c166f8b6f7f1b75b7a Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:56:25 +0200 Subject: [PATCH 020/110] Bump @iqss/dataverse-client-javascript to 2.2.0-pr403.0df68ec Pulls the latest prerelease of the SDK published from PR #403 after its CI went green following the IQSS/dataverse#12182 storage-driver endpoint move. This version ships: - The tree node listing helpers (listDatasetTreeNode + iterateDatasetTreeNode) that the tree-view track will consume. - The public re-export of DataverseApiAuthMechanism, replacing the current deep import in src/standalone-uploader/index.tsx. - The server-driven S3 tagging (FileUploadDestination.tagging) that removes the duplicate client-side flag. Lockfile updated; no behaviour change in this commit (consumer code still uses the existing deep import + inline axios; follow-up commits will wire in the new public surfaces). --- package-lock.json | 9 ++++----- package.json | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70aa6ee2c..9fed69938 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.2.0-pr403.5a9f204", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.0df68ec", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -73,7 +73,6 @@ "@types/chai-as-promised": "7.1.5", "@types/node-sass": "4.11.3", "@types/sinon": "10.0.13", - "@types/turndown": "^5.0.6", "@typescript-eslint/eslint-plugin": "5.51.0", "@typescript-eslint/parser": "5.51.0", "@vitejs/plugin-react": "4.3.1", @@ -1954,9 +1953,9 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "version": "2.2.0-pr403.5a9f204", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-pr403.5a9f204/9ca8523e924dd5f677e9b5e8f817b1f6bfea6d5a", - "integrity": "sha512-7YWSSJcBtvENtVfOr+y0IldZXzfj36eynX9ywXFKurEs+fOG6q6EtukH9tniFqBfXZUCgpeaNmjHfMJz01/DxA==", + "version": "2.2.0-pr403.0df68ec", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-pr403.0df68ec/de3cada347540f59496483c9e42c8376860c4b71", + "integrity": "sha512-G7L++rUICH6u70z3kn6f7WEPdLhwm5zGqXZkXnz+qS86Gd6DnPmAJimRFkCKWYn31ilKiYcz9sD5IRCjipWqYg==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", diff --git a/package.json b/package.json index 6ea00877b..838df8af9 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.2.0-pr403.5a9f204", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.0df68ec", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", From 925c61891416c088c2ed18690e7b0c6d3cf107e5 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 12:58:08 +0200 Subject: [PATCH 021/110] Wire SDK public exports into frontend consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the SDK prerelease ships the public re-export of DataverseApiAuthMechanism and the listDatasetTreeNode helper, swap the two waiting consumers off their workarounds: 1. src/standalone-uploader/index.tsx: replace the deep import `@iqss/dataverse-client-javascript/dist/core/infra/repositories/ ApiConfig` with the public surface `@iqss/dataverse-client-javascript`. One-line cleanup; removes the TODO marker the previous chore commit added. 2. src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts: replace the inline axios.get + raw-payload mapping with `listDatasetTreeNode.execute(...)` from the SDK. The SDK's wire format is already aligned with what the backend returns, so the only remaining adapter work is mapping SDK domain types to the frontend's domain types (FileTreeNodeType ↔ FileTreeItemType, SDK's string `access` ↔ frontend's `FileAccess` object). The 404/405/501 fallback to FileTreeFromPreviewsRepository now detects ReadError messages (the SDK wraps HTTP errors in ReadError with a `[]` prefix) instead of axios error objects. A defensive branch for raw axios errors is retained so a transitional state with mixed bundles doesn't regress. No behaviour change visible to the UI. --- .../FileTreeJSDataverseRepository.ts | 188 +++++++++--------- src/standalone-uploader/index.tsx | 5 +- 2 files changed, 91 insertions(+), 102 deletions(-) diff --git a/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts index 5be34c8cf..c61fd334c 100644 --- a/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts +++ b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts @@ -9,51 +9,29 @@ import { FileTreeItem, FileTreeItemType } from '../../domain/models/FileTreeItem' -import { axiosInstance } from '@/axiosInstance' -import { requireAppConfig } from '../../../config' +import { + FileTreeFileNode as SDKFileTreeFileNode, + FileTreeFolderNode as SDKFileTreeFolderNode, + FileTreeInclude as SDKFileTreeInclude, + FileTreeNode as SDKFileTreeNode, + FileTreeOrder as SDKFileTreeOrder, + FileTreePage as SDKFileTreePage, + ReadError, + isFileTreeFileNode, + isFileTreeFolderNode, + listDatasetTreeNode +} from '@iqss/dataverse-client-javascript' import { FileTreeFromPreviewsRepository } from './FileTreeFromPreviewsRepository' import { FileRepository } from '../../domain/repositories/FileRepository' -interface RawFolder { - type: 'folder' - name: string - path: string - counts?: { files: number; folders: number } -} - -interface RawFile { - type: 'file' - id: number - name: string - path: string - size: number - contentType?: string - access?: 'public' | 'restricted' | 'embargoed' - checksum?: { type: string; value: string } - downloadUrl: string -} - -interface RawTreeResponse { - path: string - items: (RawFolder | RawFile)[] - nextCursor: string | null - limit: number - order: string - include: string - approximateCount?: number -} - /** - * Calls the dedicated tree endpoint - * `GET /api/datasets/{id}/versions/{versionId}/tree`. When the endpoint is not - * available on the target instance the repository falls back to the in-memory - * `FileTreeFromPreviewsRepository` so the SPA stays usable in mixed-version - * deployments. + * Calls the dedicated tree endpoint via the SDK helper + * `listDatasetTreeNode`, which wraps + * `GET /api/datasets/{id}/versions/{versionId}/tree`. * - * TODO: replace the inline `axiosInstance.get` call with - * `listDatasetTreeNode` from `@iqss/dataverse-client-javascript` once the - * SDK prerelease that ships those helpers is published. The wire format is - * already aligned with the SDK's `transformTreeResponseToFileTreePage`. + * When the endpoint is not available on the target instance the + * repository falls back to the in-memory `FileTreeFromPreviewsRepository` + * so the SPA stays usable in mixed-version deployments. */ export class FileTreeJSDataverseRepository implements FileTreeRepository { private fallback?: FileTreeFromPreviewsRepository @@ -66,19 +44,18 @@ export class FileTreeJSDataverseRepository implements FileTreeRepository { return this.fallback.getNode(params) } try { - const versionId = encodeURIComponent(params.datasetVersion.number.toString()) - const persistentId = encodeURIComponent(params.datasetPersistentId) - const search = buildQuery(params) - const url = `${ - FileTreeJSDataverseRepository.baseUrl - }/api/datasets/:persistentId/versions/${versionId}/tree?persistentId=${persistentId}${ - search ? `&${search}` : '' - }` - const response = await axiosInstance.get<{ data: RawTreeResponse } | RawTreeResponse>(url, { - withCredentials: true + const sdkPage = await listDatasetTreeNode.execute({ + datasetId: params.datasetPersistentId, + datasetVersionId: params.datasetVersion.number.toString(), + path: params.path, + limit: params.limit, + cursor: params.cursor, + include: toSDKInclude(params.include), + order: toSDKOrder(params.order), + includeDeaccessioned: params.includeDeaccessioned, + originals: params.originals }) - const payload = unwrap(response.data) - return mapResponse(payload, params) + return mapPage(sdkPage, params) } catch (error) { if (this.fileRepository && isEndpointMissing(error)) { this.endpointUnavailable = true @@ -90,47 +67,35 @@ export class FileTreeJSDataverseRepository implements FileTreeRepository { throw error } } - - static get baseUrl(): string { - return requireAppConfig().backendUrl - } -} - -function buildQuery(params: GetFileTreeNodeParams): string { - const out: string[] = [] - if (params.path) out.push(`path=${encodeURIComponent(params.path)}`) - if (params.limit !== undefined) out.push(`limit=${params.limit}`) - if (params.cursor) out.push(`cursor=${encodeURIComponent(params.cursor)}`) - if (params.include) out.push(`include=${params.include}`) - if (params.order) out.push(`order=${params.order}`) - if (params.includeDeaccessioned) out.push('includeDeaccessioned=true') - if (params.originals) out.push('originals=true') - return out.join('&') } -function unwrap(value: { data: T } | T): T { - if (value && typeof value === 'object' && 'data' in (value as Record)) { - return (value as { data: T }).data +function mapPage(page: SDKFileTreePage, params: GetFileTreeNodeParams): FileTreePage { + const items: FileTreeItem[] = page.items.map(mapItem) + return { + path: page.path, + items, + nextCursor: page.nextCursor, + limit: page.limit, + order: fromSDKOrder(page.order, params.order), + include: fromSDKInclude(page.include, params.include), + approximateCount: page.approximateCount } - return value as T } -function mapResponse(raw: RawTreeResponse, params: GetFileTreeNodeParams): FileTreePage { - const items: FileTreeItem[] = raw.items.map((item) => - item.type === 'folder' ? mapFolder(item) : mapFile(item) - ) - return { - path: raw.path, - items, - nextCursor: raw.nextCursor, - limit: raw.limit, - order: parseOrder(raw.order, params.order), - include: parseInclude(raw.include, params.include), - approximateCount: raw.approximateCount +function mapItem(item: SDKFileTreeNode): FileTreeItem { + if (isFileTreeFolderNode(item)) { + return mapFolder(item) + } + if (isFileTreeFileNode(item)) { + return mapFile(item) } + // The SDK's union is exhaustive; this is a defensive fallthrough so + // a future server-side type addition doesn't crash the SPA. + /* istanbul ignore next */ + throw new Error(`Unknown file tree node type: ${(item as { type: string }).type}`) } -function mapFolder(item: RawFolder): FileTreeFolder { +function mapFolder(item: SDKFileTreeFolderNode): FileTreeFolder { return { type: FileTreeItemType.FOLDER, name: item.name, @@ -139,7 +104,7 @@ function mapFolder(item: RawFolder): FileTreeFolder { } } -function mapFile(item: RawFile): FileTreeFile { +function mapFile(item: SDKFileTreeFileNode): FileTreeFile { return { type: FileTreeItemType.FILE, id: item.id, @@ -160,25 +125,52 @@ function mapFile(item: RawFile): FileTreeFile { } } -function parseOrder(value: string, fallback?: FileTreeOrder): FileTreeOrder { - if (value === FileTreeOrder.NAME_AZ || value === FileTreeOrder.NAME_ZA) { - return value +function toSDKOrder(value?: FileTreeOrder): SDKFileTreeOrder | undefined { + if (value === undefined) return undefined + return value === FileTreeOrder.NAME_ZA ? SDKFileTreeOrder.NAME_ZA : SDKFileTreeOrder.NAME_AZ +} + +function toSDKInclude(value?: FileTreeInclude): SDKFileTreeInclude | undefined { + if (value === undefined) return undefined + switch (value) { + case FileTreeInclude.FOLDERS: + return SDKFileTreeInclude.FOLDERS + case FileTreeInclude.FILES: + return SDKFileTreeInclude.FILES + case FileTreeInclude.ALL: + default: + return SDKFileTreeInclude.ALL } - return fallback ?? FileTreeOrder.NAME_AZ } -function parseInclude(value: string, fallback?: FileTreeInclude): FileTreeInclude { - if ( - value === FileTreeInclude.ALL || - value === FileTreeInclude.FOLDERS || - value === FileTreeInclude.FILES - ) { - return value +function fromSDKOrder(value: SDKFileTreeOrder, fallback?: FileTreeOrder): FileTreeOrder { + return value === SDKFileTreeOrder.NAME_ZA + ? FileTreeOrder.NAME_ZA + : (value as unknown as FileTreeOrder) ?? fallback ?? FileTreeOrder.NAME_AZ +} + +function fromSDKInclude(value: SDKFileTreeInclude, fallback?: FileTreeInclude): FileTreeInclude { + switch (value) { + case SDKFileTreeInclude.FOLDERS: + return FileTreeInclude.FOLDERS + case SDKFileTreeInclude.FILES: + return FileTreeInclude.FILES + case SDKFileTreeInclude.ALL: + return FileTreeInclude.ALL + default: + return fallback ?? FileTreeInclude.ALL } - return fallback ?? FileTreeInclude.ALL } function isEndpointMissing(error: unknown): boolean { + if (error instanceof ReadError) { + return /\[(404|405|501)\]/.test(error.message) + } + // Defensive: pre-SDK-wrapped axios errors used by the previous + // implementation. Kept so a transitional state (older browsers / + // cached SDK build) doesn't regress. + /* istanbul ignore next */ const status = (error as { response?: { status?: number } })?.response?.status + /* istanbul ignore next */ return status === 404 || status === 405 || status === 501 } diff --git a/src/standalone-uploader/index.tsx b/src/standalone-uploader/index.tsx index 52cd05c6a..36afba3b1 100644 --- a/src/standalone-uploader/index.tsx +++ b/src/standalone-uploader/index.tsx @@ -3,10 +3,7 @@ import { StrictMode } from 'react' import i18next from 'i18next' import { initReactI18next } from 'react-i18next' import I18NextHttpBackend from 'i18next-http-backend' -import { ApiConfig } from '@iqss/dataverse-client-javascript' -// TODO: switch to `import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript'` -// once the SDK prerelease for #403 is republished — the public re-export is in `core/index.ts`. -import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dist/core/infra/repositories/ApiConfig' +import { ApiConfig, DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript' import { ToastContainer } from 'react-toastify' import { StandaloneFileUploaderPanel } from './StandaloneFileUploaderPanel' import { StandaloneFileRepository } from './StandaloneFileRepository' From e22c60271f7dcabd71b99b10ea90016b59873c37 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 13:31:08 +0200 Subject: [PATCH 022/110] Bookmarkable tree-view path via ?path= query param (#8694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing ?view=tree URL state with an optional ?path= parameter so a deep link can land directly on a folder inside the tree. useFileTree: - New optional initialPath arg. On mount, every ancestor along the path is added to the expanded set, and each ancestor folder is pre-fetched so the tree visibly opens to the bookmarked depth without the user clicking through. - New currentPath in the API: the deepest folder in the expanded set, or '' if only the root is expanded. Useful as the canonical bookmark target. FilesTree: - New initialPath prop forwarded to useFileTree. - New onCurrentPathChange callback fired whenever the deepest expanded folder changes. Decoupled from the router so FilesTree stays SPA-/router-agnostic. DatasetFiles + DatasetFilesScrollable: - Read ?path= on mount, pass to FilesTree.initialPath. - Update ?path= on every onCurrentPathChange via setSearchParams with replace:true (so each step doesn't pollute history). - When the user toggles back to table view, both ?view= and ?path= are dropped — they're only meaningful when view=tree. Test: useFileTreeBookmark.spec.tsx covers the ancestor expansion on mount + currentPath transitions on expand/collapse. --- .../dataset/dataset-files/DatasetFiles.tsx | 22 +++- .../dataset-files/DatasetFilesScrollable.tsx | 14 +++ .../dataset-files/files-tree/FilesTree.tsx | 27 ++++- .../dataset-files/files-tree/useFileTree.ts | 75 ++++++++++++- .../files-tree/useFileTreeBookmark.spec.tsx | 104 ++++++++++++++++++ 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 tests/component/sections/dataset/dataset-files/files-tree/useFileTreeBookmark.spec.tsx diff --git a/src/sections/dataset/dataset-files/DatasetFiles.tsx b/src/sections/dataset/dataset-files/DatasetFiles.tsx index 2703904c4..47ebb0988 100644 --- a/src/sections/dataset/dataset-files/DatasetFiles.tsx +++ b/src/sections/dataset/dataset-files/DatasetFiles.tsx @@ -24,6 +24,7 @@ interface DatasetFilesProps { } const VIEW_PARAM = 'view' +const PATH_PARAM = 'path' export function DatasetFiles({ filesRepository, @@ -34,12 +35,23 @@ export function DatasetFiles({ }: DatasetFilesProps) { const [searchParams, setSearchParams] = useSearchParams() const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const treePath = searchParams.get(PATH_PARAM) ?? '' const setView = (next: FilesViewMode) => { const updated = new URLSearchParams(searchParams) if (next === 'tree') { updated.set(VIEW_PARAM, 'tree') } else { updated.delete(VIEW_PARAM) + updated.delete(PATH_PARAM) + } + setSearchParams(updated, { replace: true }) + } + const setTreePath = (next: string) => { + const updated = new URLSearchParams(searchParams) + if (next) { + updated.set(PATH_PARAM, next) + } else { + updated.delete(PATH_PARAM) } setSearchParams(updated, { replace: true }) } @@ -56,6 +68,8 @@ export function DatasetFiles({ datasetVersion={datasetVersion} onChangeView={setView} view={view} + initialPath={treePath} + onCurrentPathChange={setTreePath} /> ) : ( void view: FilesViewMode + initialPath: string + onCurrentPathChange: (path: string) => void } function DatasetFilesTreeView({ @@ -137,7 +153,9 @@ function DatasetFilesTreeView({ datasetPersistentId, datasetVersion, onChangeView, - view + view, + initialPath, + onCurrentPathChange }: DatasetFilesTreeViewProps) { return ( <> @@ -148,6 +166,8 @@ function DatasetFilesTreeView({ treeRepository={treeRepository} datasetPersistentId={datasetPersistentId} datasetVersion={datasetVersion} + initialPath={initialPath} + onCurrentPathChange={onCurrentPathChange} /> ) diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx index 74a8a4dd1..45d806e38 100644 --- a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx +++ b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx @@ -31,6 +31,7 @@ interface DatasetFilesScrollableProps { } const VIEW_PARAM = 'view' +const PATH_PARAM = 'path' export type SentryRef = UseInfiniteScrollHookRefCallback @@ -48,12 +49,23 @@ export function DatasetFilesScrollable({ const [searchParams, setSearchParams] = useSearchParams() const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const treePath = searchParams.get(PATH_PARAM) ?? '' const setView = (next: FilesViewMode) => { const updated = new URLSearchParams(searchParams) if (next === 'tree') { updated.set(VIEW_PARAM, 'tree') } else { updated.delete(VIEW_PARAM) + updated.delete(PATH_PARAM) + } + setSearchParams(updated, { replace: true }) + } + const setTreePath = (next: string) => { + const updated = new URLSearchParams(searchParams) + if (next) { + updated.set(PATH_PARAM, next) + } else { + updated.delete(PATH_PARAM) } setSearchParams(updated, { replace: true }) } @@ -190,6 +202,8 @@ export function DatasetFilesScrollable({ treeRepository={treeRepository} datasetPersistentId={datasetPersistentId} datasetVersion={datasetVersion} + initialPath={treePath} + onCurrentPathChange={setTreePath} />
) diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index 6bef1253d..5ce9aecaa 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -45,6 +45,17 @@ interface FilesTreeProps { order?: FileTreeOrder rowHeight?: number fallbackHeight?: number + /** + * Folder path to expand on mount (e.g. read from a `?path=` URL query + * param). All ancestors along the path are expanded. + */ + initialPath?: string + /** + * Called with the deepest currently-expanded folder path whenever the + * expansion changes. The host can sync this to the URL for deep + * linking. Empty string means only the root is expanded. + */ + onCurrentPathChange?: (path: string) => void } const DEFAULT_ROW_HEIGHT = 32 @@ -59,7 +70,9 @@ export function FilesTree({ query, order = FileTreeOrder.NAME_AZ, rowHeight = DEFAULT_ROW_HEIGHT, - fallbackHeight = DEFAULT_FALLBACK_HEIGHT + fallbackHeight = DEFAULT_FALLBACK_HEIGHT, + initialPath = '', + onCurrentPathChange }: FilesTreeProps) { const { t } = useTranslation('files') const tree = useFileTree({ @@ -68,8 +81,18 @@ export function FilesTree({ datasetVersion, pageSize, order, - include: FileTreeInclude.ALL + include: FileTreeInclude.ALL, + initialPath }) + + const lastPathRef = useRef(initialPath) + useEffect(() => { + if (!onCurrentPathChange) return + if (tree.currentPath !== lastPathRef.current) { + lastPathRef.current = tree.currentPath + onCurrentPathChange(tree.currentPath) + } + }, [tree.currentPath, onCurrentPathChange]) const selection = useFileTreeSelection() const accessRepository = useAccessRepository() diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTree.ts b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts index b5ba6c778..b5c506f04 100644 --- a/src/sections/dataset/dataset-files/files-tree/useFileTree.ts +++ b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts @@ -23,12 +23,25 @@ export interface UseFileTreeArgs { pageSize?: number order?: FileTreeOrder include?: FileTreeInclude + /** + * Path to expand on mount — typically read from a `?path=` URL query + * param so a deep link opens the tree at the bookmarked folder. The + * hook expands every ancestor along the way (e.g. `data/raw/2024` + * causes `data`, `data/raw`, and `data/raw/2024` all to be expanded). + */ + initialPath?: string } export interface UseFileTreeApi { rootNode: FolderNode nodes: ReadonlyMap expanded: ReadonlySet + /** + * The deepest folder currently in the expanded set. Empty string means + * only the root is expanded. Useful for surfacing a single canonical + * path to a URL bookmark. + */ + currentPath: string toggleExpanded: (path: string) => Promise expand: (path: string) => Promise collapse: (path: string) => void @@ -41,16 +54,62 @@ export interface UseFileTreeApi { const ROOT = '' +/** + * Returns the chain of ancestor paths for a folder, including the folder + * itself but excluding the empty root. For `data/raw/2024` → + * `['data', 'data/raw', 'data/raw/2024']`. + */ +function ancestorChain(path: string): string[] { + if (!path) { + return [] + } + const parts = path.split('/').filter((p) => p.length > 0) + const out: string[] = [] + let acc = '' + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part + out.push(acc) + } + return out +} + +/** + * Picks the deepest folder from a set of expanded paths — used to derive + * `currentPath` for URL bookmarking. Returns `''` if no non-root folder + * is expanded. + */ +function deepestExpanded(set: ReadonlySet): string { + let deepest = '' + let depth = 0 + for (const path of set) { + if (!path) continue + const d = path.split('/').length + if (d > depth) { + deepest = path + depth = d + } + } + return deepest +} + export function useFileTree({ repository, datasetPersistentId, datasetVersion, pageSize = 200, order = FileTreeOrder.NAME_AZ, - include = FileTreeInclude.ALL + include = FileTreeInclude.ALL, + initialPath = '' }: UseFileTreeArgs): UseFileTreeApi { const [nodes, setNodes] = useState>(() => new Map()) - const [expanded, setExpanded] = useState>(() => new Set([ROOT])) + const initialExpanded = (() => { + const set = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + set.add(ancestor) + } + return set + })() + const [expanded, setExpanded] = useState>(() => initialExpanded) const knownFilesRef = useRef>(new Map()) const inFlight = useRef>>(new Map()) const versionKey = `${datasetPersistentId}::${datasetVersion.number.toString()}::${order}::${include}` @@ -132,11 +191,20 @@ export function useFileTree({ if (previousKey.current !== versionKey) { previousKey.current = versionKey setNodes(new Map()) - setExpanded(new Set([ROOT])) + const reset = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + reset.add(ancestor) + } + setExpanded(reset) knownFilesRef.current = new Map() inFlight.current.clear() } void ensureLoaded(ROOT) + // Pre-fetch every initial-path ancestor so the tree opens to the + // bookmarked depth on mount without the user clicking through. + for (const ancestor of ancestorChain(initialPath)) { + void ensureLoaded(ancestor) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [versionKey]) @@ -230,6 +298,7 @@ export function useFileTree({ rootNode, nodes, expanded, + currentPath: deepestExpanded(expanded), toggleExpanded, expand, collapse, diff --git a/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeBookmark.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeBookmark.spec.tsx new file mode 100644 index 000000000..cfd5eec6a --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-tree/useFileTreeBookmark.spec.tsx @@ -0,0 +1,104 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { useFileTree } from '../../../../../../src/sections/dataset/dataset-files/files-tree/useFileTree' +import { FileTreeRepository } from '../../../../../../src/files/domain/repositories/FileTreeRepository' +import { FileTreePage } from '../../../../../../src/files/domain/models/FileTreePage' +import { DatasetVersionMother } from '../../../../dataset/domain/models/DatasetMother' +import { + FileTreeFileMother, + FileTreeFolderMother +} from '../../../../files/domain/models/FileTreeItemMother' +import { FileTreePageMother } from '../../../../files/domain/models/FileTreePageMother' + +const datasetVersion = DatasetVersionMother.create() + +class FakeRepo implements FileTreeRepository { + public calls: string[] = [] + constructor(private readonly pages: Record) {} + getNode(params: { path?: string }): Promise { + const key = params.path ?? '' + this.calls.push(key) + const page = this.pages[key] + if (!page) { + return Promise.reject(new Error(`No mock for "${key}"`)) + } + return Promise.resolve(page) + } +} + +describe('useFileTree bookmarkability', () => { + it('expands every ancestor of initialPath on mount and pre-fetches them', async () => { + const repo = new FakeRepo({ + '': FileTreePageMother.create({ + path: '', + items: [FileTreeFolderMother.create({ name: 'data', path: 'data' })] + }), + data: FileTreePageMother.create({ + path: 'data', + items: [FileTreeFolderMother.create({ name: 'raw', path: 'data/raw' })] + }), + 'data/raw': FileTreePageMother.create({ + path: 'data/raw', + items: [FileTreeFileMother.create({ id: 1, name: 'a.txt', path: 'data/raw/a.txt' })] + }) + }) + + const { result } = renderHook(() => + useFileTree({ + repository: repo, + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion, + initialPath: 'data/raw' + }) + ) + + await waitFor(() => { + expect(result.current.expanded.has('data')).to.equal(true) + expect(result.current.expanded.has('data/raw')).to.equal(true) + }) + await waitFor(() => { + expect(repo.calls).to.include('') + expect(repo.calls).to.include('data') + expect(repo.calls).to.include('data/raw') + }) + expect(result.current.currentPath).to.equal('data/raw') + }) + + it('updates currentPath as folders are expanded and collapsed', async () => { + const repo = new FakeRepo({ + '': FileTreePageMother.create({ + path: '', + items: [FileTreeFolderMother.create({ name: 'data', path: 'data' })] + }), + data: FileTreePageMother.create({ + path: 'data', + items: [FileTreeFolderMother.create({ name: 'raw', path: 'data/raw' })] + }), + 'data/raw': FileTreePageMother.create({ + path: 'data/raw', + items: [] + }) + }) + + const { result } = renderHook(() => + useFileTree({ + repository: repo, + datasetPersistentId: 'doi:10.5072/FK2/AAA', + datasetVersion + }) + ) + + expect(result.current.currentPath).to.equal('') + await act(async () => { + await result.current.expand('data') + }) + expect(result.current.currentPath).to.equal('data') + await act(async () => { + await result.current.expand('data/raw') + }) + expect(result.current.currentPath).to.equal('data/raw') + act(() => { + result.current.collapse('data/raw') + }) + expect(result.current.currentPath).to.equal('data') + }) +}) From ec4549532156a8e274530fe8c608385374ef8f0b Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 13:53:35 +0200 Subject: [PATCH 023/110] Add standalone tree-view bundle (dv-tree-view.js) Second reusable component bundle, packaged by the same vite config that ships dv-uploader. Mounts FilesTree from a JSF page or any HTML host that sets window.dvTreeViewConfig. src/standalone-tree-view/: - config.ts: DvTreeViewConfig interface (siteUrl, datasetPid, datasetVersionId, locale?, localesPath?, rootElementId?, fileMetadataPath?). - index.tsx: bootstrap. Initialises i18next with the files+shared namespaces, ApiConfig with session-cookie auth, mounts FilesTree inside an AccessRepositoryProvider so the existing download flow works without an SPA app shell. - standalone.scss: scoped wrapper styles. - dvTreeView.html: standalone demo HTML for direct browser testing. To mount FilesTree without React Router (no SPA route context in a JSF page), FilesTree gains an optional `buildFileMetadataUrl` prop: when provided, the row uses a plain `` instead of a SPA ``. The standalone wrapper passes a builder that points at the JSF file metadata page. The SPA continues to use Link by default so client-side navigation is preserved. vite.config.uploader.ts adds a second entry; the build emits both dv-uploader.js and dv-tree-view.js sharing the same react / i18n / vendor / dataverse-shared chunks. package.json copy-step also copies the demo HTML into dist-uploader/. --- package.json | 2 +- .../dataset-files/files-tree/FilesTree.tsx | 11 +- .../dataset-files/files-tree/FilesTreeRow.tsx | 33 ++++- src/standalone-tree-view/config.ts | 54 +++++++ src/standalone-tree-view/dvTreeView.html | 39 +++++ src/standalone-tree-view/index.tsx | 140 ++++++++++++++++++ src/standalone-tree-view/standalone.scss | 34 +++++ vite.config.uploader.ts | 3 +- 8 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 src/standalone-tree-view/config.ts create mode 100644 src/standalone-tree-view/dvTreeView.html create mode 100644 src/standalone-tree-view/index.tsx create mode 100644 src/standalone-tree-view/standalone.scss diff --git a/package.json b/package.json index 838df8af9..76ff322a7 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "scripts": { "start": "vite", "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 dist-uploader/ && sass --no-source-map src/standalone-uploader/standalone-page.scss dist-uploader/standalone-page.css", + "build-uploader": "vite build --config vite.config.uploader.ts && cp -r public/locales dist-uploader/ && cp src/standalone-uploader/dvwebloaderV2.html dist-uploader/ && cp src/standalone-tree-view/dvTreeView.html dist-uploader/ && sass --no-source-map src/standalone-uploader/standalone-page.scss dist-uploader/standalone-page.css", "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/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index 5ce9aecaa..7f7c3dc79 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -56,6 +56,13 @@ interface FilesTreeProps { * linking. Empty string means only the root is expanded. */ onCurrentPathChange?: (path: string) => void + /** + * Optional URL builder for the filename → file metadata link. + * Forwarded to FilesTreeRow. When provided, the row uses a plain + * anchor (suitable for JSF embeds without React Router); when + * omitted, the row falls back to the SPA ``. + */ + buildFileMetadataUrl?: (file: FileTreeFile) => string } const DEFAULT_ROW_HEIGHT = 32 @@ -72,7 +79,8 @@ export function FilesTree({ rowHeight = DEFAULT_ROW_HEIGHT, fallbackHeight = DEFAULT_FALLBACK_HEIGHT, initialPath = '', - onCurrentPathChange + onCurrentPathChange, + buildFileMetadataUrl }: FilesTreeProps) { const { t } = useTranslation('files') const tree = useFileTree({ @@ -351,6 +359,7 @@ export function FilesTree({ }} onDownload={() => handleDownloadOne(item)} datasetVersionNumber={datasetVersion.number} + buildFileMetadataUrl={buildFileMetadataUrl} /> ) })} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx index c419fbb3b..dbdb5b810 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx @@ -28,6 +28,14 @@ interface FilesTreeRowProps { onToggleExpansion?: () => void onDownload: () => void datasetVersionNumber: DatasetVersionNumber + /** + * Optional URL builder for the filename → file metadata link. When + * provided, the row renders a plain `` (works in JSF + * standalone embeds where there is no React Router). When omitted, + * the row falls back to a SPA `` pointing at the SPA file + * page. + */ + buildFileMetadataUrl?: (file: FileTreeFile) => string } const INDENT_BASE = 14 @@ -43,7 +51,8 @@ export function FilesTreeRow({ onToggleSelection, onToggleExpansion, onDownload, - datasetVersionNumber + datasetVersionNumber, + buildFileMetadataUrl }: FilesTreeRowProps) { const { t } = useTranslation('files') const isFile = isFileTreeFile(item) @@ -115,13 +124,21 @@ export function FilesTreeRow({ })} title={item.path}> {isFile ? ( - - {item.name} - + buildFileMetadataUrl ? ( + + {item.name} + + ) : ( + + {item.name} + + ) ) : ( {item.name} )} diff --git a/src/standalone-tree-view/config.ts b/src/standalone-tree-view/config.ts new file mode 100644 index 000000000..993ba91c6 --- /dev/null +++ b/src/standalone-tree-view/config.ts @@ -0,0 +1,54 @@ +/** + * Standalone Tree View Configuration + * + * Set window.dvTreeViewConfig before loading the script: + * + * + * + *
+ * + * Authentication is via the browser's JSESSIONID session cookie. + * DATAVERSE_FEATURE_API_SESSION_AUTH must be enabled on the Dataverse + * instance. + */ + +export interface DvTreeViewConfig { + /** Base URL of the Dataverse instance, e.g. https://demo.dataverse.org */ + siteUrl: string + /** Persistent ID of the dataset whose files to list */ + datasetPid: string + /** + * Dataset version to list. Accepts ':latest', ':draft', + * ':latest-published' or a specific version like '1.0'. Default + * ':latest'. + */ + datasetVersionId?: string + /** Locale code for translations. Default: 'en' */ + locale?: string + /** + * URL template for translation files. + * Default: `{siteUrl}/dvwebloader/locales/{{lng}}/{{ns}}.json` + */ + localesPath?: string + /** ID of the DOM element to mount into. Default: 'dv-tree-view' */ + rootElementId?: string + /** + * Path of the JSF file metadata page that filename links should + * point at. The bundle appends `?fileId=&version=`. Default: + * '/file.xhtml'. + */ + fileMetadataPath?: string +} + +declare global { + interface Window { + dvTreeViewConfig?: DvTreeViewConfig + } +} diff --git a/src/standalone-tree-view/dvTreeView.html b/src/standalone-tree-view/dvTreeView.html new file mode 100644 index 000000000..629b5f704 --- /dev/null +++ b/src/standalone-tree-view/dvTreeView.html @@ -0,0 +1,39 @@ + + + + + + Dataverse Tree View — Standalone Demo + + + + + +
+ + + + + diff --git a/src/standalone-tree-view/index.tsx b/src/standalone-tree-view/index.tsx new file mode 100644 index 000000000..a0a420e11 --- /dev/null +++ b/src/standalone-tree-view/index.tsx @@ -0,0 +1,140 @@ +import { createRoot } from 'react-dom/client' +import { StrictMode } from 'react' +import i18next from 'i18next' +import { initReactI18next } from 'react-i18next' +import I18NextHttpBackend from 'i18next-http-backend' +import { ApiConfig, DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript' +import { ToastContainer } from 'react-toastify' +import { AccessRepositoryProvider } from '@/sections/access/AccessRepositoryProvider' +import { AccessJSDataverseRepository } from '@/access/infrastructure/repositories/AccessJSDataverseRepository' +import { FilesTree } from '@/sections/dataset/dataset-files/files-tree/FilesTree' +import { FileTreeJSDataverseRepository } from '@/files/infrastructure/repositories/FileTreeJSDataverseRepository' +import { DatasetVersion, DatasetVersionNumber } from '@/dataset/domain/models/Dataset' +import { FileTreeFile } from '@/files/domain/models/FileTreeItem' + +import '../../packages/design-system/dist/style.css' +import 'bootstrap/dist/css/bootstrap.min.css' +import 'react-toastify/dist/ReactToastify.css' +import './standalone.scss' + +interface MountConfig { + datasetPid: string + datasetVersionId: string + fileMetadataPath: string +} + +/** + * Builds a synthetic DatasetVersion that carries just enough information + * for FilesTree (the dataset persistent id stays in props; the version + * is only used to thread a number into requests). The bundle itself + * does not need the rich SPA Dataset object. + */ +function syntheticVersion(versionId: string): DatasetVersion { + // FilesTree uses datasetVersion.number.toString() and + // .toSearchParam(). Both work on DatasetVersionNumber. + // For a non-numeric tag like ':latest' we route the string through + // a wrapper that ignores the major/minor split. + if (/^\d+\.\d+$/.test(versionId)) { + const [major, minor] = versionId.split('.').map((n) => Number(n)) + return { + number: new DatasetVersionNumber(major, minor) + } as unknown as DatasetVersion + } + return { + number: { + toString: () => versionId, + toSearchParam: () => versionId + } as unknown as DatasetVersionNumber + } as DatasetVersion +} + +function buildFileMetadataUrlFactory(config: MountConfig) { + return (file: FileTreeFile): string => + `${config.fileMetadataPath}?fileId=${file.id}&version=${encodeURIComponent( + config.datasetVersionId + )}` +} + +async function init() { + const config = window.dvTreeViewConfig + const rootElementId = config?.rootElementId ?? 'dv-tree-view' + const container = document.getElementById(rootElementId) + if (!container) { + console.error(`[dvTreeView] Mount element #${rootElementId} not found`) + return + } + const root = createRoot(container) + + const missingFields: string[] = [] + if (!config) missingFields.push('siteUrl', 'datasetPid') + else { + if (!config.siteUrl) missingFields.push('siteUrl') + if (!config.datasetPid) missingFields.push('datasetPid') + } + if (missingFields.length > 0 || !config) { + root.render( + +
+
+

+ dvTreeView: missing required config: {missingFields.join(', ')} +

+

+ Set window.dvTreeViewConfig before loading the script. +

+
+
+
+ ) + return + } + + ApiConfig.init(`${config.siteUrl}/api/v1`, DataverseApiAuthMechanism.SESSION_COOKIE) + + const localesPath = + config.localesPath ?? `${config.siteUrl}/dvwebloader/locales/{{lng}}/{{ns}}.json` + + await i18next + .use(initReactI18next) + .use(I18NextHttpBackend) + .init({ + lng: config.locale ?? 'en', + fallbackLng: 'en', + supportedLngs: ['en', 'de', 'fr', 'es', 'it', 'nl', 'pt', 'uk'], + lowerCaseLng: true, + ns: ['files', 'shared'], + defaultNS: 'files', + returnNull: false, + backend: { loadPath: localesPath } + }) + + const mountConfig: MountConfig = { + datasetPid: config.datasetPid, + datasetVersionId: config.datasetVersionId ?? ':latest', + fileMetadataPath: config.fileMetadataPath ?? '/file.xhtml' + } + const treeRepository = new FileTreeJSDataverseRepository() + const accessRepository = new AccessJSDataverseRepository() + const datasetVersion = syntheticVersion(mountConfig.datasetVersionId) + const buildFileMetadataUrl = buildFileMetadataUrlFactory(mountConfig) + + root.render( + +
+ + + + +
+
+ ) +} + +init().catch((error) => { + console.error('[dvTreeView] init failed:', error) +}) diff --git a/src/standalone-tree-view/standalone.scss b/src/standalone-tree-view/standalone.scss new file mode 100644 index 000000000..04def353e --- /dev/null +++ b/src/standalone-tree-view/standalone.scss @@ -0,0 +1,34 @@ +/** + * Standalone Tree View Embedded Styles + * + * These styles are bundled into the JS module loaded by JSF (and the + * demo page). They MUST be safe to inject into a host page — no + * html/body/#root rules. + */ + +.dv-tree-view-root { + margin: 0; + padding: 0; + width: 100%; +} + +.standalone-error { + max-width: 600px; + margin: 1rem auto; + padding: 1rem; + border: 1px solid var(--bs-danger, #dc3545); + background: rgba(220, 53, 69, 0.06); + border-radius: 4px; + font-family: var(--bs-font-sans-serif, system-ui, sans-serif); + + p { + margin: 0.25rem 0; + } + + code { + font-family: var(--bs-font-monospace, monospace); + background: var(--bs-light, #f8f9fa); + padding: 0 4px; + border-radius: 2px; + } +} diff --git a/vite.config.uploader.ts b/vite.config.uploader.ts index dec413d31..5091aa300 100644 --- a/vite.config.uploader.ts +++ b/vite.config.uploader.ts @@ -35,7 +35,8 @@ export default defineConfig({ }, rollupOptions: { input: { - 'dv-uploader': path.resolve(__dirname, 'src/standalone-uploader/index.tsx') + 'dv-uploader': path.resolve(__dirname, 'src/standalone-uploader/index.tsx'), + 'dv-tree-view': path.resolve(__dirname, 'src/standalone-tree-view/index.tsx') }, output: { entryFileNames: 'reusable-components/[name].js', From fea51adf83f0f86c25f383aed8912060bfa0196c Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 14:05:24 +0200 Subject: [PATCH 024/110] Tree view keyboard navigation and ARIA tree pattern (M4 polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds proper accessibility scaffolding and keyboard navigation per the WAI-ARIA tree role. A11y attributes: - Viewport: role="tree", aria-multiselectable="true", aria-label. - Each row: role="treeitem", aria-expanded (folders only), aria-selected (only when fully selected — partial state is conveyed via the checkbox's aria-checked="mixed"), aria-level (1-based depth). Roving tabindex: - Exactly one row at a time has tabIndex=0; the rest are tabIndex=-1. - Focus index resets / clamps when the visible row set changes (e.g. after a folder expand/collapse or a query filter). - The focused row scrolls into view automatically on focus changes triggered by keyboard. Keyboard handlers (per WAI-ARIA tree pattern): - ArrowDown / ArrowUp: previous/next visible item row. - Home / End: first / last item row. - ArrowRight on a folder: expands if collapsed; otherwise moves focus to the first child. - ArrowLeft: collapses an expanded folder; otherwise moves focus to the parent row at depth-1. - Space: toggles selection (file or folder). - Enter on a folder: toggles expansion. On a file, the default browser behaviour activates the filename anchor. Non-item rows (loading / error / load-more) are skipped for focus. Mouse focus on a row also updates the focused index, so click-then- keyboard works as expected. New locale key: tree.label = "Dataset files" (used as the tree's aria-label). --- public/locales/en/files.json | 1 + .../dataset-files/files-tree/FilesTree.tsx | 159 +++++++++++++++++- .../dataset-files/files-tree/FilesTreeRow.tsx | 32 +++- 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 4fc07484e..bea8d255f 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -9,6 +9,7 @@ } }, "tree": { + "label": "Dataset files", "head": { "name": "Name", "size": "Size", diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index 7f7c3dc79..c235cc935 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -1,5 +1,6 @@ import { CSSProperties, + KeyboardEvent, useCallback, useEffect, useLayoutEffect, @@ -209,6 +210,156 @@ export function FilesTree({ [selection] ) + // ---------- Keyboard navigation (WAI-ARIA tree pattern) ---------- + const itemRowIndices = useMemo(() => { + const out: number[] = [] + for (let i = 0; i < visibleRows.length; i++) { + if (visibleRows[i].kind === 'item') out.push(i) + } + return out + }, [visibleRows]) + + const [focusedRowIndex, setFocusedRowIndex] = useState(-1) + + // Reset / clamp focus when the visible rows change so we never point at a + // removed row. Default focus is the first item row. + useEffect(() => { + if (itemRowIndices.length === 0) { + setFocusedRowIndex(-1) + return + } + if (focusedRowIndex === -1 || !itemRowIndices.includes(focusedRowIndex)) { + setFocusedRowIndex(itemRowIndices[0]) + } + }, [itemRowIndices, focusedRowIndex]) + + // Auto-scroll the focused row into view after focus changes. + useEffect(() => { + if (focusedRowIndex < 0) return + const el = containerRef.current + if (!el) return + const top = focusedRowIndex * rowHeight + const bottom = top + rowHeight + if (top < el.scrollTop) { + el.scrollTop = top + } else if (bottom > el.scrollTop + el.clientHeight) { + el.scrollTop = bottom - el.clientHeight + } + }, [focusedRowIndex, rowHeight]) + + const moveFocus = useCallback( + (delta: number | 'first' | 'last') => { + if (itemRowIndices.length === 0) return + if (delta === 'first') { + setFocusedRowIndex(itemRowIndices[0]) + return + } + if (delta === 'last') { + setFocusedRowIndex(itemRowIndices[itemRowIndices.length - 1]) + return + } + const currentRank = itemRowIndices.indexOf(focusedRowIndex) + const nextRank = Math.max( + 0, + Math.min( + itemRowIndices.length - 1, + (currentRank === -1 ? 0 : currentRank) + delta + ) + ) + setFocusedRowIndex(itemRowIndices[nextRank]) + }, + [focusedRowIndex, itemRowIndices] + ) + + const moveToParent = useCallback(() => { + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + if (focusedRow.kind !== 'item') return + const parentDepth = focusedRow.depth - 1 + if (parentDepth < 0) return + for (let i = focusedRowIndex - 1; i >= 0; i--) { + const r = visibleRows[i] + if (r.kind === 'item' && r.depth === parentDepth) { + setFocusedRowIndex(i) + return + } + } + }, [focusedRowIndex, visibleRows]) + + const onRowKeyDown = useCallback( + (event: KeyboardEvent) => { + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + if (focusedRow.kind !== 'item') return + const item = focusedRow.node + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + moveFocus(1) + return + case 'ArrowUp': + event.preventDefault() + moveFocus(-1) + return + case 'Home': + event.preventDefault() + moveFocus('first') + return + case 'End': + event.preventDefault() + moveFocus('last') + return + case 'ArrowRight': + if (isFileTreeFolder(item)) { + event.preventDefault() + if (!tree.expanded.has(item.path)) { + void handleToggleExpansion(item) + } else { + moveFocus(1) + } + } + return + case 'ArrowLeft': + event.preventDefault() + if (isFileTreeFolder(item) && tree.expanded.has(item.path)) { + tree.collapse(item.path) + } else { + moveToParent() + } + return + case ' ': + case 'Spacebar': + event.preventDefault() + if (isFileTreeFile(item)) { + handleToggleSelectionFile(item) + } else { + handleToggleSelectionFolder(item) + } + return + case 'Enter': + if (isFileTreeFolder(item)) { + event.preventDefault() + void handleToggleExpansion(item) + } + // For files, let the default Enter behavior activate the + // filename anchor inside the row (browser handles it). + return + default: + return + } + }, + [ + focusedRowIndex, + handleToggleExpansion, + handleToggleSelectionFile, + handleToggleSelectionFolder, + moveFocus, + moveToParent, + tree, + visibleRows + ] + ) + const isInitialLoad = !tree.rootNode.loaded && tree.rootNode.loading if (isInitialLoad) { @@ -274,8 +425,9 @@ export function FilesTree({ ref={containerRef} className={styles['tree-viewport']} onScroll={onScroll} - role="grid" - aria-rowcount={totalRows} + role="tree" + aria-multiselectable="true" + aria-label={t('tree.label', 'Dataset files')} style={{ height: fallbackHeight }}>
handleDownloadOne(item)} datasetVersionNumber={datasetVersion.number} buildFileMetadataUrl={buildFileMetadataUrl} + focused={absoluteIndex === focusedRowIndex} + onFocus={() => setFocusedRowIndex(absoluteIndex)} + onRowKeyDown={onRowKeyDown} /> ) })} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx index dbdb5b810..8365fe733 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, MouseEvent } from 'react' +import { CSSProperties, KeyboardEvent, MouseEvent, RefObject } from 'react' import cn from 'classnames' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' @@ -36,6 +36,18 @@ interface FilesTreeRowProps { * page. */ buildFileMetadataUrl?: (file: FileTreeFile) => string + /** + * Whether this row is the focused row in the roving-tabindex + * keyboard model. Only one row at a time has tabIndex=0; the rest + * are tabIndex=-1. + */ + focused?: boolean + /** Called when this row receives focus (e.g. via Tab into the tree). */ + onFocus?: () => void + /** Forwarded to the row's underlying div so the parent can scroll it into view. */ + rowRef?: RefObject + /** Keyboard handler shared by all rows; lives on the parent for navigation logic. */ + onRowKeyDown?: (event: KeyboardEvent) => void } const INDENT_BASE = 14 @@ -52,7 +64,11 @@ export function FilesTreeRow({ onToggleExpansion, onDownload, datasetVersionNumber, - buildFileMetadataUrl + buildFileMetadataUrl, + focused, + onFocus, + rowRef, + onRowKeyDown }: FilesTreeRowProps) { const { t } = useTranslation('files') const isFile = isFileTreeFile(item) @@ -78,13 +94,19 @@ export function FilesTreeRow({ return (
+ onClick={handleRowClick} + onFocus={onFocus} + onKeyDown={onRowKeyDown}>
{isFile ? ( From 69a96cc929085b332ffd6ea47eb189aec387f4c7 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 14:49:35 +0200 Subject: [PATCH 025/110] Auto-load next page when load-more row scrolls into view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the user had to click the "Load more" button to fetch the next cursor of a folder. With virtualization, the load-more row enters the rendered slice as soon as the user scrolls near the bottom — that's exactly the right trigger for an automatic fetch. Effect: when a load-more row is in the slice AND the corresponding folder has a nextCursor AND is not already loading, call loadMore. loadMore is idempotent (checks loading state internally), so this is safe even if the effect re-runs. The visible button is retained as a fallback for users who: - Use the keyboard (the button is still focusable). - Have JS-disabled IntersectionObserver behaviour (this effect doesn't depend on IntersectionObserver, just the visible slice; works the same). - Want explicit pagination control. When loadMore is in flight, the button shows the "Loading…" label and is disabled, so duplicate triggers don't surface as flicker. --- .../dataset-files/files-tree/FilesTree.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index c235cc935..004df406e 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -181,6 +181,24 @@ export function FilesTree({ setScrollTop(event.currentTarget.scrollTop) } + // Auto-load: whenever a "load-more" row enters the rendered slice and the + // corresponding folder is not already loading, trigger loadMore. The + // explicit button stays as a fallback (and a focus target) but the + // common case is now infinite-scroll-style. + useEffect(() => { + for (const row of slice) { + if (row.kind === 'load-more') { + const node = tree.nodes.get(row.path) + if (node?.nextCursor && !node.loading) { + void tree.loadMore(row.path) + } + } + } + // tree.nodes / tree.loadMore are stable callbacks; depending on `slice` + // is enough to re-evaluate when the visible range changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slice]) + const handleDownloadOne = useCallback( (item: FileTreeFile | FileTreeFolder) => { void download.downloadNode(item) From 0106f1c5fc25f7b21984b95a7989cf2e174cfc14 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 15:05:54 +0200 Subject: [PATCH 026/110] Replace access-API zip endpoint with client-side streaming zip (M4) Adds a browser-side streaming-zip download for the React file tree: - `useStreamingZipDownload` engine drives `client-zip` from an async generator. Three strategies (pause-on-fail, skip-with-manifest, twopass-retry) are wired through a single decision-promise so the UI can resolve retry/skip/cancel choices without restarting the run. - `FilesTreeDownloadTray` is a fixed bottom-sheet that shows progress, the file currently being added, and surfaces the pause/two-pass decisions inline. Stays out of the way while the user keeps browsing. - `FilesTree` now bypasses the access-API signed-zip flow. Single-file downloads still anchor-click `file.downloadUrl`; multi-file downloads go through the streaming engine and pop the tray. The toolbar button is disabled while a zip is in flight to prevent double-runs. - `useFileTreeDownload` now hands `FileTreeFile[]` to the host instead of bare ids, so the streaming engine has size/name/url for free. - New locale strings under `tree.download.tray.*`. `client-zip` is the only new dep (~3 KB gzip). - Cypress component spec covers the happy path, pause-on-fail-then-retry, and skip-with-manifest flow against a stubbed `fetch`. --- package-lock.json | 7 + package.json | 1 + public/locales/en/files.json | 40 +- .../dataset-files/files-tree/FilesTree.tsx | 91 +++-- .../FilesTreeDownloadTray.module.scss | 163 ++++++++ .../files-tree/FilesTreeDownloadTray.tsx | 217 ++++++++++ .../files-tree/useFileTreeDownload.ts | 36 +- .../files-tree/useStreamingZipDownload.ts | 381 ++++++++++++++++++ .../useStreamingZipDownload.spec.tsx | 169 ++++++++ 9 files changed, 1061 insertions(+), 44 deletions(-) create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss create mode 100644 src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx create mode 100644 src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts create mode 100644 tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx diff --git a/package-lock.json b/package-lock.json index 9fed69938..85b38ea5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "async-mutex": "0.5.0", "bootstrap": "5.2.3", "classnames": "2.5.1", + "client-zip": "^2.5.0", "dompurify": "3.2.7", "html-react-parser": "3.0.16", "i18next": "25.6.0", @@ -10792,6 +10793,12 @@ "node": ">= 12" } }, + "node_modules/client-zip": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz", + "integrity": "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", diff --git a/package.json b/package.json index 76ff322a7..931088d27 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "async-mutex": "0.5.0", "bootstrap": "5.2.3", "classnames": "2.5.1", + "client-zip": "^2.5.0", "dompurify": "3.2.7", "html-react-parser": "3.0.16", "i18next": "25.6.0", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index bea8d255f..5e45cc00c 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -34,7 +34,45 @@ "download": { "button": "Download zip", "enumerating": "Listing files…", - "preparing": "Preparing zip…" + "preparing": "Preparing zip…", + "streaming": "Streaming…", + "tray": { + "label": "Zip download progress", + "preparing": "Preparing zip…", + "streaming": "Streaming files into zip…", + "paused": "Download paused — file failed", + "firstPass_one": "First pass complete — {{count}} file failed", + "firstPass_other": "First pass complete — {{count}} files failed", + "complete": "Download complete", + "completeWithSkipped_one": "Download complete — {{count}} skipped", + "completeWithSkipped_other": "Download complete — {{count}} skipped", + "error": "Download failed", + "cancelled": "Download cancelled", + "files": "files", + "pass2": "pass 2 of 2", + "now": { + "paused": "Paused", + "awaiting": "Awaiting retry decision", + "done": "Done", + "cancelled": "Cancelled" + }, + "failed": "Failed to fetch file", + "retry": "Retry this file", + "skip": "Skip", + "skipAll": "Skip all remaining failures", + "notIncluded_one": "{{count}} file was not included in the zip", + "notIncluded_other": "{{count}} files were not included in the zip", + "twopassHint": "First pass finished. The zip will be finalised after a second-pass retry of the failures.", + "retryFailed_one": "Download {{count}} missing file", + "retryFailed_other": "Download {{count}} missing files", + "done": "Done", + "skipped_one": "{{count}} file skipped", + "skipped_other": "{{count}} files skipped", + "skippedManifest": "A manifest.txt listing skipped files has been added to the root of the zip.", + "hint": "Streamed locally into one zip — no server-side ZIP.", + "cancel": "Cancel", + "close": "Close" + } }, "state": { "loading": "Loading file index…", diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index 004df406e..d1f52a7ca 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -20,19 +20,14 @@ import { isFileTreeFolder } from '@/files/domain/models/FileTreeItem' import { DatasetVersion } from '@/dataset/domain/models/Dataset' -import { useAccessRepository } from '@/sections/access/AccessRepositoryContext' -import { - EMPTY_GUESTBOOK_RESPONSE, - downloadFromSignedUrl, - requestSignedDownloadUrlFromAccessApi -} from '@/shared/helpers/DownloadHelper' -import { FileDownloadMode } from '@/files/domain/models/FileMetadata' import { useFileTree } from './useFileTree' import { useFileTreeSelection } from './useFileTreeSelection' import { useFileTreeFlatten } from './useFileTreeFlatten' import { useFileTreeDownload } from './useFileTreeDownload' +import { useStreamingZipDownload } from './useStreamingZipDownload' import { FilesTreeRow } from './FilesTreeRow' import { FilesTreeHeader } from './FilesTreeHeader' +import { FilesTreeDownloadTray } from './FilesTreeDownloadTray' import { DownloadIcon, EmptyIcon, SpinnerIcon, WarnIcon } from './icons/FilesTreeIcons' import { formatBytes } from './format' import styles from './FilesTree.module.scss' @@ -103,23 +98,45 @@ export function FilesTree({ } }, [tree.currentPath, onCurrentPathChange]) const selection = useFileTreeSelection() - const accessRepository = useAccessRepository() + const streamingZip = useStreamingZipDownload() + const [trayOpen, setTrayOpen] = useState(false) + + // Auto-close the tray when the engine returns to idle (after the user + // dismisses a finished/cancelled run via the close button). + useEffect(() => { + if (streamingZip.state.status === 'idle') { + setTrayOpen(false) + } + }, [streamingZip.state.status]) - const onDownloadFileIds = useCallback( - async (ids: number[]) => { - if (ids.length === 0) { + const onDownloadFiles = useCallback( + async (files: FileTreeFile[]) => { + if (files.length === 0) { return } - const url = await requestSignedDownloadUrlFromAccessApi({ - accessRepository, - fileIds: ids, - guestbookResponse: EMPTY_GUESTBOOK_RESPONSE, - format: FileDownloadMode.ORIGINAL - }) - await downloadFromSignedUrl(url) - toast.success(t('actions.optionsMenu.guestbookCollectModal.downloadStarted')) + // Single file: bypass zipping and trigger a direct browser + // download. The browser handles content disposition and the + // session cookie auths the request when needed. + if (files.length === 1) { + const file = files[0] + const a = document.createElement('a') + a.href = file.downloadUrl + a.download = file.name + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + toast.success(t('actions.optionsMenu.guestbookCollectModal.downloadStarted')) + return + } + // Many files: build a zip in the browser, streaming each file + // body through `client-zip`. The tray surfaces progress and any + // per-file failure decisions. + const zipName = `${datasetPersistentId.replace(/[^a-zA-Z0-9._-]+/g, '_')}-files.zip` + streamingZip.start({ files, zipName }) + setTrayOpen(true) }, - [accessRepository, t] + [datasetPersistentId, streamingZip, t] ) const download = useFileTreeDownload({ @@ -127,7 +144,7 @@ export function FilesTree({ datasetPersistentId, datasetVersion, selection, - onDownloadFileIds, + onDownloadFiles, onError: () => toast.error(t('actions.optionsMenu.guestbookCollectModal.downloadError')) }) @@ -435,9 +452,17 @@ export function FilesTree({ ) } + const streamingZipActive = !['idle', 'done', 'error', 'cancelled'].includes( + streamingZip.state.status + ) + return (
- +
+ { + setTrayOpen(false) + streamingZip.close() + }} + />
) } @@ -546,9 +579,15 @@ interface FilesTreeToolbarProps { selection: ReturnType download: ReturnType disableDownload?: boolean + streamingZipActive?: boolean } -function FilesTreeToolbar({ selection, download, disableDownload }: FilesTreeToolbarProps) { +function FilesTreeToolbar({ + selection, + download, + disableDownload, + streamingZipActive +}: FilesTreeToolbarProps) { const { t } = useTranslation('files') const { count, bytes, hasLogicalFolders } = selection.totals const downloadable = !disableDownload && (count > 0 || hasLogicalFolders) @@ -592,12 +631,14 @@ function FilesTreeToolbar({ selection, download, disableDownload }: FilesTreeToo +
+ +
+
+
+
+
+
{nowText}
+
{pct.toFixed(1)}%
+
+ + {isPaused && lastRecoverableFailure && ( +
+
+ {t('tree.download.tray.failed', 'Failed to fetch file')} +
+
{lastRecoverableFailure.path}
+
{lastRecoverableFailure.error}
+
+ + + +
+
+ )} + + {isAwaitingRetry && ( +
+
+ {' '} + {t('tree.download.tray.notIncluded', { + defaultValue: '{{count}} file(s) were not included in the zip', + count: state.failedSoFar.length + })} +
+
+ {t( + 'tree.download.tray.twopassHint', + 'First pass finished. The zip will be finalised after a second-pass retry of the failures.' + )} +
+
+ + +
+
+ )} + + {isDone && state.failedSoFar.length > 0 && !isAwaitingRetry && ( +
+
+ {' '} + {t('tree.download.tray.skipped', { + defaultValue: '{{count}} file(s) skipped', + count: state.failedSoFar.length + })} +
+
+ {t( + 'tree.download.tray.skippedManifest', + 'A manifest.txt listing skipped files has been added to the root of the zip.' + )} +
+
+ )} + + {isError && state.message && ( +
+
+ {t('tree.download.tray.error', 'Download failed')} +
+
{state.message}
+
+ )} +
+ +
+
+ {t('tree.download.tray.hint', 'Streamed locally into one zip — no server-side ZIP.')} +
+ {!isDone && !isAwaitingRetry && !isError && !isCancelled && ( + + )} + {(isDone || isCancelled || isError) && ( + + )} +
+
+ + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts index 87a013011..4aae7cb5b 100644 --- a/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts @@ -18,11 +18,11 @@ export interface UseFileTreeDownloadArgs { selection: FileTreeSelection onError?: (error: unknown) => void /** - * Caller decides how to actually trigger the download for an array of file - * IDs (e.g. signed-URL flow with guestbook handling). The hook only owns the - * enumeration step. + * Caller decides how to actually trigger the download for a list of + * files (e.g. direct anchor click for one file, streaming zip for + * many). The hook only owns the enumeration step. */ - onDownloadFileIds: (ids: number[]) => Promise + onDownloadFiles: (files: FileTreeFile[]) => Promise } export interface UseFileTreeDownloadApi { @@ -38,7 +38,7 @@ export function useFileTreeDownload({ datasetVersion, selection, onError, - onDownloadFileIds + onDownloadFiles }: UseFileTreeDownloadArgs): UseFileTreeDownloadApi { const [progress, setProgress] = useState({ status: 'idle', @@ -49,25 +49,25 @@ export function useFileTreeDownload({ setProgress({ status: 'idle', enumeratedCount: 0 }) }, []) - const downloadFileIds = useCallback( - async (ids: number[]) => { - if (ids.length === 0) { + const dispatchFiles = useCallback( + async (files: FileTreeFile[]) => { + if (files.length === 0) { return } - setProgress({ status: 'requesting', enumeratedCount: ids.length }) + setProgress({ status: 'requesting', enumeratedCount: files.length }) try { - await onDownloadFileIds(ids) - setProgress({ status: 'success', enumeratedCount: ids.length }) + await onDownloadFiles(files) + setProgress({ status: 'success', enumeratedCount: files.length }) } catch (error) { setProgress({ status: 'error', - enumeratedCount: ids.length, + enumeratedCount: files.length, message: error instanceof Error ? error.message : String(error) }) onError?.(error) } }, - [onDownloadFileIds, onError] + [onDownloadFiles, onError] ) const collectExplicitFiles = useCallback((): FileTreeFile[] => { @@ -109,12 +109,12 @@ export function useFileTreeDownload({ } const merged = mergeFiles(explicit, enumerated, selection.deselectedFilePaths) - await downloadFileIds(merged.map((f) => f.id)) + await dispatchFiles(merged) }, [ collectExplicitFiles, datasetPersistentId, datasetVersion, - downloadFileIds, + dispatchFiles, onError, selection.deselectedFilePaths, selection.selectedFolderPaths, @@ -124,7 +124,7 @@ export function useFileTreeDownload({ const downloadNode = useCallback( async (node: FileTreeFile | FileTreeFolder) => { if (isFileTreeFile(node)) { - await downloadFileIds([node.id]) + await dispatchFiles([node]) return } setProgress({ status: 'enumerating', enumeratedCount: 0 }) @@ -134,7 +134,7 @@ export function useFileTreeDownload({ datasetVersion, paths: [node.path] }) - await downloadFileIds(files.map((f) => f.id)) + await dispatchFiles(files) } catch (error) { setProgress({ status: 'error', @@ -144,7 +144,7 @@ export function useFileTreeDownload({ onError?.(error) } }, - [datasetPersistentId, datasetVersion, downloadFileIds, onError, treeRepository] + [datasetPersistentId, datasetVersion, dispatchFiles, onError, treeRepository] ) return { progress, downloadSelection, downloadNode, reset } diff --git a/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts new file mode 100644 index 000000000..fe5139f25 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts @@ -0,0 +1,381 @@ +import { useCallback, useRef, useState } from 'react' +import { downloadZip } from 'client-zip' +import { FileTreeFile } from '@/files/domain/models/FileTreeItem' + +/** + * Strategy for handling per-file fetch failures during a streaming-zip + * download. + * + * - `pause`: stop the engine on the first failure and surface a + * retry / skip / skip-all decision to the caller. Default — matches + * the behaviour the design bundle prescribes. + * - `skip`: best-effort. Failed files are dropped, listed in a + * `manifest.txt` entry inside the resulting zip, and the engine + * keeps going. + * - `twopass`: failed files are deferred. After the first pass the + * engine waits for the caller to call `retryFailed()`; at that + * point it re-queues all recoverable failures as a second pass. + */ +export type StreamingZipStrategy = 'pause' | 'skip' | 'twopass' + +export interface StreamingZipFailure { + path: string + name: string + size: number + error: string + recoverable: boolean +} + +export interface StreamingZipState { + status: + | 'idle' + | 'preparing' + | 'running' + | 'paused' + | 'awaiting-retry' + | 'done' + | 'error' + | 'cancelled' + totalFiles: number + filesDone: number + totalBytes: number + bytesDone: number + current?: { name: string; path: string; size: number } + failedSoFar: StreamingZipFailure[] + pass: 1 | 2 + message?: string +} + +export interface StartStreamingZipArgs { + files: FileTreeFile[] + zipName?: string + strategy?: StreamingZipStrategy + /** Allows a custom URL builder (e.g. JSF integration); defaults to `file.downloadUrl`. */ + buildFetchUrl?: (file: FileTreeFile) => string + /** Extra options forwarded to `fetch()` (defaults to `credentials: 'include'`). */ + fetchInit?: RequestInit +} + +export interface StreamingZipApi { + state: StreamingZipState + start: (args: StartStreamingZipArgs) => void + retryCurrent: () => void + skipCurrent: () => void + skipAllFailures: () => void + retryFailed: () => void + cancel: () => void + close: () => void +} + +const initialState: StreamingZipState = { + status: 'idle', + totalFiles: 0, + filesDone: 0, + totalBytes: 0, + bytesDone: 0, + failedSoFar: [], + pass: 1 +} + +interface ResolveBag { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): ResolveBag { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +/** + * Streaming-zip download driver. + * + * Builds a zip in the browser by piping per-file response bodies + * through `client-zip`. The result is a single `Response` whose body is + * a `ReadableStream`; we materialise it to a `Blob` and trigger a + * save via an anchor click. For very large datasets this still buffers + * the zip in memory; future work can swap the blob save for the + * File System Access API or a Service Worker stream-saver. + * + * Per-file progress is tracked through a `TransformStream` that counts + * bytes as `client-zip` pulls them from the response body. + */ +export function useStreamingZipDownload(): StreamingZipApi { + const [state, setState] = useState(initialState) + const stateRef = useRef(initialState) + stateRef.current = state + + // Engine control: a single "decision" promise that the iterator + // awaits when paused. The UI calls retryCurrent/skipCurrent/etc. + // which resolve this promise with the requested action. + type Decision = 'retry' | 'skip' | 'skip-all' | 'retry-failed' | 'cancel' + const decisionRef = useRef | null>(null) + const cancelledRef = useRef(false) + + const update = useCallback((fn: (prev: StreamingZipState) => StreamingZipState) => { + setState((prev) => { + const next = fn(prev) + stateRef.current = next + return next + }) + }, []) + + const close = useCallback(() => { + cancelledRef.current = false + decisionRef.current = null + setState(initialState) + stateRef.current = initialState + }, []) + + const cancel = useCallback(() => { + cancelledRef.current = true + decisionRef.current?.resolve('cancel') + decisionRef.current = null + update((prev) => ({ ...prev, status: 'cancelled' })) + }, [update]) + + const sendDecision = useCallback((decision: Decision) => { + const bag = decisionRef.current + if (!bag) return + decisionRef.current = null + bag.resolve(decision) + }, []) + + const retryCurrent = useCallback(() => sendDecision('retry'), [sendDecision]) + const skipCurrent = useCallback(() => sendDecision('skip'), [sendDecision]) + const skipAllFailures = useCallback(() => sendDecision('skip-all'), [sendDecision]) + const retryFailed = useCallback(() => sendDecision('retry-failed'), [sendDecision]) + + const start = useCallback( + (args: StartStreamingZipArgs) => { + const { + files, + zipName = 'dataset.zip', + strategy: initialStrategy = 'pause', + buildFetchUrl = (f) => f.downloadUrl, + fetchInit + } = args + if (files.length === 0) return + + cancelledRef.current = false + decisionRef.current = null + const totalBytes = files.reduce((s, f) => s + f.size, 0) + update(() => ({ + ...initialState, + status: 'preparing', + totalFiles: files.length, + totalBytes, + pass: 1 + })) + + const queue: FileTreeFile[] = [...files] + const skippedManifest: StreamingZipFailure[] = [] + let strategy = initialStrategy + + async function* iterableForZip() { + let pass: 1 | 2 = 1 + + // ----- helper: process a queue ---------------------------------- + const processQueue = async function* () { + while (queue.length > 0) { + if (cancelledRef.current) return + const file = queue.shift() as FileTreeFile + update((prev) => ({ + ...prev, + status: 'running', + current: { name: file.name, path: file.path, size: file.size } + })) + + let response: Response + try { + response = await fetch(buildFetchUrl(file), { + credentials: 'include', + ...(fetchInit ?? {}) + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`) + } + } catch (err) { + const failure: StreamingZipFailure = { + path: file.path, + name: file.name, + size: file.size, + error: err instanceof Error ? err.message : String(err), + recoverable: strategy !== 'skip' + } + update((prev) => ({ + ...prev, + failedSoFar: [...prev.failedSoFar, failure] + })) + if (strategy === 'pause') { + update((prev) => ({ ...prev, status: 'paused' })) + const decision = await waitForDecision() + if (decision === 'cancel') return + if (decision === 'retry') { + // pop the failure record we just added and re-queue this file + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.slice(0, -1), + status: 'running' + })) + queue.unshift(file) + continue + } + if (decision === 'skip-all') { + strategy = 'skip' + // mark the just-added failure as non-recoverable too + update((prev) => { + const last = prev.failedSoFar[prev.failedSoFar.length - 1] + if (!last) return { ...prev, status: 'running' } + return { + ...prev, + failedSoFar: [ + ...prev.failedSoFar.slice(0, -1), + { ...last, recoverable: false } + ], + status: 'running' + } + }) + } + // 'skip' falls through + skippedManifest.push({ ...failure, recoverable: false }) + continue + } + if (strategy === 'skip') { + skippedManifest.push({ ...failure, recoverable: false }) + continue + } + // 'twopass': defer; second pass will pick recoverable ones up + continue + } + + // Wrap the body in a counting stream so we can track bytes. + if (!response.body) { + update((prev) => ({ + ...prev, + filesDone: prev.filesDone + 1, + bytesDone: prev.bytesDone + file.size + })) + continue + } + const counted = countingStream(response.body, (delta) => { + update((prev) => ({ ...prev, bytesDone: prev.bytesDone + delta })) + }) + yield { + name: file.path, + input: counted, + lastModified: new Date() + } + update((prev) => ({ ...prev, filesDone: prev.filesDone + 1 })) + } + } + + // ----- first pass -------------------------------------------------- + yield* processQueue() + if (cancelledRef.current) return + + // ----- two-pass: pause for user decision then re-queue failures ---- + if (strategy === 'twopass' && stateRef.current.failedSoFar.length > 0) { + update((prev) => ({ ...prev, status: 'awaiting-retry' })) + const decision = await waitForDecision() + if (decision === 'cancel') return + if (decision === 'retry-failed') { + const recoverable = stateRef.current.failedSoFar.filter((f) => f.recoverable) + // Reconstruct the file objects from the tail of `files`: + const fileByPath = new Map(files.map((f) => [f.path, f])) + for (const f of recoverable) { + const file = fileByPath.get(f.path) + if (file) queue.push(file) + } + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.filter((f) => !f.recoverable), + status: 'running' + })) + pass = 2 + update((prev) => ({ ...prev, pass: 2 })) + yield* processQueue() + } + } + + // ----- skip strategy: append a manifest.txt with failures ---------- + if (skippedManifest.length > 0) { + const lines = [ + 'The following files were skipped during this zip download:', + '', + ...skippedManifest.map((f) => `${f.path} — ${f.error}`) + ] + yield { + name: 'manifest.txt', + input: new Blob([lines.join('\n')], { type: 'text/plain' }), + lastModified: new Date() + } + } + } + + async function waitForDecision(): Promise { + const bag = deferred() + decisionRef.current = bag + return bag.promise + } + + ;(async () => { + try { + const response = downloadZip(iterableForZip()) + const blob = await response.blob() + if (cancelledRef.current) return + triggerDownload(blob, zipName) + update((prev) => ({ ...prev, status: 'done', current: undefined })) + } catch (err) { + if (cancelledRef.current) return + update((prev) => ({ + ...prev, + status: 'error', + message: err instanceof Error ? err.message : String(err) + })) + } + })() + }, + [update] + ) + + return { + state, + start, + retryCurrent, + skipCurrent, + skipAllFailures, + retryFailed, + cancel, + close + } +} + +function countingStream( + source: ReadableStream, + onProgress: (delta: number) => void +): ReadableStream { + const transform = new TransformStream({ + transform(chunk, controller) { + onProgress(chunk.byteLength) + controller.enqueue(chunk) + } + }) + return source.pipeThrough(transform) +} + +function triggerDownload(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + // Defer revoke so the browser actually starts the download. + setTimeout(() => URL.revokeObjectURL(url), 4_000) +} diff --git a/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx new file mode 100644 index 000000000..3691ade6f --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react' +import { useStreamingZipDownload } from '../../../../../../src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload' +import { FilesTreeDownloadTray } from '../../../../../../src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray' +import { FileTreeFile } from '../../../../../../src/files/domain/models/FileTreeItem' +import { FileTreeFileMother } from '../../../../files/domain/models/FileTreeItemMother' + +/** + * Harness component: renders the tray plus a kick-off button that + * starts the engine. Lets the spec drive the engine end-to-end via the + * same code path the host (FilesTree) uses. + */ +function StreamingZipHarness({ files, zipName }: { files: FileTreeFile[]; zipName: string }) { + const api = useStreamingZipDownload() + const [open, setOpen] = useState(false) + return ( + <> + + { + setOpen(false) + api.close() + }} + /> + + ) +} + +function fakeResponseBody(content: string): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)) + controller.close() + } + }) + return new Response(stream, { + status: 200, + headers: { 'content-type': 'application/octet-stream' } + }) +} + +describe('useStreamingZipDownload + FilesTreeDownloadTray', () => { + beforeEach(() => { + // Prevent `` clicks from actually navigating the cypress runner. + cy.window().then((win) => { + const noop = () => undefined + cy.stub(win.HTMLAnchorElement.prototype, 'click').callsFake(noop) + }) + }) + + it('streams two files into a zip on the happy path', () => { + const files: FileTreeFile[] = [ + FileTreeFileMother.create({ + id: 1, + name: 'a.txt', + path: 'a.txt', + size: 5, + downloadUrl: '/access/1' + }), + FileTreeFileMother.create({ + id: 2, + name: 'b.txt', + path: 'b.txt', + size: 5, + downloadUrl: '/access/2' + }) + ] + + cy.window().then((win) => { + cy.stub(win, 'fetch').callsFake((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/access/1')) return Promise.resolve(fakeResponseBody('AAAAA')) + if (url.endsWith('/access/2')) return Promise.resolve(fakeResponseBody('BBBBB')) + return Promise.reject(new Error(`unexpected fetch ${url}`)) + }) + }) + + cy.customMount() + cy.findByTestId('harness-start').click() + cy.findByTestId('files-tree-download-tray').should('be.visible') + cy.findByTestId('files-tree-download-tray-meta').should('contain.text', '2 / 2') + cy.contains(/download complete/i).should('exist') + }) + + it('pauses on first failure and resumes on retry', () => { + const files: FileTreeFile[] = [ + FileTreeFileMother.create({ + id: 1, + name: 'a.txt', + path: 'a.txt', + size: 3, + downloadUrl: '/access/1' + }), + FileTreeFileMother.create({ + id: 2, + name: 'b.txt', + path: 'b.txt', + size: 3, + downloadUrl: '/access/2' + }) + ] + + let attempts = 0 + cy.window().then((win) => { + cy.stub(win, 'fetch').callsFake((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/access/1')) return Promise.resolve(fakeResponseBody('AAA')) + if (url.endsWith('/access/2')) { + attempts += 1 + if (attempts === 1) return Promise.reject(new Error('network blip')) + return Promise.resolve(fakeResponseBody('BBB')) + } + return Promise.reject(new Error(`unexpected fetch ${url}`)) + }) + }) + + cy.customMount() + cy.findByTestId('harness-start').click() + + cy.findByTestId('files-tree-download-tray-failure').should('be.visible') + cy.contains(/Retry this file/i).click() + cy.contains(/download complete/i).should('exist') + cy.then(() => { + expect(attempts).to.equal(2) + }) + }) + + it('skips a failed file when "Skip" is clicked and lists it in the manifest', () => { + const files: FileTreeFile[] = [ + FileTreeFileMother.create({ + id: 1, + name: 'a.txt', + path: 'a.txt', + size: 3, + downloadUrl: '/access/1' + }), + FileTreeFileMother.create({ + id: 2, + name: 'broken.bin', + path: 'broken.bin', + size: 3, + downloadUrl: '/access/2' + }) + ] + + cy.window().then((win) => { + cy.stub(win, 'fetch').callsFake((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/access/1')) return Promise.resolve(fakeResponseBody('AAA')) + return Promise.reject(new Error('permanently broken')) + }) + }) + + cy.customMount() + cy.findByTestId('harness-start').click() + cy.findByTestId('files-tree-download-tray-failure').should('be.visible') + cy.contains(/^Skip$/).click() + cy.contains(/Download complete — 1 skipped/i).should('exist') + }) +}) From 9d0f00be466b8666dc374f31b73b5dc9ef8a188f Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 15:11:12 +0200 Subject: [PATCH 027/110] Drop dead AccessRepositoryProvider wrap from standalone tree-view bundle FilesTree no longer reaches for the access repository (downloads go through the streaming-zip engine instead), so the JSF mount can drop the provider and the JSDataverse access import. --- src/standalone-tree-view/index.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/standalone-tree-view/index.tsx b/src/standalone-tree-view/index.tsx index a0a420e11..18fd6e2d3 100644 --- a/src/standalone-tree-view/index.tsx +++ b/src/standalone-tree-view/index.tsx @@ -5,8 +5,6 @@ import { initReactI18next } from 'react-i18next' import I18NextHttpBackend from 'i18next-http-backend' import { ApiConfig, DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript' import { ToastContainer } from 'react-toastify' -import { AccessRepositoryProvider } from '@/sections/access/AccessRepositoryProvider' -import { AccessJSDataverseRepository } from '@/access/infrastructure/repositories/AccessJSDataverseRepository' import { FilesTree } from '@/sections/dataset/dataset-files/files-tree/FilesTree' import { FileTreeJSDataverseRepository } from '@/files/infrastructure/repositories/FileTreeJSDataverseRepository' import { DatasetVersion, DatasetVersionNumber } from '@/dataset/domain/models/Dataset' @@ -114,7 +112,6 @@ async function init() { fileMetadataPath: config.fileMetadataPath ?? '/file.xhtml' } const treeRepository = new FileTreeJSDataverseRepository() - const accessRepository = new AccessJSDataverseRepository() const datasetVersion = syntheticVersion(mountConfig.datasetVersionId) const buildFileMetadataUrl = buildFileMetadataUrlFactory(mountConfig) @@ -122,14 +119,12 @@ async function init() {
- - - +
) From cf62b408179f8dd56a0dc874691cedf6c4d7615f Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 15:15:29 +0200 Subject: [PATCH 028/110] Document the tree-view reusable component in docs/reusable-components.md Replace the 'in development' placeholder with the shipped surface: config interface (window.dvTreeViewConfig), feature flag, lazy folder loading, ARIA keyboard nav, ?view=tree&path bookmarkability, and the client-side streaming-zip download (single new client-zip dep). --- docs/reusable-components.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/reusable-components.md b/docs/reusable-components.md index 95e764e51..d998971ad 100644 --- a/docs/reusable-components.md +++ b/docs/reusable-components.md @@ -181,9 +181,20 @@ JSF integration: Feature flag (server-side): `dataverse.feature.react-uploader`. -### Tree view (in development — `#6691`) +### Tree view (`#6691`) -Will follow the same pattern. The SPA section already exists at `src/sections/dataset/dataset-files/files-tree/`; the standalone wrapper is M3 in [`dataverse-context/tree_view_plan.md`](../../dataverse-context/tree_view_plan.md). Until the JSF mount lands, the tree view is SPA-only and is reached via `?view=tree` on the dataset page. +Built on the same pattern. The SPA section lives at `src/sections/dataset/dataset-files/files-tree/`; the standalone wrapper is in `src/standalone-tree-view/` and is the second entry point in `vite.config.uploader.ts` (`dv-tree-view`). The bundle config interface is `window.dvTreeViewConfig` (see [`src/standalone-tree-view/config.ts`](../src/standalone-tree-view/config.ts)). + +Feature flag (server-side): `dataverse.feature.react-tree-view`. + +The tree view ships: + +- Lazy folder loading with an opaque keyset cursor. +- Path-keyed tri-state selection (folders without descendant enumeration; logical until download time). +- Visible-row virtualisation; no `react-virtual` / `react-window` dep. +- Full WAI-ARIA tree keyboard navigation (`ArrowUp/Down/Left/Right`, `Home/End`, `Space`, `Enter`). +- URL bookmarkability: `?view=tree&path=` round-trips and pre-fetches every ancestor on mount. +- **Client-side streaming-zip download.** Multi-file selections are zipped in the browser via [`client-zip`](https://github.com/Touffy/client-zip) (~3 KB gzip, the only new dep introduced by the tree). A bottom-sheet tray (`FilesTreeDownloadTray`) shows progress, the file currently being added, and surfaces retry / skip / skip-all decisions inline when a fetch fails. Single-file downloads bypass the zip wrap and anchor-click `file.downloadUrl` directly. **No server contract changes.** ## Testing reusable components From 912bfb30547797467cac82e970c3e2e4f4cbb8df Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 15:23:25 +0200 Subject: [PATCH 029/110] Tree-view a11y / focus polish + expose two-pass strategy in the tray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming-zip: - New `deferCurrentToEnd()` API on the engine. The pause-on-fail failure dialog gets a fourth button, "Skip & retry at end", which switches the strategy to twopass mid-flight. Subsequent failures accumulate without pausing; the existing two-pass UI takes over at the end of the first pass and offers `retryFailed()`. - New Cypress case covers the defer-to-end → second-pass-success flow. - Tray switches `role="dialog"` → `role="region"` (it never traps focus and announces via `aria-live="polite"`; dialog is the wrong role for a passive status panel). A11y / focus: - Tree row gains a visible `:focus-visible` ring (inset box-shadow works around the absolute-positioned row clipping a normal outline). - Loading and empty viewport states get `role="status" aria-live="polite"`; the root error state gets `role="alert" aria-live="assertive"`. Decorative glyphs are `aria-hidden`. - Toolbar gets `role="toolbar"` + an `aria-label`. Selection summary becomes `role="status" aria-live="polite"` so screen readers announce the running file count. - `FilesTreeCheckbox` is removed from the page tab order (`tabIndex=-1`): the WAI-ARIA tree pattern routes selection through the focused row's Space handler, so every-row-tabbable is wrong here and creates a long tab chain. Mouse + space-on-row both still work. --- public/locales/en/files.json | 4 ++ .../files-tree/FilesTree.module.scss | 13 +++++ .../dataset-files/files-tree/FilesTree.tsx | 37 ++++++++++--- .../files-tree/FilesTreeCheckbox.tsx | 7 ++- .../files-tree/FilesTreeDownloadTray.tsx | 5 +- .../files-tree/useStreamingZipDownload.ts | 29 +++++++++- .../useStreamingZipDownload.spec.tsx | 55 +++++++++++++++++++ 7 files changed, 138 insertions(+), 12 deletions(-) diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 5e45cc00c..2a9124b28 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -10,6 +10,9 @@ }, "tree": { "label": "Dataset files", + "toolbar": { + "label": "Selection actions" + }, "head": { "name": "Name", "size": "Size", @@ -59,6 +62,7 @@ "failed": "Failed to fetch file", "retry": "Retry this file", "skip": "Skip", + "deferToEnd": "Skip & retry at end", "skipAll": "Skip all remaining failures", "notIncluded_one": "{{count}} file was not included in the zip", "notIncluded_other": "{{count}} files were not included in the zip", diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss index ef100dc0a..7754da704 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss @@ -114,11 +114,24 @@ $indent-step: 18px; background-color: var(--bs-light, #f8f9fa); } +.row:focus-visible { + outline: none; + // Inset ring works around the absolute-positioned row clipping a + // standard outline against neighbouring rows. + box-shadow: inset 0 0 0 2px var(--bs-primary, #0d6efd); + background-color: rgba(13, 110, 253, 0.04); + z-index: 1; +} + .row-selected, .row-selected:hover { background-color: rgba(13, 110, 253, 0.08); } +.row-selected:focus-visible { + background-color: rgba(13, 110, 253, 0.12); +} + .row-name { display: flex; align-items: center; diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index d1f52a7ca..49c2e9960 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -401,9 +401,14 @@ export function FilesTree({ return (
-
+
-
+
{t('tree.state.loading')}
@@ -417,11 +422,16 @@ export function FilesTree({ return (
-
+
+ style={{ borderColor: 'var(--bs-danger)', color: 'var(--bs-danger)' }} + aria-hidden>
{t('tree.state.error')}
@@ -440,9 +450,13 @@ export function FilesTree({
-
+
-
+
{query ? t('tree.state.noMatches', { query }) : t('tree.state.empty')}
@@ -595,9 +609,16 @@ function FilesTreeToolbar({ const requesting = download.progress.status === 'requesting' return ( -
+
- + {count === 0 && !hasLogicalFolders ? ( {t('tree.selection.none')} ) : ( diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx index cc47ddaaa..2bdc410d8 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx @@ -22,13 +22,18 @@ export function FilesTreeCheckbox({ state, onToggle, label, testId }: FilesTreeC onToggle() } } + // Inside a tree row we follow the WAI-ARIA tree pattern: only the + // focused row participates in the page tab order (roving tabindex on + // the row itself), and Space on that row toggles selection. The + // checkbox handles mouse / keyboard activation when focused but is + // skipped on Tab so the user doesn't have to tab through every row. return (
@@ -132,6 +132,9 @@ export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadT + diff --git a/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts index fe5139f25..2398bd011 100644 --- a/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts +++ b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts @@ -62,6 +62,15 @@ export interface StreamingZipApi { retryCurrent: () => void skipCurrent: () => void skipAllFailures: () => void + /** + * Convert the current pause-on-fail dialog into a two-pass run: + * the failure stays in `failedSoFar` as recoverable, the strategy + * switches to `twopass`, and the engine keeps going without pausing + * on subsequent failures. After the first pass finishes the engine + * pauses with `status: 'awaiting-retry'` so the host can call + * `retryFailed`. + */ + deferCurrentToEnd: () => void retryFailed: () => void cancel: () => void close: () => void @@ -111,7 +120,13 @@ export function useStreamingZipDownload(): StreamingZipApi { // Engine control: a single "decision" promise that the iterator // awaits when paused. The UI calls retryCurrent/skipCurrent/etc. // which resolve this promise with the requested action. - type Decision = 'retry' | 'skip' | 'skip-all' | 'retry-failed' | 'cancel' + type Decision = + | 'retry' + | 'skip' + | 'skip-all' + | 'defer-to-end' + | 'retry-failed' + | 'cancel' const decisionRef = useRef | null>(null) const cancelledRef = useRef(false) @@ -147,6 +162,7 @@ export function useStreamingZipDownload(): StreamingZipApi { const retryCurrent = useCallback(() => sendDecision('retry'), [sendDecision]) const skipCurrent = useCallback(() => sendDecision('skip'), [sendDecision]) const skipAllFailures = useCallback(() => sendDecision('skip-all'), [sendDecision]) + const deferCurrentToEnd = useCallback(() => sendDecision('defer-to-end'), [sendDecision]) const retryFailed = useCallback(() => sendDecision('retry-failed'), [sendDecision]) const start = useCallback( @@ -224,6 +240,14 @@ export function useStreamingZipDownload(): StreamingZipApi { queue.unshift(file) continue } + if (decision === 'defer-to-end') { + // Switch to twopass: the failure stays recoverable in + // failedSoFar, the engine stops pausing, and a second + // pass will pick up all recoverable failures at the end. + strategy = 'twopass' + update((prev) => ({ ...prev, status: 'running' })) + continue + } if (decision === 'skip-all') { strategy = 'skip' // mark the just-added failure as non-recoverable too @@ -240,7 +264,7 @@ export function useStreamingZipDownload(): StreamingZipApi { } }) } - // 'skip' falls through + // 'skip' or 'skip-all' fall through skippedManifest.push({ ...failure, recoverable: false }) continue } @@ -348,6 +372,7 @@ export function useStreamingZipDownload(): StreamingZipApi { retryCurrent, skipCurrent, skipAllFailures, + deferCurrentToEnd, retryFailed, cancel, close diff --git a/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx b/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx index 3691ade6f..6e48c7907 100644 --- a/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx +++ b/tests/component/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.spec.tsx @@ -166,4 +166,59 @@ describe('useStreamingZipDownload + FilesTreeDownloadTray', () => { cy.contains(/^Skip$/).click() cy.contains(/Download complete — 1 skipped/i).should('exist') }) + + it('switches to two-pass on "Skip & retry at end" and finishes after retry', () => { + const files: FileTreeFile[] = [ + FileTreeFileMother.create({ + id: 1, + name: 'a.txt', + path: 'a.txt', + size: 3, + downloadUrl: '/access/1' + }), + FileTreeFileMother.create({ + id: 2, + name: 'flaky.bin', + path: 'flaky.bin', + size: 3, + downloadUrl: '/access/2' + }), + FileTreeFileMother.create({ + id: 3, + name: 'c.txt', + path: 'c.txt', + size: 3, + downloadUrl: '/access/3' + }) + ] + + let flakyAttempts = 0 + cy.window().then((win) => { + cy.stub(win, 'fetch').callsFake((input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/access/1')) return Promise.resolve(fakeResponseBody('AAA')) + if (url.endsWith('/access/3')) return Promise.resolve(fakeResponseBody('CCC')) + if (url.endsWith('/access/2')) { + flakyAttempts += 1 + // Fails on the first pass; succeeds during the second-pass retry. + if (flakyAttempts === 1) return Promise.reject(new Error('flaky network')) + return Promise.resolve(fakeResponseBody('BBB')) + } + return Promise.reject(new Error(`unexpected fetch ${url}`)) + }) + }) + + cy.customMount() + cy.findByTestId('harness-start').click() + + cy.findByTestId('files-tree-download-tray-failure').should('be.visible') + cy.contains(/Skip & retry at end/i).click() + + cy.findByTestId('files-tree-download-tray-twopass').should('be.visible') + cy.contains(/Download 1 missing file/i).click() + cy.contains(/download complete/i).should('exist') + cy.then(() => { + expect(flakyAttempts).to.equal(2) + }) + }) }) From f907114ab4720b86c326d3ca4daff519c0795a5d Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 15:53:31 +0200 Subject: [PATCH 030/110] Enable react-tree-view in the dev compose; document defer-to-end button - dev-env/docker-compose-dev.yml: turn on DATAVERSE_FEATURE_REACT_TREE_VIEW alongside REACT_UPLOADER so the JSF mount is exercised in dev without manual JVM args. - docs/reusable-components.md: tree-view section now lists the full failure-dialog button set (Retry / Skip / Skip & retry at end / Skip all) and explains what each path produces (mid-flight twopass switch vs skip-with-manifest). --- dev-env/docker-compose-dev.yml | 1 + docs/reusable-components.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-env/docker-compose-dev.yml b/dev-env/docker-compose-dev.yml index 3af44c0f7..a00c17576 100644 --- a/dev-env/docker-compose-dev.yml +++ b/dev-env/docker-compose-dev.yml @@ -52,6 +52,7 @@ services: DATAVERSE_FEATURE_API_SESSION_AUTH: 1 DATAVERSE_FEATURE_API_SESSION_AUTH_HARDENING: 1 DATAVERSE_FEATURE_REACT_UPLOADER: 1 + DATAVERSE_FEATURE_REACT_TREE_VIEW: 1 DATAVERSE_AUTH_OIDC_ENABLED: 1 DATAVERSE_AUTH_OIDC_HIDDEN_JSF: 1 DATAVERSE_AUTH_OIDC_CLIENT_ID: test diff --git a/docs/reusable-components.md b/docs/reusable-components.md index d998971ad..fc2fbeb4f 100644 --- a/docs/reusable-components.md +++ b/docs/reusable-components.md @@ -194,7 +194,7 @@ The tree view ships: - Visible-row virtualisation; no `react-virtual` / `react-window` dep. - Full WAI-ARIA tree keyboard navigation (`ArrowUp/Down/Left/Right`, `Home/End`, `Space`, `Enter`). - URL bookmarkability: `?view=tree&path=` round-trips and pre-fetches every ancestor on mount. -- **Client-side streaming-zip download.** Multi-file selections are zipped in the browser via [`client-zip`](https://github.com/Touffy/client-zip) (~3 KB gzip, the only new dep introduced by the tree). A bottom-sheet tray (`FilesTreeDownloadTray`) shows progress, the file currently being added, and surfaces retry / skip / skip-all decisions inline when a fetch fails. Single-file downloads bypass the zip wrap and anchor-click `file.downloadUrl` directly. **No server contract changes.** +- **Client-side streaming-zip download.** Multi-file selections are zipped in the browser via [`client-zip`](https://github.com/Touffy/client-zip) (~3 KB gzip, the only new dep introduced by the tree). A bottom-sheet tray (`FilesTreeDownloadTray`) shows progress, the file currently being added, and surfaces an inline **Retry / Skip / Skip & retry at end / Skip all** decision row when a fetch fails. *Skip & retry at end* converts the run into a two-pass flow mid-flight (failures accumulate as recoverable, then the tray prompts to retry them at the end). *Skip all* switches to skip-with-manifest and writes a `manifest.txt` listing the failures into the root of the zip. Single-file downloads bypass the zip wrap and anchor-click `file.downloadUrl` directly. **No server contract changes.** ## Testing reusable components From a9977ea3d11307c89aa85e6ce3afee7f9df1ac96 Mon Sep 17 00:00:00 2001 From: ErykKul Date: Tue, 5 May 2026 16:16:07 +0200 Subject: [PATCH 031/110] Lint pass on tree-view download files Fixes ESLint findings introduced by the streaming-zip slice: - onDownloadFiles in FilesTree no longer wears 'async' since its body is synchronous; the hook's onDownloadFiles arg type is widened to Promise | void to keep both shapes valid. - The streaming-zip engine's iife is now 'void (async () => {...})()' to make the fire-and-forget intent explicit; the unused local 'pass' variable is dropped (the value is tracked on state already). - Decision union, FilesTreeDownloadTray, and FilesTree formatting brought back to prettier baseline. No behavioural changes. --- .../dataset-files/files-tree/FilesTree.tsx | 7 ++---- .../files-tree/FilesTreeDownloadTray.tsx | 23 ++++++++----------- .../files-tree/useFileTreeDownload.ts | 2 +- .../files-tree/useStreamingZipDownload.ts | 15 +++--------- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx index 49c2e9960..09ab8e95d 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -110,7 +110,7 @@ export function FilesTree({ }, [streamingZip.state.status]) const onDownloadFiles = useCallback( - async (files: FileTreeFile[]) => { + (files: FileTreeFile[]) => { if (files.length === 0) { return } @@ -296,10 +296,7 @@ export function FilesTree({ const currentRank = itemRowIndices.indexOf(focusedRowIndex) const nextRank = Math.max( 0, - Math.min( - itemRowIndices.length - 1, - (currentRank === -1 ? 0 : currentRank) + delta - ) + Math.min(itemRowIndices.length - 1, (currentRank === -1 ? 0 : currentRank) + delta) ) setFocusedRowIndex(itemRowIndices[nextRank]) }, diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx index 4abe9a52d..273a57c3a 100644 --- a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx @@ -29,8 +29,7 @@ export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadT const isError = state.status === 'error' const lastRecoverableFailure = [...state.failedSoFar].reverse().find((f) => f.recoverable) - const pct = - state.totalBytes > 0 ? Math.min(100, (state.bytesDone / state.totalBytes) * 100) : 0 + const pct = state.totalBytes > 0 ? Math.min(100, (state.bytesDone / state.totalBytes) * 100) : 0 let title: string = t('tree.download.tray.preparing', 'Preparing zip…') if (isPaused) title = t('tree.download.tray.paused', 'Download paused — file failed') @@ -54,14 +53,14 @@ export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadT const nowText = isPaused ? t('tree.download.tray.now.paused', 'Paused') : isAwaitingRetry - ? t('tree.download.tray.now.awaiting', 'Awaiting retry decision') - : isDone - ? t('tree.download.tray.now.done', 'Done') - : isCancelled - ? t('tree.download.tray.now.cancelled', 'Cancelled') - : state.current - ? `▸ ${state.current.path}` - : '…' + ? t('tree.download.tray.now.awaiting', 'Awaiting retry decision') + : isDone + ? t('tree.download.tray.now.done', 'Done') + : isCancelled + ? t('tree.download.tray.now.cancelled', 'Cancelled') + : state.current + ? `▸ ${state.current.path}` + : '…' return ( <> @@ -84,9 +83,7 @@ export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadT
{state.filesDone} / {state.totalFiles} {t('tree.download.tray.files', 'files')} ·{' '} {formatBytes(state.bytesDone)} / {formatBytes(state.totalBytes)} - {state.pass === 2 && ( - <> · {t('tree.download.tray.pass2', 'pass 2 of 2')} - )} + {state.pass === 2 && <> · {t('tree.download.tray.pass2', 'pass 2 of 2')}}