From a6106bb0db822a22617361fa55432a162d8832e0 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 27 Jan 2026 11:32:56 +0100 Subject: [PATCH 01/21] add new getCollection function --- website/src/covspectrum/getCollection.spec.ts | 167 ++++++++++++++++++ website/src/covspectrum/getCollection.ts | 36 ++++ 2 files changed, 203 insertions(+) create mode 100644 website/src/covspectrum/getCollection.spec.ts create mode 100644 website/src/covspectrum/getCollection.ts diff --git a/website/src/covspectrum/getCollection.spec.ts b/website/src/covspectrum/getCollection.spec.ts new file mode 100644 index 00000000..ce39d523 --- /dev/null +++ b/website/src/covspectrum/getCollection.spec.ts @@ -0,0 +1,167 @@ +import { http } from 'msw'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getCollection } from './getCollection.ts'; +import { type Collection } from './types.ts'; +import { astroApiRouteMocker, testServer } from '../../vitest.setup.ts'; + +const DUMMY_COV_SPECTRUM_URL = 'http://cov-spectrum.dummy/api/v2'; + +describe('getCollection', () => { + beforeEach(() => { + astroApiRouteMocker.mockLog(); + }); + + test('should successfully fetch a single collection by id', async () => { + const mockCollection: Collection = { + id: 1, + title: "Editor's choice", + description: 'A curated collection', + maintainers: 'Test Team', + email: 'test@example.com', + variants: [ + { + query: '{"pangoLineage":"JN.1*"}', + name: 'JN.1*', + description: 'JN.1 variant', + highlighted: false, + }, + ], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(mockCollection); + }), + ); + + const result = await getCollection(DUMMY_COV_SPECTRUM_URL, 1); + + expect(result.id).equals(1); + expect(result.title).equals("Editor's choice"); + expect(result.variants).toHaveLength(1); + expect(result.variants[0].name).equals('JN.1*'); + }); + + test('should construct the correct endpoint URL', async () => { + const baseUrl = 'https://example.com/api/v2'; + const collectionId = 42; + let requestedUrl = ''; + + const mockCollection: Collection = { + id: collectionId, + title: 'Test Collection', + description: 'Test description', + maintainers: 'Tester', + email: 'test@test.com', + variants: [], + }; + + testServer.use( + http.get(`${baseUrl}/resource/collection/${collectionId}`, ({ request }) => { + requestedUrl = request.url; + return Response.json(mockCollection); + }), + ); + + await getCollection(baseUrl, collectionId); + + expect(requestedUrl).equals(`${baseUrl}/resource/collection/${collectionId}`); + }); + + test('should validate collection structure with all required fields', async () => { + const mockCollection: Collection = { + id: 2, + title: 'Full Collection', + description: 'Complete collection with all fields', + maintainers: 'Team A, Team B', + email: 'teams@example.com', + variants: [ + { + query: '{"aaMutations":["S:L441R"]}', + name: 'S:L441R', + description: 'Spike mutation', + highlighted: true, + }, + { + query: '{"nextcladePangoLineage":"XEC*"}', + name: 'XEC*', + description: 'XEC lineage', + highlighted: false, + }, + ], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/2`, () => { + return Response.json(mockCollection); + }), + ); + + const result = await getCollection(DUMMY_COV_SPECTRUM_URL, 2); + + expect(result.id).equals(2); + expect(result.title).equals('Full Collection'); + expect(result.description).equals('Complete collection with all fields'); + expect(result.maintainers).equals('Team A, Team B'); + expect(result.email).equals('teams@example.com'); + expect(result.variants).toHaveLength(2); + expect(result.variants[0].highlighted).equals(true); + expect(result.variants[1].highlighted).equals(false); + }); + + test('should throw error when request fails with 404', async () => { + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/999`, () => { + return new Response(null, { status: 404 }); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 999)).rejects.toThrow(/Failed to fetch collection 999/); + }); + + test('should throw error when request fails with 500', async () => { + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return new Response(null, { status: 500 }); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to fetch collection 1/); + }); + + test('should throw error when response validation fails', async () => { + const invalidData = { + id: 'not-a-number', // Invalid: should be number + title: 'Collection', + description: 'Description', + maintainers: 'Maintainer', + email: 'email@example.com', + variants: [], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(invalidData); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to parse collection 1 response/); + }); + + test('should throw error when missing required fields', async () => { + const invalidData = { + id: 1, + title: 'Collection', + // Missing description, maintainers, email, variants + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(invalidData); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to parse collection 1 response/); + }); +}); diff --git a/website/src/covspectrum/getCollection.ts b/website/src/covspectrum/getCollection.ts new file mode 100644 index 00000000..7f4b159f --- /dev/null +++ b/website/src/covspectrum/getCollection.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; + +import { getClientLogger } from '../clientLogger.ts'; +import { collectionSchema, type Collection } from './types.ts'; + +const logger = getClientLogger('getCollection'); + +/** + * Fetches a single variant collection by ID from the CoV-Spectrum API. + * + * @param covSpectrumApiBaseUrl The base URL of the CoV-Spectrum API (e.g., 'https://cov-spectrum.org/api/v2') + * @param id The ID of the collection to fetch + * @returns A promise that resolves to a Collection object + * @throws Error if the request fails or response validation fails + */ +export async function getCollection(covSpectrumApiBaseUrl: string, id: number): Promise { + const url = `${covSpectrumApiBaseUrl}/resource/collection/${id}`; + + let response; + try { + response = await axios.get(url); + } catch (error) { + const message = `Failed to fetch collection ${id}: ${JSON.stringify(error)}`; + logger.error(message); + throw new Error(message); + } + + const parsedResponse = collectionSchema.safeParse(response.data); + if (parsedResponse.success) { + return parsedResponse.data; + } + + const message = `Failed to parse collection ${id} response: ${JSON.stringify(parsedResponse.error)} (was ${JSON.stringify(response.data)})`; + logger.error(message); + throw new Error(message); +} From 69e082649c761fc143969e521838df5f060c2ed8 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 27 Jan 2026 12:32:58 +0100 Subject: [PATCH 02/21] initial implementation --- .../wasap/WasapPageStateSelector.tsx | 27 +++++++ .../filters/CollectionAnalysisFilter.tsx | 48 ++++++++++++ .../src/components/views/wasap/WasapPage.tsx | 78 ++++++++++++------- .../views/wasap/useWasapPageData.ts | 72 +++++++++++++++-- .../components/views/wasap/wasapPageConfig.ts | 32 +++++++- website/src/types/wastewaterConfig.ts | 6 ++ .../WasapPageStateHandler.spec.ts | 4 +- .../WasapPageStateHandler.ts | 16 ++++ 8 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 62d18f6d..e987419c 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -5,6 +5,7 @@ import { ApplyFilterButton } from '../ApplyFilterButton'; import { DynamicDateFilter } from '../DynamicDateFilter'; import { SelectorHeadline } from '../SelectorHeadline'; import { ExplorationModeInfo } from './InfoBlocks'; +import { CollectionAnalysisFilter } from './filters/CollectionAnalysisFilter'; import { ManualAnalysisFilter } from './filters/ManualAnalysisFilter'; import { ResistanceMutationsFilter } from './filters/ResistanceMutationsFilter'; import { UntrackedFilter } from './filters/UntrackedFilter'; @@ -54,6 +55,8 @@ export function WasapPageStateSelector({ setResistanceFilter, untrackedFilter, setUntrackedFilter, + collectionFilter, + setCollectionFilter, } = useAnalysisFilterStates(initialAnalysisFilterState, config); const [selectedAnalysisMode, setSelectedAnalysisMode] = useState(initialAnalysisFilterState.mode); @@ -71,6 +74,8 @@ export function WasapPageStateSelector({ return { base: baseFilterState, analysis: resistanceFilter! }; case 'untracked': return { base: baseFilterState, analysis: untrackedFilter! }; + case 'collection': + return { base: baseFilterState, analysis: collectionFilter! }; } /* eslint-enable @typescript-eslint/no-non-null-assertion */ } @@ -200,6 +205,17 @@ export function WasapPageStateSelector({ cladeLineageQueryResult={cladeLineageQueryResult} /> ); + case 'collection': + if (!config.collectionAnalysisModeEnabled || collectionFilter === undefined) { + throw Error("'collection' mode selected, but it isn't enabled."); + } + return ( + + ); } })()} @@ -222,6 +238,8 @@ function modeLabel(mode: WasapAnalysisMode): string { return 'Variant Explorer'; case 'untracked': return 'Untracked Mutations'; + case 'collection': + return 'Collection'; } } @@ -259,6 +277,13 @@ function useAnalysisFilterStates(initialFilter: WasapAnalysisFilter, config: Was ? config.filterDefaults.untracked : undefined, ); + const [collectionFilter, setCollectionFilter] = useState( + initialFilter.mode === 'collection' + ? initialFilter + : config.collectionAnalysisModeEnabled + ? config.filterDefaults.collection + : undefined, + ); return { manualFilter, @@ -269,5 +294,7 @@ function useAnalysisFilterStates(initialFilter: WasapAnalysisFilter, config: Was setResistanceFilter, untrackedFilter, setUntrackedFilter, + collectionFilter, + setCollectionFilter, }; } diff --git a/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx new file mode 100644 index 00000000..0ba402da --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getCollections } from '../../../../covspectrum/getCollections'; +import type { WasapCollectionFilter } from '../../../views/wasap/wasapPageConfig'; +import { LabeledField } from '../utils/LabeledField'; + +export function CollectionAnalysisFilter({ + pageState, + setPageState, + collectionsApiBaseUrl, +}: { + pageState: WasapCollectionFilter; + setPageState: (newState: WasapCollectionFilter) => void; + collectionsApiBaseUrl: string; +}) { + const { data: collections, isPending, isError } = useQuery({ + queryKey: ['collections', collectionsApiBaseUrl], + queryFn: () => getCollections(collectionsApiBaseUrl), + }); + + return ( + + {isPending ? ( +
Loading collections...
+ ) : isError ? ( +
Error loading collections
+ ) : ( + + )} +
+ ); +} diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 296b85ae..5ccfa52f 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -44,8 +44,6 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { // fetch which mutations should be analyzed const { data, isPending, isError } = useWasapPageData(config, analysis); - const displayMutations = data?.displayMutations; - const customColumns = data?.customColumns; let initialMeanProportionInterval: MeanProportionInterval = { min: 0.0, max: 1.0 }; if (analysis.mode === 'manual' && analysis.mutations === undefined) { @@ -100,39 +98,61 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { /> {isError ? ( - There was an error fetching the mutations to display. + There was an error fetching the data to display. ) : isPending ? ( - ) : ( + ) : data ? (
- {displayMutations?.length === 0 ? ( - + {data.type === 'mutations' ? ( + <> + {data.displayMutations?.length === 0 ? ( + + ) : ( + + )} + {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( + + )} + + ) : ( - - )} - {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( - +
+

+ Collection: {data.collection.title} +

+

+ Collection display is not yet implemented. This collection contains{' '} + {data.collection.variants.length} variant{data.collection.variants.length !== 1 ? 's' : ''}. +

+
+ {data.collection.variants.map((variant, idx) => ( +
+
{variant.name}
+
{variant.query}
+
+ ))} +
+
)} -
- )} + ) : null} diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index e9a2531d..d28eb74a 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import type { VariantTimeFrame, WasapAnalysisFilter, WasapPageConfig } from './wasapPageConfig'; +import { getCollection } from '../../../covspectrum/getCollection'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; import { getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; @@ -32,7 +33,19 @@ type SelectedWithJaccard = { mutationsWithScore: { mutation: string; jaccardIndex: number }[]; }; -type MutationSelection = AllMutations | SelectedMutations | SelectedWithJaccard; +type CollectionData = { + type: 'collectionData'; + collection: { + id: number; + title: string; + variants: { + name: string; + query: string; + }[]; + }; +}; + +type MutationSelection = AllMutations | SelectedMutations | SelectedWithJaccard | CollectionData; async function fetchMutationSelection( config: WasapPageConfig, @@ -108,6 +121,26 @@ async function fetchMutationSelection( mutations: allMuts.filter((m) => !excludeMutations.includes(m)), }; } + case 'collection': { + if (!config.collectionAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'collection' mode is not enabled."); + } + if (!analysis.collectionId) { + throw Error('No collection selected'); + } + const collection = await getCollection(config.collectionsApiBaseUrl, analysis.collectionId); + return { + type: 'collectionData', + collection: { + id: collection.id, + title: collection.title, + variants: collection.variants.map((v) => ({ + name: v.name, + query: v.query, + })), + }, + }; + } } } @@ -132,32 +165,54 @@ export function getLapisFilterForTimeFrame(timeFrame: VariantTimeFrame, dateFiel } /** - * The W-ASAP page data consists of the mutations to display in the mutations-over-time component, + * The W-ASAP page data can be either mutations data or collection data. + */ +export type WasapPageData = WasapMutationsData | WasapCollectionData; + +/** + * Mutations data consists of the mutations to display in the mutations-over-time component, * and the additional custom columns that might optionally be displayed. * * If displayMutations is undefined, that means that all mutations should be displayed * (That is the default behaviour of the mutations-over-time component). */ -type WasapPageData = { +export type WasapMutationsData = { + type: 'mutations'; displayMutations?: string[]; customColumns?: CustomColumn[]; }; +/** + * Collection data consists of a collection with its variants. + */ +export type WasapCollectionData = { + type: 'collection'; + collection: { + id: number; + title: string; + variants: { + name: string; + query: string; + }[]; + }; +}; + /** * Turns the internal `MutationSelection` into the easier-to-work-with `WasapPageData`. */ function wasapPageDataFromMutationSelection(mutationSelection: MutationSelection | undefined): WasapPageData { if (mutationSelection === undefined) { - return {}; + return { type: 'mutations' }; } switch (mutationSelection.type) { case 'all': - return {}; + return { type: 'mutations' }; case 'selected': - return { displayMutations: mutationSelection.mutations }; + return { type: 'mutations', displayMutations: mutationSelection.mutations }; case 'jaccard': return { + type: 'mutations', displayMutations: mutationSelection.mutationsWithScore.map(({ mutation }) => mutation), customColumns: [ { @@ -171,5 +226,10 @@ function wasapPageDataFromMutationSelection(mutationSelection: MutationSelection }, ], }; + case 'collectionData': + return { + type: 'collection', + collection: mutationSelection.collection, + }; } } diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 1ff43811..00e9bc0b 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -50,7 +50,8 @@ export type WasapPageConfigBase = { type AnalysisModeConfigs = ManualAnalysisModeConfig & VariantAnalysisModeConfig & ResistanceAnalysisModeConfig & - UntrackedAnalyisModeConfig; + UntrackedAnalyisModeConfig & + CollectionAnalysisModeConfig; type ManualAnalysisModeConfig = | { @@ -108,6 +109,18 @@ type UntrackedAnalyisModeConfig = }; }; +type CollectionAnalysisModeConfig = + | { + collectionAnalysisModeEnabled?: never; + } + | { + collectionAnalysisModeEnabled: true; + collectionsApiBaseUrl: string; + filterDefaults: { + collection: WasapCollectionFilter; + }; + }; + /** * Convenience function to get the list of enabled modes. */ @@ -125,6 +138,9 @@ export function enabledAnalysisModes(config: WasapPageConfig): WasapAnalysisMode if (config.untrackedAnalysisModeEnabled) { result.push('untracked'); } + if (config.collectionAnalysisModeEnabled) { + result.push('collection'); + } return result; } @@ -137,7 +153,7 @@ export type LinkTemplate = { aminoAcidMutation: string; }; -export type WasapAnalysisMode = 'manual' | 'variant' | 'resistance' | 'untracked'; +export type WasapAnalysisMode = 'manual' | 'variant' | 'resistance' | 'untracked' | 'collection'; /** * Contains mode-independent settings, like the filter for location and date range. @@ -203,7 +219,17 @@ export type WasapUntrackedFilter = { excludeVariants?: string[]; }; -export type WasapAnalysisFilter = WasapManualFilter | WasapVariantFilter | WasapResistanceFilter | WasapUntrackedFilter; +export type WasapCollectionFilter = { + mode: 'collection'; + collectionId?: number; +}; + +export type WasapAnalysisFilter = + | WasapManualFilter + | WasapVariantFilter + | WasapResistanceFilter + | WasapUntrackedFilter + | WasapCollectionFilter; export type WasapFilter = { base: WasapBaseFilter; diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 1cbc8f9b..487f250c 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -27,6 +27,7 @@ export const wastewaterOrganismConfigs: Record { expect(filter.base.locationName).toBe('Zürich (ZH)'); expect(filter.base.granularity).toBe('day'); expect(filter.analysis.mode).toBe('manual'); - expect(filter.analysis.sequenceType).toBe('nucleotide'); + if (filter.analysis.mode === 'manual') { + expect(filter.analysis.sequenceType).toBe('nucleotide'); + } const newUrl = handler.toUrl(filter); expect(newUrl).toBe(url); diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts index d16b8e31..c1b98d57 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts @@ -91,6 +91,15 @@ export class WasapPageStateHandler implements PageStateHandler { excludeVariants: texts.excludeVariants?.split('|'), }; break; + case 'collection': + if (!this.config.collectionAnalysisModeEnabled) { + throw Error("The 'collection' analysis mode is not enabled."); + } + analysis = { + mode, + collectionId: texts.collectionId ? Number(texts.collectionId) : undefined, + }; + break; } const base: WasapBaseFilter = { @@ -142,6 +151,9 @@ export class WasapPageStateHandler implements PageStateHandler { setSearchFromString(search, 'excludeVariants', analysis.excludeVariants?.join('|')); } break; + case 'collection': + setSearchFromString(search, 'collectionId', analysis.collectionId ? String(analysis.collectionId) : undefined); + break; } return formatUrl(this.config.path, search); @@ -216,5 +228,9 @@ function generateWasapFilterConfig(pageConfig: WasapPageConfig): BaselineFilterC type: 'text', lapisField: 'excludeVariants', }, + { + type: 'text', + lapisField: 'collectionId', + }, ]; } From 95703273c55bec833dc284a26ff1b9277c3c057a Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 28 Jan 2026 10:02:09 +0100 Subject: [PATCH 03/21] format --- website/eslint.config.js | 4 +++- .../filters/CollectionAnalysisFilter.tsx | 10 +++++++--- .../src/components/views/wasap/WasapPage.tsx | 19 +++++++++++-------- .../WasapPageStateHandler.ts | 6 +++++- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/website/eslint.config.js b/website/eslint.config.js index 6079dcbf..70326f6f 100644 --- a/website/eslint.config.js +++ b/website/eslint.config.js @@ -106,7 +106,9 @@ const disableFromReact = { export default tseslint.config( { - ignores: ['**/.astro/content.d.ts'], + ignores: ['**/.astro/content.d.ts', 'dist/**', '.astro/**', 'node_modules/**'], + }, + { files: ['**/*.ts', '**/*.tsx'], extends: [ eslint.configs.recommended, diff --git a/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx index 0ba402da..d7048ac4 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx @@ -13,7 +13,11 @@ export function CollectionAnalysisFilter({ setPageState: (newState: WasapCollectionFilter) => void; collectionsApiBaseUrl: string; }) { - const { data: collections, isPending, isError } = useQuery({ + const { + data: collections, + isPending, + isError, + } = useQuery({ queryKey: ['collections', collectionsApiBaseUrl], queryFn: () => getCollections(collectionsApiBaseUrl), }); @@ -23,7 +27,7 @@ export function CollectionAnalysisFilter({ {isPending ? (
Loading collections...
) : isError ? ( -
Error loading collections
+
Error loading collections
) : (