From 91905f1caa11206814506edaf9cf2ab0b921f805 Mon Sep 17 00:00:00 2001 From: Arman Jahanpour <77515879+rmanaem@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:25:54 -0500 Subject: [PATCH] [ENH] Implemented UI improvements (#16) * Cleaned up old files * Added favicon * Added summary stats * Implemented navbar * Implemented logic for displaying snackbar/toasts * Fixed the bg color on hover for modality buttons * Added `circularProgress` on `submit query` button while result is being fetched * Modified how dataset name is displayed in `ResultCard` * Implemented validation for `continuousField` * Refactored and formatted with prettier * [WIP] Implemented alert for OpenNeuro * Implemented `OpenNeuro` alert * Moved the logic for validation of `ContinuousField` to `QueryForm` * [REF] compute errors from props --------- Co-authored-by: rmanaem * [REF] remove responsibilities from async getter also learn a bit about promises * [REF] remove responsibilities from node getter funtion - learn more about async / await * [REF] Removed `autoHideDuration` prop from `Snackbar` * [REF] Added an empty line for readability * [REF] Reduced inline comment * [REF] Refactored logic inside `useEffect` to use `then` instead of an `async` function * [REF] Refactored `summaryStats` * [REF] Renamed `minAgeBiggerThanMax` to `minAgeExceedsMaxAge` * [REF] Renamed `label` to `nodeName` * [REF] Fixed typo in `openNeuroIsAnOption` * Added comment `TODO` comment * Added `notistack` * [REF] Reworked logic of displaying toasts to use `notistack` * Removed custom toast/snackbar components * [REF] Renamed `tryThisOptionsGetter` to `getAttributes` --------- Co-authored-by: Sebastian Urchs --- index.html | 2 +- package-lock.json | 68 +++++- package.json | 5 +- src/App.css | 17 -- src/App.cy.tsx | 8 - src/App.tsx | 323 +++++++++++++++++++++++++---- src/assets/favicon.ico | Bin 0 -> 1923 bytes src/assets/react.svg | 1 - src/assets/vite.svg | 1 - src/components/ContinuousField.tsx | 23 +- src/components/Navbar.tsx | 52 +++++ src/components/QueryForm.tsx | 246 ++++++++-------------- src/components/ResultCard.tsx | 22 +- src/components/ResultContainer.tsx | 15 +- src/main.tsx | 2 +- src/sum.js | 4 - src/sum.test.js | 8 - src/utils/constants.ts | 14 +- src/utils/types.ts | 14 ++ tailwind.config.js | 2 +- 20 files changed, 541 insertions(+), 286 deletions(-) delete mode 100644 src/App.cy.tsx create mode 100644 src/assets/favicon.ico delete mode 100644 src/assets/react.svg delete mode 100644 src/assets/vite.svg create mode 100644 src/components/Navbar.tsx delete mode 100644 src/sum.js delete mode 100644 src/sum.test.js diff --git a/index.html b/index.html index 95318d0..dec639b 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Neurobagel Query Tool diff --git a/package-lock.json b/package-lock.json index 55cbd43..a80a1fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,14 +15,17 @@ "@mui/material": "^5.15.5", "axios": "^1.6.5", "eslint-plugin-tsdoc": "^0.2.17", + "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.2" + "react-router-dom": "^6.21.2", + "uuid": "^9.0.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.18.0", "@typescript-eslint/parser": "^6.18.0", "@vitejs/plugin-react-swc": "^3.5.0", @@ -315,6 +318,15 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -2020,6 +2032,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5389,6 +5407,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -6736,6 +6762,35 @@ "node": ">=0.10.0" } }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", @@ -8892,10 +8947,13 @@ "dev": true }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 9a7c301..328aab1 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,17 @@ "@mui/material": "^5.15.5", "axios": "^1.6.5", "eslint-plugin-tsdoc": "^0.2.17", + "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.2" + "react-router-dom": "^6.21.2", + "uuid": "^9.0.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", "@types/react": "^18.2.47", "@types/react-dom": "^18.2.18", + "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^6.18.0", "@typescript-eslint/parser": "^6.18.0", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/src/App.css b/src/App.css index ee27c82..e69de29 100644 --- a/src/App.css +++ b/src/App.css @@ -1,17 +0,0 @@ -/* #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} */ - -.dataset-name-etc-container { - overflow: hidden; -} - -.dataset-name { - max-width: 90%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/src/App.cy.tsx b/src/App.cy.tsx deleted file mode 100644 index d62326c..0000000 --- a/src/App.cy.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import App from './App' - -describe('', () => { - it('renders', () => { - // see: https://on.cypress.io/mounting-react - cy.mount() - }) -}) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index af1108b..f6524d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,20 @@ import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import axios, { AxiosResponse } from 'axios'; +import { Alert, Grow } from '@mui/material'; +import { SnackbarProvider, enqueueSnackbar } from 'notistack'; import { queryURL, attributesURL, isFederationAPI, nodesURL } from './utils/constants'; -import { RetrievedAttributeOption, AttributeOption, NodeOption, Result } from './utils/types'; +import { + RetrievedAttributeOption, + AttributeOption, + NodeOption, + FieldInput, + FieldInputOption, + Result, +} from './utils/types'; import QueryForm from './components/QueryForm'; import ResultContainer from './components/ResultContainer'; +import Navbar from './components/Navbar'; import './App.css'; function App() { @@ -12,74 +23,302 @@ function App() { const [nodeOptions, setNodeOptions] = useState([ { NodeName: 'All', ApiURL: 'allNodes' }, ]); + + const [alertDismissed, setAlertDismissed] = useState(false); + + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [node, setNode] = useState([{ label: 'All', id: 'allNodes' }]); + const [minAge, setMinAge] = useState(null); + const [maxAge, setMaxAge] = useState(null); + const [sex, setSex] = useState(null); + const [diagnosis, setDiagnosis] = useState(null); + const [isControl, setIsControl] = useState(false); + const [minNumSessions, setMinNumSessions] = useState(null); + const [assessmentTool, setAssessmentTool] = useState(null); + const [imagingModality, setImagingModality] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + useEffect(() => { - async function fetchOptions( - dataElementURI: string, - setOptions: (options: AttributeOption[]) => void - ) { + async function getAttributes(dataElementURI: string) { try { const response: AxiosResponse = await axios.get( `${attributesURL}${dataElementURI}` ); - if (response.data[dataElementURI].length === 0) { - // TODO: make into a toast - } else { - setOptions(response.data[dataElementURI]); - } - } catch (err) { - // TODO: make into a toast - console.log('Failed to retrieve attribtues options', err); + return response.data[dataElementURI]; + } + catch (err) { + return null; } } - async function fetchNodes() { + getAttributes('nb:Diagnosis').then(diagnosisResponse => { + if (diagnosisResponse === null) { + enqueueSnackbar('Failed to retrieve Diagnosis options', { variant: 'error' }); + } else if (diagnosisResponse.length === 0) { + enqueueSnackbar('No options found for Diagnosis', { variant: 'info' }); + } else { + setDiagnosisOptions(diagnosisResponse); + } + }); + + getAttributes('nb:Assessment').then(assessmentResponse => { + if (assessmentResponse === null) { + enqueueSnackbar('Failed to retrieve Assessment Tool options', { variant: 'error' }); + } else if (assessmentResponse.length === 0) { + enqueueSnackbar('No options found for Assessment Tool', { variant: 'info' }); + } else { + setAssessmentOptions(assessmentResponse); + } + }); + + async function getNodeOptions(fetchURL: string) { try { - const response: AxiosResponse<[]> = await axios.get(nodesURL); - setNodeOptions([...response.data, { NodeName: 'All', ApiURL: 'allNodes' }]); - } catch (err) { - // TODO: make into a toast - console.log('Failed to retrieve nodes', err); + const response: AxiosResponse = await axios.get(fetchURL); + return response.data; + } + catch (err) { + return null; } } if (isFederationAPI) { - fetchNodes(); + getNodeOptions(nodesURL).then(nodeResponse => { + if (nodeResponse === null) { + enqueueSnackbar('Failed to retrieve Node options', { variant: 'error' }); + } else if (nodeResponse.length === 0) { + enqueueSnackbar('No options found for Node', { variant: 'info' }); + } else { + setNodeOptions([...nodeResponse, { NodeName: 'All', ApiURL: 'allNodes' }]); + } + }); + } + + }, []); + + useEffect(() => { + if (nodeOptions.length > 1) { + const searchParamNodes: string[] = searchParams.getAll('node'); + if (searchParamNodes) { + const matchedOptions: FieldInputOption[] = searchParamNodes + .map((nodeName) => { + const foundOption = nodeOptions.find((option) => option.NodeName === nodeName); + return foundOption ? { label: nodeName, id: foundOption.ApiURL } : { label: nodeName, id: '' }; + }) + .filter((option) => option.id !== ''); + // If there is no node in the search params, set it to All + if (matchedOptions.length === 0) { + setSearchParams({ node: ['All'] }); + setNode([{ label: 'All', id: 'allNodes' }]); + } + // If there is any node besides All selected, remove All from the list + else if ( + matchedOptions.length > 1 && + matchedOptions.some((option) => option.id === 'allNodes') + ) { + const filteredNode: FieldInputOption[] = matchedOptions.filter( + (n) => n.id !== 'allNodes' + ); + setNode(filteredNode); + setSearchParams({ node: filteredNode.map((n) => n.label) }); + } else { + setNode(matchedOptions); + } + } } + }, [searchParams, setSearchParams, nodeOptions, node]); - fetchOptions('nb:Diagnosis', setDiagnosisOptions); - fetchOptions('nb:Assessment', setAssessmentOptions); - }, []); + function showAlert() { + if (node && Array.isArray(node)) { + const openNeuroIsAnOption = nodeOptions.find((n) => n.NodeName === 'OpenNeuro'); + const isOpenNeuroSelected = node.find( + (n) => n.label === 'OpenNeuro' || (n.label === 'All' && openNeuroIsAnOption) + ); + return isOpenNeuroSelected && !alertDismissed; + } + return alertDismissed; + } + + function updateCategoricalQueryParams(fieldLabel: string, value: FieldInput) { + switch (fieldLabel) { + case 'Neurobagel graph': + setNode(value); + if (Array.isArray(value)) { + setSearchParams({ node: value.map((n) => n.label) }); + } + break; + case 'Sex': + setSex(value); + break; + case 'Diagnosis': + setDiagnosis(value); + break; + case 'Assessment tool': + setAssessmentTool(value); + break; + case 'Imaging modality': + setImagingModality(value); + break; + default: + break; + } + } + + function updateContinuousQueryParams(fieldLabel: string, value: number | null) { + switch (fieldLabel) { + case 'Minimum age': + setMinAge(value); + break; + case 'Maximum age': + setMaxAge(value); + break; + case 'Minimum number of sessions': + setMinNumSessions(value); + break; + default: + break; + } + } + + /** + * Sets the value of a query parameter on the query parameter object. + * + * @remarks + * This is a utility function to used to help construct the query URL using a URLSearchParams object. + * + * @param param - The name of the query parameter + * @param value - The value of the query parameter + * @param queryParamObject - The query parameter object which contains the query parameters + * @returns void + */ + function setQueryParam(param: string, value: FieldInput, queryParamObject: URLSearchParams) { + if (Array.isArray(value)) { + value.forEach((v) => { + queryParamObject.append(param, v.id); + }); + } else { + queryParamObject.set(param, value?.id ?? ''); + } + } + + /** + * Creates the query URL from user input using a URLSearchParams object. + * + * @remarks + * This function utilizes the `setQueryParam` function to set categorical query parameters. + * + * @returns The query URL. + */ + function constructQueryURL() { + const queryParams = new URLSearchParams(); + + setQueryParam('node_url', node, queryParams); + queryParams.set('min_age', minAge ? minAge.toString() : ''); + queryParams.set('max_age', maxAge ? maxAge.toString() : ''); + setQueryParam('sex', sex, queryParams); + setQueryParam('diagnosis', isControl ? null : diagnosis, queryParams); + queryParams.set('is_control', isControl ? 'true' : ''); + queryParams.set('min_num_sessions', minNumSessions ? minNumSessions.toString() : ''); + setQueryParam('assessment', assessmentTool, queryParams); + setQueryParam('image_modal', imagingModality, queryParams); + + // Remove keys with empty values + const keysToDelete: string[] = []; + + queryParams.forEach((value, key) => { + // if All option is selected for nodes field, delete all node_urls + if (value === '' || value === 'allNodes') { + keysToDelete.push(key); + } + }); - async function submitQuery(url: string) { + keysToDelete.forEach((key) => { + queryParams.delete(key); + }); + + return `${queryURL}${queryParams.toString()}`; + } + + async function submitQuery() { + setLoading(true); + const url: string = constructQueryURL(); try { const response = await axios.get(url); setResult(response.data); } catch (error) { - console.log(error); + enqueueSnackbar('Failed to retrieve results', { variant: 'error' }); } + setLoading(false); } return ( -
-
- submitQuery(url)} - /> -
-
- a.dataset_name.localeCompare(b.dataset_name)) : null - } - /> + <> + + + {showAlert() && ( + <> + + { + setAlertDismissed(true); + }} + > + The OpenNeuro node is being actively annotated at the participant level and does not + include all datasets yet. Check back soon to find more data. If you would like to + contribute annotations for existing OpenNeuro datasets, please get in touch + through  + + GitHub + + . + + +
+ + )} + +
+
+ + updateCategoricalQueryParams(label, value) + } + updateContinuousQueryParams={(label, value) => + updateContinuousQueryParams(label, value) + } + loading={loading} + onSubmitQuery={() => submitQuery()} + /> +
+
+ a.dataset_name.localeCompare(b.dataset_name)) : null + } + /> +
-
+ ); } diff --git a/src/assets/favicon.ico b/src/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7d111eca9a254017c16cb0a8c2f59cde9838cd23 GIT binary patch literal 1923 zcmV-}2YmR6P)EX>4Tx04R}tkv&MmKpe$iQ^le!4rUN>$WWc^q9Ts93Pq?8YK2xEOfLO`CM`*d zi=*ILaPVWX>fqw6tAnc`2!4RLx;QDiNQwVT3N2zhIPS;0dyl(!fY7Wk-Rg-0x?!8? zWJ1d3R;9jI3?Kp#A;c7B)#hY51>f;?j{slqVm!XA`d?D+x z#(9ggUa7P8J^2g6d1Gan>oiA^#3Gg;LxPGr%Ba9bj8=mb3u)R<`1nU$zf3NbTxBqF zET9Sh z2u%=H_Y31tW;H~ZEW&h2Se;>3KLg`erZ)GF|785tRO}+8(T>mG`wVMp5LQZ_`eNCm zSc7LucjTUWap%NXa|-1PBn?OmNDS2fj`LUm*<;Pj`nqY-k)bI8IK5(XJ{$gniUJSC zbb-M)291Y<&|!<}Fkqqa0%e0kO;e8MB@97ZS*=@ZbwHSMgsD9c(0AUpzPKwizjSv- zw;v8Ot1$yz9&~%$Pz1*`p7k@|SJ*hVJ<0=rl)Y^i9b9Otf{-ARh8 zkaW!0$KwML17hgA;0C(7G6FznMTHv`zYrsKfTV$t5tO-JKrjQ14M^M;2_i8c2KGVe zWrrR){Au=O^H- zPe^)1!Zbn#G|WBe)(kYveaV&5%r%fWD{k!jVW697OEfCxT7h!5EY z66;t6BAkHUUL8mmfM0i8kT7H;YGc8siHN%}iJ;i+zESA{uvi0bB*r8nb&*i%_9P`0 z$l%Foa{yo}CRfTo36OtGE~`>S1=`Kp71ISG%yGsW3`qVjK%8DnSVdEcJC@^pE((+3&rFeNmrp;om z(qMVR{py^yX3zGK$h2Q;Wf!3nqG`0S$`vL6rppe0whI9OdUq|V0ga2U)dG$W0EDSS zggiN=D|Z3_v~Ads0bUw~njU3Mi-nbona*oXkGU(0jvg!B;mmWkda=Bm1q6RzxVr>I z)iC4cJ2p=%F@i4B8-ZvZ0N5kaP-3QL4hrM~0CZGVmU5q7$Nf@|6tqSFLjmC1)|VO{ zA6Ys3%ka=ZE!3V|{J~uTWxN(D#~Dx8od(oZ1IllFQlcCPo=|060tt62rEiTDsy*qiDJ*l9XyoR?)iR z@P99EEW5=7^_#GA#5@^m1;TiOwOVCb;QLq=H7c)dv+90f#0OCEv2Z>p6{sKh@8G57 zXOQ@_#BwXQlj3D|Fsv!xj2tuHY_l0%V@Yut689l#P>Q-^8bIz%z!@))UOOB+3x};K zI{UxwhTa{EauMWt&2-B7Y)xJG+`0NPhtt%pn#ncbLekAl^<>)C<-EM45=|3C)L*Qrg9Q(hHa@>{*=))!FZZQl_g~w2)$H&k@~Z#<002ov JPDHLkV1n}HZZH4< literal 0 HcmV?d00001 diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/components/ContinuousField.tsx b/src/components/ContinuousField.tsx index 2bcb2b7..e1e80b5 100644 --- a/src/components/ContinuousField.tsx +++ b/src/components/ContinuousField.tsx @@ -1,21 +1,26 @@ import TextField from '@mui/material/TextField'; -function ContinuousField({ - label, - onFieldChange, -}: { +export interface ContinuousFieldProps { + helperText?: string; label: string; - onFieldChange: (fieldLabel: string, value: string) => void; -}) { + onFieldChange: (fieldLabel: string, value: number) => void; +} + +function ContinuousField({ helperText, label, onFieldChange }: ContinuousFieldProps) { + const showError: boolean= helperText !== ''; return ( - // TODO: see if we can make it so TextField returns type number instead of string as its doing now onFieldChange(label, event.target.value)} + onChange={(event) => onFieldChange(label, parseInt(event.target.value, 10))} + helperText={helperText} /> ); } +ContinuousField.defaultProps = { + helperText: '', +}; + export default ContinuousField; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..69d1e89 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Toolbar, Typography, IconButton, Badge } from '@mui/material'; +import GitHubIcon from '@mui/icons-material/GitHub'; + +function Navbar() { + const [latestReleaseTag, setLatestReleaseTag] = useState(''); + + useEffect(() => { + // TODO: replace with react-query-tool once there is a release + const GHApiURL = 'https://api.github.com/repos/neurobagel/query-tool/releases/latest'; + axios.get(GHApiURL) + .then((response) => { + const {data} = response; + setLatestReleaseTag(data.tag_name); + }).catch(() => { + setLatestReleaseTag('beta'); + }); + }, []); + + return ( + +
+
+ Logo +
+ + Neurobagel Query + + + Define and find cohorts at the subject level + +
+
+
+ + Documentation + + + + +
+
+
+ ); +} + +export default Navbar; diff --git a/src/components/QueryForm.tsx b/src/components/QueryForm.tsx index a3d06d3..a3340d8 100644 --- a/src/components/QueryForm.tsx +++ b/src/components/QueryForm.tsx @@ -1,9 +1,13 @@ -import { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Button, FormControlLabel, Checkbox } from '@mui/material'; +import { + Button, + FormControlLabel, + Checkbox, + CircularProgress, + FormHelperText, +} from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; import { isFederationAPI, sexes, modalities } from '../utils/constants'; -import { FieldInput, FieldInputOption, NodeOption, AttributeOption } from '../utils/types'; +import { NodeOption, AttributeOption, FieldInput } from '../utils/types'; import CategoricalField from './CategoricalField'; import ContinuousField from './ContinuousField'; @@ -11,169 +15,68 @@ function QueryForm({ nodeOptions, diagnosisOptions, assessmentOptions, - apiQueryURL, + node, + minAge, + maxAge, + sex, + diagnosis, + isControl, + minNumSessions, + setIsControl, + assessmentTool, + imagingModality, + updateCategoricalQueryParams, + updateContinuousQueryParams, + loading, onSubmitQuery, }: { nodeOptions: NodeOption[]; diagnosisOptions: AttributeOption[]; assessmentOptions: AttributeOption[]; - apiQueryURL: string; - onSubmitQuery: (url: string) => void; + node: FieldInput; + minAge: number | null; + maxAge: number | null; + sex: FieldInput; + diagnosis: FieldInput; + isControl: boolean; + setIsControl: (value: boolean) => void; + minNumSessions: number | null; + assessmentTool: FieldInput; + imagingModality: FieldInput; + updateCategoricalQueryParams: (label: string, value: FieldInput) => void; + updateContinuousQueryParams: (label: string, value: number | null) => void; + loading: boolean; + onSubmitQuery: () => void; }) { - const [node, setNode] = useState([{ label: 'All', id: 'allNodes' }]); - const [minAge, setMinAge] = useState(null); - const [maxAge, setMaxAge] = useState(null); - const [sex, setSex] = useState(null); - const [diagnosis, setDiagnosis] = useState(null); - const [isControl, setIsControl] = useState(false); - const [minNumSessions, setMinNumSessions] = useState(null); - const [assessmentTool, setAssessmentTool] = useState(null); - const [imagingModality, setImagingModality] = useState(null); - const [searchParams, setSearchParams] = useSearchParams(); - - useEffect(() => { - if (nodeOptions.length > 1) { - const searchParamNodes: string[] = searchParams.getAll('node'); - if (searchParamNodes) { - const matchedOptions: FieldInputOption[] = searchParamNodes - .map((label) => { - const foundOption = nodeOptions.find((option) => option.NodeName === label); - return foundOption ? { label, id: foundOption.ApiURL } : { label, id: '' }; - }) - .filter((option) => option.id !== ''); - // If there is no node in the search params, set it to All - if (matchedOptions.length === 0) { - setSearchParams({ node: ['All'] }); - setNode([{ label: 'All', id: 'allNodes' }]); - } - // If there is any node besides All selected, remove All from the list - else if ( - matchedOptions.length > 1 && - matchedOptions.some((option) => option.id === 'allNodes') - ) { - const filteredNode: FieldInputOption[] = matchedOptions.filter( - (n) => n.id !== 'allNodes' - ); - setNode(filteredNode); - setSearchParams({ node: filteredNode.map((n) => n.label) }); - } else { - setNode(matchedOptions); - } - } - } - }, [searchParams, setSearchParams, nodeOptions]); - - function updateCategoricalQueryParams(fieldLabel: string, value: FieldInput) { - switch (fieldLabel) { - case 'Neurobagel graph': - setNode(value); - if (Array.isArray(value)) { - setSearchParams({ node: value.map((n) => n.label) }); - } - break; - case 'Sex': - setSex(value); - break; - case 'Diagnosis': - setDiagnosis(value); - break; - case 'Assessment tool': - setAssessmentTool(value); - break; - case 'Imaging modality': - setImagingModality(value); - break; - default: - break; + + function giveMeError (value: number | null) { + if (value === null) { + // Value is default, user has not entered anything yet + return ''; } - } - - function updateContinuousQueryParams(fieldLabel: string, value: string | null) { - switch (fieldLabel) { - case 'Min age': - setMinAge(value); - break; - case 'Max age': - setMaxAge(value); - break; - case 'Minimum number of sessions': - setMinNumSessions(value); - break; - default: - break; + if (Number.isNaN(value)) { + return 'Please enter a valid number!'; } - } - - /** - * Sets the value of a query parameter on the query parameter object. - * - * @remarks - * This is a utility function to used to help construct the query URL using a URLSearchParams object. - * - * @param param - The name of the query parameter - * @param value - The value of the query parameter - * @param queryParamObject - The query parameter object which contains the query parameters - * @returns void - */ - function setQueryParam(param: string, value: FieldInput, queryParamObject: URLSearchParams) { - if (Array.isArray(value)) { - value.forEach((v) => { - queryParamObject.append(param, v.id); - }); - } else { - queryParamObject.set(param, value?.id ?? ''); + if (value < 0) { + return 'Please enter a positive number!'; } + return ''; } - /** - * Creates the query URL from user input using a URLSearchParams object. - * - * @remarks - * This function utilizes the `setQueryParam` function to set categorical query parameters. - * - * @returns The query URL. - */ - function constructQueryURL() { - const queryParams = new URLSearchParams(); - - setQueryParam('node_url', node, queryParams); - queryParams.set('min_age', minAge ?? ''); - queryParams.set('max_age', maxAge ?? ''); - setQueryParam('sex', sex, queryParams); - setQueryParam('diagnosis', isControl ? null : diagnosis, queryParams); - queryParams.set('is_control', isControl ? 'true' : ''); - queryParams.set('min_num_sessions', minNumSessions ?? ''); - setQueryParam('assessment', assessmentTool, queryParams); - setQueryParam('image_modal', imagingModality, queryParams); - - // Notes: - // 1. Deleting elements in an array as we loop over it is not good, either make a new object or filter (same thing) - // 2. using forEach on the QueryParams object, - // 3. Do the filtering first / switch before adding - // Solution: - // Push the keys to be deleted inside keysToDelete and loop over them and delete them from queryParams afterwards - const keysToDelete: string[] = []; - - queryParams.forEach((value, key) => { - // if All option is selected for nodes field, delete all node_urls - if (value === '' || value === 'allNodes') { - keysToDelete.push(key); - } - }); + const minAgeHelperText: string = giveMeError(minAge); + const maxAgeHelperText: string = giveMeError(maxAge); + const minNumSessionsHelperText: string = giveMeError(minNumSessions); - keysToDelete.forEach((key) => { - queryParams.delete(key); - }); - - return `${apiQueryURL}${queryParams.toString()}`; - } + const minAgeExceedsMaxAge: boolean = minAge && maxAge ? minAge > maxAge : false; + const disableSubmit: boolean = + minAgeExceedsMaxAge || minAgeHelperText !== '' || maxAgeHelperText !== '' || minNumSessionsHelperText !== ''; return (
{isFederationAPI && ( @@ -192,16 +95,25 @@ function QueryForm({ )}
updateContinuousQueryParams(label, value)} + helperText={minAgeExceedsMaxAge ? '' : minAgeHelperText} + label="Minimum age" + onFieldChange={updateContinuousQueryParams} />
updateContinuousQueryParams(label, value)} + helperText={minAgeExceedsMaxAge ? '' : maxAgeHelperText} + label="Maximum age" + onFieldChange={updateContinuousQueryParams} />
+ {minAgeExceedsMaxAge && ( +
+ + Value of maximum age must be greater than or equal to value of minimum age + +
+ )}
-
+
-
+
updateContinuousQueryParams(label, value)} + onFieldChange={updateContinuousQueryParams} />
-
+
({ label: a.Label, id: a.TermURL }))} @@ -249,7 +162,7 @@ function QueryForm({ inputValue={assessmentTool} />
-
+
({ @@ -260,11 +173,20 @@ function QueryForm({ inputValue={imagingModality} />
-
+
diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx index 95126db..0b436ea 100644 --- a/src/components/ResultCard.tsx +++ b/src/components/ResultCard.tsx @@ -4,8 +4,6 @@ import CardContent from '@mui/material/CardContent'; import Checkbox from '@mui/material/Checkbox'; import ButtonGroup from '@mui/material/ButtonGroup'; import Typography from '@mui/material/Typography'; -import Tooltip from '@mui/material/Tooltip'; -import Zoom from '@mui/material/Zoom'; import { modalities } from '../utils/constants'; function ResultCard({ @@ -35,18 +33,7 @@ function ResultCard({ onCheckboxChange(datasetUUID)} />
- {/* TODO: replace the tooltip with the ellipsis open and close trick */} - {datasetName}} - placement="top" - TransitionComponent={Zoom} - TransitionProps={{ timeout: 500 }} - enterDelay={500} - > - - {datasetName} - - + {datasetName} from {nodeName} {numMatchingSubjects} subjects match / {datasetTotalSubjects} total subjects @@ -54,9 +41,12 @@ function ResultCard({
- {/* TODO: fix the button's hover color issue */} {imageModals.sort().map((modal) => ( - ))} diff --git a/src/components/ResultContainer.tsx b/src/components/ResultContainer.tsx index 592948b..378625c 100644 --- a/src/components/ResultContainer.tsx +++ b/src/components/ResultContainer.tsx @@ -12,7 +12,15 @@ function ResultContainer({ result }: { result: Result[] | null }) { ? result.length === download.length && result.every((r) => download.includes(r.dataset_uuid)) : false; - // TODO: deal with erros + let numOfMatchedDatasets = 0; + let numOfMatchedSubjects = 0; + if (result) { + result.forEach((item) => { + numOfMatchedDatasets += 1; + numOfMatchedSubjects += item.num_matching_subjects; + }); + } + const summaryStats = `Summary stats: ${numOfMatchedDatasets} datasets, ${numOfMatchedSubjects} subjects`; /** * Updates the download array. @@ -163,7 +171,7 @@ function ResultContainer({ result }: { result: Result[] | null }) { } return ( <> -
+
+
+ {summaryStats} +
{result.map((item) => ( {/* CSS injection order for MUI and tailwind: https://mui.com/material-ui/guides/interoperability/#tailwind-css */} - + ); diff --git a/src/sum.js b/src/sum.js deleted file mode 100644 index 13e99ea..0000000 --- a/src/sum.js +++ /dev/null @@ -1,4 +0,0 @@ -// sum.js -export default function sum(a, b) { - return a + b - } \ No newline at end of file diff --git a/src/sum.test.js b/src/sum.test.js deleted file mode 100644 index 033c625..0000000 --- a/src/sum.test.js +++ /dev/null @@ -1,8 +0,0 @@ -// sum.test.js -// eslint-disable-next-line import/no-extraneous-dependencies -import { expect, test } from 'vitest' -import { sum } from './sum' - -test('adds 1 + 2 to equal 3', () => { - expect(sum(1, 2)).toBe(3) -}) \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 0b278c9..0b030c6 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -21,42 +21,42 @@ export const sexes: { [key: string]: string } = { }; export const modalities: { - [key: string]: { label: string; TermURL: string; name: string; style: string }; + [key: string]: { label: string; TermURL: string; name: string; bgColor: string }; } = { 'http://purl.org/nidash/nidm#ArterialSpinLabeling': { label: 'Arterial Spin Labeling', TermURL: 'nidm:ArterialSpinLabeling', name: 'ASL', - style: 'bg-zinc-800 text-white', + bgColor: 'bg-zinc-800', }, 'http://purl.org/nidash/nidm#DiffusionWeighted': { label: 'Diffusion Weighted', TermURL: 'nidm:DiffusionWeighted', name: 'DWI', - style: 'bg-red-700 text-white', + bgColor: 'bg-red-700', }, 'http://purl.org/nidash/nidm#EEG': { label: 'Electroencephalogram', TermURL: 'nidm:EEG', name: 'EEG', - style: 'bg-rose-300 text-white', + bgColor: 'bg-rose-300', }, 'http://purl.org/nidash/nidm#FlowWeighted': { label: 'Flow Weighted', TermURL: 'nidm:FlowWeighted', name: 'Flow', - style: 'bg-sky-700 text-white', + bgColor: 'bg-sky-700', }, 'http://purl.org/nidash/nidm#T1Weighted': { label: 'T1 Weighted', TermURL: 'nidm:T1Weighted', name: 'T1', - style: 'bg-yellow-500 text-white', + bgColor: 'bg-yellow-500', }, 'http://purl.org/nidash/nidm#T2Weighted': { label: 'T2 Weighted', TermURL: 'nidm:T2Weighted', name: 'T2', - style: 'bg-green-600 text-white', + bgColor: 'bg-green-600', }, }; diff --git a/src/utils/types.ts b/src/utils/types.ts index ee29f0f..b930a05 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -50,4 +50,18 @@ export interface CategoricalFieldProps { inputValue: FieldInput; } +export type ToastProps = { + key?: number | string; + title?: string; + message?: string; + children?: React.ReactElement; + duration?: number; + severity?: 'success' | 'info' | 'warning' | 'error'; + position?: { + vertical?: 'top' | 'bottom'; + horizontal?: 'left' | 'right' | 'center'; + }; + onClose?: () => void; +}; + export type FieldInput = FieldInputOption | FieldInputOption[] | null; diff --git a/tailwind.config.js b/tailwind.config.js index 8c14650..ced6258 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,8 +4,8 @@ export default { theme: { extend: { gridTemplateRows: { - 7: 'repeat(7, auto)', 8: 'repeat(8, auto)', + 9: 'repeat(9, auto)', }, }, },