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' },
};