From 40e45ac8fa50defd5aebda811aeb2251a126b4ee Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:40:44 +0100 Subject: [PATCH 1/5] feat(website): add Required Segments filter for multi-segmented viruses Add a new SegmentFilter component to the search form that lets users easily filter for sequence entries that include specific segments of multi-segmented viruses. Previously, users had to manually set the length filter to > 0 for each segment they wanted to require, which was unintuitive and created a cluttered UI. The new "Required segments" section appears at the top of the Sequence Filters panel (only for multi-segmented organisms) and shows a checkbox per segment. Checking a segment automatically sets length_{segment}From = 1, filtering to entries where that segment is present. Unchecking it clears the constraint. The filter is rendered only when the underlying length_{segment}From fields exist in the metadata schema, making it a no-op for organisms that haven't configured per-segment length fields. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/SearchPage/SearchForm.tsx | 10 +++ .../components/SearchPage/SegmentFilter.tsx | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 website/src/components/SearchPage/SegmentFilter.tsx diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index 5031b1c165..e04690b2ce 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -6,6 +6,7 @@ import { OffCanvasOverlay } from '../OffCanvasOverlay.tsx'; import { Button } from '../common/Button'; import type { LapisSearchParameters } from './DownloadDialog/SequenceFilters.tsx'; import { ReferenceSelector } from './ReferenceSelector.tsx'; +import { SegmentFilter } from './SegmentFilter.tsx'; import { AccessionField } from './fields/AccessionField.tsx'; import { DateField, TimestampField } from './fields/DateField.tsx'; import { DateRangeField } from './fields/DateRangeField.tsx'; @@ -340,6 +341,15 @@ export const SearchForm = ({
+ {referenceGenomesInfo.isMultiSegmented && ( + + )} + {!referenceGenomesInfo.isMultiSegmented && segmentNames.map((segmentName) => (
{renderSegmentContents(segmentName)}
diff --git a/website/src/components/SearchPage/SegmentFilter.tsx b/website/src/components/SearchPage/SegmentFilter.tsx new file mode 100644 index 0000000000..2cab87d5c1 --- /dev/null +++ b/website/src/components/SearchPage/SegmentFilter.tsx @@ -0,0 +1,70 @@ +import { type FC } from 'react'; + +import type { FieldValues, SetSomeFieldValues } from '../../types/config.ts'; +import type { ReferenceGenomesInfo } from '../../types/referencesGenomes.ts'; +import type { MetadataFilterSchema } from '../../utils/search.ts'; +import { getSegmentNames } from '../../utils/sequenceTypeHelpers.ts'; +import DisabledUntilHydrated from '../DisabledUntilHydrated.tsx'; + +interface SegmentFilterProps { + referenceGenomesInfo: ReferenceGenomesInfo; + fieldValues: FieldValues; + setSomeFieldValues: SetSomeFieldValues; + filterSchema: MetadataFilterSchema; +} + +/** + * Filter component for multi-segmented organisms that lets users require specific segments + * to be present by setting the corresponding length filter to > 0. + */ +export const SegmentFilter: FC = ({ + referenceGenomesInfo, + fieldValues, + setSomeFieldValues, + filterSchema, +}) => { + const segmentNames = getSegmentNames(referenceGenomesInfo); + + const ungroupedFilters = filterSchema.ungroupedMetadataFilters(); + + // Only show segments that have a length filter field in the schema + const segmentsWithLengthFields = segmentNames.filter((segmentName) => { + const lengthFromFieldName = `length_${segmentName}From`; + return ungroupedFilters.some((f) => f.name === lengthFromFieldName); + }); + + if (segmentsWithLengthFields.length === 0) { + return null; + } + + return ( +
+
Required segments
+
+ {segmentsWithLengthFields.map((segmentName) => { + const lengthFromFieldName = `length_${segmentName}From`; + const currentValue = fieldValues[lengthFromFieldName]; + const isChecked = + typeof currentValue === 'string' && currentValue !== '' && Number(currentValue) > 0; + const displayName = referenceGenomesInfo.segmentDisplayNames[segmentName] ?? segmentName; + + return ( + + ); + })} +
+
+ ); +}; From c7e412717c623906ae4e43b3e500943b6a4708b2 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:52:43 +0100 Subject: [PATCH 2/5] add unit tests --- .../SearchPage/SegmentFilter.spec.tsx | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 website/src/components/SearchPage/SegmentFilter.spec.tsx diff --git a/website/src/components/SearchPage/SegmentFilter.spec.tsx b/website/src/components/SearchPage/SegmentFilter.spec.tsx new file mode 100644 index 0000000000..3b1b7c39ac --- /dev/null +++ b/website/src/components/SearchPage/SegmentFilter.spec.tsx @@ -0,0 +1,192 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { SegmentFilter } from './SegmentFilter.tsx'; +import type { MetadataFilter } from '../../types/config.ts'; +import { + MULTI_SEG_MULTI_REF_REFERENCEGENOMES, + MULTI_SEG_SINGLE_REF_REFERENCEGENOMES, + SINGLE_SEG_SINGLE_REF_REFERENCEGENOMES, +} from '../../types/referenceGenomes.spec.ts'; +import { MetadataFilterSchema } from '../../utils/search.ts'; + +// Schema with length_From fields for both segments (S and L) +const lengthFields: MetadataFilter[] = [ + { name: 'length_SFrom', type: 'int' }, + { name: 'length_LFrom', type: 'int' }, +]; +const filterSchemaWithLengthFields = new MetadataFilterSchema(lengthFields); +const filterSchemaEmpty = new MetadataFilterSchema([]); + +describe('SegmentFilter', () => { + it('renders nothing for single-segmented organism', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when no length fields are in the schema', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a checkbox for each segment that has a length field', () => { + render( + , + ); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(2); + }); + + it('shows segment display names when configured', () => { + render( + , + ); + + expect(screen.getByText('S (segment)')).toBeInTheDocument(); + expect(screen.getByText('L (segment)')).toBeInTheDocument(); + }); + + it('falls back to segment name when no display name is configured', () => { + render( + , + ); + + expect(screen.getByText('S')).toBeInTheDocument(); + expect(screen.getByText('L')).toBeInTheDocument(); + }); + + it('renders checkbox as checked when length field value is greater than 0', () => { + render( + , + ); + + // S comes before L alphabetically; find by label text via surrounding label + const labels = screen.getAllByText(/^[SL]$/); + const sLabel = labels.find((el) => el.textContent === 'S')!; + const sCheckbox = sLabel.closest('label')!.querySelector('input[type="checkbox"]')!; + expect(sCheckbox.checked).toBe(true); + }); + + it('renders checkbox as unchecked when length field value is empty', () => { + render( + , + ); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => expect(cb).not.toBeChecked()); + }); + + it('renders checkbox as unchecked when length field is absent from fieldValues', () => { + render( + , + ); + + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => expect(cb).not.toBeChecked()); + }); + + it('calls setSomeFieldValues with "1" when an unchecked box is clicked', async () => { + const setSomeFieldValues = vi.fn(); + render( + , + ); + + const sLabel = screen.getByText('S').closest('label')!; + const sCheckbox = sLabel.querySelector('input[type="checkbox"]')!; + await userEvent.click(sCheckbox); + + expect(setSomeFieldValues).toHaveBeenCalledWith(['length_SFrom', '1']); + }); + + it('calls setSomeFieldValues with null when a checked box is clicked', async () => { + const setSomeFieldValues = vi.fn(); + render( + , + ); + + const sLabel = screen.getByText('S').closest('label')!; + const sCheckbox = sLabel.querySelector('input[type="checkbox"]')!; + await userEvent.click(sCheckbox); + + expect(setSomeFieldValues).toHaveBeenCalledWith(['length_SFrom', null]); + }); + + it('only shows segments that have a matching length field in the schema', () => { + // Only provide a length field for S, not L + const partialSchema = new MetadataFilterSchema([{ name: 'length_SFrom', type: 'int' }]); + + render( + , + ); + + expect(screen.getByText('S')).toBeInTheDocument(); + expect(screen.queryByText('L')).not.toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(1); + }); +}); From bb602dde78dbce1c51ab0bc2193540a462e45d75 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:58:09 +0100 Subject: [PATCH 3/5] add useMemo --- website/src/components/SearchPage/SegmentFilter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/components/SearchPage/SegmentFilter.tsx b/website/src/components/SearchPage/SegmentFilter.tsx index 2cab87d5c1..669b7d6029 100644 --- a/website/src/components/SearchPage/SegmentFilter.tsx +++ b/website/src/components/SearchPage/SegmentFilter.tsx @@ -1,4 +1,4 @@ -import { type FC } from 'react'; +import { useMemo, type FC } from 'react'; import type { FieldValues, SetSomeFieldValues } from '../../types/config.ts'; import type { ReferenceGenomesInfo } from '../../types/referencesGenomes.ts'; @@ -25,7 +25,7 @@ export const SegmentFilter: FC = ({ }) => { const segmentNames = getSegmentNames(referenceGenomesInfo); - const ungroupedFilters = filterSchema.ungroupedMetadataFilters(); + const ungroupedFilters = useMemo(() => filterSchema.ungroupedMetadataFilters(), [filterSchema]); // Only show segments that have a length filter field in the schema const segmentsWithLengthFields = segmentNames.filter((segmentName) => { From c83cc1ab886bc08c4ecef53d471952435d4ff7d7 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:00:06 +0100 Subject: [PATCH 4/5] clean up! --- website/src/components/SearchPage/SegmentFilter.spec.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/website/src/components/SearchPage/SegmentFilter.spec.tsx b/website/src/components/SearchPage/SegmentFilter.spec.tsx index 3b1b7c39ac..b3cf986b7e 100644 --- a/website/src/components/SearchPage/SegmentFilter.spec.tsx +++ b/website/src/components/SearchPage/SegmentFilter.spec.tsx @@ -99,11 +99,8 @@ describe('SegmentFilter', () => { />, ); - // S comes before L alphabetically; find by label text via surrounding label - const labels = screen.getAllByText(/^[SL]$/); - const sLabel = labels.find((el) => el.textContent === 'S')!; - const sCheckbox = sLabel.closest('label')!.querySelector('input[type="checkbox"]')!; - expect(sCheckbox.checked).toBe(true); + const sCheckbox = screen.getByRole('checkbox', { name: 'S' }); + expect(sCheckbox).toBeChecked(); }); it('renders checkbox as unchecked when length field value is empty', () => { From 1c7287a3f53ed55e92bd1b6db6cdae8b5fe1ae17 Mon Sep 17 00:00:00 2001 From: anna-parker <50943381+anna-parker@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:29:15 +0100 Subject: [PATCH 5/5] improve layout --- website/src/components/SearchPage/SegmentFilter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/SearchPage/SegmentFilter.tsx b/website/src/components/SearchPage/SegmentFilter.tsx index 669b7d6029..befa64a581 100644 --- a/website/src/components/SearchPage/SegmentFilter.tsx +++ b/website/src/components/SearchPage/SegmentFilter.tsx @@ -40,7 +40,7 @@ export const SegmentFilter: FC = ({ return (
Required segments
-
+
{segmentsWithLengthFields.map((segmentName) => { const lengthFromFieldName = `length_${segmentName}From`; const currentValue = fieldValues[lengthFromFieldName];