diff --git a/.github/workflows/chromatic-design-system.yml b/.github/workflows/chromatic-design-system.yml index 386d297cc..89b936f70 100644 --- a/.github/workflows/chromatic-design-system.yml +++ b/.github/workflows/chromatic-design-system.yml @@ -14,21 +14,37 @@ jobs: runs-on: ubuntu-latest # Job steps steps: - - uses: actions/checkout@v1 + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.ref || github.ref }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm - name: Create .npmrc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cp .npmrc.example .npmrc - sed -i -e 's//${{ secrets.GITHUB_TOKEN }}/g' .npmrc - sed -i -e 's//${{ secrets.NPM_AUTH_TOKEN }}/g' .npmrc + sed -i -e "s##${GITHUB_TOKEN}#g" .npmrc + # Comment out npmjs auth token line if no token provided via secrets + sed -i -e 's#//registry.npmjs.org/:_authToken=#; auth token omitted in CI#g' .npmrc - name: Install dependencies - # 👇 Install dependencies with the same package manager used in the project (replace it as needed), e.g. yarn, npm, pnpm - run: npm install + # 👇 Use clean install for reproducibility + run: npm ci - name: Build Dataverse Design System working-directory: packages/design-system run: npm run build # 👇 Adds Chromatic as a step in the workflow - name: Publish to Chromatic - uses: chromaui/action@v1 + uses: chromaui/action@latest + env: + CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CHROMATIC_SLUG: ${{ github.repository }} # Chromatic GitHub Action options with: # 👇 Chromatic projectToken, refer to the manage page to obtain it. diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 74132bdf9..3c8ba93c2 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -14,24 +14,41 @@ jobs: runs-on: ubuntu-latest # Job steps steps: - - uses: actions/checkout@v1 + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Ensure correct ref on PRs for proper baseline detection + ref: ${{ github.event.pull_request.head.ref || github.ref }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm - name: Create .npmrc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cp .npmrc.example .npmrc - sed -i -e 's//${{ secrets.GITHUB_TOKEN }}/g' .npmrc - sed -i -e 's//${{ secrets.NPM_AUTH_TOKEN }}/g' .npmrc + sed -i -e "s##${GITHUB_TOKEN}#g" .npmrc + # Comment out npmjs auth token line if no token provided via secrets + sed -i -e 's#//registry.npmjs.org/:_authToken=#; auth token omitted in CI#g' .npmrc - name: Install dependencies - # 👇 Install dependencies with the same package manager used in the project (replace it as needed), e.g. yarn, npm, pnpm - run: npm install + # 👇 Use clean install for reproducible CI installs + run: npm ci # 👇 Adds Chromatic as a step in the workflow # Install design system dependencies - name: Build Dataverse Design System working-directory: packages/design-system run: npm run build - name: Publish to Chromatic - uses: chromaui/action@v1 + uses: chromaui/action@latest env: STORYBOOK_CHROMATIC_BUILD: 'true' + # Provide PR metadata to Chromatic for correct git/baseline context + CHROMATIC_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + CHROMATIC_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CHROMATIC_SLUG: ${{ github.repository }} # Chromatic GitHub Action options with: diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 00c579906..d8567fdfb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,8 @@ import type { Preview } from '@storybook/react' import { ThemeProvider } from '@iqss/dataverse-design-system' import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router-dom' import { FakerHelper } from '../tests/component/shared/FakerHelper' +import { ExternalToolsProvider } from '../src/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsEmptyMockRepository } from '../src/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' import 'react-loading-skeleton/dist/skeleton.css' import '../src/assets/global.scss' import '../src/assets/swal-custom.scss' @@ -32,7 +34,9 @@ const preview: Preview = { return ( - + + + ) } diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e5a4186..7ac39a8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Dataset Templates Selector in the Create Dataset page. - Metadata Export Dropdown to the metadata tab of Dataset Page and File Page. +- External Tools integration. All types supported: Explore, Configure, Preview and Query tools in Dataset and File pages. Still not showing external tools for Auxiliary Files as additional development is needed. ### Changed diff --git a/package-lock.json b/package-lock.json index ef81ff9a7..543455944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.66", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.67", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -81,7 +81,7 @@ "babel-plugin-named-exports-order": "0.0.2", "chai": "4.3.7", "chai-as-promised": "7.1.1", - "chromatic": "6.24.1", + "chromatic": "^13.3.0", "concurrently": "8.0.1", "cypress": "15.2.0", "cypress-vite": "1.4.0", @@ -1954,9 +1954,9 @@ }, "node_modules/@iqss/dataverse-client-javascript": { "name": "@IQSS/dataverse-client-javascript", - "version": "2.0.0-alpha.66", - "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.66/5eac3e19da454f634e409469958c848b70283c16", - "integrity": "sha512-YGDUC/nk2nqmlq5DPNNbnt5KTABZAk+HCLuw90zg/8hWVhU8RSc2fRDeSuc/CQsV/NmCSw6gzhr5FsCsKitdEQ==", + "version": "2.0.0-alpha.67", + "resolved": "https://npm.pkg.github.com/download/@IQSS/dataverse-client-javascript/2.0.0-alpha.67/0ee4f1c8e03eb3ef688d6b034e84003c9e155d3b", + "integrity": "sha512-uEAGtwXz7LYkBfWCBRktgb5d8oba6yPH9YWnVFhI40UqgdB1sQ/WWCDhZTn5LFsaQY+1XBzOoTjwrQ2HGz94og==", "license": "MIT", "dependencies": { "@types/node": "^18.15.11", @@ -10651,15 +10651,27 @@ } }, "node_modules/chromatic": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-6.24.1.tgz", - "integrity": "sha512-XbpdWWHvFpEHtcq1Km71UcuQ07effB+8q8L47E1Y7HJmJ4ZCoKCuPd8liNrbnvwEAxqfBZvTcONYU/3BPz2i5w==", + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.0.tgz", + "integrity": "sha512-OtXVKSFqGS1x6E6xYzmYX2iImSknbvo5CfTxP3ztFvXQhIAwhJzJuA3XpnIewER9gtWUNnV2DDi8/f9JyZJSfg==", "dev": true, "license": "MIT", "bin": { "chroma": "dist/bin.js", "chromatic": "dist/bin.js", "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } } }, "node_modules/ci-info": { @@ -30389,7 +30401,7 @@ "@testing-library/cypress": "10.1.0", "@vitejs/plugin-react": "4.3.1", "axe-playwright": "1.2.3", - "chromatic": "6.24.1", + "chromatic": "^13.3.0", "cypress": "15.2.0", "react": "18.2.0", "vite": "5.4.20", diff --git a/package.json b/package.json index c7944b54d..34008bc0b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@dnd-kit/sortable": "8.0.0", "@dnd-kit/utilities": "3.2.2", "@faker-js/faker": "7.6.0", - "@iqss/dataverse-client-javascript": "2.0.0-alpha.66", + "@iqss/dataverse-client-javascript": "2.0.0-alpha.67", "@iqss/dataverse-design-system": "*", "@istanbuljs/nyc-config-typescript": "1.0.2", "@tanstack/react-table": "8.9.2", @@ -141,7 +141,7 @@ "babel-plugin-named-exports-order": "0.0.2", "chai": "4.3.7", "chai-as-promised": "7.1.1", - "chromatic": "6.24.1", + "chromatic": "13.3.0", "concurrently": "8.0.1", "cypress": "15.2.0", "cypress-vite": "1.4.0", diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 71eeeb667..0c5cdeb30 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -15,6 +15,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **SelectAdvanced:** - Fix word wrapping in options list to prevent overflow and ensure long text is displayed correctly. - Support for options with a shape of `{ label: string; value: string; }[]` instead of just `string[]`. +- **ButtonGroup:** + - Fix styles for vertical button groups when using tooltips. # [2.0.2](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@2.0.1...@iqss/dataverse-design-system@2.0.2) (2024-06-23) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index ab9d86a4c..cbd4bb2b2 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -57,7 +57,7 @@ "@testing-library/cypress": "10.1.0", "@vitejs/plugin-react": "4.3.1", "axe-playwright": "1.2.3", - "chromatic": "6.24.1", + "chromatic": "13.3.0", "cypress": "15.2.0", "react": "18.2.0", "vite": "5.4.20", diff --git a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss index 987045ab6..ea128189e 100644 --- a/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss +++ b/packages/design-system/src/lib/assets/styles/bootstrap-customized.scss @@ -128,17 +128,75 @@ th { vertical-align: middle; } -.btn-group > div:not(:last-child) > .btn-group > .btn, -.btn-group-vertical > div:not(:last-child) > .btn-group > .btn { +/* Start - Overrides to Fix Group Butttons with Buttons with Tooltips */ + +.btn-group > .btn, +.btn-group > div > .btn { + margin-left: -1px; +} + +.btn-group-vertical > .btn-group > .btn, +.btn-group-vertical > div:not([role='group']) > .btn-group > .btn { + margin-left: 0; +} + +/* stylelint-disable */ +.btn-group-vertical > .btn, +.btn-group-vertical > div > .btn { + margin-top: -1px; +} + +.btn-group-vertical > .btn:not(:last-child, :first-child), +.btn-group-vertical > div:not(:last-child, :first-child, [role='group']), +.btn-group-vertical > div:not(:last-child, :first-child, [role='group']) > .btn-group { + border-radius: 0; +} + +.btn-group > div:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group > div:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-group-vertical > div:not([role='group']), +.btn-group-vertical > div:not([role='group']) > .btn, +.btn-group-vertical > div:not([role='group']) > .btn-group { + width: 100%; +} + +.btn-group-vertical + > div:not(:first-child, :last-child, [role='group']) + > .btn:not(.dropdown-toggle), +.btn-group-vertical > div:not(:last-child, :first-child, [role='group']) > .btn-group > .btn { + border-radius: 0; +} + +.btn-group-vertical > div:first-child:not([role='group']) > .btn:not(.dropdown-toggle), +.btn-group-vertical > div:first-child:not([role='group']) > .btn-group > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-vertical > div:last-child:not([role='group']) > .btn:not(.dropdown-toggle), +.btn-group-vertical > div:last-child:not([role='group']) > .btn-group > .btn { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.btn-group > div:not(:last-child) > .btn-group > .btn { border-top-right-radius: 0; border-bottom-right-radius: 0; } -.btn-group > div:not(:first-child) > .btn-group > .btn, -.btn-group-vertical > div:not(:first-child) > .btn-group > .btn { +.btn-group > div:not(:first-child) > .btn-group > .btn { border-top-left-radius: 0; border-bottom-left-radius: 0; } +/* End - Overrides to Fix Group Butttons with Buttons with Tooltips */ .btn-link { text-decoration: none; diff --git a/packages/design-system/src/lib/components/button-group/ButtonGroup.module.scss b/packages/design-system/src/lib/components/button-group/ButtonGroup.module.scss index e54fd9cad..0f805ce49 100644 --- a/packages/design-system/src/lib/components/button-group/ButtonGroup.module.scss +++ b/packages/design-system/src/lib/components/button-group/ButtonGroup.module.scss @@ -1,6 +1,13 @@ @import 'src/lib/assets/styles/design-tokens/colors.module'; .border > button, -.border > [role='group'] > button { +.border > [role='group'] > button, +.border > button:not(.dropdown-item) { border: 1px solid $dv-button-border-color; } + +.border > div { + &:global > .btn { + border: 1px solid $dv-button-border-color; + } +} diff --git a/packages/design-system/src/lib/components/tooltip/Tooltip.tsx b/packages/design-system/src/lib/components/tooltip/Tooltip.tsx index 5c5d6e021..6a0f107ae 100644 --- a/packages/design-system/src/lib/components/tooltip/Tooltip.tsx +++ b/packages/design-system/src/lib/components/tooltip/Tooltip.tsx @@ -1,5 +1,5 @@ -import { OverlayTrigger as OverlayTriggerBS, Tooltip as TooltipBS } from 'react-bootstrap' import { Placement } from 'react-bootstrap/types' +import { OverlayTrigger as OverlayTriggerBS, Tooltip as TooltipBS } from 'react-bootstrap' import { ReactElement } from 'react' interface OverlayTriggerProps { diff --git a/packages/design-system/src/lib/stories/button-group/ButtonGroup.stories.tsx b/packages/design-system/src/lib/stories/button-group/ButtonGroup.stories.tsx index 2fbd7422a..bf3c4e17e 100644 --- a/packages/design-system/src/lib/stories/button-group/ButtonGroup.stories.tsx +++ b/packages/design-system/src/lib/stories/button-group/ButtonGroup.stories.tsx @@ -51,6 +51,25 @@ export const VerticalButtonGroup: Story = { ) } +export const VerticalButtonGroupWithTooltips: Story = { + render: () => ( + + + + + + + + + + + + + + + ) +} + export const NestedButtonGroups: Story = { render: () => ( @@ -99,6 +118,9 @@ export const ButtonGroupWithTooltips: Story = { Item 2 + + + ) } diff --git a/packages/design-system/tests/component/button/Button.spec.tsx b/packages/design-system/tests/component/button/Button.spec.tsx index f35632513..d865ba93b 100644 --- a/packages/design-system/tests/component/button/Button.spec.tsx +++ b/packages/design-system/tests/component/button/Button.spec.tsx @@ -59,4 +59,9 @@ describe('Button', () => { cy.mount() cy.findByText(clickMeText).should('have.attr', 'type').and('eq', 'submit') }) + + it('renders the button with the spacing styles when the spacing prop is provided', () => { + cy.mount() + cy.findByText(clickMeText).should('have.css', 'margin').and('not.eq', '0px') + }) }) diff --git a/packages/design-system/tests/component/progress-bar/ProgressBar.spec.tsx b/packages/design-system/tests/component/progress-bar/ProgressBar.spec.tsx index 6cd32d30e..11965ba27 100644 --- a/packages/design-system/tests/component/progress-bar/ProgressBar.spec.tsx +++ b/packages/design-system/tests/component/progress-bar/ProgressBar.spec.tsx @@ -6,4 +6,10 @@ describe('ProgressBar', () => { cy.findByRole('progressbar').should('exist') }) + + it('renders the ProgressBar without a now progress', () => { + cy.mount() + + cy.findByRole('progressbar').should('exist') + }) }) diff --git a/packages/design-system/tests/component/tabs/Tabs.spec.tsx b/packages/design-system/tests/component/tabs/Tabs.spec.tsx index d88c698a8..07d78fd5f 100644 --- a/packages/design-system/tests/component/tabs/Tabs.spec.tsx +++ b/packages/design-system/tests/component/tabs/Tabs.spec.tsx @@ -94,4 +94,40 @@ describe('Tabs', () => { cy.findByText('Tab 2').click() cy.get('@onSelect').should('have.been.calledWith', 'key-2') }) + + it('warns if activeKey is provided without onSelect', () => { + cy.stub(console, 'warn').as('consoleWarn') + cy.mount( + + + Content 1 + + + Content 2 + + + ) + cy.get('@consoleWarn').should( + 'have.been.calledWith', + 'Tabs component requires onSelect function when activeKey is provided' + ) + }) + + it('warns if neither activeKey nor defaultActiveKey is provided', () => { + cy.stub(console, 'warn').as('consoleWarn') + cy.mount( + + + Content 1 + + + Content 2 + + + ) + cy.get('@consoleWarn').should( + 'have.been.calledWith', + 'Tabs component requires either activeKey or defaultActiveKey' + ) + }) }) diff --git a/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx index 9e1e19a83..7d2f663e8 100644 --- a/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx +++ b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx @@ -317,6 +317,54 @@ describe('TransferList', () => { }) }) + it('renders the TransferList disabled', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all right').should('be.disabled') + cy.findByLabelText('move selected to right').should('be.disabled') + cy.findByLabelText('move selected to left').should('be.disabled') + cy.findByLabelText('move all left').should('be.disabled') + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item B').should('be.disabled') + cy.findByLabelText('Item D').should('be.disabled') + cy.findByLabelText('Item E').should('be.disabled') + }) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').should('be.disabled') + cy.findByLabelText('Item C').should('be.disabled') + }) + }) + describe('drag and drop', () => { it('should sort item A for Item B', () => { cy.mount( diff --git a/public/locales/en/file.json b/public/locales/en/file.json index 5582256f1..05113ab83 100644 --- a/public/locales/en/file.json +++ b/public/locales/en/file.json @@ -1,7 +1,10 @@ { "tabs": { "metadata": "Metadata", - "versions": "Versions" + "versions": "Versions", + "preview": "Preview", + "query": "Query", + "fileTools": "File Tools" }, "fileVersion": { "version": "Dataset Version", @@ -138,5 +141,9 @@ "requestAccessTooltipText": "If checked, users can request access to the restricted files in this dataset.", "termsOfAccessTooltipTex": "information on how and if users can access restricted files in this dataset." }, - "getCategoriesError": "Something went wrong fetching available categories. Try again later." + "getCategoriesError": "Something went wrong fetching available categories. Try again later.", + "previewTab": { + "openInNewWindow": "Open in New Window", + "defaultLoadingToolError": "Something went wrong loading the external tool. Try again later." + } } diff --git a/public/locales/en/shared.json b/public/locales/en/shared.json index d71c14aa5..cecdf1252 100644 --- a/public/locales/en/shared.json +++ b/public/locales/en/shared.json @@ -27,6 +27,8 @@ "dragHandleLabel": "press space to select and keys to drag", "unknown": "Unknown", "find": "Find", + "allowPopups": "You must enable popups in your browser to open external tools in a new window or tab.", + "externalToolOpeningFailed": "There was a problem opening the external tool. Please try again.", "exportMetadata": "Export Metadata", "pageNumberNotFound": { "heading": "Page Number Not Found", @@ -289,5 +291,8 @@ "truncateLessBtn": "Collapse {{contentName}}", "truncateMoreTip": "Click to read the full {{contentName}}", "truncateLessTip": "Click to collapse the {{contentName}}" - } + }, + "exploreOptions": "Explore Options", + "queryOptions": "Query Options", + "configureOptions": "Configure Options" } diff --git a/src/App.tsx b/src/App.tsx index 0344a7124..2cec4e501 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ import { DataverseApiAuthMechanism } from '@iqss/dataverse-client-javascript/dis import { Router } from './router' import { Route } from './sections/Route.enum' import { OIDC_AUTH_CONFIG, DATAVERSE_BACKEND_URL } from './config' +import { ExternalToolsProvider } from './shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsJSDataverseRepository } from './externalTools/infrastructure/repositories/ExternalToolsJSDataverseRepository' import 'react-loading-skeleton/dist/skeleton.css' import './assets/global.scss' import './assets/react-toastify-custom.scss' @@ -46,11 +48,15 @@ const authConfig: TAuthConfig = { clearURL: false } +const externalToolsRepository = new ExternalToolsJSDataverseRepository() + function App() { return ( <> - + + + diff --git a/src/externalTools/domain/models/DatasetExternalToolResolved.ts b/src/externalTools/domain/models/DatasetExternalToolResolved.ts new file mode 100644 index 000000000..b96007a05 --- /dev/null +++ b/src/externalTools/domain/models/DatasetExternalToolResolved.ts @@ -0,0 +1,6 @@ +export interface DatasetExternalToolResolved { + toolUrlResolved: string // The URL to access the external tool. The URL includes necessary authentication tokens and parameters based on the user's permissions and the tool's configuration. + displayName: string + datasetId: number + preview: boolean +} diff --git a/src/externalTools/domain/models/ExternalTool.ts b/src/externalTools/domain/models/ExternalTool.ts new file mode 100644 index 000000000..a409e512e --- /dev/null +++ b/src/externalTools/domain/models/ExternalTool.ts @@ -0,0 +1,23 @@ +export interface ExternalTool { + id: number + displayName: string + description: string + types: ToolType[] + scope: ToolScope + contentType?: string // Only present when scope is 'file' + toolParameters?: { queryParameters?: Record[] } + allowedApiCalls?: { name: string; httpMethod: string; urlTemplate: string; timeOut: number }[] + requirements?: { auxFilesExist: { formatTag: string; formatVersion: string }[] } +} + +export enum ToolType { + Explore = 'explore', + Configure = 'configure', + Preview = 'preview', + Query = 'query' +} + +export enum ToolScope { + Dataset = 'dataset', + File = 'file' +} diff --git a/src/externalTools/domain/models/FileExternalToolResolved.ts b/src/externalTools/domain/models/FileExternalToolResolved.ts new file mode 100644 index 000000000..a205f64b3 --- /dev/null +++ b/src/externalTools/domain/models/FileExternalToolResolved.ts @@ -0,0 +1,6 @@ +export interface FileExternalToolResolved { + toolUrlResolved: string // The URL to access the external tool. The URL includes necessary authentication tokens and parameters based on the user's permissions and the tool's configuration. + displayName: string + fileId: number + preview: boolean +} diff --git a/src/externalTools/domain/repositories/ExternalToolsRepository.ts b/src/externalTools/domain/repositories/ExternalToolsRepository.ts new file mode 100644 index 000000000..ee8d5a7e7 --- /dev/null +++ b/src/externalTools/domain/repositories/ExternalToolsRepository.ts @@ -0,0 +1,18 @@ +import { DatasetExternalToolResolved } from '../models/DatasetExternalToolResolved' +import { ExternalTool } from '../models/ExternalTool' +import { FileExternalToolResolved } from '../models/FileExternalToolResolved' +import { GetExternalToolDTO } from '../useCases/DTOs/GetExternalToolDTO' + +export interface ExternalToolsRepository { + getExternalTools(): Promise + getDatasetExternalToolResolved( + datasetId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO + ): Promise + getFileExternalToolResolved( + fileId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO + ): Promise +} diff --git a/src/externalTools/domain/useCases/DTOs/GetExternalToolDTO.ts b/src/externalTools/domain/useCases/DTOs/GetExternalToolDTO.ts new file mode 100644 index 000000000..7e415d253 --- /dev/null +++ b/src/externalTools/domain/useCases/DTOs/GetExternalToolDTO.ts @@ -0,0 +1,9 @@ +/** + * @property {boolean} preview - boolean flag to indicate if the request is for previewing the tool or not. + * @property {string} locale - string specifying the locale for internationalization + */ + +export interface GetExternalToolDTO { + preview: boolean + locale: string +} diff --git a/src/externalTools/domain/useCases/GetDatasetExternalToolResolved.ts b/src/externalTools/domain/useCases/GetDatasetExternalToolResolved.ts new file mode 100644 index 000000000..6401f008c --- /dev/null +++ b/src/externalTools/domain/useCases/GetDatasetExternalToolResolved.ts @@ -0,0 +1,16 @@ +import { DatasetExternalToolResolved } from '../models/DatasetExternalToolResolved' +import { ExternalToolsRepository } from '../repositories/ExternalToolsRepository' +import { GetExternalToolDTO } from './DTOs/GetExternalToolDTO' + +export function getDatasetExternalToolResolved( + externalToolsRepository: ExternalToolsRepository, + datasetId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO +): Promise { + return externalToolsRepository.getDatasetExternalToolResolved( + datasetId, + toolId, + getExternalToolDTO + ) +} diff --git a/src/externalTools/domain/useCases/GetExternalTools.ts b/src/externalTools/domain/useCases/GetExternalTools.ts new file mode 100644 index 000000000..889e2a165 --- /dev/null +++ b/src/externalTools/domain/useCases/GetExternalTools.ts @@ -0,0 +1,8 @@ +import { ExternalTool } from '../models/ExternalTool' +import { ExternalToolsRepository } from '../repositories/ExternalToolsRepository' + +export function getExternalTools( + externalToolsRepository: ExternalToolsRepository +): Promise { + return externalToolsRepository.getExternalTools() +} diff --git a/src/externalTools/domain/useCases/GetFileExternalToolResolved.ts b/src/externalTools/domain/useCases/GetFileExternalToolResolved.ts new file mode 100644 index 000000000..942c9837c --- /dev/null +++ b/src/externalTools/domain/useCases/GetFileExternalToolResolved.ts @@ -0,0 +1,12 @@ +import { FileExternalToolResolved } from '../models/FileExternalToolResolved' +import { ExternalToolsRepository } from '../repositories/ExternalToolsRepository' +import { GetExternalToolDTO } from './DTOs/GetExternalToolDTO' + +export function getFileExternalToolResolved( + externalToolsRepository: ExternalToolsRepository, + fileId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO +): Promise { + return externalToolsRepository.getFileExternalToolResolved(fileId, toolId, getExternalToolDTO) +} diff --git a/src/externalTools/infrastructure/repositories/ExternalToolsJSDataverseRepository.ts b/src/externalTools/infrastructure/repositories/ExternalToolsJSDataverseRepository.ts new file mode 100644 index 000000000..125ffca6e --- /dev/null +++ b/src/externalTools/infrastructure/repositories/ExternalToolsJSDataverseRepository.ts @@ -0,0 +1,36 @@ +import { + getDatasetExternalToolResolved, + getExternalTools, + getFileExternalToolResolved +} from '@iqss/dataverse-client-javascript' +import { DatasetExternalToolResolved } from '@/externalTools/domain/models/DatasetExternalToolResolved' +import { ExternalTool } from '@/externalTools/domain/models/ExternalTool' +import { FileExternalToolResolved } from '@/externalTools/domain/models/FileExternalToolResolved' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { GetExternalToolDTO } from '@/externalTools/domain/useCases/DTOs/GetExternalToolDTO' + +export class ExternalToolsJSDataverseRepository implements ExternalToolsRepository { + getExternalTools(): Promise { + return getExternalTools.execute().then((jsExternalTools) => jsExternalTools) + } + + getDatasetExternalToolResolved( + datasetId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO + ): Promise { + return getDatasetExternalToolResolved + .execute(datasetId, toolId, getExternalToolDTO) + .then((jsDatasetExternalToolResolved) => jsDatasetExternalToolResolved) + } + + getFileExternalToolResolved( + fileId: number | string, + toolId: number, + getExternalToolDTO: GetExternalToolDTO + ): Promise { + return getFileExternalToolResolved + .execute(fileId, toolId, getExternalToolDTO) + .then((jsFileExternalToolResolved) => jsFileExternalToolResolved) + } +} diff --git a/src/index.app.tsx b/src/index.app.tsx index 633aca4d1..470b580d6 100644 --- a/src/index.app.tsx +++ b/src/index.app.tsx @@ -1,7 +1,7 @@ import React from 'react' import App from './App' import './i18n' -import { LoadingProvider } from './sections/loading/LoadingProvider' +import { LoadingProvider } from './shared/contexts/loading/LoadingProvider' import { ThemeProvider } from '@iqss/dataverse-design-system' import { AppLoader } from './sections/shared/layout/app-loader/AppLoader' diff --git a/src/sections/Route.enum.ts b/src/sections/Route.enum.ts index 2adbe8334..d7d64fb7a 100644 --- a/src/sections/Route.enum.ts +++ b/src/sections/Route.enum.ts @@ -81,5 +81,6 @@ export enum QueryParamKey { DATASET_VERSION = 'datasetVersion', REFERRER = 'referrer', AUTH_STATE = 'state', - VALID_TOKEN_BUT_NOT_LINKED_ACCOUNT = 'validTokenButNotLinkedAccount' + VALID_TOKEN_BUT_NOT_LINKED_ACCOUNT = 'validTokenButNotLinkedAccount', + TOOL_TYPE = 'toolType' } diff --git a/src/sections/account/Account.tsx b/src/sections/account/Account.tsx index 082b5b0e6..26fc6cb79 100644 --- a/src/sections/account/Account.tsx +++ b/src/sections/account/Account.tsx @@ -7,7 +7,7 @@ import { UserJSDataverseRepository } from '@/users/infrastructure/repositories/U import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { ApiTokenSection } from './api-token-section/ApiTokenSection' import { AccountInfoSection } from './account-info-section/AccountInfoSection' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { MyDataItemsPanel } from '@/sections/account/my-data-section/MyDataItemsPanel' import { RoleJSDataverseRepository } from '@/roles/infrastructure/repositories/RoleJSDataverseRepository' import styles from './Account.module.scss' diff --git a/src/sections/account/api-token-section/ApiTokenSection.tsx b/src/sections/account/api-token-section/ApiTokenSection.tsx index f63edb090..90fe99a85 100644 --- a/src/sections/account/api-token-section/ApiTokenSection.tsx +++ b/src/sections/account/api-token-section/ApiTokenSection.tsx @@ -53,8 +53,8 @@ export const ApiTokenSection = ({ repository }: ApiTokenSectionProps) => { }) } - const copyToClipboard = () => { - navigator.clipboard.writeText(currentApiTokenInfo?.apiToken ?? '').catch( + const copyToClipboard = (currentApiToken: string) => { + navigator.clipboard.writeText(currentApiToken).catch( /* istanbul ignore next */ (error) => { console.error('Failed to copy text:', error) } @@ -104,7 +104,9 @@ export const ApiTokenSection = ({ repository }: ApiTokenSectionProps) => { {currentApiTokenInfo.apiToken}
- - + ) } diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.tsx index 4f2e66e3e..1533f19de 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.tsx @@ -1,11 +1,13 @@ -import { AccessFileMenu } from '../../../../../../file/file-action-buttons/access-file-menu/AccessFileMenu' -import { FilePreview } from '../../../../../../../files/domain/models/FilePreview' -import { FileOptionsMenu } from './file-options-menu/FileOptionsMenu' -import { ButtonGroup } from '@iqss/dataverse-design-system' import { useTranslation } from 'react-i18next' -import { DatasetPublishingStatus } from '../../../../../../../dataset/domain/models/Dataset' +import { ButtonGroup } from '@iqss/dataverse-design-system' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FilePreview } from '@/files/domain/models/FilePreview' +import { DatasetPublishingStatus } from '@/dataset/domain/models/Dataset' +import { AccessFileMenu } from '@/sections/file/file-action-buttons/access-file-menu/AccessFileMenu' +import { FileOptionsMenu } from './file-options-menu/FileOptionsMenu' +import { useMediaQuery } from '@/shared/hooks/useMediaQuery' +import { FileTools } from './FileTools' interface FileActionButtonsProps { file: FilePreview @@ -18,9 +20,11 @@ export function FileActionButtons({ datasetRepository }: FileActionButtonsProps) { const { t } = useTranslation('files') + const isBelow768px = useMediaQuery('(max-width: 768px)') return ( - + + { + const theme = useTheme() + const { externalTools } = useExternalTools() + + const fileApplicablePreviewOrQueryTools: ExternalTool[] = + FilePageHelper.getApplicablePreviewOrQueryToolsForFileType( + externalTools, + file.metadata.type.value + ) + + const showExternalToolsButtons = fileApplicablePreviewOrQueryTools.length > 0 && canDownloadFile + + if (!showExternalToolsButtons) return null + + return ( + <> + {fileApplicablePreviewOrQueryTools.map((tool) => ( + + + {tool.types.includes(ToolType.Preview) && ( + + )} + {tool.types.includes(ToolType.Query) && ( + + )} + + + ))} + + ) +} diff --git a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/FileOptionsMenu.tsx b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/FileOptionsMenu.tsx index 00a420619..4311aeef6 100644 --- a/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/FileOptionsMenu.tsx +++ b/src/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/file-options-menu/FileOptionsMenu.tsx @@ -10,6 +10,7 @@ import { useDataset } from '../../../../../../DatasetContext' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { EditFilesMenuDatasetInfo } from '../../../edit-files-menu/EditFilesOptions' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FileConfigureToolsOptions } from '@/sections/file/file-action-buttons/access-file-menu/FileToolOptions' interface FileOptionsMenuProps { file: FilePreview @@ -79,6 +80,7 @@ export function FileOptionsMenu({ file, fileRepository, datasetRepository }: Fil isHeader={false} datasetRepository={datasetRepository} /> + ) diff --git a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/FileInfoCell.module.scss b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/FileInfoCell.module.scss index 6159d4d95..a608f7dc6 100644 --- a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/FileInfoCell.module.scss +++ b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/FileInfoCell.module.scss @@ -8,15 +8,31 @@ .body-container { padding-left: 10px; + // Link title + > a { + @media (max-width: 576px) { + font-size: $dv-font-size-sm; + word-break: break-word; + } + } + &__subtext { color: $dv-subtext-color; font-size: $dv-font-size-sm; + + @media (max-width: 576px) { + word-break: break-word; + } } } .thumbnail-container { min-width: 80px; padding-left: 8px; + + @media (max-width: 576px) { + min-width: 30px; + } } .checksum-container { diff --git a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail.module.scss b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail.module.scss index eacbc5aa9..f985f3f1a 100644 --- a/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail.module.scss +++ b/src/sections/dataset/dataset-files/files-table/file-info/file-info-cell/file-info-data/file-thumbnail/FileThumbnail.module.scss @@ -12,6 +12,10 @@ width: 55px; height: 80px; + + @media (max-width: 576px) { + width: 30px; + } } .tooltip { diff --git a/src/sections/edit-collection-featured-items/EditFeaturedItems.tsx b/src/sections/edit-collection-featured-items/EditFeaturedItems.tsx index f3cc56875..3b3900bd7 100644 --- a/src/sections/edit-collection-featured-items/EditFeaturedItems.tsx +++ b/src/sections/edit-collection-featured-items/EditFeaturedItems.tsx @@ -4,7 +4,7 @@ import { Alert } from '@iqss/dataverse-design-system' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { useGetFeaturedItems } from '../collection/useGetFeaturedItems' import { useCollection } from '../collection/useCollection' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' import { NotFoundPage } from '../not-found-page/NotFoundPage' diff --git a/src/sections/edit-collection/EditCollection.tsx b/src/sections/edit-collection/EditCollection.tsx index 72ade9a15..0ee6c71a2 100644 --- a/src/sections/edit-collection/EditCollection.tsx +++ b/src/sections/edit-collection/EditCollection.tsx @@ -4,7 +4,7 @@ import { Alert } from '@iqss/dataverse-design-system' import { CollectionRepository } from '@/collection/domain/repositories/CollectionRepository' import { MetadataBlockInfoRepository } from '@/metadata-block-info/domain/repositories/MetadataBlockInfoRepository' import { useGetCollectionUserPermissions } from '@/shared/hooks/useGetCollectionUserPermissions' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { useSession } from '../session/SessionContext' import { useCollection } from '../collection/useCollection' import { User } from '@/users/domain/models/User' diff --git a/src/sections/edit-dataset-metadata/EditDatasetMetadata.tsx b/src/sections/edit-dataset-metadata/EditDatasetMetadata.tsx index 6b926d2fd..4ba85e69a 100644 --- a/src/sections/edit-dataset-metadata/EditDatasetMetadata.tsx +++ b/src/sections/edit-dataset-metadata/EditDatasetMetadata.tsx @@ -4,7 +4,7 @@ import { Alert, Tabs } from '@iqss/dataverse-design-system' import { DatasetRepository } from '../../dataset/domain/repositories/DatasetRepository' import { MetadataBlockInfoRepository } from '../../metadata-block-info/domain/repositories/MetadataBlockInfoRepository' import { useDataset } from '../dataset/DatasetContext' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { HostCollection } from './HostCollection' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { SeparationLine } from '../shared/layout/SeparationLine/SeparationLine' diff --git a/src/sections/edit-file-metadata/EditFileMetadata.tsx b/src/sections/edit-file-metadata/EditFileMetadata.tsx index fb8696490..c72819a0e 100644 --- a/src/sections/edit-file-metadata/EditFileMetadata.tsx +++ b/src/sections/edit-file-metadata/EditFileMetadata.tsx @@ -10,7 +10,7 @@ import { EditFileMetadataFormData, EditFilesList } from '@/sections/edit-file-metadata/EditFilesList' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { useFile } from '@/sections/file/useFile' import styles from './EditFileMetadata.module.scss' diff --git a/src/sections/featured-item/FeaturedItem.tsx b/src/sections/featured-item/FeaturedItem.tsx index 25c380a71..0403a96f5 100644 --- a/src/sections/featured-item/FeaturedItem.tsx +++ b/src/sections/featured-item/FeaturedItem.tsx @@ -5,7 +5,7 @@ import { CollectionRepository } from '@/collection/domain/repositories/Collectio import { CustomFeaturedItem } from '@/collection/domain/models/FeaturedItem' import { useGetFeaturedItems } from '../collection/useGetFeaturedItems' import { AppLoader } from '../shared/layout/app-loader/AppLoader' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { FeaturedItemView } from './featured-item-view/FeaturedItemView' import { useCollection } from '../collection/useCollection' diff --git a/src/sections/file/File.tsx b/src/sections/file/File.tsx index cb69ec267..ab8c00a4e 100644 --- a/src/sections/file/File.tsx +++ b/src/sections/file/File.tsx @@ -3,8 +3,8 @@ import styles from './File.module.scss' import { ButtonGroup, Col, Row, Tabs } from '@iqss/dataverse-design-system' import { FileRepository } from '../../files/domain/repositories/FileRepository' import { useFile } from './useFile' -import { useEffect, useState } from 'react' -import { useLoading } from '../loading/LoadingContext' +import { useEffect, useMemo, useState } from 'react' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { FileSkeleton } from './FileSkeleton' import { DatasetCitation } from '../dataset/dataset-citation/DatasetCitation' import { FileCitation } from './file-citation/FileCitation' @@ -19,32 +19,71 @@ import { NotFoundPage } from '../not-found-page/NotFoundPage' import { DraftAlert } from './draft-alert/DraftAlert' import { FileVersions } from './file-version/FileVersions' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { useExternalTools } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { FilePageHelper } from './FilePageHelper' +import { FileEmbeddedExternalTool } from './file-embedded-external-tool/FileEmbeddedExternalTool' +import { useScrollTop } from '@/shared/hooks/useScrollTop' import { DataverseInfoRepository } from '@/info/domain/repositories/DataverseInfoRepository' interface FileProps { + id: number repository: FileRepository datasetRepository: DatasetRepository - id: number - datasetVersionNumber?: string dataverseInfoRepository: DataverseInfoRepository + datasetVersionNumber?: string + toolTypeSelectedQueryParam?: string } export function File({ - repository, id, - datasetVersionNumber, + repository, datasetRepository, - dataverseInfoRepository + dataverseInfoRepository, + datasetVersionNumber, + toolTypeSelectedQueryParam }: FileProps) { + useScrollTop() const { setIsLoading } = useLoading() const { t } = useTranslation('file') const { file, isLoading } = useFile(repository, id, datasetVersionNumber) - const [activeTab, setActiveTab] = useState('metadata') + const { externalTools, externalToolsRepository } = useExternalTools() + const [activeTab, setActiveTab] = useState( + toolTypeSelectedQueryParam && file?.permissions.canDownloadFile + ? FilePageHelper.EXT_TOOL_TAB_KEY + : 'metadata' + ) + + const fileApplicablePreviewOrQueryTools = useMemo( + () => + FilePageHelper.getApplicablePreviewOrQueryToolsForFileType( + externalTools, + file?.metadata.type.value + ), + [externalTools, file] + ) + + const externalToolTabTitle: string = FilePageHelper.getExternalToolTabTitle( + fileApplicablePreviewOrQueryTools, + t, + file?.metadata.type.value + ) useEffect(() => { setIsLoading(isLoading) }, [isLoading, setIsLoading]) + // To change active tab to external tool in case the file has applicable tools + useEffect(() => { + if (file) { + const defaultActiveTab = FilePageHelper.defineDefaultActiveTab( + externalTools, + file?.metadata.type.value + ) + + setActiveTab(defaultActiveTab) + } + }, [file, externalTools]) + if (isLoading) { return } @@ -126,13 +165,27 @@ export function File({ storageIdentifier={file.metadata.storageIdentifier} existingLabels={file.metadata.labels} isTabularFile={file.metadata.isTabular} + fileType={file.metadata.type.value} datasetRepository={datasetRepository} /> )} - + + {fileApplicablePreviewOrQueryTools.length > 0 && file.permissions.canDownloadFile && ( + +
+ +
+
+ )}
@@ -34,6 +37,7 @@ function FileWithSearchParams() { id={id} datasetVersionNumber={datasetVersionNumber} datasetRepository={datasetRepository} + toolTypeSelectedQueryParam={toolTypeSelectedQueryParam} dataverseInfoRepository={dataverseInfoRepository} /> ) diff --git a/src/sections/file/FilePageHelper.ts b/src/sections/file/FilePageHelper.ts new file mode 100644 index 000000000..330102b68 --- /dev/null +++ b/src/sections/file/FilePageHelper.ts @@ -0,0 +1,91 @@ +import { ExternalTool, ToolScope, ToolType } from '@/externalTools/domain/models/ExternalTool' + +export class FilePageHelper { + static readonly EXT_TOOL_TAB_KEY = 'extTool' + + static defineDefaultActiveTab(externalTools: ExternalTool[], fileType?: string): string { + if (externalTools.length === 0) { + return 'metadata' + } + + if (this.getApplicablePreviewOrQueryToolsForFileType(externalTools, fileType).length > 0) { + return this.EXT_TOOL_TAB_KEY + } + + return 'metadata' + } + + static getApplicablePreviewOrQueryToolsForFileType( + externalTools: ExternalTool[], + fileType?: string + ): ExternalTool[] { + return externalTools + .filter((tool) => tool.scope === ToolScope.File) + .filter( + (tool) => tool.types.includes(ToolType.Preview) || tool.types.includes(ToolType.Query) + ) + .filter((tool) => (fileType ? tool.contentType === fileType : false)) + } + + static getApplicableToolsForFileType( + externalTools: ExternalTool[], + fileType?: string + ): ExternalTool[] { + return externalTools + .filter((tool) => tool.scope === ToolScope.File) + .filter((tool) => (fileType ? tool.contentType === fileType : false)) + } + + static getExternalToolTabTitle( + fileApplicablePreviewOrQueryTools: ExternalTool[], + t: (key: string) => string, + fileType?: string + ): string { + // Only one tool applicable and is a preview tool + if ( + fileApplicablePreviewOrQueryTools.length === 1 && + fileApplicablePreviewOrQueryTools[0].types.includes(ToolType.Preview) && + fileType === fileApplicablePreviewOrQueryTools[0].contentType + ) { + return t('tabs.preview') + } + + // Only one tool applicable and is a query tool + if ( + fileApplicablePreviewOrQueryTools.length === 1 && + fileApplicablePreviewOrQueryTools[0].types.includes(ToolType.Query) && + fileType === fileApplicablePreviewOrQueryTools[0].contentType + ) { + return t('tabs.query') + } + + // More than one applicable tool + if (fileApplicablePreviewOrQueryTools.length > 1) { + return t('tabs.fileTools') + } + + return t('tabs.preview') + } + + static getDefaultSelectedToolId( + toolTypeSelectedQueryParam: string | undefined, + applicableTools: ExternalTool[] + ): number { + if (toolTypeSelectedQueryParam) { + const matchedTool = applicableTools.find((tool) => + tool.types.includes(toolTypeSelectedQueryParam as ToolType) + ) + if (matchedTool) { + return matchedTool.id + } + } + // Fallback to the first applicable tool's id + return applicableTools[0].id + } + + static replacePreviewParamInToolUrl(url: string, preview: boolean): string { + const u = new URL(url, window.location.href) + u.searchParams.set('preview', String(preview)) + return u.toString() + } +} diff --git a/src/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.tsx b/src/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.tsx index 65153b184..4fde63019 100644 --- a/src/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.tsx +++ b/src/sections/file/file-action-buttons/access-file-menu/AccessFileMenu.tsx @@ -1,3 +1,4 @@ +import { ReactElement } from 'react' import { Download, FileEarmark } from 'react-bootstrap-icons' import { AccessStatus } from './AccessStatus' import { RequestAccessOption } from './RequestAccessOption' @@ -6,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { FileDownloadOptions } from './FileDownloadOptions' import { FileAccess } from '../../../../files/domain/models/FileAccess' import { FileMetadata } from '../../../../files/domain/models/FileMetadata' -import { ReactElement } from 'react' +import { FileExploreToolsOptions, FileQueryToolsOptions } from './FileToolOptions' interface FileActionButtonAccessFileProps { id: number @@ -82,6 +83,12 @@ export function AccessFileMenu({ isTabular={metadata.isTabular} userHasDownloadPermission={userHasDownloadPermission} /> + {userHasDownloadPermission && ( + <> + + + + )} ) diff --git a/src/sections/file/file-action-buttons/access-file-menu/FileToolOptions.tsx b/src/sections/file/file-action-buttons/access-file-menu/FileToolOptions.tsx new file mode 100644 index 000000000..840916b1b --- /dev/null +++ b/src/sections/file/file-action-buttons/access-file-menu/FileToolOptions.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { toast } from 'react-toastify' +import { useTranslation } from 'react-i18next' +import { + BarChartFill as BarChartFillIcon, + GearFill as GearFillIcon, + type Icon as IconType +} from 'react-bootstrap-icons' +import { DropdownButtonItem, DropdownHeader } from '@iqss/dataverse-design-system' +import { useExternalTools } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { getFileExternalToolResolved } from '@/externalTools/domain/useCases/GetFileExternalToolResolved' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { FilePageHelper } from '../../FilePageHelper' +import { ExternalTool } from '@/externalTools/domain/models/ExternalTool' + +type ToolKind = 'explore' | 'query' | 'configure' + +interface FileToolOptionsProps { + fileId: number + fileType: string + kind: ToolKind +} + +const FileToolOptions = ({ fileId, fileType, kind }: FileToolOptionsProps) => { + const { t } = useTranslation('shared') + const { fileExploreTools, fileQueryTools, fileConfigureTools, externalToolsRepository } = + useExternalTools() + + /** Per-kind config (single source of truth) */ + const configByKind: Record< + ToolKind, + { + tools: ExternalTool[] + headerI18nKey: string + Icon: IconType + } + > = { + explore: { tools: fileExploreTools, headerI18nKey: 'exploreOptions', Icon: BarChartFillIcon }, + query: { tools: fileQueryTools, headerI18nKey: 'queryOptions', Icon: BarChartFillIcon }, + configure: { tools: fileConfigureTools, headerI18nKey: 'configureOptions', Icon: GearFillIcon } + } + + const { tools, headerI18nKey, Icon } = configByKind[kind] + + if (!tools || tools.length === 0) return null + + const applicableTools = FilePageHelper.getApplicableToolsForFileType(tools, fileType) + + if (applicableTools.length === 0) return null + + return ( + <> + + {t(headerI18nKey)} + + + + {tools.map((tool) => ( + + ))} + + ) +} + +interface ToolOptionProps { + toolId: number + toolDisplayName: string + fileId: number + externalToolsRepository: ExternalToolsRepository +} + +const ToolOption = ({ + toolId, + toolDisplayName, + fileId, + externalToolsRepository +}: ToolOptionProps) => { + const [isOpening, setIsOpening] = useState(false) + const { t, i18n } = useTranslation('shared') + + const handleClick = async () => { + // If already opening, do nothing + if (isOpening) return + setIsOpening(true) + + // Open a blank window immediately to avoid popup blockers + const newWindow = window.open('', '_blank') + + // If the window didn't open, likely due to a popup blocker + if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') { + toast.info(t('allowPopups')) + setIsOpening(false) + newWindow?.close() + return + } + + try { + // Set a temporary title on the new window while fetching the tool URL + newWindow.document.title = `Loading ${toolDisplayName}...` + + const fileExternalTool = await getFileExternalToolResolved( + externalToolsRepository, + fileId, + toolId, + { preview: false, locale: i18n.language } + ) + // Change the location of the new window to the tool URL + newWindow.location.href = fileExternalTool.toolUrlResolved + } catch (error) { + // If there's an error, notify the user and close the new window + toast.error(t('externalToolOpeningFailed')) + if (!newWindow?.closed) newWindow.close() + } finally { + setIsOpening(false) + } + } + + return ( + + {toolDisplayName} + + ) +} + +/** Wrappers for readability */ +export const FileExploreToolsOptions = (props: Omit) => ( + +) + +export const FileQueryToolsOptions = (props: Omit) => ( + +) + +export const FileConfigureToolsOptions = (props: Omit) => ( + +) diff --git a/src/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.tsx b/src/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.tsx index d0b824745..4dc6deb7b 100644 --- a/src/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.tsx +++ b/src/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.tsx @@ -10,6 +10,7 @@ import { EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFile import { EditFileTagsButton } from './edit-file-tags/EditFileTagsButton' import { FileLabel } from '@/files/domain/models/FileMetadata' import { DatasetRepository } from '@/dataset/domain/repositories/DatasetRepository' +import { FileConfigureToolsOptions } from '../access-file-menu/FileToolOptions' interface EditFileMenuProps { fileId: number @@ -20,6 +21,7 @@ interface EditFileMenuProps { existingLabels?: FileLabel[] datasetRepository: DatasetRepository isTabularFile: boolean + fileType: string } export interface EditFileMenuDatasetInfo { @@ -38,7 +40,8 @@ export const EditFileMenu = ({ storageIdentifier, existingLabels, isTabularFile, - datasetRepository + datasetRepository, + fileType }: EditFileMenuProps) => { const { t } = useTranslation('file') @@ -86,6 +89,7 @@ export const EditFileMenu = ({ datasetRepository={datasetRepository} /> + ) } diff --git a/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.module.scss b/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.module.scss new file mode 100644 index 000000000..eab2d88c6 --- /dev/null +++ b/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.module.scss @@ -0,0 +1,54 @@ +.header { + display: flex; + gap: 0.5rem; +} + +.iframe-container { + position: relative; + aspect-ratio: 16 / 9; + padding-block: 1rem; + + .iframe { + width: 100%; + height: 100%; + border: none; + } + + .overlay { + position: absolute; + inset: 0; + top: 1rem; + z-index: 2; + display: flex; + justify-content: center; + padding-top: 3rem; + background-color: #fff; + opacity: 1; + pointer-events: none; // Prevents blocking interactions with the iframe + } + + &.loaded { + .overlay { + animation: ext-tool-overlay-fade-out 200ms ease forwards; + animation-delay: 1.25s; // To keep the overlay for a bit longer after the iframe is loaded to mask any flickering + + // A11ty: avoid animations for user that do not prefer it + @media (prefers-reduced-motion: reduce) { + opacity: 0; + animation: none; + } + } + } + + &.error { + .overlay { + opacity: 0; + } + } + + @keyframes ext-tool-overlay-fade-out { + to { + opacity: 0; + } + } +} diff --git a/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.tsx b/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.tsx new file mode 100644 index 000000000..512e7a6d1 --- /dev/null +++ b/src/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import cn from 'classnames' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { Alert, DropdownButton, DropdownButtonItem, Spinner } from '@iqss/dataverse-design-system' +import { BoxArrowUpRight } from 'react-bootstrap-icons' +import { ExternalTool } from '@/externalTools/domain/models/ExternalTool' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { FileExternalToolResolved } from '@/externalTools/domain/models/FileExternalToolResolved' +import { getFileExternalToolResolved } from '@/externalTools/domain/useCases/GetFileExternalToolResolved' +import { JSDataverseWriteErrorHandler } from '@/shared/helpers/JSDataverseWriteErrorHandler' +import { File } from '@/files/domain/models/File' +import { FilePageHelper } from '../FilePageHelper' +import styles from './FileEmbeddedExternalTool.module.scss' + +interface FileEmbeddedExternalToolProps { + file: File + isInView: boolean + applicableTools: ExternalTool[] + toolTypeSelectedQueryParam: string | undefined + externalToolsRepository: ExternalToolsRepository +} + +export const FileEmbeddedExternalTool = ({ + file, + isInView, + applicableTools, + toolTypeSelectedQueryParam, + externalToolsRepository +}: FileEmbeddedExternalToolProps) => { + const { t, i18n } = useTranslation('file', { keyPrefix: 'previewTab' }) + const [toolIdSelected, setToolIdSelected] = useState( + FilePageHelper.getDefaultSelectedToolId(toolTypeSelectedQueryParam, applicableTools) + ) + const [iframeLoaded, setIframeLoaded] = useState(false) + const [errorLoadingTool, setErrorLoadingTool] = useState(null) + const [fileExternalToolResolved, setFileExternalToolResolved] = + useState(null) + + const moreThanOneTool = applicableTools.length > 1 + + const handleToolSelect = (eventKey: string | null) => setToolIdSelected(Number(eventKey)) + + const handleOnLoadIframe = () => setIframeLoaded(true) + const handleOnErrorIframe = () => { + setIframeLoaded(false) + setErrorLoadingTool(t('defaultLoadingToolError')) + } + + // Loads the tool every time the tab is in view or the tool selection changes. + useEffect(() => { + if (!isInView) return + + const fetchFileExternalToolResolved = async () => { + setIframeLoaded(false) + setErrorLoadingTool(null) + setFileExternalToolResolved(null) + + try { + const fileExternalTool = await getFileExternalToolResolved( + externalToolsRepository, + file.id, + toolIdSelected, + { preview: true, locale: i18n.language } + ) + + setFileExternalToolResolved(fileExternalTool) + } catch (err: WriteError | unknown) { + if (err instanceof WriteError) { + const error = new JSDataverseWriteErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + setErrorLoadingTool(formattedError) + } else { + setErrorLoadingTool(t('defaultLoadingToolError')) + } + } + } + + void fetchFileExternalToolResolved() + }, [isInView, toolIdSelected, externalToolsRepository, file.id, t, i18n.language]) + + return ( +
+
+ {moreThanOneTool && ( + + {applicableTools.map((tool) => ( + + {tool.displayName} + + ))} + + )} + + {fileExternalToolResolved && ( + // eslint-disable-next-line react/jsx-no-target-blank + + + {t('openInNewWindow')} + + )} +
+ +
+ {fileExternalToolResolved && ( + + )} + {/* Keep overlay on top of the iframe while it loads to mask flickering */} +
+ +
+ {/* Show error message if fetching the tool URL fails or the iframe somehow fails. */} + {errorLoadingTool && ( + + {errorLoadingTool} + + )} +
+
+ ) +} diff --git a/src/sections/file/file-preview/FileIcon.module.scss b/src/sections/file/file-preview/FileIcon.module.scss index 0cb812702..2c439fa05 100644 --- a/src/sections/file/file-preview/FileIcon.module.scss +++ b/src/sections/file/file-preview/FileIcon.module.scss @@ -8,4 +8,8 @@ .icon { color: $dv-subtext-color; font-size: 64px; + + @media (max-width: 576px) { + font-size: 24px; + } } diff --git a/src/sections/homepage/Homepage.tsx b/src/sections/homepage/Homepage.tsx index 4cff459ea..cc583427d 100644 --- a/src/sections/homepage/Homepage.tsx +++ b/src/sections/homepage/Homepage.tsx @@ -7,7 +7,7 @@ import { SearchRepository } from '@/search/domain/repositories/SearchRepository' import { useGetSearchServices } from '@/search/domain/hooks/useGetSearchServices' import { useCollection } from '../collection/useCollection' import { FeaturedItems } from '../collection/featured-items/FeaturedItems' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { AppLoader } from '../shared/layout/app-loader/AppLoader' import { SearchInput } from './search-input/SearchInput' import { Metrics } from './metrics/Metrics' diff --git a/src/sections/layout/topbar-progress-indicator/TopbarProgressIndicator.tsx b/src/sections/layout/topbar-progress-indicator/TopbarProgressIndicator.tsx index 1c3b9d6ca..4d065e6c2 100644 --- a/src/sections/layout/topbar-progress-indicator/TopbarProgressIndicator.tsx +++ b/src/sections/layout/topbar-progress-indicator/TopbarProgressIndicator.tsx @@ -1,7 +1,7 @@ import TopBarProgress from 'react-topbar-progress-indicator' import { useTheme } from '@iqss/dataverse-design-system' import { useEffect, useState } from 'react' -import { useLoading } from '../../loading/LoadingContext' +import { useLoading } from '../../../shared/contexts/loading/LoadingContext' import isChromatic from 'chromatic/isChromatic' const TopBarProgressIndicator = () => { diff --git a/src/sections/not-found-page/NotFoundPage.tsx b/src/sections/not-found-page/NotFoundPage.tsx index b84cf7753..a259751ab 100644 --- a/src/sections/not-found-page/NotFoundPage.tsx +++ b/src/sections/not-found-page/NotFoundPage.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { Link } from 'react-router-dom' import { Trans, useTranslation } from 'react-i18next' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { Route } from '../Route.enum' import styles from './NotFoundPage.module.scss' diff --git a/src/sections/replace-file/ReplaceFile.tsx b/src/sections/replace-file/ReplaceFile.tsx index 8bbf2d009..ea8382456 100644 --- a/src/sections/replace-file/ReplaceFile.tsx +++ b/src/sections/replace-file/ReplaceFile.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Col, Row, Tabs } from '@iqss/dataverse-design-system' import { FileRepository } from '@/files/domain/repositories/FileRepository' import { useFile } from '../file/useFile' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { FileInfo } from './file-info/FileInfo' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' import { AppLoader } from '../shared/layout/app-loader/AppLoader' diff --git a/src/sections/shared/form/ContactForm/ContactCaptcha.tsx b/src/sections/shared/form/ContactForm/ContactCaptcha.tsx index 502a67e39..13fcedef0 100644 --- a/src/sections/shared/form/ContactForm/ContactCaptcha.tsx +++ b/src/sections/shared/form/ContactForm/ContactCaptcha.tsx @@ -9,8 +9,8 @@ export function Captcha() { const { t } = useTranslation('shared') const { control } = useFormContext() - const num1 = !isChromaticBuild ? Math.floor(Math.random() * 10) : 5 // Default value for Chromatic builds - const num2 = !isChromaticBuild ? Math.floor(Math.random() * 10) : 3 // Default value for Chromatic builds + const num1 = !isChromaticBuild ? Math.floor(Math.random() * 10) : /* istanbul ignore next */ 5 // Default value for Chromatic builds + const num2 = !isChromaticBuild ? Math.floor(Math.random() * 10) : /* istanbul ignore next */ 3 // Default value for Chromatic builds const captchaAnswer = num1 + num2 diff --git a/src/sections/shared/form/DatasetMetadataForm/index.tsx b/src/sections/shared/form/DatasetMetadataForm/index.tsx index 548e6c62a..107568919 100644 --- a/src/sections/shared/form/DatasetMetadataForm/index.tsx +++ b/src/sections/shared/form/DatasetMetadataForm/index.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useLoading } from '../../../loading/LoadingContext' +import { useLoading } from '../../../../shared/contexts/loading/LoadingContext' import { useGetMetadataBlocksInfo } from './useGetMetadataBlocksInfo' import { DatasetRepository } from '../../../../dataset/domain/repositories/DatasetRepository' import { MetadataBlockInfoRepository } from '../../../../metadata-block-info/domain/repositories/MetadataBlockInfoRepository' diff --git a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx index 4e2cf7911..3e85bbfdc 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/EditCreateCollectionForm.tsx @@ -5,7 +5,7 @@ import { useGetCollectionMetadataBlocksInfo } from '@/shared/hooks/useGetCollect import { useGetAllMetadataBlocksInfo } from '@/shared/hooks/useGetAllMetadataBlocksInfo' import { useGetCollectionFacets } from '@/shared/hooks/useGetCollectionFacets' import { useGetAllFacetableMetadataFields } from '@/shared/hooks/useGetAllFacetableMetadataFields' -import { useLoading } from '@/sections/loading/LoadingContext' +import { useLoading } from '@/shared/contexts/loading/LoadingContext' import { useDeepCompareMemo } from 'use-deep-compare' import { CollectionFormHelper } from './CollectionFormHelper' import { diff --git a/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/IdentifierField.tsx b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/IdentifierField.tsx index 3b2421ef9..58955cc4d 100644 --- a/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/IdentifierField.tsx +++ b/src/sections/shared/form/EditCreateCollectionForm/collection-form/top-fields-section/IdentifierField.tsx @@ -19,9 +19,11 @@ export const collectionNameToAlias = (name: string) => { // This is only to avoid difference snapshots in Chromatic builds, the real display origin will be the current window location const locationOrigin = - import.meta.env.STORYBOOK_CHROMATIC_BUILD === 'true' ? 'https://foo.com' : window.location.origin + import.meta.env.STORYBOOK_CHROMATIC_BUILD === 'true' + ? /* istanbul ignore next */ 'https://foo.com' + : window.location.origin -const BASENAME_URL = import.meta.env.BASE_URL ?? '' +const BASENAME_URL = import.meta.env.BASE_URL ?? /* istanbul ignore next */ '' interface IdentifierFieldProps { rules: UseControllerProps['rules'] diff --git a/src/sections/shared/link-to-page/LinkToPage.tsx b/src/sections/shared/link-to-page/LinkToPage.tsx index 430323d4d..eaa1fab39 100644 --- a/src/sections/shared/link-to-page/LinkToPage.tsx +++ b/src/sections/shared/link-to-page/LinkToPage.tsx @@ -7,13 +7,15 @@ interface LinkToPageProps { page: Route searchParams?: Record type: DvObjectType + className?: string } export function LinkToPage({ children, page, searchParams, - type + type, + className }: PropsWithChildren) { const searchParamsString: string = searchParams ? '?' + encodeSearchParamsToURI(searchParams) : '' @@ -21,7 +23,11 @@ export function LinkToPage({ return {children} } - return {children} + return ( + + {children} + + ) } const encodeSearchParamsToURI = (searchParams: Record) => { diff --git a/src/sections/sign-up/SignUp.tsx b/src/sections/sign-up/SignUp.tsx index 342f3989c..4d7e60f46 100644 --- a/src/sections/sign-up/SignUp.tsx +++ b/src/sections/sign-up/SignUp.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Alert, Tabs } from '@iqss/dataverse-design-system' import { UserRepository } from '@/users/domain/repositories/UserRepository' import { DataverseInfoRepository } from '@/info/domain/repositories/DataverseInfoRepository' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { ValidTokenNotLinkedAccountForm } from './valid-token-not-linked-account-form/ValidTokenNotLinkedAccountForm' import styles from './SignUp.module.scss' diff --git a/src/sections/upload-dataset-files/UploadDatasetFiles.tsx b/src/sections/upload-dataset-files/UploadDatasetFiles.tsx index 2c3b45bb4..0f828b99d 100644 --- a/src/sections/upload-dataset-files/UploadDatasetFiles.tsx +++ b/src/sections/upload-dataset-files/UploadDatasetFiles.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Tabs } from '@iqss/dataverse-design-system' import { FileRepository } from '../../files/domain/repositories/FileRepository' -import { useLoading } from '../loading/LoadingContext' +import { useLoading } from '../../shared/contexts/loading/LoadingContext' import { useDataset } from '../dataset/DatasetContext' import { NotFoundPage } from '../not-found-page/NotFoundPage' import { BreadcrumbsGenerator } from '../shared/hierarchy/BreadcrumbsGenerator' diff --git a/src/shared/contexts/external-tools/ExternalToolsProvider.tsx b/src/shared/contexts/external-tools/ExternalToolsProvider.tsx new file mode 100644 index 000000000..88ce8ccda --- /dev/null +++ b/src/shared/contexts/external-tools/ExternalToolsProvider.tsx @@ -0,0 +1,141 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { ExternalTool, ToolScope, ToolType } from '@/externalTools/domain/models/ExternalTool' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { getExternalTools } from '@/externalTools/domain/useCases/GetExternalTools' +import { ReadError } from '@iqss/dataverse-client-javascript' +import { JSDataverseReadErrorHandler } from '@/shared/helpers/JSDataverseReadErrorHandler' + +type ExternalToolsContextValue = { + externalTools: ExternalTool[] + loading: boolean + error: string | null + refreshExternalTools: () => Promise + datasetExploreTools: ExternalTool[] + datasetConfigureTools: ExternalTool[] + fileExploreTools: ExternalTool[] + filePreviewTools: ExternalTool[] + fileQueryTools: ExternalTool[] + fileConfigureTools: ExternalTool[] + externalToolsRepository: ExternalToolsRepository +} + +const ExternalToolsContext = createContext(undefined) + +type ExternalToolsProviderProps = { + externalToolsRepository: ExternalToolsRepository + children: React.ReactNode +} + +export function ExternalToolsProvider({ + externalToolsRepository, + children +}: ExternalToolsProviderProps) { + const [externalTools, setExternalTools] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchExternalTools = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const data = await getExternalTools(externalToolsRepository) + + // Temporary workaround, filter out external tools that have a requirement field defined + const toolsWithoutRequirements = data.filter((tool) => !tool.requirements) + + setExternalTools(toolsWithoutRequirements) + } catch (err: ReadError | unknown) { + if (err instanceof ReadError) { + const error = new JSDataverseReadErrorHandler(err) + const formattedError = + error.getReasonWithoutStatusCode() ?? /* istanbul ignore next */ error.getErrorMessage() + + setError(formattedError) + } else { + setError('An unexpected error occurred while fetching external tools.') + } + } finally { + setLoading(false) + } + }, [externalToolsRepository]) + + useEffect(() => { + void fetchExternalTools() + }, [fetchExternalTools]) + + const datasetExploreTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.Dataset && tool.types.includes(ToolType.Explore) + ) + }, [externalTools]) + + const datasetConfigureTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.Dataset && tool.types.includes(ToolType.Configure) + ) + }, [externalTools]) + + const fileExploreTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.File && tool.types.includes(ToolType.Explore) + ) + }, [externalTools]) + + const filePreviewTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.File && tool.types.includes(ToolType.Preview) + ) + }, [externalTools]) + + const fileQueryTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.File && tool.types.includes(ToolType.Query) + ) + }, [externalTools]) + + const fileConfigureTools = useMemo(() => { + return externalTools.filter( + (tool) => tool.scope === ToolScope.File && tool.types.includes(ToolType.Configure) + ) + }, [externalTools]) + + const value = useMemo( + () => ({ + externalTools, + loading, + error, + datasetExploreTools, + datasetConfigureTools, + fileExploreTools, + filePreviewTools, + fileQueryTools, + fileConfigureTools, + refreshExternalTools: fetchExternalTools, + externalToolsRepository + }), + [ + externalTools, + loading, + error, + datasetExploreTools, + datasetConfigureTools, + fileExploreTools, + filePreviewTools, + fileQueryTools, + fileConfigureTools, + fetchExternalTools, + externalToolsRepository + ] + ) + + return {children} +} + +export function useExternalTools() { + const ctx = useContext(ExternalToolsContext) + if (!ctx) { + throw new Error('useExternalTools must be used within a ExternalToolsProvider') + } + return ctx +} diff --git a/src/sections/loading/LoadingContext.ts b/src/shared/contexts/loading/LoadingContext.ts similarity index 100% rename from src/sections/loading/LoadingContext.ts rename to src/shared/contexts/loading/LoadingContext.ts diff --git a/src/sections/loading/LoadingProvider.tsx b/src/shared/contexts/loading/LoadingProvider.tsx similarity index 100% rename from src/sections/loading/LoadingProvider.tsx rename to src/shared/contexts/loading/LoadingProvider.tsx diff --git a/src/stories/WithLayout.tsx b/src/stories/WithLayout.tsx index 34a5af36d..cc7b89175 100644 --- a/src/stories/WithLayout.tsx +++ b/src/stories/WithLayout.tsx @@ -1,7 +1,7 @@ import { StoryFn } from '@storybook/react' import { Routes, Route } from 'react-router-dom' import { Layout } from '../sections/layout/Layout' -import { LoadingProvider } from '../sections/loading/LoadingProvider' +import { LoadingProvider } from '../shared/contexts/loading/LoadingProvider' export const WithLayout = (Story: StoryFn) => ( diff --git a/src/stories/collection/Collection.stories.tsx b/src/stories/collection/Collection.stories.tsx index 76bbb1c07..019c0fd02 100644 --- a/src/stories/collection/Collection.stories.tsx +++ b/src/stories/collection/Collection.stories.tsx @@ -8,7 +8,7 @@ import { CollectionLoadingMockRepository } from './CollectionLoadingMockReposito import { UnpublishedCollectionMockRepository } from '@/stories/collection/UnpublishedCollectionMockRepository' import { FeaturedItemMother } from '@tests/component/collection/domain/models/FeaturedItemMother' import { FakerHelper } from '@tests/component/shared/FakerHelper' -import { ContactMockRepository } from '../shared/contact/ContactMockRepository' +import { ContactMockRepository } from '../shared-mock-repositories/contact/ContactMockRepository' const meta: Meta = { title: 'Pages/Collection', diff --git a/src/stories/dataset/Dataset.stories.tsx b/src/stories/dataset/Dataset.stories.tsx index 2b63d1c91..697318bed 100644 --- a/src/stories/dataset/Dataset.stories.tsx +++ b/src/stories/dataset/Dataset.stories.tsx @@ -18,7 +18,7 @@ import { WithNotImplementedModal } from '../WithNotImplementedModal' import { MetadataBlockInfoMockRepository } from '../shared-mock-repositories/metadata-block-info/MetadataBlockInfoMockRepository' import { DatasetMockRepository } from './DatasetMockRepository' import { CollectionMockRepository } from '@/stories/collection/CollectionMockRepository' -import { ContactMockRepository } from '../shared/contact/ContactMockRepository' +import { ContactMockRepository } from '../shared-mock-repositories/contact/ContactMockRepository' import { DataverseInfoMockRepository } from '../shared-mock-repositories/info/DataverseInfoMockRepository' const meta: Meta = { diff --git a/src/stories/dataset/dataset-action-buttons/DatasetActionButtons.stories.tsx b/src/stories/dataset/dataset-action-buttons/DatasetActionButtons.stories.tsx index 8d20e4a55..cd68165a1 100644 --- a/src/stories/dataset/dataset-action-buttons/DatasetActionButtons.stories.tsx +++ b/src/stories/dataset/dataset-action-buttons/DatasetActionButtons.stories.tsx @@ -11,7 +11,7 @@ import { import { WithLoggedInUser } from '../../WithLoggedInUser' import { DatasetMockRepository } from '../DatasetMockRepository' import { CollectionMockRepository } from '@/stories/collection/CollectionMockRepository' -import { ContactMockRepository } from '@/stories/shared/contact/ContactMockRepository' +import { ContactMockRepository } from '@/stories/shared-mock-repositories/contact/ContactMockRepository' const meta: Meta = { title: 'Sections/Dataset Page/DatasetActionButtons', diff --git a/src/stories/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.stories.tsx b/src/stories/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.stories.tsx index 0721b6276..852af5d33 100644 --- a/src/stories/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.stories.tsx +++ b/src/stories/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.stories.tsx @@ -8,6 +8,8 @@ import { DatasetVersionMother } from '../../../../../tests/component/dataset/domain/models/DatasetMother' import { AccessDatasetMenu } from '../../../../sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' const meta: Meta = { title: 'Sections/Dataset Page/DatasetActionButtons/AccessDatasetMenu', @@ -31,6 +33,7 @@ export const WithDownloadNotAllowed: Story = { fileDownloadSizes={[DatasetFileDownloadSizeMother.createOriginal()]} downloadUrls={DatasetDownloadUrlsMother.create()} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) } @@ -43,6 +46,7 @@ export const WithoutTabularFiles: Story = { fileDownloadSizes={[DatasetFileDownloadSizeMother.createOriginal()]} downloadUrls={DatasetDownloadUrlsMother.create()} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) } @@ -58,6 +62,26 @@ export const WithTabularFiles: Story = { ]} downloadUrls={DatasetDownloadUrlsMother.create()} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) } + +export const WithExploreOptionsTools: Story = { + render: () => ( + + + + ) +} diff --git a/src/stories/dataset/dataset-files/files-table/file-actions/file-action-buttons/file-options-menu/FileOptionsMenu.stories.tsx b/src/stories/dataset/dataset-files/files-table/file-actions/file-action-buttons/file-options-menu/FileOptionsMenu.stories.tsx index 76fdc9f8c..f3afe49b2 100644 --- a/src/stories/dataset/dataset-files/files-table/file-actions/file-action-buttons/file-options-menu/FileOptionsMenu.stories.tsx +++ b/src/stories/dataset/dataset-files/files-table/file-actions/file-action-buttons/file-options-menu/FileOptionsMenu.stories.tsx @@ -8,6 +8,10 @@ import { WithDatasetLockedFromEdits } from '../../../../../WithDatasetLockedFrom import { FilePreviewMother } from '../../../../../../../../tests/component/files/domain/models/FilePreviewMother' import { FileMockRepository } from '@/stories/file/FileMockRepository' import { DatasetMockRepository } from '@/stories/dataset/DatasetMockRepository' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' +import { FakerHelper } from '@tests/component/shared/FakerHelper' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' const meta: Meta = { title: @@ -63,6 +67,28 @@ export const WithFileAlreadyDeleted: Story = { ) } +const externalToolsRepositoryWithFileConfigureTool = new ExternalToolsMockRepository() +externalToolsRepositoryWithFileConfigureTool.getExternalTools = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ExternalToolsMother.createFileConfigureTool()]) + }, FakerHelper.loadingTimout()) + }) +} + +export const WithConfigureTool: Story = { + decorators: [WithDatasetAllPermissionsGranted], + render: () => ( + + + + ) +} + // // export const WithEmbargoAllowed: Story = { // render: () => diff --git a/src/stories/file/File.stories.tsx b/src/stories/file/File.stories.tsx index 3470fbfa7..0432be4c2 100644 --- a/src/stories/file/File.stories.tsx +++ b/src/stories/file/File.stories.tsx @@ -7,6 +7,10 @@ import { FileMockLoadingRepository } from './FileMockLoadingRepository' import { FileMockNoDataRepository } from './FileMockNoDataRepository' import { FileMother } from '../../../tests/component/files/domain/models/FileMother' import { DatasetMockRepository } from '../dataset/DatasetMockRepository' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '../shared-mock-repositories/externalTools/ExternalToolsMockRepository' +import { FakerHelper } from '@tests/component/shared/FakerHelper' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' import { DataverseInfoMockRepository } from '../shared-mock-repositories/info/DataverseInfoMockRepository' const meta: Meta = { @@ -76,3 +80,63 @@ export const FileNotFound: Story = { /> ) } + +export const WithMultipleExternalTools: Story = { + render: () => ( + + + + ) +} + +const externalToolsRepositoryOnlyPreviewTool = new ExternalToolsMockRepository() +externalToolsRepositoryOnlyPreviewTool.getExternalTools = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ExternalToolsMother.createFilePreviewTool()]) + }, FakerHelper.loadingTimout()) + }) +} + +export const WithOnlyOnePreviewExternalTool: Story = { + render: () => ( + + + + ) +} + +const externalToolsRepositoryOnlyQueryTool = new ExternalToolsMockRepository() +externalToolsRepositoryOnlyQueryTool.getExternalTools = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ExternalToolsMother.createFileQueryTool()]) + }, FakerHelper.loadingTimout()) + }) +} + +export const WithOnlyOneQueryExternalTool: Story = { + render: () => ( + + + + ) +} diff --git a/src/stories/file/file-action-buttons/access-file-menu/AccessFileMenu.stories.tsx b/src/stories/file/file-action-buttons/access-file-menu/AccessFileMenu.stories.tsx index dd4e3c26d..2864e0568 100644 --- a/src/stories/file/file-action-buttons/access-file-menu/AccessFileMenu.stories.tsx +++ b/src/stories/file/file-action-buttons/access-file-menu/AccessFileMenu.stories.tsx @@ -4,6 +4,8 @@ import { WithI18next } from '../../../WithI18next' import { WithSettings } from '../../../WithSettings' import { FileAccessMother } from '../../../../../tests/component/files/domain/models/FileAccessMother' import { FileMetadataMother } from '../../../../../tests/component/files/domain/models/FileMetadataMother' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' const meta: Meta = { title: 'Sections/File Page/Action Buttons/AccessFileMenu', @@ -168,3 +170,19 @@ export const WithEmbargoAndRestrictedWithAccessGranted: Story = { /> ) } + +export const WithExploreAndQueryOptionsTools: Story = { + render: () => ( + + + + ) +} diff --git a/src/stories/file/file-action-buttons/edit-file-dropdown/EditFileDropdown.stories.tsx b/src/stories/file/file-action-buttons/edit-file-dropdown/EditFileDropdown.stories.tsx index c8205eb0a..31b83af68 100644 --- a/src/stories/file/file-action-buttons/edit-file-dropdown/EditFileDropdown.stories.tsx +++ b/src/stories/file/file-action-buttons/edit-file-dropdown/EditFileDropdown.stories.tsx @@ -5,6 +5,10 @@ import { EditFileMenu } from '@/sections/file/file-action-buttons/edit-file-menu import { FileMother } from '@tests/component/files/domain/models/FileMother' import { FileMockRepository } from '../../FileMockRepository' import { DatasetMockRepository } from '../../../dataset/DatasetMockRepository' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' +import { FakerHelper } from '@tests/component/shared/FakerHelper' const storyFile = FileMother.createRealistic() @@ -32,6 +36,38 @@ export const Default: Story = { }} storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} + fileType={storyFile.metadata.type.value} /> ) } + +const externalToolsRepositoryWithFileConfigureTool = new ExternalToolsMockRepository() +externalToolsRepositoryWithFileConfigureTool.getExternalTools = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ExternalToolsMother.createFileConfigureTool()]) + }, FakerHelper.loadingTimout()) + }) +} + +export const WithConfigureToolOption: Story = { + render: () => ( + + + + ) +} diff --git a/src/stories/file/file-embedded-external-tool/FileEmbeddedExternalTool.stories.tsx b/src/stories/file/file-embedded-external-tool/FileEmbeddedExternalTool.stories.tsx new file mode 100644 index 000000000..dcc339520 --- /dev/null +++ b/src/stories/file/file-embedded-external-tool/FileEmbeddedExternalTool.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryObj } from '@storybook/react' +import { WithI18next } from '../../WithI18next' +import { FileEmbeddedExternalTool } from '@/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool' +import { FileMother } from '@tests/component/files/domain/models/FileMother' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' + +const meta: Meta = { + title: 'Sections/File Page/File External Tools Tab', + component: FileEmbeddedExternalTool, + decorators: [WithI18next] +} + +export default meta +type Story = StoryObj + +const file = FileMother.createRealistic() // text/plain file +const externalToolsRepository = new ExternalToolsMockRepository() + +export const WithOneToolOnly: Story = { + render: () => ( + + ) +} + +export const WithMoreThanOneTool: Story = { + render: () => ( + + ) +} diff --git a/src/stories/shared/contact/ContactMockRepository.ts b/src/stories/shared-mock-repositories/contact/ContactMockRepository.ts similarity index 100% rename from src/stories/shared/contact/ContactMockRepository.ts rename to src/stories/shared-mock-repositories/contact/ContactMockRepository.ts diff --git a/src/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository.ts b/src/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository.ts new file mode 100644 index 000000000..2a91a7aea --- /dev/null +++ b/src/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository.ts @@ -0,0 +1,53 @@ +import { DatasetExternalToolResolved } from '@/externalTools/domain/models/DatasetExternalToolResolved' +import { ExternalTool } from '@/externalTools/domain/models/ExternalTool' +import { FileExternalToolResolved } from '@/externalTools/domain/models/FileExternalToolResolved' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { GetExternalToolDTO } from '@/externalTools/domain/useCases/DTOs/GetExternalToolDTO' +import { DatasetExternalToolResolvedMother } from '@tests/component/externalTools/domain/models/DatasetExternalToolResolvedMother' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' +import { FileExternalToolResolvedMother } from '@tests/component/externalTools/domain/models/FileExternalToolResolvedMother' +import { FakerHelper } from '@tests/component/shared/FakerHelper' + +export class ExternalToolsMockRepository implements ExternalToolsRepository { + getExternalTools(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(ExternalToolsMother.createList()) + }, FakerHelper.loadingTimout()) + }) + } + + getDatasetExternalToolResolved( + _datasetId: number | string, + _toolId: number, + _getExternalToolDTO: GetExternalToolDTO + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(DatasetExternalToolResolvedMother.create()) + }, FakerHelper.loadingTimout()) + }) + } + + getFileExternalToolResolved( + _fileId: number | string, + _toolId: number, + _getExternalToolDTO: GetExternalToolDTO + ): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(FileExternalToolResolvedMother.create()) + }, FakerHelper.loadingTimout()) + }) + } +} + +export class ExternalToolsEmptyMockRepository implements Partial { + getExternalTools(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([]) + }, FakerHelper.loadingTimout()) + }) + } +} diff --git a/src/stories/shared/contact/contact-modal/ContactModal.stories.tsx b/src/stories/shared/contact-modal/ContactModal.stories.tsx similarity index 94% rename from src/stories/shared/contact/contact-modal/ContactModal.stories.tsx rename to src/stories/shared/contact-modal/ContactModal.stories.tsx index e323854ae..f08e6f03d 100644 --- a/src/stories/shared/contact/contact-modal/ContactModal.stories.tsx +++ b/src/stories/shared/contact-modal/ContactModal.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react' -import { WithI18next } from '../../../WithI18next' +import { WithI18next } from '../../WithI18next' import { ContactModal } from '@/sections/shared/contact/contact-modal/contact-modal' import { ContactRepository } from '@/contact/domain/repositories/ContactRepository' diff --git a/tests/component/externalTools/domain/models/DatasetExternalToolResolvedMother.ts b/tests/component/externalTools/domain/models/DatasetExternalToolResolvedMother.ts new file mode 100644 index 000000000..6162f1e56 --- /dev/null +++ b/tests/component/externalTools/domain/models/DatasetExternalToolResolvedMother.ts @@ -0,0 +1,13 @@ +import { DatasetExternalToolResolved } from '@/externalTools/domain/models/DatasetExternalToolResolved' + +export class DatasetExternalToolResolvedMother { + static create(props?: Partial): DatasetExternalToolResolved { + return { + displayName: 'Dataset Explore Tool', + datasetId: 1, + preview: false, + toolUrlResolved: 'http://localhost:3000/external-tool', + ...props + } + } +} diff --git a/tests/component/externalTools/domain/models/ExternalToolsMother.ts b/tests/component/externalTools/domain/models/ExternalToolsMother.ts new file mode 100644 index 000000000..a43b50641 --- /dev/null +++ b/tests/component/externalTools/domain/models/ExternalToolsMother.ts @@ -0,0 +1,78 @@ +import { ToolScope, ToolType } from '@/externalTools/domain/models/ExternalTool' +import { ExternalTool } from '@iqss/dataverse-client-javascript' + +export class ExternalToolsMother { + static createList(props?: Partial): ExternalTool[] { + return [ + this.createDatasetExploreTool(), + this.createFilePreviewTool(), + this.createFileExploreTool(), + this.createFileQueryTool(), + this.createFileConfigureTool() + ].map((tool) => ({ ...tool, ...props })) + } + + static createDatasetExploreTool(): ExternalTool { + return { + id: 1, + displayName: 'Dataset Explore Tool', + description: 'Description for Dataset Explore Tool', + scope: ToolScope.Dataset, + types: [ToolType.Explore] + } + } + + static createDatasetConfigureTool(): ExternalTool { + return { + id: 5, + displayName: 'Dataset Configure Tool', + description: 'Description for Dataset Configure Tool', + scope: ToolScope.Dataset, + types: [ToolType.Configure] + } + } + + static createFilePreviewTool(): ExternalTool { + return { + id: 2, + displayName: 'File Preview Tool', + description: 'Description for File Preview Tool', + scope: ToolScope.File, + types: [ToolType.Preview], + contentType: 'text/plain' + } + } + + static createFileExploreTool(): ExternalTool { + return { + id: 3, + displayName: 'File Explore Tool', + description: 'Description for File Explore Tool', + scope: ToolScope.File, + types: [ToolType.Explore], + contentType: 'text/plain' + } + } + + static createFileQueryTool(): ExternalTool { + return { + id: 4, + displayName: 'File Query Tool', + description: 'Description for File Query Tool', + scope: ToolScope.File, + types: [ToolType.Query], + contentType: 'text/plain' + } + } + + static createFileConfigureTool(): ExternalTool { + return { + id: 6, + displayName: 'File Configure Tool', + description: 'Description for File Configure Tool', + scope: ToolScope.File, + types: [ToolType.Configure], + contentType: 'text/plain' + } + } +} diff --git a/tests/component/externalTools/domain/models/FileExternalToolResolvedMother.ts b/tests/component/externalTools/domain/models/FileExternalToolResolvedMother.ts new file mode 100644 index 000000000..1c13d76d7 --- /dev/null +++ b/tests/component/externalTools/domain/models/FileExternalToolResolvedMother.ts @@ -0,0 +1,13 @@ +import { FileExternalToolResolved } from '@/externalTools/domain/models/FileExternalToolResolved' + +export class FileExternalToolResolvedMother { + static create(props?: Partial): FileExternalToolResolved { + return { + displayName: 'File Explore Tool', + fileId: 1, + preview: false, + toolUrlResolved: 'https://example.com/explore-tool?fileId=1', + ...props + } + } +} diff --git a/tests/component/sections/account/ApiTokenSection.spec.tsx b/tests/component/sections/account/ApiTokenSection.spec.tsx index c23ed0f8a..bcd38f735 100644 --- a/tests/component/sections/account/ApiTokenSection.spec.tsx +++ b/tests/component/sections/account/ApiTokenSection.spec.tsx @@ -76,4 +76,28 @@ describe('ApiTokenSection', () => { cy.get('[data-testid="noApiToken"]').should('exist') cy.get('button').contains('Create Token').should('exist') }) + + it('should show error message when failing to fetch the current API token', () => { + userRepository.getCurrentApiToken = cy.stub().rejects(new Error('Failed to fetch API token')) + + cy.mountAuthenticated() + + cy.findByText(/Failed to fetch API token/).should('exist') + }) + + it('should show error message when failing to recreate the API token', () => { + userRepository.recreateApiToken = cy.stub().rejects(new Error('Failed to recreate API token')) + + cy.findByRole('button', { name: 'Recreate Token' }).click() + + cy.findByText(/Failed to recreate API token/).should('exist') + }) + + it('should show error message when failing to revoke the API token', () => { + userRepository.deleteApiToken = cy.stub().rejects(new Error('Failed to revoke API token')) + + cy.findByRole('button', { name: 'Revoke Token' }).click() + + cy.findByText(/Failed to revoke API token/).should('exist') + }) }) diff --git a/tests/component/sections/create-dataset/CreateDataset.spec.tsx b/tests/component/sections/create-dataset/CreateDataset.spec.tsx index 3ba26238b..59330796d 100644 --- a/tests/component/sections/create-dataset/CreateDataset.spec.tsx +++ b/tests/component/sections/create-dataset/CreateDataset.spec.tsx @@ -229,19 +229,5 @@ describe('Create Dataset', () => { cy.findAllByText('Template 2').should('exist').should('have.length', 2) // Template 2 is selected, we see two }) - - it('shows the warning alert when there is an error loading the templates', () => { - datasetRepository.getTemplates = cy.stub().rejects() - - cy.customMount( - - ) - cy.findByText(/Something went wrong getting the dataset templates./) - }) }) }) diff --git a/tests/component/sections/dataset/Dataset.spec.tsx b/tests/component/sections/dataset/Dataset.spec.tsx index 8dfd238d9..2a37cb2ca 100644 --- a/tests/component/sections/dataset/Dataset.spec.tsx +++ b/tests/component/sections/dataset/Dataset.spec.tsx @@ -1,7 +1,7 @@ import { DatasetRepository } from '../../../../src/dataset/domain/repositories/DatasetRepository' import { Dataset } from '../../../../src/sections/dataset/Dataset' import { DatasetMother } from '../../dataset/domain/models/DatasetMother' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' import { ANONYMIZED_FIELD_VALUE, MetadataBlockName diff --git a/tests/component/sections/dataset/DatasetProvider.spec.tsx b/tests/component/sections/dataset/DatasetProvider.spec.tsx index 2e2cf2420..9e7d4962b 100644 --- a/tests/component/sections/dataset/DatasetProvider.spec.tsx +++ b/tests/component/sections/dataset/DatasetProvider.spec.tsx @@ -2,7 +2,7 @@ import { DatasetProvider } from '../../../../src/sections/dataset/DatasetProvide import { DatasetRepository } from '../../../../src/dataset/domain/repositories/DatasetRepository' import { DatasetMother } from '../../dataset/domain/models/DatasetMother' import { useDataset } from '../../../../src/sections/dataset/DatasetContext' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' function TestComponent() { const { dataset, isLoading } = useDataset() diff --git a/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx new file mode 100644 index 000000000..7e37533ed --- /dev/null +++ b/tests/component/sections/dataset/dataset-action-buttons/DatasetToolOptions.spec.tsx @@ -0,0 +1,64 @@ +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { + DatasetConfigureOptions, + DatasetExploreOptions +} from '@/sections/dataset/dataset-action-buttons/DatasetToolsOptions' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' + +const testExternalToolsRepository: ExternalToolsRepository = {} as ExternalToolsRepository + +describe('DatasetToolOptions', () => { + beforeEach(() => { + testExternalToolsRepository.getExternalTools = cy + .stub() + .resolves([ + ExternalToolsMother.createDatasetExploreTool(), + ExternalToolsMother.createDatasetConfigureTool() + ]) + }) + + it('renders the dataset configure tools options if they are available', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Configure Options').should('exist') + cy.findByText('Dataset Configure Tool').should('exist') + }) + + it('renders nothing if there are no dataset configure tools', () => { + testExternalToolsRepository.getExternalTools = cy.stub().resolves([]) + cy.customMount( + + + + ) + cy.findByText('Configure Options').should('not.exist') + }) + + it('renders the dataset explore tools options if they are available', () => { + cy.customMount( + + + + ) + + cy.findByText('Configure Options').should('not.exist') + cy.findByText('Explore Options').should('exist') + cy.findByText('Dataset Explore Tool').should('exist') + }) + + it('renders nothing if there are no dataset explore tools', () => { + testExternalToolsRepository.getExternalTools = cy.stub().resolves([]) + cy.customMount( + + + + ) + cy.findByText('Explore Options').should('not.exist') + }) +}) diff --git a/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx index a05a1454d..e088fc0d8 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/access-dataset-menu/AccessDatasetMenu.spec.tsx @@ -8,6 +8,7 @@ import { import { FileSizeUnit } from '../../../../../../src/files/domain/models/FileMetadata' const downloadUrls = DatasetDownloadUrlsMother.create() + describe('AccessDatasetMenu', () => { it('renders the AccessDatasetMenu if the user has download files permissions and the dataset is not deaccessioned', () => { const version = DatasetVersionMother.createReleased() @@ -24,6 +25,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) @@ -48,6 +50,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('exist') @@ -68,6 +71,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') @@ -88,6 +92,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') @@ -107,6 +112,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('exist') @@ -134,6 +140,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('exist') @@ -164,6 +171,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') @@ -184,6 +192,7 @@ describe('AccessDatasetMenu', () => { permissions={permissions} downloadUrls={downloadUrls} fileStore="not-s3" + persistentId="doi:10.5072/FK2/ABCDEFGH" /> ) cy.findByRole('button', { name: 'Access Dataset' }).should('not.exist') diff --git a/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.spec.tsx b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.spec.tsx index 1419a559d..1523c6616 100644 --- a/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.spec.tsx +++ b/tests/component/sections/dataset/dataset-action-buttons/edit-dataset-menu/EditDatasetMenu.spec.tsx @@ -248,4 +248,23 @@ describe('EditDatasetMenu', () => { cy.findByRole('button', { name: 'Edit Dataset' }).click() cy.findByRole('button', { name: 'Files (Upload)' }).should('not.exist') }) + + it('renders the Edit Private URL if user can manage file permissions', () => { + const dataset = DatasetMother.create({ + permissions: DatasetPermissionsMother.create({ + canUpdateDataset: true, + canManageDatasetPermissions: false, + canManageFilesPermissions: true + }), + locks: [], + hasValidTermsOfAccess: true + }) + + cy.mountAuthenticated( + + ) + + cy.findByRole('button', { name: 'Edit Dataset' }).click() + cy.findByRole('button', { name: 'Private URL' }).should('exist') + }) }) diff --git a/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.spec.tsx b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.spec.tsx index 6880e1f90..3e62ab49d 100644 --- a/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.spec.tsx +++ b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileActionButtons.spec.tsx @@ -10,10 +10,17 @@ import { FileRepository } from '@/files/domain/repositories/FileRepository' const file = FilePreviewMother.createDefault() const fileRepository: FileRepository = {} as FileRepository +const datasetRepository: DatasetRepository = {} as DatasetRepository describe('FileActionButtons', () => { it('renders the file action buttons', () => { - cy.customMount() + cy.customMount( + + ) cy.findByRole('group', { name: 'File Action Buttons' }).should('exist') cy.findByRole('button', { name: 'Access File' }).should('exist') @@ -32,7 +39,11 @@ describe('FileActionButtons', () => { - + ) diff --git a/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileTools.spec.tsx b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileTools.spec.tsx new file mode 100644 index 000000000..9ff86f86a --- /dev/null +++ b/tests/component/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileTools.spec.tsx @@ -0,0 +1,71 @@ +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { FileTools } from '@/sections/dataset/dataset-files/files-table/file-actions/file-actions-cell/file-action-buttons/FileTools' +import { QueryParamKey } from '@/sections/Route.enum' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' +import { FileMetadataMother } from '@tests/component/files/domain/models/FileMetadataMother' +import { FilePreviewMother } from '@tests/component/files/domain/models/FilePreviewMother' + +const testFilePreview = FilePreviewMother.createDefault() // text/plain file +const testExternalToolsRepository: ExternalToolsRepository = {} as ExternalToolsRepository + +describe('FileTools', () => { + beforeEach(() => { + testExternalToolsRepository.getExternalTools = cy + .stub() + .resolves([ + ExternalToolsMother.createFilePreviewTool(), + ExternalToolsMother.createFileQueryTool() + ]) + }) + + it('renders external tool buttons when user can download the file and there are applicable tools', () => { + cy.customMount( + + + + ) + + cy.findByRole('link', { name: `Preview ${testFilePreview.name}` }) + .should('exist') + .as('filePreviewButton') + + cy.findByRole('link', { name: `Query ${testFilePreview.name}` }) + .should('exist') + .as('fileQueryButton') + + cy.get('@filePreviewButton') + .should('have.attr', 'href') + .and('include', `id=${testFilePreview.id}`) + .and('include', `datasetVersion=${testFilePreview.datasetVersionNumber.toString()}`) + .and('include', `${QueryParamKey.TOOL_TYPE}=preview`) + + cy.get('@fileQueryButton') + .should('have.attr', 'href') + .and('include', `id=${testFilePreview.id}`) + .and('include', `datasetVersion=${testFilePreview.datasetVersionNumber.toString()}`) + .and('include', `${QueryParamKey.TOOL_TYPE}=query`) + }) + + it('does not render external tool buttons when user cannot download the file', () => { + cy.customMount( + + + + ) + }) + + it('does not render external tool buttons when there are no applicable tools for the file type', () => { + // File type "tabular" has no applicable preview or query tools in the test repository + const fileWithoutApplicableTools = FilePreviewMother.create({ + id: 2, + metadata: FileMetadataMother.createTabular() + }) + + cy.customMount( + + + + ) + }) +}) diff --git a/tests/component/sections/dataset/dataset-metadata/DatasetMetadata.spec.tsx b/tests/component/sections/dataset/dataset-metadata/DatasetMetadata.spec.tsx index 331b5de6e..7ab8d1e3a 100644 --- a/tests/component/sections/dataset/dataset-metadata/DatasetMetadata.spec.tsx +++ b/tests/component/sections/dataset/dataset-metadata/DatasetMetadata.spec.tsx @@ -427,4 +427,76 @@ describe('DatasetMetadata', () => { cy.findByRole('button', { name: 'Citation Metadata' }).should('exist') cy.findByRole('button', { name: 'Geospatial Metadata' }).should('not.exist') }) + + it('adds name and description to extra fields of the citation metadata block', () => { + const mockDatasetWithExtraFields = DatasetMother.create({ + metadataBlocks: [ + { + name: MetadataBlockName.CITATION, + fields: { + title: 'Some Title', + subject: ['subject-one', 'subject-two'], + author: [ + { + authorName: 'Foo', + authorAffiliation: 'Bar' + }, + { + authorName: 'Another Foo', + authorAffiliation: 'Another Bar' + } + ], + datasetContact: [ + { + datasetContactName: 'John Doe', + datasetContactEmail: 'john@doe.com', + datasetContactAffiliation: 'Doe Inc.' + } + ], + dsDescription: [ + { + dsDescriptionValue: 'Description of the dataset' + } + ], + producer: [ + { + producerName: 'Foo', + producerAffiliation: 'XYZ', + producerURL: 'http://foo.com', + producerLogoURL: + 'https://beta.dataverse.org/resources/images/dataverse_project_logo.svg' + } + ], + // Extra fields + publicationDate: '2023-01-01', + alternativePersistentId: 'some-alternative-pid' + } + } + ] + }) + + cy.customMount( + + ) + + cy.get('.accordion > :nth-child(1)').within(() => { + cy.findByText(/Citation Metadata/i).should('exist') + + cy.findByText('Persistent Identifier') + .parent() + .siblings('div') + .should('contain', mockDatasetWithExtraFields.persistentId) + + cy.findByText('Publication Date').should('exist') + cy.findByText('2023-01-01').should('exist') + + cy.findByText('Previous Dataset Persistent ID').should('exist') + cy.findByText('some-alternative-pid').should('exist') + }) + }) }) diff --git a/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx b/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx index 641ef6b3a..a903875c2 100644 --- a/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx +++ b/tests/component/sections/edit-dataset-metadata/EditDatasetMetadata.spec.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react' import { DatasetRepository } from '../../../../src/dataset/domain/repositories/DatasetRepository' import { DatasetMother } from '../../dataset/domain/models/DatasetMother' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' import { Dataset as DatasetModel } from '../../../../src/dataset/domain/models/Dataset' import { DatasetProvider } from '../../../../src/sections/dataset/DatasetProvider' import { MetadataBlockInfoMother } from '../../metadata-block-info/domain/models/MetadataBlockInfoMother' diff --git a/tests/component/sections/edit-file-metadata/EditFileMetadata.spec.tsx b/tests/component/sections/edit-file-metadata/EditFileMetadata.spec.tsx index 32d4c8bbc..32ad3ff82 100644 --- a/tests/component/sections/edit-file-metadata/EditFileMetadata.spec.tsx +++ b/tests/component/sections/edit-file-metadata/EditFileMetadata.spec.tsx @@ -2,7 +2,7 @@ import { EditFileMetadata, EditFileMetadataReferrer } from '@/sections/edit-file-metadata/EditFileMetadata' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' import { FileMother } from '@tests/component/files/domain/models/FileMother' import { FilePermissionsMother } from '@tests/component/files/domain/models/FilePermissionsMother' import { FileMockRepository } from '@/stories/file/FileMockRepository' diff --git a/tests/component/sections/file/File.spec.tsx b/tests/component/sections/file/File.spec.tsx index 3c6fe1247..38d31f206 100644 --- a/tests/component/sections/file/File.spec.tsx +++ b/tests/component/sections/file/File.spec.tsx @@ -2,6 +2,10 @@ import { FileRepository } from '../../../../src/files/domain/repositories/FileRe import { FileMother } from '../../files/domain/models/FileMother' import { File } from '../../../../src/sections/file/File' import { DatasetMockRepository } from '@/stories/dataset/DatasetMockRepository' +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' +import { FileExternalToolResolvedMother } from '@tests/component/externalTools/domain/models/FileExternalToolResolvedMother' import { DataverseInfoMockRepository } from '@/stories/shared-mock-repositories/info/DataverseInfoMockRepository' const fileRepository: FileRepository = {} as FileRepository @@ -112,4 +116,114 @@ describe('File', () => { cy.contains('Contributors').should('exist') cy.contains('Published On').should('exist') }) + + describe('external tools tab', () => { + const externalToolsRepository: ExternalToolsRepository = {} as ExternalToolsRepository + + beforeEach(() => { + const testFile = FileMother.createRealistic() + fileRepository.getById = cy.stub().resolves(testFile) + externalToolsRepository.getExternalTools = cy + .stub() + .resolves([ExternalToolsMother.createFilePreviewTool()]) + externalToolsRepository.getFileExternalToolResolved = cy + .stub() + .resolves(FileExternalToolResolvedMother.create()) + }) + + it('renders the External Tools tab with "Preview" title if only one tool applicable and is a preview tool', () => { + cy.customMount( + + + + ) + + cy.findByRole('tab', { name: 'Preview' }).should('exist') + }) + + it('renders the External Tools tab with "Query" title if only one tool applicable and is an query tool', () => { + externalToolsRepository.getExternalTools = cy + .stub() + .resolves([ExternalToolsMother.createFileQueryTool()]) + + cy.customMount( + + + + ) + + cy.findByRole('tab', { name: 'Query' }).should('exist') + }) + + it('renders the External Tools tab with "File Tools" title if more than one applicable tool', () => { + externalToolsRepository.getExternalTools = cy + .stub() + .resolves([ + ExternalToolsMother.createFilePreviewTool(), + ExternalToolsMother.createFileQueryTool() + ]) + + cy.customMount( + + + + ) + + cy.findByRole('tab', { name: 'File Tools' }).should('exist') + }) + + it('does not render the External Tools tab if no applicable tools', () => { + externalToolsRepository.getExternalTools = cy.stub().resolves([]) + + cy.customMount( + + + + ) + + cy.findByRole('tab', { name: 'File Tools' }).should('not.exist') + cy.findByRole('tab', { name: 'Preview' }).should('not.exist') + cy.findByRole('tab', { name: 'Query' }).should('not.exist') + }) + + it('does not render the External Tools tab if applicable tool but user lacks download permission', () => { + const testFile = FileMother.createWithDownloadPermissionDenied() + fileRepository.getById = cy.stub().resolves(testFile) + + cy.customMount( + + + + ) + + cy.findByRole('tab', { name: 'File Tools' }).should('not.exist') + cy.findByRole('tab', { name: 'Preview' }).should('not.exist') + cy.findByRole('tab', { name: 'Query' }).should('not.exist') + }) + }) }) diff --git a/tests/component/sections/file/FilePageHelper.spec.tsx b/tests/component/sections/file/FilePageHelper.spec.tsx new file mode 100644 index 000000000..50d065f9c --- /dev/null +++ b/tests/component/sections/file/FilePageHelper.spec.tsx @@ -0,0 +1,173 @@ +import { FilePageHelper } from '@/sections/file/FilePageHelper' +import { ExternalTool, ToolScope, ToolType } from '@/externalTools/domain/models/ExternalTool' + +describe('FilePageHelper', () => { + const t = (key: string) => key + + const makeTool = ( + id: number, + types: ToolType[], + contentType: string, + scope: ToolScope = ToolScope.File, + displayName = `Tool-${id}` + ): ExternalTool => ({ + id, + displayName, + description: `${displayName} description`, + types, + scope, + contentType + }) + + describe('defineDefaultActiveTab', () => { + it('returns "metadata" when there are no external tools', () => { + const result = FilePageHelper.defineDefaultActiveTab([], 'text/csv') + expect(result).to.equal('metadata') + }) + + it('returns "extTool" tab when there is at least one applicable preview tool', () => { + const tools: ExternalTool[] = [ + makeTool(1, [ToolType.Preview], 'text/csv'), + makeTool(2, [ToolType.Configure], 'text/csv') + ] + + const result = FilePageHelper.defineDefaultActiveTab(tools, 'text/csv') + expect(result).to.equal(FilePageHelper.EXT_TOOL_TAB_KEY) + }) + + it('returns "metadata" when tools exist but none applicable for file type', () => { + const tools: ExternalTool[] = [makeTool(1, [ToolType.Preview], 'application/json')] + const result = FilePageHelper.defineDefaultActiveTab(tools, 'text/csv') + expect(result).to.equal('metadata') + }) + }) + + describe('getApplicablePreviewOrQueryToolsForFileType', () => { + it('returns empty when fileType is undefined', () => { + const tools: ExternalTool[] = [ + makeTool(1, [ToolType.Preview], 'text/csv'), + makeTool(2, [ToolType.Query], 'text/csv') + ] + + const result = FilePageHelper.getApplicablePreviewOrQueryToolsForFileType(tools) + expect(result).to.deep.equal([]) + }) + + it('filters by scope=file and only preview/query types and matching content type', () => { + const tools: ExternalTool[] = [ + makeTool(1, [ToolType.Preview], 'text/csv'), // include + makeTool(2, [ToolType.Query], 'text/csv'), // include + makeTool(3, [ToolType.Explore], 'text/csv'), // exclude - wrong tool type + makeTool(4, [ToolType.Configure], 'text/csv'), // exclude - wrong tool type + { ...makeTool(5, [ToolType.Preview], 'text/csv'), scope: ToolScope.Dataset } // exclude - wrong scope + ] + + const result = FilePageHelper.getApplicablePreviewOrQueryToolsForFileType(tools, 'text/csv') + + expect(result.map((t) => t.id)).to.deep.equal([1, 2]) + }) + }) + + describe('getApplicableToolsForFileType', () => { + it('returns empty when fileType is undefined', () => { + const tools: ExternalTool[] = [makeTool(1, [ToolType.Preview], 'text/csv')] + const result = FilePageHelper.getApplicableToolsForFileType(tools) + expect(result).to.deep.equal([]) + }) + + it('returns all tools with scope=file and matching content type regardless of type', () => { + const tools: ExternalTool[] = [ + makeTool(1, [ToolType.Preview], 'text/csv'), + makeTool(2, [ToolType.Query], 'text/csv'), + makeTool(3, [ToolType.Explore], 'text/csv'), + makeTool(4, [ToolType.Configure], 'text/csv'), + { ...makeTool(5, [ToolType.Preview], 'text/csv'), scope: ToolScope.Dataset } + ] + + const result = FilePageHelper.getApplicableToolsForFileType(tools, 'text/csv') + expect(result.map((t) => t.id)).to.deep.equal([1, 2, 3, 4]) + }) + }) + + describe('getExternalToolTabTitle', () => { + it('returns tabs.preview when single applicable preview tool matches file type', () => { + const tools = [makeTool(1, [ToolType.Preview], 'text/csv')] + const result = FilePageHelper.getExternalToolTabTitle(tools, t, 'text/csv') + expect(result).to.equal('tabs.preview') + }) + + it('returns tabs.query when single applicable query tool matches file type', () => { + const tools = [makeTool(1, [ToolType.Query], 'application/json')] + const result = FilePageHelper.getExternalToolTabTitle(tools, t, 'application/json') + expect(result).to.equal('tabs.query') + }) + + it('returns tabs.fileTools when more than one applicable tool', () => { + const tools = [ + makeTool(1, [ToolType.Preview], 'text/csv'), + makeTool(2, [ToolType.Query], 'text/csv') + ] + const result = FilePageHelper.getExternalToolTabTitle(tools, t, 'text/csv') + expect(result).to.equal('tabs.fileTools') + }) + + it('returns tabs.preview when there are no applicable tools', () => { + const tools: ExternalTool[] = [] + const result = FilePageHelper.getExternalToolTabTitle(tools, t, 'text/csv') + expect(result).to.equal('tabs.preview') + }) + + it('returns tabs.preview when single tool exists but file type mismatches', () => { + const tools = [makeTool(1, [ToolType.Query], 'application/json')] + const result = FilePageHelper.getExternalToolTabTitle(tools, t, 'text/csv') + expect(result).to.equal('tabs.preview') + }) + }) + + describe('getDefaultSelectedToolId', () => { + it('selects tool by matching type from query param', () => { + const tools = [ + makeTool(1, [ToolType.Preview], 'text/csv'), + makeTool(2, [ToolType.Query], 'text/csv') + ] + const result = FilePageHelper.getDefaultSelectedToolId('query', tools) + expect(result).to.equal(2) + }) + + it('falls back to first tool when query param is set but no type matches', () => { + const tools = [ + makeTool(5, [ToolType.Preview], 'text/csv'), + makeTool(6, [ToolType.Preview], 'text/csv') + ] + const result = FilePageHelper.getDefaultSelectedToolId('query', tools) + expect(result).to.equal(5) + }) + + it('falls back to first tool when query param is undefined', () => { + const tools = [ + makeTool(9, [ToolType.Query], 'text/csv'), + makeTool(10, [ToolType.Preview], 'text/csv') + ] + const result = FilePageHelper.getDefaultSelectedToolId(undefined, tools) + expect(result).to.equal(9) + }) + }) + + describe('replacePreviewParamInToolUrl', () => { + it('adds preview param when not present', () => { + const original = 'https://example.com/tool' + const result = FilePageHelper.replacePreviewParamInToolUrl(original, true) + const u = new URL(result) + expect(u.origin + u.pathname).to.equal('https://example.com/tool') + expect(u.searchParams.get('preview')).to.equal('true') + }) + + it('replaces preview param when already present', () => { + const original = 'https://example.com/tool?a=1&preview=false' + const result = FilePageHelper.replacePreviewParamInToolUrl(original, true) + const u = new URL(result) + expect(u.searchParams.get('a')).to.equal('1') + expect(u.searchParams.get('preview')).to.equal('true') + }) + }) +}) diff --git a/tests/component/sections/file/file-action-buttons/access-file-menu/FileToolOptions.spec.tsx b/tests/component/sections/file/file-action-buttons/access-file-menu/FileToolOptions.spec.tsx new file mode 100644 index 000000000..0514bb029 --- /dev/null +++ b/tests/component/sections/file/file-action-buttons/access-file-menu/FileToolOptions.spec.tsx @@ -0,0 +1,100 @@ +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { + FileExploreToolsOptions, + FileQueryToolsOptions, + FileConfigureToolsOptions +} from '@/sections/file/file-action-buttons/access-file-menu/FileToolOptions' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' + +const testExternalToolsRepository: ExternalToolsRepository = {} as ExternalToolsRepository + +describe('FileToolOptions', () => { + beforeEach(() => { + testExternalToolsRepository.getExternalTools = cy + .stub() + .resolves([ + ExternalToolsMother.createFileExploreTool(), + ExternalToolsMother.createFileQueryTool(), + ExternalToolsMother.createFileConfigureTool() + ]) + }) + + describe('FileExploreToolsOptions', () => { + it('renders the tool options if file explore tools are available and compatible with the type', () => { + cy.customMount( + + + + ) + + cy.findByText('Query Options').should('not.exist') + cy.findByText('Configure Options').should('not.exist') + cy.findByText('Explore Options').should('exist') + cy.findByText('File Explore Tool').should('exist') + }) + + it('does not render the tool options if there are not applicable tools for the file type', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Query Options').should('not.exist') + }) + }) + + describe('FileQueryToolsOptions', () => { + it('renders the tool options if file query tools are available and compatible with the type', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Configure Options').should('not.exist') + cy.findByText('Query Options').should('exist') + cy.findByText('File Query Tool').should('exist') + }) + + it('does not render the tool options if there are not applicable tools for the file type', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Query Options').should('not.exist') + }) + }) + + describe('FileConfigureToolsOptions', () => { + it('renders the tool options if file configure tools are available and compatible with the type', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Query Options').should('not.exist') + cy.findByText('Configure Options').should('exist') + cy.findByText('File Configure Tool').should('exist') + }) + + it('does not render the tool options if there are not applicable tools for the file type', () => { + cy.customMount( + + + + ) + + cy.findByText('Explore Options').should('not.exist') + cy.findByText('Query Options').should('not.exist') + }) + }) +}) diff --git a/tests/component/sections/file/file-action-buttons/access-file-menu/RequestAccessModal.spec.tsx b/tests/component/sections/file/file-action-buttons/access-file-menu/RequestAccessModal.spec.tsx index bc4878205..c270f067c 100644 --- a/tests/component/sections/file/file-action-buttons/access-file-menu/RequestAccessModal.spec.tsx +++ b/tests/component/sections/file/file-action-buttons/access-file-menu/RequestAccessModal.spec.tsx @@ -50,7 +50,22 @@ describe('RequestAccessModal', () => { cy.findByRole('dialog').should('not.exist') }) - it.skip('calls request access use case when button is clicked and user is logged in', () => { + it('calls request access use case when button is clicked and user is logged in', () => { + const file = FilePreviewMother.create() + + cy.mountAuthenticated() + + cy.findByRole('button', { name: 'Request Access' }).click() + + cy.findByRole('dialog').should('exist') + cy.findAllByText('Request Access').should('exist') + + cy.findByText( + 'Please confirm and/or complete the information needed below in order to request access to files in this dataset.' + ).should('exist') + cy.findByText('Terms of Access for Restricted Files').should('exist') + + cy.findByText('Accept').click() // TODO - Implement request access use case }) }) diff --git a/tests/component/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.spec.tsx b/tests/component/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.spec.tsx index b77a01a9e..f3d4332d5 100644 --- a/tests/component/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.spec.tsx +++ b/tests/component/sections/file/file-action-buttons/edit-file-menu/EditFileMenu.spec.tsx @@ -24,6 +24,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -62,6 +63,7 @@ describe('EditFileMenu', () => { isTabularFile={true} storageIdentifier="non-s3://10.5072/FK2/FNJFOR" datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -84,6 +86,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -111,6 +114,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -138,6 +142,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -174,6 +179,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -204,6 +210,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -233,6 +240,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -263,6 +271,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -293,6 +302,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -320,6 +330,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -349,6 +360,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -384,6 +396,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -414,6 +427,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -441,6 +455,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -474,6 +489,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -501,6 +517,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -526,6 +543,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -560,6 +578,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -589,6 +608,7 @@ describe('EditFileMenu', () => { storageIdentifier="s3://10.5072/FK2/FNJFOR" isTabularFile={true} datasetRepository={new DatasetMockRepository()} + fileType={testFile.metadata.type.value} /> ) @@ -624,6 +644,7 @@ describe('EditFileMenu', () => { isTabularFile={true} datasetRepository={new DatasetMockRepository()} storageIdentifier="s3://10.5072/FK2/FNJFOR" + fileType={testFile.metadata.type.value} /> ) }) diff --git a/tests/component/sections/file/file-embargo/FileEmbargoDate.spec.tsx b/tests/component/sections/file/file-embargo/FileEmbargoDate.spec.tsx index d093fbe20..6e919c988 100644 --- a/tests/component/sections/file/file-embargo/FileEmbargoDate.spec.tsx +++ b/tests/component/sections/file/file-embargo/FileEmbargoDate.spec.tsx @@ -60,4 +60,17 @@ describe('FileEmbargoDate', () => { cy.findByText(`Embargoed until`).should('exist') cy.get('time').should('have.text', dateString) }) + + it('renders the embargo date in short format', () => { + const embargoDate = new Date('2123-09-18') + const embargo = FileEmbargoMother.create({ dateAvailable: embargoDate }) + const status = DatasetPublishingStatus.RELEASED + + cy.customMount( + + ) + const dateString = DateHelper.toDisplayFormat(embargoDate) + cy.findByText(`Embargoed until`).should('exist') + cy.get('time').should('have.text', dateString) + }) }) diff --git a/tests/component/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.spec.tsx b/tests/component/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.spec.tsx new file mode 100644 index 000000000..469c57039 --- /dev/null +++ b/tests/component/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool.spec.tsx @@ -0,0 +1,171 @@ +import { ExternalToolsRepository } from '@/externalTools/domain/repositories/ExternalToolsRepository' +import { FileEmbeddedExternalTool } from '@/sections/file/file-embedded-external-tool/FileEmbeddedExternalTool' +import { FilePageHelper } from '@/sections/file/FilePageHelper' +import { WriteError } from '@iqss/dataverse-client-javascript' +import { ExternalToolsMother } from '@tests/component/externalTools/domain/models/ExternalToolsMother' +import { FileExternalToolResolvedMother } from '@tests/component/externalTools/domain/models/FileExternalToolResolvedMother' +import { FileMother } from '@tests/component/files/domain/models/FileMother' + +const externalToolsRepository: ExternalToolsRepository = {} as ExternalToolsRepository // Used for fetching the tool resolved URL + +const testFile = FileMother.createRealistic() // text/plain file +const filePreviewTool = ExternalToolsMother.createFilePreviewTool() // id: 2 +const filePreviewToolResolved = FileExternalToolResolvedMother.create({ + displayName: 'File Preview Tool', + toolUrlResolved: 'https://example.com/preview-tool?fileId=1' +}) +const fileQueryTool = ExternalToolsMother.createFileQueryTool() // id: 4 +const fileQueryToolResolved = FileExternalToolResolvedMother.create({ + displayName: 'File Query Tool', + toolUrlResolved: 'https://example.com/query-tool?fileId=1' +}) + +describe('FileEmbeddedExternalTool', () => { + it('renders a single preview tool', () => { + externalToolsRepository.getFileExternalToolResolved = cy + .stub() + .resolves(filePreviewToolResolved) + + cy.customMount( + + ) + + cy.findByTestId('external-tool-iframe') + .should('exist') + .should('have.attr', 'src', filePreviewToolResolved.toolUrlResolved) + + // Just a small wait to cover the iframe onLoad event, there is a cypress package for this, but to avoid installing it, just a wait is fine. + cy.wait(1_000) + + cy.findByRole('link', { name: 'Open in New Window' }) + .should('exist') + .should( + 'have.attr', + 'href', + FilePageHelper.replacePreviewParamInToolUrl(filePreviewToolResolved.toolUrlResolved, false) + ) + }) + + it('renders multiple tools and allows switching between them', () => { + const getFileExternalToolResolvedStub = cy.stub() + + // Stub the calls to getFileExternalToolResolved for each tool + getFileExternalToolResolvedStub + .withArgs(testFile.id, filePreviewTool.id) + .resolves(filePreviewToolResolved) + + getFileExternalToolResolvedStub + .withArgs(testFile.id, fileQueryTool.id) + .resolves(fileQueryToolResolved) + + externalToolsRepository.getFileExternalToolResolved = getFileExternalToolResolvedStub + + cy.customMount( + + ) + // The "Change Tool" button is present to select between the two tools + cy.findByRole('button', { name: 'Change Tool' }).should('exist').as('changeToolButton') + cy.get('@changeToolButton').click() + cy.findByRole('button', { name: filePreviewTool.displayName }).should('exist') + cy.findByRole('button', { name: fileQueryTool.displayName }).should('exist') + cy.get('@changeToolButton').click() // Close the dropdown + + // Initially the preview tool is selected + + cy.findByTestId('external-tool-iframe') + .should('exist') + .should('have.attr', 'src', filePreviewToolResolved.toolUrlResolved) + + cy.findByRole('link', { name: 'Open in New Window' }) + .should('exist') + .should( + 'have.attr', + 'href', + FilePageHelper.replacePreviewParamInToolUrl(filePreviewToolResolved.toolUrlResolved, false) + ) + + // Now we select the query tool + cy.get('@changeToolButton').click() + cy.findByRole('button', { name: fileQueryTool.displayName }).click() + + cy.findByTestId('external-tool-iframe') + .should('exist') + .should('have.attr', 'src', fileQueryToolResolved.toolUrlResolved) + + cy.findByRole('link', { name: 'Open in New Window' }) + .should('exist') + .should( + 'have.attr', + 'href', + FilePageHelper.replacePreviewParamInToolUrl(fileQueryToolResolved.toolUrlResolved, false) + ) + }) + + it('does not load the iframe if tab wrapping the component is not in view', () => { + externalToolsRepository.getFileExternalToolResolved = cy + .stub() + .resolves(filePreviewToolResolved) + + cy.customMount( + + ) + cy.findByTestId('external-tool-iframe').should('not.exist') + }) + + describe('error handling', () => { + it('shows js dataverse error message if fetching the tool URL fails with a JSDataverseError', () => { + externalToolsRepository.getFileExternalToolResolved = cy + .stub() + .rejects(new WriteError('Some js dataverse processed error message.')) + cy.customMount( + + ) + + cy.findByText(/Some js dataverse processed error message./) + cy.findByTestId('external-tool-iframe').should('not.exist') + }) + + it('shows fallback error message if fetching the tool URL fails', () => { + externalToolsRepository.getFileExternalToolResolved = cy + .stub() + .rejects(new Error('Failed to fetch tool URL')) + + cy.customMount( + + ) + + cy.findByText(/Something went wrong loading the external tool. Try again later./) + cy.findByTestId('external-tool-iframe').should('not.exist') + }) + }) +}) diff --git a/tests/component/sections/file/useFile.spec.tsx b/tests/component/sections/file/useFile.spec.tsx new file mode 100644 index 000000000..40dc45c7d --- /dev/null +++ b/tests/component/sections/file/useFile.spec.tsx @@ -0,0 +1,42 @@ +import { FileRepository } from '@/files/domain/repositories/FileRepository' +import { useFile } from '@/sections/file/useFile' +import { act, renderHook } from '@testing-library/react' +import { FileMother } from '@tests/component/files/domain/models/FileMother' + +const fileRepository: FileRepository = {} as FileRepository +const fileMock = FileMother.create() + +describe('useFile', () => { + it('should return file and loading state', async () => { + fileRepository.getById = cy.stub().resolves(fileMock) + + const { result } = renderHook(() => useFile(fileRepository, 2, '1.0')) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(true) + return expect(result.current.file).to.deep.equal(undefined) + }) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(false) + + return expect(result.current.file).to.deep.equal(fileMock) + }) + }) + + it('should handle error when repository fails', async () => { + fileRepository.getById = cy.stub().rejects(new Error('Error message')) + + const { result } = renderHook(() => useFile(fileRepository, 2, '1.0')) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(true) + return expect(result.current.file).to.deep.equal(undefined) + }) + + await act(() => { + expect(result.current.isLoading).to.deep.equal(false) + return expect(result.current.file).to.deep.equal(undefined) + }) + }) +}) diff --git a/tests/component/sections/layout/top-bar-progress-indicator/TopBarProgressIndicator.spec.tsx b/tests/component/sections/layout/top-bar-progress-indicator/TopBarProgressIndicator.spec.tsx index 9a15b7051..6ac27df41 100644 --- a/tests/component/sections/layout/top-bar-progress-indicator/TopBarProgressIndicator.spec.tsx +++ b/tests/component/sections/layout/top-bar-progress-indicator/TopBarProgressIndicator.spec.tsx @@ -1,5 +1,5 @@ import TopBarProgressIndicator from '../../../../../src/sections/layout/topbar-progress-indicator/TopbarProgressIndicator' -import { LoadingContext } from '../../../../../src/sections/loading/LoadingContext' +import { LoadingContext } from '../../../../../src/shared/contexts/loading/LoadingContext' describe('TopBarProgressIndicator', () => { it('should render without errors', () => { diff --git a/tests/component/sections/loading/LoadingProvider.spec.tsx b/tests/component/sections/loading/LoadingProvider.spec.tsx index 189ed9a89..9c1e0f9d0 100644 --- a/tests/component/sections/loading/LoadingProvider.spec.tsx +++ b/tests/component/sections/loading/LoadingProvider.spec.tsx @@ -1,5 +1,5 @@ -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' -import { useLoading } from '../../../../src/sections/loading/LoadingContext' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' +import { useLoading } from '../../../../src/shared/contexts/loading/LoadingContext' describe('LoadingProvider', () => { it('should render children', () => { diff --git a/tests/component/sections/replace-file/ReplaceFile.spec.tsx b/tests/component/sections/replace-file/ReplaceFile.spec.tsx index f4de39d98..56e9bf69f 100644 --- a/tests/component/sections/replace-file/ReplaceFile.spec.tsx +++ b/tests/component/sections/replace-file/ReplaceFile.spec.tsx @@ -4,7 +4,7 @@ import { FileMetadataMother, FileTypeMother } from '@tests/component/files/domain/models/FileMetadataMother' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' import { FileMockRepository } from '../../../../src/stories/file/FileMockRepository' const fileMockRepository = new FileMockRepository() diff --git a/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx b/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx index afd9fd29b..7540ca5c3 100644 --- a/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx +++ b/tests/component/sections/upload-dataset-files/UploadDatasetFiles.spec.tsx @@ -5,7 +5,7 @@ import { Dataset as DatasetModel } from '../../../../src/dataset/domain/models/D import { ReactNode } from 'react' import { DatasetProvider } from '../../../../src/sections/dataset/DatasetProvider' import { UploadDatasetFiles } from '../../../../src/sections/upload-dataset-files/UploadDatasetFiles' -import { LoadingProvider } from '../../../../src/sections/loading/LoadingProvider' +import { LoadingProvider } from '../../../../src/shared/contexts/loading/LoadingProvider' import { FileMockRepository } from '../../../../src/stories/file/FileMockRepository' const fileRepository: FileRepository = {} as FileRepository diff --git a/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx b/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx index 8b154cae9..66996ee8c 100644 --- a/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx +++ b/tests/e2e-integration/e2e/sections/create-dataset/CreateDataset.spec.tsx @@ -45,11 +45,25 @@ describe('Create Dataset', () => { cy.contains('Agricultural Sciences; Arts and Humanities').should('exist') }) - it('shows template select when a template is available and prefill fields when a template is selected', () => { - cy.wrap(DatasetHelper.createDatasetTemplate(), { timeout: 10000 }).then(() => { + describe('dataset template selection', () => { + let datasetTemplateId: number + + beforeEach(async () => { + await DatasetHelper.createDatasetTemplate() + const templates = await DatasetHelper.getDatasetTemplates() + + const { id } = templates[0] + datasetTemplateId = id + }) + + afterEach(async () => { + await DatasetHelper.deleteDatasetTemplate(datasetTemplateId) + }) + + it('shows template select when a template is available and prefill fields when a template is selected', () => { cy.visit(CREATE_DATASET_PAGE_URL) - cy.wait(1000) + cy.wait(3_000) cy.findByTestId('dataset-template-select').should('exist').as('templateSelect') cy.findByText('None').should('exist') // No default template, None is shown @@ -94,13 +108,6 @@ describe('Create Dataset', () => { cy.findByText(DatasetLabelValue.DRAFT).should('exist') cy.findByText(DatasetLabelValue.UNPUBLISHED).should('exist') cy.contains('Agricultural Sciences; Arts and Humanities').should('exist') - - // Delete template after test - cy.wrap(DatasetHelper.getDatasetTemplates(), { timeout: 10000 }).then((templates) => { - const { id } = templates[0] - - cy.wrap(DatasetHelper.deleteDatasetTemplate(id)) - }) }) }) diff --git a/tests/support/commands.tsx b/tests/support/commands.tsx index e98f45176..d302cc2b0 100644 --- a/tests/support/commands.tsx +++ b/tests/support/commands.tsx @@ -50,6 +50,8 @@ import { SessionContext } from '@/sections/session/SessionContext' import { User } from '@/users/domain/models/User' import { OIDC_AUTH_CONFIG } from '@/config' import { ToastContainer } from 'react-toastify' +import { ExternalToolsProvider } from '@/shared/contexts/external-tools/ExternalToolsProvider' +import { ExternalToolsMockRepository } from '@/stories/shared-mock-repositories/externalTools/ExternalToolsMockRepository' // Define your custom mount function @@ -71,7 +73,9 @@ Cypress.Commands.add( return cy.mount( - + + +