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.spec.tsx b/website/src/components/SearchPage/SegmentFilter.spec.tsx new file mode 100644 index 0000000000..b3cf986b7e --- /dev/null +++ b/website/src/components/SearchPage/SegmentFilter.spec.tsx @@ -0,0 +1,189 @@ +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( + , + ); + + const sCheckbox = screen.getByRole('checkbox', { name: 'S' }); + expect(sCheckbox).toBeChecked(); + }); + + 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); + }); +}); diff --git a/website/src/components/SearchPage/SegmentFilter.tsx b/website/src/components/SearchPage/SegmentFilter.tsx new file mode 100644 index 0000000000..befa64a581 --- /dev/null +++ b/website/src/components/SearchPage/SegmentFilter.tsx @@ -0,0 +1,70 @@ +import { useMemo, 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 = useMemo(() => filterSchema.ungroupedMetadataFilters(), [filterSchema]); + + // 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 ( + + ); + })} +
+
+ ); +};