diff --git a/.github/add_doi_datapublicationtype.py b/.github/add_doi_datapublicationtype.py new file mode 100644 index 000000000..47dd28a4d --- /dev/null +++ b/.github/add_doi_datapublicationtype.py @@ -0,0 +1,13 @@ +from icat.client import Client + +client = Client( + "https://localhost:8181", + checkCert=False, +) +client.login("simple", {"username": "root", "password": "pw"}) + +data_publication_type = client.new("dataPublicationType") +data_publication_type.name = "User-defined" +data_publication_type.description = "User-defined" +data_publication_type.facility = client.get("Facility", 1) +data_publication_type.create() diff --git a/.github/config.env b/.github/config.env new file mode 100644 index 000000000..1e5012ea5 --- /dev/null +++ b/.github/config.env @@ -0,0 +1,18 @@ +ICAT_URL=https://host.docker.internal:8181 +FACILITY=LILS +ICAT_USERNAME=root +ICAT_PASSWORD=pw +PUBLISHER=test +MINTER_ROLE=PI +VERSION=0.01 + +ICAT_DOI_BASE_URL=https://example.stfc.ac.uk/ +ICAT_SESSION_PATH=/icat/session/ +ICAT_AUTHENTICATOR_NAME=simple +ICAT_CHECK_CERT=False +SSL_CERT_VERIFICATION=False + +DATACITE_PREFIX=10.5286 +DATACITE_URL=https://api.test.datacite.org/dois +DATACITE_USERNAME=BL.STFC + diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 927bc609e..121363414 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -245,9 +245,21 @@ jobs: ansible-playbook icat-ansible/icat_test_hosts.yml -i icat-ansible/hosts --vault-password-file icat-ansible/vault_pass.txt -vv # Fixes on ICAT components needed for e2e tests - - name: Add anon user to rootUserNames + - name: Removing authenticator prefix for simple auth run: | - awk -F" =" '/rootUserNames/{$2="= simple/root anon/anon";print;next}1' /home/runner/install/icat.server/run.properties > /home/runner/install/icat.server/run.properties.tmp + sed -i 's/mechanism = simple/!mechanism = simple/' /home/runner/install/authn.simple/run.properties + - name: Adding Chris481 user + run: | + sed -i '/user\.list/ s/$/ Chris481/' /home/runner/install/authn.simple/run.properties + - name: Adding Chris481 user password + run: | + echo "user.Chris481.password = pw" >> /home/runner/install/authn.simple/run.properties + - name: Reinstall authn.simple + run: | + cd /home/runner/install/authn.simple/ && ./setup -vv install + - name: Add anon, root (simple without prefix) and Chris481 users to rootUserNames + run: | + awk -F" =" '/rootUserNames/{$2="= root Chris481 anon/anon";print;next}1' /home/runner/install/icat.server/run.properties > /home/runner/install/icat.server/run.properties.tmp - name: Apply rootUserNames change run: | mv -f /home/runner/install/icat.server/run.properties.tmp /home/runner/install/icat.server/run.properties @@ -260,6 +272,15 @@ jobs: - name: Reinstall IDS Server run: | cd /home/runner/install/ids.server/ && python2 ./setup -vv install + - name: Add root (simple without prefix) to datagateway-download-api adminUserNames + run: | + awk -F" =" '/adminUserNames/{$2="= root";print;next}1' /home/runner/install/datagateway-download-api/run.properties > /home/runner/install/datagateway-download-api/run.properties.tmp + - name: Apply adminUserNames change + run: | + mv -f /home/runner/install/datagateway-download-api/run.properties.tmp /home/runner/install/datagateway-download-api/run.properties + - name: Reinstall datagateway-download-api + run: | + cd /home/runner/install/datagateway-download-api/ && python2 ./setup -vv install # Disable Globus for Download e2e tests - name: Login to ICAT @@ -299,6 +320,16 @@ jobs: - name: Start API run: cd datagateway-api/; nohup poetry run python -m datagateway_api.src.main > api-output.txt & + # DOI minter setup + - name: Adding 'User-defined' DataPublicationType (needed for DOI minting api) + run: cd datagateway-api/; poetry run python ../.github/add_doi_datapublicationtype.py + + - name: 'Add password to env file' + run: echo DATACITE_PASSWORD=${{ secrets.DATACITE_PASSWORD }} >> ./.github/config.env + + - name: Run minting api + run: docker run --env-file ./.github/config.env -p 8000:8000 --add-host host.docker.internal:host-gateway -d harbor.stfc.ac.uk/icat/doi-mint-api + # E2E tests - name: Setup Node.js uses: actions/setup-node@e33196f7422957bea03ed53f6fbb155025ffc7b8 # v3 diff --git a/packages/datagateway-common/src/api/cart.test.tsx b/packages/datagateway-common/src/api/cart.test.tsx index 2178a1591..c4c9bce9d 100644 --- a/packages/datagateway-common/src/api/cart.test.tsx +++ b/packages/datagateway-common/src/api/cart.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { useCart, useAddToCart, useRemoveFromCart } from '.'; import { DownloadCart } from '../app.types'; import handleICATError from '../handleICATError'; @@ -122,9 +122,11 @@ describe('Cart api functions', () => { it('sends axios request to add item to cart once mutate function is called and calls handleICATError on failure, with a retry on code 431', async () => { (axios.post as jest.MockedFunction) .mockRejectedValueOnce({ - code: '431', + response: { + status: 431, + }, message: 'Test 431 error message', - }) + } as AxiosError) .mockRejectedValue({ message: 'Test error message', }); @@ -184,9 +186,11 @@ describe('Cart api functions', () => { it('sends axios request to remove item from cart once mutate function is called and calls handleICATError on failure, with a retry on code 431', async () => { (axios.post as jest.MockedFunction) .mockRejectedValueOnce({ - code: '431', + response: { + status: 431, + }, message: 'Test 431 error message', - }) + } as AxiosError) .mockRejectedValue({ message: 'Test error message', }); diff --git a/packages/datagateway-common/src/api/cart.tsx b/packages/datagateway-common/src/api/cart.tsx index 2a01942f1..9cd4b5b3f 100644 --- a/packages/datagateway-common/src/api/cart.tsx +++ b/packages/datagateway-common/src/api/cart.tsx @@ -100,7 +100,7 @@ export const useAddToCart = ( }, retry: (failureCount, error) => { // if we get 431 we know this is an intermittent error so retry - if (error.code === '431' && failureCount < 3) { + if (error.response?.status === 431 && failureCount < 3) { return true; } else { return false; @@ -141,7 +141,7 @@ export const useRemoveFromCart = ( }, retry: (failureCount, error) => { // if we get 431 we know this is an intermittent error so retry - if (error.code === '431' && failureCount < 3) { + if (error.response?.status === 431 && failureCount < 3) { return true; } else { return false; diff --git a/packages/datagateway-common/src/app.types.tsx b/packages/datagateway-common/src/app.types.tsx index 4f9b5c34c..22ea8fac8 100644 --- a/packages/datagateway-common/src/app.types.tsx +++ b/packages/datagateway-common/src/app.types.tsx @@ -96,6 +96,8 @@ export interface User { id: number; name: string; fullName?: string; + email?: string; + affiliation?: string; } export interface Sample { diff --git a/packages/datagateway-common/src/table/table.component.tsx b/packages/datagateway-common/src/table/table.component.tsx index aa07dd089..46eb821ad 100644 --- a/packages/datagateway-common/src/table/table.component.tsx +++ b/packages/datagateway-common/src/table/table.component.tsx @@ -1,6 +1,6 @@ import React from 'react'; import TableCell from '@mui/material/TableCell'; -import { styled, SxProps, Theme, useTheme } from '@mui/material/styles'; +import { styled, SxProps } from '@mui/material/styles'; import { AutoSizer, Column, @@ -31,6 +31,9 @@ const dataColumnMinWidth = 84; const StyledTable = styled(Table)(({ theme }) => ({ fontFamily: theme.typography.fontFamily, + '.hoverable-row:hover': { + backgroundColor: theme.palette.action.hover, + }, })); const flexContainerStyle = { @@ -48,12 +51,10 @@ const headerFlexContainerStyle = { const tableRowStyle = {}; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const getTableRowHoverStyle = (theme: Theme) => ({ - '&:hover': { - backgroundColor: theme.palette.action.hover, - }, -}); +const tableRowStyleCombined = { + ...tableRowStyle, + ...flexContainerStyle, +}; const tableCellStyle = { flex: 1, @@ -140,7 +141,6 @@ const VirtualizedTable = React.memo( const [expandedIndex, setExpandedIndex] = React.useState(-1); const [detailPanelHeight, setDetailPanelHeight] = React.useState(rowHeight); const [lastChecked, setLastChecked] = React.useState(-1); - const theme = useTheme(); let tableRef: Table | null = null; const detailPanelRef = React.useRef(null); @@ -256,18 +256,10 @@ const VirtualizedTable = React.memo( [detailPanelHeight, expandedIndex] ); - const getRowStyle = React.useCallback( - ({ index }: Index): React.CSSProperties => { - const tableRowHoverStyle = - index > -1 ? getTableRowHoverStyle(theme) : {}; - return { - ...tableRowStyle, - ...flexContainerStyle, - ...tableRowHoverStyle, - }; - }, - [theme] - ); + const getRowClassName = React.useCallback(({ index }: Index) => { + return index > -1 ? 'hoverable-row' : ''; + }, []); + const getRow = React.useCallback( ({ index }: Index): Entity => data[index], [data] @@ -353,7 +345,8 @@ const VirtualizedTable = React.memo( onRowsRendered={onRowsRendered} headerHeight={headerHeight} rowHeight={getRowHeight} - rowStyle={getRowStyle} + rowStyle={tableRowStyleCombined} + rowClassName={getRowClassName} rowGetter={getRow} rowRenderer={renderRow} // Disable tab focus on whole table for accessibility; diff --git a/packages/datagateway-dataview/cypress/e2e/card/dls/datasets.cy.ts b/packages/datagateway-dataview/cypress/e2e/card/dls/datasets.cy.ts index d6f5bf3c2..5337a08d3 100644 --- a/packages/datagateway-dataview/cypress/e2e/card/dls/datasets.cy.ts +++ b/packages/datagateway-dataview/cypress/e2e/card/dls/datasets.cy.ts @@ -118,11 +118,7 @@ describe('DLS - Datasets Cards', () => { // check that count is correct after filtering cy.get('[data-testid="card"]').first().contains('15'); - cy.get('input[id="Create Time filter from"]').type('2019-01-01'); - cy.wait(['@getDatasetsCount'], { timeout: 10000 }); - cy.get('[data-testid="card"]').first().contains('DATASET 61'); - - cy.get('input[aria-label="Create Time filter to"]') + cy.get('input[aria-label="Start Date filter to"]') .parent() .find('button') .click(); @@ -136,10 +132,16 @@ describe('DLS - Datasets Cards', () => { date.setDate(1); date.setMonth(0); date.setFullYear(2020); - cy.get('input[id="Create Time filter to"]').should( + cy.get('input[id="Start Date filter to"]').should( 'have.value', date.toISOString().slice(0, 10) ); + cy.wait(['@getDatasetsCount'], { timeout: 10000 }); + cy.get('[data-testid="card"]').first().contains('DATASET 61'); + + cy.get('input[id="Start Date filter from"]').type('2019-01-01'); + cy.wait(['@getDatasetsCount'], { timeout: 10000 }); + cy.get('[data-testid="card"]').should('not.exist'); }); }); diff --git a/packages/datagateway-download/cypress/e2e/DOIGenerationForm.cy.ts b/packages/datagateway-download/cypress/e2e/DOIGenerationForm.cy.ts new file mode 100644 index 000000000..94b3f544e --- /dev/null +++ b/packages/datagateway-download/cypress/e2e/DOIGenerationForm.cy.ts @@ -0,0 +1,222 @@ +describe('DOI Generation form', () => { + beforeEach(() => { + cy.intercept('GET', '**/topcat/user/cart/**').as('fetchCart'); + cy.intercept('GET', '**/topcat/user/downloads**').as('fetchDownloads'); + cy.login( + { username: 'Chris481', password: 'pw', mechanism: 'simple' }, + 'Chris481' + ); + cy.clearDownloadCart(); + + cy.seedMintCart().then(() => { + cy.visit('/download').wait('@fetchCart'); + }); + }); + + afterEach(() => { + cy.clearDownloadCart(); + }); + + // tidy up the data publications table + after(() => { + cy.clearDataPublications(); + }); + + it('should be able to mint a mintable cart', () => { + cy.get('[aria-label="Calculating"]', { timeout: 20000 }).should( + 'not.exist' + ); + + cy.contains('Generate DOI').click(); + cy.url().should('include', '/download/mint'); + + cy.contains('button', 'Accept').should('be.visible'); + }); + + it('should not be able to try and mint a cart directly', () => { + cy.visit('/download/mint'); + + cy.url().should('match', /\/download$/); + }); + + describe('Form tests', () => { + beforeEach(() => { + cy.get('[aria-label="Calculating"]', { timeout: 20000 }).should( + 'not.exist' + ); + + cy.contains('Generate DOI').click(); + cy.contains('button', 'Accept').click(); + }); + + it('should not let user generate DOI when fields are still unfilled', () => { + cy.contains('button', 'Generate DOI').should('be.disabled'); + }); + + it('should let user generate DOI when fields are filled', () => { + cy.contains('DOI Title').parent().find('input').type('Test title'); + cy.contains('DOI Description') + .parent() + .find('textarea') + .first() + .type('Test description'); + + cy.contains('button', 'Generate DOI').click(); + + cy.contains('Mint Confirmation').should('be.visible'); + cy.contains('Mint was successful', { timeout: 10000 }).should( + 'be.visible' + ); + cy.contains('View Data Publication').click(); + + cy.url().should('match', /\/browse\/dataPublication\/[0-9]+$/); + }); + + it('should let user add and remove creators', () => { + cy.contains('DOI Title').parent().find('input').type('Test title'); + cy.contains('DOI Description') + .parent() + .find('textarea') + .first() + .type('Test description'); + + // wait for users to load + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + + cy.contains('Username').parent().find('input').type('Michael222'); + cy.contains('button', 'Add Creator').click(); + + // check we can't delete "ourselves" + cy.contains('Thomas') + .parent() + .contains('button', 'Delete') + .should('be.disabled'); + + cy.contains('Randy').should('be.visible'); + cy.contains('Randy').parent().contains('button', 'Delete').click(); + cy.contains('Randy').should('not.exist'); + + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + }); + + it('should let user add contributors and select their contributor type', () => { + cy.contains('DOI Title').parent().find('input').type('Test title'); + cy.contains('DOI Description') + .parent() + .find('textarea') + .first() + .type('Test description'); + + // wait for users to load + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + + cy.contains('Username').parent().find('input').type('Michael222'); + cy.contains('button', 'Add Contributor').click(); + + // shouldn't let users submit DOIs without selecting a contributor type + cy.contains('button', 'Generate DOI').should('be.disabled'); + + cy.contains('label', 'Contributor Type').parent().click(); + + cy.contains('DataCollector').click(); + + // check that contributor info doesn't break the API + cy.contains('button', 'Generate DOI').click(); + + cy.contains('Mint was successful', { timeout: 10000 }).should( + 'be.visible' + ); + }); + + it('should not let user add invalid/duplicate Data Publication users', () => { + cy.contains('DOI Title').parent().find('input').type('Test title'); + cy.contains('DOI Description') + .parent() + .find('textarea') + .first() + .type('Test description'); + + // wait for users to load + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + + cy.get('table[aria-labelledby="creators-label"] tbody tr').should( + 'have.length', + 1 + ); + + cy.contains('Username').parent().find('input').type('Chris481'); + cy.contains('button', 'Add Creator').click(); + + cy.get('table[aria-labelledby="creators-label"] tbody tr').should( + 'have.length', + 1 + ); + cy.contains('Cannot add duplicate user').should('be.visible'); + + cy.contains('Username').parent().find('input').type('invalid'); + cy.contains('button', 'Add Creator').click(); + cy.get('table[aria-labelledby="creators-label"] tbody tr').should( + 'have.length', + 1 + ); + cy.contains("No record found: name='invalid' in User").should( + 'be.visible' + ); + + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + }); + + it('should let user add related DOIs and select their relation & resource type', () => { + cy.contains('DOI Title').parent().find('input').type('Test title'); + cy.contains('DOI Description') + .parent() + .find('textarea') + .first() + .type('Test description'); + + // wait for users to load + cy.contains('button', 'Generate DOI').should('not.be.disabled'); + + // DOI from https://support.datacite.org/docs/testing-guide + cy.contains(/^DOI$/).parent().find('input').type('10.17596/w76y-4s92'); + cy.contains('button', 'Add DOI').click(); + + // shouldn't let users submit DOIs without selecting a relation or resource type + cy.contains('button', 'Generate DOI').should('be.disabled'); + + cy.contains('label', 'Resource Type').parent().click(); + + cy.contains('Journal').click(); + + // shouldn't let users submit DOIs without selecting a relation type + cy.contains('button', 'Generate DOI').should('be.disabled'); + + cy.contains('label', 'Relationship').parent().click(); + + cy.contains('IsCitedBy').click(); + + // check that related DOIs info doesn't break the API + cy.contains('button', 'Generate DOI').click(); + + cy.contains('Mint was successful', { timeout: 10000 }).should( + 'be.visible' + ); + }); + + it('should let user see their current cart items', () => { + cy.contains('DATASET 75').should('be.visible'); + cy.get('table[aria-label="cart dataset table"] tbody tr').should( + 'have.length', + 1 + ); + + cy.contains('button', 'Datafiles').click(); + + cy.contains('Datafile 14').should('be.visible'); + cy.get('table[aria-label="cart datafile table"] tbody tr').should( + 'have.length', + 4 + ); + }); + }); +}); diff --git a/packages/datagateway-download/cypress/e2e/downloadCart.cy.ts b/packages/datagateway-download/cypress/e2e/downloadCart.cy.ts index 2b4918234..3ff3de9c3 100644 --- a/packages/datagateway-download/cypress/e2e/downloadCart.cy.ts +++ b/packages/datagateway-download/cypress/e2e/downloadCart.cy.ts @@ -145,4 +145,13 @@ describe('Download Cart', () => { .should('exist') .click(); }); + + it('should not be able to mint an unmintable cart', () => { + cy.get('[aria-label="Calculating"]', { timeout: 20000 }).should( + 'not.exist' + ); + + // this "button" is a link so can't actually be disabled, check pointer-events + cy.contains('Generate DOI').should('have.css', 'pointer-events', 'none'); + }); }); diff --git a/packages/datagateway-download/cypress/support/commands.js b/packages/datagateway-download/cypress/support/commands.js index d97303d1f..206ea7c2f 100644 --- a/packages/datagateway-download/cypress/support/commands.js +++ b/packages/datagateway-download/cypress/support/commands.js @@ -89,7 +89,7 @@ export const readSciGatewayToken = () => { }; }; -Cypress.Commands.add('login', (credentials) => { +Cypress.Commands.add('login', (credentials, user) => { return cy.request('datagateway-download-settings.json').then((response) => { const settings = response.body; let body = { @@ -104,7 +104,11 @@ Cypress.Commands.add('login', (credentials) => { const jwtHeader = { alg: 'HS256', typ: 'JWT' }; const payload = { sessionId: response.body.sessionID, - username: 'test', + username: user + ? user + : body.mechanism === 'anon' + ? 'anon/anon' + : 'Michael222', }; const jwt = jsrsasign.KJUR.jws.JWS.sign( 'HS256', @@ -152,6 +156,55 @@ Cypress.Commands.add('seedDownloadCart', () => { }); }); +Cypress.Commands.add('seedMintCart', () => { + const items = [ + 'dataset 75', + 'datafile 371', + 'datafile 14', + 'datafile 133', + 'datafile 252', + ].join(', '); + + return cy.request('datagateway-download-settings.json').then((response) => { + const settings = response.body; + cy.request({ + method: 'POST', + url: `${settings.downloadApiUrl}/user/cart/${settings.facilityName}/cartItems`, + body: { + sessionId: readSciGatewayToken().sessionId, + items, + }, + form: true, + }); + }); +}); + +Cypress.Commands.add('clearDataPublications', () => { + return cy.request('datagateway-download-settings.json').then((response) => { + const settings = response.body; + + cy.request({ + method: 'GET', + url: `${settings.apiUrl}/datapublications`, + headers: { Authorization: `Bearer ${readSciGatewayToken().sessionId}` }, + qs: { + where: JSON.stringify({ title: { eq: 'Test title' } }), + }, + }).then((response) => { + const datapublications = response.body; + datapublications.forEach((datapublication) => { + cy.request({ + method: 'DELETE', + url: `${settings.apiUrl}/datapublications/${datapublication.id}`, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }); + }); + }); + }); +}); + Cypress.Commands.add('addCartItem', (cartItem) => { return cy.request('datagateway-download-settings.json').then((response) => { const settings = response.body; diff --git a/packages/datagateway-download/cypress/support/index.d.ts b/packages/datagateway-download/cypress/support/index.d.ts index 138545f22..110365b48 100644 --- a/packages/datagateway-download/cypress/support/index.d.ts +++ b/packages/datagateway-download/cypress/support/index.d.ts @@ -1,15 +1,22 @@ declare namespace Cypress { interface Chainable { - login(credentials?: { - username: string; - password: string; - mechanism: string; - }): Cypress.Chainable; + login( + credentials?: { + username: string; + password: string; + mechanism: string; + }, + user?: string + ): Cypress.Chainable; clearDownloadCart(): Cypress.Chainable; seedDownloadCart(): Cypress.Chainable; + seedMintCart(): Cypress.Chainable; + + clearDataPublications(): Cypress.Chainable; + addCartItem(cartItem: string): Cypress.Chainable; seedDownloads(): Cypress.Chainable; diff --git a/packages/datagateway-download/public/res/default.json b/packages/datagateway-download/public/res/default.json index 4407133bf..415b80cfc 100644 --- a/packages/datagateway-download/public/res/default.json +++ b/packages/datagateway-download/public/res/default.json @@ -85,6 +85,9 @@ "remove": "Remove {{name}} from selection", "remove_all": "Remove All", "download": "Download Selection", + "generate_DOI": "Generate DOI", + "mintability_loading": "Checking whether you have permission to mint these files...", + "not_mintable": "You do not have permission to mint these files", "number_of_files": "Number of files", "total_size": "Total size", "no_selections": "No data selected. <2>Browse or <6>search for data.", @@ -93,5 +96,50 @@ "empty_items_error": "You have selected some empty items - please remove them to proceed with downloading your selection", "file_limit_error": "Too many files - you have exceeded limit of {{fileCountMax}} files - please remove some files", "size_limit_error": "Too much data - you have exceeded limit of {{totalSizeMax}} - please remove some files" + }, + "acceptDataPolicy": { + "accept": "Accept", + "data_policy": "Accept data policy: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum lore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + }, + "DOIGenerationForm": { + "page_header": "Generate DOI", + "data_header": "Data", + "form_header": "Details", + "title": "DOI Title", + "description": "DOI Description", + "creators": "Creators & Contributors", + "username": "Username", + "add_creator": "Add Creator", + "add_contributor": "Add Contributor", + "creator_name": "Name", + "creator_affiliation": "Affiliation", + "creator_email": "Email", + "creator_type": "Contributor Type", + "creator_action": "Action", + "delete_creator": "Delete", + "related_dois": "Related DOIs", + "related_doi": "DOI", + "add_related_doi": "Add DOI", + "related_doi_doi": "DOI", + "related_doi_relationship": "Relationship", + "related_doi_resource_type": "Resource Type", + "related_doi_action": "Action", + "delete_related_doi": "Delete", + "generate_DOI": "Generate DOI", + "cart_tabs_aria_label": "cart tabs", + "cart_tab_investigations": "Investigations", + "cart_tab_datasets": "Datasets", + "cart_tab_datafiles": "Datafiles", + "cart_table_name": "Name" + }, + "DOIConfirmDialog": { + "dialog_title": "Mint Confirmation", + "mint_success": "Mint was successful", + "mint_error": "Mint was unsuccessful", + "mint_loading": "Loading...", + "concept_doi_label": "Concept DOI", + "version_doi_label": "Version DOI", + "error_label": "Error", + "view_data_publication": "View Data Publication" } } diff --git a/packages/datagateway-download/server/e2e-settings.json b/packages/datagateway-download/server/e2e-settings.json index d4d9405e5..676dd62aa 100644 --- a/packages/datagateway-download/server/e2e-settings.json +++ b/packages/datagateway-download/server/e2e-settings.json @@ -3,6 +3,8 @@ "apiUrl": "http://localhost:5000", "downloadApiUrl": "https://localhost:8181/topcat", "idsUrl": "https://localhost:8181/ids", + "doiMinterUrl": "http://localhost:8000", + "dataCiteUrl": "https://api.test.datacite.org", "fileCountMax": 5000, "totalSizeMax": 1000000000000, "accessMethods": { diff --git a/packages/datagateway-download/src/App.tsx b/packages/datagateway-download/src/App.tsx index 47bbd7645..949eb9d34 100644 --- a/packages/datagateway-download/src/App.tsx +++ b/packages/datagateway-download/src/App.tsx @@ -7,9 +7,10 @@ import { import React, { Component } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactQueryDevtools } from 'react-query/devtools'; -import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import { BrowserRouter as Router, Link, Route, Switch } from 'react-router-dom'; import ConfigProvider from './ConfigProvider'; +import DOIGenerationForm from './DOIGenerationForm/DOIGenerationForm.component'; import AdminDownloadStatusTable from './downloadStatus/adminDownloadStatusTable.component'; import DownloadTabs from './downloadTab/downloadTab.component'; @@ -86,10 +87,19 @@ class App extends Component { > - + {/* development redirect route so people don't get confused by blank screen */} + Downloads} + /> + - + + + + diff --git a/packages/datagateway-download/src/ConfigProvider.tsx b/packages/datagateway-download/src/ConfigProvider.tsx index d4881e22a..f5c7ee468 100644 --- a/packages/datagateway-download/src/ConfigProvider.tsx +++ b/packages/datagateway-download/src/ConfigProvider.tsx @@ -15,6 +15,8 @@ export interface DownloadSettings { apiUrl: string; downloadApiUrl: string; idsUrl: string; + doiMinterUrl?: string; + dataCiteUrl?: string; fileCountMax?: number; totalSizeMax?: number; @@ -40,6 +42,7 @@ const initialConfiguration: DownloadSettings = { apiUrl: '', downloadApiUrl: '', idsUrl: '', + doiMinterUrl: '', fileCountMax: undefined, totalSizeMax: undefined, accessMethods: {}, diff --git a/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.test.tsx b/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.test.tsx new file mode 100644 index 000000000..708ced638 --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.test.tsx @@ -0,0 +1,93 @@ +import { render, RenderResult, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import * as React from 'react'; +import { Router } from 'react-router-dom'; +import DOIConfirmDialog from './DOIConfirmDialog.component'; + +describe('DOI Confirm Dialogue component', () => { + let user: ReturnType; + let props: React.ComponentProps; + + const renderComponent = (): RenderResult & { history: MemoryHistory } => { + const history = createMemoryHistory(); + return { + history, + ...render( + + + + ), + }; + }; + + beforeEach(() => { + user = userEvent.setup(); + props = { + open: true, + mintingStatus: 'loading', + data: undefined, + error: null, + setClose: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should show loading indicator when mintingStatus is loading', async () => { + renderComponent(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect( + screen.getByText('DOIConfirmDialog.mint_loading') + ).toBeInTheDocument(); + + // expect user can't close the dialog + await user.type(screen.getByRole('dialog'), '{Esc}'); + expect( + screen.queryByRole('button', { + name: 'downloadConfirmDialog.close_arialabel', + }) + ).not.toBeInTheDocument(); + expect(props.setClose).not.toHaveBeenCalled(); + }); + + it('should show success indicators when mintingStatus is success and allow user to view their data publication', async () => { + props.mintingStatus = 'success'; + props.data = { + concept: { data_publication: '123456', doi: 'test_doi' }, + version: { data_publication: '1234561', doi: 'test_doiv1' }, + }; + const { history } = renderComponent(); + + expect( + screen.getByText('DOIConfirmDialog.mint_success') + ).toBeInTheDocument(); + expect(screen.getByText(/test_doi$/)).toBeInTheDocument(); + expect(screen.getByText(/test_doiv1$/)).toBeInTheDocument(); + + await user.click( + screen.getByRole('link', { + name: 'DOIConfirmDialog.view_data_publication', + }) + ); + expect(history.location).toMatchObject({ + pathname: `/browse/dataPublication/${props.data.version.data_publication}`, + }); + }); + + it('should show error indicators when mintingStatus is error and allow user to close the dialog', async () => { + props.mintingStatus = 'error'; + props.error = { response: { data: { detail: 'error msg' } } }; + renderComponent(); + + expect(screen.getByText('DOIConfirmDialog.mint_error')).toBeInTheDocument(); + expect(screen.getByText('error msg', { exact: false })).toBeInTheDocument(); + + // use Esc to close dialog + await user.type(screen.getByRole('dialog'), '{Esc}'); + expect(props.setClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.tsx b/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.tsx new file mode 100644 index 000000000..917726f1f --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/DOIConfirmDialog.component.tsx @@ -0,0 +1,140 @@ +import { + Button, + CircularProgress, + Dialog, + Grid, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import { Mark } from 'datagateway-common'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { QueryStatus } from 'react-query'; +import { Link } from 'react-router-dom'; + +import type { DoiResponse } from '../downloadApi'; +import DialogContent from '../downloadConfirmation/dialogContent.component'; +import DialogTitle from '../downloadConfirmation/dialogTitle.component'; + +interface DOIConfirmDialogProps { + open: boolean; + mintingStatus: QueryStatus; + data: DoiResponse | undefined; + error: AxiosError<{ + detail: { msg: string }[] | string; + }> | null; + + setClose: () => void; +} + +const DOIConfirmDialog: React.FC = ( + props: DOIConfirmDialogProps +) => { + const { open, mintingStatus, data, error, setClose } = props; + + const isMintError = mintingStatus === 'error'; + + const isMintSuccess = mintingStatus === 'success'; + + const isMintLoading = mintingStatus === 'loading'; + + const [t] = useTranslation(); + + return ( + { + if (isMintError) { + setClose(); + } + }} + open={open} + fullWidth={true} + maxWidth={'sm'} + > +
+ setClose() : undefined}> + {t('DOIConfirmDialog.dialog_title')} + + + + + {isMintSuccess ? ( + + ) : isMintError ? ( + + ) : isMintLoading ? ( + + ) : null} + + + {isMintSuccess ? ( + + {t('DOIConfirmDialog.mint_success')} + + ) : isMintError ? ( + + {t('DOIConfirmDialog.mint_error')} + + ) : ( + + {t('DOIConfirmDialog.mint_loading')} + + )} + + {isMintSuccess && data && ( + + + {`${t('DOIConfirmDialog.concept_doi_label')}: ${ + data.concept.doi + }`} + + + {`${t('DOIConfirmDialog.version_doi_label')}: ${ + data.version.doi + }`} + + + )} + + {isMintError && error && ( + + + {`${t('DOIConfirmDialog.error_label')}: + ${ + error.response?.data?.detail + ? typeof error.response.data.detail === 'string' + ? error.response.data.detail + : error.response.data.detail[0].msg + : error.message + }`} + + + )} + + {isMintSuccess && data && ( + + + + )} + + +
+
+ ); +}; + +export default DOIConfirmDialog; diff --git a/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.test.tsx b/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.test.tsx new file mode 100644 index 000000000..01128ce50 --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.test.tsx @@ -0,0 +1,526 @@ +import { + render, + RenderResult, + screen, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { fetchDownloadCart } from 'datagateway-common'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { Router } from 'react-router-dom'; +import { DownloadSettingsContext } from '../ConfigProvider'; +import { mockCartItems, mockedSettings } from '../testData'; +import { + checkUser, + DOIRelationType, + DOIResourceType, + fetchDOI, + getCartUsers, + isCartMintable, + mintCart, +} from '../downloadApi'; +import DOIGenerationForm from './DOIGenerationForm.component'; + +setLogger({ + log: console.log, + warn: console.warn, + error: jest.fn(), +}); + +jest.mock('datagateway-common', () => { + const originalModule = jest.requireActual('datagateway-common'); + + return { + __esModule: true, + ...originalModule, + fetchDownloadCart: jest.fn(), + }; +}); + +jest.mock('../downloadApi', () => { + const originalModule = jest.requireActual('../downloadApi'); + + return { + ...originalModule, + isCartMintable: jest.fn(), + getCartUsers: jest.fn(), + checkUser: jest.fn(), + mintCart: jest.fn(), + fetchDOI: jest.fn(), + }; +}); + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +const renderComponent = ( + history = createMemoryHistory({ + initialEntries: [{ pathname: '/download/mint', state: { fromCart: true } }], + }) +): RenderResult & { history: MemoryHistory } => ({ + history, + ...render( + + + + + + + + ), +}); + +describe('DOI generation form component', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + + ( + fetchDownloadCart as jest.MockedFunction + ).mockResolvedValue(mockCartItems); + + ( + isCartMintable as jest.MockedFunction + ).mockResolvedValue(true); + + // mock mint cart error to test dialog can be closed after it errors + (mintCart as jest.MockedFunction).mockRejectedValue( + 'error' + ); + + ( + getCartUsers as jest.MockedFunction + ).mockResolvedValue([ + { + id: 1, + name: '1', + fullName: 'User 1', + email: 'user1@example.com', + affiliation: 'Example Uni', + }, + ]); + + (checkUser as jest.MockedFunction).mockResolvedValue({ + id: 2, + name: '2', + fullName: 'User 2', + email: 'user2@example.com', + affiliation: 'Example 2 Uni', + }); + + (fetchDOI as jest.MockedFunction).mockResolvedValue({ + id: '1', + type: 'DOI', + attributes: { + doi: 'related.doi.1', + titles: [{ title: 'Related DOI 1' }], + url: 'www.example.com', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should redirect back to /download if user directly accesses the url', async () => { + const { history } = renderComponent(createMemoryHistory()); + + expect(history.location).toMatchObject({ pathname: '/download' }); + }); + + it('should render the data policy before loading the form', async () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ).toBeInTheDocument(); + expect( + screen.queryByText('DOIGenerationForm.page_header') + ).not.toBeInTheDocument(); + }); + + it('should let the user fill in the required fields and submit a mint request', async () => { + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.title' }), + 't' + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.description' }), + 'd' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ); + + expect( + await screen.findByRole('dialog', { + name: 'DOIConfirmDialog.dialog_title', + }) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { + name: 'downloadConfirmDialog.close_arialabel', + }) + ); + + await waitForElementToBeRemoved(() => + screen.queryByRole('dialog', { + name: 'DOIConfirmDialog.dialog_title', + }) + ); + }); + + it('should not let the user submit a mint request if required fields are missing but can submit once all are filled in', async () => { + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + // missing title + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.title' }), + 't' + ); + + // missing description + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.description' }), + 'd' + ); + + // missing cart users + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }), + '2' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_contributor' }) + ); + + // missing contributor type + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + await user.click( + screen.getByRole('button', { + name: /DOIGenerationForm.creator_type/i, + }) + ); + await user.click( + await screen.findByRole('option', { name: 'DataCollector' }) + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.related_doi' }), + '1' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_related_doi' }) + ); + + // missing relationship type + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + await user.click( + screen.getByRole('button', { + name: /DOIGenerationForm.related_doi_relationship/i, + }) + ); + await user.click(await screen.findByRole('option', { name: 'IsCitedBy' })); + + // missing resource type + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + await user.click( + screen.getByRole('button', { + name: /DOIGenerationForm.related_doi_resource_type/i, + }) + ); + await user.click(await screen.findByRole('option', { name: 'Journal' })); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ); + + expect(mintCart).toHaveBeenCalledWith( + mockCartItems, + { + title: 't', + description: 'd', + creators: [ + { username: '1', contributor_type: 'Creator' }, + { username: '2', contributor_type: 'DataCollector' }, + ], + related_items: [ + { + title: 'Related DOI 1', + fullReference: '', + relatedIdentifier: 'related.doi.1', + relatedIdentifierType: 'DOI', + relationType: DOIRelationType.IsCitedBy, + resourceType: DOIResourceType.Journal, + }, + ], + }, + expect.any(Object) + ); + }); + + it('should not let the user submit a mint request if cart fails to load', async () => { + ( + fetchDownloadCart as jest.MockedFunction + ).mockRejectedValue({ message: 'error' }); + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.title' }), + 't' + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.description' }), + 'd' + ); + + // missing cart + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + }); + + it('should not let the user submit a mint request if cart is empty', async () => { + ( + fetchDownloadCart as jest.MockedFunction + ).mockResolvedValue([]); + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.title' }), + 't' + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.description' }), + 'd' + ); + + // empty cart + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + }); + + it('should not let the user submit a mint request if no users selected', async () => { + ( + getCartUsers as jest.MockedFunction + ).mockResolvedValue([]); + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.title' }), + 't' + ); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.description' }), + 'd' + ); + + // no users + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.generate_DOI' }) + ).toBeDisabled(); + + // expect add user + add contributor buttons to also be disabled + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'DOIGenerationForm.add_contributor' }) + ).toBeDisabled(); + }); + + it('should let the user change cart tabs', async () => { + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'cart investigation table' }) + ).getByRole('cell', { name: 'INVESTIGATION 1' }) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('tab', { name: 'DOIGenerationForm.cart_tab_datasets' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'cart dataset table' }) + ).getByRole('cell', { name: 'DATASET 1' }) + ).toBeInTheDocument(); + }); + + describe('only displays cart tabs if the corresponding entity type exists in the cart: ', () => { + it('investigations', async () => { + ( + fetchDownloadCart as jest.MockedFunction + ).mockResolvedValue([mockCartItems[0]]); + + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'cart investigation table' }) + ).getByRole('cell', { name: 'INVESTIGATION 1' }) + ).toBeInTheDocument(); + + expect( + screen.getByRole('tab', { + name: 'DOIGenerationForm.cart_tab_investigations', + }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_datasets', + }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_datafiles', + }) + ).not.toBeInTheDocument(); + }); + + it('datasets', async () => { + ( + fetchDownloadCart as jest.MockedFunction + ).mockResolvedValue([mockCartItems[2]]); + + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'cart dataset table' }) + ).getByRole('cell', { name: 'DATASET 1' }) + ).toBeInTheDocument(); + + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_investigations', + }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: 'DOIGenerationForm.cart_tab_datasets' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_datafiles', + }) + ).not.toBeInTheDocument(); + }); + + it('datafiles', async () => { + ( + fetchDownloadCart as jest.MockedFunction + ).mockResolvedValue([mockCartItems[3]]); + + renderComponent(); + + // accept data policy + await user.click( + screen.getByRole('button', { name: 'acceptDataPolicy.accept' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'cart datafile table' }) + ).getByRole('cell', { name: 'DATAFILE 1' }) + ).toBeInTheDocument(); + + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_investigations', + }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('tab', { + name: 'DOIGenerationForm.cart_tab_datasets', + }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('tab', { + name: 'DOIGenerationForm.cart_tab_datafiles', + }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.tsx b/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.tsx new file mode 100644 index 000000000..663243d5d --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/DOIGenerationForm.component.tsx @@ -0,0 +1,299 @@ +import { + Box, + Button, + Grid, + Paper, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + TextField, + Typography, +} from '@mui/material'; +import { readSciGatewayToken } from 'datagateway-common'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Redirect, useLocation } from 'react-router-dom'; +import { ContributorType, type RelatedDOI } from '../downloadApi'; +import { useCart, useCartUsers, useMintCart } from '../downloadApiHooks'; +import AcceptDataPolicy from './acceptDataPolicy.component'; +import CreatorsAndContributors, { + ContributorUser, +} from './creatorsAndContributors.component'; +import DOIConfirmDialog from './DOIConfirmDialog.component'; +import RelatedDOIs from './relatedDOIs.component'; + +const DOIGenerationForm: React.FC = () => { + const [acceptedDataPolicy, setAcceptedDataPolicy] = React.useState(false); + const [selectedUsers, setSelectedUsers] = React.useState( + [] + ); + const [relatedDOIs, setRelatedDOIs] = React.useState([]); + const [title, setTitle] = React.useState(''); + const [description, setDescription] = React.useState(''); + const [currentTab, setCurrentTab] = React.useState< + 'investigation' | 'dataset' | 'datafile' + >('investigation'); + const [showMintConfirmation, setShowMintConfirmation] = React.useState(false); + + const handleTabChange = ( + event: React.SyntheticEvent, + newValue: 'investigation' | 'dataset' | 'datafile' + ): void => { + setCurrentTab(newValue); + }; + + const { data: cart } = useCart(); + const { data: users } = useCartUsers(cart); + const { + mutate: mintCart, + status: mintingStatus, + data: mintData, + error: mintError, + } = useMintCart(); + + React.useEffect(() => { + if (users) + setSelectedUsers( + users.map((user) => ({ + ...user, + contributor_type: ContributorType.Creator, + })) + ); + }, [users]); + + React.useEffect(() => { + if (cart) { + if (cart?.some((cartItem) => cartItem.entityType === 'investigation')) + setCurrentTab('investigation'); + else if (cart?.some((cartItem) => cartItem.entityType === 'dataset')) + setCurrentTab('dataset'); + else if (cart?.some((cartItem) => cartItem.entityType === 'datafile')) + setCurrentTab('datafile'); + } + }, [cart]); + + const location = useLocation<{ fromCart: boolean } | undefined>(); + + const [t] = useTranslation(); + + // redirect if the user tries to access the link directly instead of from the cart + if (!location.state?.fromCart) { + return ; + } + + return ( + + {acceptedDataPolicy ? ( + <> + + {/* need to specify colour is textPrimary since this Typography is not in a Paper */} + + {t('DOIGenerationForm.page_header')} + + + {/* use row-reverse, justifyContent start and the "wrong" order of components to make overflow layout nice + i.e. data summary presented at top before DOI form, but in non-overflow + mode it's DOI form on left and data summary on right */} + + + + + {t('DOIGenerationForm.data_header')} + + + + + + {cart?.some( + (cartItem) => cartItem.entityType === 'investigation' + ) && ( + + )} + {cart?.some( + (cartItem) => cartItem.entityType === 'dataset' + ) && ( + + )} + {cart?.some( + (cartItem) => cartItem.entityType === 'datafile' + ) && ( + + )} + + + {/* TODO: do we need to display more info in this table? + we could rejig the fetch for users to return more info we want + as we're already querying every item in the cart there */} + + + + + {t('DOIGenerationForm.cart_table_name')} + + + + + {cart + ?.filter( + (cartItem) => cartItem.entityType === currentTab + ) + .map((cartItem) => ( + + {cartItem.name} + + ))} + +
+
+
+ + + + {t('DOIGenerationForm.form_header')} + + + + setTitle(event.target.value)} + /> + + + setDescription(event.target.value)} + /> + + + + + + + + + + + +
+
+
+ {/* Show the download confirmation dialog. */} + setShowMintConfirmation(false)} + /> + + ) : ( + setAcceptedDataPolicy(true)} + /> + )} +
+ ); +}; + +export default DOIGenerationForm; diff --git a/packages/datagateway-download/src/DOIGenerationForm/acceptDataPolicy.component.tsx b/packages/datagateway-download/src/DOIGenerationForm/acceptDataPolicy.component.tsx new file mode 100644 index 000000000..57c17f91f --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/acceptDataPolicy.component.tsx @@ -0,0 +1,45 @@ +import { Button, Grid, Paper, Typography } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +type AcceptDataPolicyProps = { + acceptDataPolicy: () => void; +}; + +const AcceptDataPolicy: React.FC = (props) => { + const [t] = useTranslation(); + return ( + + + + + {/* TODO: write data policy text */} + + {t('acceptDataPolicy.data_policy')} + + + + + + + + + ); +}; + +export default AcceptDataPolicy; diff --git a/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.test.tsx b/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.test.tsx new file mode 100644 index 000000000..f00d715a5 --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.test.tsx @@ -0,0 +1,278 @@ +import { render, RenderResult, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { DownloadSettingsContext } from '../ConfigProvider'; +import { mockedSettings } from '../testData'; +import { checkUser, ContributorType } from '../downloadApi'; +import CreatorsAndContributors from './creatorsAndContributors.component'; + +setLogger({ + log: console.log, + warn: console.warn, + error: jest.fn(), +}); + +jest.mock('datagateway-common', () => { + const originalModule = jest.requireActual('datagateway-common'); + + return { + __esModule: true, + ...originalModule, + readSciGatewayToken: jest.fn(() => ({ + username: '1', + })), + }; +}); + +jest.mock('../downloadApi', () => { + const originalModule = jest.requireActual('../downloadApi'); + + return { + ...originalModule, + + checkUser: jest.fn(), + }; +}); + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +describe('DOI generation form component', () => { + let user: ReturnType; + + let props: React.ComponentProps; + + const TestComponent: React.FC = () => { + const [selectedUsers, changeSelectedUsers] = React.useState( + // eslint-disable-next-line react/prop-types + props.selectedUsers + ); + + return ( + + + + + + ); + }; + + const renderComponent = (): RenderResult => render(); + + beforeEach(() => { + user = userEvent.setup(); + + props = { + selectedUsers: [ + { + id: 1, + name: '1', + fullName: 'User 1', + email: 'user1@example.com', + affiliation: 'Example Uni', + contributor_type: ContributorType.Creator, + }, + { + id: 2, + name: '2', + fullName: 'User 2', + email: 'user2@example.com', + affiliation: 'Example 2 Uni', + contributor_type: ContributorType.Creator, + }, + ], + changeSelectedUsers: jest.fn(), + }; + (checkUser as jest.MockedFunction).mockResolvedValue({ + id: 3, + name: '3', + fullName: 'User 3', + email: 'user3@example.com', + affiliation: 'Example 3 Uni', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should let the user delete users (but not delete the logged in user)', async () => { + renderComponent(); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(2); + expect( + screen.getByRole('cell', { name: 'user2@example.com' }) + ).toBeInTheDocument(); + + const userDeleteButtons = screen.getAllByRole('button', { + name: 'DOIGenerationForm.delete_creator', + }); + expect(userDeleteButtons[0]).toBeDisabled(); + + await user.click(userDeleteButtons[1]); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) + ).toHaveLength(1); + expect( + screen.getByRole('cell', { name: 'Example Uni' }) + ).toBeInTheDocument(); + }); + + it('should let the user add creators (but not duplicate users or if checkUser fails)', async () => { + renderComponent(); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(2); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }), + '3' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + expect(screen.getByRole('cell', { name: 'User 3' })).toBeInTheDocument(); + expect(screen.getAllByRole('cell', { name: 'Creator' }).length).toBe(3); + + // test errors on duplicate user + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }), + '3' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + expect(screen.getByText('Cannot add duplicate user')).toBeInTheDocument(); + expect( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }) + ).toHaveValue(''); + + // test errors with various API error responses + (checkUser as jest.MockedFunction).mockRejectedValueOnce({ + response: { data: { detail: 'error msg' }, status: 404 }, + }); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }), + '4' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ); + + expect(await screen.findByText('error msg')).toBeInTheDocument(); + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + + (checkUser as jest.MockedFunction).mockRejectedValue({ + response: { data: { detail: [{ msg: 'error msg 2' }] }, status: 404 }, + }); + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ); + + expect(await screen.findByText('error msg 2')).toBeInTheDocument(); + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + + (checkUser as jest.MockedFunction).mockRejectedValueOnce({ + response: { status: 422 }, + }); + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_creator' }) + ); + + expect(await screen.findByText('Error')).toBeInTheDocument(); + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + }); + + it('should let the user add contributors & select their contributor type', async () => { + renderComponent(); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(2); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.username' }), + '3' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_contributor' }) + ); + + expect( + within(screen.getByRole('table', { name: 'DOIGenerationForm.creators' })) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(3); + expect(screen.getByRole('cell', { name: 'User 3' })).toBeInTheDocument(); + + expect( + screen.getByRole('button', { + name: /DOIGenerationForm.creator_type/i, + }) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { + name: /DOIGenerationForm.creator_type/i, + }) + ); + await user.click( + await screen.findByRole('option', { name: 'DataCollector' }) + ); + + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + // check that the option is actually selected in the table even after the menu closes + expect(screen.getByText('DataCollector')).toBeInTheDocument(); + }); +}); diff --git a/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.tsx b/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.tsx new file mode 100644 index 000000000..5cca13279 --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/creatorsAndContributors.component.tsx @@ -0,0 +1,289 @@ +import { + Button, + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import { readSciGatewayToken, User } from 'datagateway-common'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ContributorType } from '../downloadApi'; +import { useCheckUser } from '../downloadApiHooks'; + +export type ContributorUser = User & { + contributor_type: ContributorType | ''; +}; + +const compareUsers = (a: ContributorUser, b: ContributorUser): number => { + if ( + a.contributor_type === ContributorType.Creator && + b.contributor_type !== ContributorType.Creator + ) { + return -1; + } else if ( + b.contributor_type === ContributorType.Creator && + a.contributor_type !== ContributorType.Creator + ) { + return 1; + } else return 0; +}; + +type CreatorsAndContributorsProps = { + selectedUsers: ContributorUser[]; + changeSelectedUsers: React.Dispatch>; +}; + +const CreatorsAndContributors: React.FC = ( + props +) => { + const { selectedUsers, changeSelectedUsers } = props; + const [t] = useTranslation(); + const [username, setUsername] = React.useState(''); + const [usernameError, setUsernameError] = React.useState(''); + const { refetch: checkUser } = useCheckUser(username); + + /** + * Returns a function, which you pass true or false to depending on whether + * it's the creator button or not, and returns the relevant click handler + */ + const handleAddCreatorOrContributorClick = React.useCallback( + (creator: boolean) => () => { + // don't let the user add duplicates + if ( + selectedUsers.every((selectedUser) => selectedUser.name !== username) + ) { + checkUser({ throwOnError: true }) + .then((response) => { + // add user + if (response.data) { + const user: ContributorUser = { + ...response.data, + contributor_type: creator ? ContributorType.Creator : '', + }; + changeSelectedUsers((selectedUsers) => [...selectedUsers, user]); + setUsername(''); + } + }) + .catch( + ( + error: AxiosError<{ + detail: { msg: string }[] | string; + }> + ) => { + // TODO: check this is the right message from the API + setUsernameError( + error.response?.data?.detail + ? typeof error.response.data.detail === 'string' + ? error.response.data.detail + : error.response.data.detail[0].msg + : 'Error' + ); + } + ); + } else { + setUsernameError('Cannot add duplicate user'); + setUsername(''); + } + }, + [changeSelectedUsers, checkUser, selectedUsers, username] + ); + + return ( + + theme.palette.mode === 'dark' + ? theme.palette.grey[800] + : theme.palette.grey[100], + padding: 1, + }} + elevation={0} + variant="outlined" + > + + + + {t('DOIGenerationForm.creators')} + + + 0 ? 2 : 0, + }} + > + + 0} + helperText={usernameError.length > 0 ? usernameError : ''} + color="secondary" + sx={{ + // this CSS makes it so that the helperText doesn't mess with the button alignment + '& .MuiFormHelperText-root': { + position: 'absolute', + bottom: '-1.5rem', + }, + }} + InputProps={{ + sx: { + backgroundColor: 'background.default', + }, + }} + value={username} + onChange={(event) => { + setUsername(event.target.value); + setUsernameError(''); + }} + /> + + + + + + + + + + + + + + + {t('DOIGenerationForm.creator_name')} + + {t('DOIGenerationForm.creator_affiliation')} + + {t('DOIGenerationForm.creator_email')} + {t('DOIGenerationForm.creator_type')} + {t('DOIGenerationForm.creator_action')} + + + + {selectedUsers.length === 0 && ( + + + + + + )} + {[...selectedUsers] // need to spread so we don't alter underlying array + .sort(compareUsers) + .map((user) => ( + + {user.fullName} + {user?.affiliation} + {user?.email} + + {user.contributor_type === ContributorType.Creator ? ( + ContributorType.Creator + ) : ( + + + {t('DOIGenerationForm.creator_type')} + + + + )} + + + + + + ))} + +
+
+
+
+ ); +}; + +export default CreatorsAndContributors; diff --git a/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.test.tsx b/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.test.tsx new file mode 100644 index 000000000..c166bbe3f --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.test.tsx @@ -0,0 +1,206 @@ +import { render, RenderResult, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; +import { DownloadSettingsContext } from '../ConfigProvider'; +import { mockedSettings } from '../testData'; +import { fetchDOI } from '../downloadApi'; +import RelatedDOIs from './relatedDOIs.component'; + +setLogger({ + log: console.log, + warn: console.warn, + error: jest.fn(), +}); + +jest.mock('../downloadApi', () => { + const originalModule = jest.requireActual('../downloadApi'); + + return { + ...originalModule, + + fetchDOI: jest.fn(), + }; +}); + +const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +describe('DOI generation form component', () => { + let user: ReturnType; + + let props: React.ComponentProps; + + const TestComponent: React.FC = () => { + const [relatedDOIs, changeRelatedDOIs] = React.useState( + // eslint-disable-next-line react/prop-types + props.relatedDOIs + ); + + return ( + + + + + + ); + }; + + const renderComponent = (): RenderResult => render(); + + beforeEach(() => { + user = userEvent.setup(); + + props = { + relatedDOIs: [ + { + title: 'Related DOI 1', + fullReference: '', + relatedIdentifier: 'related.doi.1', + relatedIdentifierType: 'DOI', + relationType: '', + resourceType: '', + }, + ], + changeRelatedDOIs: jest.fn(), + }; + (fetchDOI as jest.MockedFunction).mockResolvedValue({ + id: '2', + type: 'DOI', + attributes: { + doi: 'related.doi.2', + titles: [{ title: 'Related DOI 2' }], + url: 'www.example.com', + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should let the user add related dois (but not if fetchDOI fails) + lets you change the relation type + resource type', async () => { + renderComponent(); + + expect( + within( + screen.getByRole('table', { name: 'DOIGenerationForm.related_dois' }) + ) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(1); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.related_doi' }), + '2' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_related_doi' }) + ); + + expect( + within( + screen.getByRole('table', { name: 'DOIGenerationForm.related_dois' }) + ) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(2); + expect( + screen.getByRole('cell', { name: 'related.doi.2' }) + ).toBeInTheDocument(); + + await user.click( + screen.getAllByRole('button', { + name: /DOIGenerationForm.related_doi_relationship/i, + })[0] + ); + await user.click(await screen.findByRole('option', { name: 'IsCitedBy' })); + + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + // check that the option is actually selected in the table even after the menu closes + expect(screen.getByText('IsCitedBy')).toBeInTheDocument(); + + await user.click( + screen.getAllByRole('button', { + name: /DOIGenerationForm.related_doi_resource_type/i, + })[0] + ); + await user.click(await screen.findByRole('option', { name: 'Journal' })); + + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + // check that the option is actually selected in the table even after the menu closes + expect(screen.getByText('Journal')).toBeInTheDocument(); + + // test errors with various API error responses + (fetchDOI as jest.MockedFunction).mockRejectedValueOnce({ + response: { data: { errors: [{ title: 'error msg' }] }, status: 404 }, + }); + + await user.type( + screen.getByRole('textbox', { name: 'DOIGenerationForm.related_doi' }), + '3' + ); + + await user.click( + screen.getByRole('button', { name: 'DOIGenerationForm.add_related_doi' }) + ); + + expect(await screen.findByText('error msg')).toBeInTheDocument(); + expect( + within( + screen.getByRole('table', { name: 'DOIGenerationForm.related_dois' }) + ) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(2); + }); + + it('should let the user delete related dois', async () => { + renderComponent(); + + expect( + within( + screen.getByRole('table', { name: 'DOIGenerationForm.related_dois' }) + ) + .getAllByRole('row') + .slice(1) // ignores the header row + ).toHaveLength(1); + expect( + screen.getByRole('cell', { name: 'related.doi.1' }) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { + name: 'DOIGenerationForm.delete_related_doi', + }) + ); + + expect( + screen.queryByRole('table', { name: 'DOIGenerationForm.related_dois' }) + ).not.toBeInTheDocument(); + }); + + it('should render dois as links and show title on hover', async () => { + renderComponent(); + + const doiLink = screen.getByRole('link', { name: 'related.doi.1' }); + + expect(doiLink).toHaveAttribute('href', 'https://doi.org/related.doi.1'); + + await user.hover(doiLink); + + expect( + await screen.findByRole('tooltip', { name: 'Related DOI 1' }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.tsx b/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.tsx new file mode 100644 index 000000000..9994a1ad4 --- /dev/null +++ b/packages/datagateway-download/src/DOIGenerationForm/relatedDOIs.component.tsx @@ -0,0 +1,286 @@ +import { + Button, + FormControl, + Grid, + InputLabel, + Link, + MenuItem, + Paper, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import { StyledTooltip } from 'datagateway-common/lib/arrowtooltip.component'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { DOIRelationType, DOIResourceType, RelatedDOI } from '../downloadApi'; +import { useCheckDOI } from '../downloadApiHooks'; + +type RelatedDOIsProps = { + relatedDOIs: RelatedDOI[]; + changeRelatedDOIs: React.Dispatch>; +}; + +const RelatedDOIs: React.FC = (props) => { + const { relatedDOIs, changeRelatedDOIs } = props; + const [t] = useTranslation(); + const [relatedDOI, setRelatedDOI] = React.useState(''); + const [relatedDOIError, setRelatedDOIError] = React.useState(''); + const { refetch: checkDOI } = useCheckDOI(relatedDOI); + + return ( + + theme.palette.mode === 'dark' + ? theme.palette.grey[800] + : theme.palette.grey[100], + padding: 1, + }} + elevation={0} + variant="outlined" + > + + + + {t('DOIGenerationForm.related_dois')} + + + 0 ? 2 : 0, + }} + > + + 0} + helperText={relatedDOIError.length > 0 ? relatedDOIError : ''} + color="secondary" + sx={{ + // this CSS makes it so that the helperText doesn't mess with the button alignment + '& .MuiFormHelperText-root': { + position: 'absolute', + bottom: '-1.5rem', + }, + }} + InputProps={{ + sx: { + backgroundColor: 'background.default', + }, + }} + value={relatedDOI} + onChange={(event) => { + setRelatedDOI(event.target.value); + setRelatedDOIError(''); + }} + /> + + + + + + {relatedDOIs.length > 0 && ( + + + + + + {t('DOIGenerationForm.related_doi_doi')} + + + {t('DOIGenerationForm.related_doi_relationship')} + + + {t('DOIGenerationForm.related_doi_resource_type')} + + + {t('DOIGenerationForm.related_doi_action')} + + + + + {relatedDOIs.map((relatedItem) => ( + + + + + {relatedItem.relatedIdentifier} + + + + + + + {t('DOIGenerationForm.related_doi_relationship')} + + + + + + + + {t('DOIGenerationForm.related_doi_resource_type')} + + + + + + + + + ))} + +
+
+ )} +
+
+ ); +}; + +export default RelatedDOIs; diff --git a/packages/datagateway-download/src/downloadApi.ts b/packages/datagateway-download/src/downloadApi.ts index 4c982a964..d6cdb9b69 100644 --- a/packages/datagateway-download/src/downloadApi.ts +++ b/packages/datagateway-download/src/downloadApi.ts @@ -1,10 +1,13 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import type { Datafile, + Dataset, Download, DownloadCart, DownloadCartItem, + Investigation, SubmitCart, + User, } from 'datagateway-common'; import { readSciGatewayToken } from 'datagateway-common'; import type { DownloadSettings } from './ConfigProvider'; @@ -389,3 +392,331 @@ export const getPercentageComplete = async ({ const isStatus = Number.isNaN(maybeNumber); return isStatus ? data : maybeNumber; }; + +/** + * Returns true if a user is able to mint a DOI for their cart, otherwise false + */ +export const isCartMintable = async ( + cart: DownloadCartItem[], + doiMinterUrl: string +): Promise => { + const investigations: number[] = []; + const datasets: number[] = []; + const datafiles: number[] = []; + cart.forEach((cartItem) => { + if (cartItem.entityType === 'investigation') + investigations.push(cartItem.entityId); + if (cartItem.entityType === 'dataset') datasets.push(cartItem.entityId); + if (cartItem.entityType === 'datafile') datafiles.push(cartItem.entityId); + }); + const { status } = await axios.post( + `${doiMinterUrl}/ismintable`, + { + ...(investigations.length > 0 + ? { investigation_ids: investigations } + : {}), + ...(datasets.length > 0 ? { dataset_ids: datasets } : {}), + ...(datafiles.length > 0 ? { datafile_ids: datafiles } : {}), + }, + { + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + } + ); + + return status === 200; +}; + +export enum ContributorType { + Creator = 'Creator', + ContactPerson = 'ContactPerson', + DataCollector = 'DataCollector', + DataCurator = 'DataCurator', + DataManager = 'DataManager', + Distributor = 'Distributor', + Editor = 'Editor', + HostingInstitution = 'HostingInstitution', + Producer = 'Producer', + ProjectLeader = 'ProjectLeader', + ProjectManager = 'ProjectManager', + ProjectMember = 'ProjectMember', + RegistrationAgency = 'RegistrationAgency', + RelatedPerson = 'RelatedPerson', + Researcher = 'Researcher', + ResearchGroup = 'ResearchGroup', + RightsHolder = 'RightsHolder', + Sponsor = 'Sponsor', + Supervisor = 'Supervisor', + WorkPackageLeader = 'WorkPackageLeader', + Other = 'Other', +} + +export enum DOIRelationType { + IsCitedBy = 'IsCitedBy', + Cites = 'Cites', + IsSupplementTo = 'IsSupplementTo', + IsSupplementedBy = 'IsSupplementedBy', + IsContinuedBy = 'IsContinuedBy', + Continues = 'Continues', + IsDescribedBy = 'IsDescribedBy', + Describes = 'Describes', + HasMetadata = 'HasMetadata', + IsMetadataFor = 'IsMetadataFor', + HasVersion = 'HasVersion', + IsVersionOf = 'IsVersionOf', + IsNewVersionOf = 'IsNewVersionOf', + IsPreviousVersionOf = 'IsPreviousVersionOf', + IsPartOf = 'IsPartOf', + HasPart = 'HasPart', + IsPublishedIn = 'IsPublishedIn', + IsReferencedBy = 'IsReferencedBy', + References = 'References', + IsDocumentedBy = 'IsDocumentedBy', + Documents = 'Documents', + IsCompiledBy = 'IsCompiledBy', + Compiles = 'Compiles', + IsVariantFormOf = 'IsVariantFormOf', + IsOriginalFormOf = 'IsOriginalFormOf', + IsIdenticalTo = 'IsIdenticalTo', + IsReviewedBy = 'IsReviewedBy', + Reviews = 'Reviews', + IsDerivedFrom = 'IsDerivedFrom', + IsSourceOf = 'IsSourceOf', + IsRequiredBy = 'IsRequiredBy', + Requires = 'Requires', + Obsoletes = 'Obsoletes', + IsObsoletedBy = 'IsObsoletedBy', +} + +export enum DOIResourceType { + Audiovisual = 'Audiovisual', + Book = 'Book', + BookChapter = 'BookChapter', + Collection = 'Collection', + ComputationalNotebook = 'ComputationalNotebook', + ConferencePaper = 'ConferencePaper', + ConferenceProceeding = 'ConferenceProceeding', + DataPaper = 'DataPaper', + Dataset = 'Dataset', + Dissertation = 'Dissertation', + Event = 'Event', + Image = 'Image', + InteractiveResource = 'InteractiveResource', + Journal = 'Journal', + JournalArticle = 'JournalArticle', + Model = 'Model', + OutputManagementPlan = 'OutputManagementPlan', + PeerReview = 'PeerReview', + PhysicalObject = 'PhysicalObject', + Preprint = 'Preprint', + Report = 'Report', + Service = 'Service', + Software = 'Software', + Sound = 'Sound', + Standard = 'Standard', + Text = 'Text', + Workflow = 'Workflow', + Other = 'Other', +} + +export interface DoiMetadata { + title: string; + description: string; + creators?: { username: string; contributor_type: ContributorType }[]; + related_items: RelatedDOI[]; +} + +export type RelatedDOI = { + title: string; + fullReference: string; + relatedIdentifier: string; + relatedIdentifierType: 'DOI'; + relationType: DOIRelationType | ''; + resourceType: DOIResourceType | ''; +}; + +export interface DoiResponse { + concept: DoiResult; + version: DoiResult; +} + +export interface DoiResult { + data_publication: string; + doi: string; +} + +/** + * Mint a DOI for a cart, returns a DataPublication ID & DOI + */ +export const mintCart = ( + cart: DownloadCartItem[], + doiMetadata: DoiMetadata, + settings: Pick +): Promise => { + const investigations: number[] = []; + const datasets: number[] = []; + const datafiles: number[] = []; + cart.forEach((cartItem) => { + if (cartItem.entityType === 'investigation') + investigations.push(cartItem.entityId); + if (cartItem.entityType === 'dataset') datasets.push(cartItem.entityId); + if (cartItem.entityType === 'datafile') datafiles.push(cartItem.entityId); + }); + return axios + .post( + `${settings.doiMinterUrl}/mint`, + { + metadata: { + ...doiMetadata, + resource_type: investigations.length === 0 ? 'Dataset' : 'Collection', + }, + ...(investigations.length > 0 + ? { investigation_ids: investigations } + : {}), + ...(datasets.length > 0 ? { dataset_ids: datasets } : {}), + ...(datafiles.length > 0 ? { datafile_ids: datafiles } : {}), + }, + { + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + } + ) + .then((response) => response.data); +}; + +const fetchEntityUsers = ( + apiUrl: string, + entityId: number, + entityType: 'investigation' | 'dataset' | 'datafile' +): Promise => { + const params = new URLSearchParams(); + params.append('where', JSON.stringify({ id: { eq: entityId } })); + + if (entityType === 'investigation') + params.append('include', JSON.stringify({ investigationUsers: 'user' })); + if (entityType === 'dataset') + params.append( + 'include', + JSON.stringify({ investigation: { investigationUsers: 'user' } }) + ); + if (entityType === 'datafile') + params.append( + 'include', + JSON.stringify({ + dataset: { investigation: { investigationUsers: 'user' } }, + }) + ); + + return axios + .get(`${apiUrl}/${entityType}s`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + .then((response) => { + const entity = response.data[0]; + if (entityType === 'investigation') { + return (entity as Investigation).investigationUsers?.map( + (iUser) => iUser.user + ) as User[]; + } + if (entityType === 'dataset') { + return (entity as Dataset).investigation?.investigationUsers?.map( + (iUser) => iUser.user + ) as User[]; + } + if (entityType === 'datafile') + return ( + entity as Datafile + ).dataset?.investigation?.investigationUsers?.map( + (iUser) => iUser.user + ) as User[]; + return []; + }); +}; + +/** + * Deduplicates items in an array + * @param array Array to make unique + * @param key Function to apply to an array item that returns a primitive that keys that item + * @returns a deduplicated array + */ +function uniqBy(array: T[], key: (item: T) => number | string): T[] { + const seen: Record = {}; + return array.filter(function (item) { + const k = key(item); + return seen.hasOwnProperty(k) ? false : (seen[k] = true); + }); +} + +/** + * Returns a list of users from ICAT which are InvestigationUsers for each item in the cart + */ +export const getCartUsers = async ( + cart: DownloadCartItem[], + settings: Pick +): Promise => { + let users: User[] = []; + for (const cartItem of cart) { + const entityUsers = await fetchEntityUsers( + settings.apiUrl, + cartItem.entityId, + cartItem.entityType + ); + users = users.concat(entityUsers); + } + + users = uniqBy(users, (item) => item.id); + + return users; +}; + +/** + * Sends an username to the API and it checks if it's a valid ICAT User, on success + * it returns the User, on failure it returns 404 + */ +export const checkUser = ( + username: string, + settings: Pick +): Promise => { + return axios + .get(`${settings.doiMinterUrl}/user/${username}`, { + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + .then((response) => { + return response.data; + }); +}; + +interface DataCiteResponse { + data: DataCiteDOI; +} + +export interface DataCiteDOI { + id: string; + type: string; + attributes: { + doi: string; + titles: { title: string }[]; + url: string; + }; +} +/** + * Retrieve metadata for a DOI + * @param doi The DOI to fetch metadata for + */ +export const fetchDOI = ( + doi: string, + settings: Pick +): Promise => { + return axios + .get(`${settings.dataCiteUrl}/dois/${doi}`) + .then((response) => { + return response.data.data; + }); +}; diff --git a/packages/datagateway-download/src/downloadApiHooks.test.tsx b/packages/datagateway-download/src/downloadApiHooks.test.tsx index 404ed3ce0..80802955b 100644 --- a/packages/datagateway-download/src/downloadApiHooks.test.tsx +++ b/packages/datagateway-download/src/downloadApiHooks.test.tsx @@ -3,8 +3,8 @@ import { renderHook, WrapperComponent, } from '@testing-library/react-hooks'; -import axios from 'axios'; -import type { Download } from 'datagateway-common'; +import axios, { AxiosError } from 'axios'; +import { Download, InvalidateTokenType } from 'datagateway-common'; import { DownloadCartItem, handleICATError, @@ -20,18 +20,24 @@ import { useAdminDownloads, useAdminUpdateDownloadStatus, useCart, + useCartUsers, + useCheckUser, useDatafileCounts, useDownloadOrRestoreDownload, useDownloadPercentageComplete, useDownloads, useDownloadTypeStatuses, + useIsCartMintable, useIsTwoLevel, + useMintCart, useRemoveAllFromCart, useRemoveEntityFromCart, useSizes, useSubmitCart, } from './downloadApiHooks'; import { mockCartItems, mockDownloadItems, mockedSettings } from './testData'; +import log from 'loglevel'; +import { ContributorType } from './downloadApi'; jest.mock('datagateway-common', () => { const originalModule = jest.requireActual('datagateway-common'); @@ -56,16 +62,20 @@ const createTestQueryClient = (): QueryClient => defaultOptions: { queries: { retry: false, + // set retryDelay = 0 to make retries quick for custom retry functions + retryDelay: 0, }, }, }); -const createReactQueryWrapper = (): WrapperComponent => { +const createReactQueryWrapper = ( + settings = mockedSettings +): WrapperComponent => { const testQueryClient = createTestQueryClient(); const history = createMemoryHistory(); const wrapper: WrapperComponent = ({ children }) => ( - + {children} @@ -76,9 +86,29 @@ const createReactQueryWrapper = (): WrapperComponent => { return wrapper; }; -describe('Download Cart API react-query hooks test', () => { +describe('Download API react-query hooks test', () => { + const localStorageGetItemMock = jest.spyOn( + window.localStorage.__proto__, + 'getItem' + ); + let events: CustomEvent<{ + detail: { type: string; payload?: unknown }; + }>[] = []; + + beforeEach(() => { + events = []; + + document.dispatchEvent = (e: Event) => { + events.push( + e as CustomEvent<{ detail: { type: string; payload?: unknown } }> + ); + return true; + }; + }); + afterEach(() => { - (handleICATError as jest.Mock).mockClear(); + jest.clearAllMocks(); + localStorageGetItemMock.mockReset(); }); describe('useCart', () => { @@ -181,9 +211,11 @@ describe('Download Cart API react-query hooks test', () => { .fn() .mockImplementationOnce(() => Promise.reject({ - code: '431', + response: { + status: 431, + }, message: 'Test 431 error message', - }) + } as AxiosError) ) .mockImplementation(() => Promise.reject({ @@ -247,9 +279,11 @@ describe('Download Cart API react-query hooks test', () => { .fn() .mockImplementationOnce(() => Promise.reject({ - code: '431', + response: { + status: 431, + }, message: 'Test 431 error message', - }) + } as AxiosError) ) .mockImplementation(() => Promise.reject({ @@ -1338,4 +1372,622 @@ describe('Download Cart API react-query hooks test', () => { ); }); }); + + describe('useIsCartMintable', () => { + it('should check whether a cart is mintable', async () => { + axios.post = jest + .fn() + .mockResolvedValue({ data: undefined, status: 200 }); + + const { result, waitFor } = renderHook( + () => useIsCartMintable(mockCartItems), + { + wrapper: createReactQueryWrapper(), + } + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/ismintable`, + { + investigation_ids: [1, 2], + dataset_ids: [3], + datafile_ids: [4], + }, + { headers: { Authorization: 'Bearer null' } } + ); + }); + + it('should be disabled if doiMinterUrl is not defined', async () => { + const { result } = renderHook(() => useIsCartMintable(mockCartItems), { + wrapper: createReactQueryWrapper({ + ...mockedSettings, + doiMinterUrl: undefined, + }), + }); + + expect(result.current.isIdle).toEqual(true); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('should return false if cart is undefined', async () => { + const { result, waitFor } = renderHook( + () => useIsCartMintable(undefined), + { + wrapper: createReactQueryWrapper(), + } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(false); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('should return false if cart is empty', async () => { + const { result, waitFor } = renderHook(() => useIsCartMintable([]), { + wrapper: createReactQueryWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(false); + expect(axios.post).not.toHaveBeenCalled(); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being true', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'true' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.post = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook( + () => useIsCartMintable([mockCartItems[0]]), + { + wrapper: createReactQueryWrapper(), + } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.post).toHaveBeenCalledTimes(4); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/ismintable`, + { + investigation_ids: [1], + }, + { headers: { Authorization: 'Bearer null' } } + ); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please reload the page', + }, + }); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being false', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'false' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.post = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook( + () => useIsCartMintable([mockCartItems[3]]), + { + wrapper: createReactQueryWrapper(), + } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.post).toHaveBeenCalledTimes(4); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/ismintable`, + { + datafile_ids: [4], + }, + { headers: { Authorization: 'Bearer null' } } + ); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please login again', + }, + }); + }); + + it('should not log 403 errors or retry them', async () => { + const error = { + message: 'Test error message', + response: { + status: 403, + }, + }; + axios.post = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook( + () => useIsCartMintable(mockCartItems), + { + wrapper: createReactQueryWrapper(), + } + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).not.toHaveBeenCalled(); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + }); + + describe('useMintCart', () => { + const doiMetadata = { + title: 'Test title', + description: 'Test description', + creators: [{ username: '1', contributor_type: ContributorType.Creator }], + related_items: [], + }; + it('should send a request to mint a cart', async () => { + axios.post = jest.fn().mockResolvedValue({ + data: { + concept: { doi: 'test doi', data_publication: '1' }, + version: { doi: 'test doi v1', data_publication: '11' }, + }, + status: 200, + }); + + const { result } = renderHook(() => useMintCart(), { + wrapper: createReactQueryWrapper(), + }); + + await act(async () => { + await result.current.mutateAsync({ cart: mockCartItems, doiMetadata }); + }); + + expect(result.current.data).toEqual({ + concept: { doi: 'test doi', data_publication: '1' }, + version: { doi: 'test doi v1', data_publication: '11' }, + }); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/mint`, + { + metadata: { + ...doiMetadata, + resource_type: 'Collection', + }, + investigation_ids: [1, 2], + dataset_ids: [3], + datafile_ids: [4], + }, + { headers: { Authorization: 'Bearer null' } } + ); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being true', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'true' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.post = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useMintCart(), { + wrapper: createReactQueryWrapper(), + }); + + act(() => { + result.current.mutate({ cart: [mockCartItems[0]], doiMetadata }); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/mint`, + { + metadata: { + ...doiMetadata, + resource_type: 'Collection', + }, + investigation_ids: [1], + }, + { headers: { Authorization: 'Bearer null' } } + ); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please reload the page', + }, + }); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being false', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'false' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.post = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useMintCart(), { + wrapper: createReactQueryWrapper(), + }); + + act(() => { + result.current.mutate({ cart: [mockCartItems[3]], doiMetadata }); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.post).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/mint`, + { + metadata: { + ...doiMetadata, + resource_type: 'Dataset', + }, + datafile_ids: [4], + }, + { headers: { Authorization: 'Bearer null' } } + ); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please login again', + }, + }); + }); + }); + + describe('useCartUsers', () => { + it('should get a list of users associated with each cart item', async () => { + axios.get = jest.fn().mockImplementation((url) => { + if (url.includes('investigations')) { + return Promise.resolve({ + data: [ + { + investigationUsers: [ + { user: { id: 1, name: 'user 1' } }, + { user: { id: 2, name: 'user 2' } }, + ], + }, + ], + }); + } + if (url.includes('datasets')) { + return Promise.resolve({ + data: [ + { + investigation: { + investigationUsers: [ + { user: { id: 2, name: 'user 2' } }, + { user: { id: 3, name: 'user 3' } }, + ], + }, + }, + ], + }); + } + if (url.includes('datafiles')) { + return Promise.resolve({ + data: [ + { + dataset: { + investigation: { + investigationUsers: [ + { user: { id: 3, name: 'user 3' } }, + { user: { id: 4, name: 'user 4' } }, + ], + }, + }, + }, + ], + }); + } else { + return Promise.resolve({ data: [] }); + } + }); + + const { result, waitFor } = renderHook( + () => useCartUsers(mockCartItems), + { + wrapper: createReactQueryWrapper(), + } + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // data should be deduped + expect(result.current.data).toEqual([ + { id: 1, name: 'user 1' }, + { id: 2, name: 'user 2' }, + { id: 3, name: 'user 3' }, + { id: 4, name: 'user 4' }, + ]); + // needs to get called once for each item in the cart + expect(axios.get).toHaveBeenCalledTimes(mockCartItems.length); + + const inv1Params = new URLSearchParams(); + inv1Params.append( + 'where', + JSON.stringify({ + id: { eq: 1 }, + }) + ); + inv1Params.append( + 'include', + JSON.stringify({ + investigationUsers: 'user', + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + `${mockedSettings.apiUrl}/investigations`, + expect.objectContaining({ + params: inv1Params, + }) + ); + + const inv2Params = new URLSearchParams(); + inv2Params.append( + 'where', + JSON.stringify({ + id: { eq: 2 }, + }) + ); + inv2Params.append( + 'include', + JSON.stringify({ + investigationUsers: 'user', + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + `${mockedSettings.apiUrl}/investigations`, + expect.objectContaining({ + params: inv2Params, + }) + ); + + const dsParams = new URLSearchParams(); + dsParams.append( + 'where', + JSON.stringify({ + id: { eq: 3 }, + }) + ); + dsParams.append( + 'include', + JSON.stringify({ + investigation: { investigationUsers: 'user' }, + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + `${mockedSettings.apiUrl}/datasets`, + expect.objectContaining({ + params: dsParams, + }) + ); + + const dfParams = new URLSearchParams(); + dfParams.append( + 'where', + JSON.stringify({ + id: { eq: 4 }, + }) + ); + dfParams.append( + 'include', + JSON.stringify({ + datasets: { investigation: { investigationUsers: 'user' } }, + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + `${mockedSettings.apiUrl}/datafiles`, + expect.objectContaining({ + params: dfParams, + }) + ); + }); + + it('should not query for users if cart is undefined', async () => { + const { result, waitFor } = renderHook(() => useCartUsers(undefined), { + wrapper: createReactQueryWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual([]); + expect(axios.get).not.toHaveBeenCalled(); + }); + }); + + describe('useCheckUser', () => { + it('should check whether a user exists in ICAT', async () => { + axios.get = jest + .fn() + .mockResolvedValue({ data: { id: 1, name: 'user 1' } }); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ id: 1, name: 'user 1' }); + expect(axios.get).toHaveBeenCalledWith( + `${mockedSettings.doiMinterUrl}/user/${'user 1'}`, + { headers: { Authorization: 'Bearer null' } } + ); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being true', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'true' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.get = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.get).toHaveBeenCalledTimes(1); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please reload the page', + }, + }); + }); + + it('should handle 401 by broadcasting an invalidate token message with autologin being false', async () => { + localStorageGetItemMock.mockImplementation((name) => { + return name === 'autoLogin' ? 'false' : null; + }); + + const error = { + message: 'Test error message', + response: { + status: 401, + }, + }; + axios.get = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.get).toHaveBeenCalledTimes(1); + expect(events.length).toBe(1); + expect(events[0].detail).toEqual({ + type: InvalidateTokenType, + payload: { + severity: 'error', + message: 'Your session has expired, please login again', + }, + }); + }); + + it('should not retry 404 errors', async () => { + const error = { + message: 'Test error message', + response: { + status: 404, + }, + }; + axios.get = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('should not retry 422 errors', async () => { + const error = { + message: 'Test error message', + response: { + status: 422, + }, + }; + axios.get = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('should retry other errors', async () => { + const error = { + message: 'Test error message', + response: { + status: 400, + }, + }; + axios.get = jest.fn().mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useCheckUser('user 1'), { + wrapper: createReactQueryWrapper(), + }); + expect(result.current.isIdle).toBe(true); + act(() => { + result.current.refetch(); + }); + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(log.error).toHaveBeenCalledWith(error); + expect(axios.get).toHaveBeenCalledTimes(4); + }); + }); }); diff --git a/packages/datagateway-download/src/downloadApiHooks.ts b/packages/datagateway-download/src/downloadApiHooks.ts index cd42e405c..75014d1a0 100644 --- a/packages/datagateway-download/src/downloadApiHooks.ts +++ b/packages/datagateway-download/src/downloadApiHooks.ts @@ -1,5 +1,10 @@ import { AxiosError } from 'axios'; -import type { Download, DownloadStatus } from 'datagateway-common'; +import { + Download, + DownloadStatus, + InvalidateTokenType, + User, +} from 'datagateway-common'; import { DownloadCartItem, fetchDownloadCart, @@ -8,6 +13,7 @@ import { NotificationType, retryICATErrors, } from 'datagateway-common'; +import log from 'loglevel'; import pLimit from 'p-limit'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -25,9 +31,17 @@ import { UseQueryResult, } from 'react-query'; import { DownloadSettingsContext } from './ConfigProvider'; -import type { +import { + checkUser, + DoiMetadata, + DoiResponse, DownloadProgress, DownloadTypeStatus, + fetchDOI, + getCartUsers, + isCartMintable, + mintCart, + RelatedDOI, SubmitCartZipType, } from './downloadApi'; import { @@ -125,7 +139,7 @@ export const useRemoveAllFromCart = (): UseMutationResult< }, retry: (failureCount, error) => { // if we get 431 we know this is an intermittent error so retry - if (error.code === '431' && failureCount < 3) { + if (error.response?.status === 431 && failureCount < 3) { return true; } else { return false; @@ -159,7 +173,7 @@ export const useRemoveEntityFromCart = (): UseMutationResult< }, retry: (failureCount, error) => { // if we get 431 we know this is an intermittent error so retry - if (error.code === '431' && failureCount < 3) { + if (error.response?.status === 431 && failureCount < 3) { return true; } else { return false; @@ -473,7 +487,7 @@ export const useDownloadOrRestoreDownload = (): UseMutationResult< retry: (failureCount, error) => { // if we get 431 we know this is an intermittent error so retry - return error.code === '431' && failureCount < 3; + return error.response?.status === 431 && failureCount < 3; }, } ); @@ -570,7 +584,7 @@ export const useAdminDownloads = ({ const downloadSettings = React.useContext(DownloadSettingsContext); return useInfiniteQuery( - QueryKey.ADMIN_DOWNLOADS, + [QueryKey.ADMIN_DOWNLOADS, initialQueryOffset], ({ pageParam = initialQueryOffset }) => fetchAdminDownloads( { @@ -774,3 +788,206 @@ export const useDownloadPercentageComplete = ({ } ); }; + +/** + * Queries whether a cart is mintable. + * @param cart The {@link Cart} that is checked + */ +export const useIsCartMintable = ( + cart: DownloadCartItem[] | undefined +): UseQueryResult< + boolean, + AxiosError<{ detail: { msg: string }[] } | { detail: string }> +> => { + const settings = React.useContext(DownloadSettingsContext); + const { doiMinterUrl } = settings; + + return useQuery( + ['ismintable', cart], + () => { + if (doiMinterUrl && cart && cart.length > 0) + return isCartMintable(cart, doiMinterUrl); + else return Promise.resolve(false); + }, + { + onError: (error) => { + if (error.response?.status !== 403) log.error(error); + if (error.response?.status === 401) { + document.dispatchEvent( + new CustomEvent(MicroFrontendId, { + detail: { + type: InvalidateTokenType, + payload: { + severity: 'error', + message: + localStorage.getItem('autoLogin') === 'true' + ? 'Your session has expired, please reload the page' + : 'Your session has expired, please login again', + }, + }, + }) + ); + } + }, + retry: (failureCount, error) => { + // if we get 403 we know this is an legit response from the backend so don't bother retrying + // all other errors use default retry behaviour + if (error.response?.status === 403 || failureCount >= 3) { + return false; + } else { + return true; + } + }, + refetchOnWindowFocus: false, + enabled: typeof doiMinterUrl !== 'undefined', + } + ); +}; + +/** + * Mints a cart + * @param cart The {@link Cart} to mint + * @param doiMetadata The required metadata for the DOI + */ +export const useMintCart = (): UseMutationResult< + DoiResponse, + AxiosError<{ + detail: { msg: string }[] | string; + }>, + { cart: DownloadCartItem[]; doiMetadata: DoiMetadata } +> => { + const settings = React.useContext(DownloadSettingsContext); + + return useMutation( + ({ cart, doiMetadata }) => { + return mintCart(cart, doiMetadata, settings); + }, + { + onError: (error) => { + log.error(error); + if (error.response?.status === 401) { + document.dispatchEvent( + new CustomEvent(MicroFrontendId, { + detail: { + type: InvalidateTokenType, + payload: { + severity: 'error', + message: + localStorage.getItem('autoLogin') === 'true' + ? 'Your session has expired, please reload the page' + : 'Your session has expired, please login again', + }, + }, + }) + ); + } + }, + } + ); +}; + +/** + * Gets the total list of users associated with each item in the cart + * @param cart The {@link Cart} that we're getting the users for + */ +export const useCartUsers = ( + cart?: DownloadCartItem[] +): UseQueryResult => { + const settings = React.useContext(DownloadSettingsContext); + + return useQuery( + ['cartUsers', cart], + () => getCartUsers(cart ?? [], settings), + { + onError: handleICATError, + staleTime: Infinity, + } + ); +}; + +/** + * Checks whether a username belongs to an ICAT User + * @param username The username that we're checking + * @returns the {@link User} that matches the username, or 404 + */ +export const useCheckUser = ( + username: string +): UseQueryResult => { + const settings = React.useContext(DownloadSettingsContext); + + return useQuery( + ['checkUser', username], + () => checkUser(username, settings), + { + onError: (error) => { + log.error(error); + if (error.response?.status === 401) { + document.dispatchEvent( + new CustomEvent(MicroFrontendId, { + detail: { + type: InvalidateTokenType, + payload: { + severity: 'error', + message: + localStorage.getItem('autoLogin') === 'true' + ? 'Your session has expired, please reload the page' + : 'Your session has expired, please login again', + }, + }, + }) + ); + } + }, + retry: (failureCount: number, error: AxiosError) => { + if ( + // user not logged in, error code will log them out + error.response?.status === 401 || + // email doesn't match user - don't retry as this is a correct response from the server + error.response?.status === 404 || + // email is invalid - don't retry as this is correct response from the server + error.response?.status === 422 || + failureCount >= 3 + ) + return false; + return true; + }, + // set enabled false to only fetch on demand when the add creator button is pressed + enabled: false, + cacheTime: 0, + } + ); +}; + +/** + * Checks whether a DOI is valid and returns the DOI metadata + * @param doi The DOI that we're checking + * @returns the {@link RelatedDOI} that matches the username, or 404 + */ +export const useCheckDOI = ( + doi: string +): UseQueryResult => { + const settings = React.useContext(DownloadSettingsContext); + + return useQuery(['checkDOI', doi], () => fetchDOI(doi, settings), { + retry: (failureCount: number, error: AxiosError) => { + if ( + // DOI is invalid - don't retry as this is a correct response from the server + error.response?.status === 404 || + failureCount >= 3 + ) + return false; + return true; + }, + select: (doi) => ({ + title: doi.attributes.titles[0].title, + relatedIdentifier: doi.attributes.doi, + relatedIdentifierType: 'DOI', + fullReference: '', // TODO: what should we put here? + relationType: '', + resourceType: '', + }), + // set enabled false to only fetch on demand when the add creator button is pressed + enabled: false, + cacheTime: 0, + }); +}; diff --git a/packages/datagateway-download/src/downloadCart/downloadCartTable.component.test.tsx b/packages/datagateway-download/src/downloadCart/downloadCartTable.component.test.tsx index 84a46db79..44fb649b8 100644 --- a/packages/datagateway-download/src/downloadCart/downloadCartTable.component.test.tsx +++ b/packages/datagateway-download/src/downloadCart/downloadCartTable.component.test.tsx @@ -4,23 +4,31 @@ import { RenderResult, screen, waitFor, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { UserEvent } from '@testing-library/user-event/dist/types/setup'; import { fetchDownloadCart } from 'datagateway-common'; -import { createMemoryHistory } from 'history'; +import { createMemoryHistory, MemoryHistory } from 'history'; import * as React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; +import { QueryClient, QueryClientProvider, setLogger } from 'react-query'; import { Router } from 'react-router-dom'; import { DownloadSettingsContext } from '../ConfigProvider'; import { mockCartItems, mockedSettings } from '../testData'; import { getDatafileCount, getSize, + isCartMintable, removeAllDownloadCartItems, removeFromCart, } from '../downloadApi'; import DownloadCartTable from './downloadCartTable.component'; +import { createTheme } from '@mui/material'; + +setLogger({ + log: console.log, + warn: console.warn, + error: jest.fn(), +}); jest.mock('datagateway-common', () => { const originalModule = jest.requireActual('datagateway-common'); @@ -42,6 +50,7 @@ jest.mock('../downloadApi', () => { getDatafileCount: jest.fn(), getIsTwoLevel: jest.fn().mockResolvedValue(true), removeFromCart: jest.fn(), + isCartMintable: jest.fn(), }; }); @@ -54,20 +63,25 @@ const createTestQueryClient = (): QueryClient => }, }); -const renderComponent = (): RenderResult => - render( - - - - - - - - ); +const renderComponent = (): RenderResult & { history: MemoryHistory } => { + const history = createMemoryHistory(); + return { + history: history, + ...render( + + + + + + + + ), + }; +}; describe('Download cart table component', () => { let holder, queryClient; - let user: UserEvent; + let user: ReturnType; const resetDOM = (): void => { if (holder) document.body.removeChild(holder); @@ -104,6 +118,9 @@ describe('Download cart table component', () => { ( getDatafileCount as jest.MockedFunction ).mockResolvedValue(7); + ( + isCartMintable as jest.MockedFunction + ).mockResolvedValue(true); }); afterEach(() => { @@ -445,4 +462,91 @@ describe('Download cart table component', () => { }) ).toBeTruthy(); }); + + it('should go to DOI generation form when Generate DOI button is clicked', async () => { + const { history } = renderComponent(); + + await user.click( + screen.getByRole('link', { name: 'downloadCart.generate_DOI' }) + ); + + expect(history.location).toMatchObject({ + pathname: '/download/mint', + state: { fromCart: true }, + }); + }); + + it('should disable Generate DOI button when mintability is loading', async () => { + ( + isCartMintable as jest.MockedFunction + ).mockImplementation( + () => + new Promise((_) => { + // do nothing, simulating pending promise to test loading state + }) + ); + const { history } = renderComponent(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const generateDOIButton = screen + .getByRole('link', { name: 'downloadCart.generate_DOI' }) + .closest('span')!; + + await user.hover(generateDOIButton); + + expect( + await screen.findByText('downloadCart.mintability_loading') + ).toBeInTheDocument(); + + await user.click(generateDOIButton); + + expect(history.location).not.toMatchObject({ + pathname: '/download/mint', + state: { fromCart: true }, + }); + }); + + it('should disable Generate DOI button when cart is not mintable', async () => { + ( + isCartMintable as jest.MockedFunction + ).mockRejectedValue({ + response: { + data: { detail: 'Not allowed to mint the following items: [2,4]' }, + status: 403, + }, + }); + const { history } = renderComponent(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const generateDOIButton = screen + .getByRole('link', { name: 'downloadCart.generate_DOI' }) + .closest('span')!; + + await user.hover(generateDOIButton); + + expect( + await screen.findByText('downloadCart.not_mintable') + ).toBeInTheDocument(); + + const tableRows = within( + screen.getByRole('rowgroup', { name: 'grid' }) + ).getAllByRole('row'); + const muiErrorColor = createTheme().palette.error.main; + expect(tableRows[1]).toHaveStyle(`background-color: ${muiErrorColor}`); + expect(tableRows[1]).toHaveStyle(`background-color: ${muiErrorColor}`); + expect(tableRows[0]).not.toHaveStyle(`background-color: ${muiErrorColor}`); + expect(tableRows[2]).not.toHaveStyle(`background-color: ${muiErrorColor}`); + + await user.click(generateDOIButton); + + expect(history.location).not.toMatchObject({ + pathname: '/download/mint', + state: { fromCart: true }, + }); + + await user.unhover(generateDOIButton); + for (const row of tableRows) { + expect(row).not.toHaveStyle(`background-color: ${muiErrorColor}`); + } + }); }); diff --git a/packages/datagateway-download/src/downloadCart/downloadCartTable.component.tsx b/packages/datagateway-download/src/downloadCart/downloadCartTable.component.tsx index a3b2983ab..0a9596031 100644 --- a/packages/datagateway-download/src/downloadCart/downloadCartTable.component.tsx +++ b/packages/datagateway-download/src/downloadCart/downloadCartTable.component.tsx @@ -9,6 +9,7 @@ import { Link, Paper, Theme, + Tooltip, Typography, } from '@mui/material'; import { @@ -29,6 +30,7 @@ import { DownloadSettingsContext } from '../ConfigProvider'; import { useCart, useDatafileCounts, + useIsCartMintable, useIsTwoLevel, useRemoveAllFromCart, useRemoveEntityFromCart, @@ -50,9 +52,14 @@ interface DownloadCartTableProps { const DownloadCartTable: React.FC = ( props: DownloadCartTableProps ) => { - const { fileCountMax, totalSizeMax, apiUrl, facilityName } = React.useContext( - DownloadSettingsContext - ); + const { + fileCountMax, + totalSizeMax, + apiUrl, + facilityName, + doiMinterUrl, + dataCiteUrl, + } = React.useContext(DownloadSettingsContext); const [sort, setSort] = React.useState<{ [column: string]: Order }>({}); const [filters, setFilters] = React.useState<{ @@ -66,6 +73,11 @@ const DownloadCartTable: React.FC = ( const { mutate: removeAllDownloadCartItems, isLoading: removingAll } = useRemoveAllFromCart(); const { data: cartItems, isFetching: isFetchingCart } = useCart(); + const { + data: mintable, + isLoading: cartMintabilityLoading, + error: mintableError, + } = useIsCartMintable(cartItems); const fileCountQueries = useDatafileCounts(cartItems); const sizeQueries = useSizes(cartItems); @@ -168,6 +180,32 @@ const DownloadCartTable: React.FC = ( return filteredData?.sort(sortCartItems); }, [cartItems, sort, filters, sizeQueries, fileCountQueries]); + const unmintableEntityIDs: number[] | null | undefined = React.useMemo( + () => + mintableError?.response?.status === 403 && + typeof mintableError?.response?.data?.detail === 'string' && + JSON.parse( + mintableError.response.data.detail.substring( + mintableError.response.data.detail.indexOf('['), + mintableError.response.data.detail.lastIndexOf(']') + 1 + ) + ), + [mintableError] + ); + + const unmintableRowIDs = React.useMemo(() => { + if (unmintableEntityIDs && sortedAndFilteredData) { + return unmintableEntityIDs.map((id) => + sortedAndFilteredData.findIndex((entity) => entity.entityId === id) + ); + } else { + return []; + } + }, [unmintableEntityIDs, sortedAndFilteredData]); + + const [generateDOIButtonHover, setGenerateDOIButtonHover] = + React.useState(false); + const columns: ColumnType[] = React.useMemo( () => [ { @@ -384,6 +422,20 @@ const DownloadCartTable: React.FC = ( }${isLoading ? ' - 4px' : ''} - (1.75 * 0.875rem + 12px))`, minHeight: 230, overflowX: 'auto', + // handle the highlight of unmintable entities + ...(generateDOIButtonHover && { + '& [role="rowgroup"] [role="row"]': Object.assign( + {}, + ...unmintableRowIDs.map((id) => ({ + [`&:nth-of-type(${id + 1})`]: { + bgcolor: 'error.main', + '& [role="gridcell"] *': { + color: 'error.contrastText', + }, + }, + })) + ), + }), }} > = ( + {doiMinterUrl && dataCiteUrl && ( + + setGenerateDOIButtonHover(true)} + onMouseLeave={() => setGenerateDOIButtonHover(false)} + > + {/* need this span so the tooltip works when the button is disabled */} + + + + + + )} - - - - - -
-
-
-
-
- - - - -
-
-
-
-
-
-
-
-
-
-
-
- -

- downloadCart.name -

- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -

- downloadCart.type -

- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -

- downloadCart.size -

- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -

- downloadCart.fileCount -

- -
-
-
-
-
-
-
-
-
-
-
- Actions -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - downloadCart.number_of_files: 0 / 5000 - -
-
-
-
-
- - downloadCart.total_size: 0 B / 1 TB - -
-
-
-
-
- -
-
- -
-
-
-
-
-
-
- - -`; diff --git a/packages/datagateway-download/src/downloadTab/downloadTab.component.test.tsx b/packages/datagateway-download/src/downloadTab/downloadTab.component.test.tsx index 3948c60ff..569911e1e 100644 --- a/packages/datagateway-download/src/downloadTab/downloadTab.component.test.tsx +++ b/packages/datagateway-download/src/downloadTab/downloadTab.component.test.tsx @@ -1,7 +1,6 @@ -import type { RenderResult } from '@testing-library/react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup'; import { fetchDownloadCart } from 'datagateway-common'; import { createMemoryHistory } from 'history'; import * as React from 'react'; @@ -16,6 +15,7 @@ import { getSize, removeAllDownloadCartItems, removeFromCart, + isCartMintable, } from '../downloadApi'; import { mockCartItems, mockDownloadItems, mockedSettings } from '../testData'; import DownloadTabs from './downloadTab.component'; @@ -33,7 +33,7 @@ jest.mock('../downloadApi'); describe('DownloadTab', () => { let history; let holder; - let user: UserEvent; + let user: ReturnType; beforeEach(() => { history = createMemoryHistory(); @@ -55,7 +55,7 @@ describe('DownloadTab', () => { removeAllDownloadCartItems as jest.MockedFunction< typeof removeAllDownloadCartItems > - ).mockResolvedValue(null); + ).mockResolvedValue(); ( removeFromCart as jest.MockedFunction ).mockImplementation((entityType, entityIds) => { @@ -68,6 +68,9 @@ describe('DownloadTab', () => { ( getDatafileCount as jest.MockedFunction ).mockResolvedValue(7); + ( + isCartMintable as jest.MockedFunction + ).mockResolvedValue(true); }); const renderComponent = (): RenderResult => { @@ -83,11 +86,6 @@ describe('DownloadTab', () => { ); }; - it('should render correctly', () => { - const { asFragment } = renderComponent(); - expect(asFragment()).toMatchSnapshot(); - }); - it('shows the appropriate table when clicking between tabs', async () => { renderComponent(); @@ -95,34 +93,54 @@ describe('DownloadTab', () => { await user.click(await screen.findByText('downloadTab.downloads_tab')); - await waitFor(async () => { - expect( - await screen.findByLabelText( - 'downloadTab.download_cart_panel_arialabel' - ) - ).not.toBeVisible(); - expect( - await screen.findByLabelText( - 'downloadTab.download_status_panel_arialabel' - ) - ).toBeVisible(); - }); + expect( + await screen.findByLabelText('downloadTab.download_cart_panel_arialabel') + ).not.toBeVisible(); + expect( + await screen.findByLabelText( + 'downloadTab.download_status_panel_arialabel' + ) + ).toBeVisible(); // Return back to the cart tab. await user.click(await screen.findByText('downloadTab.cart_tab')); - await waitFor(async () => { - expect( - await screen.findByLabelText( - 'downloadTab.download_cart_panel_arialabel' - ) - ).toBeVisible(); - expect( - await screen.findByLabelText( - 'downloadTab.download_status_panel_arialabel' - ) - ).not.toBeVisible(); - }); + expect( + await screen.findByLabelText('downloadTab.download_cart_panel_arialabel') + ).toBeVisible(); + expect( + await screen.findByLabelText( + 'downloadTab.download_status_panel_arialabel' + ) + ).not.toBeVisible(); + }); + + it('refreshes downloads when the refresh button is clicked', async () => { + renderComponent(); + + ( + fetchDownloads as jest.MockedFunction + ).mockImplementation( + () => + new Promise((_) => { + // do nothing, simulating pending promise + // to test refreshing state + }) + ); + + // go to downloads tab + + await user.click(await screen.findByText('downloadTab.downloads_tab')); + + await user.click( + screen.getByRole('button', { + name: 'downloadTab.refresh_download_status_arialabel', + }) + ); + + expect( + await screen.findByText('downloadTab.refreshing_downloads') + ).toBeInTheDocument(); }); }); diff --git a/packages/datagateway-download/src/testData.ts b/packages/datagateway-download/src/testData.ts index 07ec9e47f..512e59876 100644 --- a/packages/datagateway-download/src/testData.ts +++ b/packages/datagateway-download/src/testData.ts @@ -335,6 +335,8 @@ export const mockedSettings: Partial = { apiUrl: 'https://example.com/api', downloadApiUrl: 'https://example.com/downloadApi', idsUrl: 'https://example.com/ids', + doiMinterUrl: 'https://example.com/doiMinter', + dataCiteUrl: 'https://example.com/dataCite', fileCountMax: 5000, totalSizeMax: 1000000000000, accessMethods: {