diff --git a/.gitignore b/.gitignore index 8f36e4547..6cdfb7f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,18 @@ # production /dist +# Reusable-components build output. `npm run build-reusable-components` +# writes the `dv-tree-view` / `dv-uploader` JS+CSS here; the files are +# then copied into `dataverse/.../webapp/reusable-components/` for the +# WAR bake. Tracking the build folder makes manual post-build cleanup +# brittle: `git checkout .` to clean stray edits also reverts the +# generated build files (or vice-versa, source-file edits get swept up +# alongside build-artefact resets). Ignore it instead. +/dist-reusable-components + +# Scratch dir of dataverse-context/scripts/fe-test-parallel.sh (worker +# logs + exit files); regenerated on every parallel test run. +/.test-parallel # storybook /storybook-static diff --git a/.storybook/main.ts b/.storybook/main.ts index ab07f18b3..e0b52fcf7 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -16,6 +16,14 @@ const config: StorybookConfig = { }, docs: {}, staticDirs: ['../public'], + viteFinal: (viteConfig) => ({ + ...viteConfig, + // Storybook already copies staticDirs into storybook-static after the + // build; leaving vite's own publicDir copy on makes every public/ file + // land twice, and node 22.x's fs.cp on the CI runners intermittently + // fails the second pass with EEXIST on the keycloakify resource tree. + publicDir: false + }), typescript: { reactDocgen: false } diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e13f875..ee2605a29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,41 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Dataset Templates UI integration, including create/edit flows, previews, and skeleton states. - Dataset Page: added a sidebar to show dataset reviews +- 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. +- Lazy file tree view on the dataset Files tab (`#6691`). A new `FilesViewToggle` flips between the existing files table and a virtualised, lazily-loaded folder tree. The tree supports tri-state path-keyed selection, full WAI-ARIA tree keyboard navigation (`Up/Down/Left/Right/Home/End/Space/Enter`), and URL bookmarkability via `?view=tree&path=`. +- Client-side streaming-zip download of the tree's selection. Multi-file selections are zipped in the browser (single new dependency: [`client-zip`](https://github.com/Touffy/client-zip), ~3 KB gzip); a bottom-sheet tray surfaces inline **Retry / Skip / Skip & retry at end / Skip all** decisions on per-file failures. Single-file downloads bypass the zip wrap and trigger a direct browser download. No server-side ZIP endpoint is involved. +- Standalone reusable bundle for the lazy tree (`dv-tree-view.js`), built by `vite.config.reusable-components.ts` alongside `dv-uploader`. Mounted on JSF dataset pages via the new `dataverse.feature.react-tree-view` flag in `IQSS/dataverse`. +- Domain layer for the new tree (`src/files/domain/{models,repositories,useCases}/`) plus an SDK-backed `FileTreeJSDataverseRepository` (with a `JSFileTreeMapper`) and a previews-derived `FileTreeFromPreviewsRepository` fallback for older Dataverse instances. ### Changed +- Bumped `@iqss/dataverse-client-javascript` to `2.2.0-pr403.f11ee66` (GitHub Packages prerelease of SDK PR #403 with current `develop` merged in) to consume the new `listDatasetTreeNode` / `iterateDatasetTreeNode` use cases plus the post-2.2.0 develop additions this branch now uses (`getDatasetReviews`, storage drivers, guestbooks). The pinned hash also carries the `x-amz-tagging` default fix so older Dataverse releases without the matching server PR keep getting `dv-state=temp` from the client. +- Tree rows surface the server's new `retentionExpired` access state (fourth state after public/restricted/embargoed): translated labels via the new `tree.access.*` i18n keys, a danger-emphasis colour cue whose folder precedence follows the server contract (retention-expired wins), and mutually exclusive folder count buckets. The entire `tree` namespace is now also translated to Spanish. +- The zip tray's awaiting-retry footer action finalizes the run ("Finish without them"): the first-pass bytes save with the missing files listed in `manifest.txt`. Previously the button closed the tray, which cancelled the run and silently discarded everything already streamed. +- Streaming-zip download fetches now use `credentials: 'same-origin'` (previously `'include'`). Cookies still travel on the same-origin Dataverse hop, but are dropped on the cross-origin redirect to S3 — required for `Allow-Origin: *` buckets to accept the request. +- Standalone bundles (`dv-tree-view`, `dv-uploader`) now mount inside a Shadow DOM root via `mountInShadowRoot`, isolating both directions from the host page's CSS context. +- Component CSS that references `var(--bs-*)` Bootstrap-5 tokens carries hardcoded fallback values, so the bundle renders correctly on JSF hosts that don't define those custom properties. + ### Fixed -### Removed +- Streaming-zip: closing the tray mid-run now cancels the engine instead of resurrecting cancelled runs (a truncated zip could previously save after Cancel + close) or leaking a suspended run when closed while paused. +- Streaming-zip: files that fail both passes of a two-pass run are demoted into `manifest.txt` instead of silently vanishing from a run that ends "done"; the retry button counts only recoverable failures. +- Streaming-zip: chunked Range parts no longer send the bearer `Authorization` header to cross-origin presigned S3 URLs (S3 rejects double authentication), and per-row download icons are disabled while a zip run is active so a second run can't race the first. +- Tree: a failing "load more" page no longer auto-retries in a tight loop; the error row's Retry button is the recovery path. +- Tree: a slow page response from a previous version/order selection can no longer overwrite the reset tree (stale responses are discarded by generation). +- Tree: folders force-opened by the filter query now load their children instead of showing an eternal spinner. +- Tree: a dataset/version-level 404 no longer permanently downgrades the session to the previews fallback — only the server's explicit "endpoint does not exist" response does. +- Standalone tree-view: file links from a draft tree keep the JSF-friendly version form (`DRAFT`), and API-style tokens (`:draft`, `:latest`) are translated for `file.xhtml`; previously such links opened the released version. +- Standalone bundles: `getBearerToken` returning null now falls through to the static `bearerToken`; the streaming-zip engine receives the bearer header via the new `downloadFetchInit` prop so restricted-file downloads work in bearer embeds. +- Build: `build-reusable-components` now copies `locales/` inside the deployable `reusable-components/` tree, so the documented straight-copy deploy ships translations. +- Successful Save in the SPA file uploader no longer re-engages the "Discard Uploaded Files?" leave modal. The post-save navigation effect was racing `useBlocker`'s predicate-update effect across a parent/child boundary; the fix colocates the navigate effect with `useBlocker` in the same component so React fires the predicate update before the navigation runs. The standalone uploader (which uses `beforeunload` instead of `useBlocker`) didn't reproduce the original symptom but uses the same colocated shape now. +- Tree download/upload paths select an S3-compatible storage driver via the typed `storageDriver.type === 's3'` capability and the `directDownload` / `directUpload` flags, instead of the previous `s3*` name-prefix heuristic. Operator-renamed drivers (e.g. `minio1`) are now recognised correctly without depending on the driver id. +- Tree DOM focus follows the keyboard's roving tabindex when the bundle is mounted inside a Shadow DOM (JSF embed): the focus-grab effect now resolves the active element via `el.getRootNode()` instead of `document.activeElement`, so the `:focus-visible` ring tracks Up/Down arrow keys across the host-page boundary. +- Tree view now re-mounts cleanly when a JSF partial update re-inserts the host `
`. Both standalone bundles install a `MutationObserver` that detects host-element identity changes and re-runs `init()` (the orphaned-Root regression that left the tree empty after toggling Table↔Tree several times). +- `useFileTree` no longer leaves the loading spinner forever when the host component unmounts mid-fetch; a `mountedRef` guard skips state updates after unmount, and the version-key-change reset path bypasses the stale-closure cache short-circuit so a refetch fires on schedule. +- Tree-view header now exposes a tristate select-all checkbox in its dedicated select column. +- Streaming-zip download tray's close button is sized for normal-pointer hit-targets when the bundle renders inside a JSF page (was rendering as a tiny `link` button due to host-page font cascade). --- diff --git a/cypress.config.ts b/cypress.config.ts index 61e8dd7f6..379efb33c 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -29,8 +29,15 @@ export default defineConfig({ supportFile: 'tests/support/e2e.ts', setupNodeEvents(on, config) { on('file:preprocessor', vitePreprocessor(path.resolve(__dirname, './vite.config.ts'))) - on('task', { + // Diagnostic printer: cy.task('diag', value) prints to the Cypress + // run's stdout, which GitHub Actions captures. cy.log / console.log + // from inside cy.then() run browser-side and never reach CI logs. + diag(value: unknown) { + // eslint-disable-next-line no-console + console.log('[diag]', JSON.stringify(value)) + return null + }, async solrSchemaFieldExists(fieldName: string): Promise { const statusCode = await runDockerCommand(config, [ 'exec', diff --git a/docs/reusable-components.md b/docs/reusable-components.md new file mode 100644 index 000000000..d26a9c307 --- /dev/null +++ b/docs/reusable-components.md @@ -0,0 +1,333 @@ +# 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, and operator setup — is documented in the Dataverse operator guide: [Reusable Frontend Components](https://guides.dataverse.org/en/latest/container/running/reusable-components.html). 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 (`#6691`) + +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.reusable-components.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 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.** Per-file fetches use `credentials: 'same-origin'` (not `'include'`) so the browser drops cookies on the cross-origin S3 hop after a `download-redirect=true` 302 — including credentials there would force `Access-Control-Allow-Credentials: true` on every S3 response and break against `Allow-Origin: *` rules. +- **Header select-all checkbox.** Tristate (none / partial / all). Selects every top-level item when nothing is selected, clears everything otherwise. + +## 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 `reusable-components` 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. diff --git a/package-lock.json b/package-lock.json index 34a5bd68e..e8aaecc43 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-alpha.10", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.f11ee66", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -54,7 +54,8 @@ "use-deep-compare": "1.2.1", "vite-plugin-istanbul": "4.0.1", "web-vitals": "2.1.4", - "zod": "4.1.12" + "zod": "4.1.12", + "client-zip": "^2.5.0" }, "devDependencies": { "@csstools/postcss-cascade-layers": "3.0.1", @@ -3285,9 +3286,7 @@ } }, "node_modules/@iqss/dataverse-client-javascript": { - "version": "2.2.0-alpha.10", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.2.0-alpha.10/1fcacf53d38b344ea8502ad68aaa69c9eeb66348", - "integrity": "sha512-qQx0dLZHEeR4FJh0J4eb7D5sXE+S+PpXB1u8AiySFN4FKXu0wSf50bGEJbmyTqmmvtwfrjuUB2YKl1mCB1rZrg==", + "version": "2.2.0-pr403.f11ee66", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", @@ -3298,9 +3297,9 @@ } }, "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==", + "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" @@ -32533,6 +32532,12 @@ "vite-plugin-dts": "2.3.0", "vite-plugin-libcss": "1.0.6" } + }, + "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" } } } diff --git a/package.json b/package.json index 7d813db74..6ac3be32d 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-alpha.10", + "@iqss/dataverse-client-javascript": "2.2.0-pr403.f11ee66", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -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", @@ -64,6 +65,7 @@ "scripts": { "start": "vite", "build": "tsc && vite build", + "build-reusable-components": "vite build --config vite.config.reusable-components.ts && cp -r public/locales dist-reusable-components/reusable-components/ && cp src/standalone-uploader/dvUploader.html dist-reusable-components/ && cp src/standalone-tree-view/dvTreeView.html dist-reusable-components/ && sass --no-source-map src/standalone-uploader/standalone-page.scss dist-reusable-components/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/packages/design-system/src/lib/components/button/Button.tsx b/packages/design-system/src/lib/components/button/Button.tsx index bbb0f94ad..e3db8e18d 100644 --- a/packages/design-system/src/lib/components/button/Button.tsx +++ b/packages/design-system/src/lib/components/button/Button.tsx @@ -12,6 +12,7 @@ interface ButtonProps extends ButtonHTMLAttributes { size?: ButtonSize variant?: ButtonVariant disabled?: boolean + active?: boolean onClick?: (event: MouseEvent) => void icon?: IconName | ReactNode withSpacing?: boolean @@ -23,6 +24,7 @@ export function Button({ size, variant = 'primary', disabled = false, + active, onClick, icon, withSpacing, @@ -35,6 +37,7 @@ export function Button({ size={size} className={withSpacing ? styles.spacing : ''} variant={variant} + active={active} onClick={disabled ? undefined : onClick} disabled={disabled} aria-disabled={disabled} diff --git a/packages/design-system/tsconfig.json b/packages/design-system/tsconfig.json index 9b257bf63..002865b24 100644 --- a/packages/design-system/tsconfig.json +++ b/packages/design-system/tsconfig.json @@ -15,7 +15,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx" }, "include": ["src", "tests", "vite.config.ts", "cypress.config.ts", ".storybook/test-runner.ts"], "exclude": ["tests/**/**.spec.ts", "tests/**/**.spec.tsx"] diff --git a/public/locales/en/files.json b/public/locales/en/files.json index 447cccfbe..1f3aa2887 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -1,6 +1,114 @@ { "files": "Files", "filesLoading": "Files loading spinner symbol", + "view": { + "toggle": { + "label": "Files view selector", + "changeView": "Change View", + "table": "Table", + "tree": "Tree" + } + }, + "tree": { + "label": "Dataset files", + "toolbar": { + "label": "Selection actions" + }, + "head": { + "name": "Name", + "size": "Size", + "count": "Files", + "actions": "Actions", + "access": "Access", + "selectAll": "Select all visible" + }, + "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…", + "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", + "completeWithVerificationFailures_one": "Download complete — {{count}} file failed checksum verification", + "completeWithVerificationFailures_other": "Download complete — {{count}} files failed checksum verification", + "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", + "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", + "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", + "finishWithout": "Finish without them", + "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…", + "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}}\"" + }, + "access": { + "public": "Public", + "restricted": "Restricted", + "embargoed": "Embargoed", + "retentionExpired": "Retention expired", + "count": { + "restricted_one": "{{count}} restricted", + "restricted_other": "{{count}} restricted", + "embargoed_one": "{{count}} embargoed", + "embargoed_other": "{{count}} embargoed", + "retentionExpired_one": "{{count}} retention expired", + "retentionExpired_other": "{{count}} retention expired" + } + } + }, "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/public/locales/en/shared.json b/public/locales/en/shared.json index 68f7c63b9..c4d17e6d9 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -219,6 +219,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.", "uploadWidgetHelp": "Select files or drag and drop into the upload widget. Maximum of {{maxFilesPerUpload}} files per upload.", diff --git a/public/locales/es/files.json b/public/locales/es/files.json index f639e392f..1bfa54eda 100644 --- a/public/locales/es/files.json +++ b/public/locales/es/files.json @@ -160,5 +160,105 @@ "reportMessage": "no fue exitosa. {{reportMessage}}", "reportMessageDefault": "no fue exitosa. La ingesta falló. No hay más información disponible." } + }, + "tree": { + "label": "Archivos del dataset", + "toolbar": { + "label": "Acciones de la selección" + }, + "head": { + "name": "Nombre", + "size": "Tamaño", + "count": "Archivos", + "actions": "Acciones", + "access": "Acceso", + "selectAll": "Seleccionar todo lo visible" + }, + "row": { + "expandFolder": "Expandir {{name}}", + "collapseFolder": "Contraer {{name}}", + "selectFolder": "Seleccionar carpeta {{name}}", + "selectFile": "Seleccionar archivo {{name}}", + "downloadFolder": "Descargar carpeta {{name}}", + "downloadFile": "Descargar archivo {{name}}" + }, + "access": { + "public": "Público", + "restricted": "Restringido", + "embargoed": "Embargado", + "retentionExpired": "Retención expirada", + "count": { + "restricted_one": "{{count}} restringido", + "restricted_other": "{{count}} restringidos", + "embargoed_one": "{{count}} embargado", + "embargoed_other": "{{count}} embargados", + "retentionExpired_one": "{{count}} con retención expirada", + "retentionExpired_other": "{{count}} con retención expirada" + } + }, + "selection": { + "none": "Ningún archivo seleccionado", + "fileCount_one": "archivo", + "fileCount_other": "archivos", + "includesFolders": "carpetas incluidas", + "clear": "Limpiar" + }, + "download": { + "button": "Descargar zip", + "enumerating": "Listando archivos…", + "preparing": "Preparando zip…", + "streaming": "Transfiriendo…", + "tray": { + "label": "Progreso de la descarga zip", + "preparing": "Preparando zip…", + "streaming": "Añadiendo archivos al zip…", + "paused": "Descarga en pausa — un archivo falló", + "firstPass_one": "Primera pasada completa — {{count}} archivo falló", + "firstPass_other": "Primera pasada completa — {{count}} archivos fallaron", + "complete": "Descarga completa", + "completeWithSkipped_one": "Descarga completa — {{count}} omitido", + "completeWithSkipped_other": "Descarga completa — {{count}} omitidos", + "completeWithVerificationFailures_one": "Descarga completa — {{count}} archivo no superó la verificación de suma", + "completeWithVerificationFailures_other": "Descarga completa — {{count}} archivos no superaron la verificación de suma", + "error": "La descarga falló", + "cancelled": "Descarga cancelada", + "files": "archivos", + "pass2": "pasada 2 de 2", + "now": { + "paused": "En pausa", + "awaiting": "Esperando decisión de reintento", + "done": "Hecho", + "cancelled": "Cancelada" + }, + "failed": "No se pudo obtener el archivo", + "retry": "Reintentar este archivo", + "skip": "Omitir", + "deferToEnd": "Omitir y reintentar al final", + "skipAll": "Omitir todos los fallos restantes", + "notIncluded_one": "{{count}} archivo no se incluyó en el zip", + "notIncluded_other": "{{count}} archivos no se incluyeron en el zip", + "twopassHint": "Primera pasada terminada. El zip se finalizará tras reintentar los fallos en una segunda pasada.", + "retryFailed_one": "Descargar {{count}} archivo pendiente", + "retryFailed_other": "Descargar {{count}} archivos pendientes", + "finishWithout": "Finalizar sin ellos", + "done": "Hecho", + "skipped_one": "{{count}} archivo omitido", + "skipped_other": "{{count}} archivos omitidos", + "skippedManifest": "Se añadió un manifest.txt con los archivos omitidos en la raíz del zip.", + "hint": "Se genera localmente en un único zip — sin ZIP en el servidor.", + "cancel": "Cancelar", + "close": "Cerrar" + } + }, + "state": { + "loading": "Cargando el índice de archivos…", + "loadingFolder": "Cargando carpeta…", + "loadingMore": "Cargando…", + "loadMore": "Cargar más", + "error": "No se pudo cargar el índice de archivos", + "retry": "Reintentar", + "empty": "Este dataset no tiene archivos", + "noMatches": "Ningún archivo coincide con \"{{query}}\"" + } } } diff --git a/src/dataset/domain/models/Dataset.ts b/src/dataset/domain/models/Dataset.ts index 4fb18403a..ddaab5de9 100644 --- a/src/dataset/domain/models/Dataset.ts +++ b/src/dataset/domain/models/Dataset.ts @@ -27,6 +27,22 @@ export class DatasetLabel { ) {} } +/** + * The dataset's effective storage driver, surfaced to the SPA so feature + * gates can ask the right question — "is this driver capable of + * browser-direct upload to S3-compatible storage?" — instead of pattern- + * matching on the driver id (which is just an operator-chosen label). + * + * Mirrors the shape of `GET /api/datasets/{id}/storageDriver`. + */ +export interface DatasetStorageDriver { + name: string + type: string + label: string + directUpload: boolean + directDownload: boolean +} + // Only for testing purposes and checking the existence of the citation block in some parts of the code export enum MetadataBlockName { CITATION = 'citation', @@ -439,7 +455,8 @@ export class Dataset { public readonly nextMinorVersion?: string, public readonly requiresMajorVersionUpdate?: boolean, public readonly fileStore?: string, - public readonly guestbookId?: number + public readonly guestbookId?: number, + public readonly storageDriver?: DatasetStorageDriver ) {} public checkIsLockedFromPublishing(userPersistentId: string): boolean { @@ -535,7 +552,8 @@ export class Dataset { public readonly nextMinorVersionNumber?: string, public readonly requiresMajorVersionUpdate?: boolean, public readonly fileStore?: string, - public readonly guestbookId?: number + public readonly guestbookId?: number, + public readonly storageDriver?: DatasetStorageDriver ) { this.withAlerts() } @@ -608,7 +626,8 @@ export class Dataset { this.nextMinorVersionNumber, this.requiresMajorVersionUpdate, this.fileStore, - this.guestbookId + this.guestbookId, + this.storageDriver ) } } diff --git a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts index 3358c7d3d..87a5a25b6 100644 --- a/src/dataset/infrastructure/mappers/JSDatasetMapper.ts +++ b/src/dataset/infrastructure/mappers/JSDatasetMapper.ts @@ -19,6 +19,7 @@ import { DatasetMetadataBlocks, DatasetMetadataFields, DatasetPermissions, + DatasetStorageDriver, DatasetVersion, MetadataBlockName, PrivateUrl @@ -49,7 +50,8 @@ export class JSDatasetMapper { latestPublishedVersionMajorNumber?: number, latestPublishedVersionMinorNumber?: number, datasetVersionDiff?: JSDatasetVersionDiff, - fileStore?: string + fileStore?: string, + storageDriver?: DatasetStorageDriver ): Dataset { const version = JSDatasetVersionMapper.toVersion( jsDataset.versionId, @@ -101,7 +103,8 @@ export class JSDatasetMapper { ), JSDatasetMapper.toRequiresMajorVersionUpdate(datasetVersionDiff), fileStore, - jsDataset.guestbookId as number + jsDataset.guestbookId as number, + storageDriver ).build() } diff --git a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts index 0d489c490..bbf90b151 100644 --- a/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts +++ b/src/dataset/infrastructure/repositories/DatasetJSDataverseRepository.ts @@ -3,6 +3,7 @@ import { Dataset, DatasetLock, DatasetNonNumericVersion, + DatasetStorageDriver, TermsOfAccess } from '../../domain/models/Dataset' import { DatasetVersionDiff } from '../../domain/models/DatasetVersionDiff' @@ -45,6 +46,7 @@ import { updateTermsOfAccess, updateDatasetLicense, getDatasetUploadLimits, + getDatasetStorageDriver, getDatasetReviews } from '@iqss/dataverse-client-javascript' import { JSDatasetMapper } from '../mappers/JSDatasetMapper' @@ -59,9 +61,7 @@ import { DatasetDownloadCount } from '@/dataset/domain/models/DatasetDownloadCou import { DatasetVersionPaginationInfo } from '@/dataset/domain/models/DatasetVersionPaginationInfo' import { FormattedCitation, CitationFormat } from '@/dataset/domain/models/DatasetCitation' import { DatasetLicenseUpdateRequest } from '../../domain/models/DatasetLicenseUpdateRequest' -import { axiosInstance } from '@/axiosInstance' import { requireAppConfig } from '../../../config' -import { AxiosResponse } from 'axios' import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' import { CollectionSummary } from '@/collection/domain/models/CollectionSummary' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' @@ -81,6 +81,7 @@ interface IDatasetDetails { latestPublishedVersionMinorNumber?: number datasetVersionDiff?: JSDatasetVersionDiff fileStore?: string + storageDriver?: DatasetStorageDriver } export class DatasetJSDataverseRepository implements DatasetRepository { @@ -161,14 +162,14 @@ export class DatasetJSDataverseRepository implements DatasetRepository { getDatasetCitation.execute(jsDataset.id, version, includeDeaccessioned), getDatasetUserPermissions.execute(jsDataset.id), getDatasetLocks.execute(jsDataset.id), - this.getFileStore(jsDataset.id) + this.getStorageDriver(jsDataset.id) ]).then( - ([summaryFieldsNames, citation, jsDatasetPermissions, jsDatasetLocks, fileStore]: [ + ([summaryFieldsNames, citation, jsDatasetPermissions, jsDatasetLocks, storageDriver]: [ string[], string, JSDatasetPermissions, JSDatasetLock[], - string | undefined + DatasetStorageDriver | undefined ]) => { return { jsDataset, @@ -178,7 +179,8 @@ export class DatasetJSDataverseRepository implements DatasetRepository { jsDatasetLocks, jsDatasetFilesTotalOriginalDownloadSize: 0, jsDatasetFilesTotalArchivalDownloadSize: 0, - fileStore + fileStore: storageDriver?.name, + storageDriver } } ) @@ -271,7 +273,8 @@ export class DatasetJSDataverseRepository implements DatasetRepository { datasetDetails.latestPublishedVersionMajorNumber, datasetDetails.latestPublishedVersionMinorNumber, datasetDetails.datasetVersionDiff, - datasetDetails.fileStore + datasetDetails.fileStore, + datasetDetails.storageDriver ) }) .catch((error: ReadError) => { @@ -433,33 +436,25 @@ export class DatasetJSDataverseRepository implements DatasetRepository { return getDatasetLinkedCollections.execute(datasetId) } - /* - TODO: This is a temporary solution as this use case doesn't exist in js-dataverse yet and the API should also return the file store type rather than name only. - After https://github.com/IQSS/dataverse/issues/11695 is implemented, create a js-dataverse use case. - */ - private async getFileStore(datasetId: number): Promise { - return axiosInstance - .get( - `${DatasetJSDataverseRepository.DATAVERSE_BACKEND_URL}/api/datasets/${datasetId}/storageDriver` - ) - .then( - ( - res: AxiosResponse<{ - data: { - name: string - label: string - type: string - directDownload: boolean - directUpload: boolean + private async getStorageDriver(datasetId: number): Promise { + return getDatasetStorageDriver + .execute(datasetId) + .then((driver) => + driver + ? { + // The SDK model leaves these optional; the SPA's + // DatasetStorageDriver keeps them required so capability + // checks stay simple — default the blanks here, at the + // boundary. + name: driver.name ?? '', + type: driver.type ?? '', + label: driver.label ?? '', + directUpload: driver.directUpload ?? false, + directDownload: driver.directDownload ?? false } - }> - ) => { - return res.data.data.name - } + : undefined ) - .catch(() => { - return undefined - }) + .catch(() => undefined) } updateDatasetLicense( diff --git a/src/files/domain/models/FileTreeItem.ts b/src/files/domain/models/FileTreeItem.ts new file mode 100644 index 000000000..919820947 --- /dev/null +++ b/src/files/domain/models/FileTreeItem.ts @@ -0,0 +1,63 @@ +import { FileAccess } from './FileAccess' + +export type FileAccessStatus = 'public' | 'restricted' | 'embargoed' | 'retentionExpired' + +export enum FileTreeItemType { + FOLDER = 'folder', + FILE = 'file' +} + +export interface FileTreeFolder { + type: FileTreeItemType.FOLDER + name: string + path: string + /** + * Recursive aggregates over the folder's subtree. `bytes`, `restricted`, + * `embargoed`, and `retentionExpired` are individually optional so the + * SPA keeps rendering against an older SDK (or a server that hasn't yet + * rolled out a given aggregate) without a type-cast workaround. The + * previews-based fallback also leaves them off — its counts are + * best-effort. The three access buckets are mutually exclusive, + * mirroring the per-file resolution: retention-expired wins, then + * restricted, then embargoed. + */ + counts?: { + files: number + folders: number + bytes?: number + restricted?: number + embargoed?: number + retentionExpired?: number + } +} + +export interface FileTreeFile { + type: FileTreeItemType.FILE + id: number + name: string + path: string + size: number + contentType?: string + access?: FileAccess + /** + * Access marker mirroring the per-file `access` string the SDK + * exposes on the tree response: `'public' | 'restricted' | + * 'embargoed' | 'retentionExpired'` (resolved by the server in + * reverse order — retention-expired wins, then restricted, then + * embargoed). Kept separate from `access` (the boolean-ish + * `FileAccess` object used elsewhere in the SPA) because that shape + * collapses the non-public states into the same `restricted: true` + * flag — fine for permission gating, lossy for tree display. + */ + accessStatus?: FileAccessStatus + 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/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/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/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/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/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/files/infrastructure/mappers/JSFileTreeMapper.ts b/src/files/infrastructure/mappers/JSFileTreeMapper.ts new file mode 100644 index 000000000..90562d057 --- /dev/null +++ b/src/files/infrastructure/mappers/JSFileTreeMapper.ts @@ -0,0 +1,158 @@ +import { + FileTreeFileNode as SDKFileTreeFileNode, + FileTreeFolderNode as SDKFileTreeFolderNode, + FileTreeInclude as SDKFileTreeInclude, + FileTreeNode as SDKFileTreeNode, + FileTreeOrder as SDKFileTreeOrder, + FileTreePage as SDKFileTreePage, + ReadError, + isFileTreeFileNode, + isFileTreeFolderNode +} from '@iqss/dataverse-client-javascript' +import { + FileTreeFile, + FileTreeFolder, + FileTreeItem, + FileTreeItemType +} from '../../domain/models/FileTreeItem' +import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage' + +/** + * Translates the wire shape returned by the SDK helper + * `listDatasetTreeNode` into the SPA's domain models, and (in the + * other direction) maps the domain enums back onto the SDK enums for + * outbound requests. + * + * Kept as a static-method class to match the prevailing pattern under + * `infrastructure/mappers/` (see `JSFileMapper`, `JSFileMetadataMapper`). + */ +export class JSFileTreeMapper { + static toFileTreePage( + page: SDKFileTreePage, + fallbackOrder?: FileTreeOrder, + fallbackInclude?: FileTreeInclude + ): FileTreePage { + return { + path: page.path, + items: page.items.map((item) => JSFileTreeMapper.toFileTreeItem(item)), + nextCursor: page.nextCursor, + limit: page.limit, + order: JSFileTreeMapper.toFileTreeOrder(page.order, fallbackOrder), + include: JSFileTreeMapper.toFileTreeInclude(page.include, fallbackInclude), + approximateCount: page.approximateCount + } + } + + static toFileTreeItem(item: SDKFileTreeNode): FileTreeItem { + if (isFileTreeFolderNode(item)) { + return JSFileTreeMapper.toFileTreeFolder(item) + } + if (isFileTreeFileNode(item)) { + return JSFileTreeMapper.toFileTreeFile(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}`) + } + + static toFileTreeFolder(item: SDKFileTreeFolderNode): FileTreeFolder { + return { + type: FileTreeItemType.FOLDER, + name: item.name, + path: item.path, + counts: item.counts + } + } + + static toFileTreeFile(item: SDKFileTreeFileNode): 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, + // Preserve the three-way distinction (public / restricted / + // embargoed) lost by the FileAccess flat shape above — needed for + // the tree row's access column. + accessStatus: item.access, + checksum: item.checksum, + downloadUrl: item.downloadUrl + } + } + + static toSDKFileTreeOrder(value?: FileTreeOrder): SDKFileTreeOrder | undefined { + if (value === undefined) return undefined + return value === FileTreeOrder.NAME_ZA ? SDKFileTreeOrder.NAME_ZA : SDKFileTreeOrder.NAME_AZ + } + + static toSDKFileTreeInclude(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 + } + } + + static toFileTreeOrder(value: SDKFileTreeOrder, fallback?: FileTreeOrder): FileTreeOrder { + return value === SDKFileTreeOrder.NAME_ZA + ? FileTreeOrder.NAME_ZA + : (value as unknown as FileTreeOrder) ?? fallback ?? FileTreeOrder.NAME_AZ + } + + static toFileTreeInclude(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 + } + } + + /** + * True when the response indicates the tree endpoint isn't available + * on the target instance — older versions, alternate deployments — + * so the host can transparently fall back to the previews-derived + * tree implementation. + */ + static isEndpointMissing(error: unknown): boolean { + if (error instanceof ReadError) { + // A 404 is only "endpoint not deployed" when it is a ROUTE miss. + // Dataverse's WebApplicationExceptionHandler maps those to the + // stable "API endpoint does not exist on this server" message, + // while resource-level 404s (dataset/version not found, draft the + // user cannot see) carry their own wording — treating them as + // endpoint-missing would silently downgrade a perfectly good + // server to the previews fallback and mask the real error. + if (/\[404\]/.test(error.message)) { + return /API endpoint does not exist/i.test(error.message) + } + return /\[(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/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts new file mode 100644 index 000000000..b77c6fc61 --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeFromPreviewsRepository.ts @@ -0,0 +1,229 @@ +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 + bytes: 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, + bytes: 0, + subfolderNames: new Set() + } + folders.set(folderPath, entry) + } + entry.fileCount += 1 + entry.bytes += preview.metadata.size?.toBytes() ?? 0 + 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, bytes: f.bytes } + })) + + 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..2c88f5eb3 --- /dev/null +++ b/src/files/infrastructure/repositories/FileTreeJSDataverseRepository.ts @@ -0,0 +1,54 @@ +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '../../domain/repositories/FileTreeRepository' +import { FileTreePage } from '../../domain/models/FileTreePage' +import { listDatasetTreeNode } from '@iqss/dataverse-client-javascript' +import { FileTreeFromPreviewsRepository } from './FileTreeFromPreviewsRepository' +import { FileRepository } from '../../domain/repositories/FileRepository' +import { JSFileTreeMapper } from '../mappers/JSFileTreeMapper' + +/** + * Calls the dedicated tree endpoint via the SDK helper + * `listDatasetTreeNode`, which wraps + * `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. + */ +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 sdkPage = await listDatasetTreeNode.execute({ + datasetId: params.datasetPersistentId, + datasetVersionId: params.datasetVersion.number.toString(), + path: params.path, + limit: params.limit, + cursor: params.cursor, + include: JSFileTreeMapper.toSDKFileTreeInclude(params.include), + order: JSFileTreeMapper.toSDKFileTreeOrder(params.order), + includeDeaccessioned: params.includeDeaccessioned, + originals: params.originals + }) + return JSFileTreeMapper.toFileTreePage(sdkPage, params.order, params.include) + } catch (error) { + if (this.fileRepository && JSFileTreeMapper.isEndpointMissing(error)) { + this.endpointUnavailable = true + if (!this.fallback) { + this.fallback = new FileTreeFromPreviewsRepository(this.fileRepository) + } + return this.fallback.getNode(params) + } + throw error + } + } +} diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index d9853c57a..f46137f17 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/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx b/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx index 8190911c6..2d032051b 100644 --- a/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx +++ b/src/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.tsx @@ -84,8 +84,9 @@ export function EditDatasetMenu({ dataset }: EditDatasetMenuProps) { asButtonGroup variant="secondary" disabled={dataset.checkIsLockedFromEdits(user.persistentId)}> - {/* TODO: remove this when we can handle non-S3 files */} - {dataset?.fileStore?.startsWith('s3') && ( + {/* SPA upload flow needs an S3-compatible direct-upload driver; + decide via the typed driver capabilities, not the driver id name. */} + {dataset?.storageDriver?.type === 's3' && dataset.storageDriver.directUpload && ( { + setSearchParams(nextSearchParamsForView(searchParams, next), { replace: true }) + } + const setTreePath = (next: string) => { + setSearchParams(nextSearchParamsForTreePath(searchParams, next), { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + + return view === 'tree' ? ( + + ) : ( + + ) +} + +interface DatasetFilesTableViewProps { + filesRepository: FileRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode +} + +function DatasetFilesTableView({ + filesRepository, + datasetPersistentId, + datasetVersion, + onChangeView, + view +}: DatasetFilesTableViewProps) { const [paginationInfo, setPaginationInfo] = useState(new FilePaginationInfo()) const [criteria, setCriteria] = useState(new FileCriteria()) const { files, isLoading, filesCountInfo, filesTotalDownloadSize } = useFiles( @@ -32,6 +99,9 @@ export function DatasetFiles({ return ( <> +
+ +
) } + +interface DatasetFilesTreeViewProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + onChangeView: (view: FilesViewMode) => void + view: FilesViewMode + initialPath: string + onCurrentPathChange: (path: string) => void +} + +function DatasetFilesTreeView({ + treeRepository, + datasetPersistentId, + datasetVersion, + onChangeView, + view, + initialPath, + onCurrentPathChange +}: DatasetFilesTreeViewProps) { + const { dataset } = useDataset() + const downloadsDisabled = treeDownloadsRequireTermsGate(dataset) + return ( + <> +
+ +
+ + + ) +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss b/src/sections/dataset/dataset-files/DatasetFilesScrollable.module.scss index f6fb9013c..249a51b67 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-start; + margin-bottom: 0.75rem; +} diff --git a/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx b/src/sections/dataset/dataset-files/DatasetFilesScrollable.tsx index 65c7e9702..f5f0b3565 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' @@ -13,6 +14,18 @@ import { useObserveElementSize } from '../../../shared/hooks/useObserveElementSi import { FilesTableScrollable } from './files-table/FilesTableScrollable' import { FileCriteriaForm } from './file-criteria-form/FileCriteriaForm' import { FilesContext } from '@/sections/file/FilesContext' +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 { useDataset } from '../DatasetContext' +import { treeDownloadsRequireTermsGate } from './treeDownloadsRequireTermsGate' +import { + PATH_PARAM, + VIEW_PARAM, + nextSearchParamsForTreePath, + nextSearchParamsForView +} from './filesViewSearchParams' import styles from './DatasetFilesScrollable.module.scss' interface DatasetFilesScrollableProps { @@ -20,6 +33,7 @@ interface DatasetFilesScrollableProps { datasetPersistentId: string datasetVersion: DatasetVersion canUpdateDataset?: boolean + fileTreeRepository?: FileTreeRepository } export type SentryRef = UseInfiniteScrollHookRefCallback @@ -28,8 +42,108 @@ export function DatasetFilesScrollable({ filesRepository, datasetPersistentId, datasetVersion, - canUpdateDataset + canUpdateDataset, + fileTreeRepository }: DatasetFilesScrollableProps) { + const [searchParams, setSearchParams] = useSearchParams() + const view: FilesViewMode = searchParams.get(VIEW_PARAM) === 'tree' ? 'tree' : 'table' + const treePath = searchParams.get(PATH_PARAM) ?? '' + const setView = (next: FilesViewMode) => { + setSearchParams(nextSearchParamsForView(searchParams, next), { replace: true }) + } + const setTreePath = (next: string) => { + setSearchParams(nextSearchParamsForTreePath(searchParams, next), { replace: true }) + } + + const treeRepository = useMemo( + () => fileTreeRepository ?? new FileTreeJSDataverseRepository(filesRepository), + [fileTreeRepository, filesRepository] + ) + + // Branch on view BEFORE invoking either subview's hooks. The previous + // single-component layout fired the table-only data hooks + // (`useGetFilesCountInfo`, `useGetFilesTotalDownloadSize`, + // `useGetAccumulatedFiles`) on every render — including tree mode — + // and a transient error in any of them aborted the render with a + // top-level , replacing a perfectly healthy tree response with + // an unrelated error banner. + return view === 'tree' ? ( + + ) : ( + + ) +} + +interface DatasetFilesScrollableTreeViewProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + view: FilesViewMode + onChangeView: (view: FilesViewMode) => void + initialPath: string + onCurrentPathChange: (path: string) => void +} + +function DatasetFilesScrollableTreeView({ + treeRepository, + datasetPersistentId, + datasetVersion, + view, + onChangeView, + initialPath, + onCurrentPathChange +}: DatasetFilesScrollableTreeViewProps) { + const { dataset } = useDataset() + const downloadsDisabled = treeDownloadsRequireTermsGate(dataset) + return ( +
+
+ +
+ +
+ ) +} + +interface DatasetFilesScrollableTableViewProps { + filesRepository: FileRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + canUpdateDataset?: boolean + view: FilesViewMode + onChangeView: (view: FilesViewMode) => void +} + +function DatasetFilesScrollableTableView({ + filesRepository, + datasetPersistentId, + datasetVersion, + canUpdateDataset, + view, + onChangeView +}: DatasetFilesScrollableTableViewProps) { const scrollableContainerRef = useRef(null) const criteriaContainerRef = useRef(null) const criteriaContainerSize = useObserveElementSize(criteriaContainerRef) @@ -139,6 +253,13 @@ export function DatasetFilesScrollable({ if (errors.some(Boolean)) { return ( <> + {/* Keep the view toggle reachable: these errors come from the + table-only data hooks, and the tree view (different endpoint) + may well still work — without the toggle the user's only way + out is hand-editing the URL. */} +
+ +
{errors.map((error, index) => { if (error) { return ( @@ -151,6 +272,7 @@ export function DatasetFilesScrollable({ ) } + return (
+
+ +
} - // TODO: remove this when we can handle non-S3 files - if (!dataset?.fileStore?.startsWith('s3')) { + // The SPA upload flow requires browser-direct upload to S3-compatible + // storage. Check the driver's typed capabilities, not its operator- + // chosen id (the previous `fileStore?.startsWith('s3')` heuristic + // missed any S3-compatible driver registered under a different id — + // e.g., `minio1`, `wasabi`, `cloudflare-r2`). + const driver = dataset.storageDriver + if (!driver || driver.type !== 's3' || !driver.directUpload) { return <> } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx index 96336e6c4..d4a04394e 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/download-files/DownloadFilesButton.tsx @@ -92,8 +92,12 @@ export function DownloadFilesButton({ files, fileSelection }: DownloadFilesButto return <> } - // TODO: remove this when we can handle non-S3 files - if (!dataset?.fileStore?.startsWith('s3')) { + // The download menu's signed-URL flow targets S3-compatible storage. + // Decide via the typed driver capabilities, not the operator-chosen + // driver id (so any driver with type=s3 + directDownload works, + // regardless of whether it's named `s3`, `minio1`, etc.). + const driver = dataset?.storageDriver + if (!driver || driver.type !== 's3' || !driver.directDownload) { return <> } diff --git a/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx b/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx index 7eefb8dac..b57fb0223 100644 --- a/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx +++ b/src/sections/dataset/dataset-files/files-table/useFilesTableScrollable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useDeepCompareMemo } from 'use-deep-compare' import { FilePreview } from '../../../../files/domain/models/FilePreview' import { getCoreRowModel, Row, useReactTable } from '@tanstack/react-table' @@ -40,15 +40,26 @@ export function useFilesTableScrollable( return result }, [selectedRowsModels, rowSelection]) + // Recreating the column definitions on every render gives every cell a new + // component identity, so any incidental re-render (e.g. opening a per-row + // dropdown, which itself triggers a couple of re-renders) unmounts and + // remounts the row cells — closing the just-opened menu before it can show. + // Memoize so cell identities stay stable across those re-renders. + const columns = useMemo( + () => + createColumnsDefinition( + paginationInfo, + fileSelection, + fileRepository, + datasetRepository, + accumulatedFilesCount + ), + [paginationInfo, fileSelection, fileRepository, datasetRepository, accumulatedFilesCount] + ) + const table = useReactTable({ data: files, - columns: createColumnsDefinition( - paginationInfo, - fileSelection, - fileRepository, - datasetRepository, - accumulatedFilesCount - ), + columns, state: { rowSelection: rowSelection }, diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss new file mode 100644 index 000000000..44ddbdc6d --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.module.scss @@ -0,0 +1,433 @@ +$row-height-comfortable: 32px; +$row-height-compact: 26px; +$indent-step: 18px; + +.tree-wrap { + margin-top: 12px; + border: 1px solid var(--bs-border-color); + background-color: var(--bs-white); + border-radius: 4px; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-white); + flex-wrap: wrap; +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + flex-wrap: wrap; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.selection-summary { + font-family: inherit; + font-size: 12px; + color: var(--bs-secondary-color); + white-space: nowrap; +} + +.selection-summary-strong { + color: var(--bs-body-color); + font-weight: 500; +} + +.selection-summary-sep { + margin: 0 6px; + color: var(--bs-tertiary-color); +} + +.tree-head { + display: grid; + grid-template-columns: 28px 1fr 110px 130px 80px 40px; + gap: 8px; + padding: 8px 14px; + border-bottom: 1px solid var(--bs-border-color); + background-color: var(--bs-light, #f8f9fa); + font-family: inherit; + font-size: 11px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + +.tree-head-size, +.tree-head-count { + text-align: right; +} + +.tree-head-access { + text-align: left; +} + +.tree-viewport { + position: relative; + overflow: auto; + height: 540px; + background: var(--bs-white); +} + +.tree-viewport::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.tree-viewport::-webkit-scrollbar-thumb { + background: var(--bs-border-color); + border-radius: 999px; + border: 2px solid var(--bs-white); +} + +.tree-spacer { + position: relative; +} + +.row { + position: absolute; + left: 0; + right: 0; + display: grid; + grid-template-columns: 28px 1fr 110px 130px 80px 40px; + gap: 8px; + align-items: center; + height: $row-height-comfortable; + padding: 0 14px; + font-size: 13px; + border-bottom: 1px solid transparent; + user-select: none; + cursor: default; + background-color: var(--bs-white); +} + +.row-select { + display: flex; + align-items: center; + justify-content: center; + + // The dedicated column gives the checkbox visual weight and signals + // that selection is a first-class action. The narrow width keeps the + // name column long; padding-right balances the gap with the twisty. +} + +.row:hover { + 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: rgb(13 110 253 / 4%); + z-index: 1; +} + +.row-selected, +.row-selected:hover { + background-color: rgb(13 110 253 / 8%); +} + +.row-selected:focus-visible { + background-color: rgb(13 110 253 / 12%); +} + +.row-name { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.row-twisty { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); + border: none; + background: transparent; + cursor: pointer; + border-radius: 2px; + flex: 0 0 auto; + padding: 0; +} + +.row-twisty:hover { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-body-color); +} + +.row-twisty:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.row-twisty-empty { + visibility: hidden; +} + +.row-twisty svg { + transition: transform 120ms ease; +} + +.row-twisty-open svg { + transform: rotate(90deg); +} + +.row-icon { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); + flex: 0 0 auto; +} + +.row-icon-folder { + color: var(--bs-primary); +} + +.row-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: inherit; + font-size: 12.5px; + color: var(--bs-body-color); +} + +.row-label-folder { + font-weight: 500; +} + +.row-label a { + color: var(--bs-primary); + text-decoration: none; +} + +.row-label a:hover { + color: var(--bs-link-hover-color); + text-decoration: underline; +} + +.row-size, +.row-count { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); + text-align: right; +} + +.row-access { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-access-restricted { + color: var(--bs-warning-text-emphasis, #997404); +} + +.row-access-embargoed { + color: var(--bs-info-text-emphasis, #087990); +} + +.row-access-retention-expired { + color: var(--bs-danger-text-emphasis, #b02a37); +} + +.row-actions { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.icon-btn { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--bs-secondary-color); + cursor: pointer; + border-radius: 2px; + padding: 0; +} + +.download-btn-icon { + margin-right: 0.3rem; + margin-bottom: 0.1rem; +} + +.icon-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.icon-btn:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} + +.icon-btn:hover:not(:disabled) { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-primary); +} + +.checkbox { + width: 16px; + height: 16px; + + // Hardcoded fallbacks because the standalone bundle is rendered into + // a Shadow DOM hosted by JSF pages that don't always define the full + // Bootstrap-5 --bs-* custom property set. Without fallbacks the + // border resolves to `1px solid` (invalid → invisible) and the + // checked-state background falls back to transparent — the user sees + // a white-on-white checkbox with no border. + border: 1px solid var(--bs-border-color, #c4c8cc); + border-radius: 2px; + background-color: var(--bs-white, #fff); + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + cursor: pointer; + position: relative; + transition: background-color 80ms ease, border-color 80ms ease; +} + +.checkbox:hover { + border-color: var(--bs-primary, #0d6efd); +} + +.checkbox:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 1px; +} + +.checkbox-checked, +.checkbox-indeterminate { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); +} + +.checkbox-checked::after { + content: ''; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg) translate(-0.5px, -1px); +} + +.checkbox-indeterminate::after { + content: ''; + width: 8px; + height: 0; + border-top: 1.5px solid #fff; +} + +.tree-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--bs-secondary-color); + font-family: inherit; + font-size: 13px; + flex-direction: column; + gap: 10px; + padding: 40px; + text-align: center; +} + +.tree-state-glyph { + width: 40px; + height: 40px; + border: 1px dashed var(--bs-border-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--bs-secondary-color); +} + +.tree-state-detail { + color: var(--bs-tertiary-color); + font-size: 11.5px; +} + +.row-loading, +.row-error { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 14px; + height: $row-height-comfortable; + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color); +} + +.row-error { + color: var(--bs-danger); +} + +.row-load-more { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 0 14px; + height: $row-height-comfortable; +} + +.btn-link-inline { + background: transparent; + border: none; + color: var(--bs-primary); + font-size: 12px; + padding: 0; + cursor: pointer; + font-family: inherit; +} + +.btn-link-inline:hover { + text-decoration: underline; +} + +.btn-link-inline:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx new file mode 100644 index 000000000..c5673e48c --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTree.tsx @@ -0,0 +1,795 @@ +import { + CSSProperties, + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { Button } from '@iqss/dataverse-design-system' +import { useTranslation } from 'react-i18next' +import { toast } from 'react-toastify' +import { FileTreeRepository } from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeInclude, FileTreeOrder } from '@/files/domain/models/FileTreePage' +import { + FileTreeFile, + FileTreeFolder, + isFileTreeFile, + isFileTreeFolder +} from '@/files/domain/models/FileTreeItem' +import { DatasetVersion } from '@/dataset/domain/models/Dataset' +import { useFileTree } from './useFileTree' +import { SelectionState, 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' + +interface FilesTreeProps { + treeRepository: FileTreeRepository + datasetPersistentId: string + datasetVersion: DatasetVersion + pageSize?: number + query?: string + 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 + /** + * 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 + /** + * When `true`, every download action in the tree (per-row download + * icon, toolbar download button) is disabled. The host should set + * this on datasets where a guestbook, custom terms, or non-default + * license requires user acknowledgement before downloading — the + * acknowledgement modal lives on the table-view side and is not + * yet wired into the tree. See the SPA caller in `DatasetFiles.tsx`. + * Standalone JSF mounts default to `false`; the JSF page is + * responsible for its own gating in that flow. + */ + downloadsDisabled?: boolean + /** + * Extra `fetch` options for the raw per-file download requests made + * by the streaming-zip engine. The SDK's listing calls carry their + * own auth (session cookie or bearer token via `ApiConfig`), but the + * zip engine fetches `downloadUrl` directly — a bearer-token embed + * must pass its `Authorization` header here or restricted/draft file + * downloads 401 while the listing works. A getter (not a value) so a + * rotating token is read fresh at the start of each download run. + */ + downloadFetchInit?: () => RequestInit | undefined +} + +const DEFAULT_ROW_HEIGHT = 32 +const DEFAULT_FALLBACK_HEIGHT = 540 +const OVERSCAN = 6 + +export function FilesTree({ + treeRepository, + datasetPersistentId, + datasetVersion, + pageSize = 200, + query, + order = FileTreeOrder.NAME_AZ, + rowHeight = DEFAULT_ROW_HEIGHT, + fallbackHeight = DEFAULT_FALLBACK_HEIGHT, + initialPath = '', + onCurrentPathChange, + buildFileMetadataUrl, + downloadsDisabled = false, + downloadFetchInit +}: FilesTreeProps) { + const { t } = useTranslation('files') + const tree = useFileTree({ + repository: treeRepository, + datasetPersistentId, + datasetVersion, + pageSize, + order, + 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 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 onDownloadFiles = useCallback( + (files: FileTreeFile[]) => { + /* istanbul ignore if */ + if (files.length === 0) { + return + } + // Single source of truth: every download — one file or many — is + // assembled into a zip via the chunked streaming engine. Means a + // single big file gets the per-part Range / retry resilience, the + // tray gives consistent progress UX, and the button label + // ("Download zip") never lies about what the user is going to + // get. The historical "anchor-click for one file" bypass was + // removed because it lost the per-part resume on large files and + // forked the UX. + const zipName = `${datasetPersistentId.replace(/[^a-zA-Z0-9._-]+/g, '_')}-files.zip` + streamingZip.start({ files, zipName, fetchInit: downloadFetchInit?.() }) + setTrayOpen(true) + }, + [datasetPersistentId, streamingZip, downloadFetchInit] + ) + + const download = useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onDownloadFiles, + 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(() => { + /* istanbul ignore if */ + if (typeof window === 'undefined') { + return + } + const el = containerRef.current + /* istanbul ignore if */ + if (!el) { + return + } + const update = () => { + const measured = el.clientHeight + /* istanbul ignore else */ + if (measured > 0) { + setViewportH(measured) + } + } + update() + /* istanbul ignore if */ + 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) + } + + // 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. A folder in the error state + // is excluded: its cursor survives the failure, so without the guard the + // error write itself re-triggers this effect and the fetch loops with no + // backoff — after a failure, loading more is the retry button's job. + // A "loading" row for a node that has no entry yet is a folder the + // filter query force-opened without the user ever expanding it — nothing + // else fetches those, so service them here. + useEffect(() => { + for (const row of slice) { + if (row.kind === 'load-more') { + const node = tree.nodes.get(row.path) + if (node?.nextCursor && !node.loading && !node.error) { + void tree.loadMore(row.path) + } + } else if (row.kind === 'loading' && !tree.nodes.get(row.path)) { + void tree.ensureLoaded(row.path) + } + } + // Depending on `slice` alone is deliberate: tree.nodes/loadMore get new + // identities on every page write, and re-running on those would fire + // duplicate loadMore calls for rows the guard has already dispatched. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slice]) + + 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] + ) + + // ---------- 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 + /* istanbul ignore if */ + if (!el) return + const top = focusedRowIndex * rowHeight + const bottom = top + rowHeight + /* istanbul ignore next */ + if (top < el.scrollTop) { + el.scrollTop = top + } else if (bottom > el.scrollTop + el.clientHeight) { + el.scrollTop = bottom - el.clientHeight + } + }, [focusedRowIndex, rowHeight]) + + // Move DOM focus to follow the roving tabindex. The state-only update + // above shifts which row carries `tabIndex=0`, but the browser's focus + // does not move on its own — without this, the user sees the + // `:focus-visible` ring stay on whichever row Tab originally landed + // on, even after pressing ArrowDown / ArrowUp. Only follow focus + // when the tree already owns it; otherwise we'd grab focus on + // initial mount and steal it from the rest of the page. + // + // We resolve the active element via `getRootNode()` rather than + // `document.activeElement` so the check works inside a Shadow DOM + // mount (the JSF standalone bundle): `document.activeElement` from + // outside the shadow returns the shadow host, not the focused + // descendant inside it, so a host-page-level check would always + // report the tree as not-owning-focus and the ring would never + // follow arrow keys in JSF. + useEffect(() => { + if (focusedRowIndex < 0) return + const el = containerRef.current + /* istanbul ignore if */ + if (!el) return + const root = el.getRootNode() as Document | ShadowRoot + if (!el.contains(root.activeElement)) return + const target = el.querySelector('[role="treeitem"][tabindex="0"]') + target?.focus() + }, [focusedRowIndex]) + + const moveFocus = useCallback( + (delta: number | 'first' | 'last') => { + /* istanbul ignore if */ + 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(() => { + /* istanbul ignore if */ + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + /* istanbul ignore if */ + 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) => { + /* istanbul ignore if */ + if (focusedRowIndex < 0) return + const focusedRow = visibleRows[focusedRowIndex] + /* istanbul ignore if */ + 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 + ] + ) + + // Header select-all state: a plain per-render computation (cheap, no + // hook), so it is safe above the early returns without affecting the + // hook count when the component swaps between loading / error / + // empty / loaded states. + const headerSelectAllState: SelectionState = (() => { + const totalCount = selection.totals.count + const hasFolders = selection.totals.hasLogicalFolders + const topLevel = tree.rootNode.items + if (totalCount === 0 && !hasFolders) { + return 'none' + } + if (topLevel.length === 0) { + return 'partial' + } + const everyTopSelected = topLevel.every((item) => + isFileTreeFile(item) + ? selection.fileState(item) === 'all' + : selection.folderState(item, tree.visibleKnownChildren(item.path)) === 'all' + ) + return everyTopSelected ? 'all' : 'partial' + })() + const onToggleSelectAll = useCallback(() => { + selection.toggleAll(tree.rootNode.items) + }, [selection, tree.rootNode.items]) + + 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')}
+
+
+
+ ) + } + + const streamingZipActive = !['idle', 'done', 'error', 'cancelled'].includes( + streamingZip.state.status + ) + + 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={ + // Also gated on an active zip run: the engine is a + // single instance, and a second start() would race + // the first (shared cancelledRef/decisionRef, two + // anchor clicks) — same condition the toolbar uses. + downloadsDisabled || + streamingZipActive || + download.progress.status === 'enumerating' || + download.progress.status === 'requesting' + ? undefined + : () => handleDownloadOne(item) + } + datasetVersionNumber={datasetVersion.number} + buildFileMetadataUrl={buildFileMetadataUrl} + focused={absoluteIndex === focusedRowIndex} + onFocus={() => setFocusedRowIndex(absoluteIndex)} + onRowKeyDown={onRowKeyDown} + /> + ) + })} +
+
+ { + setTrayOpen(false) + streamingZip.close() + }} + /> +
+ ) +} + +interface FilesTreeToolbarProps { + selection: ReturnType + download: ReturnType + disableDownload?: boolean + streamingZipActive?: boolean +} + +function FilesTreeToolbar({ + selection, + download, + disableDownload, + streamingZipActive +}: 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) { + // 14 row padding + 28 select column + 8 column gap + depth indent + + // 22 (twisty 18 + 4 gap) so the message text aligns with where a + // row's label would start. + const indent: CSSProperties = { + paddingLeft: 14 + 28 + 8 + depth * 18 + 22, + 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..2bdc410d8 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeCheckbox.tsx @@ -0,0 +1,45 @@ +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() + } + } + // 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 ( + + ) +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss new file mode 100644 index 000000000..a24d8ac15 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.module.scss @@ -0,0 +1,196 @@ +.overlay { + position: fixed; + inset: 0; + background: rgb(20 18 12 / 18%); + z-index: 1080; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} + +.overlay-open { + opacity: 1; + pointer-events: auto; +} + +.tray { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%) translateY(140%); + width: min(640px, calc(100vw - 32px)); + background: var(--bs-white, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 6px; + box-shadow: 0 6px 24px rgb(20 18 12 / 18%); + z-index: 1090; + transition: transform 220ms cubic-bezier(0.2, 0.7, 0.2, 1); + overflow: hidden; +} + +.tray-open { + transform: translateX(-50%) translateY(0); +} + +.tray-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--bs-border-color, #dee2e6); +} + +.tray-title { + font-size: 14px; + font-weight: 500; + color: var(--bs-body-color, #212529); +} + +.tray-meta { + font-family: inherit; + font-size: 11.5px; + color: var(--bs-secondary-color, #6c757d); + margin-top: 2px; +} + +// Dedicated close button. Sized larger than Bootstrap's default +// `Button size="sm"` so the × is comfortably clickable in the +// JSF-embedded tray (where the host page's font-size cascade does +// not reach into the shadow DOM and the link-variant button would +// otherwise read as a tiny x). Square hit-target with a clear hover +// state. +.close-btn { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 4px; + color: var(--bs-secondary-color, #6c757d); + font-size: 22px; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.close-btn:hover { + background-color: rgb(0 0 0 / 6%); + color: var(--bs-body-color, #212529); +} + +.close-btn:focus-visible { + outline: 2px solid var(--bs-primary, #0d6efd); + outline-offset: 1px; +} + +.tray-body { + padding: 16px; +} + +.progress { + width: 100%; + height: 6px; + background: var(--bs-light, #f8f9fa); + border-radius: 999px; + overflow: hidden; + position: relative; +} + +.progress-bar { + height: 100%; + background: var(--bs-primary, #0d6efd); + width: 0%; + transition: width 200ms ease; +} + +.progress-error .progress-bar { + background: var(--bs-danger, #dc3545); +} + +.progress-done .progress-bar { + background: var(--bs-success, #198754); +} + +.tray-status { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + font-family: inherit; + font-size: 11.5px; +} + +.tray-status-now { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + margin-right: 12px; + color: var(--bs-secondary-color, #6c757d); +} + +.tray-status-pct { + font-variant-numeric: tabular-nums; + color: var(--bs-body-color, #212529); +} + +.tray-fail { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--bs-border-color, #dee2e6); + border-left: 2px solid var(--bs-warning, #ffc107); + background: rgb(255 193 7 / 6%); + border-radius: 4px; + font-size: 13px; +} + +.tray-fail-heading { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + color: var(--bs-body-color, #212529); + margin-bottom: 4px; +} + +.tray-fail-file { + font-family: inherit; + font-size: 12px; + color: var(--bs-secondary-color, #6c757d); + word-break: break-all; +} + +.tray-fail-err { + font-family: inherit; + font-size: 11px; + color: var(--bs-tertiary-color, #adb5bd); + margin-top: 2px; +} + +.tray-fail-actions { + margin-top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.tray-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--bs-border-color, #dee2e6); + background: var(--bs-light, #f8f9fa); +} + +.tray-hint { + font-family: inherit; + font-size: 11px; + color: var(--bs-tertiary-color, #adb5bd); +} diff --git a/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx new file mode 100644 index 000000000..5ea3fa6a3 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeDownloadTray.tsx @@ -0,0 +1,244 @@ +import { useTranslation } from 'react-i18next' +import { Button } from '@iqss/dataverse-design-system' +import cn from 'classnames' +import { StreamingZipApi } from './useStreamingZipDownload' +import { WarnIcon } from './icons/FilesTreeIcons' +import { formatBytes } from './format' +import styles from './FilesTreeDownloadTray.module.scss' + +interface FilesTreeDownloadTrayProps { + api: StreamingZipApi + open: boolean + onClose: () => void +} + +/** + * Bottom-sheet status panel for the streaming-zip download. Shows + * overall progress, the file currently being added, and surfaces the + * pause-on-fail / two-pass decisions as inline buttons. Stays out of + * the user's way while they continue browsing. + */ +export function FilesTreeDownloadTray({ api, open, onClose }: FilesTreeDownloadTrayProps) { + const { t } = useTranslation('files') + const { state } = api + + const isPaused = state.status === 'paused' + const isAwaitingRetry = state.status === 'awaiting-retry' + const isDone = state.status === 'done' + const isCancelled = state.status === 'cancelled' + 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 + + let title: string = t('tree.download.tray.preparing', 'Preparing zip…') + if (isPaused) title = t('tree.download.tray.paused', 'Download paused — file failed') + else if (isAwaitingRetry) + title = t('tree.download.tray.firstPass', { + defaultValue: 'First pass complete — {{count}} file(s) failed', + count: state.failedSoFar.length + }) + else if (isDone && state.failedSoFar.length === 0 && state.verificationFailures.length === 0) + title = t('tree.download.tray.complete', 'Download complete') + else if (isDone && state.failedSoFar.length > 0 && state.verificationFailures.length === 0) + title = t('tree.download.tray.completeWithSkipped', { + defaultValue: 'Download complete — {{count}} skipped', + count: state.failedSoFar.length + }) + // The verification-failure title takes precedence over the + // skipped-files variant when both happen in the same run — the + // verification miss is the surprising, actionable signal (user + // should re-download the bad file), the skips already had their + // own dialog earlier. Both still appear in `manifest.txt`. + else if (isDone && state.verificationFailures.length > 0) + title = t('tree.download.tray.completeWithVerificationFailures', { + defaultValue_one: 'Download complete — {{count}} file failed checksum verification', + defaultValue_other: 'Download complete — {{count}} files failed checksum verification', + count: state.verificationFailures.length + }) + // The 'error' status is reached only from the IIFE catch in the + // engine — itself a defensive path covered by /* istanbul ignore */ + // — so the matching branch here is unreachable from the spec. + /* istanbul ignore next */ else if (isError) + title = t('tree.download.tray.error', 'Download failed') + else if (isCancelled) title = t('tree.download.tray.cancelled', 'Download cancelled') + else if (state.filesDone > 0 || state.bytesDone > 0) + title = t('tree.download.tray.streaming', 'Streaming files into zip…') + + 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}` + : '…' + + return ( + <> +
{ + if (isDone || isCancelled || isError) onClose() + }} + aria-hidden + /> +
+
+
+
{title}
+
+ {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')}} +
+
+ +
+ +
+
+
+
+
+
{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.' + )} +
+
+ )} + + {/* istanbul ignore next */} + {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/FilesTreeHeader.tsx b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx new file mode 100644 index 000000000..ef7b64461 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeHeader.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' +import { FilesTreeCheckbox } from './FilesTreeCheckbox' +import { SelectionState } from './useFileTreeSelection' +import styles from './FilesTree.module.scss' + +interface FilesTreeHeaderProps { + /** Aggregate selection state for the visible tree, controls the + * header's select-all checkbox visual. Omit to hide the checkbox. */ + selectAllState?: SelectionState + onToggleSelectAll?: () => void +} + +/** + * Visual sticky-labels strip above the tree viewport. The accessible + * tree pattern lives on the rows themselves (`role="tree"` / + * `role="treeitem"` further down). The header itself is decorative + * (`aria-hidden`); the only interactive element is the optional + * select-all checkbox in the dedicated select column. + */ +export function FilesTreeHeader({ selectAllState, onToggleSelectAll }: FilesTreeHeaderProps) { + const { t } = useTranslation('files') + return ( +
+
+ {selectAllState && onToggleSelectAll ? ( + + ) : null} +
+
{t('tree.head.name', 'Name')}
+
+ {t('tree.head.size', 'Size')} +
+
+ {t('tree.head.access', 'Access')} +
+
+ {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..0c0e9c8a7 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/FilesTreeRow.tsx @@ -0,0 +1,253 @@ +import { CSSProperties, KeyboardEvent, MouseEvent, RefObject } 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 { + AccessVariant, + fileAccessVariant, + folderAccessParts, + folderAccessVariant, + formatBytes, + formatCount +} from './format' + +interface FilesTreeRowProps { + depth: number + top: number + height: number + item: FileTreeFile | FileTreeFolder + selectionState: SelectionState + expanded?: boolean + onToggleSelection: () => void + onToggleExpansion?: () => void + /** + * Per-row download trigger. When omitted, the download icon is + * hidden — used by hosts that need to gate downloads behind a + * terms-of-use modal that the row itself cannot open. + */ + 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 + /** + * 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 +const INDENT_PER_LEVEL = 18 + +const ACCESS_VARIANT_CLASS: Record = { + restricted: 'row-access-restricted', + embargoed: 'row-access-embargoed', + retentionExpired: 'row-access-retention-expired' +} + +// Inline i18n defaults, following the `t(key, default)` pattern the +// header cells use, so the cell renders sensibly even before the +// locale bundle loads. +const FILE_ACCESS_LABEL_DEFAULTS = { + public: 'Public', + restricted: 'Restricted', + embargoed: 'Embargoed', + retentionExpired: 'Retention expired' +} as const + +const FOLDER_ACCESS_WORD_DEFAULTS: Record = { + restricted: 'restricted', + embargoed: 'embargoed', + retentionExpired: 'retention expired' +} + +export function FilesTreeRow({ + depth, + top, + height, + item, + selectionState, + expanded, + onToggleSelection, + onToggleExpansion, + onDownload, + datasetVersionNumber, + buildFileMetadataUrl, + focused, + onFocus, + rowRef, + onRowKeyDown +}: FilesTreeRowProps) { + const { t } = useTranslation('files') + const isFile = isFileTreeFile(item) + const accessVariant = isFile + ? fileAccessVariant(item.accessStatus) + : folderAccessVariant(item.counts) + 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 ? formatBytes(item.size) : formatBytes(item.counts?.bytes)} +
+
+ {isFile + ? item.accessStatus + ? t(`tree.access.${item.accessStatus}`, FILE_ACCESS_LABEL_DEFAULTS[item.accessStatus]) + : '' + : folderAccessParts(item.counts) + .map((part) => + t(`tree.access.count.${part.key}`, { + count: part.count, + defaultValue: `{{count}} ${FOLDER_ACCESS_WORD_DEFAULTS[part.key]}` + }) + ) + .join(' · ')} +
+
+ {!isFile && item.counts ? formatCount(item.counts.files) : ''} +
+
+ {onDownload ? ( + + ) : null} +
+
+ ) +} 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..c479ad66e --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/format.ts @@ -0,0 +1,74 @@ +import { FileSize, FileSizeUnit } from '@/files/domain/models/FileMetadata' +import { FileAccessStatus } from '@/files/domain/models/FileTreeItem' + +export function formatBytes(input: number | undefined): string { + if (input === undefined || input === null || Number.isNaN(input)) { + return '' + } + // Delegate to the domain FileSize so the tree shows the same unit + // ladder (B…PB) and rounding as the files table — a 2 TB folder must + // not render as "2048.00 GB" here while the table says "2 TB". + return new FileSize(input, FileSizeUnit.BYTES).toString() +} + +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` +} + +/** + * The non-public access states, i.e. everything the access column + * should visually flag. Single source for the display precedence used + * by both file and folder rows; labels come from the `tree.access.*` + * i18n keys so the column is translatable like every other tree cell. + */ +export type AccessVariant = Exclude + +/** + * Colour-cue variant for a file row: the access state itself, or + * `undefined` for public/unknown (no cue — public is the default and + * would be column noise). + */ +export function fileAccessVariant(access: FileAccessStatus | undefined): AccessVariant | undefined { + return access === undefined || access === 'public' ? undefined : access +} + +/** + * Colour-cue variant for a folder row: the strongest state present in + * the subtree, following the server's per-file resolution order — + * retention-expired wins (those files cannot be served at all), then + * restricted, then embargoed. The label lists every non-empty bucket + * (see `folderAccessParts`); the colour flags only the strongest. + */ +export function folderAccessVariant( + counts: { restricted?: number; embargoed?: number; retentionExpired?: number } | undefined +): AccessVariant | undefined { + if ((counts?.retentionExpired ?? 0) > 0) return 'retentionExpired' + if ((counts?.restricted ?? 0) > 0) return 'restricted' + if ((counts?.embargoed ?? 0) > 0) return 'embargoed' + return undefined +} + +/** + * The non-empty access buckets of a folder's subtree, in display order + * (reading order, not severity — the cell text is a complete summary, + * the colour carries the severity). The caller renders each part via + * the plural-aware `tree.access.count.` i18n keys and joins them. + */ +export function folderAccessParts( + counts: { restricted?: number; embargoed?: number; retentionExpired?: number } | undefined +): { key: AccessVariant; count: number }[] { + const parts: { key: AccessVariant; count: number }[] = [] + const restricted = counts?.restricted ?? 0 + const embargoed = counts?.embargoed ?? 0 + const retentionExpired = counts?.retentionExpired ?? 0 + if (restricted > 0) parts.push({ key: 'restricted', count: restricted }) + if (embargoed > 0) parts.push({ key: 'embargoed', count: embargoed }) + if (retentionExpired > 0) parts.push({ key: 'retentionExpired', count: retentionExpired }) + return parts +} 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..4b5e6bf6e --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTree.ts @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + FileTreeRepository, + GetFileTreeNodeParams +} from '@/files/domain/repositories/FileTreeRepository' +import { FileTreeItem } 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 + /** + * 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 + loadMore: (path: string) => Promise + refresh: (path?: string) => Promise + /** + * Fetch a folder's first page if it has never loaded (or errored), + * deduplicating concurrent calls. Exposed so the host can service + * folders that became visible without an explicit expand — e.g. a + * filter query force-opening a never-fetched folder. + */ + ensureLoaded: (path: string) => Promise + visibleKnownChildren: (path: string) => FileTreeItem[] +} + +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, + initialPath = '' +}: UseFileTreeArgs): UseFileTreeApi { + const [nodes, setNodes] = useState>(() => new Map()) + const initialExpanded = (() => { + const set = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + set.add(ancestor) + } + return set + })() + const [expanded, setExpanded] = useState>(() => initialExpanded) + const inFlight = useRef>>(new Map()) + const versionKey = `${datasetPersistentId}::${datasetVersion.number.toString()}::${order}::${include}` + const previousKey = useRef(versionKey) + // True only while the hook's host component is mounted. Set to false + // by the cleanup effect below so any fetch promise that resolves AFTER + // unmount becomes a no-op instead of pushing state into a defunct + // hook instance. Without this guard, switching rapidly between the + // tree view and the table view occasionally left the next mount + // showing the "loading" spinner forever, because a slow getNode that + // started under the previous mount completed against the wrong + // setState closure. + const mountedRef = useRef(true) + // Incremented on every versionKey reset. A fetch captures the value at + // start and discards its response if the tree was reset while it was in + // flight — without this, a slow response from the PREVIOUS version / + // order / include lands in the fresh map with `loaded: true`, and + // ensureLoaded then pins the stale items until a manual refresh. + const generationRef = useRef(0) + + const setNode = useCallback((path: string, updater: (prev: FolderNode) => FolderNode) => { + if (!mountedRef.current) return + 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 + } + const generation = generationRef.current + setNode(path, (prev) => ({ ...prev, loading: true, error: undefined })) + try { + const page = await repository.getNode(params) + if (generation !== generationRef.current) return + setNode(path, (prev) => ({ + ...prev, + items: cursor ? [...prev.items, ...page.items] : page.items, + nextCursor: page.nextCursor, + loading: false, + loaded: true, + error: undefined + })) + } catch (error) { + if (generation !== generationRef.current) return + 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 + generationRef.current++ + setNodes(new Map()) + const reset = new Set([ROOT]) + for (const ancestor of ancestorChain(initialPath)) { + reset.add(ancestor) + } + setExpanded(reset) + inFlight.current.clear() + // Reset path: bypass ensureLoaded's cache check (which closes + // over the pre-reset `nodes` map and would short-circuit because + // the old root was `loaded: true`). fetchPage runs unconditionally + // and uses the latest fetchPage closure (which is keyed off the + // new versionKey via its useCallback deps). + void fetchPage(ROOT) + for (const ancestor of ancestorChain(initialPath)) { + void fetchPage(ancestor) + } + return + } + 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) + } + // Deliberately keyed on versionKey ALONE: fetchPage/ensureLoaded get + // new identities on every nodes write, and re-running this effect on + // those would refetch the root after every page load. initialPath is + // mount-stable (read once from the URL). The reset branch above + // handles every input that genuinely changes the tree's identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [versionKey]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + 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) + // Also drop every descendant from the expanded set. Without this, + // collapsing `data` after the user opened `data/sub` leaves + // `data/sub` in the set; `currentPath` (deepest expanded) still + // reports `data/sub`, so the URL bookmark and a subsequent reload + // re-open the very branch the user just collapsed. + const prefix = path === '' ? '' : `${path}/` + for (const p of Array.from(next)) { + if (p !== path && (path === '' ? p !== '' : p.startsWith(prefix))) { + next.delete(p) + } + } + 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 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, + currentPath: deepestExpanded(expanded), + toggleExpanded, + expand, + collapse, + loadMore, + refresh, + ensureLoaded, + 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..efcfec28e --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeDownload.ts @@ -0,0 +1,184 @@ +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 a list of + * files (e.g. direct anchor click for one file, streaming zip for + * many). The hook only owns the enumeration step. + */ + onDownloadFiles: (files: FileTreeFile[]) => Promise | void +} + +export interface UseFileTreeDownloadApi { + progress: DownloadProgress + downloadSelection: () => Promise + downloadNode: (node: FileTreeFile | FileTreeFolder) => Promise + reset: () => void +} + +export function useFileTreeDownload({ + treeRepository, + datasetPersistentId, + datasetVersion, + selection, + onError, + onDownloadFiles +}: UseFileTreeDownloadArgs): UseFileTreeDownloadApi { + const [progress, setProgress] = useState({ + status: 'idle', + enumeratedCount: 0 + }) + + const reset = useCallback(() => { + setProgress({ status: 'idle', enumeratedCount: 0 }) + }, []) + + const dispatchFiles = useCallback( + async (files: FileTreeFile[]) => { + if (files.length === 0) { + // No files to dispatch (empty folder, or every selected file + // got filtered out by the deselect overrides). Reset progress + // back to idle so the toolbar doesn't get stuck showing + // `enumerating` / `requesting` indefinitely. + setProgress({ status: 'idle', enumeratedCount: 0 }) + return + } + setProgress({ status: 'requesting', enumeratedCount: files.length }) + try { + await onDownloadFiles(files) + setProgress({ status: 'success', enumeratedCount: files.length }) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: files.length, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [onDownloadFiles, onError] + ) + + const collectExplicitFiles = useCallback((): FileTreeFile[] => { + const out: FileTreeFile[] = [] + for (const path of selection.selectedFilePaths) { + const file = selection.filesByPath.get(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 dispatchFiles(merged) + }, [ + collectExplicitFiles, + datasetPersistentId, + datasetVersion, + dispatchFiles, + onError, + selection.deselectedFilePaths, + selection.selectedFolderPaths, + treeRepository + ]) + + const downloadNode = useCallback( + async (node: FileTreeFile | FileTreeFolder) => { + if (isFileTreeFile(node)) { + await dispatchFiles([node]) + return + } + setProgress({ status: 'enumerating', enumeratedCount: 0 }) + try { + const files = await enumerateFileTreeFiles(treeRepository, { + datasetPersistentId, + datasetVersion, + paths: [node.path] + }) + await dispatchFiles(files) + } catch (error) { + setProgress({ + status: 'error', + enumeratedCount: 0, + message: error instanceof Error ? error.message : String(error) + }) + onError?.(error) + } + }, + [datasetPersistentId, datasetVersion, dispatchFiles, onError, treeRepository] + ) + + return { progress, downloadSelection, downloadNode, reset } +} + +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..9feaaa1ab --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useFileTreeSelection.ts @@ -0,0 +1,330 @@ +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 + /** + * Header "select-all" action: if anything is currently selected, + * clears the selection; otherwise marks every supplied top-level + * item as selected (files go into selectedFilePaths, folders into + * selectedFolderPaths). Tree depth below the supplied items is + * implicitly covered by ancestor-selected logic, matching the + * row-checkbox semantics. + */ + toggleAll: (topLevelItems: FileTreeItem[]) => void + /** + * Registry of every file the tree has rendered, keyed by path — the + * key every consumer actually looks up by (selection sets store + * paths). Registered by the host as rows become visible. + */ + filesByPath: 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 [filesByPath] = useState>(() => new Map()) + + const registerFile = useCallback( + (file: FileTreeFile) => { + filesByPath.set(file.path, file) + }, + [filesByPath] + ) + + 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) && 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)) + + // 'all' only when every visited file is selected AND no nested + // folder selection covers unvisited paths we cannot vouch for. + return allFilesSelected && !nestedFolderSelected ? 'all' : 'partial' + }, + [deselectedFilePaths, isFileLogicallySelected, selectedFolderPaths] + ) + + const toggleFile = useCallback( + (file: FileTreeFile) => { + filesByPath.set(file.path, 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, filesByPath, 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) + // Defensive sweeps: both mutation sites (the select-all fold below + // and toggleAll's top-level-only writes) maintain the invariant + // that the selected sets never contain an ancestor together with + // its descendants, so these loops cannot fire through the public + // API — they only guard future mutation paths. + for (const other of Array.from(nextFolders)) { + /* istanbul ignore if */ + if (isStrictlyUnder(other, folder.path)) { + nextFolders.delete(other) + } + } + for (const path of Array.from(nextFiles)) { + /* istanbul ignore if */ + 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 toggleAll = useCallback( + (topLevelItems: FileTreeItem[]) => { + const anySelected = selectedFilePaths.size > 0 || selectedFolderPaths.size > 0 + if (anySelected) { + setSelectedFilePaths(new Set()) + setSelectedFolderPaths(new Set()) + setDeselectedFilePaths(new Set()) + return + } + const nextFiles = new Set() + const nextFolders = new Set() + for (const item of topLevelItems) { + if (isFileTreeFile(item)) { + filesByPath.set(item.path, item) + nextFiles.add(item.path) + } else { + nextFolders.add(item.path) + } + } + setSelectedFilePaths(nextFiles) + setSelectedFolderPaths(nextFolders) + setDeselectedFilePaths(new Set()) + }, + [filesByPath, selectedFilePaths, selectedFolderPaths] + ) + + const totals = useMemo(() => { + let count = 0 + let bytes = 0 + for (const path of selectedFilePaths) { + const file = filesByPath.get(path) + count += 1 + if (file) { + bytes += file.size + } + } + return { + count, + bytes, + hasLogicalFolders: selectedFolderPaths.size > 0 + } + }, [filesByPath, selectedFilePaths, selectedFolderPaths.size]) + + return { + selectedFilePaths, + selectedFolderPaths, + deselectedFilePaths, + totals, + fileState, + folderState, + toggleFile, + toggleFolder, + clear, + toggleAll, + filesByPath, + 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 +} 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..89443af5f --- /dev/null +++ b/src/sections/dataset/dataset-files/files-tree/useStreamingZipDownload.ts @@ -0,0 +1,955 @@ +import { useCallback, useRef, useState } from 'react' +import { downloadZip } from 'client-zip' +import { md5 } from 'js-md5' +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 +} + +/** + * Per-file checksum-verification miss. The file's bytes ARE in the zip + * (you can't unwrite a stream after client-zip has consumed it), but + * its computed digest doesn't match what the tree response advertised. + * Surfaced in the tray so users know which files to re-download and + * appended to `manifest.txt` for the `skip` strategy. + */ +export interface StreamingZipVerificationFailure { + path: string + name: string + size: number + algorithm: string + expected: string + actual: string +} + +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[] + /** + * Files whose bytes flowed into the zip successfully but whose + * computed digest didn't match the tree response's advertised + * `checksum`. Empty when no verification was attempted (older server) + * or all files verified clean. + */ + verificationFailures: StreamingZipVerificationFailure[] + 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: 'same-origin'` because the file-download URL typically + * 302s to S3 (or an S3-compatible store) which returns + * `Access-Control-Allow-Origin: *`, and browsers refuse `*` + credentials. + * `same-origin` carries the Dataverse session cookie on the Dataverse + * hop and drops it on the cross-origin S3 hop, which is what we want. + */ + fetchInit?: RequestInit + /** + * Files larger than this are fetched as a sequence of HTTP `Range` + * requests instead of one full GET. Each part is retried on its own + * (`partRetries`), so a transient TCP drop mid-file no longer aborts + * the whole download. Default `10 * 1024 * 1024` (10 MiB). + */ + partSize?: number + /** Per-part retry budget for chunked fetches. Default `3`. */ + partRetries?: number + /** Backoff between part retries, in ms. Default `500`. */ + partRetryDelayMs?: number +} + +const DEFAULT_PART_SIZE_BYTES = 10 * 1024 * 1024 +const DEFAULT_PART_RETRIES = 3 +const DEFAULT_PART_RETRY_DELAY_MS = 500 + +export interface StreamingZipApi { + state: StreamingZipState + start: (args: StartStreamingZipArgs) => void + 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 + /** + * At the `awaiting-retry` gate: finish the zip WITHOUT a second pass. + * The recoverable failures are demoted to skipped, listed in + * manifest.txt, and the first-pass bytes are saved — the affirmative + * counterpart to `retryFailed`, so "Done" never silently discards a + * fully-streamed first pass. + */ + finalizeRun: () => void + cancel: () => void + close: () => void +} + +const initialState: StreamingZipState = { + status: 'idle', + totalFiles: 0, + filesDone: 0, + totalBytes: 0, + bytesDone: 0, + failedSoFar: [], + verificationFailures: [], + 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 inside the custom `ReadableStream` that + * `buildChunkedStream` returns: it tallies bytes as `client-zip` pulls + * them, and lazily fetches additional Range parts when the file is + * larger than `partSize`. + */ +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' + | 'defer-to-end' + | 'retry-failed' + | 'finalize' + | '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(() => { + const status = stateRef.current.status + if ( + status === 'preparing' || + status === 'running' || + status === 'paused' || + status === 'awaiting-retry' + ) { + // Closing the tray mid-run means cancel: stop the engine and + // unblock any pending pause/awaiting-retry decision so the + // suspended generator can wind down instead of leaking. + cancelledRef.current = true + decisionRef.current?.resolve('cancel') + } + // Deliberately NOT resetting cancelledRef here — the pending + // blob promise of a cancelled run may still settle after close, + // and the pre-save guard must keep seeing the cancellation. + // start() resets the flag for the next run. + 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 + /* istanbul ignore if */ + 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 deferCurrentToEnd = useCallback(() => sendDecision('defer-to-end'), [sendDecision]) + const retryFailed = useCallback(() => sendDecision('retry-failed'), [sendDecision]) + const finalizeRun = useCallback(() => sendDecision('finalize'), [sendDecision]) + + const start = useCallback( + (args: StartStreamingZipArgs) => { + const { + files, + zipName = 'dataset.zip', + strategy: initialStrategy = 'pause', + buildFetchUrl = (f) => f.downloadUrl, + fetchInit, + partSize = DEFAULT_PART_SIZE_BYTES, + partRetries = DEFAULT_PART_RETRIES, + partRetryDelayMs = DEFAULT_PART_RETRY_DELAY_MS + } = args + /* istanbul ignore if */ + 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() { + // ----- helper: process a queue ---------------------------------- + const processQueue = async function* () { + while (queue.length > 0) { + // Loop-top cancel guard. The pause-decision path also + // resolves with 'cancel' and aborts, which the spec covers; + // this is the additional check for cancellation that lands + // *between* files in a fast-scrolling run. + /* istanbul ignore next */ + 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 } + })) + + const url = buildFetchUrl(file) + // Files larger than `partSize` are fetched as a sequence of + // Range requests so a transient drop mid-file only invalidates + // the active part instead of the whole file. The first part + // still goes through the existing retry/skip/defer dialog on + // hard failure (the body hasn't started streaming into the + // zip yet); subsequent parts can only retry inline because + // client-zip is already consuming the stream. + const useRange = file.size > partSize + const firstPartRange = useRange + ? `bytes=0-${Math.min(partSize, file.size) - 1}` + : undefined + + let response: Response + try { + response = await fetchWithRetries({ + url, + rangeHeader: firstPartRange, + fetchInit, + retries: partRetries, + delayMs: partRetryDelayMs + }) + } catch (err) { + const failure: StreamingZipFailure = { + path: file.path, + name: file.name, + size: file.size, + /* istanbul ignore next */ + 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 === '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' || decision === 'skip-all') { + // Both skip variants demote the just-added failure to + // non-recoverable. Without this, a later 'defer-to-end' + // decision on a different file would re-queue the + // already-skipped file in its second pass (see the + // recoverable filter in the awaiting-retry path). + if (decision === 'skip-all') { + strategy = 'skip' + } + update((prev) => { + const last = prev.failedSoFar[prev.failedSoFar.length - 1] + /* istanbul ignore if */ + if (!last) return { ...prev, status: 'running' } + return { + ...prev, + failedSoFar: [ + ...prev.failedSoFar.slice(0, -1), + { ...last, recoverable: false } + ], + status: 'running' + } + }) + } + // 'skip' or 'skip-all' fall 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 + } + + // `response.body` is null only for `Response.error()` and a + // handful of legacy fetch implementations; modern browsers + // always populate it on a successful HTTP response. Kept as + // a safety net; not exercised by the test harness. + /* istanbul ignore next */ + if (!response.body) { + update((prev) => ({ + ...prev, + filesDone: prev.filesDone + 1, + bytesDone: prev.bytesDone + file.size + })) + continue + } + const stream = buildChunkedStream({ + file, + initialResponse: response, + originalUrl: url, + partSize, + partRetries, + partRetryDelayMs, + fetchInit, + onProgress: (delta) => + update((prev) => ({ ...prev, bytesDone: prev.bytesDone + delta })), + onVerificationFailure: (failure) => + update((prev) => ({ + ...prev, + verificationFailures: [...prev.verificationFailures, failure] + })), + cancelled: () => cancelledRef.current + }) + yield { + name: file.path, + input: stream, + lastModified: new Date() + } + update((prev) => ({ ...prev, filesDone: prev.filesDone + 1 })) + } + } + + // ----- first pass -------------------------------------------------- + yield* processQueue() + // Cancel-after-first-pass guard. processQueue's own cancel + // checks short-circuit before reaching here in the cancel path. + /* istanbul ignore next */ + 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 === 'finalize') { + // Finish without a second pass: the recoverable failures + // become skips so manifest.txt keeps its contract of + // listing every omission, and the first-pass bytes save. + for (const f of stateRef.current.failedSoFar.filter((x) => x.recoverable)) { + skippedManifest.push({ ...f, recoverable: false }) + } + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.map((f) => + f.recoverable ? { ...f, recoverable: false } : f + ), + status: 'running' + })) + } else 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), + pass: 2, + status: 'running' + })) + yield* processQueue() + // Files that failed AGAIN in the second pass would otherwise + // vanish: this gate is not re-entered, so without demotion + // the run ends 'done' with the file absent from both the zip + // and manifest.txt. Demote the survivors to skipped so the + // manifest keeps its contract of listing every omission. + const survivors = stateRef.current.failedSoFar.filter((f) => f.recoverable) + if (survivors.length > 0) { + for (const f of survivors) skippedManifest.push({ ...f, recoverable: false }) + update((prev) => ({ + ...prev, + failedSoFar: prev.failedSoFar.map((f) => + f.recoverable ? { ...f, recoverable: false } : f + ) + })) + } + } + } + + // ----- manifest.txt: list any skipped or verification-failed files ---- + // Both populate the manifest; the zip is closing and this is the + // last yield, so we collect from `stateRef` (which has any + // mid-stream verification failures the per-file pull pushed + // through the update callback) plus `skippedManifest` (the + // fetch-level failures from the per-file loop). + const verifyFails = stateRef.current.verificationFailures + if (skippedManifest.length > 0 || verifyFails.length > 0) { + const lines: string[] = [] + if (skippedManifest.length > 0) { + lines.push('The following files were skipped during this zip download:') + lines.push('') + for (const f of skippedManifest) lines.push(`${f.path} — ${f.error}`) + } + if (verifyFails.length > 0) { + if (lines.length > 0) lines.push('') + lines.push('The following files were downloaded but failed checksum verification.') + lines.push('Their bytes are in this zip; re-download to confirm integrity:') + lines.push('') + for (const v of verifyFails) { + lines.push(`${v.path} — ${v.algorithm}: expected ${v.expected}, got ${v.actual}`) + } + } + 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 + } + + void (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) { + // Defensive catch for unexpected failures inside `client-zip` + // or the anchor-click. The per-file fetch failures are + // handled inline above; reaching here means something + // happened that the per-file flow can't classify. + /* istanbul ignore next */ + if (cancelledRef.current) return + /* istanbul ignore next */ + update((prev) => ({ + ...prev, + status: 'error', + message: err instanceof Error ? err.message : String(err) + })) + } + })() + }, + [update] + ) + + return { + state, + start, + retryCurrent, + skipCurrent, + skipAllFailures, + finalizeRun, + deferCurrentToEnd, + retryFailed, + cancel, + close + } +} + +/** + * Thrown by `fetchWithRetries` when the server returns a non-OK status. + * Carries the status code so callers can distinguish transient failures + * (worth retrying) from terminal ones (worth surfacing immediately). + */ +class HttpError extends Error { + constructor(public readonly status: number, statusText: string) { + super(`HTTP ${status} ${statusText}`) + this.name = 'HttpError' + } +} + +/** + * `true` for HTTP statuses that have any reasonable chance of succeeding + * on a re-attempt: 5xx server errors, plus the few 4xx codes that the + * spec defines as transient (408 Request Timeout, 425 Too Early, 429 + * Too Many Requests). Other 4xx codes (401, 403, 404, …) are user- + * facing terminal errors — retrying just wastes the budget while the + * user waits to see the failure tray. + */ +function isTransientHttpStatus(status: number): boolean { + if (status >= 500) return true + return status === 408 || status === 425 || status === 429 +} + +/** + * One fetch with up to `retries` extra attempts. A `Range` header is + * forwarded when supplied; a non-OK status is converted to an + * {@link HttpError} so the caller can react. Retry budget is skipped on + * terminal 4xx (no amount of retrying turns a 403 into a 200) — those + * errors propagate on the first attempt. Network errors and transient + * statuses retry normally. When all retries are exhausted the last + * error is re-thrown for the caller to surface in its own failure UI. + */ +async function fetchWithRetries(args: { + url: string + rangeHeader: string | undefined + fetchInit: RequestInit | undefined + retries: number + delayMs: number +}): Promise { + let lastErr: unknown = new Error('no attempt made') + for (let attempt = 0; attempt <= args.retries; attempt++) { + try { + const headers = new Headers(args.fetchInit?.headers ?? undefined) + if (args.rangeHeader !== undefined) headers.set('Range', args.rangeHeader) + const response = await fetch(args.url, { + // `same-origin` keeps cookies on the initial Dataverse call + // (same-origin in both SPA and JSF embed) but drops them when + // the browser follows a redirect to S3-style storage. With + // `download-redirect=true` the 302 target is on the bucket's + // hostname; sending credentials there would require + // `Access-Control-Allow-Credentials: true`, incompatible with + // the typical `Allow-Origin: *` rule. + credentials: 'same-origin', + ...(args.fetchInit ?? {}), + headers + }) + if (!response.ok) { + throw new HttpError(response.status, response.statusText) + } + return response + } catch (err) { + lastErr = err + // Terminal 4xx: bail before burning the rest of the budget. + if (err instanceof HttpError && !isTransientHttpStatus(err.status)) { + throw err + } + if (attempt < args.retries) { + await new Promise((resolve) => setTimeout(resolve, args.delayMs)) + } + } + } + /* istanbul ignore next */ + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)) +} + +/** + * Append a query parameter to a URL string without dragging in a URL + * parser. Inputs are well-formed URLs from the SDK / our own builders, + * so the simple "find a `?` or add one" rule is sufficient — no need + * to handle weird relative-with-fragment cases. + */ +function appendQueryParam(url: string, key: string, value: string): string { + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}` +} + +/** + * Range fetch with re-presign-on-403 fallback. When the cached + * `subsequentUrl` returns 403 (presigned URL has expired since the + * file's first chunk was retrieved), this helper transparently + * re-fetches the Dataverse access endpoint with `gbrecs=true` (so the + * download isn't double-counted in the guestbook), captures the new + * S3 redirect target, and returns the bytes for the requested range. + * Callers update their cached URL from the returned `refreshedUrl` so + * subsequent parts use the fresh presigning until *that* one expires. + * + * Anything other than 403 (network errors, 5xx, retried-out transient + * failures) propagates from the inner `fetchWithRetries` and aborts + * the chunked stream — re-presign helps for "URL expired", not for + * "access genuinely denied" or "S3 melted". + */ +/** + * Strips the `Authorization` header from `init` when `url` lives on a + * different origin than `originalUrl`. Bearer-token embeds put the + * header in `fetchInit` for the Dataverse access endpoint, but chunked + * Range parts 2..N hit the cached POST-REDIRECT presigned S3 URL — + * and S3 rejects a request that carries both query-string signing and + * an Authorization header (and the header would force a CORS + * preflight the bucket may not answer). + */ +export function initForUrl( + url: string, + originalUrl: string, + init: RequestInit | undefined +): RequestInit | undefined { + if (!init?.headers) return init + try { + if ( + new URL(url, window.location.href).origin === + new URL(originalUrl, window.location.href).origin + ) { + return init + } + } catch { + /* istanbul ignore next */ return init + } + const headers = new Headers(init.headers) + headers.delete('Authorization') + return { ...init, headers } +} + +async function fetchPartWithRefresh(args: { + cachedUrl: string + originalUrl: string + rangeHeader: string + fetchInit: RequestInit | undefined + retries: number + delayMs: number +}): Promise<{ response: Response; refreshedUrl: string | null }> { + try { + const response = await fetchWithRetries({ + url: args.cachedUrl, + rangeHeader: args.rangeHeader, + // cachedUrl is typically the post-redirect presigned S3 URL — + // never send the Dataverse bearer header cross-origin. + fetchInit: initForUrl(args.cachedUrl, args.originalUrl, args.fetchInit), + retries: args.retries, + delayMs: args.delayMs + }) + return { response, refreshedUrl: null } + } catch (err) { + if (!(err instanceof HttpError) || err.status !== 403) { + throw err + } + const refreshUrl = appendQueryParam(args.originalUrl, 'gbrecs', 'true') + const refreshed = await fetchWithRetries({ + url: refreshUrl, + rangeHeader: args.rangeHeader, + fetchInit: args.fetchInit, + retries: args.retries, + delayMs: args.delayMs + }) + // Prefer the post-redirect URL when fetch followed the 303 to S3 + // (`response.url` differs from the request URL); fall back to the + // refresh URL itself when no redirect happened (test env, or a + // non-redirected storage driver). Either keeps subsequent parts + // off the guestbook path. + const newUrl = + refreshed.url && refreshed.url !== refreshUrl + ? /* istanbul ignore next */ refreshed.url + : refreshUrl + return { response: refreshed, refreshedUrl: newUrl } + } +} + +/** + * Streaming digest accumulator. Two branches matching what + * `FileUploaderHelper` already does for upload: + * - MD5 (Dataverse default): `js-md5`'s `create()/update()/hex()` — + * true streaming, no buffer of the file's bytes. + * - SHA-1 / SHA-256 / SHA-512: `crypto.subtle.digest` is one-shot, + * so we accumulate chunks (1 MiB max per slice) and digest the + * concatenation when the file closes. Memory cost is the file + * size for SHA — fine for typical research-data sizes, the same + * trade-off the upload helper accepts. A streaming-SHA WASM + * library would close the gap if it ever bites; not in scope. + * + * `null` for unsupported / unknown algorithm names — caller treats it + * as "skip verification for this file" rather than erroring out, so a + * file whose `checksum.type` is something exotic doesn't kill the zip. + */ +function makeDigestAccumulator( + algorithm: string +): { update: (bytes: Uint8Array) => void; finalize: () => Promise } | null { + const upper = algorithm.toUpperCase() + if (upper === 'MD5') { + const hash = md5.create() + return { + update: (bytes) => hash.update(bytes), + // Wrapped in Promise.resolve to unify the return type with the + // SHA path's `subtle.digest` Promise — caller awaits in both + // cases, MD5 just resolves synchronously. + finalize: () => Promise.resolve(hash.hex()) + } + } + // Map Dataverse's stored algorithm name to the Web Crypto identifier. + const subtleAlgo = + upper === 'SHA-1' || upper === 'SHA1' + ? 'SHA-1' + : upper === 'SHA-256' || upper === 'SHA256' + ? 'SHA-256' + : upper === 'SHA-512' || upper === 'SHA512' + ? 'SHA-512' + : null + if (!subtleAlgo) return null + const chunks: Uint8Array[] = [] + return { + update: (bytes) => { + // Copy the slice — the source buffer may be reused by the + // fetch reader between ticks. + chunks.push(new Uint8Array(bytes)) + }, + finalize: async () => { + const total = chunks.reduce((s, c) => s + c.length, 0) + const buf = new Uint8Array(total) + let off = 0 + for (const c of chunks) { + buf.set(c, off) + off += c.length + } + const digest = await window.crypto.subtle.digest(subtleAlgo, buf as BufferSource) + const out = new Uint8Array(digest) + let hex = '' + for (const b of out) hex += b.toString(16).padStart(2, '0') + return hex + } + } +} + +/** + * Wraps the first-part response and lazily fetches the remaining parts + * via Range requests as `client-zip` pulls bytes through the stream. + * + * If the server returned `200` to the first request (Range was either + * not set or not honored), the response is treated as the full file — + * no further parts are requested. Otherwise the response's `.url` + * (after the 303 to S3) is used for the subsequent part requests so + * those bypass Dataverse entirely; that's the path that gives us the + * resilience win on flaky cross-region links. + * + * When the file's tree row carries a `checksum`, the bytes are also + * fed into a streaming digest accumulator and compared at end-of-stream + * — a mismatch is reported via `onVerificationFailure` but does NOT + * fail the file (its bytes are already in the zip; the user is told + * which files need re-downloading). + */ +function buildChunkedStream(args: { + file: FileTreeFile + initialResponse: Response + originalUrl: string + partSize: number + partRetries: number + partRetryDelayMs: number + fetchInit: RequestInit | undefined + onProgress: (delta: number) => void + onVerificationFailure: (failure: StreamingZipVerificationFailure) => void + cancelled: () => boolean +}): ReadableStream { + const total = args.file.size + const usedRange = args.initialResponse.status === 206 + const numParts = usedRange ? Math.ceil(total / args.partSize) : 1 + // `subsequentUrl` is the URL chunks 1..N-1 hit. Mutable because a 403 + // mid-download (presigned URL expired) triggers a re-presign that + // returns a fresh URL we cache here for the rest of the file. Same + // pattern repeats every time the new URL itself expires. + let subsequentUrl = + args.initialResponse.url && args.initialResponse.url !== args.originalUrl + ? /* istanbul ignore next */ args.initialResponse.url + : args.originalUrl + + // Caller has already null-checked `initialResponse.body` (see the + // /* istanbul ignore next */ guard in the engine), so the assertion + // here only narrows the type — the runtime check is upstream. + const initialBody = args.initialResponse.body as ReadableStream + let partIndex = 0 + let currentReader: ReadableStreamDefaultReader | null = initialBody.getReader() + + // Digest accumulator: present only when the tree response gave us a + // checksum AND the algorithm is one we can compute. Files where the + // tree omitted `checksum` (e.g. ingested-tabular default form) get + // no accumulator — skipped silently. Files with an exotic algorithm + // also fall through to skip rather than error. + const expectedChecksum = args.file.checksum + const digest = expectedChecksum ? makeDigestAccumulator(expectedChecksum.type) : null + + return new ReadableStream({ + async pull(controller) { + if (args.cancelled()) { + /* istanbul ignore next */ + controller.close() + /* istanbul ignore next */ + return + } + // Drain the current part; on exhaustion, advance to the next part + // (or close the stream if all parts are done). + for (;;) { + if (!currentReader) { + if (partIndex >= numParts) { + // Stream is closing — if we have a digest, finalize and + // compare against the expected value. Mismatch is reported + // via the callback (the bytes are already in the zip; the + // user is told which files to re-download). + if (digest && expectedChecksum) { + const actual = await digest.finalize() + if (actual.toLowerCase() !== expectedChecksum.value.toLowerCase()) { + args.onVerificationFailure({ + path: args.file.path, + name: args.file.name, + size: args.file.size, + algorithm: expectedChecksum.type, + expected: expectedChecksum.value, + actual + }) + } + } + controller.close() + return + } + const start = partIndex * args.partSize + const end = Math.min(start + args.partSize, total) - 1 + let response: Response + try { + const result = await fetchPartWithRefresh({ + cachedUrl: subsequentUrl, + originalUrl: args.originalUrl, + rangeHeader: `bytes=${start}-${end}`, + fetchInit: args.fetchInit, + retries: args.partRetries, + delayMs: args.partRetryDelayMs + }) + response = result.response + if (result.refreshedUrl) { + subsequentUrl = result.refreshedUrl + } + } catch (err) { + controller.error(err instanceof Error ? err : new Error(String(err))) + return + } + /* istanbul ignore if */ + if (!response.body) { + controller.error(new Error('no body for range part')) + return + } + currentReader = response.body.getReader() + } + const { value, done } = await currentReader.read() + if (done) { + currentReader.releaseLock() + currentReader = null + partIndex += 1 + continue + } + if (value && value.byteLength > 0) { + if (digest) digest.update(value) + args.onProgress(value.byteLength) + controller.enqueue(value) + return + } + } + }, + /* istanbul ignore next */ + async cancel() { + if (currentReader) { + try { + await currentReader.cancel() + } catch { + // Reader may already be closed; cancellation is best-effort. + } + } + } + }) +} + +function triggerDownload(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name + a.rel = 'noopener noreferrer' + 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/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..0996bd39f --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.module.scss @@ -0,0 +1,9 @@ +.toggle { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.label { + font-weight: 700; +} 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..f59f4a580 --- /dev/null +++ b/src/sections/dataset/dataset-files/files-view-toggle/FilesViewToggle.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import { Button, ButtonGroup } from '@iqss/dataverse-design-system' +import styles from './FilesViewToggle.module.scss' + +export type FilesViewMode = 'table' | 'tree' + +interface FilesViewToggleProps { + view: FilesViewMode + onChange: (view: FilesViewMode) => void +} + +export function FilesViewToggle({ view, onChange }: FilesViewToggleProps) { + const { t } = useTranslation('files') + return ( +
+ {t('view.toggle.changeView')} + + + + +
+ ) +} diff --git a/src/sections/dataset/dataset-files/filesViewSearchParams.ts b/src/sections/dataset/dataset-files/filesViewSearchParams.ts new file mode 100644 index 000000000..8713dee6f --- /dev/null +++ b/src/sections/dataset/dataset-files/filesViewSearchParams.ts @@ -0,0 +1,42 @@ +import { FilesViewMode } from './files-view-toggle/FilesViewToggle' + +export const VIEW_PARAM = 'view' +export const PATH_PARAM = 'path' + +/** + * Compute the next URLSearchParams when the user toggles between table + * and tree view. Switching to tree adds `?view=tree`; switching back to + * table removes both `view` and `path` so the URL doesn't keep a stale + * tree-only path query. + */ +export function nextSearchParamsForView( + current: URLSearchParams, + next: FilesViewMode +): URLSearchParams { + const updated = new URLSearchParams(current) + if (next === 'tree') { + updated.set(VIEW_PARAM, 'tree') + } else { + updated.delete(VIEW_PARAM) + updated.delete(PATH_PARAM) + } + return updated +} + +/** + * Compute the next URLSearchParams as the tree's currently-expanded + * path changes. Empty / falsy paths drop the `path` param entirely so + * the URL stays clean while at the root. + */ +export function nextSearchParamsForTreePath( + current: URLSearchParams, + next: string +): URLSearchParams { + const updated = new URLSearchParams(current) + if (next) { + updated.set(PATH_PARAM, next) + } else { + updated.delete(PATH_PARAM) + } + return updated +} diff --git a/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts b/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts new file mode 100644 index 000000000..fb5a48990 --- /dev/null +++ b/src/sections/dataset/dataset-files/treeDownloadsRequireTermsGate.ts @@ -0,0 +1,37 @@ +import { + Dataset, + DatasetPublishingStatus, + defaultLicense +} from '../../../dataset/domain/models/Dataset' + +/** + * True when the tree-view download paths must be gated behind a terms / + * guestbook / non-default-license acknowledgement that the table view + * already prompts for via its own modal. The tree's streaming-zip flow + * does not yet wire that modal in — until it does, callers should + * disable downloads when this returns true. + * + * Returns false on: + * - missing dataset (mount-time / loading) + * - draft versions (only owners can read draft files) + * - users with edit permission (they accept terms by virtue of editing) + * + * Otherwise: true if any of guestbook / non-default license / custom + * terms are present on the version. + */ +export function treeDownloadsRequireTermsGate( + dataset: + | Pick + | undefined + | null +): boolean { + if (!dataset) return false + const isDraft = dataset.version.publishingStatus === DatasetPublishingStatus.DRAFT + const canEdit = dataset.permissions.canUpdateDataset + if (isDraft || canEdit) return false + const hasGuestbook = dataset.guestbookId !== undefined + const hasNonDefaultLicense = + dataset.license !== undefined && dataset.license.name !== defaultLicense.name + const hasCustomTerms = dataset.termsOfUse?.customTerms !== undefined + return hasGuestbook || hasNonDefaultLicense || hasCustomTerms +} 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/file/FilesContext.ts b/src/sections/file/FilesContext.ts index 70a702a8a..e605bfc56 100644 --- a/src/sections/file/FilesContext.ts +++ b/src/sections/file/FilesContext.ts @@ -15,10 +15,11 @@ export const FilesContext = createContext({ export const useFilesContext = () => { const context = useContext(FilesContext) + // Unreachable while createContext is given a real default; kept as a guard + // for a future refactor that changes the default to null. + /* istanbul ignore if */ if (!context) { - /* istanbul ignore next */ throw new Error( - 'useFilesContext must be used within a FilesContext Provider' - ) + throw new Error('useFilesContext must be used within a FilesContext Provider') } return context } 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 54d61b58e..2f0cb23fb 100644 --- a/src/sections/shared/file-uploader/FileUploader.tsx +++ b/src/sections/shared/file-uploader/FileUploader.tsx @@ -2,7 +2,7 @@ import { File as FileModel } from '@/files/domain/models/File' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { FileUploaderProvider } from './context/FileUploaderContext' import { useGetFixityAlgorithm } from './useGetFixityAlgorithm' import { FileUploaderGlobalConfig } from './context/fileUploaderReducer' diff --git a/src/sections/shared/file-uploader/FileUploaderHelper.ts b/src/sections/shared/file-uploader/FileUploaderHelper.ts index 0067fed89..f6edadee8 100644 --- a/src/sections/shared/file-uploader/FileUploaderHelper.ts +++ b/src/sections/shared/file-uploader/FileUploaderHelper.ts @@ -46,7 +46,10 @@ export class FileUploaderHelper { } public static async getChecksum(blob: Blob, algorithm: FixityAlgorithm): Promise { - if (algorithm === FixityAlgorithm.MD5) { + if (algorithm === FixityAlgorithm.NONE) { + // No checksum calculation needed + return '' + } else if (algorithm === FixityAlgorithm.MD5) { return await this.getMD5Checksum(blob) } else { return await this.getSubtleDigestChecksum(blob, algorithm) diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.module.scss b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss new file mode 100644 index 000000000..c1442ddb0 --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanel.module.scss @@ -0,0 +1,16 @@ +@import 'node_modules/@iqss/dataverse-design-system/src/lib/assets/styles/design-tokens/colors.module'; + +.helper_text { + color: $dv-subtext-color; + font-size: 14px; + margin-bottom: 1rem; + + a { + color: $dv-primary-color; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} diff --git a/src/sections/shared/file-uploader/FileUploaderPanel.tsx b/src/sections/shared/file-uploader/FileUploaderPanel.tsx index 78047efaa..1af699290 100644 --- a/src/sections/shared/file-uploader/FileUploaderPanel.tsx +++ b/src/sections/shared/file-uploader/FileUploaderPanel.tsx @@ -1,19 +1,17 @@ -import { useMemo } from 'react' +import { useMemo, useCallback } from 'react' import { useDeepCompareEffect } from 'use-deep-compare' -import { toast } from 'react-toastify' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useBlocker, useNavigate } from 'react-router-dom' -import { Stack } from '@iqss/dataverse-design-system' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { QueryParamKey, Route } from '@/sections/Route.enum' import { DatasetNonNumericVersionSearchParam } from '@/dataset/domain/models/Dataset' +import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' -import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFile' +import { ReplaceFileReferrer } from '@/sections/replace-file/ReplaceFileReferrer' import { useFileUploaderContext } from './context/FileUploaderContext' -import FileUploadInput from './file-upload-input/FileUploadInput' -import { UploadedFilesList } from './uploaded-files-list/UploadedFilesList' +import { FileUploaderPanelCore } from './FileUploaderPanelCore' import { ConfirmLeaveModal } from './confirm-leave-modal/ConfirmLeaveModal' -import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' +import styles from './FileUploaderPanel.module.scss' interface FileUploaderPanelProps { fileRepository: FileRepository @@ -31,8 +29,8 @@ const FileUploaderPanel = ({ referrer, fetchUploadLimits }: FileUploaderPanelProps) => { - const { t } = useTranslation('shared') const navigate = useNavigate() + const { t } = useTranslation('shared') const { fileUploaderState: { @@ -42,7 +40,6 @@ const FileUploaderPanel = ({ replaceOperationInfo, addFilesToDatasetOperationInfo }, - uploadedFiles, removeAllFiles } = useFileUploaderContext() @@ -54,15 +51,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() } @@ -74,59 +65,60 @@ const FileUploaderPanel = ({ } } - useDeepCompareEffect(() => { - const datasetPageRedirectUrl = `${Route.DATASETS}?${QueryParamKey.PERSISTENT_ID}=${datasetPersistentId}&${QueryParamKey.VERSION}=${DatasetNonNumericVersionSearchParam.DRAFT}` + 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}` + // Navigate after a successful save/replace. This effect is registered after + // useBlocker, so React fires useBlocker's predicate-update effect first — + // by the time navigate() runs, the router's blocker fn already returns false + // and the leave modal stays hidden. + useDeepCompareEffect(() => { + if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { if (referrer === ReplaceFileReferrer.DATASET) { - navigate(datasetPageRedirectUrl) - } - - if (referrer === ReplaceFileReferrer.FILE) { + navigate(datasetPageUrl) + } else if (referrer === ReplaceFileReferrer.FILE) { navigate( `${Route.FILES}?id=${replaceOperationInfo.newFileIdentifier}&${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) + navigate(datasetPageUrl) } - }, [ - replaceOperationInfo, - addFilesToDatasetOperationInfo, - datasetPersistentId, - t, - navigate, - referrer - ]) + }, [replaceOperationInfo, addFilesToDatasetOperationInfo, 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..f567940a1 --- /dev/null +++ b/src/sections/shared/file-uploader/FileUploaderPanelCore.tsx @@ -0,0 +1,69 @@ +import { useDeepCompareEffect } from 'use-deep-compare' +import { toast } from 'react-toastify' +import { useTranslation } from 'react-i18next' +import { Stack } from '@iqss/dataverse-design-system' +import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' +import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +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 + fetchUploadLimits?: ( + datasetId: string | number, + datasetRepository: DatasetRepository + ) => Promise + /** Called when user clicks Cancel */ + onCancel: () => void +} + +export const FileUploaderPanelCore = ({ + fileRepository, + datasetPersistentId, + fetchUploadLimits, + onCancel +}: FileUploaderPanelCoreProps) => { + const { t } = useTranslation('shared') + + const { + fileUploaderState: { replaceOperationInfo, addFilesToDatasetOperationInfo }, + uploadedFiles + } = useFileUploaderContext() + + // Toast on success only. Post-success navigation is owned by each parent + // (SPA / standalone) so it can be colocated with that parent's blocking + // mechanism — useBlocker in the SPA, beforeunload in the standalone. Keeping + // navigate next to useBlocker is what makes React fire the blocker's + // predicate-update effect before the navigate, so the leave modal doesn't + // latch on a stale blocker fn. + useDeepCompareEffect(() => { + if (replaceOperationInfo.success && replaceOperationInfo.newFileIdentifier) { + toast.success(t('fileUploader.fileReplacedSuccessfully')) + } + + if (addFilesToDatasetOperationInfo.success) { + toast.success(t('fileUploader.filesAddedToDatasetSuccessfully')) + } + }, [replaceOperationInfo, addFilesToDatasetOperationInfo, t]) + + return ( + + + + {uploadedFiles.length > 0 && ( + + )} + + ) +} 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/file-upload-input/FileUploadInput.tsx b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx index 02d7a445d..153a0078e 100644 --- a/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx +++ b/src/sections/shared/file-uploader/file-upload-input/FileUploadInput.tsx @@ -1,26 +1,25 @@ -import { ChangeEventHandler, DragEventHandler, useRef, useState } from 'react' +import { ChangeEventHandler, DragEventHandler, memo, useCallback, useRef, useState } from 'react' import { Accordion, Button, Card, ProgressBar } from '@iqss/dataverse-design-system' import { ExclamationTriangle, Plus, XLg } from 'react-bootstrap-icons' -import { Trans, useTranslation } from 'react-i18next' -import { Semaphore } from 'async-mutex' +import { useTranslation } from 'react-i18next' import { toast } from 'react-toastify' import cn from 'classnames' -import { FileRepository } from '@/files/domain/repositories/FileRepository' import MimeTypeDisplay from '@/files/domain/models/FileTypeToFriendlyTypeMap' -import { uploadFile } from '@/files/domain/useCases/uploadFile' import { DatasetUploadLimits } from '@/dataset/domain/models/DatasetUploadLimits' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' 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' import { useUploadLimit } from './useUploadLimit' -import { useDatasetRepositories } from '@/shared/contexts/repositories/RepositoriesProvider' +import { useOptionalDatasetRepository } from '@/shared/contexts/repositories/RepositoriesProvider' type FileUploadInputProps = { - fileRepository: FileRepository + fileRepository: UploaderFileRepository datasetPersistentId: string fetchUploadLimits?: ( datasetId: string | number, @@ -28,9 +27,6 @@ type FileUploadInputProps = { ) => Promise } -const limit = 6 -const semaphore = new Semaphore(limit) - const maxFilesPerUpload = 1000 const FileUploadInput = ({ @@ -38,7 +34,10 @@ const FileUploadInput = ({ datasetPersistentId, fetchUploadLimits }: FileUploadInputProps) => { - const { datasetRepository } = useDatasetRepositories() + // Optional on purpose: the standalone JSF bundle mounts this component + // without a RepositoriesProvider; no repository simply means upload + // limits are not fetched (the JSF page has no limits API context). + const datasetRepository = useOptionalDatasetRepository() const { fileUploaderState, addFile, @@ -57,6 +56,7 @@ const FileUploadInput = ({ const { t } = useTranslation('shared') const inputRef = useRef(null) + const folderInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const { uploadLimit } = useUploadLimit(datasetPersistentId, datasetRepository, fetchUploadLimits) @@ -70,81 +70,53 @@ const FileUploadInput = ({ 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 = '' + 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) { + 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 + 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 || []) @@ -160,33 +132,18 @@ const FileUploadInput = ({ } } - // waiting on the possibility to test folder drop: https://github.com/cypress-io/cypress/issues/19696 - const addFromDir = (dir: FileSystemDirectoryEntry) => { - /* istanbul ignore next */ - const reader = dir.createReader() - - reader.readEntries((entries) => { - entries.forEach((entry) => { - if (entry.isFile) { - const fse = entry as FileSystemFileEntry - fse.file((file) => { - const fileWithPath = new File([file], file.name, { - type: file.type, - lastModified: file.lastModified - }) - - Object.defineProperty(fileWithPath, 'webkitRelativePath', { - value: entry.fullPath, - writable: true - }) - - void uploadOneFile(fileWithPath) - }) - } else if (entry.isDirectory) { - addFromDir(entry as FileSystemDirectoryEntry) - } - }) - }) + const handleFolderInputChange: ChangeEventHandler = (event) => { + const filesArray = Array.from(event.target.files || []) + + if (filesArray && filesArray.length > 0) { + for (const file of filesArray) { + void uploadOneFile(file) + } + } + + if (folderInputRef.current) { + folderInputRef.current.value = '' + } } const handleDropFiles: DragEventHandler = (event) => { @@ -202,6 +159,7 @@ const FileUploadInput = ({ } const droppedItems = event.dataTransfer.items + const droppedFiles = event.dataTransfer.files if (droppedItems.length > 0) { if (operationType === OperationType.REPLACE_FILE && droppedItems.length > 1) { @@ -209,16 +167,7 @@ const FileUploadInput = ({ return } - Array.from(droppedItems).forEach((droppedFile) => { - if (droppedFile.webkitGetAsEntry()?.isDirectory) { - addFromDir(droppedFile.webkitGetAsEntry() as FileSystemDirectoryEntry) - } else if (droppedFile.webkitGetAsEntry()?.isFile) { - const fse = droppedFile.webkitGetAsEntry() as FileSystemFileEntry - fse.file((file) => { - void uploadOneFile(file) - }) - } - }) + handleDroppedItems(droppedItems, droppedFiles) } } @@ -263,22 +212,6 @@ const FileUploadInput = ({ return (
-

- - ) - }} - /> -

- {t('fileUploader.accordionTitle')} @@ -315,6 +248,15 @@ const FileUploadInput = ({ ? t('fileUploader.selectFileMultiple') : t('fileUploader.selectFileSingle')} + {operationType === OperationType.ADD_FILES_TO_DATASET && ( + + )}