diff --git a/package-lock.json b/package-lock.json index e62848075..3d2603074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5440,8 +5440,7 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base": { "version": "0.11.2", @@ -5530,6 +5529,11 @@ "tryer": "^1.0.1" } }, + "big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" + }, "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -5685,7 +5689,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5720,6 +5723,36 @@ } } }, + "broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "requires": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + }, + "dependencies": { + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, "brorand": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", @@ -6883,8 +6916,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concat-stream": { "version": "1.6.2", @@ -7929,8 +7961,7 @@ "detect-node": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.4.tgz", - "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==", - "dev": true + "integrity": "sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==" }, "detect-port-alt": { "version": "1.1.6", @@ -10359,8 +10390,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "1.2.12", @@ -11161,7 +11191,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz", "integrity": "sha512-J9dlskqUXK1OeTOYBEn5s8aMukWMwWfs+rPTn/jn50Ux4MNXVhubL1wu/j2t+H4NVI+cXEcCaYellqaPVGXNqQ==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -11916,7 +11945,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -11925,8 +11953,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "1.3.8", @@ -13180,6 +13207,11 @@ "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" }, + "js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13847,6 +13879,25 @@ "object-visit": "^1.0.0" } }, + "match-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", + "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==", + "requires": { + "@babel/runtime": "^7.12.5", + "remove-accents": "0.4.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz", + "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -14091,6 +14142,11 @@ "to-regex": "^3.0.2" } }, + "microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" + }, "miller-rabin": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", @@ -14180,7 +14236,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -14342,6 +14397,14 @@ "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", "dev": true }, + "nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=", + "requires": { + "big-integer": "^1.6.16" + } + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -15024,6 +15087,11 @@ } } }, + "oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" + }, "obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -15049,7 +15117,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -15377,8 +15444,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-is-inside": { "version": "1.0.2", @@ -16176,11 +16242,13 @@ "integrity": "sha512-aRGxDGP9VoLxcsaYvKWIW+LRrMOzz2eEcubTS4NvQPPugjk2VvMhow0wWTkSl7RxookomD1MwcP4l5UStg5ShQ==" }, "react-query": { - "version": "2.26.4", - "resolved": "https://registry.npmjs.org/react-query/-/react-query-2.26.4.tgz", - "integrity": "sha512-sXGG0gh1ah11AcfptYOCRpGDoYMnssq6riQUpQaLSM2EOodVkexp3zNLk1MFDgfRGuXQst40Tnu17oNwni66aA==", + "version": "3.34.14", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.14.tgz", + "integrity": "sha512-KVMnM8omt+81oO9fPZfM65pGhQilpWzGsNwAqeeLMB2sG3xwY3bpIEYbhDf7FFgsqhAQfSzmCL4gRSiJaWIDwA==", "requires": { - "@babel/runtime": "^7.5.5" + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" } }, "react-router": { @@ -16645,6 +16713,11 @@ "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "dev": true }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=" + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -19672,6 +19745,15 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true }, + "unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "requires": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -20897,8 +20979,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write": { "version": "1.0.3", diff --git a/package.json b/package.json index 087b21720..a587e5af2 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-google-charts": "^3.0.15", - "react-query": "^2.26.4", + "react-query": "~3.34.14", "react-router-dom": "~5.3.0", "react-select": "^2.4.4", "slugify": "^1.4.0", diff --git a/src/App.js b/src/App.js index 3ce9cc3c8..76b2cac57 100644 --- a/src/App.js +++ b/src/App.js @@ -11,9 +11,11 @@ import { import { create } from 'jss'; import { SnackbarProvider } from 'notistack'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; import { BrowserRouter } from 'react-router-dom'; import { AuthProvider } from '@/components/Auth'; +import api from '@/services/api'; import * as cssTheme from './_theme.scss'; import MainView from './views/MainView'; @@ -78,13 +80,15 @@ function App() { - - - - - - - + + + + + + + + + ); diff --git a/src/components/Auth/index.js b/src/components/Auth/index.js index 97f861ab9..fd68f3a24 100644 --- a/src/components/Auth/index.js +++ b/src/components/Auth/index.js @@ -41,7 +41,9 @@ const useAuth = () => { const AuthProvider = (props) => { const { children } = props; - const [logInOrOut, { isLoading: isAuthenticating, data, error }] = useMutation( + const { + mutate: logInOrOut, isLoading: isAuthenticating, data, error, + } = useMutation( async ({ loggingIn }) => { if (loggingIn) { const loggedIn = await keycloak.init({ @@ -54,7 +56,7 @@ const AuthProvider = (props) => { await keycloak.login({ redirectUri: window.location.href }); } - const { kbToken: authorizationToken } = await api.post('/token', { keyCloakToken: keycloak.token }).request(); + const { kbToken: authorizationToken } = await api.post('/token', { keyCloakToken: keycloak.token }); const { user } = jwt.decode(authorizationToken); await keycloak.loadUserInfo(); diff --git a/src/components/FormField/FilteredRecordAutocomplete/index.js b/src/components/FormField/FilteredRecordAutocomplete/index.js index bad70d9b5..eda026357 100644 --- a/src/components/FormField/FilteredRecordAutocomplete/index.js +++ b/src/components/FormField/FilteredRecordAutocomplete/index.js @@ -3,7 +3,7 @@ import './index.scss'; import { FormControl, FormHelperText } from '@material-ui/core'; import FilterIcon from '@material-ui/icons/FilterList'; import PropTypes from 'prop-types'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import DropDownSelect from '@/components/DropDownSelect'; import RecordAutocomplete from '@/components/RecordAutocomplete'; @@ -52,9 +52,9 @@ const FilteredRecordAutocomplete = ({ const itemToString = item => schema.getLabel(item); - const searchHandler = api.defaultSuggestionHandler( + const getQueryBody = useMemo(() => api.getDefaultSuggestionQueryBody( schema.get(selectedClassName), - ); + ), [selectedClassName]); const valueToString = (record) => { if (record && record['@rid']) { @@ -90,13 +90,13 @@ const FilteredRecordAutocomplete = ({ disabled={disabled} getOptionKey={opt => opt['@rid']} getOptionLabel={itemToString} + getQueryBody={getQueryBody} isMulti={isMulti} name={name} placeholder={isMulti ? `Search for Existing ${selectedClassName} Record(s)` : `Search for an Existing ${selectedClassName} Record` } - searchHandler={searchHandler} /> {helperText && ({helperText})} diff --git a/src/components/FormField/StatementReviewsTable/StatementReview.js b/src/components/FormField/StatementReviewsTable/StatementReview.js index 8f8702bb2..17dd90fb0 100644 --- a/src/components/FormField/StatementReviewsTable/StatementReview.js +++ b/src/components/FormField/StatementReviewsTable/StatementReview.js @@ -13,8 +13,8 @@ import { import DeleteIcon from '@material-ui/icons/Delete'; import EmbeddedIcon from '@material-ui/icons/SelectAll'; import PropTypes from 'prop-types'; -import React, { useEffect, useState } from 'react'; -import { queryCache } from 'react-query'; +import React from 'react'; +import { useQuery } from 'react-query'; import ActionButton from '@/components/ActionButton'; import DetailChip from '@/components/DetailChip'; @@ -39,21 +39,14 @@ const StatementReview = ({ status, createdBy, comment, } = value; - const [author, setAuthor] = useState(createdBy); - - useEffect(() => { - const fetchUser = async () => { - const user = await queryCache.prefetchQuery( - ['/query', { target: [createdBy] }], - (url, body) => api.post(url, body).request(), - ); - setAuthor(user[0]); - }; - - if (!createdBy['@rid']) { - fetchUser(); - } - }, [createdBy]); + const { data: author = createdBy } = useQuery( + ['/query', { target: [createdBy] }], + ({ queryKey: [route, body] }) => api.post(route, body), + { + enabled: !createdBy['@rid'], + select: response => response[0], + }, + ); const previewStr = `${author.name} (${author['@rid']})`; diff --git a/src/components/FormField/StatementReviewsTable/__tests__/index.test.js b/src/components/FormField/StatementReviewsTable/__tests__/index.test.js index 77b8e9f20..21b0699de 100644 --- a/src/components/FormField/StatementReviewsTable/__tests__/index.test.js +++ b/src/components/FormField/StatementReviewsTable/__tests__/index.test.js @@ -1,7 +1,9 @@ import { mount } from 'enzyme'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; import DetailChip from '@/components/DetailChip'; +import api from '@/services/api'; import StatementReviewsTable from '..'; @@ -41,11 +43,13 @@ describe('StatementReviewsTable', () => { test('mounts successfully', () => { const wrapper = mount(( - + + + )); expect(wrapper.find(StatementReviewsTable)).toBeDefined(); @@ -53,11 +57,13 @@ describe('StatementReviewsTable', () => { test('does not crash with empty reviews array ', () => { const wrapper = mount(( - + + + )); expect(wrapper.find(StatementReviewsTable)).toBeDefined(); @@ -66,11 +72,13 @@ describe('StatementReviewsTable', () => { test('displays correct number of chips ', () => { const wrapper = mount(( - + + + )); expect(wrapper.find(StatementReviewsTable)).toBeDefined(); diff --git a/src/components/FormField/index.js b/src/components/FormField/index.js index b841ba7a3..66458fc01 100644 --- a/src/components/FormField/index.js +++ b/src/components/FormField/index.js @@ -226,24 +226,19 @@ const FormField = ({ /> ); } else { - const searchOptions = {}; - - if (linkedClass) { - if (['Source', 'UserGroup', 'User', 'EvidenceLevel', 'Vocabulary'].includes(linkedClass.name)) { - autoProps.searchHandler = () => api.post('/query', { - target: `${linkedClass.name}`, - orderBy: linkedClass.name === 'EvidenceLevel' - ? ['source.sort', 'sourceId'] - : ['name'], - neighbors: 1, - }, { forceListReturn: true }); - autoProps.singleLoad = true; - } else { - autoProps.searchHandler = api.defaultSuggestionHandler(linkedClass, searchOptions); - } + if (linkedClass && ['Source', 'UserGroup', 'User', 'EvidenceLevel', 'Vocabulary'].includes(linkedClass.name)) { + autoProps.getQueryBody = () => ({ + target: `${linkedClass.name}`, + orderBy: linkedClass.name === 'EvidenceLevel' + ? ['source.sort', 'sourceId'] + : ['name'], + neighbors: 1, + }); + autoProps.singleLoad = true; } else { - autoProps.searchHandler = api.defaultSuggestionHandler(schema.get('V'), searchOptions); + autoProps.getQueryBody = api.getDefaultSuggestionQueryBody(linkedClass ?? schema.get('V')); } + propComponent = ( { afterEach(() => { jest.clearAllMocks(); @@ -15,12 +16,14 @@ describe('FormLayout', () => { test('new variant hides generated fields', () => { const { getByText, queryByText } = render( - - - , + + + + + , ); expect(getByText('The username')).toBeInTheDocument(); @@ -29,11 +32,13 @@ describe('FormLayout', () => { test('view variant shows generated fields', () => { const { getByText, getByTestId } = render( - - - , + + + + + , ); expect(getByText('The username')).toBeInTheDocument(); @@ -44,12 +49,14 @@ describe('FormLayout', () => { test('exclusion works', () => { const { getByText, queryByText } = render( - - - , + + + + + , ); expect(getByText('The username')).toBeInTheDocument(); diff --git a/src/components/QueryResultsTable/index.js b/src/components/QueryResultsTable/index.js index 4cb166198..ea0ca569c 100644 --- a/src/components/QueryResultsTable/index.js +++ b/src/components/QueryResultsTable/index.js @@ -43,7 +43,7 @@ const QueryResultsTable = ({ }) => { const { onGridReady, gridReady, gridApi } = useGrid(); - const { data, isFetching } = useQuery(['/query', queryBody], async (route, body) => api.post(route, body).request()); + const { data, isFetching } = useQuery(['/query', queryBody], async ({ queryKey: [route, body] }) => api.post(route, body)); // resize the columns to fit once the data and grid are ready useEffect(() => { diff --git a/src/components/RecordAutocomplete/__tests__/components.test.js b/src/components/RecordAutocomplete/__tests__/components.test.js index a4e0db6b3..ad8013b56 100644 --- a/src/components/RecordAutocomplete/__tests__/components.test.js +++ b/src/components/RecordAutocomplete/__tests__/components.test.js @@ -2,6 +2,9 @@ import '@testing-library/jest-dom/extend-expect'; import { render } from '@testing-library/react'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; + +import api from '@/services/api'; import RecordAutocomplete from '..'; @@ -12,12 +15,14 @@ describe('RecordAutocomplete', () => { test('renders new placeholder', () => { const placeholder = 'blargh monkeys'; const { getByText } = render( - , + + + , ); expect(getByText(placeholder)).toBeInTheDocument(); }); @@ -25,13 +30,16 @@ describe('RecordAutocomplete', () => { test('renders when initial value is given', () => { const record = { '@rid': '#2:3', name: 'bob' }; const { getByText } = render( - v.name} - name="test" - onChange={jest.fn()} - searchHandler={jest.fn()} - value={record} - />, + + + v.name} + name="test" + onChange={jest.fn()} + value={record} + /> + , ); expect(getByText('bob')).toBeInTheDocument(); }); @@ -39,14 +47,16 @@ describe('RecordAutocomplete', () => { test('renders multiple initial values', () => { const record = [{ '@rid': '#2:3', name: 'bob' }, { '@rid': '#2:4', name: 'alice' }]; const { getByText } = render( - v.name} - name="test" - onChange={jest.fn()} - searchHandler={jest.fn()} - value={record} - />, + + v.name} + name="test" + onChange={jest.fn()} + value={record} + /> + , ); expect(getByText('bob')).toBeInTheDocument(); expect(getByText('alice')).toBeInTheDocument(); diff --git a/src/components/RecordAutocomplete/__tests__/test.js b/src/components/RecordAutocomplete/__tests__/test.js index 2cf1b1f5c..0fd65e1cb 100644 --- a/src/components/RecordAutocomplete/__tests__/test.js +++ b/src/components/RecordAutocomplete/__tests__/test.js @@ -1,20 +1,18 @@ import '@testing-library/jest-dom/extend-expect'; -import { fireEvent, render, waitForElement } from '@testing-library/react'; +import { + fireEvent, render, wait, waitForElement, +} from '@testing-library/react'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; -import RecordAutocomplete from '..'; +import api from '@/services/api'; +import RecordAutocomplete from '..'; -const mockSearchHandler = (values = []) => { - const request = jest.fn(); - request.mockResolvedValue( - values.map( - (value, index) => Object.assign({}, { '@rid': `#1:${index}` }, value), - ), - ); - return jest.fn().mockReturnValue({ abort: jest.fn(), request }); -}; +const spy = jest + .spyOn(api, 'post') + .mockImplementation(() => [{ name: 'bob', '@rid': '#1:0' }, { name: 'alice', '@rid': '#1:1' }]); /* eslint-disable react/prop-types */ jest.mock('react-select', () => ({ options = [], value, onChange }) => { @@ -47,20 +45,24 @@ jest.mock('react-select', () => ({ options = [], value, onChange }) => { describe('RecordAutocomplete (data-fetching)', () => { - test('singleLoad triggers searchHandler', async () => { - const spy = mockSearchHandler([{ name: 'bob' }, { name: 'alice' }]); + test('singleLoad triggers query', async () => { const placeholder = 'input something'; const { getByText, getByTestId } = render( - , + + ({})} + minSearchLength={0} + name="test" + onChange={jest.fn()} + placeholder={placeholder} + singleLoad + /> + , ); - expect(spy).toHaveBeenCalledTimes(1); + + await wait(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); // click action to render the newly fetched popup options fireEvent.click(getByTestId('select')); const [bob, alice] = await waitForElement(() => [getByText('bob'), getByText('alice')]); @@ -68,7 +70,7 @@ describe('RecordAutocomplete (data-fetching)', () => { expect(alice).toBeInTheDocument(); }); - test.todo('searchHandler triggered on input change'); + test.todo('query triggered on input change'); afterEach(() => { jest.clearAllMocks(); diff --git a/src/components/RecordAutocomplete/index.js b/src/components/RecordAutocomplete/index.js index 7d784c47a..8b522c576 100644 --- a/src/components/RecordAutocomplete/index.js +++ b/src/components/RecordAutocomplete/index.js @@ -3,29 +3,18 @@ import './index.scss'; import { NoSsr } from '@material-ui/core'; import PropTypes from 'prop-types'; import React, { - useCallback, useEffect, - useState, + useCallback, useEffect, useMemo, useState, } from 'react'; +import { useQuery } from 'react-query'; import Select from 'react-select'; import { useDebounce } from 'use-debounce'; -import useDeepCompareEffect from 'use-deep-compare-effect'; + +import api from '@/services/api'; import defaultComponents from './components'; const MIN_TERM_LENGTH = 3; -/** - * @typedef {function} searchHandlerRequest - * @param {string} searchTermValue the term to search for - * @returns {Promise.>} the list of records suggested - */ - -/** - * @typedef {function} searchHandler - * @param {string} term the term to search - * @returns {ApiCall} an instance of api call which implements the abort and request functions - */ - const defaultOptionGrouping = (rawOptions) => { const sourceGroups = {}; @@ -68,7 +57,7 @@ const defaultOptionGrouping = (rawOptions) => { * @property {object} props.components components to be passed to react-select * @property {object} props.DetailChipProps properties to be applied to the DetailChip * @property {object|Array.} props.value the initial selected value(s) - * @property {searchHandler} props.searchHandler the function to create the async options call + * @property {Function} props.getQueryBody function to get body of request ot /query endpoint * @property {string} props.className Additional css class name to use on the main select component * @property {string} props.errorText Error message * @property {string} props.label the label for this form field @@ -94,7 +83,7 @@ const RecordAutocomplete = (props) => { onChange, placeholder, required, - searchHandler, + getQueryBody, singleLoad, helperText: initialHelperText, groupOptions, @@ -102,8 +91,6 @@ const RecordAutocomplete = (props) => { } = props; const [searchTerm, setSearchTerm] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [options, setOptions] = useState([]); const [helperText, setHelperText] = useState(initialHelperText); const [selectedValue, setSelectedValue] = useState(value); const [debouncedSearchTerm] = useDebounce(searchTerm, debounceMs); @@ -130,92 +117,39 @@ const RecordAutocomplete = (props) => { } }, [searchTerm]); - // initial load handler - useDeepCompareEffect( + const searchBody = useMemo( () => { - let controller; - - const getOptions = async () => { - if (controller) { - // if there is already a request being executed abort it and make a new one - controller.abort(); - setIsLoading(false); - } - controller = searchHandler(''); - - try { - setIsLoading(true); - const result = await controller.request(); - - if (groupOptions) { - setOptions(groupOptions(result || [])); - } else { - setOptions(result || []); - } - setIsLoading(false); - } catch (err) { - console.error('Error in getting the RecordAutocomplete singleLoad suggestions'); - console.error(err); - setIsLoading(false); - } - }; + let searchTerms = ''; - if (singleLoad && !disabled) { - getOptions(); + if (!singleLoad) { + const terms = debouncedSearchTerm.split(' '); + searchTerms = terms + .filter(term => term) + .filter(term => term.length >= MIN_TERM_LENGTH) + .join(' '); } - return () => controller && controller.abort(); + return getQueryBody(searchTerms); }, - [disabled, searchHandler, singleLoad], + [debouncedSearchTerm, getQueryBody, singleLoad], ); - // fetch options based on the current search term - useDeepCompareEffect( - () => { - let controller; - - const getOptions = async () => { - if (debouncedSearchTerm && debouncedSearchTerm.length >= minSearchLength) { - if (controller) { - // if there is already a request being executed abort it and make a new one - controller.abort(); - setIsLoading(false); - } - const terms = debouncedSearchTerm.split(' '); - const searchTerms = terms - .filter(term => term) - .filter(term => term.length >= MIN_TERM_LENGTH) - .join(' '); - controller = searchHandler(searchTerms); - - try { - setIsLoading(true); - const result = await controller.request(); - - if (groupOptions) { - setOptions(groupOptions(result || [])); - } else { - setOptions(result || []); - } + let enabled = !disabled; - setIsLoading(false); - } catch (err) { - console.error('Error in getting the RecordAutocomplete suggestions'); - console.error(err); - setIsLoading(false); - } - controller = null; - } else { - setOptions([]); - } - }; + if (!singleLoad) { + enabled = Boolean(enabled && debouncedSearchTerm && debouncedSearchTerm.length >= minSearchLength); + } - if (!singleLoad) { - getOptions(); - } - return () => controller && controller.abort(); + const { data: options, isLoading } = useQuery( + ['/query', searchBody, { forceListReturn: true }], + ({ queryKey: [route, body, opts] }) => api.post(route, body, opts), + { + enabled, + onError: (err) => { + console.error('Error in getting the RecordAutocomplete singleLoad suggestions'); + console.error(err); + }, + select: response => groupOptions(response ?? []), }, - // Only call effect if debounced search term changes - [debouncedSearchTerm, minSearchLength, searchHandler, singleLoad], ); const handleChange = useCallback( @@ -326,8 +260,8 @@ const RecordAutocomplete = (props) => { }; RecordAutocomplete.propTypes = { + getQueryBody: PropTypes.func.isRequired, name: PropTypes.string.isRequired, - searchHandler: PropTypes.func.isRequired, DetailChipProps: PropTypes.shape({ getLink: PropTypes.func, valueToString: PropTypes.func, diff --git a/src/components/RecordForm/EdgeTable/index.js b/src/components/RecordForm/EdgeTable/index.js index f884953e6..69a4bdd65 100644 --- a/src/components/RecordForm/EdgeTable/index.js +++ b/src/components/RecordForm/EdgeTable/index.js @@ -42,32 +42,39 @@ const EdgeTable = ({ recordId }) => { } = useGrid(); const { data: edges, isFetching } = useQuery( - ['/query', { - target: [recordId], - neighbors: 3, - }, 'edges'], async (route, body) => { - const [record] = await api.post(route, body).request(); - const newEdges = []; - Object.entries(record).forEach(([propName, value]) => { - if ((propName.startsWith('out_') || propName.startsWith('in_')) && value) { - value.forEach((edge) => { - const model = schema.get(edge); - const reversed = isReversed(recordId, edge); + [ + '/query', + { + target: [recordId], + neighbors: 3, + }, + ], + async ({ queryKey: [route, body] }) => api.post(route, body), + { + select: (response) => { + const [record] = response; + const newEdges = []; + Object.entries(record).forEach(([propName, value]) => { + if ((propName.startsWith('out_') || propName.startsWith('in_')) && value) { + value.forEach((edge) => { + const model = schema.get(edge); + const reversed = isReversed(recordId, edge); - newEdges.push({ - ...edge, - reversed, - relationshipType: reversed - ? model.reverseName - : model.name, - target: reversed - ? edge.out - : edge.in, + newEdges.push({ + ...edge, + reversed, + relationshipType: reversed + ? model.reverseName + : model.name, + target: reversed + ? edge.out + : edge.in, + }); }); - }); - } - }); - return newEdges; + } + }); + return newEdges; + }, }, ); diff --git a/src/components/RecordForm/RelatedStatementsTable/index.js b/src/components/RecordForm/RelatedStatementsTable/index.js index 7bc48eff1..857c86174 100644 --- a/src/components/RecordForm/RelatedStatementsTable/index.js +++ b/src/components/RecordForm/RelatedStatementsTable/index.js @@ -63,7 +63,7 @@ const RelatedStatementsTable = ({ recordId }) => { 'evidence.displayName', 'subject.displayName', ], - }], async (route, body) => api.post(route, body).request(), + }], async ({ queryKey: [route, body] }) => api.post(route, body), { staleTime: 5000, refetchOnWindowFocus: false }, ); diff --git a/src/components/RecordForm/RelatedVariantsTable/index.js b/src/components/RecordForm/RelatedVariantsTable/index.js index 6c796974f..189faeb7c 100644 --- a/src/components/RecordForm/RelatedVariantsTable/index.js +++ b/src/components/RecordForm/RelatedVariantsTable/index.js @@ -51,7 +51,7 @@ const RelatedVariantsTable = ({ recordId }) => { '@class', 'displayName', ], - }], async (route, body) => api.post(route, body).request(), + }], async ({ queryKey: [route, body] }) => api.post(route, body), ); useEffect(() => { diff --git a/src/components/RecordForm/__tests__/index.test.js b/src/components/RecordForm/__tests__/index.test.js index 1bbb9acfd..71de53579 100644 --- a/src/components/RecordForm/__tests__/index.test.js +++ b/src/components/RecordForm/__tests__/index.test.js @@ -3,33 +3,19 @@ import '@testing-library/jest-dom/extend-expect'; import { act, fireEvent, render } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; import { AuthContext } from '@/components/Auth'; -import * as api from '@/services/api'; +import api from '@/services/api'; import RecordForm from '..'; const auth = { user: { '@rid': '23:9' }, hasWriteAccess: true }; -jest.mock('@/services/api', () => { - const mockRequest = () => ({ - request: () => Promise.resolve( - [], - ), - abort: () => {}, - }); - - // to check that initial reviewStatus is set to initial by default - const mockPost = jest.fn((route, payload) => ({ request: () => payload, abort: () => {} })); - return ({ - delete: jest.fn().mockReturnValue(mockRequest()), - post: mockPost, - get: jest.fn().mockReturnValue(mockRequest()), - patch: jest.fn().mockReturnValue(mockRequest()), - defaultSuggestionHandler: jest.fn().mockReturnValue(mockRequest()), - }); -}); - +jest.spyOn(api, 'post').mockImplementation((route, payload) => payload); +jest.spyOn(api, 'patch').mockImplementation(() => []); +jest.spyOn(api, 'delete').mockImplementation(() => []); +jest.spyOn(api, 'get').mockImplementation(() => []); jest.mock('@/components/RecordAutocomplete', () => (({ value, onChange, name, label, @@ -82,20 +68,22 @@ describe('RecordForm', () => { beforeEach(() => { ({ getByText, queryByText, getByTestId } = render( - - - - - , + + + + + + + , )); }); @@ -132,19 +120,21 @@ describe('RecordForm', () => { beforeEach(() => { ({ getByText, getByTestId } = render( - - - - - , + + + + + + + , )); }); @@ -181,19 +171,21 @@ describe('RecordForm', () => { beforeEach(() => { ({ getByText, getByTestId } = render( - - - - - , + + + + + + + , )); }); diff --git a/src/components/RecordForm/index.js b/src/components/RecordForm/index.js index 213052136..846e47a0b 100644 --- a/src/components/RecordForm/index.js +++ b/src/components/RecordForm/index.js @@ -7,9 +7,9 @@ import { import { useSnackbar } from 'notistack'; import PropTypes from 'prop-types'; import React, { - useCallback, useEffect, useRef, - useState, + useCallback, useEffect, useState, } from 'react'; +import { useMutation } from 'react-query'; import ActionButton from '@/components/ActionButton'; import FormContext from '@/components/FormContext'; @@ -52,8 +52,6 @@ const RecordForm = ({ const snackbar = useSnackbar(); const auth = useAuth(); - const [actionInProgress, setActionInProgress] = useState(false); - const controllers = useRef([]); const [isEdge, setIsEdge] = useState(false); const [fieldDefs, setFieldDefs] = useState({}); @@ -71,7 +69,24 @@ const RecordForm = ({ formIsDirty, setFormIsDirty, formContent, formErrors, formHasErrors, } = form; - useEffect(() => () => controllers.current.map(c => c.abort()), []); + const { mutate: addNewAction, isLoading: isAdding } = useMutation( + async (content) => { + const payload = cleanPayload(content); + const { routeName } = schema.get(payload); + return api.post(routeName, payload); + }, + { + onSuccess: (result) => { + snackbar.enqueueSnackbar(`Sucessfully created the record ${result['@rid']}`, { variant: 'success' }); + onSubmit(result); + }, + onError: (err, content) => { + console.error(err); + snackbar.enqueueSnackbar(`Error (${err.name}) in creating the record`, { variant: 'error' }); + onError({ error: err, content }); + }, + }, + ); /** * Handler for submission of a new record @@ -90,24 +105,27 @@ const RecordForm = ({ content['@class'] = modelName; } - const payload = cleanPayload(content); - const { routeName } = schema.get(payload); - const call = api.post(routeName, payload); - controllers.current.push(call); - setActionInProgress(true); + addNewAction(content); + } + }, [addNewAction, formContent, formErrors, formHasErrors, modelName, setFormIsDirty, snackbar]); - try { - const result = await call.request(); - snackbar.enqueueSnackbar(`Sucessfully created the record ${result['@rid']}`, { variant: 'success' }); - onSubmit(result); - } catch (err) { - console.error(err); - snackbar.enqueueSnackbar(`Error (${err.name}) in creating the record`, { variant: 'error' }); + + const { mutate: deleteAction, isLoading: isDeleting } = useMutation( + async (content) => { + const { routeName } = schema.get(content); + return api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); + }, + { + onSuccess: (_, content) => { + snackbar.enqueueSnackbar(`Successfully deleted the record ${content['@rid']}`, { variant: 'success' }); + onSubmit(); + }, + onError: (err, content) => { + snackbar.enqueueSnackbar(`Error (${err.name}) in deleting the record (${content['@rid']})`, { variant: 'error' }); onError({ error: err, content }); - } - setActionInProgress(false); - } - }, [formContent, formErrors, formHasErrors, modelName, onError, onSubmit, setFormIsDirty, snackbar]); + }, + }, + ); /** * Handler for deleting an existing record @@ -118,21 +136,26 @@ const RecordForm = ({ if (!formContent['@class']) { content['@class'] = modelName; } - const { routeName } = schema.get(content); - const call = api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); - controllers.current.push(call); - setActionInProgress(true); + deleteAction(content); + }, [deleteAction, formContent, modelName]); - try { - await call.request(); - snackbar.enqueueSnackbar(`Sucessfully deleted the record ${content['@rid']}`, { variant: 'success' }); - onSubmit(); - } catch (err) { - snackbar.enqueueSnackbar(`Error (${err.name}) in deleting the record (${content['@rid']})`, { variant: 'error' }); - onError({ error: err, content }); - } - setActionInProgress(false); - }, [formContent, modelName, onError, onSubmit, snackbar]); + const { mutate: updateAction, isLoading: isUpdating } = useMutation( + async (content) => { + const payload = cleanPayload(content); + const { routeName } = schema.get(payload); + return api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); + }, + { + onSuccess: (result) => { + snackbar.enqueueSnackbar(`Successfully edited the record ${result['@rid']}`, { variant: 'success' }); + onSubmit(result); + }, + onError: (err, content) => { + snackbar.enqueueSnackbar(`Error (${err.name}) in editing the record (${content['@rid']})`, { variant: 'error' }); + onError({ error: err, content }); + }, + }, + ); /** * Handler for edits to an existing record @@ -153,23 +176,11 @@ const RecordForm = ({ snackbar.enqueueSnackbar('no changes to submit'); onSubmit(formContent); } else { - const payload = cleanPayload(content); - const { routeName } = schema.get(payload); - const call = api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); - controllers.current.push(call); - setActionInProgress(true); - - try { - const result = await call.request(); - snackbar.enqueueSnackbar(`Sucessfully edited the record ${result['@rid']}`, { variant: 'success' }); - onSubmit(result); - } catch (err) { - snackbar.enqueueSnackbar(`Error (${err.name}) in editing the record (${content['@rid']})`, { variant: 'error' }); - onError({ error: err, content }); - } - setActionInProgress(false); + updateAction(content); } - }, [formContent, formErrors, formHasErrors, formIsDirty, modelName, onError, onSubmit, setFormIsDirty, snackbar]); + }, [formContent, formErrors, formHasErrors, formIsDirty, modelName, onSubmit, setFormIsDirty, snackbar, updateAction]); + + const actionInProgress = isAdding || isDeleting || isUpdating; const handleToggleState = useCallback((newState) => { if (newState !== variant) { diff --git a/src/components/StatementForm/__tests__/index.test.js b/src/components/StatementForm/__tests__/index.test.js index a0f74358f..122693e86 100644 --- a/src/components/StatementForm/__tests__/index.test.js +++ b/src/components/StatementForm/__tests__/index.test.js @@ -1,35 +1,19 @@ import '@testing-library/jest-dom/extend-expect'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, wait } from '@testing-library/react'; import { SnackbarProvider } from 'notistack'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; import { AuthContext } from '@/components/Auth'; +import api from '@/services/api'; import StatementForm from '..'; const auth = { user: { '@rid': '23:9' }, hasWriteAccess: true }; -jest.mock('@/services/api', () => { - const mockRequest = () => ({ - request: () => Promise.resolve( - [], - ), - abort: () => {}, - }); - - // to check that initial reviewStatus is set to initial by default - const mockPost = jest.fn((route, payload) => ({ request: () => payload, abort: () => {} })); - return ({ - delete: jest.fn().mockReturnValue(mockRequest()), - post: mockPost, - get: jest.fn().mockReturnValue(mockRequest()), - patch: jest.fn().mockReturnValue(mockRequest()), - defaultSuggestionHandler: jest.fn().mockReturnValue(mockRequest()), - }); -}); - +jest.spyOn(api, 'post').mockImplementation((_, payload) => payload); jest.mock('@/components/RecordAutocomplete', () => (({ value, onChange, name, label, @@ -76,19 +60,21 @@ describe('StatementForm', () => { test('edit statement shows add review for statements', () => { const { getByText } = render( - - - - - , + + + + + + + , ); expect(getByText('Add Review')).toBeInTheDocument(); }); @@ -99,19 +85,21 @@ describe('StatementForm', () => { beforeEach(() => { ({ getByText, getByTestId } = render( - - - - - , + + + + + + + , )); }); @@ -137,7 +125,9 @@ describe('StatementForm', () => { createdBy: '23:9', }], }; - expect(onSubmitSpy).toHaveBeenCalledWith(expectedPayload); + await wait(() => { + expect(onSubmitSpy).toHaveBeenCalledWith(expectedPayload); + }); }); }); }); diff --git a/src/components/StatementForm/index.js b/src/components/StatementForm/index.js index 559c9c6ff..c806e4c3f 100644 --- a/src/components/StatementForm/index.js +++ b/src/components/StatementForm/index.js @@ -10,9 +10,9 @@ import { Alert } from '@material-ui/lab'; import { useSnackbar } from 'notistack'; import PropTypes from 'prop-types'; import React, { - useCallback, useEffect, useRef, useState, + useCallback, useEffect, useState, } from 'react'; -import { useQuery } from 'react-query'; +import { useMutation, useQuery } from 'react-query'; import ActionButton from '@/components/ActionButton'; import { useAuth } from '@/components/Auth'; @@ -59,7 +59,7 @@ const StatementForm = ({ filters: { name: 'diagnostic indicator' }, }, returnProperties: ['name'], - }], async (route, body) => api.post(route, body).request()); + }], async ({ queryKey: [route, body] }) => api.post(route, body)); const { data: therapeuticData } = useQuery(['/query', { queryType: 'similarTo', @@ -69,7 +69,7 @@ const StatementForm = ({ filters: { name: 'therapeutic efficacy' }, }, returnProperties: ['name'], - }], async (route, body) => api.post(route, body).request()); + }], async ({ queryKey: [route, body] }) => api.post(route, body)); const { data: prognosticData } = useQuery(['/query', { queryType: 'similarTo', @@ -79,15 +79,13 @@ const StatementForm = ({ filters: { name: 'prognostic indicator' }, }, returnProperties: ['name'], - }], async (route, body) => api.post(route, body).request()); + }], async ({ queryKey: [route, body] }) => api.post(route, body)); const snackbar = useSnackbar(); const auth = useAuth(); const model = schemaDefn.schema.Statement; const fieldDefs = model.properties; - const [actionInProgress, setActionInProgress] = useState(false); - const controllers = useRef([]); const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const [civicEvidenceId, setCivicEvidenceId] = useState(''); @@ -147,8 +145,6 @@ const StatementForm = ({ } }, [variant, formContent]); - useEffect(() => () => controllers.current.map(c => c.abort()), []); - const statementReviewCheck = useCallback((currContent, content) => { const updatedContent = { ...content }; @@ -168,6 +164,25 @@ const StatementForm = ({ return updatedContent; }, [auth]); + const { mutate: addNewAction, isLoading: isAdding } = useMutation( + async (content) => { + const payload = cleanPayload(content); + const { routeName } = schema.get(payload); + return api.post(routeName, payload); + }, + { + onSuccess: (result) => { + snackbar.enqueueSnackbar(`Sucessfully created the record ${result['@rid']}`, { variant: 'success' }); + onSubmit(result); + }, + onError: (err, content) => { + console.error(err); + snackbar.enqueueSnackbar(`Error (${err.name}) in creating the record`, { variant: 'error' }); + onError({ error: err, content }); + }, + }, + ); + /** * Handler for submission of a new record */ @@ -181,48 +196,52 @@ const StatementForm = ({ // ok to POST let content = { ...formContent, '@class': model.name }; content = statementReviewCheck(formContent, content); + addNewAction(content); + } + }, [addNewAction, formContent, formErrors, formHasErrors, model.name, setFormIsDirty, snackbar, statementReviewCheck]); - - const payload = cleanPayload(content); - const { routeName } = schema.get(payload); - const call = api.post(routeName, payload); - controllers.current.push(call); - setActionInProgress(true); - - try { - const result = await call.request(); - snackbar.enqueueSnackbar(`Sucessfully created the record ${result['@rid']}`, { variant: 'success' }); - onSubmit(result); - } catch (err) { - console.error(err); - snackbar.enqueueSnackbar(`Error (${err.name}) in creating the record`, { variant: 'error' }); + const { mutate: deleteAction, isLoading: isDeleting } = useMutation( + async (content) => { + const { routeName } = schema.get(content); + return api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); + }, + { + onSuccess: (_, content) => { + snackbar.enqueueSnackbar(`Sucessfully deleted the record ${content['@rid']}`, { variant: 'success' }); + onSubmit(); + }, + onError: (err, content) => { + snackbar.enqueueSnackbar(`Error (${err.name}) in deleting the record (${content['@rid']})`, { variant: 'error' }); onError({ error: err, content }); - } - setActionInProgress(false); - } - }, [formContent, formErrors, formHasErrors, model.name, onError, onSubmit, setFormIsDirty, snackbar, statementReviewCheck]); + }, + }, + ); /** * Handler for deleting an existing record */ const handleDeleteAction = useCallback(async () => { const content = { ...formContent, '@class': model.name }; + deleteAction(content); + }, [deleteAction, formContent, model.name]); - const { routeName } = schema.get(content); - const call = api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); - controllers.current.push(call); - setActionInProgress(true); - - try { - await call.request(); - snackbar.enqueueSnackbar(`Sucessfully deleted the record ${content['@rid']}`, { variant: 'success' }); - onSubmit(); - } catch (err) { - snackbar.enqueueSnackbar(`Error (${err.name}) in deleting the record (${content['@rid']})`, { variant: 'error' }); - onError({ error: err, content }); - } - setActionInProgress(false); - }, [formContent, model.name, onError, onSubmit, snackbar]); + const { mutate: updateAction, isLoading: isUpdating } = useMutation( + async (content) => { + const payload = cleanPayload(content); + const { routeName } = schema.get(payload); + return api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); + }, + { + onSuccess: (result) => { + snackbar.enqueueSnackbar(`Sucessfully edited the record ${result['@rid']}`, { variant: 'success' }); + onSubmit(result); + }, + onError: (err, content) => { + snackbar.enqueueSnackbar(`Error (${err.name}) in editing the record (${content['@rid']})`, { variant: 'error' }); + onError({ error: err, content }); + }, + }, + ); /** * Handler for edits to an existing record @@ -239,24 +258,11 @@ const StatementForm = ({ snackbar.enqueueSnackbar('no changes to submit'); onSubmit(formContent); } else { - const payload = cleanPayload(content); - const { routeName } = schema.get(payload); - const call = api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); - controllers.current.push(call); - setActionInProgress(true); - - try { - const result = await call.request(); - snackbar.enqueueSnackbar(`Sucessfully edited the record ${result['@rid']}`, { variant: 'success' }); - onSubmit(result); - } catch (err) { - snackbar.enqueueSnackbar(`Error (${err.name}) in editing the record (${content['@rid']})`, { variant: 'error' }); - onError({ error: err, content }); - } - setActionInProgress(false); + updateAction(content); } - }, [formContent, formErrors, formHasErrors, formIsDirty, model.name, onError, onSubmit, setFormIsDirty, snackbar]); + }, [formContent, formErrors, formHasErrors, formIsDirty, model.name, onSubmit, setFormIsDirty, snackbar, updateAction]); + const actionInProgress = isAdding || isDeleting || isUpdating; const handleAddReview = useCallback((content, updateReviewStatus) => { // add the new value to the field diff --git a/src/components/VariantForm/BreakpointForm/__tests__/index.test.js b/src/components/VariantForm/BreakpointForm/__tests__/index.test.js index d016b1134..886e7eaaa 100644 --- a/src/components/VariantForm/BreakpointForm/__tests__/index.test.js +++ b/src/components/VariantForm/BreakpointForm/__tests__/index.test.js @@ -2,8 +2,10 @@ import '@testing-library/jest-dom/extend-expect'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; import FormContext from '@/components/FormContext'; +import api from '@/services/api'; import schema from '@/services/schema'; import BreakpointForm from '..'; @@ -12,13 +14,15 @@ import BreakpointForm from '..'; describe('BreakpointForm', () => { test('displays start when given', () => { const { getByText, queryByText } = render( - , + + + , ); expect(getByText(/\breference\b/)).toBeInTheDocument(); expect(getByText('position (GenomicPosition)')).toBeInTheDocument(); @@ -27,15 +31,17 @@ describe('BreakpointForm', () => { test('defaults to uncertain if end is filled in form', () => { const { getByText } = render( - - - , + + + + + , ); expect(getByText(/\breference\b/)).toBeInTheDocument(); expect(getByText('start (GenomicPosition)')).toBeInTheDocument(); @@ -45,15 +51,17 @@ describe('BreakpointForm', () => { test('clears end from form when uncertain is unset', () => { const form = { formContent: { break1End: {} }, updateField: jest.fn() }; const { getByText, getByTestId } = render( - - - , + + + + + , ); expect(getByText(/\breference\b/)).toBeInTheDocument(); expect(getByText('start (GenomicPosition)')).toBeInTheDocument(); @@ -65,11 +73,13 @@ describe('BreakpointForm', () => { test('displays only gene when start not given', () => { const { getByText, queryByText } = render( - , + + + , ); expect(getByText(/\breference\b/)).toBeInTheDocument(); expect(queryByText('position (GenomicPosition)')).not.toBeInTheDocument(); diff --git a/src/components/VariantForm/SteppedForm/index.js b/src/components/VariantForm/SteppedForm/index.js index 0372ef4b8..0bfaa021d 100644 --- a/src/components/VariantForm/SteppedForm/index.js +++ b/src/components/VariantForm/SteppedForm/index.js @@ -9,10 +9,7 @@ import { } from '@material-ui/core'; import { useSnackbar } from 'notistack'; import PropTypes from 'prop-types'; -import React, { - useCallback, useEffect, - useRef, useState, -} from 'react'; +import React, { useCallback, useState } from 'react'; import ActionButton from '@/components/ActionButton'; import FormContext from '@/components/FormContext'; @@ -33,15 +30,12 @@ const SteppedForm = ({ }) => { const snackbar = useSnackbar(); const [activeStep, setActiveStep] = useState(0); - const controllers = useRef([]); const { content: visited, updateField: setStepVisit } = useObject({ 0: true }); const form = useSchemaForm(properties, { '@class': modelName, ...value }, { variant: formVariant }); const { formContent, formErrors, formHasErrors, formIsDirty, } = form; - useEffect(() => () => controllers.current.forEach(c => c.abort()), []); - const handleOnClick = useCallback((index) => { setStepVisit(index, true); setActiveStep(index); diff --git a/src/components/VariantForm/__tests__/index.test.js b/src/components/VariantForm/__tests__/index.test.js index c9aeb7c70..f67eb13d1 100644 --- a/src/components/VariantForm/__tests__/index.test.js +++ b/src/components/VariantForm/__tests__/index.test.js @@ -2,6 +2,9 @@ import '@testing-library/jest-dom/extend-expect'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; +import { QueryClientProvider } from 'react-query'; + +import api from '@/services/api'; import NewVariant from '..'; @@ -18,7 +21,9 @@ describe('NewVariant', () => { ({ getByText, getByTestId, queryByText, } = render( - , + + + , )); }); diff --git a/src/components/VariantForm/index.js b/src/components/VariantForm/index.js index b2ac3c9fd..71b47768d 100644 --- a/src/components/VariantForm/index.js +++ b/src/components/VariantForm/index.js @@ -8,7 +8,6 @@ import PropTypes from 'prop-types'; import React, { useCallback, useEffect, - useRef, useState, } from 'react'; @@ -113,7 +112,6 @@ const VariantForm = ({ : pickInputType(value), ); const snackbar = useSnackbar(); - const controllers = useRef([]); const [model, setModel] = useState(null); const hasPositions = inputType !== MAJOR_FORM_TYPES.OTHER && inputType !== MAJOR_FORM_TYPES.TRANS; @@ -151,17 +149,16 @@ const VariantForm = ({ const handleSubmitAction = useCallback(async (content) => { const payload = cleanPayload(content); const { routeName } = schema.get(payload); - const call = formVariant === FORM_VARIANT.NEW - ? api.post(routeName, payload) - : api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); - controllers.current.push(call); const actionType = formVariant === FORM_VARIANT.NEW ? 'created' : 'edited'; try { - const result = await call.request(); + const result = await formVariant === FORM_VARIANT.NEW + ? api.post(routeName, payload) + : api.patch(`${routeName}/${content['@rid'].replace(/^#/, '')}`, payload); + snackbar.enqueueSnackbar(`Sucessfully ${actionType} the record ${result['@rid']}`, { variant: 'success' }); onSubmit(result); } catch (err) { @@ -175,11 +172,9 @@ const VariantForm = ({ const handleDeleteAction = useCallback(async (content) => { const payload = cleanPayload(content); const { routeName } = schema.get(payload); - const call = api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); - controllers.current.push(call); try { - const result = await call.request(); + const result = await api.delete(`${routeName}/${content['@rid'].replace(/^#/, '')}`); snackbar.enqueueSnackbar(`Sucessfully deleted the record ${result['@rid']}`, { variant: 'success' }); onSubmit(null); } catch (err) { diff --git a/src/services/api/call.js b/src/services/api/call.js index 2e76e52dd..a620b6e3f 100644 --- a/src/services/api/call.js +++ b/src/services/api/call.js @@ -1,4 +1,3 @@ -import { boundMethod } from 'autobind-decorator'; import * as jc from 'json-cycle'; import { @@ -9,170 +8,100 @@ import { RecordExistsError, } from '../errors'; - -class ApiCall { - /** - * Sends request to server, appending all global headers and handling responses and errors. - * @param {string} endpoint - URL endpoint - * @param {Object} init - Request properties. - * @param {Object} requestOptions - options to be passed to the Request contstructor - * @param {object} callOptions - other options - * @param {object} callOptions.forceListReturn - always return a list for succesful requests - * @param {string} callOptions.name function name to use - */ - constructor(endpoint, requestOptions, callOptions) { - const { - forceListReturn = false, - forceRecordReturn = false, - name = null, - } = callOptions || {}; - this.endpoint = endpoint; - this.requestOptions = requestOptions; - this.controller = null; - this.forceListReturn = forceListReturn; - this.forceRecordReturn = forceRecordReturn; - this.name = name || endpoint; - } - - /** - * Cancel this fetch request - */ - abort() { - if (this.controller) { - this.controller.abort(); - this.controller = null; - } +/** + * Sends request to server, appending all global headers and handling responses and errors. + * @param {string} endpoint - URL endpoint + * @param {Object} init - Request properties. + * @param {Object} requestOptions - options to be passed to the Request contstructor + * @param {object} callOptions - other options + * @param {object} callOptions.forceListReturn - always return a list for succesful requests + */ +async function request(endpoint, requestOptions, callOptions) { + if ( + requestOptions.method !== 'GET' + && !['/query', '/parse', '/token'].includes(endpoint) + && window._env_.IS_DEMO + ) { + throw new Error('Write operations are disabled in DEMO mode. Changes will not submit'); } - isFinished() { - return !this.controller; - } + let response; - /** - * Makes the fetch request and awaits the response or error. Also handles the redirect to error - * or login pages - */ - @boundMethod - async request(ignoreAbort = true) { - if ( - this.requestOptions.method !== 'GET' - && !['/query', '/parse', '/token'].includes(this.endpoint) - && window._env_.IS_DEMO - ) { - throw new Error('Write operations are disabled in DEMO mode. Changes will not submit'); - } - this.controller = new AbortController(); - - let response; + try { + response = await fetch( + `${window._env_.API_BASE_URL}/api${endpoint}`, + { + ...requestOptions, + headers: { + 'Content-type': 'application/json', + }, + }, + ); + } catch (err) { + // https://www.bcgsc.ca/jira/browse/SYS-55907 + console.error(err); + console.error('Fetch error. Re-trying Request with cache-busting'); try { response = await fetch( - `${window._env_.API_BASE_URL}/api${this.endpoint}`, + `${window._env_.API_BASE_URL}/api${endpoint}`, { - ...this.requestOptions, + ...requestOptions, headers: { 'Content-type': 'application/json', }, - signal: this.controller.signal, + cache: 'reload', }, ); - } catch (err) { - if (err.name === 'AbortError' && ignoreAbort) { - return null; - } - // https://www.bcgsc.ca/jira/browse/SYS-55907 - console.error(err); - console.error('Fetch error. Re-trying Request with cache-busting'); - this.controller = new AbortController(); - - try { - response = await fetch( - `${window._env_.API_BASE_URL}/api${this.endpoint}`, - { - ...this.requestOptions, - headers: { - 'Content-type': 'application/json', - }, - signal: this.controller.signal, - cache: 'reload', - }, - ); - } catch (err2) { - if (err2.name === 'AbortError' && ignoreAbort) { - return null; - } - console.error(err2); - throw err2; - } + } catch (err2) { + console.error(err2); + throw err2; } - this.controller = null; - - if (response.ok) { - const body = await response.json(); - const decycled = jc.retrocycle(body); - let result = decycled.result !== undefined - ? decycled.result - : decycled; + } - if (this.forceListReturn && !Array.isArray(result)) { - result = [result]; - } else if (Array.isArray(result) && this.forceRecordReturn) { - if (result.length > 1) { - throw new BadRequestError(`expected a single record but found multiple (${result.length})`); - } - [result] = result; + if (response.ok) { + const body = await response.json(); + const decycled = jc.retrocycle(body); + let result = decycled.result !== undefined + ? decycled.result + : decycled; + + if (callOptions?.forceListReturn && !Array.isArray(result)) { + result = [result]; + } else if (Array.isArray(result) && callOptions?.forceRecordReturn) { + if (result.length > 1) { + throw new BadRequestError(`expected a single record but found multiple (${result.length})`); } - return result; + [result] = result; } + return result; + } - const { status, statusText, url } = response; + const { status, statusText, url } = response; - const error = { - message: response.statusText, - ...(await response.json()), - status, - url, - }; + const error = { + message: response.statusText, + ...(await response.json()), + status, + url, + }; - if (status === 401) { - throw new AuthenticationError(error); - } - if (status === 400) { - throw new BadRequestError(error); - } - if (status === 409) { - throw new RecordExistsError(error); - } - if (status === 403) { - throw new AuthorizationError(error); - } - if (status === 404) { - throw new APIConnectionFailureError(error); - } - throw new Error(`Unexpected Error [${status}]: ${statusText}`); + if (status === 401) { + throw new AuthenticationError(error); } -} - -/** - * Set of Api calls to be co-requested and co-aborted - */ -class ApiCallSet { - constructor(calls = []) { - this.calls = calls; + if (status === 400) { + throw new BadRequestError(error); } - - push(call) { - this.calls.push(call); + if (status === 409) { + throw new RecordExistsError(error); } - - abort() { - this.calls.forEach(controller => controller.abort()); + if (status === 403) { + throw new AuthorizationError(error); } - - async request() { - return Promise.all(this.calls.map(call => async () => call.request())); + if (status === 404) { + throw new APIConnectionFailureError(error); } + throw new Error(`Unexpected Error [${status}]: ${statusText}`); } - -export { ApiCall, ApiCallSet }; +export { request }; diff --git a/src/services/api/index.js b/src/services/api/index.js index 82124df7f..b96a9268d 100644 --- a/src/services/api/index.js +++ b/src/services/api/index.js @@ -5,12 +5,24 @@ import kbSchema from '@bcgsc-pori/graphkb-schema'; import * as jc from 'json-cycle'; import qs from 'qs'; +import { QueryClient } from 'react-query'; -import { ApiCall } from './call'; +import { request } from './call'; import { buildSearchFromParseVariant, getQueryFromSearch, getSearchFromQuery, } from './search'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15 * 60 * 1000, // 15m + refetchOnWindowFocus: false, + throwOnError: true, + refetchOnMount: false, + }, + }, +}); + const ID_PROP = '@rid'; const CLASS_PROP = '@class'; @@ -29,7 +41,7 @@ const patch = (endpoint, payload, callOptions) => { method: 'PATCH', body: jc.stringify(changes), }; - return new ApiCall(endpoint, init, callOptions); + return request(endpoint, init, callOptions); }; /** @@ -40,7 +52,7 @@ const get = (endpoint, callOptions) => { const init = { method: 'GET', }; - return new ApiCall(endpoint, init, callOptions); + return request(endpoint, init, callOptions); }; /** @@ -53,7 +65,7 @@ const post = (endpoint, payload, callOptions) => { method: 'POST', body: jc.stringify(payload), }; - return new ApiCall(endpoint, init, callOptions); + return request(endpoint, init, callOptions); }; /** @@ -65,57 +77,37 @@ const del = (endpoint, callOptions) => { method: 'DELETE', }; - return new ApiCall(endpoint, init, callOptions); + return request(endpoint, init, callOptions); }; -/** - * @typedef {function} searchHandlerRequest - * @param {string} searchTermValue the term to search for - * @returns {Array.|object} the record or list of records suggested - */ - -/** - * @typedef {object} searchHandler - * @property {function} abort aborts the current fetch request - * @property {searchHandlerRequest} request the asynchronous call to fetch the data - */ - /** * @param {ClassModel} model the schema model to use to generate the search function - * @returns {searchHandler} the function to retrieve the sugesstions based on some input text + * @returns the function to retrieve the query request body based on some input text */ -const defaultSuggestionHandler = (model, opt = {}) => { - const searchHandler = (textInput) => { - const { ...rest } = opt; - - const callOptions = { forceListReturn: true, ...rest }; - let body = {}; - - if (kbSchema.util.looksLikeRID(textInput)) { - body = { - target: [textInput], - }; - return post('/query', body, callOptions); - } +const getDefaultSuggestionQueryBody = model => (textInput) => { + let body = {}; + if (kbSchema.util.looksLikeRID(textInput)) { body = { - queryType: 'keyword', - target: `${model.name}`, - keyword: textInput, - limit: MAX_SUGGESTIONS, - neighbors: 1, + target: [textInput], }; + return body; + } - if (model.inherits.includes('Ontology') || model.name === 'Ontology') { - body.orderBy = ['source.sort', 'name', 'sourceId']; - } - return post('/query', body, callOptions); + body = { + queryType: 'keyword', + target: `${model.name}`, + keyword: textInput, + limit: MAX_SUGGESTIONS, + neighbors: 1, }; - searchHandler.fname = `${model.name}SearchHandler`; // for equality comparisons (for render updates) - return searchHandler; -}; + if (model.inherits.includes('Ontology') || model.name === 'Ontology') { + body.orderBy = ['source.sort', 'name', 'sourceId']; + } + return body; +}; /** * encodes complex/payload for POST query request. Returns search with encoded complex. @@ -145,11 +137,12 @@ export default { getQueryFromSearch, getSearchFromQuery, CLASS_PROP, - defaultSuggestionHandler, delete: del, buildSearchFromParseVariant, get, ID_PROP, patch, post, + getDefaultSuggestionQueryBody, + queryClient, }; diff --git a/src/views/AboutView/components/AboutClasses/components/ClassDescription.js b/src/views/AboutView/components/AboutClasses/components/ClassDescription.js index b50d40093..e0dc6c06e 100644 --- a/src/views/AboutView/components/AboutClasses/components/ClassDescription.js +++ b/src/views/AboutView/components/AboutClasses/components/ClassDescription.js @@ -21,9 +21,8 @@ import schema from '@/services/schema'; const ClassDescription = ({ name, description }) => { const { isFetching: exampleIsFetching, data: example } = useQuery( ['/query', { target: name, neighbors: 1, limit: 1 }], - async (url, body) => { - const controller = api.post(url, body); - const [result] = await controller.request(); + async ({ queryKey: [route, body] }) => { + const [result] = await api.post(route, body); return result; }, { staleTime: Infinity, throwOnError: false }, @@ -31,21 +30,23 @@ const ClassDescription = ({ name, description }) => { const { isFetching: countIsFetching, data: count } = useQuery( `/stats?classList=${name}`, - async (url) => { - const controller = api.get(url); - const { [name]: value } = await controller.request(); - let newCount = value; + async ({ queryKey: [route] }) => api.get(route), + { + staleTime: Infinity, + select: (response) => { + const { [name]: value } = response; + let newCount = value; - if (value / 1000000 > 1) { - newCount = `${Math.round(value / 1000000)}M`; - } else if (value / 1000 > 1) { - newCount = `${Math.round(value / 1000)}K`; - } else { - newCount = `${value}`; - } - return newCount; + if (value / 1000000 > 1) { + newCount = `${Math.round(value / 1000000)}M`; + } else if (value / 1000 > 1) { + newCount = `${Math.round(value / 1000)}K`; + } else { + newCount = `${value}`; + } + return newCount; + }, }, - { staleTime: Infinity, throwOnError: false }, ); diff --git a/src/views/AboutView/components/AboutMain.js b/src/views/AboutView/components/AboutMain.js index 21ec44df6..1ac985fc1 100644 --- a/src/views/AboutView/components/AboutMain.js +++ b/src/views/AboutView/components/AboutMain.js @@ -15,29 +15,28 @@ const AboutMain = () => { const { data: chartData } = useQuery( '/stats?classList=Statement&groupBy=source', - async (url) => { - const controller = api.get(url); - const { Statement: result } = await controller.request(); - const data = [['source', 'count']]; - Object.entries(result).forEach(([label, value]) => { - data.push([ - label === 'null' - ? 'other' - : label, - value, - ]); - }); - return data; + async ({ queryKey: [route] }) => api.get(route), + { + staleTime: Infinity, + select: (response) => { + const { Statement: result } = response; + const data = [['source', 'count']]; + Object.entries(result).forEach(([label, value]) => { + data.push([ + label === 'null' + ? 'other' + : label, + value, + ]); + }); + return data; + }, }, - { staleTime: Infinity }, ); const { data: versions } = useQuery( '/version', - async (url) => { - const controller = api.get(url); - return controller.request(); - }, + async ({ queryKey: [route] }) => api.get(route), { staleTime: Infinity }, ); diff --git a/src/views/AboutView/components/AboutUsageTerms.js b/src/views/AboutView/components/AboutUsageTerms.js index 2526c9ed5..f0f06d644 100644 --- a/src/views/AboutView/components/AboutUsageTerms.js +++ b/src/views/AboutView/components/AboutUsageTerms.js @@ -5,9 +5,8 @@ import { } from '@material-ui/core'; import { formatDistanceToNow } from 'date-fns'; import { useSnackbar } from 'notistack'; -import React, { - useCallback, useEffect, useState, -} from 'react'; +import React, { useCallback, useState } from 'react'; +import { useQuery } from 'react-query'; import ActionButton from '@/components/ActionButton'; import { useAuth } from '@/components/Auth'; @@ -16,52 +15,24 @@ import api from '@/services/api'; import TableOfContext from './TableOfContents'; const AboutUsageTerms = () => { - const auth = useAuth(); + const { user } = useAuth(); const snackbar = useSnackbar(); + const [hasAcknowledgedTerms, setHasAcknowledgedTerms] = useState(false); + const [hasSigned, setHasSigned] = useState(false); - const [isSigned, setIsSigned] = useState(false); - const [requiresSigning, setRequiresSigning] = useState(false); - const [licenseContent, setlicenseContent] = useState([]); - const [licenseEnactedAt, setLicenseEnactedAt] = useState((new Date()).getTime()); - - - // get the stats for the pie chart - useEffect(() => { - let controller; - - const getData = async () => { - controller = api.get('/license'); - const { content, enactedAt } = await controller.request(); - setlicenseContent(content); - setLicenseEnactedAt(enactedAt); - }; - getData(); - return () => controller && controller.abort(); - }, [auth]); - - useEffect(() => { - let controller; - - const getData = async () => { - const { user } = auth; + const { data } = useQuery( + ['/license', user.signedLicenseAt], + () => api.get('/license'), + ); - if (!user || !user.signedLicenseAt || user.signedLicenseAt < licenseEnactedAt) { - setRequiresSigning(true); - } else { - setIsSigned(true); - setRequiresSigning(false); - } - }; - getData(); - return () => controller && controller.abort(); - }, [licenseEnactedAt, auth]); + const requiresSigning = Boolean(!data || !user || !user.signedLicenseAt || user.signedLicenseAt < data.enactedAt); + const isSigned = !requiresSigning || hasSigned; const handleConfirmSign = useCallback(async () => { - await api.post('/license/sign').request(); + await api.post('/license/sign'); snackbar.enqueueSnackbar('Signed the user agreement', { variant: 'success' }); - setIsSigned(false); - setRequiresSigning(false); + setHasSigned(true); }, [snackbar]); return ( @@ -69,9 +40,9 @@ const AboutUsageTerms = () => { GraphKB Terms of Use - - {licenseContent.map(sectionDatum => ( -
+ + {data?.content.map(sectionDatum => ( +
{sectionDatum.label} @@ -84,24 +55,26 @@ const AboutUsageTerms = () => { setIsSigned(value)} + onChange={(_, value) => setHasAcknowledgedTerms(value)} /> )} label="I have read and understood the terms of use" /> Confirm
- - page last updated {formatDistanceToNow(licenseEnactedAt)} ago - + {data?.enactedAt && ( + + page last updated {formatDistanceToNow(data.enactedAt)} ago + + )}
); }; diff --git a/src/views/AboutView/components/Matching/index.js b/src/views/AboutView/components/Matching/index.js index c2f828298..64feedf7f 100644 --- a/src/views/AboutView/components/Matching/index.js +++ b/src/views/AboutView/components/Matching/index.js @@ -15,7 +15,7 @@ import React, { useEffect, useState, } from 'react'; -import { queryCache } from 'react-query'; +import { useQuery, useQueryClient } from 'react-query'; import { useDebounce } from 'use-debounce'; import DetailChip from '@/components/DetailChip'; @@ -108,60 +108,51 @@ const MatchView = (props) => { const [termType, setTermType] = useState('Vocabulary'); const [rootText, setRootText] = useState(''); const [rootTerm] = useDebounce(rootText, DEBOUNCE_MS); - const [isLoading, setIsLoading] = useState(false); - const [matches, setMatches] = useState([]); - const [hasTooManyRecords, setHasTooManyRecords] = useState(false); + const queryClient = useQueryClient(); - useEffect(() => { - const fetchData = async () => { - setMatches([]); + const { data: { hasTooManyRecords, matches = [], isLoading } = {} } = useQuery( + ['queries', rootTerm, term, termType], + async () => { const queries = [ termTreeQuery(termType, term), equivalentTermsQuery(termType, term), ]; - if (rootTerm) { queries.push(exludeRootTermsQuery(termType, rootTerm)); } - try { - const [treeTerms, parentTerms, excludedParentTerms] = await Promise.all( - queries.map(async query => queryCache.prefetchQuery( - ['/query', query], - async (key, bodykey) => api.post(key, bodykey).request(), - { staleTime: Infinity }, - { throwOnError: false }, - )), - ); + const [treeTerms, parentTerms, excludedParentTerms] = await Promise.all( + queries.map(async query => queryClient.fetchQuery( + ['/query', query], + async ({ queryKey: [route, body] }) => api.post(route, body), + { staleTime: Infinity }, + { throwOnError: true }, + )), + ); - const terms = {}; - treeTerms.forEach((currentTerm) => { - terms[currentTerm['@rid']] = currentTerm; - }); - parentTerms.forEach((currentTerm) => { - terms[currentTerm['@rid']] = currentTerm; - }); - setHasTooManyRecords(treeTerms.length >= MATCH_LIMIT || parentTerms.length >= MATCH_LIMIT); + const terms = {}; + treeTerms.forEach((currentTerm) => { + terms[currentTerm['@rid']] = currentTerm; + }); + parentTerms.forEach((currentTerm) => { + terms[currentTerm['@rid']] = currentTerm; + }); - if (excludedParentTerms) { - excludedParentTerms.forEach((currentTerm) => { - delete terms[currentTerm['@rid']]; - }); - } - setMatches(Object.values(terms)); - } catch (err) { - handleErrorSaveLocation(err, history); + if (excludedParentTerms) { + excludedParentTerms.forEach((currentTerm) => { + delete terms[currentTerm['@rid']]; + }); } - setIsLoading(false); - }; - - if (term) { - fetchData(); - } else { - setIsLoading(false); - } - }, [history, rootTerm, term, termType]); + return { + hasTooManyRecords: treeTerms.length >= MATCH_LIMIT || parentTerms.length >= MATCH_LIMIT, + matches: Object.values(terms), + }; + }, + { + onError: err => handleErrorSaveLocation(err, history), + }, + ); useEffect(() => { const normalizedTerm = text.toLowerCase().trim(); @@ -181,9 +172,7 @@ const MatchView = (props) => { }; const handleTextChange = useCallback((value) => { - setIsLoading(true); setRootText(''); - setMatches([]); setText(value.trim()); }, []); diff --git a/src/views/AboutView/components/TableOfContents/index.js b/src/views/AboutView/components/TableOfContents/index.js index ef9cce46b..1b5dfdd9f 100644 --- a/src/views/AboutView/components/TableOfContents/index.js +++ b/src/views/AboutView/components/TableOfContents/index.js @@ -10,17 +10,14 @@ import LetterIcon from '@/components/LetterIcon'; const TableOfContents = ({ sections, baseRoute }) => ( - {sections.map(({ id, label }) => { - const anchorId = id; - return ( - - - - {label} - - - ); - })} + {sections.map(({ id, label }) => ( + + + + {label} + + + ))} ); diff --git a/src/views/ActivityView/index.js b/src/views/ActivityView/index.js index 2a4b8fc54..73a4bb566 100644 --- a/src/views/ActivityView/index.js +++ b/src/views/ActivityView/index.js @@ -67,7 +67,7 @@ const ActivityView = () => { orderBy: ['createdAt'], orderByDirection: 'DESC', returnProperties: ['@rid', '@class', 'updatedBy.name', 'updatedAt', 'displayName'], - }).request(), + }), // get recent Edge records api.post('/query', { @@ -81,7 +81,7 @@ const ActivityView = () => { orderBy: ['createdAt'], orderByDirection: 'DESC', returnProperties: ['@rid', '@class', 'createdBy.name', 'createdAt'], - }).request(), + }), ]); const result = [...records, ...edges] .sort((rec1, rec2) => (rec2.updatedAt || rec2.createdAt) - (rec1.updatedAt || rec1.createdAt)); diff --git a/src/views/AdminView/index.js b/src/views/AdminView/index.js index c1091d00b..162cd05c4 100644 --- a/src/views/AdminView/index.js +++ b/src/views/AdminView/index.js @@ -10,9 +10,8 @@ import { import { MailOutline, } from '@material-ui/icons'; -import React, { - useCallback, useEffect, useState, -} from 'react'; +import React, { useCallback } from 'react'; +import { useQuery } from 'react-query'; import api from '@/services/api'; @@ -22,66 +21,37 @@ import AdminTable from './components/AdminTable'; * View for editing or adding database users. */ const AdminView = () => { - const [users, setUsers] = useState([]); - const [groups, setGroups] = useState([]); - const [userRefresh, setUserRefresh] = useState(true); - const [groupRefresh, setGroupRefresh] = useState(true); - - useEffect(() => { - let controller; - - const getData = async () => { - controller = api.post('/query', { - target: 'User', - neighbors: 2, - returnProperties: [ - '@class', - '@rid', - 'createdAt', - 'email', - 'groups.@class', - 'groups.@rid', - 'groups.name', - 'name', - 'signedLicenseAt', - ], - }); - const result = await controller.request(); - setUsers(result); - setUserRefresh(false); - }; - - if (userRefresh) { - getData(); - } - return () => controller && controller.abort(); - }, [userRefresh]); - - useEffect(() => { - let controller; - - const getData = async () => { - controller = api.post('/query', { target: 'UserGroup', neighbors: 2 }); - const result = await controller.request(); - setGroups(result); - setGroupRefresh(false); - }; + const { data: users = [], refetch: refetchUsers } = useQuery( + ['/query', { + target: 'User', + neighbors: 2, + returnProperties: [ + '@class', + '@rid', + 'createdAt', + 'email', + 'groups.@class', + 'groups.@rid', + 'groups.name', + 'name', + 'signedLicenseAt', + ], + }], + async ({ queryKey: [route, body] }) => api.post(route, body), + ); - if (groupRefresh) { - getData(); - } - return () => controller && controller.abort(); - }, [groupRefresh]); + const { data: groups = [], refetch: refetchGroups } = useQuery( + ['/query', { target: 'UserGroup', neighbors: 2 }], + async ({ queryKey: [route, body] }) => api.post(route, body), + ); const handleUserChange = useCallback(() => { - setUserRefresh(true); - }, []); + refetchUsers(); + }, [refetchUsers]); const handleGroupChange = useCallback(() => { - setGroupRefresh(true); - }, []); - - if (!users) return null; + refetchGroups(); + }, [refetchGroups]); return (
diff --git a/src/views/DataView/components/ActiveFilters/index.js b/src/views/DataView/components/ActiveFilters/index.js index 35bc2818a..b26cc1b10 100644 --- a/src/views/DataView/components/ActiveFilters/index.js +++ b/src/views/DataView/components/ActiveFilters/index.js @@ -17,8 +17,10 @@ import CopyIcon from '@material-ui/icons/FileCopyOutlined'; import FilterListIcon from '@material-ui/icons/FilterList'; import copy from 'copy-to-clipboard'; import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useState } from 'react'; -import { queryCache } from 'react-query'; +import React, { + useCallback, useMemo, useState, +} from 'react'; +import { useQuery } from 'react-query'; import api from '@/services/api'; import schema from '@/services/schema'; @@ -48,45 +50,33 @@ const extractRids = (obj) => { const ActiveFilters = ({ search }) => { const [anchorEl, setAnchorEl] = useState(null); - const [payload, setPayload] = useState({}); - const [routeName, setRouteName] = useState('/query'); - const [recordHash, setRecordHash] = useState({}); - - useEffect(() => { - const { - payload: newPayload, - routeName: newRouteName, - } = api.getQueryFromSearch({ search, schema }); - setPayload(newPayload); - setRouteName(newRouteName); - }, [search]); - - useEffect(() => { - const fetchDisplayNames = async () => { - const recordIds = extractRids(payload); - - if (recordIds.length) { - const returnProperties = ['@class', '@rid', 'name', 'displayName']; - const result = await queryCache.prefetchQuery( - ['/query', { - target: recordIds, - returnProperties, - }], - async (route, body) => api.post(route, body).request(), - ); + const { payload, routeName } = useMemo(() => api.getQueryFromSearch({ search, schema }), [search]); + const recordIds = useMemo(() => extractRids(payload), [payload]); + + const { data: recordHash } = useQuery( + [ + '/query', + { + target: recordIds, + returnProperties: ['@class', '@rid', 'name', 'displayName'], + }, + ], + async ({ queryKey: [route, body] }) => api.post(route, body), + { + enabled: Boolean(recordIds.length), + select: (response) => { const hash = {}; - result.forEach((rec) => { + response.forEach((rec) => { if (rec['@class'] === 'Statement') { hash[rec['@rid']] = 'Statement'; } else { hash[rec['@rid']] = schema.getPreview(rec); } }); - setRecordHash(hash); - } - }; - fetchDisplayNames(); - }, [payload]); + return hash; + }, + }, + ); const handleToggleOpen = useCallback((event) => { if (!anchorEl) { diff --git a/src/views/DataView/index.js b/src/views/DataView/index.js index 36d87ff5b..5d9a2324a 100644 --- a/src/views/DataView/index.js +++ b/src/views/DataView/index.js @@ -11,9 +11,12 @@ import MoreHorizIcon from '@material-ui/icons/MoreHoriz'; import { AgGridReact } from 'ag-grid-react'; import PropTypes from 'prop-types'; import React, { - useCallback, useEffect, useState, + useCallback, + useEffect, + useMemo, + useState, } from 'react'; -import { queryCache } from 'react-query'; +import { useIsFetching, useQuery } from 'react-query'; import DetailDrawer from '@/components/DetailDrawer'; import useGrid from '@/components/hooks/useGrid'; @@ -91,19 +94,16 @@ const getRowsFromBlocks = async ({ search, skip: block, limit: blockSize, sortModel, }); - blockRequests.push(queryCache.prefetchQuery( + blockRequests.push(api.queryClient.fetchQuery( ['/query', payload], - async (route, body) => { - const result = await api.post(route, body).request(); - return result; - }, + async ({ queryKey: [route, body] }) => api.post(route, body), )); } const data = []; (await Promise.all(blockRequests)).forEach(block => data.push(...block)); data.forEach((record) => { - queryCache.setQueryData( + api.queryClient.setQueryData( ['/query', { target: [record['@rid']], neighbors: DEFAULT_NEIGHBORS }], [record], ); @@ -119,16 +119,27 @@ const DataView = ({ location: { search: initialSearch }, blockSize, history, }) => { const [isExportingData, setIsExportingData] = useState(false); - const [totalRows, setTotalRows] = useState(null); - const [isLoading, setIsLoading] = useState(0); + const isLoading = useIsFetching(); const [search, setSearch] = useState(initialSearch); const [selectedRecords, setSelectedRecords] = useState([]); - const [detailPanelRow, setDetailPanelRow] = useState(null); const [optionsMenuAnchor, setOptionsMenuAnchor] = useState(null); + const [detailsRowId, setDetailsRowId] = useState(null); const { gridApi, colApi, onGridReady, gridReady, } = useGrid(); + const payload = useMemo(() => getQueryPayload({ + search, count: true, + }), [search]); + + const { data: totalRows = null } = useQuery( + ['/query', payload], + async ({ queryKey: [route, body] }) => api.post(route, body), + { + select: response => response[0]?.count, + }, + ); + const initializeGrid = useCallback(() => { gridApi.setColumnDefs([ ...schema.defineGridColumns(search), @@ -169,23 +180,6 @@ const DataView = ({ setSearch(newSearch); }, [initialSearch]); - useEffect(() => { - // count the number of expected rows - const fetchTotalRowCount = async () => { - const payload = getQueryPayload({ - search, count: true, - }); - const [{ count }] = await queryCache.prefetchQuery( - ['/query', payload], - async (route, body) => api.post(route, body).request(), - ); - setTotalRows(count); - }; - - setTotalRows(null); - fetchTotalRowCount(); - }, [search]); - // set up infinitite row model data source useEffect(() => { const handleSelectionChange = () => { @@ -199,35 +193,29 @@ const DataView = ({ } }, [blockSize, gridApi, gridReady, initializeGrid, search, totalRows]); - // reset loading state on cache load change - queryCache.subscribe(() => setIsLoading(queryCache.isFetching)); - const handleError = useCallback((err) => { util.handleErrorSaveLocation(err, history, { pathname: '/data/table', search }); }, [history, search]); - const handleToggleDetailPanel = useCallback(async ({ data } = {}) => { + const { data: detailPanelRow } = useQuery( + ['/query', { target: [detailsRowId], neighbors: DEFAULT_NEIGHBORS }], + async ({ queryKey: [route, body] }) => api.post(route, body), + { + enabled: Boolean(detailsRowId), + onError: err => handleError(err), + select: response => response[0], + }, + ); + + const handleToggleDetailPanel = useCallback(async (params) => { // no data or clicked link is a link property without a class model - if (!data || data.isLinkProp) { - setDetailPanelRow(null); + if (!params?.data || params.data.isLinkProp) { + setDetailsRowId(null); } else { - try { - const [fullRecord] = await queryCache.prefetchQuery( - ['/query', { target: [data['@rid']], neighbors: DEFAULT_NEIGHBORS }], - async (route, body) => api.post(route, body).request(), - ); - - if (!fullRecord) { - setDetailPanelRow(null); - } else { - setDetailPanelRow(fullRecord); - } - } catch (err) { - handleError(err); - } + setDetailsRowId(params.data['@rid']); } - }, [handleError]); + }, []); /** * Opens the options menu. The trigger is defined on this component but @@ -364,7 +352,6 @@ const DataView = ({ infiniteInitialRowCount={1} maxBlocksInCache={0} maxConcurrentDatasourceRequests={1} - // onBodyScroll={detectFetchTrigger} onCellFocused={({ rowIndex }) => { if (rowIndex !== null) { const rowNode = gridApi.getDisplayedRowAtIndex(rowIndex); diff --git a/src/views/GraphView/index.js b/src/views/GraphView/index.js index ebf53a4da..b65002e28 100644 --- a/src/views/GraphView/index.js +++ b/src/views/GraphView/index.js @@ -4,12 +4,13 @@ import { CircularProgress, } from '@material-ui/core'; import React, { - useCallback, useEffect, useState, + useCallback, useMemo, useState, } from 'react'; -import { queryCache } from 'react-query'; +import { useIsFetching, useQuery, useQueryClient } from 'react-query'; +import { useLocation } from 'react-router-dom'; import DetailDrawer from '@/components/DetailDrawer'; -import { HistoryPropType, LocationPropType } from '@/components/types'; +import { HistoryPropType } from '@/components/types'; import { getNodeRIDsFromURL, navigateToGraph } from '@/components/util'; import api from '@/services/api'; import schema from '@/services/schema'; @@ -24,49 +25,34 @@ const { DEFAULT_NEIGHBORS } = config; /** * Shows the search result filters and an edit button */ -const GraphView = ({ - location: { search }, history, -}) => { +const GraphView = ({ history }) => { + const { search } = useLocation(); + const isLoading = useIsFetching(); const [detailPanelRow, setDetailPanelRow] = useState(null); - const [recordIds, setRecordIds] = useState([]); - const [graphData, setGraphData] = useState(null); + // the existing behaviour of the graph relies on this not changing even when the url *is* updated + const recordIds = useMemo(() => getNodeRIDsFromURL(window.location.href), []); + const queryClient = useQueryClient(); const handleError = useCallback((err) => { util.handleErrorSaveLocation(err, history, { pathname: '/data/table', search }); }, [history, search]); - - useEffect(() => { - const decodedNodes = getNodeRIDsFromURL(window.location.href); - setRecordIds(decodedNodes); - }, [handleError]); - - useEffect(() => { - const fetchRecords = async () => { - const fullRecords = await queryCache.prefetchQuery( - ['/query', { target: recordIds, neighbors: DEFAULT_NEIGHBORS }], - async (url, body) => { - const controller = api.post(url, body); - const result = await controller.request(); - return result; - }, - ); - - const recordHash = util.hashRecordsByRID(fullRecords); - Object.keys(recordHash).forEach((recordId) => { - queryCache.setQueryData( - [{ target: [recordId], neighbors: DEFAULT_NEIGHBORS }], - [recordHash[recordId]], - ); - }); - - setGraphData(recordHash); - }; - - if (recordIds.length) { - fetchRecords(); - } - }, [recordIds, recordIds.length]); + const { data: graphData } = useQuery( + ['/query', { target: recordIds, neighbors: DEFAULT_NEIGHBORS }], + async ({ queryKey: [route, body] }) => api.post(route, body), + { + enabled: Boolean(recordIds.length), + onSuccess: (recordHash) => { + Object.keys(recordHash).forEach((recordId) => { + queryClient.setQueryData( + [{ target: [recordId], neighbors: DEFAULT_NEIGHBORS }], + [recordHash[recordId]], + ); + }); + }, + select: response => util.hashRecordsByRID(response), + }, + ); /** * Opens the right-hand panel that shows details of a given record @@ -79,13 +65,9 @@ const GraphView = ({ setDetailPanelRow(null); } else { try { - const [fullRecord] = await queryCache.prefetchQuery( + const [fullRecord] = await queryClient.fetchQuery( ['/query', { target: [detailData['@rid']], neighbors: DEFAULT_NEIGHBORS }], - async (url, body) => { - const controller = api.post(url, body); - const result = await controller.request(); - return result; - }, + async ({ queryKey: [route, body] }) => api.post(route, body), ); if (!fullRecord) { @@ -98,7 +80,7 @@ const GraphView = ({ handleError(err); } } - }, [handleError]); + }, [handleError, queryClient]); const handleGraphStateSaveIntoURL = useCallback((nodeRIDs) => { @@ -113,10 +95,15 @@ const GraphView = ({ const detailPanelIsOpen = Boolean(detailPanelRow); const handleExpandRecord = async (recordId) => { - const [fullRecord] = await queryCache.prefetchQuery( - ['/query', { target: [recordId], neighbors: DEFAULT_NEIGHBORS }], - async (url, body) => api.post(url, body).request(), - ); + const key = ['/query', { target: [recordId], neighbors: DEFAULT_NEIGHBORS }]; + let fullRecord = queryClient.getQueryData(key); + + if (!fullRecord) { + [fullRecord] = await queryClient.fetchQuery( + key, + async ({ queryKey: [route, body] }) => api.post(route, body), + ); + } return fullRecord; }; @@ -150,7 +137,7 @@ const GraphView = ({ )}
- {queryCache.isFetching === 'loading' && ( + {Boolean(isLoading) && (
@@ -163,7 +150,6 @@ const GraphView = ({ GraphView.propTypes = { history: HistoryPropType.isRequired, - location: LocationPropType.isRequired, }; diff --git a/src/views/ImportPubmedView/index.js b/src/views/ImportPubmedView/index.js index 0880fb743..6aa8b908d 100644 --- a/src/views/ImportPubmedView/index.js +++ b/src/views/ImportPubmedView/index.js @@ -7,10 +7,8 @@ import { import { titleCase } from 'change-case'; import { useSnackbar } from 'notistack'; import PropTypes from 'prop-types'; -import React, { - useCallback, - useEffect, useRef, useState, -} from 'react'; +import React, { useCallback, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; import { useDebounce } from 'use-debounce'; import handleErrorSaveLocation from '@/services/util'; @@ -20,123 +18,83 @@ import api from '../../services/api'; import PubmedCard from './components/PubmedCard'; -const createPubmedQuery = pmid => api.post('/query', { - target: 'Publication', - filters: { - AND: [ - { - source: { - target: 'Source', - filters: { name: 'pubmed' }, - }, - }, - { sourceId: pmid }, - ], - }, -}); - - const ImportPubmedView = (props) => { const { history } = props; const snackbar = useSnackbar(); const [errorText, setErrorText] = useState(''); const [text, setText] = useState(''); - const [externalRecord, setExternalRecord] = useState(null); const [pmid] = useDebounce(text, 1000); - const [isLoading, setIsLoading] = useState(false); - - const [currentRecords, setCurrentRecords] = useState([]); - const [source, setSource] = useState(''); - - const controllers = useRef([]); // fetch the pubmed source record - useEffect(() => { - let call; - - const fetchRecord = async () => { - call = api.post('/query', { target: 'Source', filters: { name: 'pubmed' } }); - - try { - const [record] = await call.request(); - setSource(record['@rid']); - } catch (err) { - handleErrorSaveLocation(err, history); - } - }; - - fetchRecord(); - - return () => call && call.abort(); - }, [history]); + const { data: source } = useQuery( + ['/query', { target: 'Source', filters: { name: 'pubmed' } }], + ({ queryKey: [route, body] }) => api.post(route, body), + { + onError: err => handleErrorSaveLocation(err, history), + select: response => response[0]?.['@rid'], + }, + ); // fetch records that already exist in GraphKB - useEffect(() => { - let call; + const { data: currentRecords, isLoading, refetch: refetchCurrentRecords } = useQuery( + [ + '/query', + { + target: 'Publication', + filters: { + AND: [ + { + source: { + target: 'Source', + filters: { name: 'pubmed' }, + }, + }, + { sourceId: pmid }, + ], + }, + }, + ], + ({ queryKey: [route, body] }) => api.post(route, body), + { + enabled: Boolean(text), + onError: err => handleErrorSaveLocation(err, history), + }, + ); - const fetchRecords = async () => { - if (text) { - call = createPubmedQuery(text); + // fetch details from PUBMED + const { data: externalRecord = null } = useQuery( + `/extensions/pubmed/${pmid}`, + ({ queryKey: [route] }) => api.get(route), + { + enabled: Boolean(pmid), + }, + ); + const { mutate: importRecord, isLoading: isImporting } = useMutation( + async () => { + if (externalRecord) { try { - const records = await call.request(); - setCurrentRecords(records); + const result = await api.post('/publications', { ...externalRecord, source }); + snackbar.enqueueSnackbar(`created the new publication record ${result['@rid']}`, { variant: 'success' }); + refetchCurrentRecords(); } catch (err) { handleErrorSaveLocation(err, history); } } - }; - - fetchRecords(); - - return () => call && call.abort(); - }, [history, text]); - - // fetch details from PUBMED - useEffect(() => { - const getContent = async () => { - const call = api.get(`/extensions/pubmed/${pmid}`); - controllers.current.push(call); - const record = await call.request(); - setExternalRecord(record); - setIsLoading(false); - }; - getContent(); - }, [pmid]); - - - useEffect(() => { - controllers.current.forEach(c => c && c.abort()); - }, []); + }, + ); // fetch records that do not already exist in GraphKB - const handleImport = useCallback(async () => { - if (externalRecord) { - try { - const newCall = api.post('/publications', { ...externalRecord, source }); - controllers.current.push(newCall); - const result = await newCall.request(); - snackbar.enqueueSnackbar(`created the new publication record ${result['@rid']}`, { variant: 'success' }); - setCurrentRecords([result]); - } catch (err) { - handleErrorSaveLocation(err, history); - } - } - }, [externalRecord, source, snackbar, history]); + const handleImport = useCallback(async () => importRecord(), [importRecord]); const handleTextChange = useCallback((value) => { - if (text) { - setIsLoading(true); - } - setExternalRecord(null); - if (/^\d*$/.exec(`${value}`)) { setErrorText(''); setText(value); } else { setErrorText('PubMed IDs must be only numbers'); } - }, [text]); + }, []); return (
@@ -151,7 +109,7 @@ const ImportPubmedView = (props) => { placeholder="Enter a PubMed ID ex. 1234" value={text} /> - {currentRecords.map(rec => ( + {currentRecords?.map(rec => ( { title={titleCase(rec.name)} /> ))} - {isLoading && } + {(isImporting || isLoading) && } {(!currentRecords || !currentRecords.length) && externalRecord && ( { const [activeLink, setActiveLink] = useState(''); return ( - -
- - { - setDrawerOpen(isOpen); - }} - /> - { - setDrawerOpen(isOpen); - }} - /> +
+ + { + setDrawerOpen(isOpen); + }} + /> + { + setDrawerOpen(isOpen); + }} + /> -
- )}> - - - - - - - - - - - - m.toLowerCase())].join('|') - })`} - /> - - - - - - - - - -
-
-
- +
+ )}> + + + + + + + + + + + + m.toLowerCase())].join('|') + })`} + /> + + + + + + + + + +
+
+
); }; diff --git a/src/views/RecordView/index.js b/src/views/RecordView/index.js index 49437f00f..ab93dba34 100644 --- a/src/views/RecordView/index.js +++ b/src/views/RecordView/index.js @@ -6,9 +6,10 @@ import { import propTypes from 'prop-types'; import * as qs from 'qs'; import React, { - useCallback, useEffect, useState, + useCallback, useEffect, useMemo, + useState, } from 'react'; -import useDeepCompareEffect from 'use-deep-compare-effect'; +import { useQuery } from 'react-query'; import RecordForm from '@/components/RecordForm'; import StatementForm from '@/components/StatementForm'; @@ -49,7 +50,6 @@ const getModelFromName = (path = '', modelName = '', variant = FORM_VARIANT.VIEW const RecordView = (props) => { const { history, match: { path, params: { rid, modelName: modelNameParam, variant } } } = props; - const [recordContent, setRecordContent] = useState({}); const [modelName, setModelName] = useState(modelNameParam || ''); useEffect(() => { @@ -88,48 +88,39 @@ const RecordView = (props) => { util.handleErrorSaveLocation({ name, message: massagedMsg }, history); }, [history]); - // fetch the record from the rid if given - useDeepCompareEffect(() => { - let call = null; - - const fetchRecord = async () => { - const { '@class': defaultModel } = recordContent; - const model = schema.get(modelName || 'V'); + const model = useMemo(() => schema.get(modelName || 'V'), [modelName]); + const { data: recordContent } = useQuery( + [`${model?.routeName}/${rid.replace(/^#/, '')}?neighbors=1`, { forceListReturn: true }], + async ({ queryKey: [route, options] }) => { if (!model) { - handleError({ error: { name: 'ModelNotFound', message: `Unable to find model for ${modelName || defaultModel}` } }); - } else if (variant !== FORM_VARIANT.NEW && variant !== FORM_VARIANT.SEARCH && rid) { - // If not a new form then should have existing content - try { - call = api.get(`${model.routeName}/${rid.replace(/^#/, '')}?neighbors=1`, { forceListReturn: true }); - const result = await call.request(); - - if (result && result.length) { - setRecordContent({ ...result[0] }); - setModelName(result[0]['@class']); - } else { - handleError({ error: { name: 'RecordNotFound', message: `Unable to retrieve record details for ${model.routeName}/${rid}` } }); - } - } catch (err) { - handleError({ error: err }); - } + handleError({ error: { name: 'ModelNotFound', message: `Unable to find model for ${modelName}` } }); + return undefined; + } + const result = await api.get(route, options); + + if (result && result.length) { + return { ...result[0] }; } - }; - fetchRecord(); - return () => call && call.abort(); - // must not update on modelName since this sets modelName - }, [rid, modelNameParam, schema, path, recordContent, variant, handleError]); // eslint-disable-line + handleError({ error: { name: 'RecordNotFound', message: `Unable to retrieve record details for ${model.routeName}/${rid}` } }); + return undefined; + }, + { + enabled: Boolean(variant !== FORM_VARIANT.NEW && variant !== FORM_VARIANT.SEARCH && rid), + onError: err => handleError({ error: err }), + onSuccess: result => result && setModelName(result['@class']), + }, + ); // redirect when the user clicks the top right button const onTopClick = useCallback(() => { - const model = schema.get(modelName); const newVariant = variant === FORM_VARIANT.EDIT ? FORM_VARIANT.VIEW : FORM_VARIANT.EDIT; const newPath = `/${newVariant}/${model.name}/${rid}`; history.push(newPath); - }, [history, modelName, rid, variant]); + }, [history, model.name, rid, variant]); const navigateToGraphView = useCallback(() => { navigateToGraph([recordContent['@rid']], history, handleError);