Skip to content

Commit

Permalink
feat(website): Dedicated lineage search field (#3467)
Browse files Browse the repository at this point in the history
* make 'lineageSearch' a flag that can be enabled for metadata fields

* Add some todos

* WIP

* Forward lineageSearch property

* progress

* progress

* progress

* use autocomplete field

* swap layout

* don't write '*' query

* Update lineage file

* update link

* Add prefixes to combobox?

* improve

* format

* remove pango-lineage-specific expansion

* Add Test
  • Loading branch information
fhennig authored Jan 14, 2025
1 parent 0f3931e commit 86eb930
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 21 deletions.
3 changes: 3 additions & 0 deletions kubernetes/loculus/templates/_common-metadata.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ organisms:
{{- if .rangeOverlapSearch }}
rangeOverlapSearch: {{ .rangeOverlapSearch | toJson }}
{{- end}}
{{- if .lineageSystem }}
lineageSearch: true
{{- end }}
{{- if .hideOnSequenceDetailsPage }}
hideOnSequenceDetailsPage: {{ .hideOnSequenceDetailsPage }}
{{- end }}
Expand Down
2 changes: 1 addition & 1 deletion kubernetes/loculus/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ logo:
height: 100
lineageSystemDefinitions:
pangoLineage:
1: https://raw.githubusercontent.com/loculus-project/loculus/refs/heads/main/preprocessing/dummy/lineage.yaml
1: https://raw.githubusercontent.com/loculus-project/loculus/c400348ea0ba0b8178aa43475d5c7539fc097997/preprocessing/dummy/lineage.yaml
defaultOrganismConfig: &defaultOrganismConfig
schema: &schema
loadSequencesAutomatically: true
Expand Down
12 changes: 9 additions & 3 deletions preprocessing/dummy/lineage.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
A.1:
A:
aliases: []
parents: []
A.1.1:
A.1:
aliases: []
parents:
- A
A.1.1:
aliases:
- B
parents:
- A.1
A.2:
aliases: []
parents: []
parents:
- A
13 changes: 12 additions & 1 deletion website/src/components/SearchPage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AccessionField } from './fields/AccessionField.tsx';
import { AutoCompleteField } from './fields/AutoCompleteField';
import { DateField, TimestampField } from './fields/DateField.tsx';
import { DateRangeField } from './fields/DateRangeField.tsx';
import { LineageField } from './fields/LineageField.tsx';
import { MutationField } from './fields/MutationField.tsx';
import { NormalTextField } from './fields/NormalTextField';
import { searchFormHelpDocsUrl } from './searchFormHelpDocsUrl.ts';
Expand Down Expand Up @@ -180,8 +181,18 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe
setSomeFieldValues={setSomeFieldValues}
/>
);

