Skip to content
Merged
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
10 changes: 10 additions & 0 deletions website/src/components/SearchPage/SearchForm.tsx
Comment thread
anna-parker marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -340,6 +341,15 @@ export const SearchForm = ({

<section className='flex flex-col gap-1.5 mb-4'>
<CollapsibleSection title='Sequence Filters' open>
{referenceGenomesInfo.isMultiSegmented && (
<SegmentFilter
referenceGenomesInfo={referenceGenomesInfo}
fieldValues={fieldValues}
setSomeFieldValues={setSomeFieldValues}
filterSchema={filterSchema}
/>
)}

{!referenceGenomesInfo.isMultiSegmented &&
segmentNames.map((segmentName) => (
<div key={segmentName}>{renderSegmentContents(segmentName)}</div>
Expand Down
189 changes: 189 additions & 0 deletions website/src/components/SearchPage/SegmentFilter.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SegmentFilter
referenceGenomesInfo={SINGLE_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

expect(container).toBeEmptyDOMElement();
});

it('renders nothing when no length fields are in the schema', () => {
const { container } = render(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaEmpty}
/>,
);

expect(container).toBeEmptyDOMElement();
});

it('renders a checkbox for each segment that has a length field', () => {
render(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(2);
});

it('shows segment display names when configured', () => {
render(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_MULTI_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

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(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

expect(screen.getByText('S')).toBeInTheDocument();
expect(screen.getByText('L')).toBeInTheDocument();
});

it('renders checkbox as checked when length field value is greater than 0', () => {
render(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
// eslint-disable-next-line @typescript-eslint/naming-convention
fieldValues={{ length_SFrom: '1' }}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

const sCheckbox = screen.getByRole('checkbox', { name: 'S' });
expect(sCheckbox).toBeChecked();
});

it('renders checkbox as unchecked when length field value is empty', () => {
render(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
// eslint-disable-next-line @typescript-eslint/naming-convention
fieldValues={{ length_SFrom: '' }}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

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(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={filterSchemaWithLengthFields}
/>,
);

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(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={setSomeFieldValues}
filterSchema={filterSchemaWithLengthFields}
/>,
);

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(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
// eslint-disable-next-line @typescript-eslint/naming-convention
fieldValues={{ length_SFrom: '1' }}
setSomeFieldValues={setSomeFieldValues}
filterSchema={filterSchemaWithLengthFields}
/>,
);

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(
<SegmentFilter
referenceGenomesInfo={MULTI_SEG_SINGLE_REF_REFERENCEGENOMES}
fieldValues={{}}
setSomeFieldValues={vi.fn()}
filterSchema={partialSchema}
/>,
);

expect(screen.getByText('S')).toBeInTheDocument();
expect(screen.queryByText('L')).not.toBeInTheDocument();
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
});
});
70 changes: 70 additions & 0 deletions website/src/components/SearchPage/SegmentFilter.tsx
Original file line number Diff line number Diff line change
@@ -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<SegmentFilterProps> = ({
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 (
<div className='mb-3 px-1'>
<div className='text-sm text-gray-500 mb-1.5'>Required segments</div>
<div className='grid grid-cols-2 gap-x-4 gap-y-1.5'>
{segmentsWithLengthFields.map((segmentName) => {
const lengthFromFieldName = `length_${segmentName}From`;
const currentValue = fieldValues[lengthFromFieldName];
const isChecked =
typeof currentValue === 'string' && currentValue !== '' && Number(currentValue) > 0;
Comment thread
anna-parker marked this conversation as resolved.
const displayName = referenceGenomesInfo.segmentDisplayNames[segmentName] ?? segmentName;

return (
<label key={segmentName} className='flex items-center gap-1.5 cursor-pointer text-sm'>
<DisabledUntilHydrated>
<input
type='checkbox'
className='h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-600 cursor-pointer'
checked={isChecked}
onChange={() => {
setSomeFieldValues([lengthFromFieldName, isChecked ? null : '1']);
}}
/>
</DisabledUntilHydrated>
{displayName}
</label>
);
})}
</div>
</div>
);
};
Loading