From 2ffe0fae3d52b8043209a47066fe9767de724ea0 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:34:27 +0100 Subject: [PATCH 1/7] feat(website, config): add an option for search presets --- .../loculus/templates/_common-metadata.tpl | 6 + kubernetes/loculus/values.schema.json | 12 ++ kubernetes/loculus/values.yaml | 9 ++ .../src/components/SearchPage/SearchForm.tsx | 2 + .../MultiChoiceAutoCompleteField.spec.tsx | 128 +++++++++++++++++- .../fields/MultiChoiceAutoCompleteField.tsx | 57 +++++++- .../fields/SingleChoiceAutoCompleteField.tsx | 34 ++++- website/src/types/config.ts | 2 + website/src/utils/search.ts | 1 + 9 files changed, 242 insertions(+), 9 deletions(-) diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index 1dfa05fc93..de49c14e78 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -395,6 +395,9 @@ fields: header: {{ printf "%s %s" (default "Other" .header) $segmentDisplayName | quote }} {{- end }} relatesToSegment: {{ $segment }} + {{- if .fieldPresets }} + fieldPresets: {{ .fieldPresets | toJson }} + {{- end }} {{- if .isSequenceFilter }} isSequenceFilter: true {{- end }} @@ -415,6 +418,9 @@ fields: displayName: {{ quote .displayName }} {{- end }} header: {{ default "Other" .header }} + {{- if .fieldPresets }} + fieldPresets: {{ .fieldPresets | toJson }} + {{- end }} {{- end}} {{- end}} {{- end}} diff --git a/kubernetes/loculus/values.schema.json b/kubernetes/loculus/values.schema.json index b085b06ac7..e37df13073 100644 --- a/kubernetes/loculus/values.schema.json +++ b/kubernetes/loculus/values.schema.json @@ -139,6 +139,18 @@ "type": "boolean", "description": "If true, this field will appear under 'Sequence Filters' on the search page instead of 'Metadata Filters'. Defaults to false." }, + "fieldPresets": { + "groups": ["metadata"], + "type": "object", + "description": "Map from a selected value to additional fields and their values that selecting that value should automatically apply.", + "additionalProperties": { + "type": "object", + "description": "Field/value pairs applied when this preset is selected.", + "additionalProperties": { + "type": "string" + } + } + }, "relatesToSegment": { "groups": ["metadata"], "type": "string", diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index 71b3647768..b9fb75c7b7 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -552,6 +552,15 @@ defaultOrganismConfig: &defaultOrganismConfig order: 30 orderOnDetailsPage: 460 ingest: division + fieldPresets: + Punjab: + geoLocCountry: Pakistan + Karachi: + geoLocCountry: Pakistan + Washington: + geoLocCountry: USA + California: + geoLocCountry: USA - name: geoLocAdmin2 displayName: Collection subdivision level 2 desired: true diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index f04fec22d3..29b3a91775 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -452,6 +452,7 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe lapisSearchParameters, fieldName: field.name, }} + fieldPresets={field.fieldPresets} /> ); } @@ -469,6 +470,7 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe lapisSearchParameters, fieldName: field.name, }} + fieldPresets={field.fieldPresets} /> ); } diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx index aac095e210..eedaaf54d1 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MultiChoiceAutoCompleteField } from './MultiChoiceAutoCompleteField'; +import { MultiChoiceAutoCompleteField, type FieldPresetMap } from './MultiChoiceAutoCompleteField'; import { lapisClientHooks } from '../../../services/serviceHooks.ts'; import type { MetadataFilter } from '../../../types/config.ts'; @@ -150,4 +150,130 @@ describe('MultiChoiceAutoCompleteField', () => { expect(screen.getByText('Option 3')).toBeInTheDocument(); expect(screen.queryAllByText('Option 2')).toHaveLength(0); }); + + describe('fieldPresets', () => { + const presets: FieldPresetMap = { + /* eslint-disable @typescript-eslint/naming-convention */ + 'Option 1': { host: 'human', lineage: 'B.1' }, + 'Option 2': { host: 'bat', lineage: 'BtCoV' }, + 'Option 3': { host: 'human', lineage: 'H1N1' }, + /* eslint-disable @typescript-eslint/naming-convention */ + }; + + it('sets preset fields when an option with a preset is selected', async () => { + renderField({ fieldPresets: presets }); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); // Option 1 + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', ['Option 1']], + ['host', 'human'], + ['lineage', 'B.1'], + ); + }); + + it('sets presets field when all selected options agree on the value', async () => { + // Option 1 and Option 3 both have host: 'human' + renderField({ + fieldValues: ['Option 1'], + fieldPresets: presets, + }); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + const options = await screen.findAllByRole('option'); + await userEvent.click(options[2]); // Option 3 + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', ['Option 1', 'Option 3']], + ['host', 'human'], + ); + }); + + it('skips a preset field when not all selected options have a preset for that field', async () => { + // Option 1 has host: 'bat', but Option 2 has no host preset at all + const partialPresets: FieldPresetMap = { + 'Option 1': { host: 'bat' }, + 'Option 2': { lineage: 'B.1' }, + }; + renderField({ + fieldValues: ['Option 1'], + fieldPresets: partialPresets, + }); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + const options = await screen.findAllByRole('option'); + await userEvent.click(options[1]); // Option 2 — has no host preset + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', ['Option 1', 'Option 2']], + ); + expect(setSomeFieldValues).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([['host', expect.anything()]]), + ); + }); + + it('skips a preset field when selected options have conflicting values', async () => { + // Option 1: host 'human', Option 2: host 'bat' → conflict, field should be skipped + renderField({ + fieldValues: ['Option 1'], + fieldPresets: presets, + }); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + const options = await screen.findAllByRole('option'); + await userEvent.click(options[1]); // Option 2 + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', ['Option 1', 'Option 2']], + ); + expect(setSomeFieldValues).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([['host', expect.anything()]]), + ); + }); + + it('clears preset fields when the selection is cleared', async () => { + const { rerender } = renderField({ fieldPresets: presets }); + + // Select Option 1 to populate lastPresetKeysRef inside the component + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); // Option 1 → sets host and lineage in ref + + setSomeFieldValues.mockClear(); + + // Rerender with Option 1 selected so the clear button is visible + rerender( + , + ); + + const clearButton = screen.getByLabelText('Clear Test Field'); + await userEvent.click(clearButton); + + expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', ''], ['host', ''], ['lineage', '']); + }); + }); }); diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx index 561582c52b..9797f1cd02 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx @@ -3,7 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createOptionsProviderHook, type OptionsProvider } from './AutoCompleteOptions.ts'; import { FloatingLabelContainer } from './FloatingLabelContainer.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; -import { type GroupedMetadataFilter, type MetadataFilter, type SetSomeFieldValues } from '../../../types/config.ts'; +import { + type FieldValueUpdate, + type GroupedMetadataFilter, + type MetadataFilter, + type SetSomeFieldValues, +} from '../../../types/config.ts'; import { formatNumberWithDefaultLocale } from '../../../utils/formatNumber.tsx'; import { NULL_QUERY_VALUE } from '../../../utils/search.ts'; import { Button } from '../../common/Button'; @@ -18,6 +23,8 @@ import MaterialSymbolsClose from '~icons/material-symbols/close'; import MdiChevronUpDown from '~icons/mdi/chevron-up-down'; import MdiTick from '~icons/mdi/tick'; +export type FieldPresetMap = Partial>>; + const logger = getClientLogger('MultiChoiceAutoCompleteField'); type MultiChoiceAutoCompleteFieldProps = { @@ -26,6 +33,7 @@ type MultiChoiceAutoCompleteFieldProps = { setSomeFieldValues: SetSomeFieldValues; fieldValues: (string | null)[]; maxDisplayedOptions?: number; + fieldPresets?: FieldPresetMap; }; export const MultiChoiceAutoCompleteField = ({ @@ -34,10 +42,12 @@ export const MultiChoiceAutoCompleteField = ({ setSomeFieldValues, fieldValues, maxDisplayedOptions = 1000, + fieldPresets, }: MultiChoiceAutoCompleteFieldProps) => { const inputRef = useRef(null); const [query, setQuery] = useState(''); const [isFocused, setIsFocused] = useState(false); + const lastPresetKeysRef = useRef([]); // Maximum number of badges to show before switching to summary const MAX_VISIBLE_BADGES = 2; @@ -63,17 +73,56 @@ export const MultiChoiceAutoCompleteField = ({ }, [options, query, maxDisplayedOptions]); const handleChange = (value: string[] | null) => { + const updates: FieldValueUpdate[] = []; + + for (const key of lastPresetKeysRef.current) { + updates.push([key, '']); + } if (!value || value.length === 0) { - setSomeFieldValues([field.name, '']); + updates.unshift([field.name, '']); + lastPresetKeysRef.current = []; } else { const convertedValues = value.map((v) => (v === NULL_QUERY_VALUE ? null : v)); - setSomeFieldValues([field.name, convertedValues]); + updates.unshift([field.name, convertedValues]); + + if (fieldPresets) { + const presetAccumulator: Record = {}; + const presetContributorCount: Record = {}; + for (const v of value) { + const preset = fieldPresets[v]; + if (!preset) continue; + for (const [k, pv] of Object.entries(preset)) { + (presetAccumulator[k] ??= []).push(pv); + presetContributorCount[k] = (presetContributorCount[k] ?? 0) + 1; + } + } + + const appliedKeys: string[] = []; + for (const [k, vals] of Object.entries(presetAccumulator)) { + const uniqueVals = [...new Set(vals)]; + + if (uniqueVals.length === 1 && presetContributorCount[k] === value.length) { + updates.push([k, uniqueVals[0]]); + appliedKeys.push(k); + } + } + lastPresetKeysRef.current = appliedKeys; + } else { + lastPresetKeysRef.current = []; + } } + + setSomeFieldValues(...updates); }; const handleClear = () => { + const updates: FieldValueUpdate[] = [[field.name, '']]; + for (const key of lastPresetKeysRef.current) { + updates.push([key, '']); + } + lastPresetKeysRef.current = []; setQuery(''); - handleChange([]); + setSomeFieldValues(...updates); }; // Convert selectedValues Set to array for Combobox value diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx index aca4993051..2a3a7f1b4f 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx @@ -1,9 +1,9 @@ -import { type InputHTMLAttributes, useEffect, useMemo, useState, forwardRef } from 'react'; +import { type InputHTMLAttributes, useEffect, useMemo, useState, forwardRef, useRef } from 'react'; import { createOptionsProviderHook, type OptionsProvider } from './AutoCompleteOptions.ts'; import { TextField } from './TextField.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; -import { type GroupedMetadataFilter, type MetadataFilter, type SetSomeFieldValues } from '../../../types/config.ts'; +import { type FieldValueUpdate, type GroupedMetadataFilter, type MetadataFilter, type SetSomeFieldValues } from '../../../types/config.ts'; import { formatNumberWithDefaultLocale } from '../../../utils/formatNumber.tsx'; import { NULL_QUERY_VALUE } from '../../../utils/search.ts'; import { Button } from '../../common/Button'; @@ -33,6 +33,8 @@ const CustomInput = forwardRef>>; + type SingleChoiceAutoCompleteFieldProps = { field: MetadataFilter | GroupedMetadataFilter; optionsProvider: OptionsProvider; @@ -40,6 +42,7 @@ type SingleChoiceAutoCompleteFieldProps = { fieldValue?: string | number | null; fieldDisplayNameMap?: Map; maxDisplayedOptions?: number; + fieldPresets?: FieldPresetMap; }; export const SingleChoiceAutoCompleteField = ({ @@ -49,6 +52,7 @@ export const SingleChoiceAutoCompleteField = ({ fieldValue, fieldDisplayNameMap, maxDisplayedOptions = 1000, + fieldPresets, }: SingleChoiceAutoCompleteFieldProps) => { const [query, setQuery] = useState(''); @@ -80,14 +84,36 @@ export const SingleChoiceAutoCompleteField = ({ return displayedOptions.slice(0, maxDisplayedOptions); }, [options, query, maxDisplayedOptions, fieldDisplayNameMap]); + const lastPresetKeysRef = useRef([]); + const handleChange = (value: string | null) => { const finalValue = value === NULL_QUERY_VALUE ? null : (value ?? ''); - setSomeFieldValues([field.name, finalValue]); + const updates: FieldValueUpdate[] = [[field.name, finalValue]]; + + for (const key of lastPresetKeysRef.current) { + updates.push([key, '']); + } + + const preset = fieldPresets?.[value ?? '']; + if (preset) { + const entries = Object.entries(preset) as [key: string, value: string][]; + updates.push(...entries.map(([k, v]) => [k, v] as FieldValueUpdate)); + lastPresetKeysRef.current = entries.map(([k]) => k); + } else { + lastPresetKeysRef.current = []; + } + + setSomeFieldValues(...updates); }; const handleClear = () => { + const updates: FieldValueUpdate[] = [[field.name, '']]; + for (const key of lastPresetKeysRef.current) { + updates.push([key, '']); + } + lastPresetKeysRef.current = []; setQuery(''); - setSomeFieldValues([field.name, '']); + setSomeFieldValues(...updates); }; return ( diff --git a/website/src/types/config.ts b/website/src/types/config.ts index 41e192cccf..281f8af0aa 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -87,6 +87,7 @@ export const metadata = z.object({ onlyForReference: z.string().optional(), isSequenceFilter: z.boolean().optional(), relatesToSegment: z.string().optional(), + fieldPresets: z.record(z.record(z.string())).optional(), percentage: z.boolean().optional(), }); @@ -135,6 +136,7 @@ export type GroupedMetadataFilter = { header?: string; isSequenceFilter?: Metadata['isSequenceFilter']; relatesToSegment?: Metadata['relatesToSegment']; + fieldPresets?: Metadata['fieldPresets']; order?: number; orderInSearchDisplay?: number; }; diff --git a/website/src/utils/search.ts b/website/src/utils/search.ts index 800cf65d27..cf53ab5433 100644 --- a/website/src/utils/search.ts +++ b/website/src/utils/search.ts @@ -210,6 +210,7 @@ const consolidateGroupedFields = (filters: MetadataFilter[]): (MetadataFilter | header: filter.header, isSequenceFilter: filter.isSequenceFilter, relatesToSegment: filter.relatesToSegment, + fieldPresets: filter.fieldPresets, }; fieldList.push(fieldForGroup); groupsMap.set(filter.fieldGroup, fieldForGroup); From 8de1b43548d2602ed11957d88827551d8de6712e Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:39:53 +0100 Subject: [PATCH 2/7] format --- .../fields/MultiChoiceAutoCompleteField.spec.tsx | 13 +++---------- .../fields/SingleChoiceAutoCompleteField.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx index eedaaf54d1..c0e0086c72 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx @@ -189,10 +189,7 @@ describe('MultiChoiceAutoCompleteField', () => { const options = await screen.findAllByRole('option'); await userEvent.click(options[2]); // Option 3 - expect(setSomeFieldValues).toHaveBeenCalledWith( - ['testField', ['Option 1', 'Option 3']], - ['host', 'human'], - ); + expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', ['Option 1', 'Option 3']], ['host', 'human']); }); it('skips a preset field when not all selected options have a preset for that field', async () => { @@ -212,9 +209,7 @@ describe('MultiChoiceAutoCompleteField', () => { const options = await screen.findAllByRole('option'); await userEvent.click(options[1]); // Option 2 — has no host preset - expect(setSomeFieldValues).toHaveBeenCalledWith( - ['testField', ['Option 1', 'Option 2']], - ); + expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', ['Option 1', 'Option 2']]); expect(setSomeFieldValues).not.toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([['host', expect.anything()]]), @@ -234,9 +229,7 @@ describe('MultiChoiceAutoCompleteField', () => { const options = await screen.findAllByRole('option'); await userEvent.click(options[1]); // Option 2 - expect(setSomeFieldValues).toHaveBeenCalledWith( - ['testField', ['Option 1', 'Option 2']], - ); + expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', ['Option 1', 'Option 2']]); expect(setSomeFieldValues).not.toHaveBeenCalledWith( expect.anything(), expect.arrayContaining([['host', expect.anything()]]), diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx index 2a3a7f1b4f..170d497de6 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx @@ -3,7 +3,12 @@ import { type InputHTMLAttributes, useEffect, useMemo, useState, forwardRef, use import { createOptionsProviderHook, type OptionsProvider } from './AutoCompleteOptions.ts'; import { TextField } from './TextField.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; -import { type FieldValueUpdate, type GroupedMetadataFilter, type MetadataFilter, type SetSomeFieldValues } from '../../../types/config.ts'; +import { + type FieldValueUpdate, + type GroupedMetadataFilter, + type MetadataFilter, + type SetSomeFieldValues, +} from '../../../types/config.ts'; import { formatNumberWithDefaultLocale } from '../../../utils/formatNumber.tsx'; import { NULL_QUERY_VALUE } from '../../../utils/search.ts'; import { Button } from '../../common/Button'; From c241f8818720da28c08dd2c2cdba509d0a6466f5 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:46:56 +0100 Subject: [PATCH 3/7] add some small docs --- .../SearchPage/fields/MultiChoiceAutoCompleteField.tsx | 2 ++ .../SearchPage/fields/SingleChoiceAutoCompleteField.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx index 9797f1cd02..bbae1e3b21 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx @@ -75,6 +75,7 @@ export const MultiChoiceAutoCompleteField = ({ const handleChange = (value: string[] | null) => { const updates: FieldValueUpdate[] = []; + // Clear values from the last applied preset for (const key of lastPresetKeysRef.current) { updates.push([key, '']); } @@ -101,6 +102,7 @@ export const MultiChoiceAutoCompleteField = ({ for (const [k, vals] of Object.entries(presetAccumulator)) { const uniqueVals = [...new Set(vals)]; + // Only apply the preset value if all selected options contribute the same value for this key if (uniqueVals.length === 1 && presetContributorCount[k] === value.length) { updates.push([k, uniqueVals[0]]); appliedKeys.push(k); diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx index 170d497de6..b28cd61585 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx @@ -95,6 +95,7 @@ export const SingleChoiceAutoCompleteField = ({ const finalValue = value === NULL_QUERY_VALUE ? null : (value ?? ''); const updates: FieldValueUpdate[] = [[field.name, finalValue]]; + // Clear values from the last applied preset for (const key of lastPresetKeysRef.current) { updates.push([key, '']); } From cbebed2872c4566436ace0a8528774facad6f003 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:01:40 +0100 Subject: [PATCH 4/7] use one import in config --- .../SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx | 4 ++-- .../SearchPage/fields/MultiChoiceAutoCompleteField.tsx | 3 +-- .../SearchPage/fields/SingleChoiceAutoCompleteField.tsx | 3 +-- website/src/types/config.ts | 2 ++ 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx index c0e0086c72..68be30689a 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.spec.tsx @@ -2,9 +2,9 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MultiChoiceAutoCompleteField, type FieldPresetMap } from './MultiChoiceAutoCompleteField'; +import { MultiChoiceAutoCompleteField } from './MultiChoiceAutoCompleteField'; import { lapisClientHooks } from '../../../services/serviceHooks.ts'; -import type { MetadataFilter } from '../../../types/config.ts'; +import type { FieldPresetMap, MetadataFilter } from '../../../types/config.ts'; vi.mock('../../../services/serviceHooks.ts'); vi.mock('../../../clientLogger.ts', () => ({ diff --git a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx index bbae1e3b21..5ba1e45966 100644 --- a/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/MultiChoiceAutoCompleteField.tsx @@ -4,6 +4,7 @@ import { createOptionsProviderHook, type OptionsProvider } from './AutoCompleteO import { FloatingLabelContainer } from './FloatingLabelContainer.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; import { + type FieldPresetMap, type FieldValueUpdate, type GroupedMetadataFilter, type MetadataFilter, @@ -23,8 +24,6 @@ import MaterialSymbolsClose from '~icons/material-symbols/close'; import MdiChevronUpDown from '~icons/mdi/chevron-up-down'; import MdiTick from '~icons/mdi/tick'; -export type FieldPresetMap = Partial>>; - const logger = getClientLogger('MultiChoiceAutoCompleteField'); type MultiChoiceAutoCompleteFieldProps = { diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx index b28cd61585..cb6e80133b 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.tsx @@ -4,6 +4,7 @@ import { createOptionsProviderHook, type OptionsProvider } from './AutoCompleteO import { TextField } from './TextField.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; import { + type FieldPresetMap, type FieldValueUpdate, type GroupedMetadataFilter, type MetadataFilter, @@ -38,8 +39,6 @@ const CustomInput = forwardRef>>; - type SingleChoiceAutoCompleteFieldProps = { field: MetadataFilter | GroupedMetadataFilter; optionsProvider: OptionsProvider; diff --git a/website/src/types/config.ts b/website/src/types/config.ts index 281f8af0aa..0921e9c147 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -91,6 +91,8 @@ export const metadata = z.object({ percentage: z.boolean().optional(), }); +export type FieldPresetMap = Partial>>; + export const inputFieldOption = z.object({ name: z.string(), }); From b3ac09cadb2ec4c8170691dfc992fe7faeeb5e88 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:07:30 +0100 Subject: [PATCH 5/7] add more tests --- .../SingleChoiceAutoCompleteField.spec.tsx | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx index 51eb4c2768..3bfb11cf8c 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx @@ -259,6 +259,188 @@ describe('SingleChoiceAutoCompleteField', () => { expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', null]); }); + describe('fieldPresets', () => { + const fieldPresets = { + 'Option 1': { otherField: 'presetValue1', anotherField: 'presetValue2' }, + 'Option 2': { otherField: 'presetValue3' }, + }; + + it('applies preset field values when an option with a preset is selected', async () => { + mockUseAggregated.mockReturnValue({ + data: { + data: [ + { testField: 'Option 1', count: 10 }, + { testField: 'Option 2', count: 20 }, + ], + }, + isPending: false, + error: null, + mutate: vi.fn(), + }); + render( + , + ); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', 'Option 1'], + ['otherField', 'presetValue1'], + ['anotherField', 'presetValue2'], + ); + }); + + it('clears previous preset fields when switching to a different option', async () => { + mockUseAggregated.mockReturnValue({ + data: { + data: [ + { testField: 'Option 1', count: 10 }, + { testField: 'Option 2', count: 20 }, + ], + }, + isPending: false, + error: null, + mutate: vi.fn(), + }); + const { rerender } = render( + , + ); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + + // Select Option 1 (has two preset fields) + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); + setSomeFieldValues.mockClear(); + + // Re-render with the new field value and select Option 2 + rerender( + , + ); + + await userEvent.click(input); + const options2 = await screen.findAllByRole('option'); + await userEvent.click(options2[1]); + + // Should clear both fields from the previous preset, then apply Option 2's preset + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', 'Option 2'], + ['otherField', ''], + ['anotherField', ''], + ['otherField', 'presetValue3'], + ); + }); + + it('clears preset field values when the clear button is clicked', async () => { + mockUseAggregated.mockReturnValue({ + data: { + data: [{ testField: 'Option 1', count: 10 }], + }, + isPending: false, + error: null, + mutate: vi.fn(), + }); + render( + , + ); + + // Select Option 1 to apply preset + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); + setSomeFieldValues.mockClear(); + + // Now clear + const clearButton = screen.getByLabelText('Clear Test Field'); + await userEvent.click(clearButton); + + expect(setSomeFieldValues).toHaveBeenCalledWith( + ['testField', ''], + ['otherField', ''], + ['anotherField', ''], + ); + }); + + it('does not apply preset fields when selected option has no preset', async () => { + mockUseAggregated.mockReturnValue({ + data: { + data: [{ testField: 'Option 3', count: 5 }], + }, + isPending: false, + error: null, + mutate: vi.fn(), + }); + render( + , + ); + + const input = screen.getByLabelText('Test Field'); + await userEvent.click(input); + const options = await screen.findAllByRole('option'); + await userEvent.click(options[0]); + + expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', 'Option 3']); + }); + }); + it('limits the number of displayed options', async () => { const data = []; for (let i = 0; i < 100; i++) { From 13242feeb7fe5742db47fcb0f993393afbc65ad2 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:08:58 +0100 Subject: [PATCH 6/7] fix tests --- .../SingleChoiceAutoCompleteField.spec.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx index 3bfb11cf8c..40223ad76f 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx @@ -316,7 +316,7 @@ describe('SingleChoiceAutoCompleteField', () => { error: null, mutate: vi.fn(), }); - const { rerender } = render( + render( { await userEvent.click(options[0]); setSomeFieldValues.mockClear(); - // Re-render with the new field value and select Option 2 - rerender( - , - ); - + // Blur then re-open the dropdown to select Option 2 + await userEvent.click(document.body); await userEvent.click(input); const options2 = await screen.findAllByRole('option'); await userEvent.click(options2[1]); From 3b3113016b29d7189f718c5bfcae1d9f84fafbd2 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:17:33 +0100 Subject: [PATCH 7/7] ignore naming-convention --- .../SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx index 40223ad76f..4b4b0b1e21 100644 --- a/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx +++ b/website/src/components/SearchPage/fields/SingleChoiceAutoCompleteField.spec.tsx @@ -261,7 +261,9 @@ describe('SingleChoiceAutoCompleteField', () => { describe('fieldPresets', () => { const fieldPresets = { + // eslint-disable-next-line @typescript-eslint/naming-convention 'Option 1': { otherField: 'presetValue1', anotherField: 'presetValue2' }, + // eslint-disable-next-line @typescript-eslint/naming-convention 'Option 2': { otherField: 'presetValue3' }, };