diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index b15cfe8f0..9892cbb90 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -135,6 +135,9 @@ fields: {{- if .initiallyVisible }} initiallyVisible: {{ .initiallyVisible }} {{- end }} + {{- if or (or (eq .type "timestamp") (eq .type "date")) ( .rangeSearch) }} + rangeSearch: true + {{- end }} {{- if .hideOnSequenceDetailsPage }} hideOnSequenceDetailsPage: {{ .hideOnSequenceDetailsPage }} {{- end }} diff --git a/website/src/components/SearchPage/CustomizeModal.tsx b/website/src/components/SearchPage/CustomizeModal.tsx index d9338d5f6..cf9b3138f 100644 --- a/website/src/components/SearchPage/CustomizeModal.tsx +++ b/website/src/components/SearchPage/CustomizeModal.tsx @@ -22,31 +22,26 @@ const CheckboxField: React.FC = ({ label, checked, onChange, ); -interface FieldValue { - name: string; - label: string; - isVisible?: boolean; - notSearchable?: boolean; -} - interface CustomizeModalProps { isCustomizeModalOpen: boolean; toggleCustomizeModal: () => void; alwaysPresentFieldNames: string[]; - fieldValues: FieldValue[]; - handleFieldVisibilityChange: (fieldName: string, isVisible: boolean) => void; + visibilities: Map; + setAVisibility: (fieldName: string, isVisible: boolean) => void; + nameToLabelMap: Record; } export const CustomizeModal: React.FC = ({ isCustomizeModalOpen, toggleCustomizeModal, alwaysPresentFieldNames, - fieldValues, - handleFieldVisibilityChange, + visibilities, + setAVisibility, + nameToLabelMap, }) => { return ( - +
@@ -66,18 +61,16 @@ export const CustomizeModal: React.FC = ({ ))} - {fieldValues - .filter((field) => field.notSearchable !== true) - .map((field) => ( - { - handleFieldVisibilityChange(field.name, e.target.checked); - }} - /> - ))} + {Array.from(visibilities).map(([fieldName, visible]) => ( + { + setAVisibility(fieldName, e.target.checked); + }} + /> + ))}
diff --git a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx index c423c7f6a..cfc635f04 100644 --- a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx +++ b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx @@ -1,27 +1,22 @@ import type { FC } from 'react'; -import type { FilterValue, MutationFilter } from '../../../types/config.ts'; +import type { FieldValues } from '../../../types/config'; type ActiveDownloadFiltersProps = { - metadataFilter: FilterValue[]; - mutationFilter: MutationFilter; + lapisSearchParameters: Record; + hiddenFieldValues: FieldValues; }; -export const ActiveDownloadFilters: FC = ({ metadataFilter, mutationFilter }) => { - const filterValues: FilterValue[] = metadataFilter.filter((f) => f.filterValue.length > 0); - [ - { name: 'nucleotideMutations', value: mutationFilter.nucleotideMutationQueries }, - { name: 'aminoAcidMutations', value: mutationFilter.aminoAcidMutationQueries }, - { name: 'nucleotideInsertion', value: mutationFilter.nucleotideInsertionQueries }, - { name: 'aminoAcidInsertions', value: mutationFilter.aminoAcidInsertionQueries }, - ].forEach(({ name, value }) => { - if (value !== undefined && value.length > 0) { - filterValues.push({ name, filterValue: value.join(', ') }); - } - }); +export const ActiveDownloadFilters: FC = ({ lapisSearchParameters, hiddenFieldValues }) => { + let filterValues = Object.entries(lapisSearchParameters) + .filter((vals) => vals[1] !== undefined && vals[1] !== '') + .filter(([name, val]) => !(Object.keys(hiddenFieldValues).includes(name) && hiddenFieldValues[name] === val)) + .map(([name, filterValue]) => ({ name, filterValue })); + + filterValues = filterValues.filter(({ filterValue }) => filterValue.length > 0); if (filterValues.length === 0) { - return undefined; + return null; } return ( @@ -30,7 +25,7 @@ export const ActiveDownloadFilters: FC = ({ metadata
{filterValues.map(({ name, filterValue }) => (
- {name}: {filterValue} + {name}: {typeof filterValue === 'object' ? filterValue.join(', ') : filterValue}
))}
diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadButton.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadButton.tsx index c81899da5..072d0bbc0 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadButton.tsx +++ b/website/src/components/SearchPage/DownloadDialog/DownloadButton.tsx @@ -2,14 +2,11 @@ import { type FC, type MouseEventHandler, useMemo } from 'react'; import { type DownloadOption, generateDownloadUrl } from './generateDownloadUrl.ts'; import { approxMaxAcceptableUrlLength } from '../../../routes/routes.ts'; -import type { AccessionFilter, FilterValue, MutationFilter } from '../../../types/config.ts'; type DownloadButtonProps = { downloadOption: DownloadOption | undefined; lapisUrl: string; - accessionFilter: AccessionFilter; - metadataFilter: FilterValue[]; - mutationFilter: MutationFilter; + lapisSearchParameters: Record; disabled?: boolean; onClick?: () => void; }; @@ -17,9 +14,7 @@ type DownloadButtonProps = { export const DownloadButton: FC = ({ downloadOption, lapisUrl, - accessionFilter, - metadataFilter, - mutationFilter, + lapisSearchParameters, disabled = false, onClick, }) => { @@ -37,13 +32,7 @@ export const DownloadButton: FC = ({ }; } - const { url, baseUrl, params } = generateDownloadUrl( - accessionFilter, - metadataFilter, - mutationFilter, - downloadOption, - lapisUrl, - ); + const { url, baseUrl, params } = generateDownloadUrl(lapisSearchParameters, downloadOption, lapisUrl); const useGet = url.length <= approxMaxAcceptableUrlLength; if (useGet) { return { @@ -62,7 +51,7 @@ export const DownloadButton: FC = ({ } }, }; - }, [downloadOption, disabled, accessionFilter, metadataFilter, mutationFilter, lapisUrl, onClick]); + }, [downloadOption, disabled, lapisSearchParameters, lapisUrl, onClick]); return ( , ); @@ -49,12 +41,11 @@ describe('DownloadDialog', () => { }); test('should display active filters if there are some', async () => { - await renderDialog({}, [{ name: 'field1', filterValue: 'value1' }], { - nucleotideMutationQueries: ['A123T', 'G234C'], - }); + await renderDialog({ field1: 'value1', nucleotideMutations: 'A123T,G234C' }); expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); expect(screen.queryByText('field1: value1')).toBeInTheDocument(); - expect(screen.queryByText(/A123T, G234C/)).toBeInTheDocument(); + + expect(screen.queryByText(/A123T,G234C/)).toBeInTheDocument(); }); test('should not display active filters if there are none', async () => { @@ -77,7 +68,7 @@ describe('DownloadDialog', () => { }); test('should generate the right download link', async () => { - await renderDialog({ accession: ['accession1', 'accession2'] }, [{ name: 'field1', filterValue: 'value1' }]); + await renderDialog({ accession: ['accession1', 'accession2'], field1: 'value1' }); await checkAgreement(); expect(getDownloadHref()).toBe( diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx index 1136127f9..39a584dc3 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx +++ b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx @@ -5,23 +5,21 @@ import { DownloadButton } from './DownloadButton.tsx'; import { DownloadForm } from './DownloadForm.tsx'; import { type DownloadOption } from './generateDownloadUrl.ts'; import { routes } from '../../../routes/routes.ts'; -import type { AccessionFilter, FilterValue, MutationFilter } from '../../../types/config.ts'; +import type { FieldValues } from '../../../types/config.ts'; import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; type DownloadDialogProps = { - accessionFilter: AccessionFilter; - metadataFilter: FilterValue[]; - mutationFilter: MutationFilter; + lapisSearchParameters: Record; referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; lapisUrl: string; + hiddenFieldValues: FieldValues; }; export const DownloadDialog: FC = ({ - accessionFilter, - metadataFilter, - mutationFilter, + lapisSearchParameters, referenceGenomesSequenceNames, lapisUrl, + hiddenFieldValues, }) => { const dialogRef = useRef(null); const [downloadOption, setDownloadOption] = useState(); @@ -53,7 +51,10 @@ export const DownloadDialog: FC = ({

Download

- + = ({ disabled={!agreedToDataUseTerms} lapisUrl={lapisUrl} downloadOption={downloadOption} - accessionFilter={accessionFilter} - metadataFilter={metadataFilter} - mutationFilter={mutationFilter} + lapisSearchParameters={lapisSearchParameters} onClick={closeDialog} />
diff --git a/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts b/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts index 5304bf971..ae44c5cdd 100644 --- a/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts +++ b/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts @@ -1,5 +1,4 @@ import { IS_REVOCATION_FIELD, metadataDefaultDownloadDataFormat, VERSION_STATUS_FIELD } from '../../../settings.ts'; -import type { AccessionFilter, FilterValue, MutationFilter } from '../../../types/config.ts'; import { siloVersionStatuses } from '../../../types/lapis.ts'; export type DownloadDataType = @@ -18,14 +17,13 @@ export type DownloadOption = { }; export const generateDownloadUrl = ( - accessionFilter: AccessionFilter, - metadataFilter: FilterValue[], - mutationFilter: MutationFilter, + lapisSearchParameters: Record, option: DownloadOption, lapisUrl: string, ) => { const baseUrl = `${lapisUrl}${getEndpoint(option.dataType)}`; const params = new URLSearchParams(); + params.set('downloadAsFile', 'true'); if (!option.includeOldData) { params.set(VERSION_STATUS_FIELD, siloVersionStatuses.latestVersion); @@ -40,31 +38,31 @@ export const generateDownloadUrl = ( if (option.compression !== undefined) { params.set('compression', option.compression); } - if (accessionFilter.accession !== undefined) { - for (const accession of accessionFilter.accession) { + if (lapisSearchParameters.accession !== undefined) { + for (const accession of lapisSearchParameters.accession) { params.append('accession', accession); } } - for (const { name, filterValue } of metadataFilter) { - if (filterValue.trim().length > 0) { - params.set(name, filterValue); + + const mutationKeys = ['nucleotideMutations', 'aminoAcidMutations', 'nucleotideInsertions', 'aminoAcidInsertions']; + + for (const [key, value] of Object.entries(lapisSearchParameters)) { + // Skip accession and mutations + if (key === 'accession' || mutationKeys.includes(key)) { + continue; + } + const trimmedValue = value.trim(); + if (trimmedValue.length > 0) { + params.set(key, trimmedValue); } } - if (mutationFilter.nucleotideMutationQueries !== undefined && mutationFilter.nucleotideMutationQueries.length > 0) { - params.set('nucleotideMutations', mutationFilter.nucleotideMutationQueries.join(',')); - } - if (mutationFilter.aminoAcidMutationQueries !== undefined && mutationFilter.aminoAcidMutationQueries.length > 0) { - params.set('aminoAcidMutations', mutationFilter.aminoAcidMutationQueries.join(',')); - } - if ( - mutationFilter.nucleotideInsertionQueries !== undefined && - mutationFilter.nucleotideInsertionQueries.length > 0 - ) { - params.set('nucleotideInsertions', mutationFilter.nucleotideInsertionQueries.join(',')); - } - if (mutationFilter.aminoAcidInsertionQueries !== undefined && mutationFilter.aminoAcidInsertionQueries.length > 0) { - params.set('aminoAcidInsertions', mutationFilter.aminoAcidInsertionQueries.join(',')); - } + + mutationKeys.forEach((key) => { + if (lapisSearchParameters[key] !== undefined) { + params.set(key, lapisSearchParameters[key].join(',')); + } + }); + return { url: `${baseUrl}?${params}`, baseUrl, diff --git a/website/src/components/SearchPage/SearchForm.spec.tsx b/website/src/components/SearchPage/SearchForm.spec.tsx deleted file mode 100644 index 5fbfd2130..000000000 --- a/website/src/components/SearchPage/SearchForm.spec.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render, screen, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import { SearchForm, searchButtonText } from './SearchForm'; -import { testConfig, testOrganism } from '../../../vitest.setup.ts'; -import { routes, SEARCH } from '../../routes/routes.ts'; -import type { MetadataFilter } from '../../types/config.ts'; -import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; -import type { ClientConfig } from '../../types/runtimeConfig.ts'; - -global.ResizeObserver = class FakeResizeObserver { - // This is needed or we get a test failure: https://github.com/tailwindlabs/headlessui/discussions/2414 - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function - observe() {} - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function - disconnect() {} - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, @typescript-eslint/no-empty-function - unobserve() {} -}; - -vi.mock('../../config', () => ({ - fetchAutoCompletion: vi.fn().mockResolvedValue([]), - getLapisUrl: vi.fn().mockReturnValue('lapis.dummy.url'), -})); - -const queryClient = new QueryClient(); - -const defaultSearchFormFilters = [ - { - name: 'field1', - type: 'string' as const, - label: 'Field 1', - autocomplete: false, - filterValue: '', - initiallyVisible: true, - }, - { - name: 'field2', - type: 'date' as const, - autocomplete: false, - filterValue: '', - label: 'Field 2', - initiallyVisible: true, - }, - { - name: 'field3', - type: 'pango_lineage' as const, - label: 'Field 3', - autocomplete: true, - filterValue: '', - initiallyVisible: true, - }, -]; - -const defaultReferenceGenomesSequenceNames = { - nucleotideSequences: ['main'], - genes: ['gene1', 'gene2'], -}; - -function renderSearchForm( - searchFormFilters: MetadataFilter[] = [...defaultSearchFormFilters], - clientConfig: ClientConfig = testConfig.public, - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames = defaultReferenceGenomesSequenceNames, -) { - render( - - - , - ); -} - -describe('SearchForm', () => { - beforeEach(() => { - Object.defineProperty(window, 'location', { - value: { - href: '', - }, - }); - }); - - test('should render the form with all fields that are searchable', async () => { - renderSearchForm(); - - expect(screen.getByLabelText('Field 1')).toBeDefined(); - expect(screen.getByText('Field 2')).toBeDefined(); - expect(screen.getByLabelText('Field 3')).toBeDefined(); - }); - - test('should redirect according to filters', async () => { - renderSearchForm(); - - const filterValue = 'test'; - await userEvent.type(screen.getByLabelText('Field 1'), filterValue); - - const searchButton = screen.getByRole('button', { name: searchButtonText }); - await userEvent.click(searchButton); - - expect(window.location.href).toBe( - routes.searchPage(testOrganism, [{ ...defaultSearchFormFilters[0], filterValue }]), - ); - }); - - test('should not render the form with fields with flag notSearchable', async () => { - renderSearchForm([ - ...defaultSearchFormFilters, - { - name: 'NotSearchable', - type: 'string' as const, - autocomplete: false, - filterValue: '', - notSearchable: true, - initiallyVisible: true, - }, - ]); - - expect(screen.getByLabelText('Field 1')).toBeDefined(); - expect(screen.queryByPlaceholderText('NotSearchable')).not.toBeInTheDocument(); - }); - - test('should display dates of timestamp fields', async () => { - const timestampFieldName = 'timestampField'; - renderSearchForm([ - { - name: timestampFieldName, - type: 'timestamp' as const, - filterValue: '1706147200', - initiallyVisible: true, - }, - ]); - - const timestampLabel = screen.getByText('Timestamp field'); - const timestampField = timestampLabel.nextElementSibling?.getElementsByTagName('input')[0]; - if (!timestampField) { - throw new Error('Timestamp field not found'); - } - expect(timestampField).toHaveValue('2024-01-25'); - - await userEvent.type(timestampField, '2025'); - - await userEvent.click(screen.getByRole('button', { name: searchButtonText })); - - expect(window.location.href).toContain(`${timestampFieldName}=1737769600`); - }); - - test('should display dates of date fields', async () => { - const dateFieldName = 'dateField'; - renderSearchForm([ - { - name: dateFieldName, - type: 'date' as const, - filterValue: '2024-01-25', - initiallyVisible: true, - }, - ]); - const dateLabel = screen.getByText('Date field'); - const dateField = dateLabel.nextElementSibling?.getElementsByTagName('input')[0]; - if (!dateField) { - throw new Error('Date field not found'); - } - expect(dateField).toHaveValue('2024-01-25'); - - await userEvent.type(dateField, '2025'); - - await userEvent.click(screen.getByRole('button', { name: searchButtonText })); - - expect(window.location.href).toContain(`${dateFieldName}=2025-01-25`); - }); - - test('toggle field visibility', async () => { - renderSearchForm(); - - expect(await screen.findByLabelText('Field 1')).toBeVisible(); - - const customizeButton = await screen.findByRole('button', { name: 'Customize fields' }); - await userEvent.click(customizeButton); - - const field1Checkbox = await screen.findByRole('checkbox', { name: 'Field 1' }); - expect(field1Checkbox).toBeChecked(); - - await userEvent.click(field1Checkbox); - - const closeButton = await screen.findByRole('button', { name: 'Close' }); - await userEvent.click(closeButton); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }); - - expect(screen.queryByLabelText('Field 1')).not.toBeInTheDocument(); - }); -}); diff --git a/website/src/components/SearchPage/SearchForm.tsx b/website/src/components/SearchPage/SearchForm.tsx index 928189b38..9fa7da46d 100644 --- a/website/src/components/SearchPage/SearchForm.tsx +++ b/website/src/components/SearchPage/SearchForm.tsx @@ -1,191 +1,48 @@ -import CircularProgress from '@mui/material/CircularProgress'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { sentenceCase } from 'change-case'; -import { type FC, type FormEventHandler, useMemo, useState, useCallback } from 'react'; +import { useState } from 'react'; import { CustomizeModal } from './CustomizeModal.tsx'; import { AccessionField } from './fields/AccessionField.tsx'; -import { AutoCompleteField, type AutoCompleteFieldProps } from './fields/AutoCompleteField'; +import { AutoCompleteField } from './fields/AutoCompleteField'; import { DateField, TimestampField } from './fields/DateField.tsx'; import { MutationField } from './fields/MutationField.tsx'; import { NormalTextField } from './fields/NormalTextField'; -import { PangoLineageField } from './fields/PangoLineageField'; -import { getClientLogger } from '../../clientLogger.ts'; -import { getLapisUrl } from '../../config.ts'; -import useClientFlag from '../../hooks/isClient.ts'; -import { useOffCanvas } from '../../hooks/useOffCanvas'; -import { type ClassOfSearchPageType, navigateToSearchLikePage } from '../../routes/routes.ts'; -import type { AccessionFilter, GroupedMetadataFilter, MetadataFilter, MutationFilter } from '../../types/config.ts'; -import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; +import { useOffCanvas } from '../../hooks/useOffCanvas.ts'; +import type { GroupedMetadataFilter, MetadataFilter, FieldValues, SetAFieldValue } from '../../types/config.ts'; +import { type ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; -import { OffCanvasOverlay } from '../OffCanvasOverlay'; -import { SandwichIcon } from '../SandwichIcon'; - +import { OffCanvasOverlay } from '../OffCanvasOverlay.tsx'; const queryClient = new QueryClient(); interface SearchFormProps { organism: string; - filters: MetadataFilter[]; - initialAccessionFilter: AccessionFilter; - initialMutationFilter: MutationFilter; + consolidatedMetadataSchema: (GroupedMetadataFilter | MetadataFilter)[]; clientConfig: ClientConfig; + fieldValues: FieldValues; + setAFieldValue: SetAFieldValue; + lapisUrl: string; + visibilities: Map; + setAVisibility: (fieldName: string, value: boolean) => void; referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; - classOfSearchPage: ClassOfSearchPageType; - groupId?: number; + lapisSearchParameters: Record; } -const clientLogger = getClientLogger('SearchForm'); - -export const SearchForm: FC = ({ - organism, - filters, - initialAccessionFilter, - initialMutationFilter, - clientConfig, +export const SearchForm = ({ + consolidatedMetadataSchema, + fieldValues, + setAFieldValue, + lapisUrl, + visibilities, + setAVisibility, referenceGenomesSequenceNames, - classOfSearchPage, - groupId, -}) => { - const fieldList: (MetadataFilter | GroupedMetadataFilter)[] = consolidateGroupedFields(filters); - - const [fieldValues, setFieldValues] = useState<((MetadataFilter | GroupedMetadataFilter) & { label: string })[]>( - fieldList.map((filter) => ({ - ...filter, - label: filter.label ?? filter.displayName ?? sentenceCase(filter.name), - isVisible: filter.initiallyVisible ?? false, - })), - ); + lapisSearchParameters, +}: SearchFormProps) => { + const visibleFields = consolidatedMetadataSchema.filter((field) => visibilities.get(field.name)); - const alwaysPresentFieldNames = ['Accession', 'Mutation']; - const [accessionFilter, setAccessionFilter] = useState(initialAccessionFilter); - const [mutationFilter, setMutationFilter] = useState(initialMutationFilter); - const [isLoading, setIsLoading] = useState(false); - const { isOpen: isMobileOpen, close: closeOnMobile, toggle: toggleMobileOpen } = useOffCanvas(); const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false); - const isClient = useClientFlag(); - - const handleFieldChange = useCallback( - (metadataName: string, filter: string) => { - setFieldValues((prev) => { - const updatedFields = [...prev]; - const fieldToChange = deepFind(updatedFields, (field) => field.name === metadataName); - if (fieldToChange === undefined) { - throw new Error(`Tried to change a filter that does not exist: ${metadataName}`); - } - fieldToChange.filterValue = filter; - return updatedFields; - }); - }, - [setFieldValues], - ); - - const flattenFields = (fields: (MetadataFilter | GroupedMetadataFilter)[]): MetadataFilter[] => { - const flattenedFields: MetadataFilter[] = []; - for (const field of fields) { - if (field.grouped === true) { - flattenedFields.push(...flattenFields(field.groupedFields)); - } else { - flattenedFields.push(field); - } - } - return flattenedFields; - }; - - const handleSearch: FormEventHandler = async (event) => { - event.preventDefault(); - setIsLoading(true); - const flattenedFields = flattenFields(fieldValues); - const searchableFieldValues = flattenedFields.filter((field) => !(field.notSearchable ?? false)); - navigateToSearchLikePage( - organism, - classOfSearchPage, - groupId, - searchableFieldValues, - accessionFilter, - mutationFilter, - ); - }; - - const resetSearch = async () => { - setIsLoading(true); - await clientLogger.info('reset_search'); - location.href = location.pathname; - }; - - const lapisUrl = getLapisUrl(clientConfig, organism); - - const flattenedFieldValues = flattenFields(fieldValues); - - const fields = useMemo( - () => - fieldValues.map((field) => { - if (field.isVisible !== true) { - return null; - } - if (field.grouped === true) { - return ( -
-

{field.displayName}

- - {field.groupedFields.map((groupedField) => ( - - ))} -
- ); - } - - return ( - - ); - }), - [fieldValues, handleFieldChange, isLoading, lapisUrl, flattenedFieldValues], - ); - - const toggleCustomizeModal = () => { - setIsCustomizeModalOpen(!isCustomizeModalOpen); - }; - - const clearValues = (possiblyGroupedFieldName: string) => { - const fieldInQuestion = fieldValues.find((field) => field.name === possiblyGroupedFieldName); - if (fieldInQuestion === undefined) { - return; - } - - if (fieldInQuestion.grouped === true) { - for (const groupedField of fieldInQuestion.groupedFields) { - handleFieldChange(groupedField.name, ''); - } - } else { - handleFieldChange(possiblyGroupedFieldName, ''); - } - }; - const handleFieldVisibilityChange = (fieldName: string, isVisible: boolean) => { - if (isVisible === false) { - clearValues(fieldName); - } - setFieldValues((prev) => - prev.map((field) => { - if (field.name === fieldName) { - return { ...field, isVisible }; - } - return field; - }), - ); - }; + const { isOpen: isMobileOpen, close: closeOnMobile, toggle: toggleMobileOpen } = useOffCanvas(); + const toggleCustomizeModal = () => setIsCustomizeModalOpen(!isCustomizeModalOpen); return ( @@ -202,129 +59,132 @@ export const SearchForm: FC = ({ md:translate-y-0 md:static md:h-auto md:overflow-visible md:min-w-72`} >
+

Search query

-

Search query

- - - -
- +
+ + +
+
{' '}
- -
-
- - - {fields} -
-
-
{ + acc[field.name] = field.displayName ?? field.label ?? sentenceCase(field.name); + return acc; + }, + {} as Record, + )} + /> +
+ setAFieldValue('accession', value)} + /> + + setAFieldValue('mutation', value)} + /> + + {visibleFields.map((filter) => ( + -
- -
-
- + ))} +
{' '}
-
); }; -const SearchField: FC = (props) => { - const { field } = props; +interface SearchFieldProps { + field: GroupedMetadataFilter | MetadataFilter; + lapisUrl: string; + fieldValues: FieldValues; + setAFieldValue: SetAFieldValue; + lapisSearchParameters: Record; +} + +const SearchField = ({ field, lapisUrl, fieldValues, setAFieldValue, lapisSearchParameters }: SearchFieldProps) => { field.label = field.label ?? field.displayName ?? sentenceCase(field.name); - if (field.notSearchable === true) { - return null; + if (field.grouped === true) { + return ( +
+

+ {field.displayName !== undefined ? field.displayName : field.label} +

+ + {field.groupedFields.map((f) => ( + + ))} +
+ ); } switch (field.type) { case 'date': - return ; + return ( + + ); case 'timestamp': - return ; - case 'pango_lineage': - return ; + return ( + + ); + default: if (field.autocomplete === true) { - return ; - } - return ; - } -}; - -export const searchButtonText = 'Search sequences'; - -const SearchButton: FC<{ isLoading: boolean; isClient: boolean }> = ({ isLoading }) => ( - -); - -const deepFind = ( - listed: (MetadataFilter | GroupedMetadataFilter)[], - matchFunction: (field: MetadataFilter | GroupedMetadataFilter) => boolean, -): MetadataFilter | undefined => { - // does a normal find, but for grouped fields iterates over the grouped fields and returns fields within - for (const field of listed) { - if (field.grouped === true) { - const found = deepFind(field.groupedFields, matchFunction); - if (found !== undefined) { - return found; - } - } else if (matchFunction(field)) { - return field; - } - } - - return undefined; -}; - -const consolidateGroupedFields = (filters: MetadataFilter[]): (MetadataFilter | GroupedMetadataFilter)[] => { - const fieldList: (MetadataFilter | GroupedMetadataFilter)[] = []; - const groupsMap = new Map(); - - for (const filter of filters) { - if (filter.fieldGroup !== undefined) { - if (!groupsMap.has(filter.fieldGroup)) { - const fieldForGroup: GroupedMetadataFilter = { - name: filter.fieldGroup, - groupedFields: [], - type: filter.type, - grouped: true, - displayName: filter.fieldGroupDisplayName, - label: filter.label, - initiallyVisible: filter.initiallyVisible, - }; - fieldList.push(fieldForGroup); - groupsMap.set(filter.fieldGroup, fieldForGroup); + return ( + + ); } - groupsMap.get(filter.fieldGroup)!.groupedFields.push(filter); - } else { - fieldList.push(filter); - } + return ( + + ); } - - return fieldList; }; diff --git a/website/src/components/SearchPage/SearchFullUI.spec.tsx b/website/src/components/SearchPage/SearchFullUI.spec.tsx new file mode 100644 index 000000000..5d3e7b7de --- /dev/null +++ b/website/src/components/SearchPage/SearchFullUI.spec.tsx @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { SearchFullUI } from './SearchFullUI'; +import { testConfig, testOrganism } from '../../../vitest.setup.ts'; +import type { MetadataFilter, Schema } from '../../types/config.ts'; +import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; + +global.ResizeObserver = class FakeResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +}; + +vi.mock('../../config', () => ({ + fetchAutoCompletion: vi.fn().mockResolvedValue([]), + getLapisUrl: vi.fn().mockReturnValue('http://lapis.dummy.url'), +})); + +const defaultSearchFormFilters: MetadataFilter[] = [ + { + name: 'field1', + type: 'string' as const, + label: 'Field 1', + autocomplete: false, + initiallyVisible: true, + }, + { + name: 'field2', + type: 'date' as const, + autocomplete: false, + label: 'Field 2', + initiallyVisible: true, + }, + { + name: 'field3', + type: 'pango_lineage' as const, + label: 'Field 3', + autocomplete: true, + initiallyVisible: true, + }, +]; + +const defaultReferenceGenomesSequenceNames: ReferenceGenomesSequenceNames = { + nucleotideSequences: ['main'], + genes: ['gene1', 'gene2'], +}; + +function renderSearchFullUI({ + searchFormFilters = [...defaultSearchFormFilters], + clientConfig = testConfig.public, + referenceGenomesSequenceNames = defaultReferenceGenomesSequenceNames, +} = {}) { + const metadataSchema: MetadataFilter[] = searchFormFilters.map((filter) => ({ + ...filter, + grouped: false, + })); + + const props = { + accessToken: 'dummyAccessToken', + referenceGenomesSequenceNames, + myGroups: [], + organism: testOrganism, + clientConfig, + schema: { + metadata: metadataSchema, + tableColumns: ['field1', 'field2', 'field3'], + primaryKey: 'field1', + } as Schema, + }; + + render(); +} + +describe('SearchFullUI', () => { + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { + href: '', + }, + }); + }); + + test('should render the form with all fields that are searchable', async () => { + renderSearchFullUI(); + + expect(screen.getByLabelText('Field 1')).toBeDefined(); + expect(screen.getByText('Field 2')).toBeDefined(); + expect(screen.getByLabelText('Field 3')).toBeDefined(); + }); + /* + test('should redirect according to filters', async () => { + renderSearchFullUI(); + + const filterValue = 'test'; + const labelText = 'Field 1'; + // first click on Field 1, then type in it. use findByLabelText to wait for the field to appear + await userEvent.click(await screen.findByLabelText(labelText)); + // send typing events + // wait 1 sec + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + await userEvent.type(document.activeElement as HTMLElement, filterValue); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }); + + expect(window.history.state.path).toContain(`field1=${filterValue}`); + }); +*/ + test('should not render the form with fields with flag notSearchable', async () => { + renderSearchFullUI({ + searchFormFilters: [ + ...defaultSearchFormFilters, + { + name: 'NotSearchable', + type: 'string' as const, + autocomplete: false, + notSearchable: true, + initiallyVisible: true, + }, + ], + }); + + expect(screen.getByLabelText('Field 1')).toBeDefined(); + expect(screen.queryByPlaceholderText('NotSearchable')).not.toBeInTheDocument(); + }); + + test('should display timestamp field', async () => { + const timestampFieldName = 'timestampField'; + renderSearchFullUI({ + searchFormFilters: [ + { + name: timestampFieldName, + type: 'timestamp' as const, + initiallyVisible: true, + }, + ], + }); + + const timestampLabel = screen.getByText('Timestamp field'); + const timestampField = timestampLabel.nextElementSibling?.getElementsByTagName('input')[0]; + if (!timestampField) { + throw new Error('Timestamp field not found'); + } + }); + + test('should display date field', async () => { + const dateFieldName = 'dateField'; + renderSearchFullUI({ + searchFormFilters: [ + { + name: dateFieldName, + type: 'date' as const, + initiallyVisible: true, + rangeSearch: true, + displayName: 'Date field', + }, + ], + }); + + const dateLabel = screen.getByText('Date field'); + const dateField = dateLabel.nextElementSibling?.getElementsByTagName('input')[0]; + if (!dateField) { + throw new Error('Date field not found'); + } + }); + /* + test('toggle field visibility', async () => { + renderSearchFullUI({}); + + expect(await screen.findByLabelText('Field 1')).toBeVisible(); + + const customizeButton = await screen.findByRole('button', { name: 'Customize fields' }); + await userEvent.click(customizeButton); + + const field1Checkbox = await screen.findByRole('checkbox', { name: 'Field 1' }); + expect(field1Checkbox).toBeChecked(); + + await userEvent.click(field1Checkbox); + + const closeButton = await screen.findByRole('button', { name: 'Close' }); + await userEvent.click(closeButton); + + expect(screen.queryByLabelText('Field 1')).not.toBeInTheDocument(); + }); + */ +}); diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index 045a9c1f2..07996ed8d 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -1,75 +1,230 @@ -import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { sentenceCase } from 'change-case'; +import { useEffect, useMemo, useState } from 'react'; -import { DownloadDialog } from './DownloadDialog/DownloadDialog'; +import { DownloadDialog } from './DownloadDialog/DownloadDialog.tsx'; import { RecentSequencesBanner } from './RecentSequencesBanner.tsx'; import { SearchForm } from './SearchForm'; import { SearchPagination } from './SearchPagination'; import { SeqPreviewModal } from './SeqPreviewModal'; -import { Table } from './Table'; -import { SEARCH } from '../../routes/routes'; -import { type ClassOfSearchPageType } from '../../routes/routes.ts'; +import { Table, type TableSequenceData } from './Table'; +import { parseMutationString } from './fields/MutationField.tsx'; +import useQueryAsState from './useQueryAsState.js'; +import { getLapisUrl } from '../../config.ts'; +import { lapisClientHooks } from '../../services/serviceHooks.ts'; import { pageSize } from '../../settings'; import type { Group } from '../../types/backend.ts'; -import type { AccessionFilter, MetadataFilter, MutationFilter, Schema } from '../../types/config.ts'; -import type { OrderBy } from '../../types/lapis.ts'; +import { type MetadataFilter, type Schema, type GroupedMetadataFilter, type FieldValues } from '../../types/config.ts'; +import { type OrderBy } from '../../types/lapis.ts'; import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; -import type { SearchResponse } from '../../utils/search.ts'; +const orderKey = 'orderBy'; +const orderDirectionKey = 'order'; -interface SearchFullUIProps { +const VISIBILITY_PREFIX = 'visibility_'; + +interface InnerSearchFullUIProps { + accessToken?: string; + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + myGroups: Group[]; organism: string; - filters: MetadataFilter[]; - initialAccessionFilter: AccessionFilter; - initialMutationFilter: MutationFilter; clientConfig: ClientConfig; - referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; - classOfSearchPage: ClassOfSearchPageType; - groupId?: number; - orderBy: OrderBy; - lapisUrl: string; schema: Schema; - metadataFilter: MetadataFilter[]; - accessionFilter: AccessionFilter; - mutationFilter: MutationFilter; - metadataFilterWithoutHiddenFilters: MetadataFilter[]; - page: number; - data: SearchResponse | null; - error: null | { message: string }; - myGroups: Group[]; - accessToken: string | undefined; + hiddenFieldValues?: FieldValues; +} +interface QueryState { + [key: string]: string; } -export const SearchFullUI = ({ - organism, - data, - page, - metadataFilter, - metadataFilterWithoutHiddenFilters, - accessionFilter, - mutationFilter, - lapisUrl, +export const InnerSearchFullUI = ({ + accessToken, referenceGenomesSequenceNames, - schema, - clientConfig, - orderBy, - error, - classOfSearchPage, myGroups, - accessToken, -}: SearchFullUIProps) => { + organism, + clientConfig, + schema, + hiddenFieldValues, +}: InnerSearchFullUIProps) => { + if (!hiddenFieldValues) { + hiddenFieldValues = {}; + } + const metadataSchema = schema.metadata; + + const metadataSchemaWithExpandedRanges = useMemo(() => { + const result = []; + for (const field of metadataSchema) { + if (field.rangeSearch === true) { + const fromField = { + ...field, + name: `${field.name}From`, + label: `From`, + fieldGroup: field.name, + fieldGroupDisplayName: field.displayName ?? sentenceCase(field.name), + }; + const toField = { + ...field, + name: `${field.name}To`, + label: `To`, + fieldGroup: field.name, + fieldGroupDisplayName: field.displayName ?? sentenceCase(field.name), + }; + result.push(fromField); + result.push(toField); + } else { + result.push(field); + } + } + return result; + }, [metadataSchema]); + const [previewedSeqId, setPreviewedSeqId] = useState(null); const [previewHalfScreen, setPreviewHalfScreen] = useState(false); + const [state, setState] = useQueryAsState({}); + const [page, setPage] = useState(1); - if (error !== null) { - return ( -
-
Error
- {error.message} -
+ const orderByField = state.orderBy ?? schema.primaryKey; + const orderDirection = state.order ?? 'ascending'; + + const setOrderByField = (field: string) => { + setState((prev: QueryState) => ({ + ...prev, + orderBy: field, + })); + }; + const setOrderDirection = (direction: string) => { + setState((prev: QueryState) => ({ + ...prev, + order: direction, + })); + }; + + const visibilities = useMemo(() => { + const visibilities = new Map(); + schema.metadata.forEach((field) => { + visibilities.set(field.name, field.initiallyVisible === true); + }); + + const visibilityKeys = Object.keys(state).filter((key) => key.startsWith(VISIBILITY_PREFIX)); + + for (const key of visibilityKeys) { + visibilities.set(key.slice(VISIBILITY_PREFIX.length), state[key] === 'true'); + } + return visibilities; + }, [schema.metadata, state]); + + const fieldValues = useMemo(() => { + const fieldKeys = Object.keys(state) + .filter((key) => !key.startsWith(VISIBILITY_PREFIX)) + .filter((key) => key !== orderKey && key !== orderDirectionKey); + + const values: Record = { ...hiddenFieldValues }; + for (const key of fieldKeys) { + values[key] = state[key]; + } + return values; + }, [state, hiddenFieldValues]); + + const setAFieldValue = (fieldName: string, value: string | number) => { + setState((prev: any) => { + const newState = { + ...prev, + [fieldName]: value, + }; + if (value === '') { + delete newState[fieldName]; + } + return newState; + }); + setPage(1); + }; + + const setAVisibility = (fieldName: string, visible: boolean) => { + setState((prev: any) => ({ + ...prev, + [`${VISIBILITY_PREFIX}${fieldName}`]: visible ? 'true' : 'false', + })); + // if visible is false, we should also remove the field from the fieldValues + if (!visible) { + setAFieldValue(fieldName, ''); + } + }; + + const lapisUrl = getLapisUrl(clientConfig, organism); + + const consolidatedMetadataSchema = consolidateGroupedFields(metadataSchemaWithExpandedRanges); + + const hooks = lapisClientHooks(lapisUrl).zodiosHooks; + const aggregatedHook = hooks.useAggregated({}, {}); + const detailsHook = hooks.useDetails({}, {}); + + const lapisSearchParameters = useMemo(() => { + const sequenceFilters = Object.fromEntries( + Object.entries(fieldValues).filter(([, value]) => value !== undefined && value !== ''), ); - } - data = data as SearchResponse; + if (sequenceFilters.accession !== '' && sequenceFilters.accession !== undefined) { + sequenceFilters.accession = textAccessionsToList(sequenceFilters.accession); + } + + delete sequenceFilters.mutation; + + const mutationFilter = parseMutationString(fieldValues.mutation ?? '', referenceGenomesSequenceNames); + + return { + ...sequenceFilters, + nucleotideMutations: mutationFilter + .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'substitutionOrDeletion') + .map((m) => m.text), + aminoAcidMutations: mutationFilter + .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'substitutionOrDeletion') + .map((m) => m.text), + nucleotideInsertions: mutationFilter + .filter((m) => m.baseType === 'nucleotide' && m.mutationType === 'insertion') + .map((m) => m.text), + aminoAcidInsertions: mutationFilter + .filter((m) => m.baseType === 'aminoAcid' && m.mutationType === 'insertion') + .map((m) => m.text), + }; + }, [fieldValues, referenceGenomesSequenceNames]); + + useEffect(() => { + aggregatedHook.mutate({ + ...lapisSearchParameters, + fields: [], + }); + const OrderByList: OrderBy[] = [ + { + field: orderByField, + type: orderDirection, + }, + ]; + // @ts-expect-error because the hooks don't accept OrderBy + detailsHook.mutate({ + ...lapisSearchParameters, + fields: [...schema.tableColumns, schema.primaryKey], + limit: pageSize, + offset: (page - 1) * pageSize, + orderBy: OrderByList, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lapisSearchParameters, schema.tableColumns, schema.primaryKey, pageSize, page, orderByField, orderDirection]); + + const totalSequences = aggregatedHook.data?.data[0].count ?? undefined; + + const [oldData, setOldData] = useState(null); + const [oldCount, setOldCount] = useState(null); + + useEffect(() => { + if (detailsHook.data?.data && oldData !== detailsHook.data.data) { + setOldData(detailsHook.data.data); + } + }, [detailsHook.data?.data, oldData]); + + useEffect(() => { + if (aggregatedHook.data?.data && oldCount !== aggregatedHook.data.data[0].count) { + setOldCount(aggregatedHook.data.data[0].count); + } + }, [aggregatedHook.data?.data, oldCount]); return (
@@ -87,59 +242,153 @@ export const SearchFullUI = ({
-
-
- Search returned {data.totalCount.toLocaleString()} sequence{data.totalCount === 1 ? '' : 's'} -
-
- {classOfSearchPage === SEARCH && ( + + {(detailsHook.isError || aggregatedHook.isError) && + // @ts-expect-error because response is not expected on error, but does exist + (aggregatedHook.error?.response?.status === 503 ? ( +
+ {' '} + The database is currently empty. +
+ ) : ( +
+

There was an error loading the data.

+

{JSON.stringify(detailsHook.error)}

+ +

{detailsHook.error?.message}

+

{aggregatedHook.error?.message}

+
+ ))} + {(detailsHook.isPaused || aggregatedHook.isPaused) && + (!detailsHook.isSuccess || !aggregatedHook.isSuccess) && ( +
Connection problem
+ )} + {!(totalSequences === undefined && oldCount === null) && ( +
+
+
+ Search returned{' '} + {totalSequences !== undefined + ? totalSequences.toLocaleString() + : oldCount !== null + ? oldCount.toLocaleString() + : ''}{' '} + sequence + {totalSequences === 1 ? '' : 's'} + {detailsHook.isLoading || aggregatedHook.isLoading ? ( + + ) : null} +
+ - )} +
+ + + +
+ {totalSequences !== undefined && ( + + )} +
- -
-
- -
- {previewHalfScreen && previewedSeqId !== null &&
} + )} ); }; + +const consolidateGroupedFields = (filters: MetadataFilter[]): (MetadataFilter | GroupedMetadataFilter)[] => { + const fieldList: (MetadataFilter | GroupedMetadataFilter)[] = []; + const groupsMap = new Map(); + + for (const filter of filters) { + if (filter.fieldGroup !== undefined) { + if (!groupsMap.has(filter.fieldGroup)) { + const fieldForGroup: GroupedMetadataFilter = { + name: filter.fieldGroup, + groupedFields: [], + type: filter.type, + grouped: true, + displayName: filter.fieldGroupDisplayName, + label: filter.label, + initiallyVisible: filter.initiallyVisible, + }; + fieldList.push(fieldForGroup); + groupsMap.set(filter.fieldGroup, fieldForGroup); + } + groupsMap.get(filter.fieldGroup)!.groupedFields.push(filter); + } else { + fieldList.push(filter); + } + } + + return fieldList; +}; + +export const SearchFullUI = (props: InnerSearchFullUIProps) => { + const queryClient = new QueryClient(); + + return ( + + + + ); +}; + +const textAccessionsToList = (text: string): string[] => { + const accessions = text + .split(/[\t,;\n ]/) + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((s) => { + if (s.includes('.')) { + return s.split('.')[0]; + } + return s; + }); + + return accessions; +}; diff --git a/website/src/components/SearchPage/SearchPagination.tsx b/website/src/components/SearchPage/SearchPagination.tsx index 502fe1cf4..6020460ec 100644 --- a/website/src/components/SearchPage/SearchPagination.tsx +++ b/website/src/components/SearchPage/SearchPagination.tsx @@ -1,49 +1,23 @@ import MUIPagination from '@mui/material/Pagination'; import type { FC } from 'react'; -import { navigateToSearchLikePage, type ClassOfSearchPageType } from '../../routes/routes'; -import type { AccessionFilter, MetadataFilter, MutationFilter } from '../../types/config.ts'; -import type { OrderBy } from '../../types/lapis.ts'; - type SearchPaginationProps = { count: number; - metadataFilter: MetadataFilter[]; - accessionFilter: AccessionFilter; - mutationFilter: MutationFilter; - orderBy: OrderBy; - organism: string; page: number; - classOfSearchPage: ClassOfSearchPageType; - groupId?: number; + setPage: (page: number) => void; }; export const SearchPagination: FC = ({ count, - metadataFilter, - accessionFilter, - mutationFilter, - orderBy, - organism, + page, - classOfSearchPage, - groupId, + setPage, }) => { return ( { - navigateToSearchLikePage( - organism, - classOfSearchPage, - groupId, - metadataFilter, - accessionFilter, - mutationFilter, - newPage, - orderBy, - ); - }} + onChange={(_, newPage) => setPage(newPage)} color='primary' variant='outlined' shape='rounded' diff --git a/website/src/components/SearchPage/SeqPreviewModal.tsx b/website/src/components/SearchPage/SeqPreviewModal.tsx index d61f27606..e4b3cbef3 100644 --- a/website/src/components/SearchPage/SeqPreviewModal.tsx +++ b/website/src/components/SearchPage/SeqPreviewModal.tsx @@ -57,6 +57,12 @@ export const SeqPreviewModal: React.FC = ({
+ {data !== null && data.isRevocation === true && ( +
+ This sequence has been revoked. +
+ )} + {isLoading ? (
Loading...
) : data !== null && !isError ? ( diff --git a/website/src/components/SearchPage/Table.tsx b/website/src/components/SearchPage/Table.tsx index a4749fe9e..52cf73349 100644 --- a/website/src/components/SearchPage/Table.tsx +++ b/website/src/components/SearchPage/Table.tsx @@ -2,8 +2,8 @@ import { capitalCase } from 'change-case'; import type { FC, ReactElement } from 'react'; import { Tooltip } from 'react-tooltip'; -import { type ClassOfSearchPageType, navigateToSearchLikePage, routes } from '../../routes/routes.ts'; -import type { AccessionFilter, MetadataFilter, MutationFilter, Schema } from '../../types/config.ts'; +import { routes } from '../../routes/routes.ts'; +import type { Schema } from '../../types/config.ts'; import type { Metadatum, OrderBy } from '../../types/lapis.ts'; import MdiTriangle from '~icons/mdi/triangle'; import MdiTriangleDown from '~icons/mdi/triangle-down'; @@ -13,33 +13,23 @@ export type TableSequenceData = { }; type TableProps = { - organism: string; schema: Schema; data: TableSequenceData[]; - metadataFilter: MetadataFilter[]; - accessionFilter: AccessionFilter; - mutationFilter: MutationFilter; - page: number; - orderBy: OrderBy; - classOfSearchPage: ClassOfSearchPageType; - groupId?: number; setPreviewedSeqId: (seqId: string | null) => void; previewedSeqId: string | null; + orderBy: OrderBy; + setOrderByField: (field: string) => void; + setOrderDirection: (direction: 'ascending' | 'descending') => void; }; export const Table: FC = ({ - organism, data, schema, - metadataFilter, - accessionFilter, - mutationFilter, - page, - orderBy, - classOfSearchPage, - groupId, setPreviewedSeqId, previewedSeqId, + orderBy, + setOrderByField, + setOrderDirection, }) => { const primaryKey = schema.primaryKey; @@ -54,48 +44,13 @@ export const Table: FC = ({ const handleSort = (field: string) => { if (orderBy.field === field) { if (orderBy.type === 'ascending') { - navigateToSearchLikePage( - organism, - classOfSearchPage, - groupId, - metadataFilter, - accessionFilter, - mutationFilter, - page, - { - field, - type: 'descending', - }, - ); + setOrderDirection('descending'); } else { - navigateToSearchLikePage( - organism, - classOfSearchPage, - groupId, - metadataFilter, - accessionFilter, - mutationFilter, - page, - { - field, - type: 'ascending', - }, - ); + setOrderDirection('ascending'); } } else { - navigateToSearchLikePage( - organism, - classOfSearchPage, - groupId, - metadataFilter, - accessionFilter, - mutationFilter, - page, - { - field, - type: 'ascending', - }, - ); + setOrderByField(field); + setOrderDirection('ascending'); } }; diff --git a/website/src/components/SearchPage/fields/AccessionField.tsx b/website/src/components/SearchPage/fields/AccessionField.tsx index 1074bb381..37278bb01 100644 --- a/website/src/components/SearchPage/fields/AccessionField.tsx +++ b/website/src/components/SearchPage/fields/AccessionField.tsx @@ -1,16 +1,13 @@ -import { type FC, useState } from 'react'; +import { type FC } from 'react'; import { NormalTextField } from './NormalTextField.tsx'; -import type { AccessionFilter } from '../../../types/config.ts'; type AccessionFieldProps = { - initialValue: AccessionFilter; - onChange: (accessionFilter: AccessionFilter) => void; + textValue: string; + setTextValue: (value: string) => void; }; -export const AccessionField: FC = ({ initialValue, onChange }) => { - const [textValue, setTextValue] = useState((initialValue.accession ?? []).sort().join('\n')); - +export const AccessionField: FC = ({ textValue, setTextValue }) => { return ( = ({ initialValue, onChange autocomplete: false, name: 'accession', notSearchable: false, - filterValue: textValue, }} - handleFieldChange={(_, filter) => { - setTextValue(filter); - const accessions = filter - .split(/[\t,;\n ]/) - .map((s) => s.trim()) - .filter((s) => s !== '') - .map((s) => { - if (s.includes('.')) { - return s.split('.')[0]; - } - return s; - }); - const uniqueAccessions = [...new Set(accessions)]; - onChange({ accession: uniqueAccessions }); + setAFieldValue={(_, filter) => { + setTextValue(filter as string); }} - isLoading={false} + fieldValue={textValue} multiline /> ); diff --git a/website/src/components/SearchPage/fields/AutoCompleteField.tsx b/website/src/components/SearchPage/fields/AutoCompleteField.tsx index d7152d935..9d71fd81b 100644 --- a/website/src/components/SearchPage/fields/AutoCompleteField.tsx +++ b/website/src/components/SearchPage/fields/AutoCompleteField.tsx @@ -1,16 +1,23 @@ import { Combobox } from '@headlessui/react'; -import { type FC, useEffect, useMemo, useState, useRef, forwardRef } from 'react'; +import { useEffect, useMemo, useState, useRef, forwardRef } from 'react'; -import type { FieldProps } from './FieldProps'; import { TextField } from './TextField.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; import { lapisClientHooks } from '../../../services/serviceHooks.ts'; -import type { MetadataFilter } from '../../../types/config.ts'; +import { type GroupedMetadataFilter, type MetadataFilter, type SetAFieldValue } from '../../../types/config.ts'; + +type AutoCompleteFieldProps = { + field: MetadataFilter | GroupedMetadataFilter; + setAFieldValue: SetAFieldValue; + lapisUrl: string; + fieldValue?: string | number; + lapisSearchParameters: Record; +}; const CustomInput = forwardRef>((props, ref) => ( = ({ field, allFields, handleFieldChange, lapisUrl }) => { +export const AutoCompleteField = ({ + field, + setAFieldValue, + lapisUrl, + fieldValue, + lapisSearchParameters, +}: AutoCompleteFieldProps) => { const buttonRef = useRef(null); const [query, setQuery] = useState(''); const { @@ -44,8 +52,16 @@ export const AutoCompleteField: FC = ({ field, allFields }, [error]); const handleOpen = () => { - const otherFieldsFilter = getOtherFieldsFilter(allFields, field); - mutate({ fields: [field.name], ...otherFieldsFilter }); + const otherFields = { ...lapisSearchParameters }; + delete otherFields[field.name]; + + Object.keys(otherFields).forEach((key) => { + if (otherFields[key] === '') { + delete otherFields[key]; + } + }); + + mutate({ fields: [field.name], ...otherFields }); if (buttonRef.current) { buttonRef.current.click(); } @@ -69,7 +85,7 @@ export const AutoCompleteField: FC = ({ field, allFields ); return ( - handleFieldChange(field.name, value)}> + setAFieldValue(field.name, value)}>
{ setQuery(''); - handleFieldChange(field.name, ''); + setAFieldValue(field.name, ''); }} > @@ -159,9 +175,3 @@ export const AutoCompleteField: FC = ({ field, allFields ); }; - -function getOtherFieldsFilter(allFields: MetadataFilter[], field: MetadataFilter) { - return allFields - .filter((f) => f.name !== field.name && f.filterValue !== '') - .reduce((acc, f) => ({ ...acc, [f.name]: f.filterValue }), {}); -} diff --git a/website/src/components/SearchPage/fields/DateField.tsx b/website/src/components/SearchPage/fields/DateField.tsx index 497d10c64..71016c667 100644 --- a/website/src/components/SearchPage/fields/DateField.tsx +++ b/website/src/components/SearchPage/fields/DateField.tsx @@ -1,16 +1,20 @@ import { DateTime } from 'luxon'; -import type { FC } from 'react'; import { DatePicker } from 'rsuite'; -import type { FieldProps } from './FieldProps'; import 'rsuite/DatePicker/styles/index.css'; +import { type MetadataFilter, type SetAFieldValue } from '../../../types/config'; -type ValueConverter = { +type CustomizedDatePickerProps = { + field: MetadataFilter; + setAFieldValue: SetAFieldValue; dateToValueConverter: (date: Date | null) => string; valueToDateConverter: (value: string) => Date | undefined; + fieldValue: string | number; }; -export const DateField: FC = (props) => ( +export const DateField: React.FC> = ( + props, +) => ( { @@ -22,22 +26,25 @@ export const DateField: FC = (props) => ( /> ); -export const TimestampField: FC = (props) => ( +export const TimestampField: React.FC< + Omit +> = (props) => ( (date ? String(Math.floor(date.getTime() / 1000)) : '')} valueToDateConverter={(value) => { - const timestamp = parseInt(value, 10); + const timestamp = Math.max(parseInt(value, 10)); return isNaN(timestamp) ? undefined : new Date(timestamp * 1000); }} /> ); -const CustomizedDatePicker: FC = ({ +const CustomizedDatePicker: React.FC = ({ field, - handleFieldChange, + setAFieldValue, dateToValueConverter, valueToDateConverter, + fieldValue, }) => { return (
@@ -47,18 +54,17 @@ const CustomizedDatePicker: FC = ({ { - if (value && isNaN(value.getTime())) { - return; + value={fieldValue !== '' ? valueToDateConverter(fieldValue.toString()) : undefined} + key={field.name} + onChange={(date) => { + if (date) { + setAFieldValue(field.name, dateToValueConverter(date)); + } else { + setAFieldValue(field.name, ''); } - handleFieldChange(field.name, dateToValueConverter(value)); - }} - onChangeCalendarDate={(value) => { - handleFieldChange(field.name, dateToValueConverter(value)); }} onClean={() => { - handleFieldChange(field.name, ''); + setAFieldValue(field.name, ''); }} />
diff --git a/website/src/components/SearchPage/fields/FieldProps.tsx b/website/src/components/SearchPage/fields/FieldProps.tsx index b9edf4f54..d9d792755 100644 --- a/website/src/components/SearchPage/fields/FieldProps.tsx +++ b/website/src/components/SearchPage/fields/FieldProps.tsx @@ -1,12 +1,14 @@ import type { FocusEventHandler } from 'react'; -import type { MetadataFilter } from '../../../types/config.ts'; +import type { MetadataFilter, SetAFieldValue } from '../../../types/config.ts'; export type FieldProps = { field: MetadataFilter; - handleFieldChange: (metadataName: string, filter: string) => void; + setAFieldValue: SetAFieldValue; isLoading: boolean; multiline?: boolean; onFocus?: FocusEventHandler; onBlur?: FocusEventHandler; + fieldValue: string; + type?: 'string' | 'boolean' | 'float' | 'int' | 'pango_lineage' | 'authors'; }; diff --git a/website/src/components/SearchPage/fields/MutationField.spec.tsx b/website/src/components/SearchPage/fields/MutationField.spec.tsx index c58ba0a7a..ee4dbbaec 100644 --- a/website/src/components/SearchPage/fields/MutationField.spec.tsx +++ b/website/src/components/SearchPage/fields/MutationField.spec.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, test, vi } from 'vitest'; import { MutationField } from './MutationField.tsx'; -import type { MutationFilter } from '../../../types/config.ts'; import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; const singleSegmentedReferenceGenome: ReferenceGenomesSequenceNames = { @@ -17,25 +16,17 @@ const multiSegmentedReferenceGenome: ReferenceGenomesSequenceNames = { }; function renderField( - value: MutationFilter, - onChange: (mutationFilter: MutationFilter) => void, + value: string, + onChange: (mutationFilter: string) => void, referenceGenome: ReferenceGenomesSequenceNames, ) { - render(); + render(); } describe('MutationField', () => { test('should render provided value', async () => { const handleChange = vi.fn(); - renderField( - { - aminoAcidMutationQueries: ['gene1:10Y'], - nucleotideMutationQueries: ['A20T'], - nucleotideInsertionQueries: ['ins_30:G?G'], - }, - handleChange, - singleSegmentedReferenceGenome, - ); + renderField('gene1:10Y, A20T, ins_30:G?G', handleChange, singleSegmentedReferenceGenome); expect(screen.queryByText('gene1:10Y')).toBeInTheDocument(); expect(screen.queryByText('A20T')).toBeInTheDocument(); expect(screen.queryByText('ins_30:G?G')).toBeInTheDocument(); @@ -43,33 +34,23 @@ describe('MutationField', () => { test('should accept input and dispatch events (single-segmented)', async () => { const handleChange = vi.fn(); - renderField({}, handleChange, singleSegmentedReferenceGenome); + renderField('', handleChange, singleSegmentedReferenceGenome); await userEvent.type(screen.getByLabelText('Mutations'), 'G100A{enter}'); - expect(handleChange).toHaveBeenCalledWith({ - nucleotideMutationQueries: ['G100A'], - aminoAcidMutationQueries: [], - nucleotideInsertionQueries: [], - aminoAcidInsertionQueries: [], - }); + expect(handleChange).toHaveBeenCalledWith('G100A'); }); test('should accept input and dispatch events (multi-segmented)', async () => { const handleChange = vi.fn(); - renderField({}, handleChange, multiSegmentedReferenceGenome); + renderField('', handleChange, multiSegmentedReferenceGenome); await userEvent.type(screen.getByLabelText('Mutations'), 'seg1:G100A{enter}'); - expect(handleChange).toHaveBeenCalledWith({ - nucleotideMutationQueries: ['seg1:G100A'], - aminoAcidMutationQueries: [], - nucleotideInsertionQueries: [], - aminoAcidInsertionQueries: [], - }); + expect(handleChange).toHaveBeenCalledWith('seg1:G100A'); }); test('should reject invalid input', async () => { const handleChange = vi.fn(); - renderField({}, handleChange, singleSegmentedReferenceGenome); + renderField('', handleChange, singleSegmentedReferenceGenome); await userEvent.type(screen.getByLabelText('Mutations'), 'main:G200A{enter}'); expect(handleChange).toHaveBeenCalledTimes(0); diff --git a/website/src/components/SearchPage/fields/MutationField.tsx b/website/src/components/SearchPage/fields/MutationField.tsx index 8c0eedc8a..46f6b17a7 100644 --- a/website/src/components/SearchPage/fields/MutationField.tsx +++ b/website/src/components/SearchPage/fields/MutationField.tsx @@ -2,32 +2,153 @@ import { Combobox, Transition } from '@headlessui/react'; import { type FC, Fragment, useMemo, useState } from 'react'; import * as React from 'react'; -import type { MutationFilter } from '../../../types/config.ts'; import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; import type { BaseType } from '../../../utils/sequenceTypeHelpers.ts'; interface MutationFieldProps { - referenceGenomes: ReferenceGenomesSequenceNames; - value: MutationFilter; - onChange: (mutationFilter: MutationFilter) => void; + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + value: string; + onChange: (mutationFilter: string) => void; } -export const MutationField: FC = ({ referenceGenomes, value, onChange }) => { +type MutationQuery = { + baseType: BaseType; + mutationType: 'substitutionOrDeletion' | 'insertion'; + text: string; +}; + +const isValidNucleotideMutationQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; + const textUpper = text.toUpperCase(); + let mutation = textUpper; + if (isMultiSegmented) { + const [segment, _mutation] = textUpper.split(':'); + const existingSegments = new Set( + referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), + ); + if (!existingSegments.has(segment)) { + return false; + } + mutation = _mutation; + } + return /^[A-Z]?[0-9]+[A-Z-\\.]?$/.test(mutation); + } catch (_) { + return false; + } +}; + +const isValidAminoAcidMutationQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const textUpper = text.toUpperCase(); + const [gene, mutation] = textUpper.split(':'); + const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); + if (!existingGenes.has(gene)) { + return false; + } + return /^[A-Z*]?[0-9]+[A-Z-*\\.]?$/.test(mutation); + } catch (_) { + return false; + } +}; + +const isValidNucleotideInsertionQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; + const textUpper = text.toUpperCase(); + if (!textUpper.startsWith('INS_')) { + return false; + } + const query = textUpper.slice(4); + const split = query.split(':'); + const [segment, position, insertion] = isMultiSegmented + ? split + : ([undefined, ...split] as [undefined | string, string, string]); + if (segment !== undefined) { + const existingSegments = new Set( + referenceGenomesSequenceNames.nucleotideSequences.map((n) => n.toUpperCase()), + ); + if (!existingSegments.has(segment)) { + return false; + } + } + if (!Number.isInteger(Number(position))) { + return false; + } + return /^[A-Z*?]+$/.test(insertion); + } catch (_) { + return false; + } +}; + +const isValidAminoAcidInsertionQuery = ( + text: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): boolean => { + try { + const textUpper = text.toUpperCase(); + if (!textUpper.startsWith('INS_')) { + return false; + } + const query = textUpper.slice(4); + const [gene, position, insertion] = query.split(':'); + const existingGenes = new Set(referenceGenomesSequenceNames.genes.map((g) => g.toUpperCase())); + if (!existingGenes.has(gene) || !Number.isInteger(Number(position))) { + return false; + } + return /^[A-Z*?]+$/.test(insertion); + } catch (_) { + return false; + } +}; + +export const parseMutationString = ( + value: string, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames, +): MutationQuery[] => { + return value + .split(',') + .map((mutation) => { + const trimmedMutation = mutation.trim(); + if (isValidNucleotideMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'nucleotide', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; + } + if (isValidAminoAcidMutationQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'aminoAcid', mutationType: 'substitutionOrDeletion', text: trimmedMutation }; + } + if (isValidNucleotideInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'nucleotide', mutationType: 'insertion', text: trimmedMutation }; + } + if (isValidAminoAcidInsertionQuery(trimmedMutation, referenceGenomesSequenceNames)) { + return { baseType: 'aminoAcid', mutationType: 'insertion', text: trimmedMutation }; + } + return null; + }) + .filter(Boolean) as MutationQuery[]; +}; + +const serializeMutationQueries = (selectedOptions: MutationQuery[]): string => { + return selectedOptions.map((option) => option.text).join(', '); +}; + +export const MutationField: FC = ({ referenceGenomesSequenceNames, value, onChange }) => { const [options, setOptions] = useState([]); const [inputValue, setInputValue] = useState(''); const [hasFocus, setHasFocus] = useState(false); - const selectedOptions: MutationQuery[] = useMemo(() => { - const mappers = [ - { from: value.nucleotideMutationQueries, baseType: 'nucleotide', mutationType: 'substitutionOrDeletion' }, - { from: value.aminoAcidMutationQueries, baseType: 'aminoAcid', mutationType: 'substitutionOrDeletion' }, - { from: value.nucleotideInsertionQueries, baseType: 'nucleotide', mutationType: 'insertion' }, - { from: value.aminoAcidInsertionQueries, baseType: 'aminoAcid', mutationType: 'insertion' }, - ] as const; - return mappers - .map(({ from, baseType, mutationType }) => from?.map((text) => ({ baseType, mutationType, text })) ?? []) - .flat(); - }, [value]); + const selectedOptions = useMemo( + () => parseMutationString(value, referenceGenomesSequenceNames), + [value, referenceGenomesSequenceNames], + ); const handleInputChange = (event: React.ChangeEvent) => { const newValue = event.target.value; @@ -40,7 +161,7 @@ export const MutationField: FC = ({ referenceGenomes, value, { baseType: 'aminoAcid', mutationType: 'insertion', test: isValidAminoAcidInsertionQuery }, ] as const; tests.forEach(({ baseType, mutationType, test }) => { - if (test(newValue, referenceGenomes)) { + if (test(newValue, referenceGenomesSequenceNames)) { newOptions.push({ baseType, mutationType, text: newValue }); } }); @@ -52,68 +173,14 @@ export const MutationField: FC = ({ referenceGenomes, value, option = option[0]; } const newSelectedOptions = [...selectedOptions, option]; - const mutationFilter: Required = { - nucleotideMutationQueries: [], - aminoAcidMutationQueries: [], - nucleotideInsertionQueries: [], - aminoAcidInsertionQueries: [], - }; - const mappers = [ - { - to: mutationFilter.nucleotideMutationQueries, - baseType: 'nucleotide', - mutationType: 'substitutionOrDeletion', - }, - { - to: mutationFilter.aminoAcidMutationQueries, - baseType: 'aminoAcid', - mutationType: 'substitutionOrDeletion', - }, - { to: mutationFilter.nucleotideInsertionQueries, baseType: 'nucleotide', mutationType: 'insertion' }, - { to: mutationFilter.aminoAcidInsertionQueries, baseType: 'aminoAcid', mutationType: 'insertion' }, - ] as const; - for (const { baseType, mutationType, text } of newSelectedOptions) { - mappers.forEach((mapper) => { - if (baseType === mapper.baseType && mutationType === mapper.mutationType) { - mapper.to.push(text); - } - }); - } - onChange(mutationFilter); + onChange(serializeMutationQueries(newSelectedOptions)); setInputValue(''); setOptions([]); }; const handleTagDelete = (index: number) => { const newSelectedOptions = selectedOptions.filter((_, i) => i !== index); - const mutationFilter: Required = { - nucleotideMutationQueries: [], - aminoAcidMutationQueries: [], - nucleotideInsertionQueries: [], - aminoAcidInsertionQueries: [], - }; - const mappers = [ - { - to: mutationFilter.nucleotideMutationQueries, - baseType: 'nucleotide', - mutationType: 'substitutionOrDeletion', - }, - { - to: mutationFilter.aminoAcidMutationQueries, - baseType: 'aminoAcid', - mutationType: 'substitutionOrDeletion', - }, - { to: mutationFilter.nucleotideInsertionQueries, baseType: 'nucleotide', mutationType: 'insertion' }, - { to: mutationFilter.aminoAcidInsertionQueries, baseType: 'aminoAcid', mutationType: 'insertion' }, - ] as const; - for (const { baseType, mutationType, text } of newSelectedOptions) { - mappers.forEach((mapper) => { - if (baseType === mapper.baseType && mutationType === mapper.mutationType) { - mapper.to.push(text); - } - }); - } - onChange(mutationFilter); + onChange(serializeMutationQueries(newSelectedOptions)); }; return ( @@ -198,86 +265,3 @@ export const MutationField: FC = ({ referenceGenomes, value,
); }; -type MutationQuery = { - baseType: BaseType; - mutationType: 'substitutionOrDeletion' | 'insertion'; - text: string; -}; - -const isValidNucleotideMutationQuery = (text: string, referenceGenomes: ReferenceGenomesSequenceNames): boolean => { - try { - const isMultiSegmented = referenceGenomes.nucleotideSequences.length > 1; - const textUpper = text.toUpperCase(); - let mutation = textUpper; - if (isMultiSegmented) { - const [segment, _mutation] = textUpper.split(':'); - const existingSegments = new Set(referenceGenomes.nucleotideSequences.map((n) => n.toUpperCase())); - if (!existingSegments.has(segment)) { - return false; - } - mutation = _mutation; - } - return /^[A-Z]?[0-9]+[A-Z-\\.]?$/.test(mutation); - } catch (_) { - return false; - } -}; - -const isValidAminoAcidMutationQuery = (text: string, referenceGenomes: ReferenceGenomesSequenceNames): boolean => { - try { - const textUpper = text.toUpperCase(); - const [gene, mutation] = textUpper.split(':'); - const existingGenes = new Set(referenceGenomes.genes.map((g) => g.toUpperCase())); - if (!existingGenes.has(gene)) { - return false; - } - return /^[A-Z*]?[0-9]+[A-Z-*\\.]?$/.test(mutation); - } catch (_) { - return false; - } -}; - -const isValidNucleotideInsertionQuery = (text: string, referenceGenomes: ReferenceGenomesSequenceNames): boolean => { - try { - const isMultiSegmented = referenceGenomes.nucleotideSequences.length > 1; - const textUpper = text.toUpperCase(); - if (!textUpper.startsWith('INS_')) { - return false; - } - const query = textUpper.slice(4); - const split = query.split(':'); - const [segment, position, insertion] = isMultiSegmented - ? split - : ([undefined, ...split] as [undefined | string, string, string]); - if (segment !== undefined) { - const existingSegments = new Set(referenceGenomes.nucleotideSequences.map((n) => n.toUpperCase())); - if (!existingSegments.has(segment)) { - return false; - } - } - if (!Number.isInteger(Number(position))) { - return false; - } - return /^[A-Z*?]+$/.test(insertion); - } catch (_) { - return false; - } -}; - -const isValidAminoAcidInsertionQuery = (text: string, referenceGenomes: ReferenceGenomesSequenceNames): boolean => { - try { - const textUpper = text.toUpperCase(); - if (!textUpper.startsWith('INS_')) { - return false; - } - const query = textUpper.slice(4); - const [gene, position, insertion] = query.split(':'); - const existingGenes = new Set(referenceGenomes.genes.map((g) => g.toUpperCase())); - if (!existingGenes.has(gene) || !Number.isInteger(Number(position))) { - return false; - } - return /^[A-Z*?]+$/.test(insertion); - } catch (_) { - return false; - } -}; diff --git a/website/src/components/SearchPage/fields/NormalTextField.tsx b/website/src/components/SearchPage/fields/NormalTextField.tsx index 7bb111333..ead378e55 100644 --- a/website/src/components/SearchPage/fields/NormalTextField.tsx +++ b/website/src/components/SearchPage/fields/NormalTextField.tsx @@ -1,20 +1,29 @@ -import { forwardRef } from 'react'; +import { forwardRef, type FocusEventHandler } from 'react'; -import type { FieldProps } from './FieldProps'; import { TextField } from './TextField'; +import type { MetadataFilter, SetAFieldValue } from '../../../types/config.ts'; -export const NormalTextField = forwardRef((props, ref) => { - const { field, handleFieldChange, isLoading, multiline, onFocus, onBlur } = props; +export type NormalFieldProps = { + field: MetadataFilter; + setAFieldValue: SetAFieldValue; + multiline?: boolean; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + fieldValue: string | number; + type?: 'string' | 'boolean' | 'float' | 'int' | 'pango_lineage' | 'authors'; +}; + +export const NormalTextField = forwardRef((props, ref) => { + const { field, setAFieldValue, multiline, onFocus, onBlur, fieldValue } = props; return ( handleFieldChange(field.name, e.target.value)} + onChange={(e) => setAFieldValue(field.name, e.target.value)} autoComplete='off' multiline={multiline} ref={ref} diff --git a/website/src/components/SearchPage/fields/PangoLineageField.tsx b/website/src/components/SearchPage/fields/PangoLineageField.tsx deleted file mode 100644 index 6b17b5586..000000000 --- a/website/src/components/SearchPage/fields/PangoLineageField.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import Checkbox from '@mui/material/Checkbox'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import { type FC, useState } from 'react'; - -import { AutoCompleteField, type AutoCompleteFieldProps } from './AutoCompleteField'; -import { NormalTextField } from './NormalTextField'; - -export const PangoLineageField: FC = ({ - field, - allFields, - handleFieldChange, - isLoading, - lapisUrl, -}) => { - const filter = field.filterValue; - const [includeSubLineages, setIncludeSubLineages] = useState(filter.length > 0 ? filter.endsWith('*') : true); - - const textField = { - ...field, - filter: includeSubLineages ? filter.slice(0, filter.length - 1) : filter, - }; - const handleTextFieldChange = (metadataName: string, newFilter: string) => { - if (newFilter.length > 0) { - handleFieldChange(metadataName, newFilter + (includeSubLineages ? '*' : '')); - } else { - handleFieldChange(metadataName, ''); - } - }; - const handleIncludeSubLineagesChange = (checked: boolean) => { - setIncludeSubLineages(checked); - if (filter.length > 0) { - handleFieldChange(field.name, textField.filter + (checked ? '*' : '')); - } - }; - - const textFieldProps = { - field: textField, - allFields, - handleFieldChange: handleTextFieldChange, - isLoading, - lapisUrl, - }; - - return ( - <> - {field.autocomplete === true ? ( - - ) : ( - - )} -
- } - label='Include sublineages' - disabled={isLoading} - onChange={(_, checked) => handleIncludeSubLineagesChange(checked)} - /> -
- - ); -}; diff --git a/website/src/components/SearchPage/fields/TextField.tsx b/website/src/components/SearchPage/fields/TextField.tsx index cffef86cd..131370c98 100644 --- a/website/src/components/SearchPage/fields/TextField.tsx +++ b/website/src/components/SearchPage/fields/TextField.tsx @@ -16,7 +16,7 @@ interface TextFieldProps { disabled?: boolean; onChange?: ChangeEventHandler; autoComplete?: string; - value?: string | number | readonly string[]; + fieldValue?: string | number | readonly string[]; className?: string; onFocus?: FocusEventHandler; placeholder?: string; @@ -26,10 +26,12 @@ interface TextFieldProps { } export const TextField = forwardRef(function (props, ref) { - const { label, disabled, onChange, autoComplete, value, className, onFocus, multiline, onBlur } = props; + const { label, disabled, onChange, autoComplete, fieldValue, className, onFocus, multiline, onBlur } = props; const id = useId(); const [isTransitionEnabled, setIsTransitionEnabled] = useState(false); const [hasFocus, setHasFocus] = useState(false); + const numericTypes = ['number', 'int', 'float']; + const inputType = props.type !== undefined && numericTypes.includes(props.type) ? 'number' : 'text'; useEffect(() => { const timeout = setTimeout(() => { @@ -57,7 +59,6 @@ export const TextField = forwardRef; + return ; } const refTextArea = ref as ForwardedRef; @@ -86,16 +87,19 @@ export const TextField = forwardRef