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
6 changes: 6 additions & 0 deletions kubernetes/loculus/templates/_common-metadata.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -415,6 +418,9 @@ fields:
displayName: {{ quote .displayName }}
{{- end }}
header: {{ default "Other" .header }}
{{- if .fieldPresets }}
fieldPresets: {{ .fieldPresets | toJson }}
{{- end }}
{{- end}}
{{- end}}
{{- end}}
Expand Down
12 changes: 12 additions & 0 deletions kubernetes/loculus/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions kubernetes/loculus/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions website/src/components/SearchPage/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe
lapisSearchParameters,
fieldName: field.name,
}}
fieldPresets={field.fieldPresets}
/>
);
}
Expand All @@ -469,6 +470,7 @@ const SearchField = ({ field, lapisUrl, fieldValues, setSomeFieldValues, lapisSe
lapisSearchParameters,
fieldName: field.name,
}}
fieldPresets={field.fieldPresets}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

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', () => ({
Expand Down Expand Up @@ -150,4 +150,123 @@ 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(
<MultiChoiceAutoCompleteField
field={field}
optionsProvider={{
type: 'generic',
lapisUrl,
lapisSearchParameters,
fieldName: field.name,
}}
setSomeFieldValues={setSomeFieldValues}
fieldValues={['Option 1']}
fieldPresets={presets}
/>,
);

const clearButton = screen.getByLabelText('Clear Test Field');
await userEvent.click(clearButton);

expect(setSomeFieldValues).toHaveBeenCalledWith(['testField', ''], ['host', ''], ['lineage', '']);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ 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 FieldPresetMap,
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';
Expand All @@ -26,6 +32,7 @@ type MultiChoiceAutoCompleteFieldProps = {
setSomeFieldValues: SetSomeFieldValues;
fieldValues: (string | null)[];
maxDisplayedOptions?: number;
fieldPresets?: FieldPresetMap;
};

export const MultiChoiceAutoCompleteField = ({
Expand All @@ -34,10 +41,12 @@ export const MultiChoiceAutoCompleteField = ({
setSomeFieldValues,
fieldValues,
maxDisplayedOptions = 1000,
fieldPresets,
}: MultiChoiceAutoCompleteFieldProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [isFocused, setIsFocused] = useState(false);
const lastPresetKeysRef = useRef<string[]>([]);
Copy link
Contributor

Choose a reason for hiding this comment

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

The lastPresetKeysRef tracking is in-memory only. If a user refreshes the page, the URL still contains the preset-applied field values, but lastPresetKeysRef.current will be empty. Changing or clearing the triggering field after a refresh won't clean up those preset values.

This is a known limitation of using a ref for this kind of ephemeral state, but worth noting as a known edge case since the behaviour can seem surprising (stale filter values persist until manually cleared after a page reload).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if there is a better way to handle this, @theosanderson do you have any better ideas?


// Maximum number of badges to show before switching to summary
const MAX_VISIBLE_BADGES = 2;
Expand All @@ -63,17 +72,58 @@ export const MultiChoiceAutoCompleteField = ({
}, [options, query, maxDisplayedOptions]);

const handleChange = (value: string[] | null) => {
const updates: FieldValueUpdate[] = [];

// Clear values from the last applied preset
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<string, string[]> = {};
const presetContributorCount: Record<string, number> = {};
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)];

// 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);
}
}
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
Expand Down
Loading
Loading