diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0546a7..e1bf79a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.6.1] 2020-04-16 +## [0.6.2] - 2020-04-23 + +### Added + +* Paging projects ([#137](https://github.com/iomega/paired-data-form/issues/137)) + +### Changed + +* Sort projects moved from web application to elastic search ([#138](https://github.com/iomega/paired-data-form/issues/138)) +* Allow search and filter to be combined + +## [0.6.1] - 2020-04-16 ### Added @@ -18,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Enrichments cause Limit of total fields exceeded error in elastic search ([#131](https://github.com/iomega/paired-data-form/issues/131)) -## [0.6.0] 2020-04-16 +## [0.6.0] - 2020-04-16 Search functionality using elastic search has been added. diff --git a/api/package.json b/api/package.json index dca4a8e0..20b2e3f3 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "podp-api", - "version": "0.6.1", + "version": "0.6.2", "description": "", "license": "Apache-2.0", "scripts": { diff --git a/api/src/app.test.ts b/api/src/app.test.ts index 237aa53a..d3012ac6 100644 --- a/api/src/app.test.ts +++ b/api/src/app.test.ts @@ -20,6 +20,13 @@ describe('app', () => { listProjects: async () => { return [] as EnrichedProjectDocument[]; }, + searchProjects: jest.fn().mockImplementation(async () => { + const data: EnrichedProjectDocument[] = []; + return { + data, + total: 0 + }; + }), createProject: async () => { return 'projectid1.1'; }, @@ -89,10 +96,60 @@ describe('app', () => { expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { - data: [] + data: [], + total: 0 }; expect(body).toEqual(expected); }); + + describe.each([ + ['/api/projects', { size: 100, from: 0 }], + ['/api/projects?size=12', { size: 12, from: 0 }], + ['/api/projects?page=2', { size: 100, from: 200 }], + ['/api/projects?size=12&page=3', { size: 12, from: 36 }], + ['/api/projects?sort=nr_genomes', { size: 100, from: 0, sort: 'nr_genomes' }], + ['/api/projects?order=asc', { size: 100, from: 0, order: 'asc' }], + ['/api/projects?sort=nr_genomes&order=desc', { size: 100, from: 0, sort: 'nr_genomes', order: 'desc' }], + ['/api/projects?q=Justin', { query: 'Justin', size: 100, from: 0 }], + ['/api/projects?q=Justin&size=12&page=3', { query: 'Justin', size: 12, from: 36 }], + ['/api/projects?fk=species&fv=somevalue', { filter: { key: 'species', value: 'somevalue' }, size: 100, from: 0 }], + ['/api/projects?fk=species&fv=somevalue&size=12&page=3', { filter: { key: 'species', value: 'somevalue' }, size: 12, from: 36 }], + ['/api/projects?q=Justin&fk=species&fv=somevalue', { query: 'Justin', filter: { key: 'species', value: 'somevalue' }, size: 100, from: 0 }], + ])('GET %s', (url, expected) => { + it('should call store.searchProjects', async () => { + await supertest(app).get(url); + expect(store.searchProjects).toBeCalledWith(expected); + }); + }); + + describe.each([ + ['/api/projects?fk=wrongfield&fv=somevalue', 'Invalid `fk`'], + ['/api/projects?fk=species', 'Require both `fk` and `fv` to filter'], + ['/api/projects?fv=somevalue', 'Require both `fk` and `fv` to filter'], + ['/api/projects?size=foo', 'Size is not an integer'], + ['/api/projects?size=-10', 'Size must be between `1` and `1000`'], + ['/api/projects?size=1110', 'Size must be between `1` and `1000`'], + ['/api/projects?page=foo', 'Page is not an integer'], + ['/api/projects?page=-10', 'Page must be between `0` and `1000`'], + ['/api/projects?page=1111', 'Page must be between `0` and `1000`'], + ['/api/projects?sort=somebadfield', 'Invalid `sort`'], + ['/api/projects?order=somebadorder', 'Invalid `order`, must be either `desc` or `asc`'], + ])('GET %s', (url, expected) => { + let response: any; + + beforeEach(async () => { + response = await supertest(app).get(url); + }); + + it('should return a 400 status', () => { + expect(response.status).toBe(400); + }); + + it('should return an error message', () => { + const body = JSON.parse(response.text); + expect(body.message).toEqual(expected); + }); + }); }); describe('GET /api/pending/projects', () => { @@ -100,7 +157,7 @@ describe('app', () => { const response = await supertest(app) .get('/api/pending/projects') .set('Authorization', 'Bearer ashdfjhasdlkjfhalksdjhflak') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -115,7 +172,7 @@ describe('app', () => { const response = await supertest(app) .get('/api/pending/projects/projectid1.1') .set('Authorization', 'Bearer ashdfjhasdlkjfhalksdjhflak') - ; + ; expect(response.status).toBe(404); }); }); @@ -125,7 +182,7 @@ describe('app', () => { const response = await supertest(app) .get('/api/pending/projects/projectid1.1') .set('Authorization', 'Bearer incorrecttoken') - ; + ; expect(response.status).toBe(401); }); }); @@ -172,6 +229,12 @@ describe('app', () => { listProjects: async () => { return [eproject]; }, + searchProjects: async () => { + return { + data: [eproject], + total: 1 + }; + }, createProject: async () => { return 'projectid1.1'; }, @@ -209,7 +272,7 @@ describe('app', () => { const response = await supertest(app) .get('/api/pending/projects/projectid1.1') .set('Authorization', 'Bearer ashdfjhasdlkjfhalksdjhflak') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -225,7 +288,7 @@ describe('app', () => { const response = await supertest(app) .post('/api/pending/projects/projectid1.1') .set('Authorization', 'Bearer ashdfjhasdlkjfhalksdjhflak') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -241,7 +304,7 @@ describe('app', () => { const response = await supertest(app) .delete('/api/pending/projects/projectid1.1') .set('Authorization', 'Bearer ashdfjhasdlkjfhalksdjhflak') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -255,7 +318,7 @@ describe('app', () => { it('should return project', async () => { const response = await supertest(app) .get('/api/projects/projectid1.1') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); expect(body).toEqual(project); @@ -266,7 +329,7 @@ describe('app', () => { it('should return enriched project', async () => { const response = await supertest(app) .get('/api/projects/projectid1.1/enriched') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -281,7 +344,7 @@ describe('app', () => { it('should return enriched project', async () => { const response = await supertest(app) .get('/api/projects/projectid1.1/history') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { @@ -345,7 +408,7 @@ describe('app', () => { it('should return version info', async () => { const response = await supertest(app) .get('/api/version') - ; + ; expect(response.status).toBe(200); const body = JSON.parse(response.text); const expected: any = { diff --git a/api/src/controller.ts b/api/src/controller.ts index 6658f4af..b448575b 100644 --- a/api/src/controller.ts +++ b/api/src/controller.ts @@ -1,12 +1,13 @@ import { Request, Response } from 'express'; -import { ProjectDocumentStore, NotFoundException, ListOptions } from './projectdocumentstore'; +import { ProjectDocumentStore, NotFoundException } from './projectdocumentstore'; import { Validator } from './validate'; import { Queue } from 'bull'; import { IOMEGAPairedOmicsDataPlatform as ProjectDocument } from './schema'; import { computeStats } from './util/stats'; -import { summarizeProject, compareMetaboliteID } from './summarize'; +import { summarizeProject } from './summarize'; import { ZENODO_DEPOSITION_ID } from './util/secrets'; +import { FilterFields, DEFAULT_PAGE_SIZE, Order, SearchOptions, SortFields } from './store/search'; function getStore(req: Request) { @@ -81,32 +82,71 @@ export async function denyProject(req: Request, res: Response) { res.json({'message': 'Denied pending project'}); } -export async function listProjects(req: Request, res: Response) { - const store = getStore(req); - const options: ListOptions = {}; - if (req.query.q) { - options.query = req.query.q; +function checkRange(svalue: string, label: string, min: number, max: number) { + const value = parseInt(svalue); + if (Number.isNaN(value)) { + throw `${label} is not an integer`; + } + if (value < min || value > max ) { + throw `${label} must be between \`${min}\` and \`${max}\``; + } + return value; +} + +function validateSearchOptions(query: any) { + const options: SearchOptions = {}; + + if (query.q) { + options.query = query.q; } - if (req.query.fk || req.query.fv) { - if (req.query.fk && req.query.fv) { + if (query.fk || query.fv) { + if (query.fk && query.fv) { + if (!(query.fk in FilterFields)) { + throw 'Invalid `fk`'; + } options.filter = { - key: req.query.fk, - value: req.query.fv + key: query.fk, + value: query.fv }; } else { - res.status(400); - res.json({ message: 'Require both `fk` and `fv` to filter'}); - return; + throw 'Require both `fk` and `fv` to filter'; } - if (req.query.query) { - res.status(400); - res.json({ message: 'Eiter search with `q` or filter with `fk` and `fv`'}); - return; + } + if (query.size) { + options.size = checkRange(query.size, 'Size', 1, 1000); + } else { + options.size = DEFAULT_PAGE_SIZE; + } + if (query.page) { + options.from = checkRange(query.page, 'Page', 0, 1000) * options.size; + } else { + options.from = 0; + } + if (query.sort) { + if (!(query.sort in SortFields)) { + throw 'Invalid `sort`'; } + options.sort = query.sort; + } + if (query.order) { + if (!(query.order in Order)) { + throw 'Invalid `order`, must be either `desc` or `asc`'; + } + options.order = Order[query.order as 'desc' | 'asc']; + } + return options; +} + +export async function listProjects(req: Request, res: Response) { + const store = getStore(req); + try { + const options = validateSearchOptions(req.query); + const hits = await store.searchProjects(options); + res.json(hits); + } catch (message) { + res.status(400); + res.json({message}); } - const projects = await store.listProjects(options); - const data = projects.map(summarizeProject).sort(compareMetaboliteID).reverse(); - res.json({data}); } export async function getProject(req: Request, res: Response) { diff --git a/api/src/projectdocumentstore.test.ts b/api/src/projectdocumentstore.test.ts index 5c019686..0a368447 100644 --- a/api/src/projectdocumentstore.test.ts +++ b/api/src/projectdocumentstore.test.ts @@ -137,23 +137,23 @@ describe('ProjectDocumentStore', () => { expect(approved_project).toEqual(submitted_project); }); - describe('listProjects()', () => { + describe('searchProjects()', () => { describe('without arguments', () => { - it('should be listed', async () => { + it('should have project1 in list', async () => { expect.assertions(1); - const projects = await store.listProjects(); + const {data: projects} = await store.searchProjects(); const project_ids = new Set(projects.map(p => p._id)); - expect(project_ids).toEqual(new Set([project_id])); + expect(project_ids).toEqual(new Set(['projectid1'])); }); }); describe('with query=Justin', () => { - it('should be listed', async () => { + it('should have project1 in list', async () => { expect.assertions(1); - const projects = await store.listProjects({ + const {data: projects} = await store.searchProjects({ query: 'Justin' }); const project_ids = new Set(projects.map(p => p._id)); @@ -163,10 +163,10 @@ describe('ProjectDocumentStore', () => { }); describe('with filter `principal_investigator=Pieter C. Dorrestein`', () => { - it('should be listed', async () => { + it('should have project1 in list', async () => { expect.assertions(1); - const projects = await store.listProjects({ + const {data: projects} = await store.searchProjects({ filter: { key: 'principal_investigator', value: 'Pieter C. Dorrestein' @@ -179,6 +179,17 @@ describe('ProjectDocumentStore', () => { }); }); + describe('listProjects()', () => { + it('should have project1 in list', async () => { + expect.assertions(1); + + const projects = await store.listProjects(); + const project_ids = new Set(projects.map(p => p._id)); + + expect(project_ids).toEqual(new Set([project_id])); + }); + }); + describe('addEnrichments()', () => { beforeEach(async () => { client.index.mockClear(); diff --git a/api/src/projectdocumentstore.ts b/api/src/projectdocumentstore.ts index da3c47dc..f6e73ded 100644 --- a/api/src/projectdocumentstore.ts +++ b/api/src/projectdocumentstore.ts @@ -4,19 +4,11 @@ import { ProjectDocumentDiskStore } from './store/Disk'; import { IOMEGAPairedOmicsDataPlatform as ProjectDocument } from './schema'; import logger from './util/logger'; import { ProjectEnrichmentStore, EnrichedProjectDocument } from './store/enrichments'; -import { SearchEngine, FilterField } from './store/search'; +import { SearchEngine, SearchOptions } from './store/search'; import { ProjectEnrichments } from './enrich'; export const NotFoundException = MemoryNotFoundException; -export interface ListOptions { - query?: string; - filter?: { - key: FilterField; - value: string; - }; -} - export class ProjectDocumentStore { memory_store = new ProjectDocumentMemoryStore(); disk_store: ProjectDocumentDiskStore; @@ -55,12 +47,11 @@ export class ProjectDocumentStore { return new_project_id; } - async listProjects(options: ListOptions = {}) { - if (options.query) { - return await this.search_engine.search(options.query); - } else if (options.filter) { - return await this.search_engine.filter(options.filter.key, options.filter.value); - } + async searchProjects(options: SearchOptions = {}) { + return await this.search_engine.search(options); + } + + async listProjects() { const entries = this.memory_store.listProjects(); return await this.enrichment_store.mergeMany(entries); } diff --git a/api/src/store/enrichments.ts b/api/src/store/enrichments.ts index aa259fdf..a497b977 100644 --- a/api/src/store/enrichments.ts +++ b/api/src/store/enrichments.ts @@ -2,6 +2,7 @@ import Keyv from 'keyv'; import { IOMEGAPairedOmicsDataPlatform as ProjectDocument } from '../schema'; import { ProjectEnrichments } from '../enrich'; +import { ProjectSummary } from '../summarize'; const PREFIX = 'enrichment:'; @@ -9,6 +10,7 @@ export interface EnrichedProjectDocument { _id: string; project: ProjectDocument; enrichments?: ProjectEnrichments; + summary?: ProjectSummary; } export class ProjectEnrichmentStore { diff --git a/api/src/store/search.test.ts b/api/src/store/search.test.ts index b83321d6..fb1cf3ab 100644 --- a/api/src/store/search.test.ts +++ b/api/src/store/search.test.ts @@ -2,6 +2,7 @@ import { loadJSONDocument } from '../util/io'; import { EXAMPLE_PROJECT_JSON_FN, mockedElasticSearchClient } from '../testhelpers'; import { SearchEngine, FilterField } from './search'; import { Client } from '@elastic/elasticsearch'; +import { EnrichedProjectDocument } from './enrichments'; jest.mock('@elastic/elasticsearch'); const MockedClient: jest.Mock = Client as any; @@ -38,7 +39,6 @@ describe('new SearchEngine()', () => { expect(client.bulk).not.toHaveBeenCalled(); }); - describe('with a single genome project', () => { beforeEach(async () => { const eproject = await genomeProject(); @@ -51,19 +51,25 @@ describe('new SearchEngine()', () => { _id: 'projectid1', _score: 0.5, _source: esproject - }] + }], + total: { + value: 1 + } } } }); }); - it('should have called client.index', () => { + it('should have called client.index', async () => { + const project = await esGenomeProject(); expect(client.index).toHaveBeenCalledWith({ index: 'podp', - id: 'projectid1', + id: project.project_id, body: { - project: expect.anything(), - enrichments: expect.anything() + project_id: project.project_id, + project: project.project, + enrichments: project.enrichments, + summary: project.summary } }); }); @@ -71,7 +77,7 @@ describe('new SearchEngine()', () => { describe('the added document', () => { let doc: any; - beforeAll(() => { + beforeEach(() => { doc = client.index.mock.calls[0][0].body; }); @@ -116,111 +122,215 @@ describe('new SearchEngine()', () => { }); }); - describe('search(\'Justin\')', () => { - const query = 'Justin'; - let hits: any; + describe('search()', () => { + describe('defaults', () => { + let hits: any; - beforeAll(async () => { - hits = await searchEngine.search(query); - }); + beforeEach(async () => { + hits = await searchEngine.search({}); + }); - it('should have called client.search', () => { - expect(client.search).toHaveBeenCalledWith({ - index: 'podp', - body: { - 'query': { - simple_query_string: { - query + it('should have called client.search', () => { + expect(client.search).toHaveBeenCalledWith({ + index: 'podp', + size: 100, + from: 0, + _source: 'summary', + sort: 'summary.metabolite_id.keyword:desc', + body: { + 'query': { + match_all: {} } } - } + }); }); - }); - it('should return hits', async () => { - const expected = await genomeProject(); - expect(hits).toEqual([expected]); + it('should return hits', async () => { + const expected_project = await genomeProjectSummary(); + const expected = { + data: [expected_project], + total: 1 + }; + expect(hits).toEqual(expected); + }); }); - }); - describe('delete(\projectid1\')', () => { - it('should have called client.delete', async () => { - await searchEngine.delete('projectid1'); + describe('paging', () => { + let hits: any; - expect(client.delete).toHaveBeenCalledWith({ - id: 'projectid1', - index: 'podp', + beforeEach(async () => { + hits = await searchEngine.search({ size: 1, from: 2 }); + }); + + it('should have called client.search', () => { + expect(client.search).toHaveBeenCalledWith({ + index: 'podp', + size: 1, + from: 2, + _source: 'summary', + sort: 'summary.metabolite_id.keyword:desc', + body: { + 'query': { + match_all: {} + } + } + }); + }); + + it('should return hits', async () => { + const expected_project = await genomeProjectSummary(); + const expected = { + data: [expected_project], + total: 1 + }; + expect(hits).toEqual(expected); }); }); - }); - describe.each([ - ['principal_investigator', 'Marnix Medema'], - ['submitter', 'Justin van der Hooft'], - ['genome_type', 'genome'], - ['species', 'Streptomyces sp. CNB091'], - ['metagenomic_environment', 'Soil'], - ['instrument_type', 'Time-of-flight (TOF)'], - ['growth_medium', 'A1 medium'], - ['solvent', 'Butanol'], - ['ionization_mode', 'Positive'], - ['ionization_type', 'Electrospray Ionization (ESI)'] - ])('filter(\'%s\', \'%s\')', (key: FilterField, value) => { - let hits: any; - beforeEach(async () => { - client.search.mockClear(); - hits = await searchEngine.filter(key, value); + describe('query=\'Justin\'', () => { + const query = 'Justin'; + let hits: any; + + beforeEach(async () => { + hits = await searchEngine.search({ query }); + }); + + it('should have called client.search', () => { + expect(client.search).toHaveBeenCalledWith({ + index: 'podp', + from: 0, + size: 100, + _source: 'summary', + body: { + 'query': { + simple_query_string: { + query + } + } + } + }); + }); + + it('should return hits', async () => { + const expected_project = await genomeProjectSummary(); + const expected = { + data: [expected_project], + total: 1 + }; + expect(hits).toEqual(expected); + }); }); - it('should have called index.search', () => { - expect(client.search).toBeCalled(); - const called = client.search.mock.calls[0][0]; - let expected = { - index: 'podp', - body: { - query: { - match: expect.anything() + describe.each([ + ['principal_investigator', 'Marnix Medema'], + ['submitter', 'Justin van der Hooft'], + ['genome_type', 'genome'], + ['species', 'Streptomyces sp. CNB091'], + ['metagenomic_environment', 'Soil'], + ['instrument_type', 'Time-of-flight (TOF)'], + ['growth_medium', 'A1 medium'], + ['solvent', 'Butanol'], + ['ionization_mode', 'Positive'], + ['ionization_type', 'Electrospray Ionization (ESI)'] + ])('filter={key:\'%s\', value:\'%s\'}', (key: FilterField, value) => { + let hits: any; + beforeEach(async () => { + client.search.mockClear(); + hits = await searchEngine.search({ filter: { key, value } }); + }); + + it('should have called index.search', () => { + expect(client.search).toBeCalled(); + const called = client.search.mock.calls[0][0]; + const expected = { + index: 'podp', + from: 0, + size: 100, + _source: 'summary', + body: { + query: { + match: expect.anything() + } } + }; + expect(called).toEqual(expected); + }); + + it('should return hits', async () => { + const expected_project = await genomeProjectSummary(); + const expected = { + data: [expected_project], + total: 1 + }; + expect(hits).toEqual(expected); + }); + }); + + describe('invalid filter field', () => { + it('should throw Error', async () => { + expect.assertions(1); + try { + await searchEngine.search({ + filter: { + key: 'some invalid key' as any, + value: 'somevalue' + } + }); + } catch (error) { + expect(error).toEqual(new Error('Invalid filter field')); } - }; - if (key === 'submitter') { - expected = { + }); + }); + + describe('filter & query', () => { + it('should have called index.search', async () => { + client.search.mockClear(); + const query = 'Justin'; + const filter = { key: 'solvent' as FilterField, value: 'Butanol' }; + + await searchEngine.search({ query, filter }); + + expect(client.search).toBeCalled(); + const called = client.search.mock.calls[0][0]; + const expected = { index: 'podp', + from: 0, + size: 100, + _source: 'summary', body: { query: { bool: { - should: [ - { - match: expect.anything() - }, - { - match: expect.anything() + must: { + simple_query_string: { + query: 'Justin' } - ], - }, - }, - }, - } as any; - } - expect(called).toEqual(expected); - }); + }, + filter: { + match: { + 'project.experimental.extraction_methods.solvents.solvent_title.keyword': 'Butanol' + } + } + } + } + } + }; + expect(called).toEqual(expected); + }); - it('should return hits', async () => { - const expected = await genomeProject(); - expect(hits).toEqual([expected]); }); }); - describe('filter(invalid field)', () => { - it('should throw Error', async () => { - expect.assertions(1); - try { - await searchEngine.filter('some invalid key' as any, 'somevalue'); - } catch (error) { - expect(error).toEqual(new Error('Invalid filter field')); - } + describe('delete(\projectid1\')', () => { + it('should have called client.delete', async () => { + await searchEngine.delete('projectid1'); + + expect(client.delete).toHaveBeenCalledWith({ + id: 'projectid1', + index: 'podp', + }); }); }); + }); describe('with a single metagenome project', () => { @@ -299,7 +409,7 @@ async function esGenomeProject() { project.experimental.instrumentation_methods[0].mode_title = 'Positive'; project.experimental.instrumentation_methods[0].ionization_type_title = 'Electrospray Ionization (ESI)'; const esproject = { - _id: 'projectid1', + project_id: 'projectid1', project, enrichments: { genomes: [{ @@ -311,6 +421,17 @@ async function esGenomeProject() { 'title': 'Streptomyces sp. CNB091, whole genome shotgun sequencing project', 'url': 'https://www.ncbi.nlm.nih.gov/nuccore/ARJI01000000' }] + }, + summary: { + metabolite_id: 'MSV000078839', + PI_name: 'Marnix Medema', + submitters: 'Justin van der Hooft', + nr_extraction_methods: 3, + nr_genecluster_mspectra_links: 3, + nr_genome_metabolomics_links: 21, + nr_genomes: 3, + nr_growth_conditions: 3, + nr_instrumentation_methods: 1, } }; return esproject; @@ -334,5 +455,22 @@ async function genomeProject() { } } }; - return eproject; + return eproject as EnrichedProjectDocument; } + +async function genomeProjectSummary() { + const summary = { + _id: 'projectid1', + metabolite_id: 'MSV000078839', + PI_name: 'Marnix Medema', + submitters: 'Justin van der Hooft', + nr_extraction_methods: 3, + nr_genecluster_mspectra_links: 3, + nr_genome_metabolomics_links: 21, + nr_genomes: 3, + nr_growth_conditions: 3, + nr_instrumentation_methods: 1, + score: 0.5, + }; + return summary; +} \ No newline at end of file diff --git a/api/src/store/search.ts b/api/src/store/search.ts index ab0d04f7..24a91a8f 100644 --- a/api/src/store/search.ts +++ b/api/src/store/search.ts @@ -1,6 +1,56 @@ import { Client } from '@elastic/elasticsearch'; import { EnrichedProjectDocument } from './enrichments'; import { loadSchema, Lookups } from '../util/schema'; +import { summarizeProject, ProjectSummary } from '../summarize'; + +export interface SearchOptions { + query?: string; + filter?: { + key: FilterField; + value: string; + }; + from?: number; + size?: number; + sort?: SortField; + order?: Order; +} + +export enum FilterFields { + principal_investigator = 'project.personal.PI_name.keyword', + submitter = 'summary.submitters.keyword', + genome_type = 'project.genomes.genome_ID.genome_type.keyword', + species = 'enrichments.genomes.species.scientific_name.keyword', + metagenomic_environment = 'project.experimental.sample_preparation.medium_details.metagenomic_environment_title.keyword', + instrument_type = 'project.experimental.instrumentation_methods.instrumentation.instrument_title.keyword', + ionization_mode = 'project.experimental.instrumentation_methods.mode_title.keyword', + ionization_type = 'project.experimental.instrumentation_methods.ionization_type_title.keyword', + growth_medium = 'project.experimental.sample_preparation.medium_details.medium_title.keyword', + solvent = 'project.experimental.extraction_methods.solvents.solvent_title.keyword', +} + +export type FilterField = keyof typeof FilterFields; + +export enum SortFields { + score = 'score', + _id = 'project_id.keyword', + met_id = 'summary.metabolite_id.keyword', + PI_name = 'summary.PI_name.keyword', + submitters = 'summary.submitters.keyword', + nr_genomes = 'summary.nr_genomes', + nr_growth_conditions = 'summary.nr_growth_conditions', + nr_extraction_methods = 'summary.nr_extraction_methods', + nr_instrumentation_methods = 'summary.nr_instrumentation_methods', + nr_genome_metabolomics_links = 'summary.nr_genome_metabolomics_links', + nr_genecluster_mspectra_links = 'summary.nr_genecluster_mspectra_links', +} +export type SortField = keyof typeof SortFields; + +export enum Order { + desc = 'desc', + asc = 'asc' +} + +export const DEFAULT_PAGE_SIZE = 100; interface Hit { _id: string; @@ -34,7 +84,7 @@ export function expandEnrichedProjectDocument(project: EnrichedProjectDocument, if (mode_title) { d.mode_title = mode_title; } - const ionization_type_title = lookups.ionization_type.get(d.ionization.ionization_type); + const ionization_type_title = lookups.ionization_type.get(d.ionization.ionization_type); if (ionization_type_title) { d.ionization_type_title = ionization_type_title; } @@ -51,52 +101,79 @@ export function expandEnrichedProjectDocument(project: EnrichedProjectDocument, if (doc.enrichments && doc.enrichments.genomes) { doc.enrichments.genomes = Object.entries(doc.enrichments.genomes).map((keyval: any) => { - return {...keyval[1], label: keyval[0]}; + return { ...keyval[1], label: keyval[0] }; }); } // TODO species label fallback for unenriched project + doc.summary = summarizeProject(project); + delete doc.summary._id; + // To sort on _id we need to store id in es, but es does not like _id name + // so storing as project_id + doc.project_id = doc._id; delete doc._id; return doc; } -export function collapseHit(hit: Hit): EnrichedProjectDocument { - const project = hit._source; - project.project.experimental.sample_preparation.forEach( - (d: any) => { - delete d.medium_details.medium_title; - delete d.medium_details.metagenomic_environment_title; - } - ); - project.project.experimental.instrumentation_methods.forEach( - (d: any) => { - delete d.instrumentation.instrument_title; - delete d.mode_title; - delete d.ionization_type_title; - } - ); - project.project.experimental.extraction_methods.forEach( - (m: any) => m.solvents.forEach( - (d: any) => delete d.solvent_title - ) - ); - - if (project.enrichments && project.enrichments.genomes) { - const array = project.enrichments.genomes; - const object: any = {}; - array.forEach((item: any) => { - object[item.label] = item; - delete item.label; - }); - project.enrichments.genomes = object; +export function collapseHit(hit: Hit): ProjectSummary { + const summary = hit._source.summary; + if (hit._score) { + summary.score = hit._score; } + summary._id = hit._id; + delete hit._source.project_id; + return summary; +} - project._id = hit._id; +function buildAll() { + return { + match_all: {} + }; +} - return project; +function buildQuery(query: string) { + return { + 'simple_query_string': { + query + } + }; } -export type FilterField = 'principal_investigator' | 'submitter' | 'genome_type' | 'species' | 'metagenomic_environment' | 'instrument_type' | 'ionization_mode' | 'ionization_type' | 'growth_medium' | 'solvent'; +function buildFilter(key: FilterField, value: string) { + const eskey = FilterFields[key]; + if (!eskey) { + throw new Error('Invalid filter field'); + } + const match: { [key: string]: string } = {}; + match[eskey] = value; + return { + match + }; +} + +function buildQueryFilter(query: any, filter: any) { + return { + bool: { + must: query, + filter + } + }; +} + +function buildBody(options: SearchOptions) { + if (options.query && options.filter) { + return buildQueryFilter( + buildQuery(options.query), + buildFilter(options.filter.key, options.filter.value) + ); + } else if (options.query) { + return buildQuery(options.query); + } else if (options.filter) { + return buildFilter(options.filter.key, options.filter.value); + } else { + return buildAll(); + } +} export class SearchEngine { private schema: any; @@ -123,7 +200,7 @@ export class SearchEngine { } private async createIndex() { - // Force all detected integers to be floats + // Force all detected integers to be floats except fields that start with nr_ // Due to dynamic mapping `1` will be mapped to a long while other documents will require a float. await this.client.indices.create({ index: this.index, @@ -131,6 +208,7 @@ export class SearchEngine { mappings: { dynamic_templates: [{ floats: { + unmatch: 'nr_*', match_mapping_type: 'long', mapping: { type: 'float' @@ -174,59 +252,34 @@ export class SearchEngine { }); } - async search(query: string) { - const { body } = await this.client.search({ - index: this.index, - body: { - 'query': { - 'simple_query_string': { - query - } - } - } - }); - const hits: EnrichedProjectDocument[] = body.hits.hits.map(collapseHit); - return hits; + async search(options: SearchOptions) { + const defaultSort = (options.query || options.filter) ? 'score' : 'met_id'; + const { + size = DEFAULT_PAGE_SIZE, + from = 0, + sort = defaultSort, + order = Order.desc + } = options; + const qbody = buildBody(options); + return await this._search(qbody, size, from, sort, order); } - async filter(key: FilterField, value: string) { - const query: any = {}; - if (key === 'submitter') { - query.bool = { - should: [{ - 'match': {} - }, { - 'match': {} - }] - }; - query.bool.should[0].match['project.personal.submitter_name.keyword'] = value; - query.bool.should[1].match['project.personal.submitter_name_secondary.keyword'] = value; - } else { - const key2eskey = { - principal_investigator: 'project.personal.PI_name.keyword', - genome_type: 'project.genomes.genome_ID.genome_type.keyword', - species: 'enrichments.genomes.species.scientific_name.keyword', - metagenomic_environment: 'project.experimental.sample_preparation.medium_details.metagenomic_environment_title.keyword', - instrument_type: 'project.experimental.instrumentation_methods.instrumentation.instrument_title.keyword', - ionization_mode: 'project.experimental.instrumentation_methods.mode_title.keyword', - ionization_type: 'project.experimental.instrumentation_methods.ionization_type_title.keyword', - growth_medium: 'project.experimental.sample_preparation.medium_details.medium_title.keyword', - solvent: 'project.experimental.extraction_methods.solvents.solvent_title.keyword', - }; - const eskey = key2eskey[key]; - if (!eskey) { - throw new Error('Invalid filter field'); - } - query.match = {}; - query.match[eskey] = value; - } - const { body } = await this.client.search({ + private async _search(query: any, size: number, from: number, sort: SortField, order: Order) { + const request: any = { index: this.index, + size: size, + from, + _source: 'summary', body: { query } - }); - const hits: EnrichedProjectDocument[] = body.hits.hits.map(collapseHit); - return hits; + }; + if (sort && sort !== 'score') { + request.sort = SortFields[sort] + ':' + order; + } + const response = await this.client.search(request); + const data: ProjectSummary[] = response.body.hits.hits.map(collapseHit); + const total: number = response.body.hits.total.value; + return { data, total }; } } \ No newline at end of file diff --git a/api/src/summarize.test.ts b/api/src/summarize.test.ts index 1ab7e3c0..67123d56 100644 --- a/api/src/summarize.test.ts +++ b/api/src/summarize.test.ts @@ -1,5 +1,5 @@ import { loadJSONDocument } from './util/io'; -import { summarizeProject, compareMetaboliteID } from './summarize'; +import { summarizeProject } from './summarize'; import { EXAMPLE_PROJECT_JSON_FN } from './testhelpers'; describe('summarizeProject()', () => { @@ -14,13 +14,12 @@ describe('summarizeProject()', () => { const expected = { '_id': 'some-project-id', - 'GNPSMassIVE_ID': 'MSV000078839', + 'metabolite_id': 'MSV000078839', 'PI_name': 'Marnix Medema', 'submitters': 'Justin van der Hooft', - 'metabolights_study_id': '', 'nr_extraction_methods': 3, 'nr_genecluster_mspectra_links': 3, - 'nr_genome_metabolmics_links': 21, + 'nr_genome_metabolomics_links': 21, 'nr_genomes': 3, 'nr_growth_conditions': 3, 'nr_instrumentation_methods': 1 @@ -41,13 +40,12 @@ describe('summarizeProject()', () => { const expected = { '_id': 'some-project-id', - 'GNPSMassIVE_ID': 'MSV000078839', + 'metabolite_id': 'MSV000078839', 'PI_name': 'Marnix Medema', 'submitters': 'Justin van der Hooft & Stefan Verhoeven', - 'metabolights_study_id': '', 'nr_extraction_methods': 3, 'nr_genecluster_mspectra_links': 3, - 'nr_genome_metabolmics_links': 21, + 'nr_genome_metabolomics_links': 21, 'nr_genomes': 3, 'nr_growth_conditions': 3, 'nr_instrumentation_methods': 1 @@ -56,62 +54,3 @@ describe('summarizeProject()', () => { }); }); }); - -describe('compareMetaboliteID()', () => { - test.each([ - ['MSV000078839', '', 'MSV000078839', '', 0], - ['MSV000098839', '', 'MSV000078839', '', 1], - ['MSV000078839', '', 'MSV000098839', '', -1], - ['', 'MTBLS1302', '', 'MTBLS1302', 0], - ['', 'MTBLS2302', '', 'MTBLS1302', 1], - ['', 'MTBLS1302', '', 'MTBLS2302', -1], - ])('%s,%s,%s,%s,%i', (a_gnps: string, a_metabolights: string, b_gnps: string, b_metabolights: string, expected: number) => { - const a = { - '_id': 'some-project-id', - 'GNPSMassIVE_ID': '', - 'PI_name': 'Marnix Medema', - 'submitters': 'Justin van der Hooft & Stefan Verhoeven', - 'metabolights_study_id': '', - 'nr_extraction_methods': 3, - 'nr_genecluster_mspectra_links': 3, - 'nr_genome_metabolmics_links': 21, - 'nr_genomes': 3, - 'nr_growth_conditions': 3, - 'nr_instrumentation_methods': 1 - }; - if (a_gnps) { - a['GNPSMassIVE_ID'] = a_gnps; - } else { - delete a['GNPSMassIVE_ID']; - } - if (a_metabolights) { - a['metabolights_study_id'] = a_metabolights; - } else { - delete a['metabolights_study_id']; - } - const b = { - '_id': 'some-project-id', - 'PI_name': 'Marnix Medema', - 'GNPSMassIVE_ID': '', - 'submitters': 'Justin van der Hooft & Stefan Verhoeven', - 'metabolights_study_id': '', - 'nr_extraction_methods': 3, - 'nr_genecluster_mspectra_links': 3, - 'nr_genome_metabolmics_links': 21, - 'nr_genomes': 3, - 'nr_growth_conditions': 3, - 'nr_instrumentation_methods': 1 - }; - if (b_gnps) { - b['GNPSMassIVE_ID'] = b_gnps; - } else { - delete b['GNPSMassIVE_ID']; - } - if (b_metabolights) { - b['metabolights_study_id'] = b_metabolights; - } else { - delete b['metabolights_study_id']; - } - expect(compareMetaboliteID(a, b)).toEqual(expected); - }); -}); \ No newline at end of file diff --git a/api/src/summarize.ts b/api/src/summarize.ts index 1f855a1b..4fcfe230 100644 --- a/api/src/summarize.ts +++ b/api/src/summarize.ts @@ -1,18 +1,18 @@ import { GNPSMassIVE, MetaboLights } from './schema'; import { EnrichedProjectDocument } from './store/enrichments'; -interface ProjectSummary { +export interface ProjectSummary { _id: string; - GNPSMassIVE_ID: string; - metabolights_study_id: string; + metabolite_id: string; PI_name: string; submitters: string; nr_genomes: number; nr_growth_conditions: number; nr_extraction_methods: number; nr_instrumentation_methods: number; - nr_genome_metabolmics_links: number; + nr_genome_metabolomics_links: number; nr_genecluster_mspectra_links: number; + score?: number; } function isMetaboLights(project: GNPSMassIVE | MetaboLights): project is MetaboLights { @@ -29,37 +29,24 @@ export const summarizeProject = (d: EnrichedProjectDocument): ProjectSummary => const nr_growth_conditions = project['experimental'] && project['experimental']['sample_preparation'] ? project['experimental']['sample_preparation'].length : 0; const nr_extraction_methods = project['experimental'] && project['experimental']['extraction_methods'] ? project['experimental']['extraction_methods'].length : 0; const nr_instrumentation_methods = project['experimental'] && project['experimental']['instrumentation_methods'] ? project['experimental']['instrumentation_methods'].length : 0; - const nr_genome_metabolmics_links = project['genome_metabolome_links'] ? project['genome_metabolome_links'].length : 0; + const nr_genome_metabolomics_links = project['genome_metabolome_links'] ? project['genome_metabolome_links'].length : 0; const nr_genecluster_mspectra_links = project['BGC_MS2_links'] ? project['BGC_MS2_links']!.length : 0; const summary = { _id: d._id, - metabolights_study_id: '', - GNPSMassIVE_ID: '', + metabolite_id: '', PI_name: project['personal']['PI_name']!, submitters, nr_genomes, nr_growth_conditions, nr_extraction_methods, nr_instrumentation_methods, - nr_genome_metabolmics_links, + nr_genome_metabolomics_links, nr_genecluster_mspectra_links, }; if (isMetaboLights(project.metabolomics.project)) { - summary.metabolights_study_id = project.metabolomics.project.metabolights_study_id; + summary.metabolite_id = project.metabolomics.project.metabolights_study_id; } else { - summary.GNPSMassIVE_ID = project.metabolomics.project.GNPSMassIVE_ID; + summary.metabolite_id = project.metabolomics.project.GNPSMassIVE_ID; } return summary; }; - -export const compareMetaboliteID = (a: ProjectSummary, b: ProjectSummary): 1 | -1 | 0 => { - const ia = a.GNPSMassIVE_ID ? a.GNPSMassIVE_ID : a.metabolights_study_id; - const ib = b.GNPSMassIVE_ID ? b.GNPSMassIVE_ID : b.metabolights_study_id; - if (ia < ib) { - return -1; - } - if (ia > ib) { - return 1; - } - return 0; -}; \ No newline at end of file diff --git a/api/src/testhelpers.ts b/api/src/testhelpers.ts index b015ece9..ac67b774 100644 --- a/api/src/testhelpers.ts +++ b/api/src/testhelpers.ts @@ -3,7 +3,6 @@ import { loadJSONDocument } from './util/io'; export const EXAMPLE_PROJECT_JSON_FN = '../app/public/examples/paired_datarecord_MSV000078839_example.json'; export async function mockedElasticSearchClient() { - const project = await loadJSONDocument(EXAMPLE_PROJECT_JSON_FN); const client = { indices: { delete: jest.fn(), @@ -14,6 +13,17 @@ export async function mockedElasticSearchClient() { search: jest.fn(), delete: jest.fn(), }; + const summary = { + metabolite_id: 'MSV000078839', + PI_name: 'Marnix Medema', + submitters: 'Justin van der Hooft', + nr_extraction_methods: 3, + nr_genecluster_mspectra_links: 3, + nr_genome_metabolomics_links: 21, + nr_genomes: 3, + nr_growth_conditions: 3, + nr_instrumentation_methods: 1, + }; client.search.mockResolvedValue({ body: { hits: { @@ -22,9 +32,12 @@ export async function mockedElasticSearchClient() { _score: 0.5, _source: { _id: 'projectid1', - project + summary } - }] + }], + total: { + value: 1 + } } } }); diff --git a/app/package.json b/app/package.json index 6ad85463..492746f3 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "paired-data-platform", - "version": "0.6.1", + "version": "0.6.2", "private": true, "dependencies": { "@root/encoding": "^1.0.1", diff --git a/app/public/openapi.yaml b/app/public/openapi.yaml index 49edfe4b..088d7e71 100644 --- a/app/public/openapi.yaml +++ b/app/public/openapi.yaml @@ -36,6 +36,8 @@ paths: "species", "metagenomic_environment", "instrument_type", + "ionization_mode", + "ionization_type", "growth_medium", "solvent", ] @@ -45,6 +47,40 @@ paths: example: Pieter C. Dorrestein schema: type: string + - name: size + in: query + description: Page size + example: 50 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 1000 + - name: page + in: query + description: Page number + example: 25 + schema: + type: integer + default: 0 + minimum: 0 + maximum: 1000 + - name: sort + in: query + description: Field to sort on + example: nr_genomes + schema: + type: string + default: 'met_id' + enum: ["nr_genomes" , "score" , "_id" , "met_id" , "PI_name" , "submitters" , "nr_growth_conditions" , "nr_extraction_methods" , "nr_instrumentation_methods" , "nr_genome_metabolomics_links" , "nr_genecluster_mspectra_links"] + - name: order + in: query + description: Order to sort + example: asc + schema: + type: string + default: 'desc' + enum: ['asc', 'desc'] responses: "200": description: Successful Response @@ -135,10 +171,14 @@ components: type: object properties: data: + description: Projects on current page type: array items: $ref: "#/components/schemas/ProjectSummary" - required: [data] + total: + description: Total number of projects + type: integer + required: [data, total] additionalProperties: false ProjectSummary: type: object @@ -193,6 +233,9 @@ components: $ref: "/schema.json" enrichments: $ref: "#/components/schemas/Enrichments" + score: + description: Search score + type: number required: [_id, project] additionalProperties: false Enrichments: diff --git a/app/src/ProjectList.tsx b/app/src/ProjectList.tsx index bfb0debe..5f20c859 100644 --- a/app/src/ProjectList.tsx +++ b/app/src/ProjectList.tsx @@ -8,35 +8,47 @@ interface ColumnHeaderProps { skey: string; onClick(key: string): void; title: string; + sortOrder: 'desc' | 'asc' } -const ColumnHeader = ({ active, skey, onClick, title }: ColumnHeaderProps) => ( - active !== skey && onClick(skey)} - style={active !== skey ? { cursor: 'pointer' } : {}} - title="Click to sort on"> - {title} - {active === skey && <> } - -) +const ColumnHeader = ({ active, skey, onClick, title, sortOrder }: ColumnHeaderProps) => { + let glyph = <>; + if (active === skey) { + if (sortOrder === 'asc') { + glyph = ; + } else { + glyph = ; + } + } + return ( + onClick(skey)} + style={{ cursor: 'pointer' }} + title="Click to sort on"> + {title} + <> {glyph} + + ); +} interface Props { projects: ProjectSummary[]; setSortedOn(key: string): void; - sortedOn: string; + sortKey: string; + sortOrder: 'desc' | 'asc'; } -export const ProjectList = ({ projects, setSortedOn, sortedOn }: Props) => { +export const ProjectList = ({ projects, setSortedOn, sortKey, sortOrder }: Props) => { const rows = projects.map(d => ( - {d.GNPSMassIVE_ID ? d.GNPSMassIVE_ID : d.metabolights_study_id} + {d.metabolite_id} {d.PI_name} {d.submitters} {d.nr_genomes} {d.nr_growth_conditions} {d.nr_extraction_methods} {d.nr_instrumentation_methods} - {d.nr_genome_metabolmics_links} + {d.nr_genome_metabolomics_links} {d.nr_genecluster_mspectra_links} )); @@ -47,15 +59,15 @@ export const ProjectList = ({ projects, setSortedOn, sortedOn }: Props) => { - - - - - - - - - + + + + + + + + + diff --git a/app/src/ProjectPager.test.tsx b/app/src/ProjectPager.test.tsx new file mode 100644 index 00000000..e493451a --- /dev/null +++ b/app/src/ProjectPager.test.tsx @@ -0,0 +1,167 @@ +import * as React from 'react'; + +import { render, RenderResult, getRoles, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { ProjectPager } from './ProjectPager'; + +describe('', () => { + describe('when all hits fit on current page', () => { + it('should render nothing', () => { + const nextPage = jest.fn(); + const prevPage = jest.fn(); + + const comp = render(); + + expect(comp.container.innerHTML).toEqual(''); + }); + }); + + describe('when first page of many', () => { + let comp: RenderResult; + let prevPage: jest.Mock; + let nextPage: jest.Mock; + + beforeEach(() => { + nextPage = jest.fn(); + prevPage = jest.fn(); + + comp = render(); + }); + + it('should display page counts', () => { + expect(comp.baseElement).toHaveTextContent(/1 - 100 of 450/); + }); + + it('should have prev page button disabled', () => { + const but = comp.getByTitle('Previous').parentElement; + expect(but!.className).toContain('disabled'); + }); + + it('should have prev page button not disabled', () => { + const but = comp.getByTitle('Next').parentElement; + expect(but!.className).not.toContain('disabled'); + }); + + describe('when next button clicked', () => { + beforeEach(() => { + const but = comp.getByTitle('Next') + fireEvent.click(but); + }); + + it('should call nextPage()', () => { + expect(nextPage).toHaveBeenCalled(); + }); + }); + }); + + describe('when middle page of many', () => { + let comp: RenderResult; + let prevPage: jest.Mock; + let nextPage: jest.Mock; + + beforeEach(() => { + nextPage = jest.fn(); + prevPage = jest.fn(); + + comp = render(); + }); + + it('should display page counts', () => { + expect(comp.baseElement).toHaveTextContent(/101 - 200 of 450/); + }); + + it('should have prev page button not disabled', () => { + const but = comp.getByTitle('Previous').parentElement; + expect(but!.className).not.toContain('disabled'); + }); + + it('should have prev page button not disabled', () => { + const but = comp.getByTitle('Next').parentElement; + expect(but!.className).not.toContain('disabled'); + }); + + describe('when next button clicked', () => { + beforeEach(() => { + const but = comp.getByTitle('Next') + fireEvent.click(but); + }); + + it('should call nextPage()', () => { + expect(nextPage).toHaveBeenCalled(); + }); + }); + + describe('when prev button clicked', () => { + beforeEach(() => { + const but = comp.getByTitle('Previous') + fireEvent.click(but); + }); + + it('should call prevPage()', () => { + expect(prevPage).toHaveBeenCalled(); + }); + }); + }); + + describe('when last page of many', () => { + let comp: RenderResult; + let prevPage: jest.Mock; + let nextPage: jest.Mock; + + beforeEach(() => { + nextPage = jest.fn(); + prevPage = jest.fn(); + + comp = render(); + }); + + it('should display page counts', () => { + expect(comp.baseElement).toHaveTextContent(/401 - 450 of 450/); + }); + + it('should have prev page button not disabled', () => { + const but = comp.getByTitle('Previous').parentElement; + expect(but!.className).not.toContain('disabled'); + }); + + it('should have prev page button not disabled', () => { + const but = comp.getByTitle('Next').parentElement; + expect(but!.className).toContain('disabled'); + }); + + describe('when prev button clicked', () => { + beforeEach(() => { + const but = comp.getByTitle('Previous') + fireEvent.click(but); + }); + + it('should call prevPage()', () => { + expect(prevPage).toHaveBeenCalled(); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/src/ProjectPager.tsx b/app/src/ProjectPager.tsx new file mode 100644 index 00000000..16bfbe8f --- /dev/null +++ b/app/src/ProjectPager.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { Pager } from "react-bootstrap"; +import { PAGE_SIZE } from "./api"; + +interface Props { + prevPage(): void; + nextPage(): void; + page: number; + page_count: number; + total: number; +} + +export const ProjectPager = ({ page, prevPage, nextPage, page_count, total }: Props) => { + if (page_count === total) { + return <>; + } else { + return ( + + + ← Previous + +
  • {page * PAGE_SIZE + 1} - {(page * PAGE_SIZE) + page_count} of {total}
  • + = total} + onClick={nextPage} + title="Next" + > + Next → + +
    + ); + } +} \ No newline at end of file diff --git a/app/src/api.ts b/app/src/api.ts index 18628e3f..d13fbb1a 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -9,24 +9,37 @@ import { UiSchema } from "react-jsonschema-form"; export const API_BASE_URL = '/api'; -export const useProjects = (query='', filter={ key: '', value: '' }) => { - const searchParams = new URLSearchParams(); +export const PAGE_SIZE = 100; + +export const useProjects = (query = '', filter = { key: '', value: '' }, page = 0, sort = '', order = '') => { + const params = new URLSearchParams(); + params.set('size', PAGE_SIZE.toString()); + params.set('page', page.toString()); if (query) { - searchParams.set('q', query); + params.set('q', query); } if (filter.key && filter.value) { - searchParams.set('fk', filter.key); - searchParams.set('fv', filter.value); + params.set('fk', filter.key); + params.set('fv', filter.value); + } + if (sort) { + params.set('sort', sort); + } + if (order) { + params.set('order', order); } - const url = API_BASE_URL + '/projects?' + searchParams.toString(); - const response = useFetch<{ data: ProjectSummary[] }>(url); + const url = API_BASE_URL + '/projects?' + params.toString(); + const response = useFetch<{ data: ProjectSummary[], total: number }>(url); let data: ProjectSummary[] = []; + let total = 0; if (response.data) { data = response.data.data; + total = response.data.total; } return { ...response, data, + total }; }; diff --git a/app/src/pages/PendingProjects.test.tsx b/app/src/pages/PendingProjects.test.tsx index d9dd5567..94edf64d 100644 --- a/app/src/pages/PendingProjects.test.tsx +++ b/app/src/pages/PendingProjects.test.tsx @@ -96,7 +96,7 @@ describe('', () => { data: { data: [{ _id: 'id1', - GNPSMassIVE_ID: 'somegnpsid', + metabolite_id: 'somegnpsid', PI_name: 'somepi', submitter: 'somesubmitter', nr_genomes: 3, diff --git a/app/src/pages/PendingProjects.tsx b/app/src/pages/PendingProjects.tsx index 25aab307..4080098f 100644 --- a/app/src/pages/PendingProjects.tsx +++ b/app/src/pages/PendingProjects.tsx @@ -30,32 +30,32 @@ export function PendingProjects() { const onDeny = (project_id: string) => async () => { await denyPendingProject(project_id, token); const pruned_projects = dropProject(project_id, projects.data!.data) - projects.setData({data: pruned_projects}); + projects.setData({ data: pruned_projects }); }; const onApprove = (project_id: string) => async () => { await approvePendingProject(project_id, token); const pruned_projects = dropProject(project_id, projects.data!.data) - projects.setData({data: pruned_projects}); + projects.setData({ data: pruned_projects }); }; const rows = projects.data!.data.map(d => (
    - + - + - + )); if (projects.data!.data.length === 0) { - rows.push(); + rows.push(); } return (
    diff --git a/app/src/pages/Projects.test.tsx b/app/src/pages/Projects.test.tsx index 846cf09f..f81a31bf 100644 --- a/app/src/pages/Projects.test.tsx +++ b/app/src/pages/Projects.test.tsx @@ -138,14 +138,19 @@ describe('', () => { describe('click on PI column header', () => { beforeEach(() => { + (useProjects as jest.Mock).mockClear(); const button = wrapper.getByText('Principal investigator'); fireEvent.click(button); }); it('should have sorted rows on PI', () => { const cells = wrapper.getAllByRole('cell'); - expect(cells[1].textContent).toEqual('otherpi'); - expect(cells[10].textContent).toEqual('somepi'); + expect(cells[10].textContent).toEqual('otherpi'); + expect(cells[1].textContent).toEqual('somepi'); + }); + + it('should call api with sort on PI', () => { + expect(useProjects).toHaveBeenCalledWith(undefined, undefined, 0, 'PI_name', 'desc'); }); }); @@ -167,12 +172,13 @@ describe('', () => { describe('when search is submitted', () => { beforeEach(() => { + (useProjects as jest.Mock).mockClear(); const search = wrapper.getByTitle('Search'); fireEvent.click(search); }); it('should pass search query to api', () => { - expect(useProjects).toHaveBeenCalledWith('foobar', undefined); + expect(useProjects).toHaveBeenCalledWith('foobar', undefined, 0, "score", "desc"); }); it('should include search query in url', () => { @@ -187,7 +193,7 @@ describe('', () => { }); it('should clear search query to api', () => { - expect(useProjects).toHaveBeenCalledWith(undefined, undefined); + expect(useProjects).toHaveBeenCalledWith(undefined, undefined, 0, "met_id", "desc"); }); it('should no longer include search query in url',() => { @@ -203,6 +209,7 @@ describe('', () => { describe('when filter is in url', () => { beforeEach(() => { + (useProjects as jest.Mock).mockClear(); const route = '/projects?fk=submitter&fv=submitter3'; history.push(route); }); @@ -221,7 +228,7 @@ describe('', () => { expect(useProjects).toHaveBeenCalledWith(undefined, { key: 'submitter', value: 'submitter3' - }); + }, 0, "met_id", "desc"); }); describe('when clear filter button is clicked', () => { diff --git a/app/src/pages/Projects.tsx b/app/src/pages/Projects.tsx index df73bd52..74206428 100644 --- a/app/src/pages/Projects.tsx +++ b/app/src/pages/Projects.tsx @@ -1,11 +1,10 @@ import * as React from "react"; -import { useState } from "react"; import { useLocation, useHistory } from "react-router-dom"; import { useProjects } from "../api"; -import { compareProjectSummary } from "../summarize"; import { ProjectList } from "../ProjectList"; import { ProjectSearch, FilterKey } from "../ProjectSearch"; +import { ProjectPager } from "../ProjectPager"; const style = { padding: '10px' }; @@ -22,27 +21,56 @@ export function Projects() { value: params.get('fv')! } } + const defaultSortKey = q ? 'score' : 'met_id'; + const sortKey = params.has('sort') ? params.get('sort')! : defaultSortKey; + const order = params.has('order') ? params.get('order')! : 'desc'; + const page = params.has('page') ? parseInt(params.get('page')!) : 0; const { error, loading, data: projects, - setData: setProjects - } = useProjects(q, filter); + total, + } = useProjects(q, filter, page, sortKey, order); - const [sortkey, setSortKey] = useState('met_id'); const sortOn = (key: string) => { - // TODO reverse sort when sorted column is clicked again - const data = [...projects]; - data.sort(compareProjectSummary(key)); - setProjects({ data }); - setSortKey(key); + if (key === sortKey) { + params.set('order', order === 'asc' ? 'desc' : 'asc'); + } + params.set('sort', key); + history.push("/projects?" + params.toString()); } + const prevPage = () => { + const newpage = page - 1; + if (newpage === 0) { + params.delete('page'); + } else { + params.set('page', newpage.toString()); + } + history.push("/projects?" + params.toString()); + }; + const nextPage = () => { + const newpage = page + 1; + params.set('page', newpage.toString()); + history.push("/projects?" + params.toString()); + }; + let list = Loading ...; if (error) { list = Error: {error.message} } else if (!loading) { - list = + list = ( + <> + + + + ); } function clearFilter() { diff --git a/app/src/summarize.test.ts b/app/src/summarize.test.ts deleted file mode 100644 index ee309751..00000000 --- a/app/src/summarize.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { compareProjectSummary, ProjectSummary } from "./summarize"; - -describe('compareProjectSummary()', () => { - - describe('a === a', () => { - test.each([ - ['_id'], - ['met_id'], - ['PI_name'], - ['submitters'], - ['nr_genomes'], - ['nr_growth_conditions'], - ['nr_extraction_methods'], - ['nr_instrumentation_methods'], - ['nr_genome_metabolmics_links'], - ['nr_genecluster_mspectra_links'], - ])('%s', (key: string) => { - const comparer = compareProjectSummary(key); - const a: ProjectSummary ={ - _id: 'id1', - GNPSMassIVE_ID: 'somegnpsid', - metabolights_study_id: '', - PI_name: 'somepi', - submitters: 'somesubmitter', - nr_genomes: 3, - nr_growth_conditions: 4, - nr_extraction_methods: 5, - nr_instrumentation_methods: 2, - nr_genome_metabolmics_links: 123, - nr_genecluster_mspectra_links: 42, - }; - const result = comparer(a, a); - expect(result).toEqual(0); - }); - }); - - describe('when key is given which is not part of ProjectSummary', () => { - it('should throw an Error', () => { - expect(() => compareProjectSummary('wrongkey')).toThrowError(/wrongkey/); - }); - }); -}); diff --git a/app/src/summarize.ts b/app/src/summarize.ts index 3954d4a8..11e7f873 100644 --- a/app/src/summarize.ts +++ b/app/src/summarize.ts @@ -2,15 +2,14 @@ import { IOMEGAPairedOmicsDataPlatform as ProjectDocument } from './schema'; export interface ProjectSummary { _id: string; - GNPSMassIVE_ID: string; - metabolights_study_id: string; + metabolite_id: string; PI_name: string; submitters: string; nr_genomes: number; nr_growth_conditions: number; nr_extraction_methods: number; nr_instrumentation_methods: number; - nr_genome_metabolmics_links: number; + nr_genome_metabolomics_links: number; nr_genecluster_mspectra_links: number; } @@ -40,36 +39,3 @@ export interface EnrichedProjectDocument { project: ProjectDocument; enrichments?: ProjectEnrichments; } - -export const compareProjectSummary = function(key: string): (a: ProjectSummary, b: ProjectSummary) => number { - if (key === '_id') { - return (a, b) => { - if (a._id > b._id) { - return -1; - } - if (a._id < b._id) { - return 1; - } - return 0; - } - } else if (key === 'met_id') { - return (a, b) => { - const ia = a.GNPSMassIVE_ID ? a.GNPSMassIVE_ID : a.metabolights_study_id; - const ib = b.GNPSMassIVE_ID ? b.GNPSMassIVE_ID : b.metabolights_study_id; - if (ia < ib) { - return -1; - } - if (ia > ib) { - return 1; - } - return 0; - }; - } else if (key === 'PI_name' || key === 'submitters') { - return (a, b) => a[key].localeCompare(b[key]); - } else if (key === 'nr_genomes' || key === 'nr_growth_conditions' || key === 'nr_extraction_methods' || key === 'nr_instrumentation_methods' || key === 'nr_genome_metabolmics_links' || key === 'nr_genecluster_mspectra_links') { - return (a, b) => { - return b[key] - a[key]; - }; - } - throw new TypeError(`${key} is not a property of ProjectSummary`); -}; \ No newline at end of file
    { d.GNPSMassIVE_ID ? d.GNPSMassIVE_ID : d.metabolights_study_id }{d.metabolite_id} {d.PI_name} {d.submitters} {d.nr_genomes} {d.nr_growth_conditions} {d.nr_extraction_methods} {d.nr_instrumentation_methods}{d.nr_genome_metabolmics_links}{d.nr_genome_metabolomics_links} {d.nr_genecluster_mspectra_links}
    No pending projects found.
    No pending projects found.