diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 31bb58971..be5436cd5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,8 @@ updates: open-pull-requests-limit: 10 versioning-strategy: increase ignore: + - dependency-name: "*" + update-types: ["version-update:semver-patch"] - dependency-name: "@date-io/date-fns" versions: - ">= 2.a, < 3" diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 598e6c804..fc23dc4b3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,7 +10,7 @@ on: jobs: lint-and-unit-test: name: Lint & Unit Tests - runs-on: ubuntu-16.04 + runs-on: ubuntu-20.04 steps: - name: Checkout repo uses: actions/checkout@v2 @@ -76,7 +76,7 @@ jobs: dataview-e2e-tests: name: DataGateway DataView End to End Tests - runs-on: ubuntu-16.04 + runs-on: ubuntu-20.04 steps: - name: Checkout repo uses: actions/checkout@v2 @@ -90,7 +90,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.x + python-version: 3.6 architecture: x64 # ICAT Ansible clone and install dependencies @@ -105,7 +105,7 @@ jobs: # Prep for running the playbook - name: Create hosts file - run: echo -e "[icat-test-hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts + run: echo -e "[icat_test_hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts - name: Prepare vault pass run: echo -e "icattravispw" > icat-ansible/vault_pass.txt - name: Move vault to directory it'll get detected by Ansible @@ -115,16 +115,23 @@ jobs: sed -i -e "s/^payara_user: \"glassfish\"/payara_user: \"runner\"/" icat-ansible/group_vars/all/vars.yml - name: Amending roles run: | - sed -i 's/role: authn-uows-isis/role: authn-anon/' icat-ansible/icat-test-hosts.yml + sed -i 's/role: authn_uows_isis/role: authn_anon/' icat-ansible/icat_test_hosts.yml # Force hostname to localhost - bug fix for previous ICAT Ansible issues on Actions - name: Change hostname to localhost run: sudo hostname -b localhost + # Remove existing MySQL installation so it doesn't interfere with GitHub Actions + - name: Remove existing mysql + run: | + sudo apparmor_parser -R /etc/apparmor.d/usr.sbin.mysqld + sudo apt-get remove --purge "mysql*" + sudo rm -rf /var/lib/mysql* /etc/mysql + # Create local instance of ICAT - name: Run ICAT Ansible Playbook run: | - ansible-playbook icat-ansible/icat-test-hosts.yml -i icat-ansible/hosts --vault-password-file icat-ansible/vault_pass.txt -vv + 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 @@ -142,6 +149,7 @@ jobs: with: repository: ral-facilities/datagateway-api path: datagateway-api + ref: v1.0.1 # DataGateway API file setup - name: Create log file @@ -209,7 +217,7 @@ jobs: download-e2e-tests: name: DataGateway Download End to End Tests - runs-on: ubuntu-16.04 + runs-on: ubuntu-20.04 steps: - name: Checkout repo uses: actions/checkout@v2 @@ -223,7 +231,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.x + python-version: 3.6 architecture: x64 # ICAT Ansible clone and install dependencies @@ -238,7 +246,7 @@ jobs: # Prep for running the playbook - name: Create hosts file - run: echo -e "[icat-test-hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts + run: echo -e "[icat_test_hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts - name: Prepare vault pass run: echo -e "icattravispw" > icat-ansible/vault_pass.txt - name: Move vault to directory it'll get detected by Ansible @@ -248,16 +256,23 @@ jobs: sed -i -e "s/^payara_user: \"glassfish\"/payara_user: \"runner\"/" icat-ansible/group_vars/all/vars.yml - name: Amending roles run: | - sed -i 's/role: authn-uows-isis/role: authn-anon/' icat-ansible/icat-test-hosts.yml + sed -i 's/role: authn_uows_isis/role: authn_anon/' icat-ansible/icat_test_hosts.yml # Force hostname to localhost - bug fix for previous ICAT Ansible issues on Actions - name: Change hostname to localhost run: sudo hostname -b localhost + # Remove existing MySQL installation so it doesn't interfere with GitHub Actions + - name: Remove existing mysql + run: | + sudo apparmor_parser -R /etc/apparmor.d/usr.sbin.mysqld + sudo apt-get remove --purge "mysql*" + sudo rm -rf /var/lib/mysql* /etc/mysql + # Create local instance of ICAT - name: Run ICAT Ansible Playbook run: | - ansible-playbook icat-ansible/icat-test-hosts.yml -i icat-ansible/hosts --vault-password-file icat-ansible/vault_pass.txt -vv + 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 @@ -291,6 +306,7 @@ jobs: with: repository: ral-facilities/datagateway-api path: datagateway-api + ref: v1.0.1 # DataGateway API file setup - name: Create log file @@ -358,7 +374,7 @@ jobs: search-e2e-tests: name: DataGateway Search End to End Tests - runs-on: ubuntu-16.04 + runs-on: ubuntu-20.04 steps: - name: Checkout repo uses: actions/checkout@v2 @@ -372,7 +388,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.x + python-version: 3.6 architecture: x64 # ICAT Ansible clone and install dependencies @@ -387,7 +403,7 @@ jobs: # Prep for running the playbook - name: Create hosts file - run: echo -e "[icat-test-hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts + run: echo -e "[icat_test_hosts]\nlocalhost ansible_connection=local" > icat-ansible/hosts - name: Prepare vault pass run: echo -e "icattravispw" > icat-ansible/vault_pass.txt - name: Move vault to directory it'll get detected by Ansible @@ -397,16 +413,23 @@ jobs: sed -i -e "s/^payara_user: \"glassfish\"/payara_user: \"runner\"/" icat-ansible/group_vars/all/vars.yml - name: Amending roles run: | - sed -i 's/role: authn-uows-isis/role: authn-anon/' icat-ansible/icat-test-hosts.yml + sed -i 's/role: authn_uows_isis/role: authn_anon/' icat-ansible/icat_test_hosts.yml # Force hostname to localhost - bug fix for previous ICAT Ansible issues on Actions - name: Change hostname to localhost run: sudo hostname -b localhost + # Remove existing MySQL installation so it doesn't interfere with GitHub Actions + - name: Remove existing mysql + run: | + sudo apparmor_parser -R /etc/apparmor.d/usr.sbin.mysqld + sudo apt-get remove --purge "mysql*" + sudo rm -rf /var/lib/mysql* /etc/mysql + # Create local instance of ICAT - name: Run ICAT Ansible Playbook run: | - ansible-playbook icat-ansible/icat-test-hosts.yml -i icat-ansible/hosts --vault-password-file icat-ansible/vault_pass.txt -vv + ansible-playbook icat-ansible/icat_test_hosts.yml -i icat-ansible/hosts --vault-password-file icat-ansible/vault_pass.txt -vv - name: Add anon user to rootUserNames 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 @@ -422,6 +445,7 @@ jobs: with: repository: ral-facilities/datagateway-api path: datagateway-api + ref: v1.0.1 # DataGateway API file setup - name: Create log file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9b768fb74 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to DataGateway + +Looking to contribute to DataGateway? **Here's how you can help.** + +Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved. + +## Reporting Bugs + +1. Use the [GitHub issue search](https://github.com/ral-facilities/datagateway/issues) -- check if the issue has already been reported. +2. Check if the issue has been fixed -- try to reproduce it with the latest code +3. Isolate the problem -- ideally create a [minimal example](https://stackoverflow.com/help/minimal-reproducible-example). +4. [Create an issue](https://github.com/ral-facilities/datagateway/issues/new) -- provide as much relevant detail as possible. + +A good bug report should contain all the information necessary to allow a developer to reproduce the issue, without needing to ask for further information. + +Please try to be as detailed as possible in your report. What is your environment? What steps will reproduce the issue? + +All of these details assist the process of investigating and fixing bugs. + +## Requesting Features + +Feature requests are welcome. Please provide as much detail and context as possible. + +[Create an issue](https://github.com/ral-facilities/datagateway/issues/new) to describe the feature. + +## Submitting Pull Requests + +Good pull requests—patches, improvements, new features—are a fantastic help. They should remain focused in scope and avoid containing unrelated commits. + +**Please ask first** before embarking on any significant pull request (e.g. implementing features, refactoring code), otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project. \ No newline at end of file diff --git a/README.md b/README.md index 078f8817e..829ef263a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DataGateway -[![Build Status](https://github.com/ral-facilities/datagateway/workflows/CI%20Build/badge.svg?branch=main)](https://github.com/ral-facilities/datagateway/actions?query=workflow%3A%22CI+Build%22) [![codecov](https://codecov.io/gh/ral-facilities/datagateway/branch/main/graph/badge.svg)](https://codecov.io/gh/ral-facilities/datagateway) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Build Status](https://github.com/ral-facilities/datagateway/workflows/CI%20Build/badge.svg?branch=main)](https://github.com/ral-facilities/datagateway/actions?query=workflow%3A%22CI+Build%22) [![codecov](https://codecov.io/gh/ral-facilities/datagateway/branch/main/graph/badge.svg)](https://codecov.io/gh/ral-facilities/datagateway) DataGateway is a [ReactJs](https://reactjs.org/)-based web application that provides ways of discovering and accessing data produced at large-scale science facilities. DataGateway is a [micro-frontend](https://micro-frontends.org/) that can be integrated with the parent web application [SciGateway](https://github.com/ral-facilities/scigateway). diff --git a/package.json b/package.json index 365202dd0..2d65b72ab 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "packages/*" ], "devDependencies": { - "husky": "^5.1.2", - "lerna": "^3.22.1", - "lerna-update-wizard": "^0.17.7" + "husky": "^7.0.4", + "lerna": "^4.0.0", + "lerna-update-wizard": "^1.1.0" }, "scripts": { "prepare": "lerna run prepare", diff --git a/packages/datagateway-common/package.json b/packages/datagateway-common/package.json index c46e8fe67..9675f31d4 100644 --- a/packages/datagateway-common/package.json +++ b/packages/datagateway-common/package.json @@ -8,29 +8,30 @@ "main": "./lib/index.js", "dependencies": { "@date-io/date-fns": "^1.3.13", - "@material-ui/pickers": "^3.3.10", "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/pickers": "^3.3.10", "@types/lodash.debounce": "^4.0.6", - "axios": "^0.21.1", + "axios": "^0.26.0", "clsx": "^1.1.1", "connected-react-router": "^6.9.1", - "date-fns": "^2.17.0", + "date-fns": "^2.28.0", "hex-to-rgba": "^2.0.1", "history": "^4.10.1", - "i18next": "^20.3.5", + "i18next": "^21.6.13", "lodash.debounce": "^4.0.8", - "loglevel": "^1.7.1", + "loglevel": "^1.8.0", "react-draggable": "^4.4.3", - "react-i18next": "^11.11.4", + "react-i18next": "^11.15.4", "react-query": "^3.18.1", "react-redux": "^7.1.0", "react-router": "^5.0.1", - "react-scripts": "4.0.3", + "react-scripts": "5.0.0", "react-virtualized": "^9.22.3", - "redux": "^4.0.4", + "redux": "^4.1.2", "redux-mock-store": "^1.5.4", - "redux-thunk": "^2.3.0", - "use-deep-compare-effect": "^1.6.1" + "redux-thunk": "^2.4.1", + "resize-observer-polyfill": "^1.5.1", + "use-deep-compare-effect": "^1.8.1" }, "peerDependencies": { "@material-ui/core": ">= 4.0.0 < 5", @@ -43,36 +44,31 @@ "@material-ui/core": "^4.11.3", "@material-ui/icons": "^4.11.2", "@testing-library/react-hooks": "^7.0.1", - "@types/enzyme": "^3.10.8", - "@types/jest": "^26.0.4", - "@types/node": "^14.14.31", + "@types/enzyme": "^3.10.10", + "@types/jest": "^27.4.0", + "@types/node": "^17.0.17", "@types/react": "^17.0.2", - "@types/react-router-dom": "^5.1.8", + "@types/react-router-dom": "^5.3.3", "@types/react-virtualized": "^9.21.10", - "@typescript-eslint/eslint-plugin": "^4.16.1", - "@typescript-eslint/parser": "^4.16.1", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.13.0", "babel-eslint": "10.1.0", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.6", "enzyme-to-json": "^3.6.1", - "eslint": "^7.5.0", - "eslint-config-prettier": "^8.1.0", - "eslint-config-react-app": "^6.0.0", - "eslint-plugin-cypress": "^2.11.3", - "eslint-plugin-flowtype": "^5.9.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint": "^8.9.0", + "eslint-config-prettier": "^8.3.0", + "eslint-config-react-app": "^7.0.0", + "eslint-plugin-cypress": "^2.12.1", "eslint-plugin-prettier": "^3.3.1", - "eslint-plugin-react": "^7.24.0", - "eslint-plugin-react-hooks": "^4.1.0", "lint-staged": "^10.5.4", "prettier": "^2.2.1", "react": "^16.13.1", "react-dom": "^16.11.0", - "react-router-dom": "^5.0.1", + "react-router-dom": "^5.3.0", "react-test-renderer": "16.13.1", "tslib": "^2.3.0", - "typescript": "4.2.2" + "typescript": "4.5.3" }, "scripts": { "start": "react-scripts start", @@ -119,4 +115,4 @@ ], "resetMocks": false } -} +} \ No newline at end of file diff --git a/packages/datagateway-common/src/api/cart.tsx b/packages/datagateway-common/src/api/cart.tsx index e95f4b686..f2c6a8eb0 100644 --- a/packages/datagateway-common/src/api/cart.tsx +++ b/packages/datagateway-common/src/api/cart.tsx @@ -86,6 +86,7 @@ export const useCart = (): UseQueryResult => { onError: (error) => { handleICATError(error); }, + staleTime: 0, } ); }; diff --git a/packages/datagateway-common/src/api/datafiles.test.tsx b/packages/datagateway-common/src/api/datafiles.test.tsx index 736fa4d66..260d0c35e 100644 --- a/packages/datagateway-common/src/api/datafiles.test.tsx +++ b/packages/datagateway-common/src/api/datafiles.test.tsx @@ -78,7 +78,7 @@ describe('datafile api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -159,7 +159,7 @@ describe('datafile api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -255,16 +255,58 @@ describe('datafile api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); + await waitFor(() => result.current.isSuccess); - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + params.append( + 'where', + JSON.stringify({ + name: { ilike: 'test' }, + }) + ); + params.append('distinct', JSON.stringify(['name', 'title'])); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/datafiles/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data).toEqual(mockData.length); + }); + + it('sends axios request to fetch datafile count and returns successful response using the stored filters', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: mockData.length, + }); + + const { result, waitFor } = renderHook( + () => + useDatafileCount( + [ + { + filterType: 'distinct', + filterValue: JSON.stringify(['name', 'title']), + }, + ], + { + name: { value: 'test2', type: 'include' }, + }, + 'investigation' + ), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test2' }, }) ); params.append('distinct', JSON.stringify(['name', 'title'])); diff --git a/packages/datagateway-common/src/api/datafiles.tsx b/packages/datagateway-common/src/api/datafiles.tsx index e5524423d..f53ab4dc2 100644 --- a/packages/datagateway-common/src/api/datafiles.tsx +++ b/packages/datagateway-common/src/api/datafiles.tsx @@ -155,11 +155,17 @@ export const fetchDatafileCountQuery = ( }; export const useDatafileCount = ( - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + storedFilters?: FiltersType, + currentTab?: string ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); - const { filters } = parseSearchToQuery(location.search); + + const filters = + currentTab === 'datafile' || !storedFilters + ? parseSearchToQuery(location.search).filters + : storedFilters; return useQuery< number, @@ -173,7 +179,6 @@ export const useDatafileCount = ( return fetchDatafileCountQuery(apiUrl, filters, additionalFilters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, diff --git a/packages/datagateway-common/src/api/datasets.test.tsx b/packages/datagateway-common/src/api/datasets.test.tsx index 4b919ee2f..58ff318b6 100644 --- a/packages/datagateway-common/src/api/datasets.test.tsx +++ b/packages/datagateway-common/src/api/datasets.test.tsx @@ -1,5 +1,5 @@ import { Dataset } from '../app.types'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { createMemoryHistory, History } from 'history'; import axios from 'axios'; import handleICATError from '../handleICATError'; @@ -54,6 +54,7 @@ describe('dataset api functions', () => { afterEach(() => { (handleICATError as jest.Mock).mockClear(); (axios.get as jest.Mock).mockClear(); + jest.useRealTimers(); }); describe('useDataset', () => { @@ -147,7 +148,7 @@ describe('dataset api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -228,7 +229,7 @@ describe('dataset api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -412,12 +413,13 @@ describe('dataset api functions', () => { }) ); - const { result, waitFor } = renderHook( - () => useDatasetSizes({ pages: [mockData], pageParams: null }), - { - wrapper: createReactQueryWrapper(), - } - ); + const pagedData = { + pages: [mockData], + pageParams: null, + }; + const { result, waitFor } = renderHook(() => useDatasetSizes(pagedData), { + wrapper: createReactQueryWrapper(), + }); await waitFor(() => result.current.every((query) => query.isSuccess)); @@ -461,13 +463,16 @@ describe('dataset api functions', () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', }); - const { result, waitFor } = renderHook(() => useDatasetSizes(mockData), { - wrapper: createReactQueryWrapper(), - }); + const { result, waitFor } = renderHook( + () => useDatasetSizes(mockData[0]), + { + wrapper: createReactQueryWrapper(), + } + ); await waitFor(() => result.current.every((query) => query.isError)); - expect(handleICATError).toHaveBeenCalledTimes(3); + expect(handleICATError).toHaveBeenCalledTimes(1); expect(handleICATError).toHaveBeenCalledWith( { message: 'Test error' }, false @@ -475,15 +480,17 @@ describe('dataset api functions', () => { }); it("doesn't send any requests if the array supplied is empty to undefined", () => { - const { result: emptyResult } = renderHook(() => useDatasetSizes([]), { + let data = []; + const { result: emptyResult } = renderHook(() => useDatasetSizes(data), { wrapper: createReactQueryWrapper(), }); expect(emptyResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); + data = undefined; const { result: undefinedResult } = renderHook( - () => useDatasetSizes(undefined), + () => useDatasetSizes(data), { wrapper: createReactQueryWrapper(), } @@ -492,6 +499,111 @@ describe('dataset api functions', () => { expect(undefinedResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); }); + + it('batches updates correctly & updates results correctly when data updates', async () => { + jest.useFakeTimers(); + mockData = [ + { + id: 1, + name: 'Test 1', + modTime: '2019-06-10', + createTime: '2019-06-11', + }, + { + id: 2, + name: 'Test 2', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 3, + name: 'Test 3', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 4, + name: 'Test 4', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 5, + name: 'Test 5', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 6, + name: 'Test 6', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 7, + name: 'Test 7', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + ]; + (axios.get as jest.Mock).mockImplementation( + (url, options) => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: options.params.entityId ?? 0, + }), + options.params.entityId * 10 + ) + ) + ); + + const { result, rerender, waitForNextUpdate } = renderHook( + () => useDatasetSizes(mockData), + { + wrapper: createReactQueryWrapper(), + } + ); + + jest.advanceTimersByTime(30); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual( + Array(7).fill(undefined) + ); + + jest.advanceTimersByTime(40); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ]); + + mockData = [ + { + id: 4, + name: 'Test 4', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + ]; + + await act(async () => { + rerender(); + await waitForNextUpdate(); + }); + + expect(result.current.map((query) => query.data)).toEqual([4]); + }); }); describe('useDatasetsDatafileCount', () => { @@ -572,12 +684,12 @@ describe('dataset api functions', () => { }) ); + const pagedData = { + pages: [mockData], + pageParams: null, + }; const { result, waitFor } = renderHook( - () => - useDatasetsDatafileCount({ - pages: [mockData], - pageParams: null, - }), + () => useDatasetsDatafileCount(pagedData), { wrapper: createReactQueryWrapper(), } @@ -639,20 +751,25 @@ describe('dataset api functions', () => { expect(result.current.map((query) => query.data)).toEqual([1, 2, 3]); }); - it('sends axios request to fetch dataset dataset counts once refetch function is called and calls handleICATError on failure', async () => { + it('sends axios request to fetch dataset dataset counts and calls handleICATError on failure', async () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', }); const { result, waitFor } = renderHook( - () => useDatasetsDatafileCount(mockData), + () => useDatasetsDatafileCount(mockData[0]), { wrapper: createReactQueryWrapper(), } ); + // for some reason we need to flush promise queue in this test + await act(async () => { + await Promise.resolve(); + }); + await waitFor(() => result.current.every((query) => query.isError)); - expect(handleICATError).toHaveBeenCalledTimes(3); + expect(handleICATError).toHaveBeenCalledTimes(1); expect(handleICATError).toHaveBeenCalledWith( { message: 'Test error' }, false @@ -660,8 +777,9 @@ describe('dataset api functions', () => { }); it("doesn't send any requests if the array supplied is empty to undefined", () => { + let data = []; const { result: emptyResult } = renderHook( - () => useDatasetsDatafileCount([]), + () => useDatasetsDatafileCount(data), { wrapper: createReactQueryWrapper(), } @@ -670,8 +788,9 @@ describe('dataset api functions', () => { expect(emptyResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); + data = undefined; const { result: undefinedResult } = renderHook( - () => useDatasetsDatafileCount(undefined), + () => useDatasetsDatafileCount(data), { wrapper: createReactQueryWrapper(), } @@ -680,6 +799,112 @@ describe('dataset api functions', () => { expect(undefinedResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); }); + + it('batches updates correctly & updates results correctly when data updates', async () => { + jest.useFakeTimers(); + mockData = [ + { + id: 1, + name: 'Test 1', + modTime: '2019-06-10', + createTime: '2019-06-11', + }, + { + id: 2, + name: 'Test 2', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 3, + name: 'Test 3', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 4, + name: 'Test 4', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 5, + name: 'Test 5', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 6, + name: 'Test 6', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + { + id: 7, + name: 'Test 7', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + ]; + (axios.get as jest.Mock).mockImplementation( + (url, options) => + new Promise((resolve) => { + const id = JSON.parse(options.params.get('where'))['dataset.id'].eq; + return setTimeout( + () => + resolve({ + data: id ?? 0, + }), + id * 10 + ); + }) + ); + + const { result, rerender, waitForNextUpdate } = renderHook( + () => useDatasetsDatafileCount(mockData), + { + wrapper: createReactQueryWrapper(), + } + ); + + jest.advanceTimersByTime(30); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual( + Array(7).fill(undefined) + ); + + jest.advanceTimersByTime(40); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ]); + + mockData = [ + { + id: 4, + name: 'Test 4', + modTime: '2019-06-10', + createTime: '2019-06-12', + }, + ]; + + await act(async () => { + rerender(); + await waitForNextUpdate(); + }); + + expect(result.current.map((query) => query.data)).toEqual([4]); + }); }); describe('useDatasetCount', () => { @@ -701,16 +926,58 @@ describe('dataset api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); + await waitFor(() => result.current.isSuccess); - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + params.append( + 'where', + JSON.stringify({ + name: { ilike: 'test' }, + }) + ); + params.append('distinct', JSON.stringify(['name', 'title'])); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/datasets/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data).toEqual(mockData.length); + }); + + it('sends axios request to fetch dataset count and returns successful response using the stored filters', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: mockData.length, + }); + + const { result, waitFor } = renderHook( + () => + useDatasetCount( + [ + { + filterType: 'distinct', + filterValue: JSON.stringify(['name', 'title']), + }, + ], + { + name: { value: 'test2', type: 'include' }, + }, + 'datafile' + ), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test2' }, }) ); params.append('distinct', JSON.stringify(['name', 'title'])); diff --git a/packages/datagateway-common/src/api/datasets.tsx b/packages/datagateway-common/src/api/datasets.tsx index 8863a055d..cdd3b172d 100644 --- a/packages/datagateway-common/src/api/datasets.tsx +++ b/packages/datagateway-common/src/api/datasets.tsx @@ -220,7 +220,7 @@ export const useDatasetSize = ( }; export const useDatasetSizes = ( - data: Dataset[] | InfiniteData | undefined + data: Dataset[] | InfiniteData | Dataset | undefined ): UseQueryResult[] => { const downloadApiUrl = useSelector( (state: StateType) => state.dgcommon.urls.downloadApiUrl @@ -235,11 +235,13 @@ export const useDatasetSizes = ( number, ['datasetSize', number] >[] = React.useMemo(() => { - // check if we're from an infinite query or not to determine the way the data needs to be iterated + // check the type of the data parameter to determine the way the data needs to be iterated const aggregatedData = data ? 'pages' in data ? data.pages.flat() - : data + : data instanceof Array + ? data + : [data] : []; return aggregatedData.map((dataset) => { @@ -268,6 +270,14 @@ export const useDatasetSizes = ( >([]); const countAppliedRef = React.useRef(0); + + // when data changes (i.e. due to sorting or filtering) set the countAppliedRef + // back to 0 so we can restart the process, as well as clear sizes + React.useEffect(() => { + countAppliedRef.current = 0; + setSizes([]); + }, [data]); + // need to use useDeepCompareEffect here because the array returned by useQueries // is different every time this hook runs useDeepCompareEffect(() => { @@ -284,13 +294,13 @@ export const useDatasetSizes = ( setSizes(queries); countAppliedRef.current = currCountReturned; } - }, [queries]); + }, [sizes, queries]); return sizes; }; export const useDatasetsDatafileCount = ( - data: Dataset[] | InfiniteData | undefined + data: Dataset[] | InfiniteData | Dataset | undefined ): UseQueryResult[] => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); @@ -300,11 +310,13 @@ export const useDatasetsDatafileCount = ( number, ['datasetDatafileCount', number] >[] = React.useMemo(() => { - // check if we're from an infinite query or not to determine the way the data needs to be iterated + // check the type of the data parameter to determine the way the data needs to be iterated const aggregatedData = data ? 'pages' in data ? data.pages.flat() - : data + : data instanceof Array + ? data + : [data] : []; return aggregatedData.map((dataset) => { @@ -335,11 +347,19 @@ export const useDatasetsDatafileCount = ( // prettier-ignore const queries: UseQueryResult[] = useQueries(queryConfigs); - const [datasetCounts, setDatasetCounts] = React.useState< + const [datafileCounts, setDatafileCounts] = React.useState< UseQueryResult[] >([]); const countAppliedRef = React.useRef(0); + + // when data changes (i.e. due to sorting or filtering) set the countAppliedRef + // back to 0 so we can restart the process, as well as clear datafileCounts + React.useEffect(() => { + countAppliedRef.current = 0; + setDatafileCounts([]); + }, [data]); + // need to use useDeepCompareEffect here because the array returned by useQueries // is different every time this hook runs useDeepCompareEffect(() => { @@ -348,17 +368,17 @@ export const useDatasetsDatafileCount = ( 0 ); const batchMax = - datasetCounts.length - currCountReturned < 5 - ? datasetCounts.length - currCountReturned + datafileCounts.length - currCountReturned < 5 + ? datafileCounts.length - currCountReturned : 5; // this in effect batches our updates to only happen in batches >= 5 if (currCountReturned - countAppliedRef.current >= batchMax) { - setDatasetCounts(queries); + setDatafileCounts(queries); countAppliedRef.current = currCountReturned; } - }, [queries]); + }, [datafileCounts, queries]); - return datasetCounts; + return datafileCounts; }; export const fetchDatasetCountQuery = ( @@ -386,11 +406,16 @@ export const fetchDatasetCountQuery = ( }; export const useDatasetCount = ( - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + storedFilters?: FiltersType, + currentTab?: string ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); - const { filters } = parseSearchToQuery(location.search); + const filters = + currentTab === 'dataset' || !storedFilters + ? parseSearchToQuery(location.search).filters + : storedFilters; return useQuery< number, @@ -404,7 +429,6 @@ export const useDatasetCount = ( return fetchDatasetCountQuery(apiUrl, filters, additionalFilters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, diff --git a/packages/datagateway-common/src/api/facilityCycles.test.tsx b/packages/datagateway-common/src/api/facilityCycles.test.tsx index fddf65677..464bfaf38 100644 --- a/packages/datagateway-common/src/api/facilityCycles.test.tsx +++ b/packages/datagateway-common/src/api/facilityCycles.test.tsx @@ -9,6 +9,7 @@ import { useFacilityCycleCount, useFacilityCyclesInfinite, useFacilityCyclesPaginated, + useFacilityCyclesByInvestigation, } from './facilityCycles'; jest.mock('../handleICATError'); @@ -102,7 +103,7 @@ describe('facility cycle api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -172,7 +173,7 @@ describe('facility cycle api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -256,16 +257,12 @@ describe('facility cycle api functions', () => { wrapper: createReactQueryWrapper(history), }); - // testing default is 0 - expect(result.current.data).toEqual(0); - - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); @@ -303,4 +300,83 @@ describe('facility cycle api functions', () => { expect(handleICATError).toHaveBeenCalledWith({ message: 'Test error' }); }); }); + + describe('useFacilityCyclesByInvestigation', () => { + it('sends axios request to fetch facility cycle given a investigation start date and returns successful response', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: mockData, + }); + + const { result, waitFor } = renderHook( + () => useFacilityCyclesByInvestigation('2022-04-01 00:00:00'), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); + + params.append( + 'where', + JSON.stringify({ + startDate: { lte: '2022-04-01 00:00:00' }, + }) + ); + params.append( + 'where', + JSON.stringify({ + endDate: { gte: '2022-04-01 00:00:00' }, + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/facilitycycles', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data).toEqual(mockData); + }); + + it('sends axios request to fetch paginated facility cycles and calls handleICATError on failure', async () => { + (axios.get as jest.Mock).mockRejectedValue({ + message: 'Test error', + }); + const { result, waitFor } = renderHook( + () => useFacilityCyclesByInvestigation('2022-04-01 00:00:00'), + { + wrapper: createReactQueryWrapper(), + } + ); + + await waitFor(() => result.current.isError); + + params.append( + 'where', + JSON.stringify({ + startDate: { lte: '2022-04-01 00:00:00' }, + }) + ); + params.append( + 'where', + JSON.stringify({ + endDate: { gte: '2022-04-01 00:00:00' }, + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/facilitycycles', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(handleICATError).toHaveBeenCalledWith({ message: 'Test error' }); + }); + }); }); diff --git a/packages/datagateway-common/src/api/facilityCycles.tsx b/packages/datagateway-common/src/api/facilityCycles.tsx index 3d719d06b..8eace79ad 100644 --- a/packages/datagateway-common/src/api/facilityCycles.tsx +++ b/packages/datagateway-common/src/api/facilityCycles.tsx @@ -74,6 +74,53 @@ export const useAllFacilityCycles = ( ); }; +const fetchFacilityCyclesByInvestigation = ( + apiUrl: string, + investigationStartDate: string | undefined +): Promise => { + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ startDate: { lte: investigationStartDate } }) + ); + params.append( + 'where', + JSON.stringify({ endDate: { gte: investigationStartDate } }) + ); + return axios + .get(`${apiUrl}/facilitycycles`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + .then((response) => { + return response.data; + }); +}; + +export const useFacilityCyclesByInvestigation = ( + investigationStartDate?: string +): UseQueryResult => { + const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); + + return useQuery< + FacilityCycle[], + AxiosError, + FacilityCycle[], + [string, string?] + >( + ['facilityCycle', investigationStartDate], + () => fetchFacilityCyclesByInvestigation(apiUrl, investigationStartDate), + { + onError: (error) => { + handleICATError(error); + }, + enabled: !!investigationStartDate, + } + ); +}; + export const useFacilityCyclesPaginated = ( instrumentId: number ): UseQueryResult => { @@ -194,7 +241,6 @@ export const useFacilityCycleCount = ( return fetchFacilityCycleCount(apiUrl, instrumentId, filters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, diff --git a/packages/datagateway-common/src/api/index.test.tsx b/packages/datagateway-common/src/api/index.test.tsx index d2c4b36db..74b5ce0be 100644 --- a/packages/datagateway-common/src/api/index.test.tsx +++ b/packages/datagateway-common/src/api/index.test.tsx @@ -3,13 +3,15 @@ import { nestedValue, parseQueryToSearch, parseSearchToQuery, - useFilter, + useCustomFilter, useIds, + usePushFilter, usePushFilters, usePushPage, usePushResults, - usePushSort, - usePushView, + useSort, + usePushCurrentTab, + useUpdateView, } from './index'; import { FiltersType, @@ -29,12 +31,21 @@ import axios from 'axios'; import handleICATError from '../handleICATError'; import { createReactQueryWrapper } from '../setupTests'; +import { + useCustomFilterCount, + usePushSearchText, + usePushSearchToggles, + usePushSearchEndDate, + usePushSearchStartDate, + useUpdateQueryParam, +} from '..'; jest.mock('../handleICATError'); describe('generic api functions', () => { afterEach(() => { (handleICATError as jest.Mock).mockClear(); + (axios.get as jest.Mock).mockClear(); }); describe('nestedValue', () => { @@ -101,9 +112,61 @@ describe('generic api functions', () => { results: 10, filters: { name: { value: 'test', type: 'include' } }, sort: { name: 'asc' }, + searchText: null, + dataset: true, + datafile: true, + investigation: true, + startDate: null, + endDate: null, + currentTab: 'investigation', }); }); + it('parses query string with search parameters successfully', () => { + const query = + 'view=table&searchText=testText&datafile=false&startDate=2021-10-17&endDate=2021-10-25'; + + expect(parseSearchToQuery(query)).toEqual({ + view: 'table', + search: null, + page: null, + results: null, + filters: {}, + sort: {}, + searchText: 'testText', + dataset: true, + datafile: false, + investigation: true, + startDate: new Date('2021-10-17T00:00:00Z'), + endDate: new Date('2021-10-25T00:00:00Z'), + currentTab: 'investigation', + }); + }); + + it('parses query string with invalid search parameters successfully', () => { + const query = + 'view=table&searchText=testText&datafile=false&startDate=2021-10-34&endDate=2021-14-25'; + + //Use JSON.stringify so wont fail due to startDate/endDate not being the same instance + expect(JSON.stringify(parseSearchToQuery(query))).toEqual( + JSON.stringify({ + view: 'table', + search: null, + page: null, + results: null, + filters: {}, + sort: {}, + searchText: 'testText', + dataset: true, + datafile: false, + investigation: true, + startDate: new Date(NaN), + endDate: new Date(NaN), + currentTab: 'investigation', + }) + ); + }); + it('logs errors if filter or search params are wrong', () => { console.error = jest.fn(); @@ -128,10 +191,65 @@ describe('generic api functions', () => { results: 10, filters: { name: { value: 'test', type: 'include' } }, sort: { name: 'asc' }, + searchText: null, + dataset: true, + datafile: true, + investigation: true, + startDate: null, + endDate: null, + currentTab: 'investigation', + }; + + const params = new URLSearchParams( + '?view=table&search=test&page=1&results=10&filters=%7B%22name%22%3A%7B%22value%22%3A%22test%22%2C%22type%22%3A%22include%22%7D%7D&sort=%7B%22name%22%3A%22asc%22%7D' + ); + + expect(parseQueryToSearch(query).toString()).toEqual(params.toString()); + }); + + it('parses query object with search parameters successfully', () => { + const query: QueryParams = { + view: 'table', + search: null, + page: null, + results: null, + filters: {}, + sort: {}, + searchText: 'testText', + dataset: true, + datafile: false, + investigation: true, + startDate: new Date('2021-10-17T00:00:00Z'), + endDate: new Date('2021-10-25T00:00:00Z'), + currentTab: 'investigation', + }; + + const params = new URLSearchParams( + '?view=table&searchText=testText&datafile=false&startDate=2021-10-17&endDate=2021-10-25' + ); + + expect(parseQueryToSearch(query).toString()).toEqual(params.toString()); + }); + + it('parses query object with invalid search parameters successfully', () => { + const query: QueryParams = { + view: 'table', + search: null, + page: null, + results: null, + filters: {}, + sort: {}, + searchText: 'testText', + dataset: true, + datafile: false, + investigation: true, + startDate: new Date('2021-10-34T00:00:00Z'), + endDate: new Date('2021-14-25T00:00:00Z'), + currentTab: 'investigation', }; const params = new URLSearchParams( - '?view=table&search=test&page=1&results=10&filters={"name"%3A{"value"%3A"test"%2C"type"%3A"include"}}&sort={"name"%3A"asc"}' + '?view=table&searchText=testText&datafile=false&startDate=Invalid+Date&endDate=Invalid+Date' ); expect(parseQueryToSearch(query).toString()).toEqual(params.toString()); @@ -156,8 +274,8 @@ describe('generic api functions', () => { const params = new URLSearchParams(); params.append('order', JSON.stringify('name asc')); params.append('order', JSON.stringify('id asc')); - params.append('where', JSON.stringify({ name: { like: 'test' } })); - params.append('where', JSON.stringify({ title: { nlike: 'test' } })); + params.append('where', JSON.stringify({ name: { ilike: 'test' } })); + params.append('where', JSON.stringify({ title: { nilike: 'test' } })); params.append( 'where', JSON.stringify({ startDate: { gte: '2021-08-05 00:00:00' } }) @@ -172,15 +290,50 @@ describe('generic api functions', () => { params.toString() ); }); + + it('parses all filter types to api params successfully when ignoreIDSort is true', () => { + const sortAndFilters: { sort: SortType; filters: FiltersType } = { + filters: { + name: { value: 'test', type: 'include' }, + title: { value: 'test', type: 'exclude' }, + startDate: { + startDate: '2021-08-05', + endDate: '2021-08-06', + }, + type: ['1', '2', '3'], + }, + sort: { name: 'asc' }, + }; + + const params = new URLSearchParams(); + params.append('order', JSON.stringify('name asc')); + params.append('where', JSON.stringify({ name: { ilike: 'test' } })); + params.append('where', JSON.stringify({ title: { nilike: 'test' } })); + params.append( + 'where', + JSON.stringify({ startDate: { gte: '2021-08-05 00:00:00' } }) + ); + params.append( + 'where', + JSON.stringify({ startDate: { lte: '2021-08-06 23:59:59' } }) + ); + params.append('where', JSON.stringify({ type: { in: ['1', '2', '3'] } })); + + expect(getApiParams(sortAndFilters, true).toString()).toEqual( + params.toString() + ); + }); }); describe('push functions', () => { let history: History; let wrapper: WrapperComponent; let pushSpy: jest.SpyInstance; + let replaceSpy: jest.SpyInstance; beforeEach(() => { history = createMemoryHistory(); pushSpy = jest.spyOn(history, 'push'); + replaceSpy = jest.spyOn(history, 'replace'); const newWrapper: WrapperComponent = ({ children }) => ( {children} ); @@ -192,14 +345,14 @@ describe('generic api functions', () => { jest.resetModules(); }); - describe('usePushSort', () => { - it('returns callback that when called pushes a new sort to the url query', () => { - const { result } = renderHook(() => usePushSort(), { + describe('useSort', () => { + it('returns callback that can push a new sort to the url query', () => { + const { result } = renderHook(() => useSort(), { wrapper, }); act(() => { - result.current('name', 'asc'); + result.current('name', 'asc', 'push'); }); expect(pushSpy).toHaveBeenCalledWith({ @@ -207,6 +360,20 @@ describe('generic api functions', () => { }); }); + it('returns callback that can replace the sort with a new one in the url query', () => { + const { result } = renderHook(() => useSort(), { + wrapper, + }); + + act(() => { + result.current('name', 'asc', 'replace'); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ + search: `?sort=${encodeURIComponent('{"name":"asc"}')}`, + }); + }); + it('returns callback that when called removes a null sort from the url query', () => { jest.mock('./index.tsx', () => ({ ...jest.requireActual('./index.tsx'), @@ -215,12 +382,12 @@ describe('generic api functions', () => { ), })); - const { result } = renderHook(() => usePushSort(), { + const { result } = renderHook(() => useSort(), { wrapper, }); act(() => { - result.current('name', null); + result.current('name', null, 'push'); }); expect(pushSpy).toHaveBeenCalledWith({ @@ -229,9 +396,9 @@ describe('generic api functions', () => { }); }); - describe('usePushFilters', () => { + describe('usePushFilter', () => { it('returns callback that when called pushes a new filter to the url query', () => { - const { result } = renderHook(() => usePushFilters(), { + const { result } = renderHook(() => usePushFilter(), { wrapper, }); @@ -255,7 +422,7 @@ describe('generic api functions', () => { ), })); - const { result } = renderHook(() => usePushFilters(), { + const { result } = renderHook(() => usePushFilter(), { wrapper, }); @@ -269,6 +436,52 @@ describe('generic api functions', () => { }); }); + describe('usePushFilters', () => { + it('returns callback that when called pushes multiple new filters to the url query', () => { + const { result } = renderHook(() => usePushFilters(), { + wrapper, + }); + + act(() => { + result.current([ + { filterKey: 'name', filter: { value: 'test', type: 'include' } }, + { filterKey: 'title', filter: { value: 'test2', type: 'include' } }, + ]); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: `?filters=${encodeURIComponent( + '{"name":{"value":"test","type":"include"},"title":{"value":"test2","type":"include"}}' + )}`, + }); + }); + + it('returns callback that when called removes multiple null filters from the url query', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn( + () => + '?filters=%7B%22name%22%3A%7B%22value%22%3A%22test%22%2C%22type%22%3A%22include%22%7D%2C%22title%22%3A%7B%22value%22%3A%22test2%22%2C%22type%22%3A%22include%22%7D%7D' + ), + })); + + const { result } = renderHook(() => usePushFilters(), { + wrapper, + }); + + act(() => { + result.current([ + { filterKey: 'name', filter: null }, + { filterKey: 'title', filter: null }, + ]); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: '?', + }); + }); + }); + describe('usePushPage', () => { it('returns callback that when called pushes a new page to the url query', () => { const { result } = renderHook(() => usePushPage(), { @@ -283,6 +496,20 @@ describe('generic api functions', () => { }); }); + describe('usePushCurrentTab', () => { + it('returns callback that when called pushes a new tab to the url query', () => { + const { result } = renderHook(() => usePushCurrentTab(), { + wrapper, + }); + + act(() => { + result.current('dataset'); + }); + + expect(pushSpy).toHaveBeenCalledWith('?currentTab=dataset'); + }); + }); + describe('usePushResults', () => { it('returns callback that when called pushes a new page to the url query', () => { const { result } = renderHook(() => usePushResults(), { @@ -297,9 +524,195 @@ describe('generic api functions', () => { }); }); - describe('usePushView', () => { - it('returns callback that when called pushes a new page to the url query', () => { - const { result } = renderHook(() => usePushView(), { + describe('useUpdateQueryParam', () => { + it('returns callback that when called removes all filters from the url query (push)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('filters', 'push'), + { + wrapper, + } + ); + + act(() => { + result.current({ + name: { value: 'test', type: 'include' }, + title: { value: 'test2', type: 'include' }, + }); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: + '?filters=%7B%22name%22%3A%7B%22value%22%3A%22test%22%2C%22type%22%3A%22include%22%7D%2C%22title%22%3A%7B%22value%22%3A%22test2%22%2C%22type%22%3A%22include%22%7D%7D', + }); + }); + + it('returns callback that when called removes all sorts from the url query (push)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('sort', 'push'), + { + wrapper, + } + ); + + act(() => { + result.current({ name: 'asc' }); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: '?sort=%7B%22name%22%3A%22asc%22%7D', + }); + }); + + it('returns callback that when called removes page number from the url query (push)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('page', 'push'), + { + wrapper, + } + ); + + act(() => { + result.current(2); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: '?page=2', + }); + }); + + it('returns callback that when called removes results number from the url query (push)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('results', 'push'), + { + wrapper, + } + ); + + act(() => { + result.current(10); + }); + + expect(pushSpy).toHaveBeenCalledWith({ + search: '?results=10', + }); + }); + + it('returns callback that when called removes all filters from the url query (replace)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('filters', 'replace'), + { + wrapper, + } + ); + + act(() => { + result.current({ + name: { value: 'test', type: 'include' }, + title: { value: 'test2', type: 'include' }, + }); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ + search: + '?filters=%7B%22name%22%3A%7B%22value%22%3A%22test%22%2C%22type%22%3A%22include%22%7D%2C%22title%22%3A%7B%22value%22%3A%22test2%22%2C%22type%22%3A%22include%22%7D%7D', + }); + }); + + it('returns callback that when called removes all sorts from the url query (replace)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('sort', 'replace'), + { + wrapper, + } + ); + + act(() => { + result.current({ name: 'asc' }); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ + search: '?sort=%7B%22name%22%3A%22asc%22%7D', + }); + }); + + it('returns callback that when called removes page number from the url query (replace)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('page', 'replace'), + { + wrapper, + } + ); + + act(() => { + result.current(2); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ + search: '?page=2', + }); + }); + + it('returns callback that when called removes results number from the url query (replace)', () => { + jest.mock('./index.tsx', () => ({ + ...jest.requireActual('./index.tsx'), + parseSearchToQuery: jest.fn(() => '?'), + })); + + const { result } = renderHook( + () => useUpdateQueryParam('results', 'replace'), + { + wrapper, + } + ); + + act(() => { + result.current(10); + }); + + expect(replaceSpy).toHaveBeenCalledWith({ + search: '?results=10', + }); + }); + }); + + describe('useUpdateView', () => { + it('returns callback that when called pushes a new view to the url query', () => { + const { result } = renderHook(() => useUpdateView('push'), { wrapper, }); @@ -309,6 +722,104 @@ describe('generic api functions', () => { expect(pushSpy).toHaveBeenCalledWith('?view=table'); }); + + it('returns callback that when called replaces the current view with a new one in the url query', () => { + const { result } = renderHook(() => useUpdateView('replace'), { + wrapper, + }); + + act(() => { + result.current('table'); + }); + + expect(replaceSpy).toHaveBeenCalledWith('?view=table'); + }); + }); + + describe('usePushSearchText', () => { + it('returns callback that when called pushes search text to the url query', () => { + const { result } = renderHook(() => usePushSearchText(), { + wrapper, + }); + + act(() => { + result.current('test'); + }); + + expect(pushSpy).toHaveBeenCalledWith('?searchText=test'); + }); + }); + + describe('usePushSearchToggles', () => { + it('returns callback that when called pushes search toggles to the url query', () => { + const { result } = renderHook(() => usePushSearchToggles(), { + wrapper, + }); + + act(() => { + result.current(false, false, false); + }); + + expect(pushSpy).toHaveBeenCalledWith( + '?dataset=false&datafile=false&investigation=false' + ); + }); + }); + + describe('usePushStartDate', () => { + it('returns callback that when called pushes startDate to the url query', () => { + const { result } = renderHook(() => usePushSearchStartDate(), { + wrapper, + }); + + act(() => { + result.current(new Date('2021-10-17T00:00:00Z')); + }); + + expect(pushSpy).toHaveBeenCalledWith( + expect.stringContaining('?startDate=2021-10-17') + ); + }); + it('returns callback that when called with null can remove startDate from the url query', () => { + const { result } = renderHook(() => usePushSearchStartDate(), { + wrapper, + }); + + act(() => { + result.current(new Date('2021-10-17T00:00:00Z')); + result.current(null); + }); + + expect(pushSpy).toHaveBeenLastCalledWith('?'); + }); + }); + + describe('usePushEndDate', () => { + it('returns callback that when called pushes endDate to the url query', () => { + const { result } = renderHook(() => usePushSearchEndDate(), { + wrapper, + }); + + act(() => { + result.current(new Date('2021-10-25T00:00:00Z')); + }); + + expect(pushSpy).toHaveBeenCalledWith( + expect.stringContaining('?endDate=2021-10-25') + ); + }); + it('returns callback that when called with null can remove endDate from the url query', () => { + const { result } = renderHook(() => usePushSearchEndDate(), { + wrapper, + }); + + act(() => { + result.current(new Date('2021-10-25T00:00:00Z')); + result.current(null); + }); + + expect(pushSpy).toHaveBeenLastCalledWith('?'); + }); }); }); @@ -346,6 +857,18 @@ describe('generic api functions', () => { expect(result.current.data).toEqual([1, 2, 3]); }); + it('does not send axios request to fetch ids when set to disabled', async () => { + const { result } = renderHook( + () => useIds('investigation', undefined, false), + { + wrapper: createReactQueryWrapper(), + } + ); + + expect(result.current.isIdle).toBe(true); + expect(axios.get).not.toHaveBeenCalled(); + }); + it('sends axios request to fetch ids and calls handleICATError on failure', async () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', @@ -360,7 +883,7 @@ describe('generic api functions', () => { }); }); - describe('useFilter', () => { + describe('useCustomFilter', () => { it('sends axios request to fetch filters and returns successful response', async () => { (axios.get as jest.Mock).mockResolvedValue({ data: [{ title: '1' }, { title: '2' }, { title: '3' }], @@ -368,7 +891,7 @@ describe('generic api functions', () => { const { result, waitFor } = renderHook( () => - useFilter('investigation', 'title', [ + useCustomFilter('investigation', 'title', [ { filterType: 'distinct', filterValue: '"name"' }, ]), { @@ -379,8 +902,7 @@ describe('generic api functions', () => { await waitFor(() => result.current.isSuccess); const params = new URLSearchParams(); - params.append('order', JSON.stringify('id asc')); - params.append('distinct', JSON.stringify(['name', 'id'])); + params.append('distinct', JSON.stringify(['name', 'title'])); expect(axios.get).toHaveBeenCalledWith( 'https://example.com/api/investigations', @@ -399,7 +921,7 @@ describe('generic api functions', () => { message: 'Test error', }); const { result, waitFor } = renderHook( - () => useFilter('investigation', 'title'), + () => useCustomFilter('investigation', 'title'), { wrapper: createReactQueryWrapper(), } @@ -410,4 +932,102 @@ describe('generic api functions', () => { expect(handleICATError).toHaveBeenCalledWith({ message: 'Test error' }); }); }); + + describe('useCustomFilterCount', () => { + it('sends axios request to fetch filter counts and returns successful response', async () => { + const filterKey = 'title'; + (axios.get as jest.Mock).mockImplementation((url, options) => + Promise.resolve({ + data: JSON.parse(options.params.get('where'))[filterKey].eq ?? 0, + }) + ); + + const { result, waitFor } = renderHook( + () => useCustomFilterCount('investigation', 'title', ['1', '2', '3']), + { + wrapper: createReactQueryWrapper(), + } + ); + + await waitFor(() => result.current.every((query) => query.isSuccess)); + + const params = new URLSearchParams(); + params.append( + 'where', + JSON.stringify({ + [filterKey]: { eq: '1' }, + }) + ); + expect(axios.get).toHaveBeenNthCalledWith( + 1, + 'https://example.com/api/investigations/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + + params.set( + 'where', + JSON.stringify({ + [filterKey]: { eq: '2' }, + }) + ); + expect(axios.get).toHaveBeenNthCalledWith( + 2, + 'https://example.com/api/investigations/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[1][1].params.toString()).toBe( + params.toString() + ); + + params.set( + 'where', + JSON.stringify({ + [filterKey]: { eq: '3' }, + }) + ); + expect(axios.get).toHaveBeenNthCalledWith( + 3, + 'https://example.com/api/investigations/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[2][1].params.toString()).toBe( + params.toString() + ); + + expect(result.current.map((query) => query.data)).toEqual([ + '1', + '2', + '3', + ]); + }); + + it('sends axios request to fetch filter counts and calls handleICATError on failure', async () => { + (axios.get as jest.Mock).mockRejectedValue({ + message: 'Test error', + }); + + const { result, waitFor } = renderHook( + () => useCustomFilterCount('investigation', 'title', ['1', '2', '3']), + { + wrapper: createReactQueryWrapper(), + } + ); + + await waitFor(() => result.current.every((query) => query.isError)); + + expect(handleICATError).toHaveBeenCalledTimes(3); + expect(result.current.map((query) => query.error)).toEqual( + Array(3).fill({ message: 'Test error' }) + ); + }); + }); }); diff --git a/packages/datagateway-common/src/api/index.tsx b/packages/datagateway-common/src/api/index.tsx index 4515a9a1b..f5b0d9015 100644 --- a/packages/datagateway-common/src/api/index.tsx +++ b/packages/datagateway-common/src/api/index.tsx @@ -10,12 +10,20 @@ import { QueryParams, ViewsType, Entity, + UpdateMethod, } from '../app.types'; -import { useQuery, UseQueryResult } from 'react-query'; +import { + useQueries, + useQuery, + UseQueryOptions, + UseQueryResult, +} from 'react-query'; import handleICATError from '../handleICATError'; import { readSciGatewayToken } from '../parseTokens'; import { useSelector } from 'react-redux'; import { StateType } from '../state/app.types'; +import format from 'date-fns/format'; +import { isValid } from 'date-fns'; export * from './cart'; export * from './facilityCycles'; @@ -55,6 +63,13 @@ export const parseSearchToQuery = (queryParams: string): QueryParams => { const filters = query.get('filters'); const sort = query.get('sort'); const view = query.get('view') as ViewsType; + const searchText = query.get('searchText'); + const dataset = query.get('dataset'); + const datafile = query.get('datafile'); + const investigation = query.get('investigation'); + const startDateString = query.get('startDate'); + const endDateString = query.get('endDate'); + const currentTab = query.get('currentTab'); // Parse filters in the query. const parsedFilters: FiltersType = {}; @@ -92,6 +107,12 @@ export const parseSearchToQuery = (queryParams: string): QueryParams => { } } + let startDate = null; + let endDate = null; + + if (startDateString) startDate = new Date(startDateString + 'T00:00:00Z'); + if (endDateString) endDate = new Date(endDateString + 'T00:00:00Z'); + // Create the query parameters object. const params: QueryParams = { view: view, @@ -100,6 +121,13 @@ export const parseSearchToQuery = (queryParams: string): QueryParams => { results: results ? Number(results) : null, filters: parsedFilters, sort: parsedSort, + searchText: searchText, + dataset: dataset !== null ? dataset === 'true' : true, + datafile: datafile !== null ? datafile === 'true' : true, + investigation: investigation !== null ? investigation === 'true' : true, + startDate: startDate, + endDate: endDate, + currentTab: currentTab ? currentTab : 'investigation', }; return params; @@ -117,7 +145,17 @@ export const parseQueryToSearch = (query: QueryParams): URLSearchParams => { // Loop and add all the query parameters which is in use. for (const [q, v] of Object.entries(query)) { if (v !== null && q !== 'filters' && q !== 'sort') { - queryParams.append(q, v); + if ((q === 'startDate' || q === 'endDate') && isValid(v)) { + queryParams.append(q, format(v, 'yyyy-MM-dd')); + } else if ( + //Take default value of these as true, so don't put in url if this is the case + !( + (q === 'dataset' || q === 'datafile' || q === 'investigation') && + v === true + ) && + !(q === 'currentTab' && v === 'investigation') + ) + queryParams.append(q, v); } } @@ -151,10 +189,13 @@ export const parseQueryToSearch = (query: QueryParams): URLSearchParams => { /** * Convert Sort and Filter types to datagateway-api compatible URLSearchParams */ -export const getApiParams = (props: { - sort: SortType; - filters: FiltersType; -}): URLSearchParams => { +export const getApiParams = ( + props: { + sort: SortType; + filters: FiltersType; + }, + ignoreIDSort?: boolean +): URLSearchParams => { const { sort, filters } = props; const searchParams = new URLSearchParams(); @@ -162,8 +203,9 @@ export const getApiParams = (props: { searchParams.append('order', JSON.stringify(`${key} ${value}`)); } - // sort by ID first to guarantee order - searchParams.append('order', JSON.stringify(`id asc`)); + if (!ignoreIDSort) + // sort by ID first to guarantee order + searchParams.append('order', JSON.stringify(`id asc`)); for (const [column, filter] of Object.entries(filters)) { if (typeof filter === 'object') { @@ -186,12 +228,12 @@ export const getApiParams = (props: { if (filter.type === 'include') { searchParams.append( 'where', - JSON.stringify({ [column]: { like: filter.value } }) + JSON.stringify({ [column]: { ilike: filter.value } }) ); } else { searchParams.append( 'where', - JSON.stringify({ [column]: { nlike: filter.value } }) + JSON.stringify({ [column]: { nilike: filter.value } }) ); } } @@ -209,13 +251,19 @@ export const getApiParams = (props: { return searchParams; }; -export const usePushSort = (): (( +export const useSort = (): (( sortKey: string, - order: Order | null + order: Order | null, + updateMethod: UpdateMethod ) => void) => { - const { push } = useHistory(); + const { push, replace } = useHistory(); + return React.useCallback( - (sortKey: string, order: Order | null): void => { + ( + sortKey: string, + order: Order | null, + updateMethod: UpdateMethod + ): void => { let query = parseSearchToQuery(window.location.search); if (order !== null) { query = { @@ -235,17 +283,20 @@ export const usePushSort = (): (( }, }; } - push({ search: `?${parseQueryToSearch(query).toString()}` }); + (updateMethod === 'push' ? push : replace)({ + search: `?${parseQueryToSearch(query).toString()}`, + }); }, - [push] + [push, replace] ); }; -export const usePushFilters = (): (( +export const usePushFilter = (): (( filterKey: string, filter: Filter | null ) => void) => { const { push } = useHistory(); + return React.useCallback( (filterKey: string, filter: Filter | null) => { let query = parseSearchToQuery(window.location.search); @@ -274,6 +325,82 @@ export const usePushFilters = (): (( ); }; +export const usePushFilters = (): (( + filters: { filterKey: string; filter: Filter | null }[] +) => void) => { + const { push } = useHistory(); + return React.useCallback( + (filters: { filterKey: string; filter: Filter | null }[]) => { + let query = parseSearchToQuery(window.location.search); + filters.forEach((f) => { + const { filter, filterKey } = f; + if (filter !== null) { + // if given an defined filter, update the relevant column in the sort state + query = { + ...query, + filters: { + ...query.filters, + [filterKey]: filter, + }, + }; + } else { + // if filter is null, user no longer wants to filter by that column so remove column from filter state + const { [filterKey]: filter, ...rest } = query.filters; + query = { + ...query, + filters: { + ...rest, + }, + }; + } + }); + push({ search: `?${parseQueryToSearch(query).toString()}` }); + }, + [push] + ); +}; + +export const useUpdateQueryParam = ( + type: 'filters' | 'sort' | 'page' | 'results', + updateMethod: 'push' | 'replace' +): ((param: FiltersType | SortType | number | null) => void) => { + const { push, replace } = useHistory(); + const functionToUse = updateMethod === 'push' ? push : replace; + return React.useCallback( + (param: FiltersType | SortType | number | null) => { + const query = parseSearchToQuery(window.location.search); + + if (type === 'filters') { + query.filters = param as FiltersType; + } else if (type === 'sort') { + query.sort = param as SortType; + } else if (type === 'page') { + query.page = param as number | null; + } else if (type === 'results') { + query.results = param as number | null; + } + + functionToUse({ search: `?${parseQueryToSearch(query).toString()}` }); + }, + [type, functionToUse] + ); +}; + +export const usePushCurrentTab = (): ((currentTab: string) => void) => { + const { push } = useHistory(); + + return React.useCallback( + (currentTab: string) => { + const query = { + ...parseSearchToQuery(window.location.search), + currentTab, + }; + push(`?${parseQueryToSearch(query).toString()}`); + }, + [push] + ); +}; + export const usePushPage = (): ((page: number) => void) => { const { push } = useHistory(); @@ -304,8 +431,11 @@ export const usePushResults = (): ((results: number) => void) => { ); }; -export const usePushView = (): ((view: ViewsType) => void) => { - const { push } = useHistory(); +export const useUpdateView = ( + updateMethod: UpdateMethod +): ((view: ViewsType) => void) => { + const { push, replace } = useHistory(); + const functionToUse = updateMethod === 'push' ? push : replace; return React.useCallback( (view: ViewsType) => { @@ -313,12 +443,98 @@ export const usePushView = (): ((view: ViewsType) => void) => { ...parseSearchToQuery(window.location.search), view, }; + functionToUse(`?${parseQueryToSearch(query).toString()}`); + }, + [functionToUse] + ); +}; + +export const usePushSearchText = (): ((searchText: string) => void) => { + const { push } = useHistory(); + + return React.useCallback( + (searchText: string) => { + const query = { + ...parseSearchToQuery(window.location.search), + searchText, + }; + push(`?${parseQueryToSearch(query).toString()}`); + }, + [push] + ); +}; + +export const usePushSearchToggles = (): (( + dataset: boolean, + datafile: boolean, + investigation: boolean +) => void) => { + const { push } = useHistory(); + + return React.useCallback( + (dataset: boolean, datafile: boolean, investigation: boolean) => { + const query = { + ...parseSearchToQuery(window.location.search), + dataset, + datafile, + investigation, + }; push(`?${parseQueryToSearch(query).toString()}`); }, [push] ); }; +export const usePushSearchStartDate = (): (( + startDate: Date | null +) => void) => { + const { push } = useHistory(); + + return React.useCallback( + (startDate: Date | null) => { + //If null remove from URL instead + if (startDate) { + const query = { + ...parseSearchToQuery(window.location.search), + startDate, + }; + push(`?${parseQueryToSearch(query).toString()}`); + } else { + const searchParams = parseQueryToSearch( + parseSearchToQuery(window.location.search) + ); + searchParams.delete('startDate'); + push(`?${searchParams.toString()}`); + } + }, + [push] + ); +}; + +export const usePushSearchEndDate = (): ((endDate: Date | null) => void) => { + const { push } = useHistory(); + + return React.useCallback( + (endDate: Date | null) => { + //If null remove from URL instead + if (endDate) { + const query = { + ...parseSearchToQuery(window.location.search), + endDate, + }; + push(`?${parseQueryToSearch(query).toString()}`); + } else { + const searchParams = parseQueryToSearch( + parseSearchToQuery(window.location.search) + ); + searchParams.delete('endDate'); + push(`?${searchParams.toString()}`); + } + }, + [push] + ); +}; + export const fetchIds = ( apiUrl: string, entityType: 'investigation' | 'dataset' | 'datafile', @@ -359,7 +575,8 @@ export const fetchIds = ( export const useIds = ( entityType: 'investigation' | 'dataset' | 'datafile', - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + enabled = true ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); @@ -380,6 +597,7 @@ export const useIds = ( onError: (error) => { handleICATError(error); }, + enabled, } ); }; @@ -433,8 +651,7 @@ const fetchFilter = ( }); }; -// TODO: name this in a way to not get confused with filtering in general? -export const useFilter = ( +export const useCustomFilter = ( entityType: 'investigation' | 'dataset' | 'datafile', filterKey: string, additionalFilters?: { @@ -467,3 +684,118 @@ export const useFilter = ( } ); }; + +export const formatFilterCount = ( + query: UseQueryResult +): string => (query?.isSuccess ? query.data.toString() : ''); + +export const fetchFilterCountQuery = ( + apiUrl: string, + entityType: + | 'investigation' + | 'dataset' + | 'datafile' + | 'facilityCycle' + | 'instrument' + | 'facility' + | 'study', + additionalFilters?: AdditionalFilters +): Promise => { + const params = new URLSearchParams(); + + if (additionalFilters) { + additionalFilters.forEach((filter) => { + params.append(filter.filterType, filter.filterValue); + }); + } + + // TODO: Call from a separate function? + // Pluralise the entity type for the request + const pluralisedEntityType = + entityType.charAt(entityType.length - 1) === 'y' + ? `${entityType.slice(0, entityType.length - 1)}ies` + : `${entityType}s`; + + return axios + .get(`${apiUrl}/${pluralisedEntityType}/count`, { + params, + headers: { + Authorization: `Bearer ${readSciGatewayToken().sessionId}`, + }, + }) + .then((response) => response.data); +}; + +export const useCustomFilterCount = ( + entityType: + | 'investigation' + | 'dataset' + | 'datafile' + | 'facilityCycle' + | 'instrument' + | 'facility' + | 'study', + filterKey: string, + filterIds: string[] | undefined, + additionalFilters?: { + filterType: 'where' | 'distinct' | 'include'; + filterValue: string; + }[] +): UseQueryResult[] => { + const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); + + const queryConfigs: UseQueryOptions< + number, + AxiosError, + number, + [ + string, + ( + | 'investigation' + | 'dataset' + | 'datafile' + | 'facilityCycle' + | 'instrument' + | 'facility' + | 'study' + ), + string, + string, + AdditionalFilters? + ] + >[] = React.useMemo(() => { + const ids = filterIds ?? []; + + return ids.map((filterId) => { + return { + queryKey: [ + 'filterCount', + entityType, + filterKey, + filterId, + additionalFilters, + ], + queryFn: () => + fetchFilterCountQuery(apiUrl, entityType, [ + { + filterType: 'where', + filterValue: JSON.stringify({ + [filterKey]: { eq: filterId }, + }), + }, + ...(additionalFilters ?? []), + ]), + onError: (error) => { + handleICATError(error, false); + }, + staleTime: Infinity, + }; + }); + }, [apiUrl, entityType, filterIds, filterKey, additionalFilters]); + + // useQueries doesn't allow us to specify type info, so ignore this line + // since we strongly type the queries object anyway + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return useQueries(queryConfigs); +}; diff --git a/packages/datagateway-common/src/api/instruments.test.tsx b/packages/datagateway-common/src/api/instruments.test.tsx index 0d402c11c..214864251 100644 --- a/packages/datagateway-common/src/api/instruments.test.tsx +++ b/packages/datagateway-common/src/api/instruments.test.tsx @@ -58,7 +58,7 @@ describe('instrument api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -80,15 +80,25 @@ describe('instrument api functions', () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', }); - const { result, waitFor } = renderHook(() => useInstrumentsPaginated(), { - wrapper: createReactQueryWrapper(), - }); + const { result, waitFor } = renderHook( + () => + useInstrumentsPaginated([ + { + filterType: 'include', + filterValue: JSON.stringify('facility'), + }, + ]), + { + wrapper: createReactQueryWrapper(), + } + ); await waitFor(() => result.current.isError); params.append('order', JSON.stringify('id asc')); params.append('skip', JSON.stringify(0)); params.append('limit', JSON.stringify(10)); + params.append('include', JSON.stringify('facility')); expect(axios.get).toHaveBeenCalledWith( 'https://example.com/api/instruments', @@ -122,7 +132,7 @@ describe('instrument api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -170,15 +180,25 @@ describe('instrument api functions', () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', }); - const { result, waitFor } = renderHook(() => useInstrumentsInfinite(), { - wrapper: createReactQueryWrapper(), - }); + const { result, waitFor } = renderHook( + () => + useInstrumentsInfinite([ + { + filterType: 'include', + filterValue: JSON.stringify('facility'), + }, + ]), + { + wrapper: createReactQueryWrapper(), + } + ); await waitFor(() => result.current.isError); params.append('order', JSON.stringify('id asc')); params.append('skip', JSON.stringify(0)); params.append('limit', JSON.stringify(50)); + params.append('include', JSON.stringify('facility')); expect(axios.get).toHaveBeenCalledWith( 'https://example.com/api/instruments', @@ -203,16 +223,12 @@ describe('instrument api functions', () => { wrapper: createReactQueryWrapper(history), }); - // testing default is 0 - expect(result.current.data).toEqual(0); - - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); diff --git a/packages/datagateway-common/src/api/instruments.tsx b/packages/datagateway-common/src/api/instruments.tsx index b102dc7cc..a7c5e3733 100644 --- a/packages/datagateway-common/src/api/instruments.tsx +++ b/packages/datagateway-common/src/api/instruments.tsx @@ -5,7 +5,12 @@ import { IndexRange } from 'react-virtualized'; import { getApiParams, parseSearchToQuery } from '.'; import handleICATError from '../handleICATError'; import { readSciGatewayToken } from '../parseTokens'; -import { FiltersType, Instrument, SortType } from '../app.types'; +import { + AdditionalFilters, + FiltersType, + Instrument, + SortType, +} from '../app.types'; import { StateType } from '../state/app.types'; import { useQuery, @@ -20,6 +25,7 @@ const fetchInstruments = ( sort: SortType; filters: FiltersType; }, + additionalFilters?: AdditionalFilters, offsetParams?: IndexRange ): Promise => { const params = getApiParams(sortAndFilters); @@ -32,6 +38,10 @@ const fetchInstruments = ( ); } + additionalFilters?.forEach((filter) => { + params.append(filter.filterType, filter.filterValue); + }); + return axios .get(`${apiUrl}/instruments`, { params, @@ -44,10 +54,9 @@ const fetchInstruments = ( }); }; -export const useInstrumentsPaginated = (): UseQueryResult< - Instrument[], - AxiosError -> => { +export const useInstrumentsPaginated = ( + additionalFilters?: AdditionalFilters +): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); const { filters, sort, page, results } = parseSearchToQuery(location.search); @@ -71,14 +80,10 @@ export const useInstrumentsPaginated = (): UseQueryResult< const { sort, filters, page, results } = params.queryKey[1]; const startIndex = (page - 1) * results; const stopIndex = startIndex + results - 1; - return fetchInstruments( - apiUrl, - { sort, filters }, - { - startIndex, - stopIndex, - } - ); + return fetchInstruments(apiUrl, { sort, filters }, additionalFilters, { + startIndex, + stopIndex, + }); }, { onError: (error) => { @@ -88,10 +93,9 @@ export const useInstrumentsPaginated = (): UseQueryResult< ); }; -export const useInstrumentsInfinite = (): UseInfiniteQueryResult< - Instrument[], - AxiosError -> => { +export const useInstrumentsInfinite = ( + additionalFilters?: AdditionalFilters +): UseInfiniteQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); const { filters, sort } = parseSearchToQuery(location.search); @@ -102,11 +106,16 @@ export const useInstrumentsInfinite = (): UseInfiniteQueryResult< Instrument[], [string, { sort: SortType; filters: FiltersType }] >( - ['investigation', { sort, filters }], + ['instrument', { sort, filters }], (params) => { const { sort, filters } = params.queryKey[1]; const offsetParams = params.pageParam ?? { startIndex: 0, stopIndex: 49 }; - return fetchInstruments(apiUrl, { sort, filters }, offsetParams); + return fetchInstruments( + apiUrl, + { sort, filters }, + additionalFilters, + offsetParams + ); }, { onError: (error) => { @@ -150,7 +159,6 @@ export const useInstrumentCount = (): UseQueryResult => { return fetchInstrumentCount(apiUrl, filters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, diff --git a/packages/datagateway-common/src/api/investigations.test.tsx b/packages/datagateway-common/src/api/investigations.test.tsx index a9c90629d..d1eb537a2 100644 --- a/packages/datagateway-common/src/api/investigations.test.tsx +++ b/packages/datagateway-common/src/api/investigations.test.tsx @@ -1,5 +1,5 @@ import { Investigation } from '../app.types'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; import { createMemoryHistory, History } from 'history'; import axios from 'axios'; import handleICATError from '../handleICATError'; @@ -64,6 +64,7 @@ describe('investigation api functions', () => { afterEach(() => { (handleICATError as jest.Mock).mockClear(); (axios.get as jest.Mock).mockClear(); + jest.useRealTimers(); }); describe('useInvestigation', () => { @@ -157,7 +158,60 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, + }) + ); + params.append('skip', JSON.stringify(20)); + params.append('limit', JSON.stringify(20)); + params.append( + 'include', + JSON.stringify({ + investigationInstruments: 'instrument', + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/investigations', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data).toEqual(mockData); + }); + + it('sends axios request to fetch paginated investigations and returns successful response when ignoreIDSort is true', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: mockData, + }); + + const { result, waitFor } = renderHook( + () => + useInvestigationsPaginated( + [ + { + filterType: 'include', + filterValue: JSON.stringify({ + investigationInstruments: 'instrument', + }), + }, + ], + true + ), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); + + params.append('order', JSON.stringify('name asc')); + params.append( + 'where', + JSON.stringify({ + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -241,7 +295,88 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, + }) + ); + params.append('skip', JSON.stringify(0)); + params.append('limit', JSON.stringify(50)); + params.append( + 'include', + JSON.stringify({ + investigationInstruments: 'instrument', + }) + ); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/investigations', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data.pages).toStrictEqual([mockData[0]]); + + result.current.fetchNextPage({ + pageParam: { startIndex: 50, stopIndex: 74 }, + }); + + await waitFor(() => result.current.isFetching); + + await waitFor(() => !result.current.isFetching); + + expect(axios.get).toHaveBeenNthCalledWith( + 2, + 'https://example.com/api/investigations', + expect.objectContaining({ + params, + }) + ); + params.set('skip', JSON.stringify(50)); + params.set('limit', JSON.stringify(25)); + expect((axios.get as jest.Mock).mock.calls[1][1].params.toString()).toBe( + params.toString() + ); + + expect(result.current.data.pages).toStrictEqual([ + mockData[0], + mockData[1], + ]); + }); + + it('sends axios request to fetch infinite investigations and returns successful response when ignoreIDSort is true', async () => { + (axios.get as jest.Mock).mockImplementation((url, options) => + options.params.get('skip') === '0' + ? Promise.resolve({ data: mockData[0] }) + : Promise.resolve({ data: mockData[1] }) + ); + + const { result, waitFor } = renderHook( + () => + useInvestigationsInfinite( + [ + { + filterType: 'include', + filterValue: JSON.stringify({ + investigationInstruments: 'instrument', + }), + }, + ], + true + ), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); + + params.append('order', JSON.stringify('name asc')); + params.append( + 'where', + JSON.stringify({ + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -431,8 +566,9 @@ describe('investigation api functions', () => { }) ); + const pagedData = { pages: [mockData], pageParams: null }; const { result, waitFor } = renderHook( - () => useInvestigationSizes({ pages: [mockData], pageParams: null }), + () => useInvestigationSizes(pagedData), { wrapper: createReactQueryWrapper(), } @@ -481,7 +617,7 @@ describe('investigation api functions', () => { message: 'Test error', }); const { result, waitFor } = renderHook( - () => useInvestigationSizes(mockData), + () => useInvestigationSizes(mockData[0]), { wrapper: createReactQueryWrapper(), } @@ -489,7 +625,7 @@ describe('investigation api functions', () => { await waitFor(() => result.current.every((query) => query.isError)); - expect(handleICATError).toHaveBeenCalledTimes(3); + expect(handleICATError).toHaveBeenCalledTimes(1); expect(handleICATError).toHaveBeenCalledWith( { message: 'Test error' }, false @@ -497,8 +633,9 @@ describe('investigation api functions', () => { }); it("doesn't send any requests if the array supplied is empty to undefined", () => { + let data = []; const { result: emptyResult } = renderHook( - () => useInvestigationSizes([]), + () => useInvestigationSizes(data), { wrapper: createReactQueryWrapper(), } @@ -507,8 +644,9 @@ describe('investigation api functions', () => { expect(emptyResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); + data = undefined; const { result: undefinedResult } = renderHook( - () => useInvestigationSizes(undefined), + () => useInvestigationSizes(data), { wrapper: createReactQueryWrapper(), } @@ -517,6 +655,111 @@ describe('investigation api functions', () => { expect(undefinedResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); }); + + it('batches updates correctly & updates results correctly when data updates', async () => { + jest.useFakeTimers(); + mockData = [ + { + id: 1, + title: 'Test 1', + name: 'Test 1', + visitId: '1', + }, + { + id: 2, + title: 'Test 2', + name: 'Test 2', + visitId: '2', + }, + { + id: 3, + title: 'Test 3', + name: 'Test 3', + visitId: '3', + }, + { + id: 4, + title: 'Test 4', + name: 'Test 4', + visitId: '4', + }, + { + id: 5, + title: 'Test 5', + name: 'Test 5', + visitId: '5', + }, + { + id: 6, + title: 'Test 6', + name: 'Test 6', + visitId: '6', + }, + { + id: 7, + title: 'Test 7', + name: 'Test 7', + visitId: '7', + }, + ]; + (axios.get as jest.Mock).mockImplementation( + (url, options) => + new Promise((resolve) => + setTimeout( + () => + resolve({ + data: options.params.entityId ?? 0, + }), + options.params.entityId * 10 + ) + ) + ); + + const { result, rerender, waitForNextUpdate } = renderHook( + () => useInvestigationSizes(mockData), + { + wrapper: createReactQueryWrapper(), + } + ); + + jest.advanceTimersByTime(30); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual( + Array(7).fill(undefined) + ); + + jest.advanceTimersByTime(40); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ]); + + mockData = [ + { + id: 4, + title: 'Test 4', + name: 'Test 4', + visitId: '4', + }, + ]; + + await act(async () => { + rerender(); + await waitForNextUpdate(); + }); + + expect(result.current.map((query) => query.data)).toEqual([4]); + }); }); describe('useInvestigationsDatasetCount', () => { @@ -599,12 +842,12 @@ describe('investigation api functions', () => { }) ); + const pagedData = { + pages: [mockData], + pageParams: null, + }; const { result, waitFor } = renderHook( - () => - useInvestigationsDatasetCount({ - pages: [mockData], - pageParams: null, - }), + () => useInvestigationsDatasetCount(pagedData), { wrapper: createReactQueryWrapper(), } @@ -671,15 +914,20 @@ describe('investigation api functions', () => { message: 'Test error', }); const { result, waitFor } = renderHook( - () => useInvestigationsDatasetCount(mockData), + () => useInvestigationsDatasetCount(mockData[0]), { wrapper: createReactQueryWrapper(), } ); + // for some reason we need to flush promise queue in this test + await act(async () => { + await Promise.resolve(); + }); + await waitFor(() => result.current.every((query) => query.isError)); - expect(handleICATError).toHaveBeenCalledTimes(3); + expect(handleICATError).toHaveBeenCalledTimes(1); expect(handleICATError).toHaveBeenCalledWith( { message: 'Test error' }, false @@ -687,8 +935,9 @@ describe('investigation api functions', () => { }); it("doesn't send any requests if the array supplied is empty to undefined", () => { + let data = []; const { result: emptyResult } = renderHook( - () => useInvestigationsDatasetCount([]), + () => useInvestigationsDatasetCount(data), { wrapper: createReactQueryWrapper(), } @@ -697,8 +946,9 @@ describe('investigation api functions', () => { expect(emptyResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); + data = undefined; const { result: undefinedResult } = renderHook( - () => useInvestigationsDatasetCount(undefined), + () => useInvestigationsDatasetCount(data), { wrapper: createReactQueryWrapper(), } @@ -707,6 +957,114 @@ describe('investigation api functions', () => { expect(undefinedResult.current.length).toBe(0); expect(axios.get).not.toHaveBeenCalled(); }); + + it('batches updates correctly & updates results correctly when data updates', async () => { + jest.useFakeTimers(); + mockData = [ + { + id: 1, + title: 'Test 1', + name: 'Test 1', + visitId: '1', + }, + { + id: 2, + title: 'Test 2', + name: 'Test 2', + visitId: '2', + }, + { + id: 3, + title: 'Test 3', + name: 'Test 3', + visitId: '3', + }, + { + id: 4, + title: 'Test 4', + name: 'Test 4', + visitId: '4', + }, + { + id: 5, + title: 'Test 5', + name: 'Test 5', + visitId: '5', + }, + { + id: 6, + title: 'Test 6', + name: 'Test 6', + visitId: '6', + }, + { + id: 7, + title: 'Test 7', + name: 'Test 7', + visitId: '7', + }, + ]; + (axios.get as jest.Mock).mockImplementation( + (url, options) => + new Promise((resolve) => { + const id = JSON.parse(options.params.get('where'))[ + 'investigation.id' + ].eq; + return setTimeout( + () => + resolve({ + data: id ?? 0, + }), + id * 10 + ); + }) + ); + + const { result, rerender, waitForNextUpdate } = renderHook( + () => useInvestigationsDatasetCount(mockData), + { + wrapper: createReactQueryWrapper(), + } + ); + + jest.advanceTimersByTime(30); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual( + Array(7).fill(undefined) + ); + + jest.advanceTimersByTime(40); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.map((query) => query.data)).toEqual([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + ]); + + mockData = [ + { + id: 4, + title: 'Test 4', + name: 'Test 4', + visitId: '4', + }, + ]; + + await act(async () => { + rerender(); + await waitForNextUpdate(); + }); + + expect(result.current.map((query) => query.data)).toEqual([4]); + }); }); describe('useInvestigationCount', () => { @@ -728,16 +1086,58 @@ describe('investigation api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); + await waitFor(() => result.current.isSuccess); - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + params.append( + 'where', + JSON.stringify({ + name: { ilike: 'test' }, + }) + ); + params.append('distinct', JSON.stringify(['name', 'title'])); + + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/api/investigations/count', + expect.objectContaining({ + params, + }) + ); + expect((axios.get as jest.Mock).mock.calls[0][1].params.toString()).toBe( + params.toString() + ); + expect(result.current.data).toEqual(mockData.length); + }); + + it('sends axios request to fetch investigation count and returns successful response using stored filters', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: mockData.length, + }); + + const { result, waitFor } = renderHook( + () => + useInvestigationCount( + [ + { + filterType: 'distinct', + filterValue: JSON.stringify(['name', 'title']), + }, + ], + { + name: { value: 'test2', type: 'include' }, + }, + 'datafile' + ), + { + wrapper: createReactQueryWrapper(history), + } + ); + + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test2' }, }) ); params.append('distinct', JSON.stringify(['name', 'title'])); @@ -851,7 +1251,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -861,6 +1261,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -895,7 +1296,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -917,6 +1318,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -953,6 +1355,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -991,7 +1394,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -1001,6 +1404,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -1063,7 +1467,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -1085,6 +1489,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -1147,6 +1552,7 @@ describe('investigation api functions', () => { JSON.stringify([ { investigationInstruments: 'instrument' }, { studyInvestigations: 'study' }, + { investigationUsers: 'user' }, ]) ); @@ -1176,16 +1582,12 @@ describe('investigation api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); - - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); @@ -1213,16 +1615,12 @@ describe('investigation api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); - - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append( @@ -1295,7 +1693,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); @@ -1329,7 +1727,7 @@ describe('investigation api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append( @@ -1358,6 +1756,18 @@ describe('investigation api functions', () => { expect(result.current.data).toEqual([1, 2, 3]); }); + it('does not send axios request to fetch ids when set to disabled', async () => { + const { result } = renderHook( + () => useISISInvestigationIds(1, 2, false, false), + { + wrapper: createReactQueryWrapper(history), + } + ); + + expect(result.current.isIdle).toBe(true); + expect(axios.get).not.toHaveBeenCalled(); + }); + it('sends axios request to fetch ISIS investigation ids and calls handleICATError on failure', async () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error', diff --git a/packages/datagateway-common/src/api/investigations.tsx b/packages/datagateway-common/src/api/investigations.tsx index 8f0295640..87d591e73 100644 --- a/packages/datagateway-common/src/api/investigations.tsx +++ b/packages/datagateway-common/src/api/investigations.tsx @@ -32,9 +32,10 @@ const fetchInvestigations = ( filters: FiltersType; }, additionalFilters?: AdditionalFilters, - offsetParams?: IndexRange + offsetParams?: IndexRange, + ignoreIDSort?: boolean ): Promise => { - const params = getApiParams(sortAndFilters); + const params = getApiParams(sortAndFilters, ignoreIDSort); if (offsetParams) { params.append('skip', JSON.stringify(offsetParams.startIndex)); @@ -70,7 +71,7 @@ export const useInvestigation = ( Investigation[], AxiosError, Investigation[], - [string, number, AdditionalFilters?] + [string, number, AdditionalFilters?, boolean?] >( ['investigation', investigationId, additionalFilters], (params) => { @@ -93,7 +94,8 @@ export const useInvestigation = ( }; export const useInvestigationsPaginated = ( - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + ignoreIDSort?: boolean ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); @@ -111,22 +113,30 @@ export const useInvestigationsPaginated = ( page: number; results: number; }, - AdditionalFilters? + AdditionalFilters?, + boolean? ] >( [ 'investigation', { sort, filters, page: page ?? 1, results: results ?? 10 }, additionalFilters, + ignoreIDSort, ], (params) => { const { sort, filters, page, results } = params.queryKey[1]; const startIndex = (page - 1) * results; const stopIndex = startIndex + results - 1; - return fetchInvestigations(apiUrl, { sort, filters }, additionalFilters, { - startIndex, - stopIndex, - }); + return fetchInvestigations( + apiUrl, + { sort, filters }, + additionalFilters, + { + startIndex, + stopIndex, + }, + ignoreIDSort + ); }, { onError: (error) => { @@ -137,7 +147,8 @@ export const useInvestigationsPaginated = ( }; export const useInvestigationsInfinite = ( - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + ignoreIDSort?: boolean ): UseInfiniteQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); @@ -147,9 +158,14 @@ export const useInvestigationsInfinite = ( Investigation[], AxiosError, Investigation[], - [string, { sort: SortType; filters: FiltersType }, AdditionalFilters?] + [ + string, + { sort: SortType; filters: FiltersType }, + AdditionalFilters?, + boolean? + ] >( - ['investigation', { sort, filters }, additionalFilters], + ['investigation', { sort, filters }, additionalFilters, ignoreIDSort], (params) => { const { sort, filters } = params.queryKey[1]; const offsetParams = params.pageParam ?? { startIndex: 0, stopIndex: 49 }; @@ -157,7 +173,8 @@ export const useInvestigationsInfinite = ( apiUrl, { sort, filters }, additionalFilters, - offsetParams + offsetParams, + ignoreIDSort ); }, { @@ -223,7 +240,11 @@ export const useInvestigationSize = ( }; export const useInvestigationSizes = ( - data: Investigation[] | InfiniteData | undefined + data: + | Investigation[] + | InfiniteData + | Investigation + | undefined ): UseQueryResult[] => { const downloadApiUrl = useSelector( (state: StateType) => state.dgcommon.urls.downloadApiUrl @@ -238,11 +259,13 @@ export const useInvestigationSizes = ( number, ['investigationSize', number] >[] = React.useMemo(() => { - // check if we're from an infinite query or not to determine the way the data needs to be iterated + // check the type of the data parameter to determine the way the data needs to be iterated const aggregatedData = data ? 'pages' in data ? data.pages.flat() - : data + : data instanceof Array + ? data + : [data] : []; return aggregatedData.map((investigation) => { @@ -274,6 +297,14 @@ export const useInvestigationSizes = ( >([]); const countAppliedRef = React.useRef(0); + + // when data changes (i.e. due to sorting or filtering) set the countAppliedRef + // back to 0 so we can restart the process, as well as clear sizes + React.useEffect(() => { + countAppliedRef.current = 0; + setSizes([]); + }, [data]); + // need to use useDeepCompareEffect here because the array returned by useQueries // is different every time this hook runs useDeepCompareEffect(() => { @@ -285,18 +316,23 @@ export const useInvestigationSizes = ( sizes.length - currCountReturned < 5 ? sizes.length - currCountReturned : 5; + // this in effect batches our updates to only happen in batches >= 5 if (currCountReturned - countAppliedRef.current >= batchMax) { setSizes(queries); countAppliedRef.current = currCountReturned; } - }, [queries]); + }, [sizes, queries]); return sizes; }; export const useInvestigationsDatasetCount = ( - data: Investigation[] | InfiniteData | undefined + data: + | Investigation[] + | InfiniteData + | Investigation + | undefined ): UseQueryResult[] => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); @@ -306,11 +342,13 @@ export const useInvestigationsDatasetCount = ( number, ['investigationDatasetCount', number] >[] = React.useMemo(() => { - // check if we're from an infinite query or not to determine the way the data needs to be iterated + // check the type of the data parameter to determine the way the data needs to be iterated const aggregatedData = data ? 'pages' in data ? data.pages.flat() - : data + : data instanceof Array + ? data + : [data] : []; return aggregatedData.map((investigation) => { @@ -346,6 +384,14 @@ export const useInvestigationsDatasetCount = ( >([]); const countAppliedRef = React.useRef(0); + + // when data changes (i.e. due to sorting or filtering) set the countAppliedRef + // back to 0 so we can restart the process, as well as clear datasetCounts + React.useEffect(() => { + countAppliedRef.current = 0; + setDatasetCounts([]); + }, [data]); + // need to use useDeepCompareEffect here because the array returned by useQueries // is different every time this hook runs useDeepCompareEffect(() => { @@ -357,12 +403,13 @@ export const useInvestigationsDatasetCount = ( datasetCounts.length - currCountReturned < 5 ? datasetCounts.length - currCountReturned : 5; + // this in effect batches our updates to only happen in batches >= 5 if (currCountReturned - countAppliedRef.current >= batchMax) { setDatasetCounts(queries); countAppliedRef.current = currCountReturned; } - }, [queries]); + }, [datasetCounts, queries]); return datasetCounts; }; @@ -392,11 +439,16 @@ const fetchInvestigationCount = ( }; export const useInvestigationCount = ( - additionalFilters?: AdditionalFilters + additionalFilters?: AdditionalFilters, + storedFilters?: FiltersType, + currentTab?: string ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); - const { filters } = parseSearchToQuery(location.search); + const filters = + currentTab === 'investigation' || !storedFilters + ? parseSearchToQuery(location.search).filters + : storedFilters; return useQuery< number, @@ -410,7 +462,6 @@ export const useInvestigationCount = ( return fetchInvestigationCount(apiUrl, filters, additionalFilters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, @@ -516,6 +567,9 @@ export const useISISInvestigationsPaginated = ( { studyInvestigations: 'study', }, + { + investigationUsers: 'user', + }, ]), }; @@ -613,6 +667,9 @@ export const useISISInvestigationsInfinite = ( { studyInvestigations: 'study', }, + { + investigationUsers: 'user', + }, ]), }; @@ -736,7 +793,6 @@ export const useISISInvestigationCount = ( } }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, @@ -774,7 +830,8 @@ const fetchAllISISInvestigationIds = ( export const useISISInvestigationIds = ( instrumentId: number, instrumentChildId: number, - studyHierarchy: boolean + studyHierarchy: boolean, + enabled = true ): UseQueryResult => { const apiUrl = useSelector((state: StateType) => state.dgcommon.urls.apiUrl); const location = useLocation(); @@ -820,6 +877,7 @@ export const useISISInvestigationIds = ( onError: (error) => { handleICATError(error); }, + enabled, } ); }; diff --git a/packages/datagateway-common/src/api/lucene.test.tsx b/packages/datagateway-common/src/api/lucene.test.tsx index 4096af37f..9d40a1e0c 100644 --- a/packages/datagateway-common/src/api/lucene.test.tsx +++ b/packages/datagateway-common/src/api/lucene.test.tsx @@ -23,7 +23,7 @@ describe('Lucene actions', () => { endDate: null, }; const { result, waitFor } = renderHook( - () => useLuceneSearch('Datafile', luceneSearchParams), + () => useLuceneSearch('Investigation', luceneSearchParams), { wrapper: createReactQueryWrapper(), } @@ -39,9 +39,7 @@ describe('Lucene actions', () => { const params = { sessionId: null, query: { - target: 'Datafile', - lower: '0000001010000', - upper: '9000012312359', + target: 'Investigation', }, maxCount: 300, }; @@ -98,6 +96,79 @@ describe('Lucene actions', () => { expect(result.current.data).toEqual([1]); }); + it('sends axios request to fetch lucene search results once refetch function is called and returns successful response with only one date set', async () => { + (axios.get as jest.Mock).mockResolvedValue({ + data: [{ id: 1 }], + }); + + const luceneSearchParams = { + searchText: 'test', + startDate: new Date(2000, 0, 1), + endDate: null, + maxCount: 100, + }; + const startDateTest = renderHook( + () => useLuceneSearch('Datafile', luceneSearchParams), + { + wrapper: createReactQueryWrapper(), + } + ); + + expect(axios.get).not.toHaveBeenCalled(); + expect(startDateTest.result.current.isIdle).toBe(true); + + startDateTest.result.current.refetch(); + + await startDateTest.waitFor(() => startDateTest.result.current.isSuccess); + + const params = { + sessionId: null, + query: { + target: 'Datafile', + text: 'test', + lower: '200001010000', + upper: '9000012312359', + }, + maxCount: 100, + }; + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/icat/lucene/data', + { + params: params, + } + ); + expect(startDateTest.result.current.data).toEqual([1]); + + (axios.get as jest.Mock).mockClear(); + + luceneSearchParams.endDate = new Date(2020, 11, 31); + luceneSearchParams.startDate = null; + + const endDateTest = renderHook( + () => useLuceneSearch('Datafile', luceneSearchParams), + { + wrapper: createReactQueryWrapper(), + } + ); + + expect(axios.get).not.toHaveBeenCalled(); + expect(endDateTest.result.current.isIdle).toBe(true); + + endDateTest.result.current.refetch(); + + await endDateTest.waitFor(() => endDateTest.result.current.isSuccess); + + params.query.upper = '202012312359'; + params.query.lower = '0000001010000'; + expect(axios.get).toHaveBeenCalledWith( + 'https://example.com/icat/lucene/data', + { + params: params, + } + ); + expect(endDateTest.result.current.data).toEqual([1]); + }); + it('sends axios request to fetch lucene search results once refetch function is called and calls handleICATError on failure', async () => { (axios.get as jest.Mock).mockRejectedValue({ message: 'Test error message', diff --git a/packages/datagateway-common/src/api/lucene.tsx b/packages/datagateway-common/src/api/lucene.tsx index 61d510ff4..6e1fea6c0 100644 --- a/packages/datagateway-common/src/api/lucene.tsx +++ b/packages/datagateway-common/src/api/lucene.tsx @@ -1,6 +1,6 @@ import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date'; import axios, { AxiosError } from 'axios'; -import { format } from 'date-fns'; +import { format, set } from 'date-fns'; import { useQuery, UseQueryResult } from 'react-query'; import { useSelector } from 'react-redux'; import { StateType } from '..'; @@ -34,27 +34,23 @@ const urlParamsBuilder = ( target: datasearchtype, }; - const stringStartDate = - params.startDate !== null - ? format(params.startDate, 'yyyy-MM-dd') - : '00000-01-01'; - const stringStartDateArray = stringStartDate.split('-'); - query.lower = - stringStartDateArray[0] + - stringStartDateArray[1] + - stringStartDateArray[2] + - '0000'; + if (params.startDate !== null || params.endDate !== null) { + query.lower = + params.startDate !== null + ? format( + set(params.startDate, { hours: 0, minutes: 0 }), + 'yyyyMMddHHmm' + ) + : '0000001010000'; - const stringEndDate = - params.endDate !== null - ? format(params.endDate, 'yyyy-MM-dd') - : '90000-12-31'; - const stringEndDateArray = stringEndDate.split('-'); - query.upper = - stringEndDateArray[0] + - stringEndDateArray[1] + - stringEndDateArray[2] + - '2359'; + query.upper = + params.endDate !== null + ? format( + set(params.endDate, { hours: 23, minutes: 59 }), + 'yyyyMMddHHmm' + ) + : '9000012312359'; + } if (params.searchText.length > 0) { query.text = params.searchText; diff --git a/packages/datagateway-common/src/api/studies.test.tsx b/packages/datagateway-common/src/api/studies.test.tsx index 0b2870afa..6dc7e39a1 100644 --- a/packages/datagateway-common/src/api/studies.test.tsx +++ b/packages/datagateway-common/src/api/studies.test.tsx @@ -77,7 +77,7 @@ describe('study api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(20)); @@ -162,7 +162,7 @@ describe('study api functions', () => { params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append('skip', JSON.stringify(0)); @@ -353,16 +353,12 @@ describe('study api functions', () => { } ); - // testing default is 0 - expect(result.current.data).toEqual(0); - - await waitFor(() => result.current.isFetching); - await waitFor(() => !result.current.isFetching); + await waitFor(() => result.current.isSuccess); params.append( 'where', JSON.stringify({ - name: { like: 'test' }, + name: { ilike: 'test' }, }) ); params.append( diff --git a/packages/datagateway-common/src/api/studies.tsx b/packages/datagateway-common/src/api/studies.tsx index 5ab5f733d..c6e144674 100644 --- a/packages/datagateway-common/src/api/studies.tsx +++ b/packages/datagateway-common/src/api/studies.tsx @@ -211,7 +211,6 @@ export const useStudyCount = ( return fetchStudyCount(apiUrl, filters, additionalFilters); }, { - placeholderData: 0, onError: (error) => { handleICATError(error); }, diff --git a/packages/datagateway-common/src/app.types.tsx b/packages/datagateway-common/src/app.types.tsx index dbbbbecc7..50a1389f7 100644 --- a/packages/datagateway-common/src/app.types.tsx +++ b/packages/datagateway-common/src/app.types.tsx @@ -118,8 +118,8 @@ export interface InvestigationType { export interface StudyInvestigation { id: number; - study: Study; - investigation: Investigation; + study?: Study; + investigation?: Investigation; } export interface Study { @@ -227,6 +227,7 @@ export interface SubmitCart { export type DownloadCartTableItem = DownloadCartItem & { size: number; + fileCount: number; [key: string]: string | number | DownloadCartItem[]; }; @@ -276,6 +277,8 @@ export type Filter = string[] | TextFilter | DateFilter; export type Order = 'asc' | 'desc'; +export type UpdateMethod = 'push' | 'replace'; + export interface FiltersType { [column: string]: Filter; } @@ -298,4 +301,11 @@ export interface QueryParams { search: string | null; page: number | null; results: number | null; + searchText: string | null; + dataset: boolean; + datafile: boolean; + investigation: boolean; + startDate: Date | null; + endDate: Date | null; + currentTab: string; } diff --git a/packages/datagateway-common/src/arrowtooltip.component.test.tsx b/packages/datagateway-common/src/arrowtooltip.component.test.tsx index bc224b0d0..18bdede52 100644 --- a/packages/datagateway-common/src/arrowtooltip.component.test.tsx +++ b/packages/datagateway-common/src/arrowtooltip.component.test.tsx @@ -3,18 +3,21 @@ import { createMount } from '@material-ui/core/test-utils'; import { ReactWrapper } from 'enzyme'; import { ArrowTooltip } from '.'; import { Tooltip } from '@material-ui/core'; +import { getTooltipText } from './arrowtooltip.component'; +import { act } from 'react-dom/test-utils'; describe('ArrowTooltip component', () => { let mount; + const createWrapper = ( - percentageWidth?: number, - maxEnabledHeight?: number + disableHoverListener?: boolean, + open?: boolean ): ReactWrapper => { return mount(
@@ -25,6 +28,48 @@ describe('ArrowTooltip component', () => { mount = createMount({}); }); + afterEach(() => { + mount.cleanUp(); + }); + + describe('getTooltipText', () => { + it('returns empty string for anything null-ish', () => { + expect(getTooltipText(undefined)).toBe(''); + expect(getTooltipText(null)).toBe(''); + expect(getTooltipText({})).toBe(''); + }); + + it('returns value for any primitives', () => { + expect(getTooltipText(1)).toBe('1'); + expect(getTooltipText(false)).toBe('false'); + expect(getTooltipText('test')).toBe('test'); + }); + + it('returns nested value for any react nodes', () => { + expect(getTooltipText({'Test'})).toBe('Test'); + expect( + getTooltipText( +
+ + {'Test'} + +
+ ) + ).toBe('Test'); + }); + + it('returns concatted nested values for any react node lists', () => { + expect( + getTooltipText( + + {'Test'} + {1} + + ) + ).toBe('Test1'); + }); + }); + // Note that disableHoverListener has the opposite value to isTooltipVisible it('tooltip disabled when tooltipElement null', () => { @@ -33,52 +78,55 @@ describe('ArrowTooltip component', () => { .spyOn(React, 'createRef') .mockReturnValueOnce(null); - const wrapper = createWrapper(undefined, undefined); + const wrapper = createWrapper(); expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(true); spyCreateRef.mockRestore(); }); - it('tooltip enabled when offsetWidth/windowWidth >= percentageWidth', () => { - // Set percentageWidth negative to trigger when offsetWidth is 0 - const wrapper = createWrapper(-1, undefined); + it('can override disableHoverListener', () => { + let wrapper = createWrapper(true); + expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(true); + + wrapper = createWrapper(false); expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(false); }); - it('tooltip disabled when offsetWidth/windowWidth < percentageWidth', () => { - // Initialise state to true so we can check it's later set to false - const spyUseState = jest - .spyOn(React, 'useState') - .mockImplementationOnce( - () => React.useState(true) as [unknown, React.Dispatch] - ); - - const wrapper = createWrapper(1, undefined); - expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(true); + it('check if the tooltip is false when onClose is invoked', () => { + const wrapper = createWrapper(undefined, true); + act(() => { + wrapper.find(Tooltip)?.invoke('onClose')(); + }); + wrapper.update(); - spyUseState.mockRestore(); + expect(wrapper.find(Tooltip).props().open).toEqual(false); }); - it('tooltip disabled when offsetHeight >= maxEnabledHeight', () => { - // Enable with negative percentageWidth, then overide with negative maxEnabledHeight - const wrapper = createWrapper(-1, -1); - expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(true); - }); + it('check if the tooltip is true when onOpen is invoked and check when escape is press the tooltip is false', () => { + let handleKeydown; + const spyUseCallback = jest + .spyOn(React, 'useCallback') + .mockImplementation((f) => { + handleKeydown = f; + return f; + }); + const wrapper = createWrapper(undefined, false); - it('tooltip unchanged when offsetHeight < maxEnabledHeight', () => { - // Enable with negative percentageWidth, don't overide with maxEnabledHeight - const wrapper = createWrapper(-1, 1); - expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(false); - }); + act(() => { + wrapper.find(Tooltip)?.invoke('onOpen')(); + }); + wrapper.update(); + expect(wrapper.find(Tooltip).props().open).toEqual(true); - it('tooltip enabled when offsetWidth < scrollWidth', () => { - // Mock the value of scrollWidth - Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { - configurable: true, - value: 1, + act(() => { + const e = new KeyboardEvent('keydown', { key: 'Escape' }); + handleKeydown(e); }); - const wrapper = createWrapper(undefined, undefined); - expect(wrapper.find(Tooltip).props().disableHoverListener).toEqual(false); + wrapper.update(); + + expect(wrapper.find(Tooltip).props().open).toEqual(false); + + spyUseCallback.mockRestore(); }); }); diff --git a/packages/datagateway-common/src/arrowtooltip.component.tsx b/packages/datagateway-common/src/arrowtooltip.component.tsx index d1f429a01..5513da666 100644 --- a/packages/datagateway-common/src/arrowtooltip.component.tsx +++ b/packages/datagateway-common/src/arrowtooltip.component.tsx @@ -1,56 +1,8 @@ -import React, { useEffect } from 'react'; +import React from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; import Tooltip, { TooltipProps } from '@material-ui/core/Tooltip'; import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'; -const arrowGenerator = ( - color: string -): Record>> => { - return { - '&[x-placement*="bottom"] $arrow': { - top: 0, - left: 0, - marginTop: '-0.95em', - width: '2em', - height: '1em', - '&::before': { - borderWidth: '0 1em 1em 1em', - borderColor: `transparent transparent ${color} transparent`, - }, - }, - '&[x-placement*="top"] $arrow': { - bottom: 0, - left: 0, - marginBottom: '-0.95em', - width: '2em', - height: '1em', - '&::before': { - borderWidth: '1em 1em 0 1em', - borderColor: `${color} transparent transparent transparent`, - }, - }, - '&[x-placement*="right"] $arrow': { - left: 0, - marginLeft: '-0.95em', - height: '2em', - width: '1em', - '&::before': { - borderWidth: '1em 1em 1em 0', - borderColor: `transparent ${color} transparent transparent`, - }, - }, - '&[x-placement*="left"] $arrow': { - right: 0, - marginRight: '-0.95em', - height: '2em', - width: '1em', - '&::before': { - borderWidth: '1em 0 1em 1em', - borderColor: `transparent transparent transparent ${color}`, - }, - }, - }; -}; - const useStylesArrow = makeStyles((theme: Theme) => createStyles({ tooltip: { @@ -58,107 +10,106 @@ const useStylesArrow = makeStyles((theme: Theme) => backgroundColor: theme.palette.common.black, fontSize: '0.875rem', }, - popper: arrowGenerator(theme.palette.common.black), arrow: { - position: 'absolute', - fontSize: 6, - '&::before': { - content: '""', - margin: 'auto', - display: 'block', - width: 0, - height: 0, - borderStyle: 'solid', - }, + color: theme.palette.common.black, }, }) ); +export const getTooltipText = (node: React.ReactNode): string => { + if (typeof node === 'string') return node; + if (typeof node === 'number' || typeof node === 'boolean') + return node.toString(); + if (node instanceof Array) return node.map(getTooltipText).join(''); + if (typeof node === 'object' && node && 'props' in node) + return getTooltipText(node.props.children); + return ''; +}; + const ArrowTooltip = ( props: TooltipProps & { - percentageWidth?: number; - maxEnabledHeight?: number; + disableHoverListener?: boolean; } ): React.ReactElement => { - const { percentageWidth, maxEnabledHeight, ...tooltipProps } = props; + const { disableHoverListener, ...tooltipProps } = props; - const { arrow, ...classes } = useStylesArrow(); - const [arrowRef, setArrowRef] = React.useState(null); + const { ...classes } = useStylesArrow(); - const tooltipElement: React.RefObject = React.createRef(); const [isTooltipVisible, setTooltipVisible] = React.useState(false); + const [open, setOpen] = React.useState(false); - useEffect(() => { - function updateTooltip(): void { + const tooltipResizeObserver = React.useRef( + new ResizeObserver((entries) => { + const tooltipElement = entries[0].target; // Check that the element has been rendered and set the viewable // as false before checking to see the element has exceeded maximum width. - if (tooltipElement !== null) { - // We pass in a percentage width (as a prop) of the viewport width, - // which is set as the max width the tooltip will allow content which - // is wrapped within it until it makes the tooltip visible. - if (percentageWidth) { - // Check to ensure whether the tooltip should be visible given the width provided. - if ( - tooltipElement.current && - tooltipElement.current.offsetWidth / window.innerWidth >= - percentageWidth / 100 - ) - setTooltipVisible(true); - else setTooltipVisible(false); - } - - if (maxEnabledHeight) { - if ( - tooltipElement.current && - tooltipElement.current.offsetHeight > maxEnabledHeight - ) { - setTooltipVisible(false); - } - } + if ( + tooltipElement !== null && + entries.length > 0 && + entries[0].borderBoxSize.length > 0 + ) { + // Width of the tooltip contents including padding and borders + // This is rounded as window.innerWidth and tooltip.scrollWidth are always integer + const borderBoxWidth = Math.round( + entries[0].borderBoxSize[0].inlineSize + ); - if (!percentageWidth && !maxEnabledHeight) { - // If props haven't been given, have tooltip appear only when visible - // text width is smaller than full text width. - if ( - tooltipElement.current && - tooltipElement.current.offsetWidth < - tooltipElement.current.scrollWidth - ) { - setTooltipVisible(true); - } else { - setTooltipVisible(false); - } + // have tooltip appear only when visible text width is smaller than full text width. + if (tooltipElement && borderBoxWidth < tooltipElement.scrollWidth) { + setTooltipVisible(true); + } else { + setTooltipVisible(false); } } + }) + ); + + // need to use a useCallback instead of a useRef for this + // see https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node + const tooltipRef = React.useCallback((container: HTMLDivElement) => { + if (container !== null) { + tooltipResizeObserver.current.observe(container); + } + // When element is unmounted we know container is null so time to clean up + else { + if (tooltipResizeObserver.current) + tooltipResizeObserver.current.disconnect(); } - window.addEventListener('resize', updateTooltip); - updateTooltip(); - return () => window.removeEventListener('resize', updateTooltip); - }, [tooltipElement, setTooltipVisible, percentageWidth, maxEnabledHeight]); + }, []); + + const handleKeyDown = React.useCallback((e) => { + if (e.key === 'Escape') { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onClose = (): void => { + window.removeEventListener('keydown', handleKeyDown); + setOpen(false); + }; + + const onOpen = (): void => { + window.addEventListener('keydown', handleKeyDown); + setOpen(true); + }; + + let shouldDisableHoverListener = !isTooltipVisible; + //Allow disableHoverListener to be overidden + if (disableHoverListener !== undefined) + shouldDisableHoverListener = disableHoverListener; return ( - {tooltipProps.title} - - - } - // TODO: This shouldn't really be calculated inside and should still be possible to be overriden by a prop. - disableHoverListener={!isTooltipVisible} + disableHoverListener={shouldDisableHoverListener} + arrow={true} + onOpen={onOpen} + onClose={onClose} + open={open} + data-testid={`arrow-tooltip-component-${open}`} /> ); }; diff --git a/packages/datagateway-common/src/card/__snapshots__/cardView.component.test.tsx.snap b/packages/datagateway-common/src/card/__snapshots__/cardView.component.test.tsx.snap index c297d6be5..b7be1eafe 100644 --- a/packages/datagateway-common/src/card/__snapshots__/cardView.component.test.tsx.snap +++ b/packages/datagateway-common/src/card/__snapshots__/cardView.component.test.tsx.snap @@ -16,7 +16,7 @@ exports[`Card View renders correctly 1`] = ` > @@ -110,7 +113,6 @@ exports[`Card View renders correctly 1`] = ` > { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="title-label"]').text()).toEqual('Test'); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="Test"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('shows title correctly when no label provided', () => { @@ -57,15 +58,15 @@ describe('AdvancedFilter', () => { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="title-label"]').text()).toEqual('TEST'); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="TEST"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('shows description correctly', () => { @@ -82,17 +83,15 @@ describe('AdvancedFilter', () => { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="description-label"]').text()).toEqual( - 'Desc' - ); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="Desc"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('shows description correctly when no label provided', () => { @@ -108,17 +107,15 @@ describe('AdvancedFilter', () => { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="description-label"]').text()).toEqual( - 'DESC' - ); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="DESC"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('shows information correctly', () => { @@ -137,17 +134,15 @@ describe('AdvancedFilter', () => { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="information-label"]').text()).toEqual( - 'Info' - ); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="Info"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('shows information correctly when label not provided', () => { @@ -165,17 +160,15 @@ describe('AdvancedFilter', () => { // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); - expect(wrapper.find('[aria-label="information-label"]').text()).toEqual( - 'INFO' - ); - expect(wrapper.find('[aria-label="advanced-filters-link"]').text()).toEqual( - 'advanced_filters.hide' - ); + expect(wrapper.find('[children="INFO"]').exists()).toBe(true); + expect( + wrapper.find('[data-testid="advanced-filters-link"]').text() + ).toEqual('advanced_filters.hide'); }); it('TitleIcon displays correctly', () => { @@ -190,7 +183,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -209,7 +202,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -228,7 +221,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -247,7 +240,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -266,7 +259,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -285,7 +278,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -304,7 +297,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -323,7 +316,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -342,7 +335,7 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); @@ -361,10 +354,29 @@ describe('AdvancedFilter', () => { ); // Click on the link to show the filters. wrapper - .find('[aria-label="advanced-filters-link"]') + .find('[data-testid="advanced-filters-link"]') .first() .simulate('click'); wrapper.update(); expect(wrapper.exists(LinkIcon)).toBeTruthy(); }); + + it('LinkIcon displays correctly', () => { + const wrapper = shallow( + + ); + // Click on the link to show the filters. + wrapper + .find('[data-testid="advanced-filters-link"]') + .first() + .simulate('click'); + wrapper.update(); + expect(wrapper.exists(PersonIcon)).toBeTruthy(); + }); }); diff --git a/packages/datagateway-common/src/card/advancedFilter.component.tsx b/packages/datagateway-common/src/card/advancedFilter.component.tsx index b1974b9ca..3c2f7a9b0 100644 --- a/packages/datagateway-common/src/card/advancedFilter.component.tsx +++ b/packages/datagateway-common/src/card/advancedFilter.component.tsx @@ -19,6 +19,7 @@ import ExploreIcon from '@material-ui/icons/Explore'; import SaveIcon from '@material-ui/icons/Save'; import DescriptionIcon from '@material-ui/icons/Description'; import LinkIcon from '@material-ui/icons/Link'; +import PersonIcon from '@material-ui/icons/Person'; import { useTranslation } from 'react-i18next'; const useAdvancedFilterStyles = makeStyles((theme: Theme) => @@ -125,6 +126,12 @@ export const UnmemoisedAdvancedFilter = ( }) as string[]).includes(label) ) { return ; + } else if ( + (t('advanced_filters.icons.person', { + returnObjects: true, + }) as string[]).includes(label) + ) { + return ; } else { return null; } @@ -136,10 +143,10 @@ export const UnmemoisedAdvancedFilter = (
{/* Filters for title and description provided on card */} {title && title.filterComponent && ( -
+
{title.label && chooseIcon(title.label)} - + {title.label ? title.label : title.dataKey} @@ -151,10 +158,10 @@ export const UnmemoisedAdvancedFilter = (
)} {description && description.filterComponent && ( -
+
{description.label && chooseIcon(description.label)} - + {description.label ? description.label : description.dataKey} @@ -170,13 +177,13 @@ export const UnmemoisedAdvancedFilter = ( information.map( (info, index) => info.filterComponent && ( -
+
{info.label && chooseIcon(info.label)} - + {info.label ? info.label : info.dataKey} @@ -193,9 +200,10 @@ export const UnmemoisedAdvancedFilter = ( {/* Advanced filters link */}
setAdvSearchCollapsed((prev) => !prev)} > {!advSearchCollapsed diff --git a/packages/datagateway-common/src/card/cardView.component.test.tsx b/packages/datagateway-common/src/card/cardView.component.test.tsx index 7a896db18..e3d4ca12e 100644 --- a/packages/datagateway-common/src/card/cardView.component.test.tsx +++ b/packages/datagateway-common/src/card/cardView.component.test.tsx @@ -102,11 +102,26 @@ describe('Card View', () => { let updatedProps = { ...props, customFilters: [ - { label: 'Type ID', dataKey: 'type.id', filterItems: ['1', '2'] }, + { + label: 'Type ID', + dataKey: 'type.id', + filterItems: [ + { + name: '1', + count: '1', + }, + { + name: '2', + count: '1', + }, + ], + }, ], }; const wrapper = createWrapper(updatedProps); - expect(wrapper.find('#card').at(0).find(Chip).text()).toEqual('1'); + expect( + wrapper.find('[data-testid="card"]').at(0).find(Chip).text() + ).toEqual('1'); // Open custom filters const typePanel = wrapper.find(Accordion).first(); @@ -168,12 +183,27 @@ describe('Card View', () => { const updatedProps = { ...props, customFilters: [ - { label: 'Type ID', dataKey: 'type.id', filterItems: ['1', '2'] }, + { + label: 'Type ID', + dataKey: 'type.id', + filterItems: [ + { + name: '1', + count: '1', + }, + { + name: '2', + count: '1', + }, + ], + }, ], filters: { 'type.id': { value: 'abc', type: 'include' } }, }; const wrapper = createWrapper(updatedProps); - expect(wrapper.find('#card').at(0).find(Chip).text()).toEqual('1'); + expect( + wrapper.find('[data-testid="card"]').at(0).find(Chip).text() + ).toEqual('1'); // Open custom filters const typePanel = wrapper.find(Accordion).first(); @@ -258,7 +288,7 @@ describe('Card View', () => { // Click to sort ascending button.simulate('click'); - expect(onSort).toHaveBeenNthCalledWith(1, 'title', 'asc'); + expect(onSort).toHaveBeenNthCalledWith(1, 'title', 'asc', 'push'); updatedProps = { ...updatedProps, sort: { title: 'asc' }, @@ -267,7 +297,7 @@ describe('Card View', () => { // Click to sort descending button.simulate('click'); - expect(onSort).toHaveBeenNthCalledWith(2, 'title', 'desc'); + expect(onSort).toHaveBeenNthCalledWith(2, 'title', 'desc', 'push'); updatedProps = { ...updatedProps, sort: { title: 'desc' }, @@ -276,7 +306,29 @@ describe('Card View', () => { // Click to clear sorting button.simulate('click'); - expect(onSort).toHaveBeenNthCalledWith(3, 'title', null); + expect(onSort).toHaveBeenNthCalledWith(3, 'title', null, 'push'); + }); + + it('default sort applied correctly', () => { + const updatedProps: CardViewProps = { + ...props, + title: { ...props.title, defaultSort: 'asc' }, + description: { dataKey: 'name', label: 'Name', defaultSort: 'desc' }, + information: [ + { dataKey: 'visitId' }, + { + dataKey: 'test', + label: 'Name', + defaultSort: 'asc', + }, + ], + }; + const wrapper = createWrapper(updatedProps); + wrapper.update(); + + expect(onSort).toHaveBeenCalledWith('title', 'asc', 'replace'); + expect(onSort).toHaveBeenCalledWith('name', 'desc', 'replace'); + expect(onSort).toHaveBeenCalledWith('test', 'asc', 'replace'); }); it('can sort by description with label', () => { @@ -292,7 +344,7 @@ describe('Card View', () => { // Click to sort ascending button.simulate('click'); - expect(onSort).toHaveBeenCalledWith('name', 'asc'); + expect(onSort).toHaveBeenCalledWith('name', 'asc', 'push'); }); it('can sort by description without label', () => { @@ -308,7 +360,7 @@ describe('Card View', () => { // Click to sort ascending button.simulate('click'); - expect(onSort).toHaveBeenCalledWith('name', 'asc'); + expect(onSort).toHaveBeenCalledWith('name', 'asc', 'push'); }); it('page changed when sort applied', () => { @@ -319,7 +371,7 @@ describe('Card View', () => { // Click to sort ascending button.simulate('click'); - expect(onSort).toHaveBeenCalledWith('title', 'asc'); + expect(onSort).toHaveBeenCalledWith('title', 'asc', 'push'); expect(onPageChange).toHaveBeenCalledWith(1); }); @@ -340,23 +392,23 @@ describe('Card View', () => { }; const wrapper = createWrapper(updatedProps); expect( - wrapper.find('[aria-label="card-info-visitId"]').first().text() + wrapper.find('[data-testid="card-info-visitId"]').first().text() ).toEqual('visitId:'); expect( - wrapper.find('[aria-label="card-info-data-visitId"]').first().text() + wrapper.find('[data-testid="card-info-data-visitId"]').first().text() ).toEqual('1'); expect( - wrapper.find('[aria-label="card-info-Name"]').first().text() + wrapper.find('[data-testid="card-info-Name"]').first().text() ).toEqual('Name:'); expect( - wrapper.find('[aria-label="card-info-data-Name"]').first().text() + wrapper.find('[data-testid="card-info-data-Name"]').first().text() ).toEqual('Content'); // Click to sort ascending const button = wrapper.find(ListItemText).first(); expect(button.text()).toEqual('visitId'); button.simulate('click'); - expect(onSort).toHaveBeenCalledWith('visitId', 'asc'); + expect(onSort).toHaveBeenCalledWith('visitId', 'asc', 'push'); }); it('information displays with content that has no tooltip', () => { @@ -376,10 +428,10 @@ describe('Card View', () => { const wrapper = createWrapper(updatedProps); expect( - wrapper.find('[aria-label="card-info-Name"]').first().text() + wrapper.find('[data-testid="card-info-Name"]').first().text() ).toEqual('Name:'); expect( - wrapper.find('[aria-label="card-info-data-Name"]').first().text() + wrapper.find('[data-testid="card-info-data-Name"]').first().text() ).toEqual('Content'); }); @@ -420,6 +472,30 @@ describe('Card View', () => { expect(onPageChange).toHaveBeenNthCalledWith(1, 1); }); + it('results changed when max results exceeded', () => { + const updatedProps = { + ...props, + totalDataCount: 100, + resultsOptions: [10, 20, 30], + results: 40, + page: 1, + }; + createWrapper(updatedProps); + expect(onResultsChange).toHaveBeenNthCalledWith(1, 10); + }); + + it('results changed when max results exceeded when total data count is between 10 and 20)', () => { + const updatedProps = { + ...props, + totalDataCount: 14, + resultsOptions: [10, 20, 30], + results: 30, + page: 1, + }; + createWrapper(updatedProps); + expect(onResultsChange).toHaveBeenNthCalledWith(1, 10); + }); + it('selector sends pushQuery with results', () => { const updatedProps = { ...props, @@ -432,7 +508,7 @@ describe('Card View', () => { .find(Select) .props() .onChange?.({ target: { value: 2 } }); - expect(onResultsChange).toHaveBeenNthCalledWith(1, 2); + expect(onResultsChange).toHaveBeenNthCalledWith(2, 2); }); it('selector sends pushQuery with results and page', () => { @@ -447,7 +523,7 @@ describe('Card View', () => { .find(Select) .props() .onChange?.({ target: { value: 3 } }); - expect(onResultsChange).toHaveBeenNthCalledWith(1, 3); + expect(onResultsChange).toHaveBeenNthCalledWith(2, 3); expect(onPageChange).toHaveBeenNthCalledWith(1, 1); }); }); diff --git a/packages/datagateway-common/src/card/cardView.component.tsx b/packages/datagateway-common/src/card/cardView.component.tsx index 169b583ef..dd21766c2 100644 --- a/packages/datagateway-common/src/card/cardView.component.tsx +++ b/packages/datagateway-common/src/card/cardView.component.tsx @@ -15,12 +15,20 @@ import { TableSortLabel, Typography, Select, + Divider, } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import { Pagination } from '@material-ui/lab'; import ArrowTooltip from '../arrowtooltip.component'; -import { Entity, Filter, Order, SortType, FiltersType } from '../app.types'; +import { + Entity, + Filter, + Order, + SortType, + FiltersType, + UpdateMethod, +} from '../app.types'; import React from 'react'; import { useTranslation } from 'react-i18next'; import AdvancedFilter from './advancedFilter.component'; @@ -28,9 +36,6 @@ import EntityCard, { EntityImageDetails } from './entityCard.component'; const useCardViewStyles = makeStyles((theme: Theme) => createStyles({ - root: { - backgroundColor: theme.palette.background.paper, - }, formControl: { margin: theme.spacing(1), minWidth: 120, @@ -69,9 +74,20 @@ export interface CardViewDetails { // Filter and sort options. filterComponent?: (label: string, dataKey: string) => React.ReactElement; disableSort?: boolean; + defaultSort?: Order; noTooltip?: boolean; } +export interface CVCustomFilters { + label: string; + dataKey: string; + filterItems: { + name: string; + count: string; + }[]; + prefixLabel?: boolean; +} + type CVPaginationPosition = 'top' | 'bottom' | 'both'; export interface CardViewProps { @@ -87,7 +103,11 @@ export interface CardViewProps { onPageChange: (page: number) => void; onFilter: (filter: string, data: Filter | null) => void; onResultsChange: (page: number) => void; - onSort: (sort: string, order: Order | null) => void; + onSort: ( + sort: string, + order: Order | null, + updateMethod: UpdateMethod + ) => void; // Props to get title, description of the card // represented by data. @@ -100,7 +120,7 @@ export interface CardViewProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any buttons?: ((data?: any) => React.ReactNode)[]; - customFilters?: { label: string; dataKey: string; filterItems: string[] }[]; + customFilters?: CVCustomFilters[]; resultsOptions?: number[]; image?: EntityImageDetails; @@ -111,7 +131,10 @@ interface CVFilterInfo { [filterKey: string]: { label: string; items: { - [data: string]: boolean; + [data: string]: { + selected: boolean; + count: number; + }; }; hasSelectedItems: boolean; }; @@ -151,11 +174,11 @@ function CVPagination( hideNextButton={page >= numPages} showLastButton aria-label="pagination" + className="tour-dataview-pagination" /> ); } -// TODO: Hide/disable pagination and sort/filters if no results retrieved. const CardView = (props: CardViewProps): React.ReactElement => { const classes = useCardViewStyles(); @@ -216,6 +239,7 @@ const CardView = (props: CardViewProps): React.ReactElement => { const [selectedFilters, setSelectedFilters] = React.useState< CVSelectedFilter[] >([]); + const [filterUpdate, setFilterUpdate] = React.useState(false); // Sort. const [cardSort, setCardSort] = React.useState(null); @@ -224,6 +248,30 @@ const CardView = (props: CardViewProps): React.ReactElement => { (description ? !description.disableSort : false) || (information ? information.some((i) => !i.disableSort) : false); + //Apply default sort on page load (but only if not already defined in URL params) + //This will apply them in the order of title, description and information, wherever + //defaultSort has been provided + React.useEffect(() => { + if (title.defaultSort !== undefined && sort[title.dataKey] === undefined) + onSort(title.dataKey, title.defaultSort, 'replace'); + if ( + description && + description.defaultSort !== undefined && + sort[description.dataKey] === undefined + ) + onSort(description.dataKey, description.defaultSort, 'replace'); + if (information) { + information.forEach((element: CardViewDetails) => { + if ( + element.defaultSort !== undefined && + sort[element.dataKey] === undefined + ) + onSort(element.dataKey, element.defaultSort, 'replace'); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Get sort information from title, description and information lists. React.useEffect(() => { let sortList: CVSort[] = []; @@ -281,7 +329,10 @@ const CardView = (props: CardViewProps): React.ReactElement => { items: filter.filterItems.reduce( (o, item) => ({ ...o, - [item]: getSelectedFilter(filter.dataKey, item), + [item.name]: { + selected: getSelectedFilter(filter.dataKey, item.name), + count: item.count, + }, }), {} ), @@ -291,14 +342,14 @@ const CardView = (props: CardViewProps): React.ReactElement => { // Update the selected count for each filter. const selectedItems = Object.values(data[filter.dataKey].items).find( - (v) => v === true + (v) => v.selected === true ); if (selectedItems) { data[filter.dataKey].hasSelectedItems = true; } return data; }, {}) - : []; + : {}; setFiltersInfo(info); }, [customFilters, filters]); @@ -312,7 +363,7 @@ const CardView = (props: CardViewProps): React.ReactElement => { filterKey: filterKey, label: info.label, items: Object.entries(info.items) - .filter(([, v]) => v) + .filter(([, v]) => v.selected) .map(([i]) => i), }), [] @@ -322,6 +373,10 @@ const CardView = (props: CardViewProps): React.ReactElement => { } }, [filtersInfo]); + React.useEffect(() => { + if (filterUpdate && loadedData) setFilterUpdate(false); + }, [filterUpdate, totalDataCount, loadedData]); + const changeFilter = ( filterKey: string, filterValue: string, @@ -398,7 +453,27 @@ const CardView = (props: CardViewProps): React.ReactElement => { loadedCount, ]); + // Handle (max) result + React.useEffect(() => { + if (loadedCount && props.results) { + if ( + resOptions + .filter( + (n, i) => + (i === 0 && totalDataCount > n) || + (i > 0 && totalDataCount > resOptions[i - 1]) + ) + .includes(props.results) === true + ) { + onResultsChange(props.results); + } else { + onResultsChange(resOptions[0]); + } + } + }, [onResultsChange, resOptions, loadedCount, totalDataCount, props.results]); + const [t] = useTranslation(); + const hasFilteredResults = loadedData && (filterUpdate || totalDataCount > 0); return ( @@ -423,7 +498,7 @@ const CardView = (props: CardViewProps): React.ReactElement => { )} - {totalDataCount > 0 && loadedData && ( + {(filterUpdate || totalDataCount > 0) && ( { name: 'Max Results', id: 'select-max-results', }} + className="tour-dataview-max-results" onChange={(e) => { const newResults = e.target.value as number; const newMaxPage = ~~( @@ -492,8 +568,8 @@ const CardView = (props: CardViewProps): React.ReactElement => { )} - {loadedData && ( - + + {(hasSort || customFilters || !hasFilteredResults) && ( { style={{ marginLeft: 0, marginRight: 0, marginBottom: 0 }} > {/* Sorting options */} - {hasSort && totalDataCount > 0 && ( + {hasSort && (filterUpdate || totalDataCount > 0) && ( @@ -516,14 +592,22 @@ const CardView = (props: CardViewProps): React.ReactElement => { {/* Show all the available sort options: title, description and the further information (if provided) */} - + {cardSort && cardSort.map((s, i) => ( { - onSort(s.dataKey, nextSortDirection(s.dataKey)); + onSort( + s.dataKey, + nextSortDirection(s.dataKey), + 'push' + ); if (page !== 1) { onPageChange(1); } @@ -558,7 +642,7 @@ const CardView = (props: CardViewProps): React.ReactElement => { )} {/* Filtering options */} - {customFilters && ( + {customFilters && (filterUpdate || totalDataCount > 0) && ( @@ -587,25 +671,42 @@ const CardView = (props: CardViewProps): React.ReactElement => { aria-label="filter-by-list" > {Object.entries(filter.items).map( - ([item, selected], valueIndex) => ( + ([name, data], valueIndex) => ( { - changeFilter(filterKey, item); + changeFilter(filterKey, name); + setFilterUpdate(true); }} - aria-label={`Filter by ${filter.label} ${item}`} + aria-label={`Filter by ${filter.label} ${name}`} > - - - {item} - - - } - /> +
+ + + {name} + + + } + /> +
+ {data.count && ( + + )} + {data.count && ( + + {data.count} + + )}
) )} @@ -622,74 +723,68 @@ const CardView = (props: CardViewProps): React.ReactElement => { )}
+ )} - {/* Card data */} - - {/* Selected filters array */} - {selectedFilters.length > 0 && ( -
- {selectedFilters.map((filter, filterIndex) => ( -
  • - {filter.items.map((item, itemIndex) => ( - { - changeFilter(filter.filterKey, item, true); - }} - /> - ))} -
  • - ))} -
    - )} - - {/* List of cards */} - {totalDataCount > 0 ? ( - - {/* TODO: The width of the card should take up more room when - there is no information or buttons. */} - {data.map((entity, index) => { - return ( - - {/* Create an individual card */} - - - ); - })} - - ) : ( - - - - {t('loading.filter_message')} - - - - )} -
    + {/* Card data */} + + {/* Selected filters array */} + {selectedFilters.length > 0 && (filterUpdate || totalDataCount > 0) && ( +
      + {selectedFilters.map((filter, filterIndex) => ( +
    • + {filter.items.map((item, itemIndex) => ( + { + changeFilter(filter.filterKey, item, true); + setFilterUpdate(true); + }} + /> + ))} +
    • + ))} +
    + )} + + {/* List of cards */} + {hasFilteredResults ? ( + + {data.map((entity, index) => { + return ( + + {/* Create an individual card */} + + + ); + })} + + ) : ( + + + + {t('loading.filter_message')} + + + + )}
    - )} +
    {/* Pagination */} - {totalDataCount > 0 && - loadedData && + {(filterUpdate || totalDataCount > 0) && (paginationPos === 'bottom' || paginationPos === 'both') && ( {CVPagination(page, maxPage, onPageChange)} diff --git a/packages/datagateway-common/src/card/entityCard.component.test.tsx b/packages/datagateway-common/src/card/entityCard.component.test.tsx index 4d423071b..c1e901b21 100644 --- a/packages/datagateway-common/src/card/entityCard.component.test.tsx +++ b/packages/datagateway-common/src/card/entityCard.component.test.tsx @@ -61,6 +61,7 @@ describe('Card', () => { expect(wrapper.find('[aria-label="card-title"]').text()).toEqual( 'Test Title' ); + expect(wrapper.find('ArrowTooltip').prop('title')).toEqual('Test Title'); }); it('renders with a description', () => { @@ -143,22 +144,29 @@ describe('Card', () => { information={[ { dataKey: 'visitId', - content: () => '1', + content: function Test() { + return {'1'}; + }, icon: function Icon() { return ICON - ; }, - noTooltip: true, }, ]} /> ); - expect(wrapper.exists("[aria-label='card-info-visitId']")).toBe(true); - expect(wrapper.find("[aria-label='card-info-visitId']").text()).toEqual( + expect(wrapper.exists("[data-testid='card-info-visitId']")).toBe(true); + expect(wrapper.find("[data-testid='card-info-visitId']").text()).toEqual( 'visitId:' ); - expect(wrapper.exists("[aria-label='card-info-data-visitId']")).toBe(true); + expect(wrapper.exists("[data-testid='card-info-data-visitId']")).toBe(true); + expect( + wrapper.find("[data-testid='card-info-data-visitId']").find('b').text() + ).toEqual('1'); expect( - wrapper.find("[aria-label='card-info-data-visitId']").text() + wrapper + .find("[data-testid='card-info-data-visitId']") + .find('ArrowTooltip') + .prop('title') ).toEqual('1'); }); diff --git a/packages/datagateway-common/src/card/entityCard.component.tsx b/packages/datagateway-common/src/card/entityCard.component.tsx index 17e642983..e33295117 100644 --- a/packages/datagateway-common/src/card/entityCard.component.tsx +++ b/packages/datagateway-common/src/card/entityCard.component.tsx @@ -13,23 +13,23 @@ import { } from '@material-ui/core'; import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import ArrowTooltip from '../arrowtooltip.component'; +import ArrowTooltip, { getTooltipText } from '../arrowtooltip.component'; import React from 'react'; import { useTranslation } from 'react-i18next'; import hexToRbga from 'hex-to-rgba'; import { nestedValue } from '../api'; import { Entity } from '../app.types'; -import { CardViewDetails } from './cardView.component'; +import { CardViewDetails, CVCustomFilters } from './cardView.component'; const useCardStyles = makeStyles((theme: Theme) => { - // TODO: Remove use of "vw" here + // TODO: Remove use of "vw" here? // NOTE: This is width of the main content // (this also matches the description shadow width). // Change this width in accordance with the maxWidth in root class. const mainWidth = '45vw'; // Expected width of info labels to prevent misalignment due to newlines const labelWidth = '15ch'; - // TODO: Remove use of "vw" here + // TODO: Remove use of "vw" here? const infoDataMaxWidth = '10vw'; // Transparent and opaque values for the background theme (used in the 'show more' shadow gradient) @@ -62,7 +62,7 @@ const useCardStyles = makeStyles((theme: Theme) => { flexGrow: 1, flexShrink: 1, flexBasis: mainWidth, - // TODO: Remove use of "vw" here + // TODO: Remove use of "vw" here? minWidth: '30vw', paddingRight: '10px', }, @@ -181,8 +181,8 @@ interface EntityCardProps { moreInformation?: (data: Entity) => React.ReactNode; buttons?: ((data: Entity) => React.ReactNode)[]; + customFilters?: CVCustomFilters[]; image?: EntityImageDetails; - customFilters?: { label: string; dataKey: string; filterItems: string[] }[]; } const EntityCard = React.memo( @@ -215,6 +215,9 @@ const EntityCard = React.memo( : nestedValue(entity, details.dataKey), noTooltip: details.noTooltip, })) + // TODO: The only issue this might cause if someone sorts/filters + // by this field and a card with no content for this field + // would not show up on the card. // Filter afterwards to only show content with information. .filter((v) => v.content) // Add in tooltips to the content we have filtered. @@ -222,7 +225,7 @@ const EntityCard = React.memo( ...details, // If we use custom content we can choose to not show a tooltip. content: !details.noTooltip ? ( - + {details.content} ) : ( @@ -231,9 +234,11 @@ const EntityCard = React.memo( })); const buttons = props.buttons?.map((button) => button(entity)); - const tags = props.customFilters?.map((f) => - nestedValue(entity, f.dataKey) - ); + const tags = props.customFilters?.map((f) => ({ + data: nestedValue(entity, f.dataKey), + label: f.label, + prefixLabel: f.prefixLabel ?? false, + })); // The default collapsed height for card description is 100px. const defaultCollapsedHeight = 100; @@ -272,7 +277,8 @@ const EntityCard = React.memo( const [t] = useTranslation(); return ( - + + {/* TODO: Check width and sizing of having image on card under different circumstances */} {/* We allow for additional width when having an image in the card (see card styles). */} {image && ( - {/* TODO: Delay not consistent between cards? */} {/* Divider is optional based on if there is information/buttons. */} - {(information || buttons) && ( + {((information && information.length > 0) || + (buttons && buttons.length > 0)) && ( // Set flexItem to true to allow it to show when flex direction is column for content. )} @@ -385,7 +391,7 @@ const EntityCard = React.memo( const { label, icon: Icon } = info; return ( {Icon && } @@ -400,7 +406,7 @@ const EntityCard = React.memo( {information.map( (info: EntityCardDetails, index: number) => (
    {info.content && info.content} @@ -438,6 +444,7 @@ const EntityCard = React.memo( variant="outlined" expanded={isMoreInfoCollapsed} onChange={(e, expanded) => setMoreInfoCollapsed(expanded)} + className="tour-dataview-expand" > {tags.map((v, i) => ( ))}
    diff --git a/packages/datagateway-common/src/detailsPanels/__snapshots__/datafileDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/__snapshots__/datafileDetailsPanel.component.test.tsx.snap new file mode 100644 index 000000000..ea8f84173 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/__snapshots__/datafileDetailsPanel.component.test.tsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Datafile details panel component renders correctly 1`] = ` + + + + + + + + + + datafiles.size + + + + Unknown + + + + + + datafiles.location + + + + + + +`; diff --git a/packages/datagateway-common/src/detailsPanels/__snapshots__/datasetDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/__snapshots__/datasetDetailsPanel.component.test.tsx.snap new file mode 100644 index 000000000..0572b24f5 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/__snapshots__/datasetDetailsPanel.component.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dataset details panel component renders correctly 1`] = ` + + + + + + + + + + datasets.description + + + + + + +`; diff --git a/packages/datagateway-common/src/detailsPanels/__snapshots__/investigationDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/__snapshots__/investigationDetailsPanel.component.test.tsx.snap new file mode 100644 index 000000000..0c0f98fa3 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/__snapshots__/investigationDetailsPanel.component.test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Investigation details panel component renders correctly 1`] = ` + + + + + Test 1 + + + + + + + investigations.details.name + + + + Test 1 + + + + + + investigations.details.start_date + + + + 2019-06-10 + + + + + + investigations.details.end_date + + + + 2019-06-11 + + + + +`; diff --git a/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.test.tsx new file mode 100644 index 000000000..7ab0111b7 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { createShallow } from '@material-ui/core/test-utils'; +import { Datafile } from '../app.types'; +import DatafileDetailsPanel from './datafileDetailsPanel.component'; + +describe('Datafile details panel component', () => { + let shallow; + let rowData: Datafile; + const detailsPanelResize = jest.fn(); + + beforeEach(() => { + shallow = createShallow(); + rowData = [ + { + id: 1, + name: 'Test 1', + location: '/test1', + fileSize: 1, + modTime: '2019-07-23', + createTime: '2019-07-23', + }, + ]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.tsx new file mode 100644 index 000000000..1b097bfb1 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/datafileDetailsPanel.component.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { + Typography, + Grid, + createStyles, + makeStyles, + Theme, + Divider, +} from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { Datafile, Entity } from '../app.types'; +import { formatBytes } from '../table/cellRenderers/cellContentRenderers'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: theme.spacing(2), + }, + divider: { + marginBottom: theme.spacing(2), + }, + }) +); + +interface DatafileDetailsPanelProps { + rowData: Entity; + detailsPanelResize?: () => void; +} + +const DatafileDetailsPanel = ( + props: DatafileDetailsPanelProps +): React.ReactElement => { + const { detailsPanelResize } = props; + + const classes = useStyles(); + const [t] = useTranslation(); + const datafileData = props.rowData as Datafile; + + React.useLayoutEffect(() => { + if (detailsPanelResize) detailsPanelResize(); + }, [detailsPanelResize]); + + return ( + + + + {datafileData.name} + + + + + {t('datafiles.size')} + + {formatBytes(datafileData.fileSize)} + + + + {t('datafiles.location')} + + {datafileData.location} + + + + ); +}; + +export default DatafileDetailsPanel; diff --git a/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.test.tsx new file mode 100644 index 000000000..2e3bd89de --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { createShallow } from '@material-ui/core/test-utils'; +import { Dataset } from '../app.types'; +import DatasetDetailsPanel from './datasetDetailsPanel.component'; + +describe('Dataset details panel component', () => { + let shallow; + let rowData: Dataset; + const detailsPanelResize = jest.fn(); + + beforeEach(() => { + shallow = createShallow(); + rowData = [ + { + id: 1, + name: 'Test 1', + size: 1, + modTime: '2019-07-23', + createTime: '2019-07-23', + }, + ]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.tsx new file mode 100644 index 000000000..4f904d104 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/datasetDetailsPanel.component.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { + Typography, + Grid, + createStyles, + makeStyles, + Theme, + Divider, +} from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { Dataset, Entity } from '../app.types'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: theme.spacing(2), + }, + divider: { + marginBottom: theme.spacing(2), + }, + }) +); + +interface DatasetDetailsPanelProps { + rowData: Entity; + detailsPanelResize?: () => void; +} + +const DatasetDetailsPanel = ( + props: DatasetDetailsPanelProps +): React.ReactElement => { + const { detailsPanelResize } = props; + + const classes = useStyles(); + const [t] = useTranslation(); + const datasetData = props.rowData as Dataset; + + React.useLayoutEffect(() => { + if (detailsPanelResize) detailsPanelResize(); + }, [detailsPanelResize]); + + return ( + + + + {datasetData.name} + + + + + {t('datasets.description')} + + {datasetData.description} + + + + ); +}; + +export default DatasetDetailsPanel; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/datafileDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/dls/__snapshots__/datafileDetailsPanel.component.test.tsx.snap similarity index 100% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/datafileDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/dls/__snapshots__/datafileDetailsPanel.component.test.tsx.snap diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/datasetDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/dls/__snapshots__/datasetDetailsPanel.component.test.tsx.snap similarity index 100% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/datasetDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/dls/__snapshots__/datasetDetailsPanel.component.test.tsx.snap diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap similarity index 60% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap index fe5849e9a..fc3c2d7f1 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap +++ b/packages/datagateway-common/src/detailsPanels/dls/__snapshots__/visitDetailsPanel.component.test.tsx.snap @@ -1,5 +1,97 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Visit details panel component checks if multiple publications result in change of title to plural version 1`] = ` +Object { + "detailsPanelResize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "rowData": Object { + "doi": "doi 1", + "endDate": "2019-06-11", + "id": 1, + "investigationInstruments": Array [ + Object { + "id": 1, + "instrument": Object { + "id": 3, + "name": "LARMOR", + }, + }, + ], + "name": "Test 1", + "publications": Array [ + Object { + "fullReference": "Test publication", + "id": 8, + }, + Object { + "fullReference": "Test publication 1", + "id": 9, + }, + ], + "size": 1, + "startDate": "2019-06-10", + "summary": "foo bar", + "title": "Test 1", + "visitId": "1", + }, +} +`; + +exports[`Visit details panel component checks if multiple samples result in change of title to plural version 1`] = ` +Object { + "detailsPanelResize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "rowData": Object { + "doi": "doi 1", + "endDate": "2019-06-11", + "id": 1, + "investigationInstruments": Array [ + Object { + "id": 1, + "instrument": Object { + "id": 3, + "name": "LARMOR", + }, + }, + ], + "name": "Test 1", + "samples": Array [ + Object { + "id": 7, + "name": "Test sample", + }, + Object { + "id": 8, + "name": "Test sample 1", + }, + ], + "size": 1, + "startDate": "2019-06-10", + "summary": "foo bar", + "title": "Test 1", + "visitId": "1", + }, +} +`; + exports[`Visit details panel component gracefully handles InvestigationUsers without Users 1`] = ` Object { "detailsPanelResize": [MockFunction] { diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.test.tsx similarity index 86% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.test.tsx index 315dc222c..a0548677c 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.test.tsx @@ -1,19 +1,12 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import DatafilesDetailsPanel from './datafileDetailsPanel.component'; -import { Datafile, useDatafileDetails } from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { Datafile } from '../../app.types'; +import { useDatafileDetails } from '../../api/datafiles'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useDatafileDetails: jest.fn(), - }; -}); +jest.mock('../../api/datafiles'); describe('Datafile details panel component', () => { let mount; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.tsx similarity index 90% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.tsx index 36290a49b..2b2d14d6e 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/dls/datafileDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/dls/datafileDetailsPanel.component.tsx @@ -1,10 +1,4 @@ import React from 'react'; -import { - Entity, - Datafile, - useDatafileDetails, - formatBytes, -} from 'datagateway-common'; import { Typography, Grid, @@ -14,6 +8,9 @@ import { Divider, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { useDatafileDetails } from '../../api/datafiles'; +import { Datafile, Entity } from '../../app.types'; +import { formatBytes } from '../../table/cellRenderers/cellContentRenderers'; const useStyles = makeStyles((theme: Theme) => createStyles({ diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.test.tsx similarity index 92% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.test.tsx index 73320eadf..6402fd8fd 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.test.tsx @@ -1,25 +1,11 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import DatasetDetailsPanel from './datasetDetailsPanel.component'; -import { - Dataset, - DatasetType, - useDatasetDetails, - useDatasetSize, -} from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { useDatasetDetails, useDatasetSize } from '../../api/datasets'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useDatasetDetails: jest.fn(), - useDatasetSize: jest.fn(), - }; -}); +jest.mock('../../api/datasets'); describe('Dataset details panel component', () => { let mount; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.tsx similarity index 95% rename from packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.tsx index 247c9cf76..ec495fd14 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/dls/datasetDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/dls/datasetDetailsPanel.component.tsx @@ -1,11 +1,4 @@ import React from 'react'; -import { - Entity, - Dataset, - formatBytes, - useDatasetDetails, - useDatasetSize, -} from 'datagateway-common'; import { Typography, Grid, @@ -18,6 +11,9 @@ import { Button, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { useDatasetDetails, useDatasetSize } from '../../api/datasets'; +import { Dataset, Entity } from '../../app.types'; +import { formatBytes } from '../../table/cellRenderers/cellContentRenderers'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -134,7 +130,7 @@ const DatasetDetailsPanel = (
    - {datasetData.size ? ( + {datasetData.size !== undefined ? ( formatBytes(datasetData.size) ) : (
    )} @@ -230,18 +237,26 @@ const VisitDetailsPanel = ( hidden={value !== 'samples'} > - {investigationData.samples.map((sample) => { - return ( - - - {t('investigations.details.samples.name')} - - - {sample.name} - - - ); - })} + + {t('investigations.details.samples.name', { + count: investigationData.samples.length, + })} + + {investigationData.samples.length > 0 ? ( + investigationData.samples.map((sample) => { + return ( + + + {sample.name} + + + ); + }) + ) : ( + + {t('investigations.details.samples.no_samples')} + + )}
    )} @@ -253,18 +268,28 @@ const VisitDetailsPanel = ( hidden={value !== 'publications'} > - {investigationData.publications.map((publication) => { - return ( - - - {t('investigations.details.publications.reference')} - - - {publication.fullReference} - - - ); - })} + + {t('investigations.details.publications.reference', { + count: investigationData.publications.length, + })} + + {investigationData.publications.length > 0 ? ( + investigationData.publications.map((publication) => { + return ( + + + {publication.fullReference} + + + ); + }) + ) : ( + + + {t('investigations.details.publications.no_publications')} + + + )}
    )} diff --git a/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.test.tsx new file mode 100644 index 000000000..974ed58a3 --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { createShallow } from '@material-ui/core/test-utils'; +import InvestigationDetailsPanel from './investigationDetailsPanel.component'; +import { Investigation } from '../app.types'; + +describe('Investigation details panel component', () => { + let shallow; + let rowData: Investigation; + const detailsPanelResize = jest.fn(); + + beforeEach(() => { + shallow = createShallow(); + rowData = { + id: 1, + title: 'Test 1', + name: 'Test 1', + summary: 'foo bar', + visitId: '1', + doi: 'doi 1', + size: 1, + investigationInstruments: [ + { + id: 1, + instrument: { + id: 3, + name: 'LARMOR', + }, + }, + ], + studyInvestigations: [ + { + id: 11, + study: { + id: 12, + pid: 'study pid', + }, + }, + ], + startDate: '2019-06-10', + endDate: '2019-06-11', + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.tsx new file mode 100644 index 000000000..03bb0323d --- /dev/null +++ b/packages/datagateway-common/src/detailsPanels/investigationDetailsPanel.component.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { + Typography, + Grid, + createStyles, + makeStyles, + Theme, + Divider, +} from '@material-ui/core'; +import { useTranslation } from 'react-i18next'; +import { Entity, Investigation } from '../app.types'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: theme.spacing(2), + }, + divider: { + marginBottom: theme.spacing(2), + }, + }) +); + +interface InvestigationDetailsPanelProps { + rowData: Entity; + detailsPanelResize?: () => void; +} + +const InvestigationDetailsPanel = ( + props: InvestigationDetailsPanelProps +): React.ReactElement => { + const { detailsPanelResize } = props; + + const classes = useStyles(); + const [t] = useTranslation(); + const investigationData = props.rowData as Investigation; + + React.useLayoutEffect(() => { + if (detailsPanelResize) detailsPanelResize(); + }, [detailsPanelResize]); + + return ( + + + + {investigationData.title} + + + + + + {t('investigations.details.name')} + + + {investigationData.name} + + + + + {t('investigations.details.start_date')} + + + {investigationData.startDate} + + + + + {t('investigations.details.end_date')} + + + {investigationData.endDate} + + + + ); +}; + +export default InvestigationDetailsPanel; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/datafileDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/isis/__snapshots__/datafileDetailsPanel.component.test.tsx.snap similarity index 100% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/datafileDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/isis/__snapshots__/datafileDetailsPanel.component.test.tsx.snap diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/datasetDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/isis/__snapshots__/datasetDetailsPanel.component.test.tsx.snap similarity index 100% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/datasetDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/isis/__snapshots__/datasetDetailsPanel.component.test.tsx.snap diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/instrumentDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/isis/__snapshots__/instrumentDetailsPanel.component.test.tsx.snap similarity index 100% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/instrumentDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/isis/__snapshots__/instrumentDetailsPanel.component.test.tsx.snap diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap b/packages/datagateway-common/src/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap similarity index 60% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap rename to packages/datagateway-common/src/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap index 354290d20..62644183a 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap +++ b/packages/datagateway-common/src/detailsPanels/isis/__snapshots__/investigationDetailsPanel.component.test.tsx.snap @@ -1,5 +1,115 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Investigation details panel component checks if multiple publications result in change of title to plural version 1`] = ` +Object { + "detailsPanelResize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "rowData": Object { + "doi": "doi 1", + "endDate": "2019-06-11", + "id": 1, + "investigationInstruments": Array [ + Object { + "id": 1, + "instrument": Object { + "id": 3, + "name": "LARMOR", + }, + }, + ], + "name": "Test 1", + "publications": Array [ + Object { + "fullReference": "Test publication", + "id": 8, + }, + Object { + "fullReference": "Test publication 1", + "id": 9, + }, + ], + "size": 1, + "startDate": "2019-06-10", + "studyInvestigations": Array [ + Object { + "id": 11, + "study": Object { + "id": 12, + "pid": "study pid", + }, + }, + ], + "summary": "foo bar", + "title": "Test 1", + "visitId": "1", + }, +} +`; + +exports[`Investigation details panel component checks if multiple samples result in change of title to plural version 1`] = ` +Object { + "detailsPanelResize": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + }, + "rowData": Object { + "doi": "doi 1", + "endDate": "2019-06-11", + "id": 1, + "investigationInstruments": Array [ + Object { + "id": 1, + "instrument": Object { + "id": 3, + "name": "LARMOR", + }, + }, + ], + "name": "Test 1", + "samples": Array [ + Object { + "id": 7, + "name": "Test sample", + }, + Object { + "id": 8, + "name": "Test sample 1", + }, + ], + "size": 1, + "startDate": "2019-06-10", + "studyInvestigations": Array [ + Object { + "id": 11, + "study": Object { + "id": 12, + "pid": "study pid", + }, + }, + ], + "summary": "foo bar", + "title": "Test 1", + "visitId": "1", + }, +} +`; + exports[`Investigation details panel component gracefully handles StudyInvestigations without Studies and InvestigationUsers without Users 1`] = ` Object { "detailsPanelResize": [MockFunction] { diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.test.tsx similarity index 91% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.test.tsx index 2a18fb49e..34d3a50e6 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.test.tsx @@ -1,19 +1,11 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import DatafilesDetailsPanel from './datafileDetailsPanel.component'; -import { Datafile, useDatafileDetails } from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { useDatafileDetails } from '../../api/datafiles'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useDatafileDetails: jest.fn(), - }; -}); +jest.mock('../../api/datafiles'); describe('Datafile details panel component', () => { let mount; @@ -188,4 +180,14 @@ describe('Datafile details panel component', () => { 'datafiles.details.description not provided' ); }); + + it('renders datafile parameters tab and text "No parameters" when no data is present', () => { + rowData.parameters = []; + const wrapper = createWrapper(); + expect( + wrapper + .find('[data-testid="datafile-details-panel-no-parameters"]') + .exists() + ).toBeTruthy(); + }); }); diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.tsx similarity index 62% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.tsx index effa1d260..69f22641f 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/datafileDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/datafileDetailsPanel.component.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Entity, Datafile, useDatafileDetails } from 'datagateway-common'; import { Typography, Grid, @@ -11,6 +10,8 @@ import { Tab, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { Datafile, Entity } from '../../app.types'; +import { useDatafileDetails } from '../../api/datafiles'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -126,52 +127,58 @@ const DatafileDetailsPanel = ( className={classes.root} direction="column" > - {datafileData.parameters.map((parameter) => { - if (parameter.type) { - switch (parameter.type.valueType) { - case 'STRING': - return ( - - - {parameter.type.name} - - - {parameter.stringValue} - - - ); - case 'NUMERIC': - return ( - - - {parameter.type.name} - - - {parameter.numericValue} - - - ); - case 'DATE_AND_TIME': - return ( - - - {parameter.type.name} - - - - {parameter.dateTimeValue && - parameter.dateTimeValue.split(' ')[0]} - - - - ); - default: - return null; + {datafileData.parameters.length > 0 ? ( + datafileData.parameters.map((parameter) => { + if (parameter.type) { + switch (parameter.type.valueType) { + case 'STRING': + return ( + + + {parameter.type.name} + + + {parameter.stringValue} + + + ); + case 'NUMERIC': + return ( + + + {parameter.type.name} + + + {parameter.numericValue} + + + ); + case 'DATE_AND_TIME': + return ( + + + {parameter.type.name} + + + + {parameter.dateTimeValue && + parameter.dateTimeValue.split(' ')[0]} + + + + ); + default: + return null; + } + } else { + return null; } - } else { - return null; - } - })} + }) + ) : ( + + {t('datafiles.details.parameters.no_parameters')} + + )}
    )} diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.test.tsx similarity index 92% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.test.tsx index fdbbec47f..a04989417 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.test.tsx @@ -1,19 +1,12 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import DatasetDetailsPanel from './datasetDetailsPanel.component'; -import { Dataset, DatasetType, useDatasetDetails } from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { Dataset, DatasetType } from '../../app.types'; +import { useDatasetDetails } from '../../api/datasets'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useDatasetDetails: jest.fn(), - }; -}); +jest.mock('../../api/datasets'); describe('Dataset details panel component', () => { let mount; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.tsx similarity index 97% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.tsx index 821b8c0ca..3cbace2e9 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/datasetDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/datasetDetailsPanel.component.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Entity, Dataset, useDatasetDetails } from 'datagateway-common'; import { Typography, Grid, @@ -11,6 +10,8 @@ import { Tab, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { useDatasetDetails } from '../../api/datasets'; +import { Dataset, Entity } from '../../app.types'; const useStyles = makeStyles((theme: Theme) => createStyles({ diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx similarity index 91% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx index d2366529c..9f7d6b63e 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.test.tsx @@ -1,19 +1,12 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import InstrumentDetailsPanel from './instrumentDetailsPanel.component'; -import { Instrument, useInstrumentDetails } from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { Instrument } from '../../app.types'; +import { useInstrumentDetails } from '../../api/instruments'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useInstrumentDetails: jest.fn(), - }; -}); +jest.mock('../../api/instruments'); describe('Instrument details panel component', () => { let mount; @@ -146,6 +139,14 @@ describe('Instrument details panel component', () => { expect(wrapper.find('InstrumentDetailsPanel').props()).toMatchSnapshot(); }); + it('renders users tab and text "No Scientists" when no data is present', () => { + rowData.instrumentScientists = []; + const wrapper = createWrapper(); + expect( + wrapper.find('[data-testid="instrument-details-panel-no-name"]').exists() + ).toBeTruthy(); + }); + it('Shows "No provided" incase of a null field', () => { const { description, type, url, ...amendedRowData } = rowData; diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.tsx similarity index 76% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.tsx index 9da36f13f..1aa221406 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/instrumentDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/instrumentDetailsPanel.component.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Entity, Instrument, useInstrumentDetails } from 'datagateway-common'; import { Typography, Grid, @@ -12,6 +11,8 @@ import { Link, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { Entity, Instrument } from '../../app.types'; +import { useInstrumentDetails } from '../../api/instruments'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -130,25 +131,33 @@ const InstrumentDetailsPanel = ( hidden={value !== 'users'} > - {instrumentData.instrumentScientists.map((instrumentScientist) => { - if (instrumentScientist.user) { - return ( - - - {t('instruments.details.instrument_scientists.name')} - - - - {instrumentScientist.user.fullName || - instrumentScientist.user.name} - - - - ); - } else { - return null; - } - })} + + {t('instruments.details.instrument_scientists.name', { + count: instrumentData.instrumentScientists.length, + })} + + {instrumentData.instrumentScientists.length > 0 ? ( + instrumentData.instrumentScientists.map((instrumentScientist) => { + if (instrumentScientist.user) { + return ( + + + + {instrumentScientist.user.fullName || + instrumentScientist.user.name} + + + + ); + } else { + return null; + } + }) + ) : ( + + {t('instruments.details.instrument_scientists.no_name')} + + )}
    )} diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.test.tsx b/packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.test.tsx similarity index 71% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.test.tsx rename to packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.test.tsx index 331fba256..c28ee4225 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.test.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.test.tsx @@ -1,19 +1,12 @@ import React from 'react'; import { createMount } from '@material-ui/core/test-utils'; import InvestigationDetailsPanel from './investigationDetailsPanel.component'; -import { Investigation, useInvestigationDetails } from 'datagateway-common'; import { QueryClient, QueryClientProvider } from 'react-query'; import { ReactWrapper } from 'enzyme'; +import { Investigation } from '../../app.types'; +import { useInvestigationDetails } from '../../api/investigations'; -jest.mock('datagateway-common', () => { - const originalModule = jest.requireActual('datagateway-common'); - - return { - __esModule: true, - ...originalModule, - useInvestigationDetails: jest.fn(), - }; -}); +jest.mock('../../api/investigations'); describe('Investigation details panel component', () => { let mount; @@ -118,6 +111,68 @@ describe('Investigation details panel component', () => { expect(wrapper.find('InvestigationDetailsPanel').props()).toMatchSnapshot(); }); + it('checks if multiple samples result in change of title to plural version', () => { + rowData.samples = [ + { + id: 7, + name: 'Test sample', + }, + { + id: 8, + name: 'Test sample 1', + }, + ]; + + const wrapper = createWrapper(); + expect(wrapper.find('InvestigationDetailsPanel').props()).toMatchSnapshot(); + }); + + it('checks if multiple publications result in change of title to plural version', () => { + rowData.publications = [ + { + id: 8, + fullReference: 'Test publication', + }, + { + id: 9, + fullReference: 'Test publication 1', + }, + ]; + + const wrapper = createWrapper(); + expect(wrapper.find('InvestigationDetailsPanel').props()).toMatchSnapshot(); + }); + + it('renders publications tab and text "No publications" when no data is present', () => { + rowData.publications = []; + const wrapper = createWrapper(); + expect( + wrapper + .find('[data-testid="investigation-details-panel-no-publications"]') + .exists() + ).toBeTruthy(); + }); + + it('renders samples tab and text "No samples" when no data is present', () => { + rowData.samples = []; + const wrapper = createWrapper(); + expect( + wrapper + .find('[data-testid="investigation-details-panel-no-samples"]') + .exists() + ).toBeTruthy(); + }); + + it('renders users tab and text "No users" when no data is present', () => { + rowData.investigationUsers = []; + const wrapper = createWrapper(); + expect( + wrapper + .find('[data-testid="investigation-details-panel-no-name"]') + .exists() + ).toBeTruthy(); + }); + it('calls useInvestigationDetails hook on load', () => { createWrapper(); expect(useInvestigationDetails).toHaveBeenCalledWith(rowData.id); @@ -167,6 +222,22 @@ describe('Investigation details panel component', () => { expect(detailsPanelResize).toHaveBeenCalledTimes(0); }); + it('displays DOI and renders the expected Link ', () => { + const wrapper = createWrapper(); + expect( + wrapper + .find('[data-testid="investigation-details-panel-doi-link"]') + .first() + .text() + ).toEqual('doi 1'); + expect( + wrapper + .find('[data-testid="investigation-details-panel-doi-link"]') + .first() + .prop('href') + ).toEqual('https://doi.org/doi 1'); + }); + it('gracefully handles StudyInvestigations without Studies and InvestigationUsers without Users', () => { rowData.studyInvestigations = [ { diff --git a/packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.tsx b/packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.tsx similarity index 69% rename from packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.tsx rename to packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.tsx index 210d58f63..379a7c883 100644 --- a/packages/datagateway-dataview/src/views/detailsPanels/isis/investigationDetailsPanel.component.tsx +++ b/packages/datagateway-common/src/detailsPanels/isis/investigationDetailsPanel.component.tsx @@ -1,9 +1,4 @@ import React from 'react'; -import { - Entity, - Investigation, - useInvestigationDetails, -} from 'datagateway-common'; import { Typography, Grid, @@ -13,9 +8,11 @@ import { Divider, Tabs, Tab, - Link, + Link as MuiLink, } from '@material-ui/core'; import { useTranslation } from 'react-i18next'; +import { useInvestigationDetails } from '../../api/investigations'; +import { Entity, Investigation } from '../../app.types'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -154,11 +151,12 @@ const InvestigationDetailsPanel = ( {t('investigations.details.pid')} - {studyInvestigation.study.pid} - + ); @@ -171,11 +169,16 @@ const InvestigationDetailsPanel = ( {t('investigations.details.doi')} - - {investigationData.doi && investigationData.doi !== 'null' - ? investigationData.doi - : `${t('investigations.details.doi')} not provided`} - + {investigationData.doi && investigationData.doi !== 'null' ? ( + + {investigationData.doi} + + ) : ( + {`${t('investigations.details.doi')} not provided`} + )} @@ -214,25 +217,33 @@ const InvestigationDetailsPanel = ( hidden={value !== 'users'} > - {investigationData.investigationUsers.map((investigationUser) => { - if (investigationUser.user) { - return ( - - - {t('investigations.details.users.name')} - - - - {investigationUser.user.fullName || - investigationUser.user.name} - - - - ); - } else { - return null; - } - })} + + {t('investigations.details.users.name', { + count: investigationData.investigationUsers.length, + })} + + {investigationData.investigationUsers.length > 0 ? ( + investigationData.investigationUsers.map((investigationUser) => { + if (investigationUser.user) { + return ( + + + + {investigationUser.user.fullName || + investigationUser.user.name} + + + + ); + } else { + return null; + } + }) + ) : ( + + {t('investigations.details.users.no_name')} + + )}
    )} @@ -244,18 +255,26 @@ const InvestigationDetailsPanel = ( hidden={value !== 'samples'} > - {investigationData.samples.map((sample) => { - return ( - - - {t('investigations.details.samples.name')} - - - {sample.name} - - - ); - })} + + {t('investigations.details.samples.name', { + count: investigationData.samples.length, + })} + + {investigationData.samples.length > 0 ? ( + investigationData.samples.map((sample) => { + return ( + + + {sample.name} + + + ); + }) + ) : ( + + {t('investigations.details.samples.no_samples')} + + )}
    )} @@ -267,18 +286,28 @@ const InvestigationDetailsPanel = ( hidden={value !== 'publications'} > - {investigationData.publications.map((publication) => { - return ( - - - {t('investigations.details.publications.reference')} - - - {publication.fullReference} - - - ); - })} + + {t('investigations.details.publications.reference', { + count: investigationData.publications.length, + })} + + {investigationData.publications.length > 0 ? ( + investigationData.publications.map((publication) => { + return ( + + + {publication.fullReference} + + + ); + }) + ) : ( + + + {t('investigations.details.publications.no_publications')} + + + )}
    )} diff --git a/packages/datagateway-common/src/homePage/__snapshots__/homePage.component.test.tsx.snap b/packages/datagateway-common/src/homePage/__snapshots__/homePage.component.test.tsx.snap index 05afb842d..54432ece1 100644 --- a/packages/datagateway-common/src/homePage/__snapshots__/homePage.component.test.tsx.snap +++ b/packages/datagateway-common/src/homePage/__snapshots__/homePage.component.test.tsx.snap @@ -5,108 +5,312 @@ exports[`Home page component homepage renders correctly 1`] = ` id="dg-homepage" >
    - + + + + + Data discovery + + and + + access + + + + + + for + + large-scale + + science facilities + + +
    -
    - - test-howLabel - + + + + + home_page.browse.title + + + home_page.browse.description1 + + + + + DataGateway + + focuses on providing data discovery and data access functionality to the data. + + + + + home_page.browse.button + + + + + +
    +
    +
    + + + - - test-exploreLabel - - test-exploreLabel - - test-exploreDescription - + + + + + + home_page.search.title + + + home_page.search.description + + + + home_page.search.button + + + + - - test-discoverLabel - - test-discoverLabel - - test-discoverDescription - + + + + + + home_page.download.title + + + home_page.download.description + + + + home_page.download.button + + + + - - test-downloadLabel - - test-downloadLabel - - test-downloadDescription - +
    + + + home_page.facility.title + + + home_page.facility.description + + + + home_page.facility.button + + + +
    +
    -
    +
    `; diff --git a/packages/datagateway-common/src/homePage/homePage.component.test.tsx b/packages/datagateway-common/src/homePage/homePage.component.test.tsx index 08e100cee..c92a16c25 100644 --- a/packages/datagateway-common/src/homePage/homePage.component.test.tsx +++ b/packages/datagateway-common/src/homePage/homePage.component.test.tsx @@ -10,19 +10,15 @@ describe('Home page component', () => { shallow = createShallow({ untilSelector: 'div' }); props = { - title: 'test-title', - howLabel: 'test-howLabel', - exploreLabel: 'test-exploreLabel', - exploreDescription: 'test-exploreDescription', - discoverLabel: 'test-discoverLabel', - discoverDescription: 'test-discoverDescription', - downloadLabel: 'test-downloadLabel', - downloadDescription: 'test-downloadDescription', logo: 'test-logo', - backgroundImage: 'test-backgroundImage', - exploreImage: 'test-exploreImage', - discoverImage: 'test-discoverImage', - downloadImage: 'test-downloadImage', + backgroundImage: 'test-bakcgroundImage', + greenSwirl1Image: 'test-greenSwirl1Image', + greenSwirl2Image: 'test-greenSwirl2Image', + decal1Image: 'test-decal1Image', + decal2Image: 'test-decal2Image', + decal2DarkImage: 'test-decal2DarkImage', + decal2DarkHCImage: 'test-Decal2DarkHCImage', + facilityImage: 'test-facilityImage', }; }); diff --git a/packages/datagateway-common/src/homePage/homePage.component.tsx b/packages/datagateway-common/src/homePage/homePage.component.tsx index ee9e3cdd1..061780237 100644 --- a/packages/datagateway-common/src/homePage/homePage.component.tsx +++ b/packages/datagateway-common/src/homePage/homePage.component.tsx @@ -1,178 +1,338 @@ import React from 'react'; import Typography from '@material-ui/core/Typography'; import { - withStyles, Theme, - WithStyles, Grid, createStyles, + Box, + Paper, + Button, + Avatar, + makeStyles, + fade, } from '@material-ui/core'; import { StyleRules } from '@material-ui/core/styles'; - -const styles = (theme: Theme): StyleRules => - createStyles({ - bigImage: { - height: 250, - width: '100%', - '& img': { - paddingLeft: 90, - paddingTop: 80, - height: 150, - float: 'left', - }, - }, - howItWorks: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - // TODO: Remove use of "vw" here - paddingLeft: '10vw', - paddingRight: '10vw', - paddingTop: 15, - backgroundColor: theme.palette.background.default, - }, - howItWorksTitle: { - fontWeight: 'bold', - color: theme.palette.text.primary, - paddingBottom: 20, - }, - howItWorksGridItem: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, - howItWorksGridItemTitle: { - color: '#FF6900', - fontWeight: 'bold', - paddingBottom: 10, - }, - howItWorksGridItemImage: { - height: 200, - width: 200, - borderRadius: 200 / 2, - paddingBottom: 10, - }, - howItWorksGridItemCaption: { - textAlign: 'center', - color: theme.palette.secondary.main, - }, - }); +import SearchIcon from '@material-ui/icons/Search'; +import DownloadIcon from '@material-ui/icons/GetApp'; +import { Trans, useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; export interface HomePageProps { - title: string; - logoLabel: string; - howLabel: string; - exploreLabel: string; - exploreDescription: string; - discoverLabel: string; - discoverDescription: string; - downloadLabel: string; - downloadDescription: string; logo: string; backgroundImage: string; - exploreImage: string; - discoverImage: string; - downloadImage: string; + greenSwirl1Image: string; + greenSwirl2Image: string; + decal1Image: string; + decal2Image: string; + decal2DarkImage: string; + decal2DarkHCImage: string; + facilityImage: string; } -type CombinedHomePageProps = HomePageProps & WithStyles; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const useStyles = (props: HomePageProps) => { + return makeStyles( + (theme: Theme): StyleRules => + createStyles({ + backgroundImage: { + backgroundImage: `url(${props.backgroundImage})`, + backgroundPosition: 'center 40%', + width: '100%', + height: 250, + }, + backgroundDecals: { + backgroundImage: `url(${props.greenSwirl1Image}), url(${props.decal1Image})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'top left, top right', + width: '100%', + height: 250, + }, + backgroundTitle: { + color: '#FFFFFF', + margin: 'auto', + fontSize: '48px', + fontWeight: 'lighter', + textAlign: 'center', + }, + contentBox: { + transform: 'translate(0px, -20px)', + marginLeft: '8%', + marginRight: '8%', + }, + paper: { + borderRadius: '4px', + marginBottom: theme.spacing(2), + height: '100%', + }, + bluePaper: { + borderRadius: '4px', + marginBottom: theme.spacing(2), + backgroundColor: '#003088', + height: '100%', + }, + paperContent: { + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-start', + height: '100%', + boxSizing: 'border-box', + }, + avatar: { + backgroundColor: '#1E5DF8', + color: '#FFFFFF', + width: '60px', + height: '60px', + marginBottom: theme.spacing(2), + }, + avatarIcon: { + transform: 'scale(1.75)', + }, + paperMainHeading: { + fontWeight: 'bold', + fontSize: '32px', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + color: (theme as any).colours?.homePage?.heading, + marginBottom: theme.spacing(2), + }, + paperHeading: { + fontWeight: 'bold', + fontSize: '24px', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + color: (theme as any).colours?.homePage?.heading, + marginBottom: theme.spacing(2), + }, + paperDescription: { + textAlign: 'left', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + color: (theme as any).colours?.contrastGrey, + marginBottom: theme.spacing(2), + }, + bluePaperHeading: { + fontWeight: 'bold', + fontSize: '24px', + color: '#FFFFFF', + marginBottom: theme.spacing(2), + }, + bluePaperDescription: { + textAlign: 'left', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + color: (theme as any).colours?.homePage?.blueDescription, + marginBottom: theme.spacing(2), + }, + browseBackground: { + backgroundImage: `url(${props.facilityImage})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'bottom right', + backgroundSize: 'cover', + width: '100%', + height: '100%', + borderRadius: '4px', + }, + browseDecal: { + backgroundImage: + theme.palette.type === 'light' + ? `url(${props.decal2Image})` + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + (theme as any).colours?.type === 'default' + ? `url(${props.decal2DarkImage})` + : `url(${props.decal2DarkHCImage})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'top left', + backgroundSize: 'auto 100%', + height: '100%', + }, + facilityDecal: { + backgroundImage: `url(${props.greenSwirl2Image})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'top right', + backgroundSize: 'auto 100%', + height: '100%', + }, + lightBlueButton: { + color: '#FFFFFF', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + backgroundColor: (theme as any).colours?.homePage?.blueButton, + '&:hover': { + //Check if null to avoid error when loading + // eslint-disable-next-line @typescript-eslint/no-explicit-any + backgroundColor: (theme as any).colours?.homePage?.blueButton + ? fade( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (theme as any).colours?.homePage?.blueButton, + 0.8 + ) + : '#FFFFFF', + }, + }, + }) + ); +}; + +const HomePage = (props: HomePageProps): React.ReactElement => { + const [t] = useTranslation(); + const classes = useStyles(props)(); -const HomePage = (props: CombinedHomePageProps): React.ReactElement => { return (
    -
    -
    - {props.logoLabel} -
    -
    -
    - - {props.howLabel} - - - - +
    + - - {props.exploreLabel} + + + Data discovery and access + - {props.exploreLabel} - - {props.exploreDescription} + + + for large-scale + science facilities + + +
    +
    + + + + + + + {t('home_page.browse.title')} + + + {t('home_page.browse.description1')} + + + + DataGateway focuses on providing data + discovery and data access functionality to the data. + + + + + + + + +
    +
    +
    +
    - - - {props.discoverLabel} - - {props.discoverLabel} - - {props.discoverDescription} - +
    + + + + + + + + + {t('home_page.search.title')} + + + {t('home_page.search.description')} + + + + + + - - - {props.downloadLabel} - - {props.downloadLabel} - - {props.downloadDescription} - + + + + + + + + {t('home_page.download.title')} + + + {t('home_page.download.description')} + + + + + + + + + +
    + + + {t('home_page.facility.title')} + + + {t('home_page.facility.description')} + + + + + +
    +
    -
    + ); }; -export default withStyles(styles)(HomePage); +export default HomePage; diff --git a/packages/datagateway-common/src/images/background-plain.jpg b/packages/datagateway-common/src/images/background-plain.jpg new file mode 100644 index 000000000..cb86ae10c Binary files /dev/null and b/packages/datagateway-common/src/images/background-plain.jpg differ diff --git a/packages/datagateway-common/src/images/background.jpg b/packages/datagateway-common/src/images/background.jpg index cb86ae10c..f2641a429 100644 Binary files a/packages/datagateway-common/src/images/background.jpg and b/packages/datagateway-common/src/images/background.jpg differ diff --git a/packages/datagateway-common/src/images/datagateway-logo.svg b/packages/datagateway-common/src/images/datagateway-logo.svg index d78ce9f3b..38e60b3a6 100755 --- a/packages/datagateway-common/src/images/datagateway-logo.svg +++ b/packages/datagateway-common/src/images/datagateway-logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/datgateway-white-text-blue-mark-logo.svg b/packages/datagateway-common/src/images/datgateway-white-text-blue-mark-logo.svg index 04a836632..17bda902a 100755 --- a/packages/datagateway-common/src/images/datgateway-white-text-blue-mark-logo.svg +++ b/packages/datagateway-common/src/images/datgateway-white-text-blue-mark-logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/decal1.svg b/packages/datagateway-common/src/images/decal1.svg new file mode 100644 index 000000000..4949ea2c7 --- /dev/null +++ b/packages/datagateway-common/src/images/decal1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/decal2-dark.svg b/packages/datagateway-common/src/images/decal2-dark.svg new file mode 100644 index 000000000..87c964d60 --- /dev/null +++ b/packages/datagateway-common/src/images/decal2-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/decal2-darkhc.svg b/packages/datagateway-common/src/images/decal2-darkhc.svg new file mode 100644 index 000000000..df97476e8 --- /dev/null +++ b/packages/datagateway-common/src/images/decal2-darkhc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/decal2.svg b/packages/datagateway-common/src/images/decal2.svg new file mode 100644 index 000000000..c720cf8b0 --- /dev/null +++ b/packages/datagateway-common/src/images/decal2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/datagateway-common/src/images/discover.jpg b/packages/datagateway-common/src/images/discover.jpg deleted file mode 100644 index 6b751547b..000000000 Binary files a/packages/datagateway-common/src/images/discover.jpg and /dev/null differ diff --git a/packages/datagateway-common/src/images/download.jpg b/packages/datagateway-common/src/images/download.jpg deleted file mode 100644 index 24eb15a10..000000000 Binary files a/packages/datagateway-common/src/images/download.jpg and /dev/null differ diff --git a/packages/datagateway-common/src/images/explore.jpg b/packages/datagateway-common/src/images/explore.jpg deleted file mode 100644 index 87e47eb81..000000000 Binary files a/packages/datagateway-common/src/images/explore.jpg and /dev/null differ diff --git a/packages/datagateway-common/src/images/facility.jpg b/packages/datagateway-common/src/images/facility.jpg new file mode 100644 index 000000000..00dce93cf Binary files /dev/null and b/packages/datagateway-common/src/images/facility.jpg differ diff --git a/packages/datagateway-common/src/images/green-swirl1.png b/packages/datagateway-common/src/images/green-swirl1.png new file mode 100644 index 000000000..62fc0e6ba Binary files /dev/null and b/packages/datagateway-common/src/images/green-swirl1.png differ diff --git a/packages/datagateway-common/src/images/green-swirl2.png b/packages/datagateway-common/src/images/green-swirl2.png new file mode 100644 index 000000000..bb8960a1a Binary files /dev/null and b/packages/datagateway-common/src/images/green-swirl2.png differ diff --git a/packages/datagateway-common/src/index.tsx b/packages/datagateway-common/src/index.tsx index 0a8fabfd4..5e78c895c 100644 --- a/packages/datagateway-common/src/index.tsx +++ b/packages/datagateway-common/src/index.tsx @@ -17,6 +17,7 @@ export { default as DataHeader } from './table/headerRenderers/dataHeader.compon export { default as TextColumnFilter, useTextFilter, + usePrincipalExperimenterFilter, } from './table/columnFilters/textColumnFilter.component'; export { default as DateColumnFilter, @@ -28,6 +29,7 @@ export { default as ExpandCellComponent } from './table/cellRenderers/expandCell export * from './table/cellRenderers/cellContentRenderers'; export { default as CardView } from './card/cardView.component'; +export type { CardViewDetails } from './card/cardView.component'; export { default as AdvancedFilter } from './card/advancedFilter.component'; export * from './state/actions/index'; @@ -47,11 +49,33 @@ export type DGCommonState = StateType; export * from './parseTokens'; export { default as handleICATError } from './handleICATError'; -export { default as ArrowTooltip } from './arrowtooltip.component'; +export { + default as ArrowTooltip, + getTooltipText, +} from './arrowtooltip.component'; export { default as Sticky } from './sticky.component'; export { default as DGThemeProvider } from './dgThemeProvider.component'; export { default as Mark } from './mark.component'; export { default as HomePage } from './homePage/homePage.component'; +export { default as AddToCartButton } from './views/addToCartButton.component'; +export { default as ViewCartButton } from './views/viewCartButton.component'; +export type { CartProps } from './views/viewCartButton.component'; +export { default as ViewButton } from './views/viewButton.component'; +export { default as ClearFiltersButton } from './views/clearFiltersButton.component'; +export { default as DownloadButton } from './views/downloadButton.component'; +export { default as SelectionAlert } from './views/selectionAlert.component'; + +export { default as ISISDatafileDetailsPanel } from './detailsPanels/isis/datafileDetailsPanel.component'; +export { default as ISISDatasetDetailsPanel } from './detailsPanels/isis/datasetDetailsPanel.component'; +export { default as ISISInstrumentDetailsPanel } from './detailsPanels/isis/instrumentDetailsPanel.component'; +export { default as ISISInvestigationDetailsPanel } from './detailsPanels/isis/investigationDetailsPanel.component'; +export { default as DLSDatafileDetailsPanel } from './detailsPanels/dls/datafileDetailsPanel.component'; +export { default as DLSDatasetDetailsPanel } from './detailsPanels/dls/datasetDetailsPanel.component'; +export { default as DLSVisitDetailsPanel } from './detailsPanels/dls/visitDetailsPanel.component'; +export { default as InvestigationDetailsPanel } from './detailsPanels/investigationDetailsPanel.component'; +export { default as DatasetDetailsPanel } from './detailsPanels/datasetDetailsPanel.component'; +export { default as DatafileDetailsPanel } from './detailsPanels/datasetDetailsPanel.component'; + // ReactDOM.render(, document.getElementById('root')); diff --git a/packages/datagateway-common/src/setupTests.tsx b/packages/datagateway-common/src/setupTests.tsx index 965946734..fd6a8c060 100644 --- a/packages/datagateway-common/src/setupTests.tsx +++ b/packages/datagateway-common/src/setupTests.tsx @@ -24,6 +24,9 @@ if (typeof window.URL.createObjectURL === 'undefined') { Object.defineProperty(window.URL, 'createObjectURL', { value: noOp }); } +// Add in ResizeObserver as it's not in Jest's environment +global.ResizeObserver = require('resize-observer-polyfill'); + // these are used for testing async actions export let actions: Action[] = []; export const resetActions = (): void => { diff --git a/packages/datagateway-common/src/state/actions/actions.types.tsx b/packages/datagateway-common/src/state/actions/actions.types.tsx index cd5871238..a386e37ab 100644 --- a/packages/datagateway-common/src/state/actions/actions.types.tsx +++ b/packages/datagateway-common/src/state/actions/actions.types.tsx @@ -17,6 +17,7 @@ export const InvalidateTokenType = `${CustomFrontendMessageType}:invalidate_toke export const RegisterRouteType = `${CustomFrontendMessageType}:register_route`; export const RequestPluginRerenderType = `${CustomFrontendMessageType}:plugin_rerender`; export const SendThemeOptionsType = `${CustomFrontendMessageType}:send_themeoptions`; +export const BroadcastSignOutType = `${CustomFrontendMessageType}:signout`; // internal actions export const ConfigureFacilityNameType = @@ -260,6 +261,7 @@ export interface PluginRoute { link: string; displayName: string; admin?: boolean; + hideFromMenu?: boolean; order: number; } diff --git a/packages/datagateway-common/src/state/middleware/dgcommon.middleware.tsx b/packages/datagateway-common/src/state/middleware/dgcommon.middleware.tsx index 0ca44cec3..d8a8d370a 100644 --- a/packages/datagateway-common/src/state/middleware/dgcommon.middleware.tsx +++ b/packages/datagateway-common/src/state/middleware/dgcommon.middleware.tsx @@ -5,6 +5,7 @@ import { RequestPluginRerenderType, CustomFrontendMessageType, SendThemeOptionsType, + BroadcastSignOutType, } from '../actions/actions.types'; import axios from 'axios'; import { MicroFrontendId } from '../../app.types'; @@ -34,6 +35,7 @@ export const listenToMessages = (dispatch: Dispatch): void => { case RequestPluginRerenderType: case RegisterRouteType: case SendThemeOptionsType: + case BroadcastSignOutType: break; default: // log and ignore diff --git a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/actionCell.component.test.tsx.snap b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/actionCell.component.test.tsx.snap index 4dbe80da4..49ca5b2cf 100644 --- a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/actionCell.component.test.tsx.snap +++ b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/actionCell.component.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Action cell component renders an action correctly 1`] = `
    `; diff --git a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/dataCell.component.test.tsx.snap b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/dataCell.component.test.tsx.snap index 1708ce164..be220abba 100644 --- a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/dataCell.component.test.tsx.snap +++ b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/dataCell.component.test.tsx.snap @@ -5,15 +5,14 @@ exports[`Data cell component renders correctly 1`] = ` aria-sort={null} className="MuiTableCell-root MuiTableCell-body test-class MuiTableCell-sizeSmall" role="cell" - style={ - Object { - "paddingLeft": 8, - "paddingRight": 8, - } - } > +
    + +
    `; @@ -31,15 +50,14 @@ exports[`Data cell component renders nested cell data correctly 1`] = ` aria-sort={null} className="MuiTableCell-root MuiTableCell-body test-class MuiTableCell-sizeSmall" role="cell" - style={ - Object { - "paddingLeft": 8, - "paddingRight": 8, - } - } > +
    + +
    `; @@ -57,23 +95,44 @@ exports[`Data cell component renders provided cell data correctly 1`] = ` aria-sort={null} className="MuiTableCell-root MuiTableCell-body test-class MuiTableCell-sizeSmall" role="cell" - style={ - Object { - "paddingLeft": 8, - "paddingRight": 8, - } - } > - provided test + + provided test + +
    + +
    `; diff --git a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/expandCell.component.test.tsx.snap b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/expandCell.component.test.tsx.snap index 6e313942c..9e76d04a8 100644 --- a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/expandCell.component.test.tsx.snap +++ b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/expandCell.component.test.tsx.snap @@ -23,6 +23,7 @@ exports[`Expand cell component renders correctly when not expanded 1`] = ` > diff --git a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/selectCell.component.test.tsx.snap b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/selectCell.component.test.tsx.snap index 68adc8754..a357cd78f 100644 --- a/packages/datagateway-common/src/table/cellRenderers/__snapshots__/selectCell.component.test.tsx.snap +++ b/packages/datagateway-common/src/table/cellRenderers/__snapshots__/selectCell.component.test.tsx.snap @@ -13,6 +13,7 @@ exports[`Select cell component renders correctly when checked 1`] = ` fontSize="small" /> } + className="tour-dataview-add-to-cart" icon={ } + className="tour-dataview-add-to-cart" icon={ { @@ -19,7 +20,7 @@ describe('Cell content renderers', () => { describe('formatBytes', () => { it('converts to bytes correctly', () => { - expect(formatBytes(10000)).toEqual('9.77 KB'); + expect(formatBytes(10000)).toEqual('10 KB'); }); it('handles 0 correctly', () => { @@ -65,7 +66,7 @@ describe('Cell content renderers', () => { formatCountOrSize({ isFetching: false, isSuccess: true, data: 1 }, true) ).toEqual('1 B'); expect(formatCountOrSize({ data: 10000, isSuccess: true }, true)).toEqual( - '9.77 KB' + '10 KB' ); }); @@ -76,6 +77,63 @@ describe('Cell content renderers', () => { }); }); + describe('getStudyInfoInvestigation', () => { + it('filters out missing investigations and returns first existing investigation', () => { + expect( + getStudyInfoInvestigation({ + id: 1, + pid: 'doi 1', + name: 'study 1', + modTime: '', + createTime: '', + studyInvestigations: [ + { + id: 2, + }, + { + id: 3, + investigation: { + id: 4, + title: 'Investigating the properties of the number 4', + name: 'investigation 4', + visitId: '1', + }, + }, + ], + })?.name + ).toEqual('investigation 4'); + }); + + it('handles undefined properties fine', () => { + expect( + getStudyInfoInvestigation({ + id: 1, + pid: 'doi 1', + name: 'study 1', + modTime: '', + createTime: '', + }) + ).toBeUndefined(); + expect( + getStudyInfoInvestigation({ + id: 1, + pid: 'doi 1', + name: 'study 1', + modTime: '', + createTime: '', + studyInvestigations: [ + { + id: 2, + }, + { + id: 3, + }, + ], + }) + ).toBeUndefined(); + }); + }); + describe('datasetLink', () => { it('renders correctly', () => { const wrapper = shallow( diff --git a/packages/datagateway-common/src/table/cellRenderers/cellContentRenderers.tsx b/packages/datagateway-common/src/table/cellRenderers/cellContentRenderers.tsx index 35d8148e5..43e32506d 100644 --- a/packages/datagateway-common/src/table/cellRenderers/cellContentRenderers.tsx +++ b/packages/datagateway-common/src/table/cellRenderers/cellContentRenderers.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { Link } from '@material-ui/core'; -import { ViewsType } from '../../app.types'; +import { Investigation, Study, ViewsType } from '../../app.types'; import { UseQueryResult } from 'react-query'; export function formatBytes(bytes: number | undefined): string { @@ -11,9 +11,9 @@ export function formatBytes(bytes: number | undefined): string { const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const i = Math.floor(Math.log(bytes) / Math.log(1000)); - return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; + return parseFloat((bytes / Math.pow(1000, i)).toFixed(2)) + ' ' + sizes[i]; } /** @@ -35,6 +35,13 @@ export function formatCountOrSize( return 'Unknown'; } +export const getStudyInfoInvestigation = ( + study: Study +): Investigation | undefined => { + return study.studyInvestigations?.filter((si) => si?.investigation)?.[0] + ?.investigation; +}; + // NOTE: Allow the link to specify the view to keep the same view when navigating. const appendView = (link: string, view?: ViewsType): string => view ? (link += `?view=${view}`) : link; @@ -56,11 +63,16 @@ export function datasetLink( export function investigationLink( investigationId: number, investigationTitle: string, - view?: ViewsType + view?: ViewsType, + testid?: string ): React.ReactElement { const link = `/browse/investigation/${investigationId}/dataset`; return ( - + {investigationTitle} ); @@ -69,10 +81,27 @@ export function investigationLink( export function tableLink( linkUrl: string, linkText: string, - view?: ViewsType + view?: ViewsType, + testid?: string +): React.ReactElement { + return ( + + {linkText} + + ); +} + +export function externalSiteLink( + linkUrl: string, + linkText?: string, + testid?: string ): React.ReactElement { return ( - + {linkText} ); diff --git a/packages/datagateway-common/src/table/cellRenderers/dataCell.component.test.tsx b/packages/datagateway-common/src/table/cellRenderers/dataCell.component.test.tsx index 84daa8c80..f137ae95e 100644 --- a/packages/datagateway-common/src/table/cellRenderers/dataCell.component.test.tsx +++ b/packages/datagateway-common/src/table/cellRenderers/dataCell.component.test.tsx @@ -29,7 +29,7 @@ describe('Data cell component', () => { const wrapper = shallow( 'provided test'} + cellContentRenderer={() => {'provided test'}} /> ); expect(wrapper).toMatchSnapshot(); diff --git a/packages/datagateway-common/src/table/cellRenderers/dataCell.component.tsx b/packages/datagateway-common/src/table/cellRenderers/dataCell.component.tsx index 1ab874a98..71b52cd47 100644 --- a/packages/datagateway-common/src/table/cellRenderers/dataCell.component.tsx +++ b/packages/datagateway-common/src/table/cellRenderers/dataCell.component.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { TableCellProps, TableCellRenderer } from 'react-virtualized'; -import { TableCell, Typography, useMediaQuery } from '@material-ui/core'; -import ArrowTooltip from '../../arrowtooltip.component'; +import { Divider, TableCell, Typography } from '@material-ui/core'; +import ArrowTooltip, { getTooltipText } from '../../arrowtooltip.component'; type CellRendererProps = TableCellProps & { className: string; @@ -13,24 +13,45 @@ const DataCell = React.memo( const { className, dataKey, rowData, cellContentRenderer } = props; // use . in dataKey name to drill down into nested row data - const cellValue = dataKey.split('.').reduce(function (prev, curr) { - return prev ? prev[curr] : null; - }, rowData); + // if cellContentRenderer not provided + const cellContent = cellContentRenderer + ? cellContentRenderer(props) + : dataKey.split('.').reduce(function (prev, curr) { + return prev ? prev[curr] : null; + }, rowData); - const smWindow = !useMediaQuery('(min-width: 960px)'); return ( - + - {cellContentRenderer ? cellContentRenderer(props) : cellValue} + {cellContent} +
    + +
    ); } diff --git a/packages/datagateway-common/src/table/cellRenderers/expandCell.component.tsx b/packages/datagateway-common/src/table/cellRenderers/expandCell.component.tsx index bd5c3a3b3..fba0b54f2 100644 --- a/packages/datagateway-common/src/table/cellRenderers/expandCell.component.tsx +++ b/packages/datagateway-common/src/table/cellRenderers/expandCell.component.tsx @@ -23,6 +23,7 @@ const ExpandCell = React.memo( > {props.rowIndex !== expandedIndex ? ( setExpandedIndex(props.rowIndex)} > diff --git a/packages/datagateway-common/src/table/cellRenderers/selectCell.component.tsx b/packages/datagateway-common/src/table/cellRenderers/selectCell.component.tsx index 403866dc1..9c0a7378a 100644 --- a/packages/datagateway-common/src/table/cellRenderers/selectCell.component.tsx +++ b/packages/datagateway-common/src/table/cellRenderers/selectCell.component.tsx @@ -42,6 +42,7 @@ const SelectCell = React.memo( variant="body" >
    + + + + + - - + + @@ -3900,7 +4561,7 @@ exports[`SearchPageTable renders correctly when request received 1`] = ` > - -