From 3eb9043059e00c14111d7b7aec1b1dfcf52f9ceb Mon Sep 17 00:00:00 2001 From: Arman Jahanpour <77515879+rmanaem@users.noreply.github.com> Date: Thu, 24 Oct 2024 15:46:51 -0400 Subject: [PATCH] [ENH] Implemented logic to allow for querying pipeline name and version (#322) * Added types for pipeline related fields * Implemented logic for pipeline fields * Implemented dropdowns for pipeline fields * Implemented logic for displaying pipelines and their versions * Implemented logic for displaying pipeline info in the downloadable files * Updated fetching of diagnoses and assessment terms * Added `RetrievedVersions` interface * Implemented logic to retrieve pipeline names and versions * Implemented logic to populate pipeline related fields * Updated mocked responses * Updated e2e tests * Added tests for pipeline related responses * Updated relevant component tests * Added an assertion for pipeline info in `participants-level-results.tsv` * Added a test for enabling of pipeline version field * Added assertions for displaying available pipelines * Implemented assertions for displaying pipeline related fields * Added `All` option for pipeline version * Fixed the `APIRequest` e2e test * Remvoed the `only` method from `Form` e2e test * Addressed suggested changes from PR review --- cypress/component/QueryForm.cy.tsx | 19 ++ cypress/component/ResultCard.cy.tsx | 17 +- cypress/e2e/APIRequests.cy.ts | 278 ++++++++++++++++++++++++--- cypress/e2e/Alert.cy.ts | 19 +- cypress/e2e/Auth.cy.ts | 4 +- cypress/e2e/Checkbox.cy.ts | 4 +- cypress/e2e/Form.cy.ts | 32 ++- cypress/e2e/ResultsTSV.cy.ts | 3 + cypress/fixtures/mocked-responses.ts | 89 +++++++++ src/App.tsx | 114 +++++++++-- src/components/QueryForm.tsx | 71 ++++++- src/components/ResultCard.tsx | 38 +++- src/components/ResultContainer.tsx | 18 +- src/utils/constants.ts | 19 +- src/utils/types.ts | 14 ++ 15 files changed, 661 insertions(+), 78 deletions(-) diff --git a/cypress/component/QueryForm.cy.tsx b/cypress/component/QueryForm.cy.tsx index 17558928..eba1f0d5 100644 --- a/cypress/component/QueryForm.cy.tsx +++ b/cypress/component/QueryForm.cy.tsx @@ -42,6 +42,11 @@ const props = { setIsControl: () => {}, assessmentTool: null, imagingModality: null, + pipelineVersion: null, + pipelineName: null, + pipelines: { + 'np:fmriprep': ['0.2.3', '23.1.3'], + }, updateCategoricalQueryParams: () => {}, updateContinuousQueryParams: () => {}, loading: false, @@ -66,6 +71,9 @@ describe('QueryForm', () => { setIsControl={props.setIsControl} assessmentTool={props.assessmentTool} imagingModality={props.imagingModality} + pipelineVersion={props.pipelineVersion} + pipelineName={props.pipelineName} + pipelines={props.pipelines} updateCategoricalQueryParams={props.updateCategoricalQueryParams} updateContinuousQueryParams={props.updateContinuousQueryParams} loading={props.loading} @@ -85,6 +93,8 @@ describe('QueryForm', () => { ); cy.get('[data-cy="Assessment tool-categorical-field"]').should('be.visible'); cy.get('[data-cy="Imaging modality-categorical-field"]').should('be.visible'); + cy.get('[data-cy="Pipeline name-categorical-field"]').should('be.visible'); + cy.get('[data-cy="Pipeline version-categorical-field"]').should('be.visible'); cy.get('[data-cy="submit-query-button"]').should('be.visible'); }); it('Fires updateCategoricalQueryParams event handler with the appropriate payload when a categorical field is selected', () => { @@ -105,6 +115,9 @@ describe('QueryForm', () => { setIsControl={props.setIsControl} assessmentTool={props.assessmentTool} imagingModality={props.imagingModality} + pipelineVersion={props.pipelineVersion} + pipelineName={props.pipelineName} + pipelines={props.pipelines} updateCategoricalQueryParams={updateCategoricalQueryParamsSpy} updateContinuousQueryParams={props.updateContinuousQueryParams} loading={props.loading} @@ -136,6 +149,9 @@ describe('QueryForm', () => { setIsControl={props.setIsControl} assessmentTool={props.assessmentTool} imagingModality={props.imagingModality} + pipelineVersion={props.pipelineVersion} + pipelineName={props.pipelineName} + pipelines={props.pipelines} updateCategoricalQueryParams={props.updateCategoricalQueryParams} updateContinuousQueryParams={updateContinuousQueryParamsSpy} loading={props.loading} @@ -163,6 +179,9 @@ describe('QueryForm', () => { setIsControl={props.setIsControl} assessmentTool={props.assessmentTool} imagingModality={props.imagingModality} + pipelineVersion={props.pipelineVersion} + pipelineName={props.pipelineName} + pipelines={props.pipelines} updateCategoricalQueryParams={props.updateCategoricalQueryParams} updateContinuousQueryParams={props.updateContinuousQueryParams} loading={props.loading} diff --git a/cypress/component/ResultCard.cy.tsx b/cypress/component/ResultCard.cy.tsx index a8691a70..38bdc84e 100644 --- a/cypress/component/ResultCard.cy.tsx +++ b/cypress/component/ResultCard.cy.tsx @@ -10,12 +10,18 @@ const props = { 'http://purl.org/nidash/nidm#ArterialSpinLabeling', 'http://purl.org/nidash/nidm#DiffusionWeighted', ], + pipelines: { + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/fmriprep': [ + '0.2.3', + '23.1.3', + ], + }, checked: true, onCheckboxChange: () => {}, }; describe('ResultCard', () => { - it('Displays a MUI card with node name, dataset name, number of matched subjects, total number of subjects, and a checkbox', () => { + it('Displays a MUI card with node name, dataset name, number of matched subjects, total number of subjects, available pipelines, and a checkbox', () => { cy.mount( { datasetTotalSubjects={props.datasetTotalSubjects} numMatchingSubjects={props.numMatchingSubjects} imageModals={props.imageModals} + pipelines={props.pipelines} checked={props.checked} onCheckboxChange={props.onCheckboxChange} /> @@ -37,9 +44,14 @@ describe('ResultCard', () => { .should('contain', 'ASL') .should('have.class', 'bg-zinc-800'); cy.get('[data-cy="card-some uuid"] button') - .eq(1) + .eq(2) .should('contain', 'DWI') .should('have.class', 'bg-red-700'); + + cy.get('[data-cy="card-some uuid-available-pipelines-button"]').trigger('mouseover', { + force: true, + }); + cy.get('.MuiTooltip-tooltip').should('contain', 'fmriprep 0.2.3'); }); it('Fires onCheckboxChange event handler with the appropriate payload when the checkbox is clicked', () => { const onCheckboxChangeSpy = cy.spy().as('onCheckboxChangeSpy'); @@ -51,6 +63,7 @@ describe('ResultCard', () => { datasetTotalSubjects={props.datasetTotalSubjects} numMatchingSubjects={props.numMatchingSubjects} imageModals={props.imageModals} + pipelines={props.pipelines} checked={false} onCheckboxChange={onCheckboxChangeSpy} /> diff --git a/cypress/e2e/APIRequests.cy.ts b/cypress/e2e/APIRequests.cy.ts index a2babdb7..d161b67f 100644 --- a/cypress/e2e/APIRequests.cy.ts +++ b/cypress/e2e/APIRequests.cy.ts @@ -12,6 +12,14 @@ import { emptyAssessmentToolOptions, partiallyFailedAssessmentToolOptions, failedAssessmentToolOptions, + pipelineOptions, + emptyPipelineOPtions, + partiallyFailedPipelineOptions, + failedPipelineOptions, + emptyPipelineVersionOptions, + partiallyFailedPipelineVersionOptions, + failedPipelineVersionOptions, + pipelineVersionOptions, } from '../fixtures/mocked-responses'; describe('Successful API attribute responses', () => { @@ -26,28 +34,53 @@ describe('Successful API attribute responses', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); + cy.intercept( + { + method: 'GET', + url: 'pipelines/np:fmriprep/versions', + }, + pipelineVersionOptions + ).as('getPipelineVersionsOptions'); cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); }); it('Loads correctly if all node responses are successful', () => { cy.get('.notistack-SnackbarContainer').should('not.exist'); }); + it('Loads pipeline versions correctly if all node responses are successful', () => { + cy.get('[data-cy="close-auth-dialog-button"]').click(); + cy.get('[data-cy="Pipeline name-categorical-field"]').type('fmri{downarrow}{enter}'); + cy.wait('@getPipelineVersionsOptions'); + cy.get('[data-cy="Pipeline version-categorical-field"]').type('23.1.3{downarrow}{enter}'); + }); it('Empty diagnosis response makes info toast appear', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, emptyDiagnosisOptions ).as('getDiagnosisOptions'); @@ -55,13 +88,13 @@ describe('Successful API attribute responses', () => { cy.wait('@getDiagnosisOptions'); cy.get('.notistack-SnackbarContainer') .find('.notistack-MuiContent-info') - .should('contain', 'No Diagnosis options were available'); + .should('contain', 'No diagnoses options were available'); }); it('Empty assessment response makes info toast appear', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, emptyAssessmentToolOptions ).as('getAssessmentToolOptions'); @@ -70,7 +103,37 @@ describe('Successful API attribute responses', () => { cy.get('.notistack-SnackbarContainer') .find('.notistack-MuiContent-info') - .should('contain', 'No Assessment options were available'); + .should('contain', 'No assessments options were available'); + }); + it('Empty pipeline response makes info toast appear', () => { + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + emptyPipelineOPtions + ).as('getPipelineOptions'); + cy.visit('/'); + cy.wait('@getPipelineOptions'); + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-info') + .should('contain', 'No pipelines options were available'); + }); + it('Empty pipeline version response makes info toast appear', () => { + cy.intercept( + { + method: 'GET', + url: 'pipelines/np:fmriprep/versions', + }, + emptyPipelineVersionOptions + ).as('getPipelineVersionsOptions'); + cy.visit('/'); + cy.get('[data-cy="close-auth-dialog-button"]').click(); + cy.get('[data-cy="Pipeline name-categorical-field"]').type('fmri{downarrow}{enter}'); + cy.wait('@getPipelineVersionsOptions'); + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-info') + .should('contain', 'No fmriprep versions were available'); }); }); @@ -86,19 +149,38 @@ describe('Partially successful API attribute responses', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, partiallyFailedDiagnosisToolOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, partiallyFailedAssessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + partiallyFailedPipelineOptions + ).as('getPipelineOptions'); + cy.intercept( + { + method: 'GET', + url: 'pipelines/np:fmriprep/versions', + }, + partiallyFailedPipelineVersionOptions + ).as('getPipelineVersionsOptions'); cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); }); it('Shows warning for node that failed Assessment tool option request', () => { cy.get('.notistack-SnackbarContainer') @@ -110,6 +192,19 @@ describe('Partially successful API attribute responses', () => { .find('.notistack-MuiContent-warning') .should('contain', 'NoDiagnosisNode'); }); + it('Shows warning for node that failed Pipeline option request', () => { + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-warning') + .should('contain', 'NoPipelineNode'); + }); + it('Shows warning for node that failed Pipeline version option request', () => { + cy.get('[data-cy="close-auth-dialog-button"]').click(); + cy.get('[data-cy="Pipeline name-categorical-field"]').type('fmri{downarrow}{enter}'); + cy.wait('@getPipelineVersionsOptions'); + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-warning') + .should('contain', 'NoPipelineVersionNode'); + }); }); describe('Failed API attribute responses', () => { @@ -124,29 +219,107 @@ describe('Failed API attribute responses', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, failedDiagnosisToolOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, failedAssessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + failedPipelineOptions + ).as('getPipelineOptions'); + cy.intercept( + { + method: 'GET', + url: 'pipelines/np:fmriprep/versions', + }, + failedPipelineVersionOptions + ).as('getPipelineVersionsOptions'); cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); }); it('Shows error toast for failed Assessment tool options', () => { cy.get('.notistack-SnackbarContainer') .find('.notistack-MuiContent-error') - .should('contain', 'Assessment'); + .should('contain', 'assessments'); }); it('Shows error toast for failed Diagnosis options', () => { cy.get('.notistack-SnackbarContainer') .find('.notistack-MuiContent-error') - .should('contain', 'Diagnosis'); + .should('contain', 'diagnoses'); + }); + it('Shows error toast for failed Pipeline options', () => { + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-error') + .should('contain', 'pipelines'); + }); +}); + +// We need to do this separately for the pipeline versions as it requires selecting a pipeline (name) +describe('Failed API attribute responses continued', () => { + it('Shows error toast for failed Pipeline version options', () => { + cy.intercept( + { + method: 'GET', + url: '/nodes', + }, + nodeOptions + ).as('getNodes'); + cy.intercept( + { + method: 'GET', + url: '/diagnoses', + }, + failedDiagnosisToolOptions + ).as('getDiagnosisOptions'); + cy.intercept( + { + method: 'GET', + url: '/assessments', + }, + failedAssessmentToolOptions + ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); + cy.intercept( + { + method: 'GET', + url: 'pipelines/np:fmriprep/versions', + }, + failedPipelineVersionOptions + ).as('getPipelineVersionsOptions'); + cy.visit('/'); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); + cy.get('[data-cy="close-auth-dialog-button"]').click(); + cy.get('[data-cy="Pipeline name-categorical-field"]').type('fmri{downarrow}{enter}'); + cy.wait('@getPipelineVersionsOptions'); + cy.get('.notistack-SnackbarContainer') + .find('.notistack-MuiContent-error') + .should('contain', 'versions'); }); }); @@ -172,19 +345,31 @@ describe('Successful API query requests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); // TODO: remove this // Bit of a hacky way to close the auth dialog // But we need to do it until we make auth an always-on feature @@ -220,7 +405,7 @@ describe('Regression Tests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, badDiagnosisOptions ).as('getDiagnosisOptions'); @@ -228,13 +413,26 @@ describe('Regression Tests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); + cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); // TODO: remove this // Bit of a hacky way to close the auth dialog // But we need to do it until we make auth an always-on feature @@ -270,7 +468,7 @@ describe('Partially successful API query requests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); @@ -278,13 +476,26 @@ describe('Partially successful API query requests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); + cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); // TODO: remove this // Bit of a hacky way to close the auth dialog // But we need to do it until we make auth an always-on feature @@ -321,7 +532,7 @@ describe('Failed API query requests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); @@ -329,13 +540,26 @@ describe('Failed API query requests', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); + cy.visit('/'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); // TODO: remove this // Bit of a hacky way to close the auth dialog // But we need to do it until we make auth an always-on feature diff --git a/cypress/e2e/Alert.cy.ts b/cypress/e2e/Alert.cy.ts index 2b9c51ce..9c3ec5e3 100644 --- a/cypress/e2e/Alert.cy.ts +++ b/cypress/e2e/Alert.cy.ts @@ -1,6 +1,7 @@ import { failedAssessmentToolOptions, failedDiagnosisToolOptions, + pipelineOptions, nodeOptions, } from '../fixtures/mocked-responses'; @@ -16,19 +17,31 @@ describe('Alert', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: 'diagnoses', }, failedDiagnosisToolOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, failedAssessmentToolOptions ).as('getAssessmentToolOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); cy.visit('/?node=All'); - cy.wait(['@getNodes', '@getDiagnosisOptions', '@getAssessmentToolOptions']); + cy.wait([ + '@getNodes', + '@getDiagnosisOptions', + '@getAssessmentToolOptions', + '@getPipelineOptions', + ]); // TODO: remove this // Bit of a hacky way to close the auth dialog // But we need to do it until we make auth an always-on feature diff --git a/cypress/e2e/Auth.cy.ts b/cypress/e2e/Auth.cy.ts index 8b7bbb34..e4f86ac2 100644 --- a/cypress/e2e/Auth.cy.ts +++ b/cypress/e2e/Auth.cy.ts @@ -12,14 +12,14 @@ describe('Authentication flow', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, assessmentToolOptions ).as('getAssessmentToolOptions'); diff --git a/cypress/e2e/Checkbox.cy.ts b/cypress/e2e/Checkbox.cy.ts index f57b3a24..b3c20b66 100644 --- a/cypress/e2e/Checkbox.cy.ts +++ b/cypress/e2e/Checkbox.cy.ts @@ -18,14 +18,14 @@ describe('Dataset result checkbox', () => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, failedDiagnosisToolOptions ).as('getDiagnosisOptions'); cy.intercept( { method: 'GET', - url: '/attributes/nb:Assessment', + url: '/assessments', }, failedAssessmentToolOptions ).as('getAssessmentToolOptions'); diff --git a/cypress/e2e/Form.cy.ts b/cypress/e2e/Form.cy.ts index baf0c8d1..38dbb717 100644 --- a/cypress/e2e/Form.cy.ts +++ b/cypress/e2e/Form.cy.ts @@ -1,16 +1,27 @@ -import { diagnosisOptions } from '../fixtures/mocked-responses'; +import { + diagnosisOptions, + pipelineOptions, + pipelineVersionOptions, +} from '../fixtures/mocked-responses'; describe('App', () => { beforeEach(() => { cy.intercept( { method: 'GET', - url: '/attributes/nb:Diagnosis', + url: '/diagnoses', }, diagnosisOptions ).as('getDiagnosisOptions'); + cy.intercept( + { + method: 'GET', + url: '/pipelines', + }, + pipelineOptions + ).as('getPipelineOptions'); cy.visit('/'); - cy.wait('@getDiagnosisOptions'); + cy.wait(['@getDiagnosisOptions', '@getPipelineOptions']); // TODO: remove this // Bit of a hacky way to close the auth dialog @@ -60,4 +71,19 @@ describe('App', () => { "Parkinson's disease" ); }); + it('Enables the pipeline version field once a pipeline name is selected', () => { + cy.intercept( + { + method: 'GET', + url: '/pipelines/np:fmriprep/versions', + }, + pipelineVersionOptions + ).as('getPipelineVersionsOptions'); + cy.get('[data-cy="Pipeline version-categorical-field"] input').should('be.disabled'); + cy.get('[data-cy="Pipeline name-categorical-field"]').type('fmri{downarrow}{enter}'); + cy.wait('@getPipelineVersionsOptions'); + cy.get('[data-cy="Pipeline version-categorical-field"] input').should('not.be.disabled'); + cy.get('[data-cy="Pipeline version-categorical-field"]').type('0.2.3{downarrow}{enter}'); + cy.get('[data-cy="Pipeline version-categorical-field"] input').should('have.value', '0.2.3'); + }); }); diff --git a/cypress/e2e/ResultsTSV.cy.ts b/cypress/e2e/ResultsTSV.cy.ts index e5b4720f..23aa3e62 100644 --- a/cypress/e2e/ResultsTSV.cy.ts +++ b/cypress/e2e/ResultsTSV.cy.ts @@ -95,6 +95,9 @@ describe('Unprotected response', () => { expect(imagingSession.split('\t')[11]).to.equal( 'http://purl.org/nidash/nidm#FlowWeighted, http://purl.org/nidash/nidm#T2Weighted' ); + expect(imagingSession.split('\t')[12]).to.equal( + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/fmriprep 23.1.3, https://github.com/nipoppy/pipeline-catalog/tree/main/processing/freesurfer 7.3.2' + ); }); }); }); diff --git a/cypress/fixtures/mocked-responses.ts b/cypress/fixtures/mocked-responses.ts index 7bdb4545..4654e9dd 100644 --- a/cypress/fixtures/mocked-responses.ts +++ b/cypress/fixtures/mocked-responses.ts @@ -15,6 +15,10 @@ const protectedDatasetSnippet = { 'http://purl.org/nidash/nidm#FlowWeighted', 'http://purl.org/nidash/nidm#T1Weighted', ], + available_pipelines: { + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/fmriprep': ['23.1.3'], + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/freesurfer': ['7.3.2'], + }, }; const unprotectedDatasetSnippet = { @@ -39,6 +43,7 @@ const unprotectedDatasetSnippet = { assessment: ['https://www.cognitiveatlas.org/task/id/trm_4f2419c4a1646'], image_modal: [null], session_file_path: null, + completed_pipelines: {}, }, { sub_id: 'sub-300101', @@ -56,12 +61,20 @@ const unprotectedDatasetSnippet = { 'http://purl.org/nidash/nidm#T2Weighted', ], session_file_path: '/ds004116/sub-300101', + completed_pipelines: { + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/fmriprep': ['23.1.3'], + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/freesurfer': ['7.3.2'], + }, }, ], image_modals: [ 'http://purl.org/nidash/nidm#T2Weighted', 'http://purl.org/nidash/nidm#FlowWeighted', ], + available_pipelines: { + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/fmriprep': ['23.1.3'], + 'https://github.com/nipoppy/pipeline-catalog/tree/main/processing/freesurfer': ['7.3.2'], + }, }; // doesn't care @@ -249,3 +262,79 @@ export const failedAssessmentToolOptions = { }, ], }; + +export const pipelineOptions = { + errors: [], + responses: { + 'nb:Pipeline': [ + { + TermURL: 'np:fmriprep', + Label: 'fmriprep', + }, + { + TermURL: 'np:freesurfer', + Label: 'freesurfer', + }, + ], + }, + nodes_response_status: 'success', +}; + +export const emptyPipelineOPtions = { ...pipelineOptions, responses: { 'nb:Pipeline': [] } }; + +export const partiallyFailedPipelineOptions = { + ...pipelineOptions, + nodes_response_status: 'partial success', + errors: [ + { + node_name: 'NoPipelineNode', + error: 'some error message', + }, + ], +}; + +export const failedPipelineOptions = { + ...emptyPipelineOPtions, + nodes_response_status: 'fail', + errors: [ + { + node_name: 'NoPipelineNode', + error: 'some error message', + }, + ], +}; + +export const pipelineVersionOptions = { + errors: [], + responses: { + 'np:fmriprep': ['0.2.3', '23.1.3'], + }, + nodes_response_status: 'success', +}; + +export const emptyPipelineVersionOptions = { + ...pipelineVersionOptions, + responses: { 'np:fmriprep': [] }, +}; + +export const partiallyFailedPipelineVersionOptions = { + ...pipelineVersionOptions, + nodes_response_status: 'partial success', + errors: [ + { + node_name: 'NoPipelineVersionNode', + error: 'some error message', + }, + ], +}; + +export const failedPipelineVersionOptions = { + ...emptyPipelineVersionOptions, + nodes_response_status: 'fail', + errors: [ + { + node_name: 'NoPipelineVersionNode', + error: 'some error message', + }, + ], +}; diff --git a/src/App.tsx b/src/App.tsx index a989d121..1b87d6d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,13 +6,15 @@ import CloseIcon from '@mui/icons-material/Close'; import { SnackbarKey, SnackbarProvider, closeSnackbar, enqueueSnackbar } from 'notistack'; import { jwtDecode } from 'jwt-decode'; import { googleLogout } from '@react-oauth/google'; -import { queryURL, attributesURL, nodesURL, enableAuth, enableChatbot } from './utils/constants'; +import { queryURL, baseAPIURL, nodesURL, enableAuth, enableChatbot } from './utils/constants'; import { RetrievedAttributeOption, AttributeOption, + RetrievedPipelineVersions, NodeOption, FieldInput, FieldInputOption, + Pipelines, NodeError, QueryResponse, GoogleJWT, @@ -30,6 +32,7 @@ function App() { const [availableNodes, setAvailableNodes] = useState([ { NodeName: 'All', ApiURL: 'allNodes' }, ]); + const [pipelines, setPipelines] = useState({}); const [alertDismissed, setAlertDismissed] = useState(false); @@ -46,6 +49,8 @@ function App() { const [minNumPhenotypicSessions, setMinNumPhenotypicSessions] = useState(null); const [assessmentTool, setAssessmentTool] = useState(null); const [imagingModality, setImagingModality] = useState(null); + const [pipelineVersion, setPipelineVersion] = useState(null); + const [pipelineName, setPipelineName] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -76,35 +81,39 @@ function App() { ); useEffect(() => { - async function getAttributes(dataElementURI: string) { + async function getAttributes(NBResource: string, dataElementURI: string) { try { const response: AxiosResponse = await axios.get( - `${attributesURL}${dataElementURI}` + `${baseAPIURL}${NBResource}/` ); if (response.data.nodes_response_status === 'fail') { - enqueueSnackbar(`Failed to retrieve ${dataElementURI.slice(3)} options`, { + enqueueSnackbar(`Failed to retrieve ${NBResource} options`, { variant: 'error', action, }); } else { // If any errors occurred, report them response.data.errors.forEach((error) => { - enqueueSnackbar( - `Failed to retrieve ${dataElementURI.slice(3)} options from ${error.node_name}`, - { variant: 'warning', action } - ); + enqueueSnackbar(`Failed to retrieve ${NBResource} options from ${error.node_name}`, { + variant: 'warning', + action, + }); }); // If the results are empty, report that if (Object.keys(response.data.responses[dataElementURI]).length === 0) { - enqueueSnackbar(`No ${dataElementURI.slice(3)} options were available`, { + enqueueSnackbar(`No ${NBResource} options were available`, { variant: 'info', action, }); - } else if (response.data.responses[dataElementURI].some((item) => item.Label === null)) { - enqueueSnackbar( - `Warning: Missing labels were removed for ${dataElementURI.slice(3)} `, - { variant: 'warning', action } - ); + // TODO: remove the second condition once pipeline labels are added + } else if ( + response.data.responses[dataElementURI].some((item) => item.Label === null) && + NBResource !== 'pipelines' + ) { + enqueueSnackbar(`Warning: Missing labels were removed for ${NBResource} `, { + variant: 'warning', + action, + }); response.data.responses[dataElementURI] = response.data.responses[ dataElementURI ].filter((item) => item.Label !== null); @@ -116,18 +125,26 @@ function App() { } } - getAttributes('nb:Diagnosis').then((diagnosisResponse) => { + getAttributes('diagnoses', 'nb:Diagnosis').then((diagnosisResponse) => { if (diagnosisResponse !== null && diagnosisResponse.length !== 0) { setDiagnosisOptions(diagnosisResponse); } }); - getAttributes('nb:Assessment').then((assessmentResponse) => { + getAttributes('assessments', 'nb:Assessment').then((assessmentResponse) => { if (assessmentResponse !== null && assessmentResponse.length !== 0) { setAssessmentOptions(assessmentResponse); } }); + getAttributes('pipelines', 'nb:Pipeline').then((pipelineResponse) => { + if (pipelineResponse !== null && pipelineResponse.length !== 0) { + pipelineResponse.forEach((option) => { + setPipelines((prevPipelines) => ({ ...prevPipelines, [option.TermURL]: [] })); + }); + } + }); + async function getNodeOptions(fetchURL: string) { try { const response: AxiosResponse = await axios.get(fetchURL); @@ -148,6 +165,58 @@ function App() { }); }, []); + useEffect(() => { + async function getPipelineVersions(pipelineURI: FieldInputOption) { + try { + const response: AxiosResponse = await axios.get( + `${baseAPIURL}pipelines/${pipelineURI.id}/versions` + ); + if (response.data.nodes_response_status === 'fail') { + enqueueSnackbar(`Failed to retrieve ${pipelineURI.label} versions`, { + variant: 'error', + action, + }); + } else { + // If any errors occurred, report them + response.data.errors.forEach((error) => { + enqueueSnackbar( + `Failed to retrieve ${pipelineURI.label} versions from ${error.node_name}`, + { + variant: 'warning', + action, + } + ); + }); + // If the results are empty, report that + if (Object.keys(response.data.responses[pipelineURI.id]).length === 0) { + enqueueSnackbar(`No ${pipelineURI.label} versions were available`, { + variant: 'info', + action, + }); + } + } + return response.data.responses[pipelineURI.id]; + } catch { + return []; + } + } + // Get pipeline versions if + // 1. A pipeline has been selected (this implementation only works for single value for pipeline name field) + // 2. This is the first time its being selected (i.e., we haven't retrieved pipeline versions before) + if ( + pipelineName !== null && + !Array.isArray(pipelineName) && + pipelines[pipelineName.id].length === 0 + ) { + getPipelineVersions(pipelineName).then((pipelineVersionsRespnse) => { + setPipelines((prevPipelines) => ({ + ...prevPipelines, + [pipelineName.id]: pipelineVersionsRespnse, + })); + }); + } + }, [pipelines, pipelineName]); + useEffect(() => { if (availableNodes.length > 1) { const searchParamNodes: string[] = searchParams.getAll('node'); @@ -206,6 +275,12 @@ function App() { case 'Imaging modality': setImagingModality(value); break; + case 'Pipeline version': + setPipelineVersion(value); + break; + case 'Pipeline name': + setPipelineName(value); + break; default: break; } @@ -278,6 +353,8 @@ function App() { ); setQueryParam('assessment', assessmentTool, queryParams); setQueryParam('image_modal', imagingModality, queryParams); + setQueryParam('pipeline_version', pipelineVersion, queryParams); + setQueryParam('pipeline_name', pipelineName, queryParams); // Remove keys with empty values const keysToDelete: string[] = []; @@ -418,6 +495,9 @@ function App() { setIsControl={setIsControl} assessmentTool={assessmentTool} imagingModality={imagingModality} + pipelineVersion={pipelineVersion} + pipelineName={pipelineName} + pipelines={pipelines} updateCategoricalQueryParams={(label, value) => updateCategoricalQueryParams(label, value) } @@ -428,7 +508,7 @@ function App() { onSubmitQuery={() => submitQuery()} /> -
+
diff --git a/src/components/QueryForm.tsx b/src/components/QueryForm.tsx index 96688acd..ca21f6fd 100644 --- a/src/components/QueryForm.tsx +++ b/src/components/QueryForm.tsx @@ -4,10 +4,18 @@ import { Checkbox, CircularProgress, FormHelperText, + Tooltip, + Typography, } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; import { sexes, modalities } from '../utils/constants'; -import { NodeOption, AttributeOption, FieldInput } from '../utils/types'; +import { + NodeOption, + AttributeOption, + FieldInputOption, + FieldInput, + Pipelines, +} from '../utils/types'; import CategoricalField from './CategoricalField'; import ContinuousField from './ContinuousField'; @@ -26,6 +34,9 @@ function QueryForm({ setIsControl, assessmentTool, imagingModality, + pipelineVersion, + pipelineName, + pipelines, updateCategoricalQueryParams, updateContinuousQueryParams, loading, @@ -45,6 +56,9 @@ function QueryForm({ minNumPhenotypicSessions: number | null; assessmentTool: FieldInput; imagingModality: FieldInput; + pipelineVersion: FieldInput; + pipelineName: FieldInput; + pipelines: Pipelines; updateCategoricalQueryParams: (label: string, value: FieldInput) => void; updateContinuousQueryParams: (label: string, value: number | null) => void; loading: boolean; @@ -78,7 +92,7 @@ function QueryForm({ minNumImagingSessionsHelperText !== ''; return ( -
+
-
+
-
+
-
+
-
+
({ label: a.Label, id: a.TermURL }))} @@ -169,7 +183,7 @@ function QueryForm({ inputValue={assessmentTool} />
-
+
({ @@ -180,7 +194,48 @@ function QueryForm({ inputValue={imagingModality} />
-
+
+ ({ + // Remove the `np:` prefix + label: pipelineURI.slice(3), + id: pipelineURI, + }))} + onFieldChange={(label, value) => updateCategoricalQueryParams(label, value)} + inputValue={pipelineName} + /> +
+ {pipelineName === null ? ( + Please select a pipeline name} + placement="right" + > +
+ updateCategoricalQueryParams(label, value)} + inputValue={pipelineVersion} + disabled + /> +
+
+ ) : ( +
+ ({ + label: v, + id: v, + }))} + onFieldChange={(label, value) => updateCategoricalQueryParams(label, value)} + inputValue={pipelineVersion} + /> +
+ )} + +
-
+
{datasetName} from {nodeName} {numMatchingSubjects} subjects match / {datasetTotalSubjects} total subjects
-
+
+ + {Object.entries(pipelines) + .flatMap(([name, versions]) => + versions.map((version) => `${name.slice(65)} ${version}`) + ) + .map((pipeline) => ( + {pipeline} + ))} + + } + placement="top" + > + + +
+
{imageModals.sort().map((modal) => ( diff --git a/src/components/ResultContainer.tsx b/src/components/ResultContainer.tsx index 61b694e3..b94e4f5e 100644 --- a/src/components/ResultContainer.tsx +++ b/src/components/ResultContainer.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Button, FormControlLabel, Checkbox, Typography } from '@mui/material'; import ResultCard from './ResultCard'; -import { QueryResponse } from '../utils/types'; +import { QueryResponse, Pipelines } from '../utils/types'; import DownloadResultButton from './DownloadResultButton'; import GetDataDialog from './GetDataDialog'; @@ -58,6 +58,16 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { } }, [response]); + function parsePipelinesInfoToString(pipelines: Pipelines) { + return pipelines + ? Object.entries(pipelines) + .flatMap(([name, versions]) => + (versions as string[]).map((version: string) => `${name} ${version}`) + ) + .join(', ') + : {}; + } + function generateTSVString(buttonIdentifier: string) { if (response) { const tsvRows = []; @@ -77,6 +87,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { 'NumPhenotypicSessions', 'NumImagingSessions', 'Modality', + 'CompletedPipelines', ].join('\t'); tsvRows.push(headers); @@ -96,6 +107,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { 'protected', // num_phenotypic_sessions 'protected', // num_imaging_sessions 'protected', // image_modal + 'protected', // completed_pipelines ].join('\t') ); } else { @@ -115,6 +127,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { subject.num_matching_phenotypic_sessions, subject.num_matching_imaging_sessions, subject.image_modal?.join(', '), + parsePipelinesInfoToString(subject.completed_pipelines), ].join('\t') ); }); @@ -127,6 +140,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { 'PortalURI', 'NumMatchingSubjects', 'AvailableImageModalities', + 'AvailablePipelines', ].join('\t'); tsvRows.push(headers); @@ -138,6 +152,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { res.dataset_portal_uri, res.num_matching_subjects, res.image_modals?.join(', '), + parsePipelinesInfoToString(res.available_pipelines), ].join('\t') ); }); @@ -219,6 +234,7 @@ function ResultContainer({ response }: { response: QueryResponse | null }) { datasetTotalSubjects={item.dataset_total_subjects} numMatchingSubjects={item.num_matching_subjects} imageModals={item.image_modals} + pipelines={item.available_pipelines} checked={download.includes(item.dataset_uuid)} onCheckboxChange={updateDownload} /> diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c6a4ea05..06be5be5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,13 +1,12 @@ -const baseAPIURL: string = import.meta.env.NB_API_QUERY_URL; -export const queryURL: string = baseAPIURL.endsWith('/') - ? `${baseAPIURL}query?` - : `${baseAPIURL}/query?`; -export const attributesURL: string = baseAPIURL.endsWith('/') - ? `${baseAPIURL}attributes/` - : `${baseAPIURL}/attributes/`; -export const nodesURL: string = baseAPIURL.endsWith('/') - ? `${baseAPIURL}nodes` - : `${baseAPIURL}/nodes`; +const { NB_API_QUERY_URL } = import.meta.env; + +export const baseAPIURL: string = NB_API_QUERY_URL.endsWith('/') + ? NB_API_QUERY_URL + : `${NB_API_QUERY_URL}/`; + +export const queryURL: string = `${baseAPIURL}query?`; + +export const nodesURL: string = `${baseAPIURL}nodes`; export const appBasePath: string = import.meta.env.NB_QUERY_APP_BASE_PATH ?? '/'; diff --git a/src/utils/types.ts b/src/utils/types.ts index 6caccd22..e6b098c3 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -26,6 +26,18 @@ export interface RetrievedAttributeOption { errors: NodeError[]; } +export interface RetrievedPipelineVersions { + responses: { + [key: string]: string[]; + }; + nodes_response_status: string; + errors: NodeError[]; +} + +export interface Pipelines { + [key: string]: string[]; +} + export interface Subject { sub_id: string; session_id: string; @@ -37,6 +49,7 @@ export interface Subject { assessment: string[]; image_modal: string[]; session_file_path: string; + completed_pipelines: Pipelines; } export interface Result { @@ -49,6 +62,7 @@ export interface Result { num_matching_subjects: number; subject_data: Subject[] | string; image_modals: string[]; + available_pipelines: Pipelines; } export interface QueryResponse {