diff --git a/website/package-lock.json b/website/package-lock.json index af0218637..9db2ea4ba 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -16,6 +16,7 @@ "better-auth": "^1.6.14", "cookie": "^1.1.1", "dayjs": "^1.11.21", + "downshift": "^9.3.3", "katex": "^0.17.0", "patch-package": "^8.0.1", "react": "^19.2.7", @@ -6495,9 +6496,9 @@ } }, "node_modules/downshift": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.3.2.tgz", - "integrity": "sha512-5VD0WZLQDhipWiDU+K5ili3VDhGrXwlvOlSaSG1Cb0eS4XpssxVuoD09JNgju+bAzxB2Wvlwx+FwTE/FNdrqow==", + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.3.3.tgz", + "integrity": "sha512-otjI/WtoFcIXwPOyIQYkbrAZJA2Gjz67F8ENOh1RvmJalOg6fk0KEx4ljJSFuJB54ryc6doymPkCM+GjAZ7zjA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", @@ -8225,6 +8226,13 @@ "set-cookie-parser": "^3.0.1" } }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", diff --git a/website/package.json b/website/package.json index 2dba7e144..8a0a94ab2 100644 --- a/website/package.json +++ b/website/package.json @@ -33,6 +33,7 @@ "cookie": "^1.1.1", "dayjs": "^1.11.21", "katex": "^0.17.0", + "downshift": "^9.3.3", "patch-package": "^8.0.1", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/website/src/backendApi/backendService.ts b/website/src/backendApi/backendService.ts index 8019e6d9d..dbe332519 100644 --- a/website/src/backendApi/backendService.ts +++ b/website/src/backendApi/backendService.ts @@ -177,8 +177,21 @@ export class BackendService extends ApiService { return this.get({ url: `/users/${id}`, schema: publicUserSchema }); } - public async getCollectionSummaries(requestParams: { organism?: string; excludeSystemCollections?: boolean } = {}) { - return this.get({ url: '/collections', requestParams, schema: z.array(collectionSummarySchema) }); + public async getCollectionSummaries({ + organism, + userId, + excludeSystemCollections, + }: { organism?: string; userId?: number; excludeSystemCollections?: boolean } = {}) { + const requestParams: Record = {}; + if (organism !== undefined) requestParams.organism = organism; + if (userId !== undefined) requestParams.userId = String(userId); + if (excludeSystemCollections !== undefined) + requestParams.excludeSystemCollections = String(excludeSystemCollections); + return this.get({ + url: '/collections', + requestParams: Object.keys(requestParams).length > 0 ? requestParams : undefined, + schema: z.array(collectionSummarySchema), + }); } public async getCollections({ organism }: { organism?: string } = {}) { diff --git a/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx b/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx index c75b1b337..715d4940f 100644 --- a/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx +++ b/website/src/components/pageStateSelectors/wasap/InfoBlocks.tsx @@ -144,12 +144,12 @@ export function ExplorationModeInfo() {
  • Variant Explorer: track variant-specific - mutations over time. Variant-specific mutations are computed based on user parameters and filtering - clinical sequences from{' '} + mutations over time. Variants can be defined in two ways: by selecting a predefined variant from a + curated list, or by filtering clinical sequences from{' '} CovSpectrum - - . + {' '} + based on user parameters.
  • Untracked Mutations: novel mutations not yet diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 5f5c483ff..f968b786d 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { type Dispatch, type SetStateAction, useState } from 'react'; +import { getBackendServiceForClientside } from '../../../backendApi/backendService'; import { ApplyFilterButton } from '../ApplyFilterButton'; import { DynamicDateFilter } from '../DynamicDateFilter'; import { SelectorHeadline } from '../SelectorHeadline'; @@ -101,6 +102,24 @@ export function WasapPageStateSelector({ }, }); + const predefinedVariantsQueryResult = useQuery({ + enabled: config.variantAnalysisModeEnabled && config.predefinedVariantsSource !== undefined, + queryKey: ['predefinedVariants', config.variantAnalysisModeEnabled && config.predefinedVariantsSource], + queryFn: async () => { + if (!config.variantAnalysisModeEnabled || config.predefinedVariantsSource === undefined) { + throw Error( + 'This predefined variants query was called despite it being disabled. This should not happen.', + ); + } + const { collectionsUserId, collectionsTag } = config.predefinedVariantsSource; + const collections = await getBackendServiceForClientside().getCollectionSummaries({ + userId: collectionsUserId, + organism: config.internalName, + }); + return collections.filter((c) => c.description?.includes(collectionsTag) ?? false); + }, + }); + return (
    Filter dataset @@ -181,6 +200,12 @@ export function WasapPageStateSelector({ setPageState={setVariantFilter} clinicalSequenceLapisBaseUrl={config.clinicalLapis.lapisBaseUrl} clinicalSequenceLapisLineageField={config.clinicalLapis.lineageField} + predefinedVariantsQueryResult={ + config.predefinedVariantsSource !== undefined + ? predefinedVariantsQueryResult + : undefined + } + predefinedVariantsLabel={config.predefinedVariantsSource?.variantSourceLabel} /> ); case 'resistance': diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx index dca132918..e3c3e69ff 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.browser.spec.tsx @@ -1,17 +1,26 @@ -import { userEvent } from '@vitest/browser/context'; +import { type UseQueryResult } from '@tanstack/react-query'; +import { page, userEvent } from '@vitest/browser/context'; import { describe, expect, vi } from 'vitest'; import { render } from 'vitest-browser-react'; import { VariantExplorerFilter } from './VariantExplorerFilter'; import { DUMMY_LAPIS_URL, type LapisRouteMocker } from '../../../../../routeMocker'; import { it } from '../../../../../test-extend'; +import type { CollectionSummary } from '../../../../types/Collection'; import type { WasapVariantFilter } from '../../../views/wasap/wasapPageConfig'; const DUMMY_LAPIS_URL_2 = 'http://lapis2.dummy'; +const DUMMY_COLLECTIONS: CollectionSummary[] = [ + { id: 1, name: 'XBB.1.5', ownedBy: 1, organism: 'SARS-CoV-2', description: null, variantCount: 5 }, + { id: 2, name: 'JN.1', ownedBy: 1, organism: 'SARS-CoV-2', description: null, variantCount: 3 }, +]; +const mockPredefinedQueryResult = { data: DUMMY_COLLECTIONS } as unknown as UseQueryResult; + describe('VariantExplorerFilter', () => { const defaultPageState: WasapVariantFilter = { mode: 'variant', + signatureType: 'computed', sequenceType: 'nucleotide', variant: undefined, minProportion: 0.05, @@ -20,6 +29,11 @@ describe('VariantExplorerFilter', () => { timeFrame: 'all', }; + const predefinedPageState: WasapVariantFilter = { + ...defaultPageState, + signatureType: 'predefined', + }; + it('calls setPageState when changing sequence type', async ({ routeMockers: { lapis } }) => { setupLapisMocks(lapis); const mockSetPageState = vi.fn(); @@ -98,6 +112,87 @@ describe('VariantExplorerFilter', () => { minProportion: 0.1, }); }); + + it('calls setPageState when switching to predefined signature type', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + render( + + + , + ); + + const variantSourceSelect = page.getByRole('combobox').first(); + await variantSourceSelect.selectOptions('predefined'); + + expect(mockSetPageState).toHaveBeenCalledWith({ + ...defaultPageState, + signatureType: 'predefined', + }); + }); + + it('calls setPageState when selecting a predefined collection', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + const { getByRole } = render( + + + , + ); + + const collectionInput = page.getByPlaceholder('Select variant'); + await collectionInput.click(); + await userEvent.type(collectionInput, 'XBB'); + + const option = await vi.waitFor(() => getByRole('option', { name: 'XBB.1.5', exact: true })); + await option.click(); + + await vi.waitFor(() => { + expect(mockSetPageState).toHaveBeenCalledWith({ + ...predefinedPageState, + collectionId: 1, + }); + }); + }); + + it('calls setPageState when toggling "Mutation not in parent"', async ({ routeMockers: { lapis } }) => { + setupLapisMocks(lapis); + const mockSetPageState = vi.fn(); + + const { getByLabelText } = render( + + + , + ); + + const checkbox = getByLabelText('Mutation not in parent'); + await checkbox.click(); + + expect(mockSetPageState).toHaveBeenCalledWith({ + ...predefinedPageState, + newMutationsOnly: true, + }); + }); }); function setupLapisMocks(lapisRouteMocker: LapisRouteMocker) { diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx index 635f01fb7..6739cf1b5 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx @@ -1,13 +1,19 @@ +import { type UseQueryResult } from '@tanstack/react-query'; +import { useId } from 'react'; + import { Inset } from '../../../../styles/Inset'; +import { type CollectionSummary } from '../../../../types/Collection'; import { GsLineageFilter } from '../../../genspectrum/GsLineageFilter'; import { VARIANT_TIME_FRAME, variantTimeFrameLabel, + type SignatureType, type VariantTimeFrame, type WasapVariantFilter, } from '../../../views/wasap/wasapPageConfig'; import { SelectorHeadline } from '../../SelectorHeadline'; import { DefineClinicalSignatureInfo } from '../InfoBlocks'; +import { CollectionCombobox } from '../utils/CollectionCombobox'; import { LabeledField } from '../utils/LabeledField'; import { NumericInput } from '../utils/NumericInput'; import { SequenceTypeSelector } from '../utils/SequenceTypeSelector'; @@ -21,6 +27,8 @@ interface VariantExplorerFilterProps { */ clinicalSequenceLapisBaseUrl: string; clinicalSequenceLapisLineageField: string; + predefinedVariantsQueryResult?: UseQueryResult; + predefinedVariantsLabel?: string; } export function VariantExplorerFilter({ @@ -28,69 +36,148 @@ export function VariantExplorerFilter({ setPageState, clinicalSequenceLapisBaseUrl, clinicalSequenceLapisLineageField, + predefinedVariantsQueryResult, + predefinedVariantsLabel = 'Predefined', }: VariantExplorerFilterProps) { + const handleSignatureTypeChange = (newType: SignatureType) => { + setPageState({ ...pageState, signatureType: newType }); + }; + return ( <> setPageState({ ...pageState, sequenceType })} /> - - }>Define Clinical Signature - - - { - setPageState({ ...pageState, variant: lineages[clinicalSequenceLapisLineageField] }); - }} - hideCounts={true} - /> - - - setPageState({ ...pageState, minProportion: v })} - /> - setPageState({ ...pageState, minCount: Math.round(v) })} - /> - setPageState({ ...pageState, minJaccard: v })} - /> - + {predefinedVariantsQueryResult !== undefined && ( + - + )} + {pageState.signatureType === 'predefined' && ( + + )} + {pageState.signatureType === 'computed' && ( + + }> + Define Clinical Signature + + + + { + setPageState({ + ...pageState, + variant: lineages[clinicalSequenceLapisLineageField], + }); + }} + hideCounts={true} + /> + + + setPageState({ ...pageState, minProportion: v })} + /> + setPageState({ ...pageState, minCount: Math.round(v) })} + /> + setPageState({ ...pageState, minJaccard: v })} + /> + + + + + )} ); } + +function PredefinedSignature({ + pageState, + setPageState, + predefinedVariantsQueryResult, +}: { + pageState: WasapVariantFilter; + setPageState: (newState: WasapVariantFilter) => void; + predefinedVariantsQueryResult: UseQueryResult | undefined; +}) { + const collections = predefinedVariantsQueryResult?.data ?? []; + const selectedCollection = collections.find((c) => c.id === pageState.collectionId) ?? null; + const newMutationsCheckBoxId = useId(); + + return ( + + + setPageState({ ...pageState, collectionId: c?.id })} + /> + +
    + setPageState({ ...pageState, newMutationsOnly: e.target.checked })} + /> +
    + +
    +
    +
    + ); +} diff --git a/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx new file mode 100644 index 000000000..1e887fbd9 --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.browser.spec.tsx @@ -0,0 +1,155 @@ +import { userEvent } from '@vitest/browser/context'; +import { describe, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { CollectionCombobox } from './CollectionCombobox'; +import { it } from '../../../../../test-extend'; +import type { CollectionSummary } from '../../../../types/Collection'; + +const makeCollection = (id: number, name: string): CollectionSummary => ({ + id, + name, + ownedBy: 1, + organism: 'test', + description: null, + variantCount: 0, +}); + +const collections = [makeCollection(1, 'Alpha'), makeCollection(2, 'Beta'), makeCollection(3, 'Gamma')]; + +describe('CollectionCombobox', () => { + describe('onInputBlur', () => { + it('calls onChange(null) when blurred with empty input', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill(''); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('calls onChange with matched collection when blurred with an exact name', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Beta'); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(collections[1]); + }); + + it('calls onChange with matched collection when input has surrounding whitespace', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill(' Beta '); + await userEvent.tab(); + + expect(onChange).toHaveBeenCalledWith(collections[1]); + }); + + it('shows error state and does not call onChange when blurred with an unrecognised name', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Unknown'); + await userEvent.tab(); + + expect(onChange).not.toHaveBeenCalled(); + const wrapper = getByRole('combobox').element().closest('.input'); + await expect.element(wrapper as HTMLElement).toHaveClass('input-error'); + }); + + it('clears error state when user starts typing after an invalid blur', async () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + + await getByRole('combobox').fill('Unknown'); + await userEvent.tab(); + + const wrapper = getByRole('combobox').element().closest('.input'); + await expect.element(wrapper as HTMLElement).toHaveClass('input-error'); + + await getByRole('combobox').fill('A'); + await expect.element(wrapper as HTMLElement).not.toHaveClass('input-error'); + }); + }); + + describe('clear button', () => { + it('is not rendered when input is empty', () => { + const { getByLabelText } = render( + , + ); + + expect(getByLabelText('clear selection').elements()).toHaveLength(0); + }); + + it('is visible when a value is selected', async () => { + const { getByLabelText } = render( + , + ); + + await expect.element(getByLabelText('clear selection')).toBeVisible(); + }); + + it('clears the input and calls onChange(null) when clicked', async () => { + const onChange = vi.fn(); + const { getByLabelText, getByRole } = render( + , + ); + + await getByLabelText('clear selection').click(); + + expect(onChange).toHaveBeenCalledWith(null); + await expect.element(getByRole('combobox')).toHaveValue(''); + }); + + it('disappears after being clicked', async () => { + const onChange = vi.fn(); + const { getByLabelText } = render( + , + ); + + await getByLabelText('clear selection').click(); + + expect(getByLabelText('clear selection').elements()).toHaveLength(0); + }); + }); + + describe('filtering', () => { + it('shows only matching items when typing', async () => { + const { getByRole, getByText } = render( + , + ); + + await getByRole('button', { name: 'toggle menu' }).click(); + await getByRole('combobox').fill('Al'); + + await expect.element(getByText('Alpha')).toBeVisible(); + expect(getByText('Beta').elements()).toHaveLength(0); + expect(getByText('Gamma').elements()).toHaveLength(0); + }); + + it('shows "No variants found" when no collections match the input', async () => { + const { getByRole, getByText } = render( + , + ); + + await getByRole('button', { name: 'toggle menu' }).click(); + await getByRole('combobox').fill('zzz'); + + await expect.element(getByText('No variants found.')).toBeVisible(); + }); + }); +}); diff --git a/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx new file mode 100644 index 000000000..cb22c2ada --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/utils/CollectionCombobox.tsx @@ -0,0 +1,127 @@ +import { useCombobox } from 'downshift'; +import { useMemo, useRef, useState } from 'react'; + +import { type CollectionSummary } from '../../../../types/Collection'; + +export function CollectionCombobox({ + collections, + value, + onChange, + placeholderText = 'Select variant', +}: { + collections: CollectionSummary[]; + value: CollectionSummary | null; + onChange: (item: CollectionSummary | null) => void; + placeholderText?: string; +}) { + const [inputFilter, setInputFilter] = useState(() => value?.name ?? ''); + const [inputIsInvalid, setInputIsInvalid] = useState(false); + + const sortedCollections = useMemo( + () => [...collections].sort((a, b) => a.name.localeCompare(b.name)), + [collections], + ); + + const filteredCollections = useMemo( + () => sortedCollections.filter((c) => c.name.toLowerCase().includes(inputFilter.toLowerCase())), + [sortedCollections, inputFilter], + ); + + const buttonRef = useRef(null); + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + inputValue, + closeMenu, + reset, + } = useCombobox({ + items: filteredCollections, + itemToString: (item) => item?.name ?? '', + selectedItem: value, + onInputValueChange({ inputValue }) { + setInputIsInvalid(false); + setInputFilter(inputValue.trim()); + }, + onSelectedItemChange({ selectedItem }) { + onChange(selectedItem ?? null); + }, + }); + + const onInputBlur = () => { + if (inputValue === '') { + onChange(null); + return; + } + const match = collections.find((c) => c.name === inputValue.trim()); + if (match !== undefined) { + onChange(match); + return; + } + setInputIsInvalid(true); + }; + + return ( +
    +
    { + if (e.relatedTarget !== buttonRef.current) { + closeMenu(); + } + }} + > + + {inputValue !== '' && ( + + )} + +
    +
      + {filteredCollections.length > 0 ? ( + filteredCollections.map((item, index) => ( +
    • + {item.name} +
    • + )) + ) : ( +
    • No variants found.
    • + )} +
    +
    + ); +} diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 85b5be6a4..f7de97c3e 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -1,15 +1,8 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { type FC } from 'react'; -import { CollectionInfo } from './components/CollectionInfo'; -import { NoDataHelperText } from './components/NoDataHelperText'; -import { VariantFetchInfo } from './components/VariantFetchInfo'; -import { WasapStats } from './components/WasapStats'; -import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; -import type { ResistanceData } from './resistanceData'; -import { useWasapPageData } from './useWasapPageData'; -import type { WasapPageConfig } from './wasapPageConfig'; import { withQueryProvider } from '../../../backendApi/withQueryProvider'; +import { getClientLogger } from '../../../clientLogger'; import { defaultBreadcrumbs } from '../../../layouts/Breadcrumbs.tsx'; import { DataPageLayout } from '../../../layouts/OrganismPage/DataPageLayout.tsx'; import { dataOrigins } from '../../../types/dataOrigins.ts'; @@ -20,6 +13,16 @@ import { GsMutationsOverTime } from '../../genspectrum/GsMutationsOverTime'; import { GsQueriesOverTime } from '../../genspectrum/GsQueriesOverTime.tsx'; import { WasapPageStateSelector } from '../../pageStateSelectors/wasap/WasapPageStateSelector'; import { usePageState } from '../usePageState.ts'; +import { CollectionInfo } from './components/CollectionInfo'; +import { NoDataHelperText } from './components/NoDataHelperText'; +import { VariantFetchInfo } from './components/VariantFetchInfo'; +import { WasapStats } from './components/WasapStats'; +import { getInitialMeanProportionInterval } from './initialMeanProportionInterval'; +import type { ResistanceData } from './resistanceData'; +import { useWasapPageData } from './useWasapPageData'; +import type { WasapPageConfig } from './wasapPageConfig'; + +const logger = getClientLogger('WasapPage'); export type WasapPageProps = { config: WasapPageConfig; @@ -37,7 +40,13 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = const { mutationAnnotations, displayMutationsBySet } = resistanceData; // fetch which mutations should be analyzed - const { data, isPending, isError } = useWasapPageData(config, displayMutationsBySet, analysis); + const { data, isPending, isError, error } = useWasapPageData(config, displayMutationsBySet, analysis); + + useEffect(() => { + if (error) { + logger.error(`Failed to fetch wasap page data: ${error instanceof Error ? error.message : String(error)}`); + } + }, [error]); const initialMeanProportionInterval = getInitialMeanProportionInterval(analysis); @@ -77,7 +86,16 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = />
    {isError ? ( - There was an error fetching the data to display. + analysis.mode === 'variant' && + analysis.signatureType === 'predefined' && + analysis.collectionId === undefined ? ( +
    +

    No variant selected

    +

    Please select a variant from the filter panel.

    +
    + ) : ( + There was an error fetching the data to display. + ) ) : isPending ? ( ) : ( @@ -101,15 +119,17 @@ export const WasapPageInner: FC = ({ config, resistanceData }) = customColumns={data.customColumns} /> )} - {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( - - )} + {analysis.mode === 'variant' && + analysis.signatureType === 'computed' && + config.variantAnalysisModeEnabled && ( + + )} ) : ( <> diff --git a/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts index 03f38a240..521a2e8d9 100644 --- a/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts +++ b/website/src/components/views/wasap/initialMeanProportionInterval.spec.ts @@ -27,6 +27,7 @@ describe('getInitialMeanProportionInterval', () => { test('other analysis states initially show the full mean proportion range', () => { const analysis: WasapAnalysisFilter = { mode: 'variant', + signatureType: 'computed', sequenceType: 'nucleotide', variant: 'XFG*', minProportion: 0.8, diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 5799daf5e..9d69bde1e 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -12,6 +12,7 @@ import type { WasapUntrackedFilter, WasapVariantFilter, } from './wasapPageConfig'; +import { getBackendServiceForClientside } from '../../../backendApi/backendService'; import { getCollection } from '../../../covspectrum/getCollection'; import { detailedMutationsToQuery } from '../../../covspectrum/variantConversionUtil'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; @@ -68,6 +69,21 @@ function fetchManualModeData(config: WasapPageConfig, analysis: WasapManualFilte async function fetchVariantModeData( config: WasapPageConfig, analysis: WasapVariantFilter, +): Promise { + if (!config.variantAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'variant' mode is not enabled."); + } + switch (analysis.signatureType) { + case 'computed': + return fetchVariantComputedModeData(config, analysis); + case 'predefined': + return fetchVariantPredefinedModeData(analysis); + } +} + +async function fetchVariantComputedModeData( + config: WasapPageConfig, + analysis: WasapVariantFilter, ): Promise { if (!config.variantAnalysisModeEnabled) { throw Error("Cannot fetch data, 'variant' mode is not enabled."); @@ -97,6 +113,36 @@ async function fetchVariantModeData( }; } +async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Promise { + if (analysis.collectionId === undefined) { + throw new Error('No collection selected for predefined variant mode.'); + } + const collection = await getBackendServiceForClientside().getCollection({ id: String(analysis.collectionId) }); + + // These names match the variant names hardcoded in the collection seeder. + let variantName: string; + if (analysis.sequenceType === 'nucleotide') { + variantName = analysis.newMutationsOnly ? 'New nucleotide substitutions' : 'Nucleotide substitutions'; + } else { + variantName = analysis.newMutationsOnly ? 'New amino acid substitutions' : 'Amino acid substitutions'; + } + + const variant = collection.variants.find((v) => v.name === variantName); + if (!variant) { + throw new Error(`Variant "${variantName}" not found in collection ${collection.id}.`); + } + if (variant.type !== 'filterObject') { + throw new Error(`Variant "${variantName}" in collection ${collection.id} is not a filterObject variant.`); + } + + const mutations = + analysis.sequenceType === 'nucleotide' + ? (variant.filterObject.nucleotideMutations ?? []) + : (variant.filterObject.aminoAcidMutations ?? []); + + return { type: 'mutations', displayMutations: mutations }; +} + function fetchResistanceModeData( displayMutationsBySet: Record, analysis: WasapResistanceFilter, diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 2c4cbfe69..3cde304db 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -9,6 +9,11 @@ export type WasapPageConfig = WasapPageConfigBase & AnalysisModeConfigs; * Base settings that apply to all modes. */ export type WasapPageConfigBase = { + /** + * The internal identifier of the organism, i.e. 'covid'. Used as a key in maps and API parameters. + */ + internalName: string; + /** * The name of the organism, i.e. 'Sars-CoV-2' */ @@ -70,6 +75,11 @@ type VariantAnalysisModeConfig = } | { variantAnalysisModeEnabled: true; + predefinedVariantsSource?: { + collectionsUserId: number; + collectionsTag: string; + variantSourceLabel?: string; + }; clinicalLapis: { lapisBaseUrl: string; dateField: string; @@ -195,14 +205,25 @@ export function variantTimeFrameLabel(timeFrame: VariantTimeFrame): string { } } +/** + * The type of variant mutation signature. `predefined` is a pre-defined list pulled from online, + * `computed` computes the list of signature mutations for a variant based on user parameters. + */ +export type SignatureType = 'computed' | 'predefined'; + export type WasapVariantFilter = { mode: 'variant'; + signatureType: SignatureType; sequenceType: SequenceType; + // computed signature fields variant?: string; minProportion: number; minCount: number; minJaccard: number; timeFrame: VariantTimeFrame; + // predefined signature fields + collectionId?: number; + newMutationsOnly?: boolean; }; export type WasapResistanceFilter = { diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 8e10fa1d5..949d28601 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -15,6 +15,7 @@ export const wastewaterPathFragment = 'swiss-wastewater'; export const wastewaterOrganismConfigs: Record = { [wastewaterOrganisms.covid]: { + internalName: wastewaterOrganisms.covid, name: 'SARS-CoV-2', path: `/${wastewaterPathFragment}/covid`, description: 'Analyze SARS-CoV-2 data that was collected by the WISE project.', @@ -56,6 +57,11 @@ export const wastewaterOrganismConfigs: Record { 'granularity=week&' + 'analysisMode=variant&' + 'sequenceType=nucleotide&' + + 'signatureType=computed&' + 'variant=BA.2*&' + 'minProportion=0.5&' + 'minCount=10&' + @@ -267,6 +270,56 @@ describe('WasapPageStateHandler', () => { expect(analysis2.minProportion).toBe(analysis1.minProportion); expect(analysis2.minJaccard).toBe(analysis1.minJaccard); }); + + it('defaults signatureType to computed when absent from URL', () => { + const url = '/wastewater/covid?analysisMode=variant&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('computed'); + }); + + it('parses and encodes predefined variant filter with collectionId (round-trip)', () => { + const url = + '/wastewater/covid?' + + 'locationName=Z%C3%BCrich+%28ZH%29&' + + 'granularity=day&' + + 'analysisMode=variant&' + + 'sequenceType=nucleotide&' + + 'signatureType=predefined&' + + 'collectionId=42&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('variant'); + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('predefined'); + expect(analysis.collectionId).toBe(42); + expect(analysis.newMutationsOnly).toBe(false); + + const newUrl = handler.toUrl(filter); + expect(newUrl).toBe(url); + }); + + it('parses and encodes newMutationsOnly=true in predefined variant mode (round-trip)', () => { + const url = + '/wastewater/covid?' + + 'locationName=Z%C3%BCrich+%28ZH%29&' + + 'granularity=day&' + + 'analysisMode=variant&' + + 'sequenceType=nucleotide&' + + 'signatureType=predefined&' + + 'collectionId=42&' + + 'newMutationsOnly=true&'; + const filter = handler.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('variant'); + const analysis = filter.analysis as WasapVariantFilter; + expect(analysis.signatureType).toBe('predefined'); + expect(analysis.newMutationsOnly).toBe(true); + + const newUrl = handler.toUrl(filter); + expect(newUrl).toBe(url); + }); }); describe('resistance mode', () => { diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts index 37f6909a4..f4b4569ad 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts @@ -7,6 +7,7 @@ import type { BaselineFilterConfig } from '../../components/pageStateSelectors/B import { enabledAnalysisModes, type ExcludeSetName, + type SignatureType, type VariantTimeFrame, type WasapAnalysisFilter, type WasapAnalysisMode, @@ -52,12 +53,15 @@ export class WasapPageStateHandler implements PageStateHandler { mutations: texts.mutations?.split('|'), }; break; - case 'variant': + case 'variant': { if (!this.config.variantAnalysisModeEnabled) { throw Error("The 'variant' analysis mode is not enabled."); } analysis = { mode, + signatureType: + (texts.signatureType as SignatureType | undefined) ?? + this.config.filterDefaults.variant.signatureType, sequenceType: providedSequenceType ?? this.config.filterDefaults.variant.sequenceType, variant: texts.variant ?? this.config.filterDefaults.variant.variant, minProportion: Number(texts.minProportion ?? this.config.filterDefaults.variant.minProportion), @@ -66,8 +70,13 @@ export class WasapPageStateHandler implements PageStateHandler { timeFrame: (texts.timeFrame as VariantTimeFrame | undefined) ?? this.config.filterDefaults.variant.timeFrame, + collectionId: texts.collectionId + ? Number(texts.collectionId) + : this.config.filterDefaults.variant.collectionId, + newMutationsOnly: texts.newMutationsOnly === 'true', }; break; + } case 'resistance': if (!this.config.resistanceAnalysisModeEnabled) { throw Error("The 'resistance' analysis mode is not enabled."); @@ -135,11 +144,23 @@ export class WasapPageStateHandler implements PageStateHandler { break; case 'variant': setSearchFromString(search, 'sequenceType', analysis.sequenceType); - setSearchFromString(search, 'variant', analysis.variant); - setSearchFromString(search, 'minProportion', String(analysis.minProportion)); - setSearchFromString(search, 'minCount', String(analysis.minCount)); - setSearchFromString(search, 'minJaccard', String(analysis.minJaccard)); - setSearchFromString(search, 'timeFrame', analysis.timeFrame); + setSearchFromString(search, 'signatureType', analysis.signatureType); + if (analysis.signatureType === 'predefined') { + setSearchFromString( + search, + 'collectionId', + analysis.collectionId ? String(analysis.collectionId) : undefined, + ); + if (analysis.newMutationsOnly) { + setSearchFromString(search, 'newMutationsOnly', 'true'); + } + } else { + setSearchFromString(search, 'variant', analysis.variant); + setSearchFromString(search, 'minProportion', String(analysis.minProportion)); + setSearchFromString(search, 'minCount', String(analysis.minCount)); + setSearchFromString(search, 'minJaccard', String(analysis.minJaccard)); + setSearchFromString(search, 'timeFrame', analysis.timeFrame); + } break; case 'resistance': setSearchFromString(search, 'resistanceSet', analysis.resistanceSet); @@ -236,5 +257,13 @@ function generateWasapFilterConfig(pageConfig: WasapPageConfig): BaselineFilterC type: 'text', lapisField: 'collectionId', }, + { + type: 'text', + lapisField: 'signatureType', + }, + { + type: 'text', + lapisField: 'newMutationsOnly', + }, ]; }