-
Notifications
You must be signed in to change notification settings - Fork 8
feat(website): add feature to search multiple string fields #5881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { expect } from '@playwright/test'; | ||
| import { test } from '../../../fixtures/console-warnings.fixture'; | ||
| import { SearchPage } from '../../../pages/search.page'; | ||
| import fs from 'fs'; | ||
|
|
||
| test.describe('Multi-field search', () => { | ||
| let searchPage: SearchPage; | ||
|
|
||
| test.beforeEach(({ page }) => { | ||
| searchPage = new SearchPage(page); | ||
| }); | ||
|
|
||
| test('searches by identifier and contributor fields, verifies URL params, and downloads', async ({ | ||
| page, | ||
| browserName, | ||
| }) => { | ||
| test.skip( | ||
| browserName === 'webkit', | ||
| 'WebKit raises a native warning that blocks the download', | ||
| ); | ||
|
|
||
| await searchPage.ebolaSudan(); | ||
| await searchPage.waitForSequencesInSearch(3); | ||
|
|
||
| const identifierField = page.getByRole('textbox', { name: 'Identifier', exact: true }); | ||
| await identifierField.fill('foobar-readonly'); | ||
| await identifierField.press('Enter'); | ||
| await page.waitForFunction( | ||
| () => | ||
| new URL(window.location.href).searchParams.get('identifier') === 'foobar-readonly', | ||
| ); | ||
|
|
||
| let urlParams = new URL(page.url()).searchParams; | ||
| expect(urlParams.get('identifier')).toBe('foobar-readonly'); | ||
| await expect(page.getByText(/Search returned 3 sequence/)).toBeVisible(); | ||
|
|
||
| const contributorField = page.getByRole('textbox', { name: 'Contributor', exact: true }); | ||
| await contributorField.fill('Paris'); | ||
| await contributorField.press('Enter'); | ||
| await page.waitForFunction( | ||
| () => new URL(window.location.href).searchParams.get('contributor') === 'Paris', | ||
| ); | ||
|
|
||
| urlParams = new URL(page.url()).searchParams; | ||
| expect(urlParams.get('identifier')).toBe('foobar-readonly'); | ||
| expect(urlParams.get('contributor')).toBe('Paris'); | ||
| await expect(page.getByText(/Search returned 1 sequence/)).toBeVisible(); | ||
|
|
||
| await expect(page.getByText(/Identifier:\s*foobar-readonly/)).toBeVisible(); | ||
| await expect(page.getByText(/Contributor:\s*Paris/)).toBeVisible(); | ||
|
|
||
| await page.getByRole('button', { name: 'Download all entries' }).click(); | ||
| await page.getByLabel('I agree to the data use terms.').check(); | ||
|
|
||
| const downloadPromise = page.waitForEvent('download'); | ||
| await page.getByTestId('start-download').click(); | ||
| const download = await downloadPromise; | ||
|
|
||
| const downloadPath = await download.path(); | ||
| expect(downloadPath).toBeTruthy(); | ||
|
|
||
| const fileContent = fs.readFileSync(downloadPath, 'utf8'); | ||
| const lines = fileContent.split('\n').filter((line) => line.trim() !== ''); | ||
| expect(lines.length).toBeGreaterThanOrEqual(2); | ||
| expect(lines.length).toBeLessThanOrEqual(2); | ||
| expect(fileContent).toContain('Paris'); | ||
| }); | ||
|
|
||
| test('identifier filter can be removed by clicking the X', async ({ page }) => { | ||
| await searchPage.ebolaSudan(); | ||
|
|
||
| const identifierField = page.getByRole('textbox', { name: 'Identifier', exact: true }); | ||
| await identifierField.fill('foobar'); | ||
| await identifierField.press('Enter'); | ||
| await page.waitForFunction( | ||
| () => new URL(window.location.href).searchParams.get('identifier') === 'foobar', | ||
| ); | ||
|
|
||
| await expect(page.getByText(/Identifier:\s*foobar/)).toBeVisible(); | ||
|
|
||
| const filterChip = page.locator('text=/Identifier:\\s*foobar/').locator('..'); | ||
| await filterChip.getByRole('button').click(); | ||
|
|
||
| await expect(page.getByText(/Identifier:\s*foobar/)).toBeHidden(); | ||
| await expect(identifierField).toHaveValue(''); | ||
|
|
||
| const urlParams = new URL(page.url()).searchParams; | ||
| expect(urlParams.has('identifier')).toBe(false); | ||
| }); | ||
|
|
||
| test('identifier search uses per-segment fields for multi-segmented organism (CCHF)', async ({ | ||
| page, | ||
| }) => { | ||
| await searchPage.cchf(); | ||
|
|
||
| const lapisRequestBodies: string[] = []; | ||
| await page.route('**/sample/aggregated', async (route) => { | ||
| const body = route.request().postData(); | ||
| if (body) { | ||
| lapisRequestBodies.push(body); | ||
| } | ||
| await route.continue(); | ||
| }); | ||
|
|
||
| const identifierField = page.getByRole('textbox', { name: 'Identifier', exact: true }); | ||
| await identifierField.fill('nonexistent-id'); | ||
| await identifierField.press('Enter'); | ||
|
|
||
| await expect(page.getByText(/Search returned 0 sequence/)).toBeVisible(); | ||
|
|
||
| const queryWithIdentifier = lapisRequestBodies.find((body) => | ||
| body.includes('nonexistent-id'), | ||
| ); | ||
| expect(queryWithIdentifier).toBeDefined(); | ||
| expect(queryWithIdentifier).toContain('insdcAccessionFull_L.regex'); | ||
| expect(queryWithIdentifier).toContain('insdcAccessionFull_M.regex'); | ||
| expect(queryWithIdentifier).toContain('insdcAccessionFull_S.regex'); | ||
| expect(queryWithIdentifier).not.toContain('insdcAccessionFull.regex'); | ||
| }); | ||
|
|
||
| test('contributor filter can be removed by clicking the X', async ({ page }) => { | ||
| await searchPage.ebolaSudan(); | ||
|
|
||
| const contributorField = page.getByRole('textbox', { name: 'Contributor', exact: true }); | ||
| await contributorField.fill('Institute'); | ||
| await contributorField.press('Enter'); | ||
| await page.waitForFunction( | ||
| () => new URL(window.location.href).searchParams.get('contributor') === 'Institute', | ||
| ); | ||
|
|
||
| await expect(page.getByText(/Contributor:\s*Institute/)).toBeVisible(); | ||
|
|
||
| const filterChip = page.locator('text=/Contributor:\\s*Institute/').locator('..'); | ||
| await filterChip.getByRole('button').click(); | ||
|
|
||
| await expect(page.getByText(/Contributor:\s*Institute/)).toBeHidden(); | ||
| await expect(contributorField).toHaveValue(''); | ||
|
|
||
| const urlParams = new URL(page.url()).searchParams; | ||
| expect(urlParams.has('contributor')).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,9 +83,12 @@ export class FieldFilterSet implements SequenceFilter { | |
| } | ||
|
|
||
| public toApiParams(): LapisSearchParameters { | ||
| const multiFieldSearchNames = new Set(this.filterSchema.multiFieldSearches.map((mfs) => mfs.name)); | ||
chaoran-chen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // The "normal" search fields | ||
| const sequenceFilters = Object.fromEntries( | ||
| Object.entries(this.fieldValues as Record<string, any>).filter( | ||
| ([, value]) => value !== undefined && value !== '', | ||
| ([key, value]) => value !== undefined && value !== '' && !multiFieldSearchNames.has(key), | ||
| ), | ||
| ); | ||
| for (const filterName of Object.keys(sequenceFilters)) { | ||
|
|
@@ -97,10 +100,12 @@ export class FieldFilterSet implements SequenceFilter { | |
| } | ||
| } | ||
|
|
||
| // Accessions | ||
| if (sequenceFilters.accession !== '' && sequenceFilters.accession !== undefined) { | ||
| sequenceFilters.accession = textAccessionsToList(sequenceFilters.accession); | ||
| } | ||
|
|
||
| // Mutations | ||
| delete sequenceFilters.mutation; | ||
| const mutationSearchParams = | ||
| this.suborganismSegmentAndGeneInfo !== null | ||
|
|
@@ -112,10 +117,27 @@ export class FieldFilterSet implements SequenceFilter { | |
| nucleotideMutations: [], | ||
| }; | ||
|
|
||
| return { | ||
| // advancedQuery for multi-field searches | ||
| const advancedQueryParts: string[] = []; | ||
chaoran-chen marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| for (const mfs of this.filterSchema.multiFieldSearches) { | ||
| const value = this.fieldValues[mfs.name]; | ||
| if (value && typeof value === 'string' && value.trim()) { | ||
theosanderson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const regex = makeCaseInsensitiveLiteralSubstringRegex(value.trim()); | ||
| const fieldQueries = mfs.fields.map((f) => `${f}.regex='${regex}'`); | ||
theosanderson marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+124
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new multi-field search builds Useful? React with 👍 / 👎. |
||
| advancedQueryParts.push(`(${fieldQueries.join(' or ')})`); | ||
| } | ||
| } | ||
theosanderson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const result: LapisSearchParameters = { | ||
| ...sequenceFilters, | ||
| ...mutationSearchParams, | ||
| }; | ||
|
|
||
| if (advancedQueryParts.length > 0) { | ||
| result.advancedQuery = advancedQueryParts.join(' and '); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| public toUrlSearchParams(): [string, string | string[]][] { | ||
|
|
@@ -163,7 +185,6 @@ export class FieldFilterSet implements SequenceFilter { | |
| } | ||
| } | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { TextField } from './TextField'; | ||
| import type { MultiFieldSearch, SetSomeFieldValues } from '../../../types/config.ts'; | ||
| import DisabledUntilHydrated from '../../DisabledUntilHydrated'; | ||
|
|
||
| export interface MultiFieldSearchFieldProps { | ||
| multiFieldSearch: MultiFieldSearch; | ||
| setSomeFieldValues: SetSomeFieldValues; | ||
| fieldValue: string; | ||
| } | ||
|
|
||
| export const MultiFieldSearchField = ({ | ||
| multiFieldSearch, | ||
| setSomeFieldValues, | ||
| fieldValue, | ||
| }: MultiFieldSearchFieldProps) => { | ||
| return ( | ||
| <DisabledUntilHydrated> | ||
| <TextField | ||
| label={multiFieldSearch.displayName} | ||
| type='string' | ||
| fieldValue={fieldValue} | ||
| onChange={(e) => setSomeFieldValues([multiFieldSearch.name, e.target.value])} | ||
theosanderson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| autoComplete='off' | ||
| /> | ||
| </DisabledUntilHydrated> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.