From 86eb930d5ddf7f0a3d4b2f6317dc2701cefc054b Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 14 Jan 2025 14:19:37 +0100 Subject: [PATCH] feat(website): Dedicated lineage search field (#3467) * 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 --- .../loculus/templates/_common-metadata.tpl | 3 + kubernetes/loculus/values.yaml | 2 +- preprocessing/dummy/lineage.yaml | 12 +- .../src/components/SearchPage/SearchForm.tsx | 13 +- .../SearchPage/fields/AutoCompleteField.tsx | 38 +++--- .../SearchPage/fields/LineageField.spec.tsx | 124 ++++++++++++++++++ .../SearchPage/fields/LineageField.tsx | 55 ++++++++ website/src/types/config.ts | 1 + 8 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 website/src/components/SearchPage/fields/LineageField.spec.tsx create mode 100644 website/src/components/SearchPage/fields/LineageField.tsx diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index f553f61ea3..5eca1e1ffa 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -215,6 +215,9 @@ organisms: {{- if .rangeOverlapSearch }} rangeOverlapSearch: {{ .rangeOverlapSearch | toJson }} {{- end}} + {{- if .lineageSystem }} + lineageSearch: true + {{- end }} {{- if .hideOnSequenceDetailsPage }} hideOnSequenceDetailsPage: {{ .hideOnSequenceDetailsPage }} {{- end }} diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index b36cb49cc8..de59f2f7a7 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -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 diff --git a/preprocessing/dummy/lineage.yaml b/preprocessing/dummy/lineage.yaml index a2008539c5..0c533e1a6c 100644 --- a/preprocessing/dummy/lineage.yaml +++ b/preprocessing/dummy/lineage.yaml @@ -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 diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index e64a43ca7a..71eabdfc1d 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -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'; @@ -180,8 +181,18 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe setSomeFieldValues={setSomeFieldValues} /> ); - default: + if (field.lineageSearch) { + return ( + + ); + } if (field.autocomplete === true) { return ( - (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( () => @@ -153,9 +157,11 @@ export const AutoCompleteField = ({ {option.option} - - ({formatNumberWithDefaultLocale(option.count)}) - + {option.count !== undefined && ( + + ({formatNumberWithDefaultLocale(option.count)}) + + )} {selected && ( ({ + 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( + , + ); + + expect(screen.getByText('My Lineage')).toBeInTheDocument(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + }); + + it('updates query when sublineages checkbox is toggled', () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeChecked(); + + fireEvent.click(checkbox); + + expect(setSomeFieldValues).toHaveBeenCalledWith(['lineage', 'value']); + }); +}); diff --git a/website/src/components/SearchPage/fields/LineageField.tsx b/website/src/components/SearchPage/fields/LineageField.tsx new file mode 100644 index 0000000000..aa48441c1e --- /dev/null +++ b/website/src/components/SearchPage/fields/LineageField.tsx @@ -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; // eslint-disable-line @typescript-eslint/no-explicit-any -- TODO(#3451) use a proper type + field: MetadataFilter; + fieldValue: string; + setSomeFieldValues: SetSomeFieldValues; +} + +export const LineageField: FC = ({ + 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 ( +
+ { + setInputText(value as string); + }} + fieldValue={inputText} + lapisSearchParameters={lapisSearchParameters} + /> +
+ +
+
+ ); +}; diff --git a/website/src/types/config.ts b/website/src/types/config.ts index 477866f004..dfaf3aaf1f 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -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(), });