default:
if (field.lineageSearch) {
return (
<LineageField
field={field}
fieldValue={(fieldValues[field.name] ?? '') as string}
setSomeFieldValues={setSomeFieldValues}
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
/>
);
}
if (field.autocomplete === true) {
return (
<AutoCompleteField
Expand Down
38 changes: 22 additions & 16 deletions website/src/components/SearchPage/fields/AutoCompleteField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { lapisClientHooks } from '../../../services/serviceHooks.ts';
import { type GroupedMetadataFilter, type MetadataFilter, type SetSomeFieldValues } from '../../../types/config.ts';
import { formatNumberWithDefaultLocale } from '../../../utils/formatNumber.tsx';

export type Option = {
option: string;
count: number | undefined;
};

type AutoCompleteFieldProps = {
field: MetadataFilter | GroupedMetadataFilter;
setSomeFieldValues: SetSomeFieldValues;
Expand Down Expand Up @@ -65,19 +70,18 @@ export const AutoCompleteField = ({
mutate({ fields: [field.name], ...otherFields });
};

const options = useMemo(
() =>
(data?.data ?? [])
.filter(
(it) =>
typeof it[field.name] === 'string' ||
typeof it[field.name] === 'boolean' ||
typeof it[field.name] === 'number',
)
.map((it) => ({ option: it[field.name]!.toString(), count: it.count }))
.sort((a, b) => (a.option.toLowerCase() < b.option.toLowerCase() ? -1 : 1)),
[data, field.name],
);
const options: Option[] = useMemo(() => {
const options: Option[] = (data?.data ?? [])
.filter(
(it) =>
typeof it[field.name] === 'string' ||
typeof it[field.name] === 'boolean' ||
typeof it[field.name] === 'number',
)
.map((it) => ({ option: it[field.name]!.toString(), count: it.count }));

return options.sort((a, b) => (a.option.toLowerCase() < b.option.toLowerCase() ? -1 : 1));
}, [data, field.name]);

const filteredOptions = useMemo(
() =>
Expand Down Expand Up @@ -153,9 +157,11 @@ export const AutoCompleteField = ({
<span className={`inline-block ${selected ? 'font-medium' : 'font-normal'}`}>
{option.option}
</span>
<span className='inline-block ml-1'>
({formatNumberWithDefaultLocale(option.count)})
</span>
{option.count !== undefined && (
<span className='inline-block ml-1'>
({formatNumberWithDefaultLocale(option.count)})
</span>
)}
{selected && (
<span
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
Expand Down
124 changes: 124 additions & 0 deletions website/src/components/SearchPage/fields/LineageField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { LineageField } from './LineageField';
import { lapisClientHooks } from '../../../services/serviceHooks.ts';
import type { MetadataFilter } from '../../../types/config';

vi.mock('../../../services/serviceHooks.ts');
vi.mock('../../../clientLogger.ts', () => ({
getClientLogger: () => ({
error: vi.fn(),
}),
}));

const mockUseAggregated = vi.fn();
// @ts-expect-error because mockReturnValue is not defined in the type definition
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
lapisClientHooks.mockReturnValue({
zodiosHooks: {
useAggregated: mockUseAggregated,
},
});

describe('LineageField', () => {
const field: MetadataFilter = { name: 'lineage', label: 'My Lineage', type: 'string' };
const setSomeFieldValues = vi.fn();
const lapisUrl = 'https://example.com/api';
const lapisSearchParameters = { lineage: 'A.1' };

beforeEach(() => {
setSomeFieldValues.mockClear();

mockUseAggregated.mockReturnValue({
data: {
data: [
{ lineage: 'A.1', count: 10 },
{ lineage: 'A.1.1', count: 20 },
],
},
isLoading: false,
error: null,
mutate: vi.fn(),
});
});

it('renders correctly with initial state', () => {
render(
<LineageField
field={field}
fieldValue='initialValue'
setSomeFieldValues={setSomeFieldValues}
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
/>,
);

expect(screen.getByText('My Lineage')).toBeInTheDocument();
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
});

it('updates query when sublineages checkbox is toggled', () => {
render(
<LineageField
field={field}
fieldValue='A.1'
setSomeFieldValues={setSomeFieldValues}
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
/>,
);

const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);

expect(checkbox).toBeChecked();
expect(setSomeFieldValues).toHaveBeenCalledWith(['lineage', 'A.1*']);
});

it('handles input changes and calls setSomeFieldValues', async () => {
render(
<LineageField
field={field}
fieldValue='A.1'
setSomeFieldValues={setSomeFieldValues}
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
/>,
);

await userEvent.click(screen.getByLabelText('My Lineage'));

const options = await screen.findAllByRole('option');
await userEvent.click(options[1]);

expect(setSomeFieldValues).toHaveBeenCalledWith(['lineage', 'A.1.1']);

const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();

expect(setSomeFieldValues).toHaveBeenCalledWith(['lineage', 'A.1.1*']);
});

it('clears wildcard when sublineages is unchecked', () => {
render(
<LineageField
field={field}
fieldValue='value*'
setSomeFieldValues={setSomeFieldValues}
lapisUrl={lapisUrl}
lapisSearchParameters={lapisSearchParameters}
/>,
);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();

fireEvent.click(checkbox);

expect(setSomeFieldValues).toHaveBeenCalledWith(['lineage', 'value']);
});
});
55 changes: 55 additions & 0 deletions website/src/components/SearchPage/fields/LineageField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, useState, type FC } from 'react';

import { AutoCompleteField } from './AutoCompleteField';
import type { MetadataFilter, SetSomeFieldValues } from '../../../types/config';

interface LineageFieldProps {
lapisUrl: string;
lapisSearchParameters: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any -- TODO(#3451) use a proper type
field: MetadataFilter;
fieldValue: string;
setSomeFieldValues: SetSomeFieldValues;
}

export const LineageField: FC<LineageFieldProps> = ({
field,
fieldValue,
setSomeFieldValues,
lapisUrl,
lapisSearchParameters,
}) => {
const [includeSublineages, setIncludeSubLineages] = useState(fieldValue.endsWith('*'));
const [inputText, setInputText] = useState(fieldValue.endsWith('*') ? fieldValue.slice(0, -1) : fieldValue);

useEffect(() => {
let queryText = includeSublineages ? `${inputText}*` : inputText;
if (queryText === '*') queryText = '';
if (queryText === fieldValue) return;
setSomeFieldValues([field.name, queryText]);
}, [includeSublineages, inputText, fieldValue]);

return (
<div key={field.name} className='flex flex-col border p-3 mb-3 rounded-md border-gray-300'>
<AutoCompleteField
field={field}
lapisUrl={lapisUrl}
setSomeFieldValues={([_, value]) => {
setInputText(value as string);
}}
fieldValue={inputText}
lapisSearchParameters={lapisSearchParameters}
/>
<div className='flex flex-row justify-end'>
<label>
<span className='text-gray-400 text-sm mr-2'>include sublineages</span>
<input
type='checkbox'
className='checkbox checkbox-sm text-3xl [--chkbg:white] [--chkfg:theme(colors.gray.700)] checked:border-gray-300'
checked={includeSublineages}
onChange={(event) => setIncludeSubLineages(event.target.checked)}
/>
</label>
</div>
</div>
);
};
1 change: 1 addition & 0 deletions website/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const metadata = z.object({
rangeSearch: z.boolean().optional(),
rangeOverlapSearch: rangeOverlapSearch.optional(),
substringSearch: z.boolean().optional(),
lineageSearch: z.boolean().optional(),
columnWidth: z.number().optional(),
order: z.number().optional(),
});
Expand Down

0 comments on commit 86eb930

Please sign in to comment.