Skip to content
Open
4 changes: 3 additions & 1 deletion website/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ const disableFromReact = {

export default tseslint.config(
{
ignores: ['**/.astro/content.d.ts'],
ignores: ['**/.astro/content.d.ts', 'dist/**', 'node_modules/**'],
},
{
files: ['**/*.ts', '**/*.tsx'],
extends: [
eslint.configs.recommended,
Expand Down
21 changes: 21 additions & 0 deletions website/routeMocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SetupServer } from 'msw/node';
import { expect } from 'vitest';

import { type OrganismsConfig } from './src/config';
import type { CollectionRaw } from './src/covspectrum/types.ts';
import type { LapisInfo } from './src/lapis/getLastUpdatedDate.ts';
import type { ProblemDetail } from './src/types/ProblemDetail.ts';
import type {
Expand Down Expand Up @@ -45,6 +46,26 @@ type MockCase = {
requestParam?: Record<string, string>;
};

export class CovSpectrumRouteMocker {
constructor(private workerOrServer: MSWWorkerOrServer) {}

mockGetCollections(baseUrl: string, response: CollectionRaw[], statusCode = 200) {
this.workerOrServer.use(
http.get(`${baseUrl}/resource/collection`, () => {
return new Response(JSON.stringify(response), { status: statusCode });
}),
);
}

mockGetCollection(baseUrl: string, id: number, response: CollectionRaw, statusCode = 200) {
this.workerOrServer.use(
http.get(`${baseUrl}/resource/collection/${id}`, () => {
return new Response(JSON.stringify(response), { status: statusCode });
}),
);
}
}

/**
* Allows you to mock LAPIS API routes.
* By default, the host is DUMMY_LAPIS_URL, so it's best if you use that as the lapisUrl in your tests.
Expand Down
59 changes: 59 additions & 0 deletions website/src/components/genspectrum/GsQueriesOverTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type {
CustomColumn,
LapisFilter,
MeanProportionInterval,
QueryDefinition,
TemporalGranularity,
} from '@genspectrum/dashboard-components/util';
import { type FC } from 'react';

import { ComponentWrapper } from '../ComponentWrapper';

export type GsQueriesOverTimeProps = {
collectionTitle?: string;
lapisFilter: LapisFilter;
queries: QueryDefinition[];
granularity: TemporalGranularity;
lapisDateField: string;
height?: string;
pageSizes?: number[];
hideGaps?: true;
initialMeanProportionInterval?: MeanProportionInterval;
customColumns?: CustomColumn[];
};

export const GsQueriesOverTime: FC<GsQueriesOverTimeProps> = ({
collectionTitle,
lapisFilter,
queries,
granularity,
lapisDateField,
height,
pageSizes,
hideGaps,
initialMeanProportionInterval,
customColumns,
}) => {
return (
<ComponentWrapper
title={'Collection over time' + (collectionTitle ? `: ${collectionTitle}` : '')}
height={height}
>
<gs-queries-over-time
width='100%'
height={height ? '100%' : undefined}
lapisFilter={JSON.stringify(lapisFilter)}
queries={JSON.stringify(queries)}
views='["grid"]'
granularity={granularity}
lapisDateField={lapisDateField}
hideGaps={hideGaps}
pageSizes={JSON.stringify(pageSizes ?? [10, 20, 30, 40, 50])}
initialMeanProportionInterval={
initialMeanProportionInterval ? JSON.stringify(initialMeanProportionInterval) : undefined
}
customColumns={customColumns ? JSON.stringify(customColumns) : undefined}
></gs-queries-over-time>
</ComponentWrapper>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,6 +55,8 @@ export function WasapPageStateSelector({
setResistanceFilter,
untrackedFilter,
setUntrackedFilter,
collectionFilter,
setCollectionFilter,
} = useAnalysisFilterStates(initialAnalysisFilterState, config);

const [selectedAnalysisMode, setSelectedAnalysisMode] = useState(initialAnalysisFilterState.mode);
Expand All @@ -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 */
}
Expand Down Expand Up @@ -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 (
<CollectionAnalysisFilter
pageState={collectionFilter}
setPageState={setCollectionFilter}
collectionsApiBaseUrl={config.collectionsApiBaseUrl}
/>
);
}
})()}
</Inset>
Expand All @@ -222,6 +238,8 @@ function modeLabel(mode: WasapAnalysisMode): string {
return 'Variant Explorer';
case 'untracked':
return 'Untracked Mutations';
case 'collection':
return 'Collection';
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -269,5 +294,7 @@ function useAnalysisFilterStates(initialFilter: WasapAnalysisFilter, config: Was
setResistanceFilter,
untrackedFilter,
setUntrackedFilter,
collectionFilter,
setCollectionFilter,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, expect, vi } from 'vitest';
import { render } from 'vitest-browser-react';

import { CollectionAnalysisFilter } from './CollectionAnalysisFilter';
import { it } from '../../../../../test-extend';
import type { CollectionRaw } from '../../../../covspectrum/types';
import type { WasapCollectionFilter } from '../../../views/wasap/wasapPageConfig';

const DUMMY_COV_SPECTRUM_URL = 'https://cov-spectrum-dummy.com/api';

const mockCollections: CollectionRaw[] = [
{
id: 42,
title: '3CLpro',
description: 'Test collection',
maintainers: 'Test',
email: 'test@example.com',
variants: [{ query: '{}', name: 'Variant 1', description: 'Desc 1', highlighted: false }],
},
{
id: 99,
title: 'RdRp',
description: 'Another collection',
maintainers: 'Test',
email: 'test@example.com',
variants: [],
},
];

const queryClient = new QueryClient();

describe('CollectionAnalysisFilter', () => {
const defaultPageState: WasapCollectionFilter = {
mode: 'collection',
collectionId: 42,
};

it('renders with initial collection set', async ({ routeMockers: { covSpectrum } }) => {
const mockSetPageState = vi.fn();

covSpectrum.mockGetCollections(DUMMY_COV_SPECTRUM_URL, mockCollections);

const { getByRole } = render(
<QueryClientProvider client={queryClient}>
<CollectionAnalysisFilter
pageState={defaultPageState}
setPageState={mockSetPageState}
collectionsApiBaseUrl={DUMMY_COV_SPECTRUM_URL}
/>
</QueryClientProvider>,
);

const select = getByRole('combobox');
await expect.element(select).toHaveValue('42'); // collectionId from defaultPageState
});

it('calls setPageState when selecting a different collection', async ({ routeMockers: { covSpectrum } }) => {
const mockSetPageState = vi.fn();

covSpectrum.mockGetCollections(DUMMY_COV_SPECTRUM_URL, mockCollections);

const { getByRole } = render(
<QueryClientProvider client={queryClient}>
<CollectionAnalysisFilter
pageState={defaultPageState}
setPageState={mockSetPageState}
collectionsApiBaseUrl={DUMMY_COV_SPECTRUM_URL}
/>
</QueryClientProvider>,
);

const select = getByRole('combobox');
await select.selectOptions('99'); // Select RdRp collection

expect(mockSetPageState).toHaveBeenCalledWith({
...defaultPageState,
collectionId: 99,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 (
<LabeledField label='Collection'>
{isPending ? (
<div className='text-sm text-gray-500'>Loading collections...</div>
) : isError ? (
<div className='text-error text-sm'>Error loading collections</div>
) : (
<select
className='select select-bordered'
value={pageState.collectionId ?? ''}
onChange={(e) =>
setPageState({
...pageState,
collectionId: e.target.value ? Number(e.target.value) : undefined,
})
}
>
<option value=''>Select a collection...</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
#{collection.id} {collection.title}
</option>
))}
</select>
)}
</LabeledField>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod';

import { getClientLogger } from '../../../clientLogger.ts';
import { getCollections } from '../../../covspectrum/getCollections.ts';
import { type Collection, type CollectionVariant } from '../../../covspectrum/types.ts';
import { type CollectionRaw, type CollectionVariantRaw } from '../../../covspectrum/types.ts';
import { type CovidVariantData } from '../../../views/covid.ts';
import { useErrorToast } from '../../ErrorReportInstruction.tsx';

Expand Down Expand Up @@ -46,7 +46,7 @@ export function CollectionsList({ initialCollectionId, pageState, setPageState }
}

type CollectionSelectorProps = {
collections: Collection[];
collections: CollectionRaw[];
selectedId: number;
onSelect: (index: number) => void;
};
Expand Down Expand Up @@ -78,7 +78,7 @@ const querySchema = z.object({
});

type CollectionVariantListProps = {
collection: Collection;
collection: CollectionRaw;
pageState: CovidVariantData;
setPageState: Dispatch<SetStateAction<CovidVariantData>>;
};
Expand All @@ -102,7 +102,7 @@ function CollectionVariantList({ collection, pageState, setPageState }: Collecti
}

type VariantLinkProps = {
variant: CollectionVariant;
variant: CollectionVariantRaw;
collectionId: number;
pageState: CovidVariantData;
setPageState: Dispatch<SetStateAction<CovidVariantData>>;
Expand Down Expand Up @@ -130,7 +130,7 @@ const logger = getClientLogger('CollectionList');
function useVariantLinkPageState(
currentPageState: CovidVariantData,
collectionId: number,
variant: CollectionVariant,
variant: CollectionVariantRaw,
): CovidVariantData | undefined {
const { showErrorToast } = useErrorToast(logger);

Expand Down
4 changes: 3 additions & 1 deletion website/src/components/views/wasap/Wasap.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import { WasapPage } from './WasapPage';
import { isStaging } from '../../../config';
import BaseLayout from '../../../layouts/base/BaseLayout.astro';
import { wastewaterOrganismConfigs, type WastewaterOrganismName } from '../../../types/wastewaterConfig';

Expand All @@ -9,8 +10,9 @@ type Props = {

const { wastewaterOrganism } = Astro.props;
const { name } = wastewaterOrganismConfigs[wastewaterOrganism];
const staging = isStaging();
---

<BaseLayout title={`Swiss wastewater - ${name}`}>
<WasapPage wastewaterOrganism={wastewaterOrganism} client:only='react' />
<WasapPage wastewaterOrganism={wastewaterOrganism} isStaging={staging} client:only='react' />
</BaseLayout>
Loading
Loading