Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
26 changes: 25 additions & 1 deletion kubernetes/loculus/templates/_common-metadata.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,31 @@ organisms:
metadataTemplate:
{{ .metadataTemplate | toYaml | nindent 8}}
{{ end }}
{{ .website | toYaml | nindent 6 }}
{{ omit .website "multiFieldSearches" | toYaml | nindent 6 }}
{{- if .website.multiFieldSearches }}
{{- $perSegmentFields := dict }}
{{- range (concat $commonMetadata .metadata) }}
{{- if .perSegment }}{{- $_ := set $perSegmentFields .name true }}{{- end }}
{{- end }}
{{- $segments := (include "loculus.getNucleotideSegmentNames" $instance.referenceGenomes | fromYaml).segments }}
{{- $isSegmented := gt (len $segments) 1 }}
multiFieldSearches:
{{- range .website.multiFieldSearches }}
- name: {{ .name }}
displayName: {{ .displayName }}
fields:
{{- range .fields }}
{{- if and $isSegmented (hasKey $perSegmentFields .) }}
{{- $field := . }}
{{- range $segments }}
- {{ printf "%s_%s" $field . }}
{{- end }}
{{- else }}
- {{ . }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
referenceGenomes:
{{ $instance.referenceGenomes | toYaml | nindent 6 }}
Expand Down
27 changes: 27 additions & 0 deletions kubernetes/loculus/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,33 @@
"groups": ["schema"],
"type": "string",
"description": "Must be set to use the sub-organism feature. This metadata field is used to determine which suborganism a sequence entry belongs to. N.B. the suborganism feature is incomplete and not ready for production use."
},
"multiFieldSearches": {
"groups": ["schema"],
"type": "array",
"description": "Define multi-field searches that allow full-text searching across multiple metadata fields of type string.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "displayName", "fields"],
"properties": {
"name": {
"type": "string",
"description": "The technical name of the search field."
},
"displayName": {
"type": "string",
"description": "The display name of the search field."
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of metadata field names to search across when this multi-field search is used. The fields must all be string fields."
}
}
}
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions kubernetes/loculus/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,26 @@ defaultOrganismConfig: &defaultOrganismConfig
- length
defaultOrderBy: sampleCollectionDate
defaultOrder: descending
multiFieldSearches:
- name: identifier
displayName: Identifier
fields:
- accessionVersion
- submissionId
- insdcAccessionFull
- bioprojectAccession
- gcaAccession
- biosampleAccession
- insdcRawReadsAccession
- name: contributor
displayName: Contributor
fields:
- authors
- authorAffiliations
- sequencedByOrganization
- sequencedByContactName
- submitter
- groupName
extraInputFields: []
preprocessing:
- &preprocessing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ export class FieldFilterSet implements SequenceFilter {
}

public toApiParams(): LapisSearchParameters {
const multiFieldSearchNames = new Set(this.filterSchema.multiFieldSearches.map((mfs) => mfs.name));

// 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)) {
Expand All @@ -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
Expand All @@ -112,10 +117,27 @@ export class FieldFilterSet implements SequenceFilter {
nucleotideMutations: [],
};

return {
// advancedQuery for multi-field searches
const advancedQueryParts: string[] = [];
for (const mfs of this.filterSchema.multiFieldSearches) {
const value = this.fieldValues[mfs.name];
if (value && typeof value === 'string' && value.trim()) {
const regex = makeCaseInsensitiveLiteralSubstringRegex(value.trim());
const fieldQueries = mfs.fields.map((f) => `${f}.regex='${regex}'`);
Comment on lines +124 to +126

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape quotes in advancedQuery regex literals

The new multi-field search builds advancedQuery by interpolating the user’s raw input into a single-quoted string (${f}.regex='${regex}'). makeCaseInsensitiveLiteralSubstringRegex escapes regex metacharacters but does not escape single quotes, so inputs containing apostrophes (e.g., “O'Connor”, “King’s College”) will terminate the string literal and produce a malformed advancedQuery, causing the search to fail or be parsed incorrectly. Please escape ' (or switch to a safer encoding/quoting strategy) before embedding user input in the advanced query string.

Useful? React with 👍 / 👎.

advancedQueryParts.push(`(${fieldQueries.join(' or ')})`);
}
}

const result: LapisSearchParameters = {
...sequenceFilters,
...mutationSearchParams,
};

if (advancedQueryParts.length > 0) {
result.advancedQuery = advancedQueryParts.join(' and ');
}

return result;
}

public toUrlSearchParams(): [string, string | string[]][] {
Expand Down Expand Up @@ -163,7 +185,6 @@ export class FieldFilterSet implements SequenceFilter {
}
}
}

return result;
}

Expand Down
9 changes: 9 additions & 0 deletions website/src/components/SearchPage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DateField, TimestampField } from './fields/DateField.tsx';
import { DateRangeField } from './fields/DateRangeField.tsx';
import { LineageField } from './fields/LineageField.tsx';
import { MultiChoiceAutoCompleteField } from './fields/MultiChoiceAutoCompleteField';
import { MultiFieldSearchField } from './fields/MultiFieldSearchField.tsx';
import { MutationField } from './fields/MutationField.tsx';
import { NormalTextField } from './fields/NormalTextField';
import { searchFormHelpDocsUrl } from './searchFormHelpDocsUrl.ts';
Expand Down Expand Up @@ -219,6 +220,14 @@ export const SearchForm = ({
lapisSearchParameters={lapisSearchParameters}
/>
))}
{filterSchema.multiFieldSearches.map((mfs) => (
<MultiFieldSearchField
key={mfs.name}
multiFieldSearch={mfs}
fieldValue={(fieldValues[mfs.name] as string | undefined) ?? ''}
setSomeFieldValues={setSomeFieldValues}
/>
))}
</div>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion website/src/components/SearchPage/SearchFullUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export const InnerSearchFullUI = ({
hiddenFieldValues ??= {};

const metadataSchema = schema.metadata;
const filterSchema = useMemo(() => new MetadataFilterSchema(metadataSchema), [metadataSchema]);
const multiFieldSearches = schema.multiFieldSearches;
const filterSchema = useMemo(
() => new MetadataFilterSchema(metadataSchema, multiFieldSearches),
[metadataSchema, multiFieldSearches],
);

const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);

Expand Down
27 changes: 27 additions & 0 deletions website/src/components/SearchPage/fields/MultiFieldSearchField.tsx
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])}
autoComplete='off'
/>
</DisabledUntilHydrated>
);
};
9 changes: 9 additions & 0 deletions website/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ export const linkOut = z.object({

export type LinkOut = z.infer<typeof linkOut>;

export const multiFieldSearch = z.object({
name: z.string(),
displayName: z.string(),
fields: z.array(z.string()),
});

export type MultiFieldSearch = z.infer<typeof multiFieldSearch>;

export const fileCategory = z.object({
name: z.string(),
});
Expand Down Expand Up @@ -164,6 +172,7 @@ export const schema = z.object({
richFastaHeaderFields: z.array(z.string()).optional(),
linkOuts: z.array(linkOut).optional(),
referenceIdentifierField: z.string().optional(),
multiFieldSearches: z.array(multiFieldSearch).optional(),
});
export type Schema = z.infer<typeof schema>;

Expand Down
Loading
Loading