From 4adeee1d79f653a6d3770de10f3cdcc4032501cf Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Mon, 23 Mar 2026 13:09:43 +0000 Subject: [PATCH 1/8] Add collection detail view - Add viewCollection route to pages.ts - Add clickable rows to CollectionsOverview linking to detail page - Add CollectionDetail component (read-only, shows variants) - Add [organism]/[id]/index.astro page with breadcrumbs and session-based userId Co-Authored-By: Claude Sonnet 4.6 Use plain h2 for variants section in CollectionDetail Co-Authored-By: Claude Sonnet 4.6 --- .../collections/detail/CollectionDetail.tsx | 142 ++++++++++++++++++ .../overview/CollectionsOverview.tsx | 35 +++-- .../collections/[organism]/[id]/index.astro | 40 +++++ website/src/types/pages.ts | 1 + 4 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 website/src/components/collections/detail/CollectionDetail.tsx create mode 100644 website/src/pages/collections/[organism]/[id]/index.astro diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx new file mode 100644 index 00000000..e9226144 --- /dev/null +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -0,0 +1,142 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getBackendServiceForClientside } from '../../../backendApi/backendService.ts'; +import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx'; +import { getClientLogger } from '../../../clientLogger.ts'; +import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; +import type { Collection, Variant } from '../../../types/Collection.ts'; +import type { Organism } from '../../../types/Organism.ts'; +import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts'; + +export const CollectionDetail = withQueryProvider(CollectionDetailInner); + +const logger = getClientLogger('CollectionDetail'); + +function CollectionDetailInner({ id }: { organism: Organism; id: string; userId?: string }) { + const { + isLoading, + isError, + data: collection, + error, + } = useQuery({ + queryKey: ['collection', id], + queryFn: () => getBackendServiceForClientside().getCollection({ id }), + }); + + if (isLoading) { + return ; + } + + if (isError) { + logger.error(`Failed to fetch collection: ${getErrorLogMessage(error)}`); + return
Failed to load collection. Please try reloading the page.
; + } + + if (collection === undefined) { + return null; + } + + return ( +
+
+ + #{collection.id} + {collection.name} + + {collection.description !== null &&

{collection.description}

} +

+ {collection.organism} collection owned by {collection.ownedBy} +

+
+ + +
+ ); +} + +function VariantsCard({ collection }: { collection: Collection }) { + return ( +
+

Variants ({collection.variants.length})

+ {collection.variants.length === 0 ? ( +

No variants defined.

+ ) : ( +
+ {collection.variants.map((variant) => ( + + ))} +
+ )} +
+ ); +} + +function VariantCard({ variant }: { variant: Variant }) { + return ( +
+
+ {variant.name} + + {variant.type === 'query' ? 'Query' : 'Mutation list'} + +
+ {variant.description !== null &&

{variant.description}

} + {variant.type === 'query' ? ( + + ) : ( + + )} +
+ ); +} + +function QueryVariantDetails({ variant }: { variant: Extract }) { + return ( +
+
Count query
+
{variant.countQuery}
+ {variant.coverageQuery !== null && ( + <> +
Coverage query
+
{variant.coverageQuery}
+ + )} +
+ ); +} + +function FilterObjectVariantDetails({ variant }: { variant: Extract }) { + const fields: { key: keyof typeof variant.filterObject; label: string }[] = [ + { key: 'aminoAcidMutations', label: 'AA mutations' }, + { key: 'nucleotideMutations', label: 'Nucleotide mutations' }, + { key: 'aminoAcidInsertions', label: 'AA insertions' }, + { key: 'nucleotideInsertions', label: 'Nucleotide insertions' }, + ]; + + const presentFields = fields.filter(({ key }) => { + const val = variant.filterObject[key]; + return Array.isArray(val) && val.length > 0; + }); + + if (presentFields.length === 0) { + return

No mutations defined.

; + } + + return ( +
+ {presentFields.map(({ key, label }) => { + const val = variant.filterObject[key]; + return ( + <> +
+ {label} +
+
+ {Array.isArray(val) ? val.join(', ') : ''} +
+ + ); + })} +
+ ); +} diff --git a/website/src/components/collections/overview/CollectionsOverview.tsx b/website/src/components/collections/overview/CollectionsOverview.tsx index 207446df..ab394b18 100644 --- a/website/src/components/collections/overview/CollectionsOverview.tsx +++ b/website/src/components/collections/overview/CollectionsOverview.tsx @@ -6,6 +6,7 @@ import { getClientLogger } from '../../../clientLogger.ts'; import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; import type { Collection } from '../../../types/Collection.ts'; import { organismConfig, type Organism } from '../../../types/Organism.ts'; +import { Page } from '../../../types/pages.ts'; import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts'; export const CollectionsOverview = withQueryProvider(CollectionsOverviewInner); @@ -37,13 +38,13 @@ function CollectionsOverviewInner({ organism, isLoggedIn: _isLoggedIn }: { organ ) : collections === undefined || collections.length === 0 ? (
No collections yet.
) : ( - + )} ); } -function CollectionsTable({ collections }: { collections: Collection[] }) { +function CollectionsTable({ collections, organism }: { collections: Collection[]; organism: Organism }) { return (
@@ -57,21 +58,23 @@ function CollectionsTable({ collections }: { collections: Collection[] }) { {collections.map((collection) => ( - - - - + + + + - + + )} + + + ))} diff --git a/website/src/pages/collections/[organism]/[id]/index.astro b/website/src/pages/collections/[organism]/[id]/index.astro new file mode 100644 index 00000000..883cf2e1 --- /dev/null +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -0,0 +1,40 @@ +--- +import { getSession } from 'auth-astro/server'; + +import { CollectionDetail } from '../../../../components/collections/detail/CollectionDetail'; +import { defaultBreadcrumbs } from '../../../../layouts/Breadcrumbs'; +import ContaineredPageLayout from '../../../../layouts/ContaineredPage/ContaineredPageLayout.astro'; +import { organismConfig, organismSchema } from '../../../../types/Organism'; +import { Page } from '../../../../types/pages'; + +const { organism, id } = Astro.params; + +const parsedOrganism = organismSchema.safeParse(organism); +if (!parsedOrganism.success) { + return Astro.redirect('/404'); +} + +if (id === undefined) { + return Astro.redirect('/404'); +} + +const orgConfig = organismConfig[parsedOrganism.data]; +const session = await getSession(Astro.request); +--- + + + + diff --git a/website/src/types/pages.ts b/website/src/types/pages.ts index a3e48b27..d7e473ee 100644 --- a/website/src/types/pages.ts +++ b/website/src/types/pages.ts @@ -6,4 +6,5 @@ export const Page = { dataSources: '/data', collectionsOverview: '/collections', collectionsForOrganism: (organism: Organism) => `/collections/${organism}`, + viewCollection: (organism: Organism, id: string) => `/collections/${organism}/${id}`, } as const; From 996acf95fd1d7d398b43c45add123a030dc8d9ee Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 24 Mar 2026 11:18:32 +0100 Subject: [PATCH 2/8] Improve collection detail view UI and add browser tests - Show organism label (e.g. 'SARS-CoV-2') instead of raw key in detail page - Render lineage fields (catchall string entries) in filter object variants - Remove variant type badge since the type is implied by the shown properties - Use 'Collection #' for page title and breadcrumb - Add browser spec for CollectionDetail covering success and error states - Add mockGetCollection to AstroApiRouteMocker Co-Authored-By: Claude Sonnet 4.6 --- website/routeMocker.ts | 6 ++ .../detail/CollectionDetail.browser.spec.tsx | 68 +++++++++++++++++++ .../collections/detail/CollectionDetail.tsx | 35 +++++++--- .../collections/[organism]/[id]/index.astro | 4 +- 4 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 website/src/components/collections/detail/CollectionDetail.browser.spec.tsx diff --git a/website/routeMocker.ts b/website/routeMocker.ts index 0c4e7adc..75917db5 100644 --- a/website/routeMocker.ts +++ b/website/routeMocker.ts @@ -38,6 +38,12 @@ export class AstroApiRouteMocker { ), ); } + + mockGetCollection(id: string, response: Collection, statusCode = 200) { + this.workerOrServer.use( + http.get(`${ASTRO_SERVER_URL}/collections/${id}`, resolver([{ statusCode, response }])), + ); + } } type ReferenceSequence = { name: string; sequence: string }; diff --git a/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx b/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx new file mode 100644 index 00000000..9a15b5b1 --- /dev/null +++ b/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx @@ -0,0 +1,68 @@ +import { describe, expect } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { CollectionDetail } from './CollectionDetail'; +import { it } from '../../../../test-extend'; +import { Organisms } from '../../../types/Organism'; +import type { FilterObject } from '../../../types/Collection'; + +const ORGANISM = Organisms.covid; +const LINEAGE_FIELD = 'pangoLineage'; + +const mockCollection = { + id: 1, + name: 'My first collection', + ownedBy: 'user1', + organism: ORGANISM, + description: 'A test collection', + variants: [ + { + type: 'query' as const, + id: 10, + collectionId: 1, + name: 'Alpha', + description: 'A query-based variant', + countQuery: 'variantQuery=ABC', + coverageQuery: null, + }, + { + type: 'filterObject' as const, + id: 11, + collectionId: 1, + name: 'Beta', + description: 'A filter-object variant', + filterObject: { + nucleotideMutations: ['A123T', 'G456C'], + [LINEAGE_FIELD]: 'A.B.1' + } as unknown as FilterObject, + }, + ], +}; + +describe('CollectionDetail', () => { + it('shows the collection name and variants on success', async ({ routeMockers: { astro } }) => { + astro.mockGetCollection('1', mockCollection); + + const { getByText } = render(); + + await expect.element(getByText('My first collection')).toBeVisible(); + await expect.element(getByText('A test collection')).toBeVisible(); + await expect.element(getByText('SARS-CoV-2 collection owned by user1')).toBeVisible(); + await expect.element(getByText('Alpha')).toBeVisible(); + await expect.element(getByText('A query-based variant')).toBeVisible(); + await expect.element(getByText('variantQuery=ABC')).toBeVisible(); + await expect.element(getByText('Beta')).toBeVisible(); + await expect.element(getByText('A filter-object variant')).toBeVisible(); + await expect.element(getByText('A123T, G456C')).toBeVisible(); + await expect.element(getByText('A.B.1')).toBeVisible(); + }); + + it('shows an error message when the fetch fails', async ({ routeMockers: { astro } }) => { + astro.mockGetCollection('1', mockCollection, 500); + astro.mockLog(); + + const { getByText } = render(); + + await expect.element(getByText('Failed to load collection. Please try reloading the page.')).toBeVisible(); + }); +}); diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx index e9226144..b1171dab 100644 --- a/website/src/components/collections/detail/CollectionDetail.tsx +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -5,7 +5,7 @@ import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx'; import { getClientLogger } from '../../../clientLogger.ts'; import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; import type { Collection, Variant } from '../../../types/Collection.ts'; -import type { Organism } from '../../../types/Organism.ts'; +import { organismConfig, type Organism } from '../../../types/Organism.ts'; import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts'; export const CollectionDetail = withQueryProvider(CollectionDetailInner); @@ -36,6 +36,8 @@ function CollectionDetailInner({ id }: { organism: Organism; id: string; userId? return null; } + const organismName = organismConfig[collection.organism as Organism].label; + return (
@@ -45,7 +47,7 @@ function CollectionDetailInner({ id }: { organism: Organism; id: string; userId? {collection.description !== null &&

{collection.description}

}

- {collection.organism} collection owned by {collection.ownedBy} + {organismName} collection owned by {collection.ownedBy}

@@ -74,11 +76,8 @@ function VariantsCard({ collection }: { collection: Collection }) { function VariantCard({ variant }: { variant: Variant }) { return (
-
+
{variant.name} - - {variant.type === 'query' ? 'Query' : 'Mutation list'} -
{variant.description !== null &&

{variant.description}

} {variant.type === 'query' ? ( @@ -105,26 +104,32 @@ function QueryVariantDetails({ variant }: { variant: Extract }) { - const fields: { key: keyof typeof variant.filterObject; label: string }[] = [ + const arrayFields: { key: (typeof KNOWN_FILTER_OBJECT_KEYS)[number]; label: string }[] = [ { key: 'aminoAcidMutations', label: 'AA mutations' }, { key: 'nucleotideMutations', label: 'Nucleotide mutations' }, { key: 'aminoAcidInsertions', label: 'AA insertions' }, { key: 'nucleotideInsertions', label: 'Nucleotide insertions' }, ]; - const presentFields = fields.filter(({ key }) => { + const presentArrayFields = arrayFields.filter(({ key }) => { const val = variant.filterObject[key]; return Array.isArray(val) && val.length > 0; }); - if (presentFields.length === 0) { + const lineageFields = Object.entries(variant.filterObject).filter( + ([key]) => !(KNOWN_FILTER_OBJECT_KEYS as readonly string[]).includes(key), + ) as [string, string][]; + + if (presentArrayFields.length === 0 && lineageFields.length === 0) { return

No mutations defined.

; } return (
- {presentFields.map(({ key, label }) => { + {presentArrayFields.map(({ key, label }) => { const val = variant.filterObject[key]; return ( <> @@ -137,6 +142,16 @@ function FilterObjectVariantDetails({ variant }: { variant: Extract ); })} + {lineageFields.map(([key, val]) => ( + <> +
+ {key} +
+
+ {val} +
+ + ))}
); } diff --git a/website/src/pages/collections/[organism]/[id]/index.astro b/website/src/pages/collections/[organism]/[id]/index.astro index 883cf2e1..7572747a 100644 --- a/website/src/pages/collections/[organism]/[id]/index.astro +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -23,12 +23,12 @@ const session = await getSession(Astro.request); --- Date: Tue, 24 Mar 2026 14:42:49 +0100 Subject: [PATCH 3/8] refactor(collections): fetch collection server-side in detail page Move data fetching from client-side React (useQuery) to Astro frontmatter, enabling the real collection name in the title/breadcrumb at render time and eliminating the loading spinner. 404s redirect; other errors show an inline message. CollectionDetail becomes a pure renderer accepting a collection prop. Co-Authored-By: Claude Sonnet 4.6 --- .../detail/CollectionDetail.browser.spec.tsx | 22 ++---- .../collections/detail/CollectionDetail.tsx | 72 +++++-------------- .../collections/[organism]/[id]/index.astro | 43 ++++++++--- 3 files changed, 56 insertions(+), 81 deletions(-) diff --git a/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx b/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx index 9a15b5b1..c8f6d2e1 100644 --- a/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx +++ b/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx @@ -1,10 +1,9 @@ -import { describe, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-react'; import { CollectionDetail } from './CollectionDetail'; -import { it } from '../../../../test-extend'; -import { Organisms } from '../../../types/Organism'; import type { FilterObject } from '../../../types/Collection'; +import { Organisms } from '../../../types/Organism'; const ORGANISM = Organisms.covid; const LINEAGE_FIELD = 'pangoLineage'; @@ -33,17 +32,15 @@ const mockCollection = { description: 'A filter-object variant', filterObject: { nucleotideMutations: ['A123T', 'G456C'], - [LINEAGE_FIELD]: 'A.B.1' + [LINEAGE_FIELD]: 'A.B.1', } as unknown as FilterObject, }, ], }; describe('CollectionDetail', () => { - it('shows the collection name and variants on success', async ({ routeMockers: { astro } }) => { - astro.mockGetCollection('1', mockCollection); - - const { getByText } = render(); + it('shows the collection name and variants on success', async () => { + const { getByText } = render(); await expect.element(getByText('My first collection')).toBeVisible(); await expect.element(getByText('A test collection')).toBeVisible(); @@ -56,13 +53,4 @@ describe('CollectionDetail', () => { await expect.element(getByText('A123T, G456C')).toBeVisible(); await expect.element(getByText('A.B.1')).toBeVisible(); }); - - it('shows an error message when the fetch fails', async ({ routeMockers: { astro } }) => { - astro.mockGetCollection('1', mockCollection, 500); - astro.mockLog(); - - const { getByText } = render(); - - await expect.element(getByText('Failed to load collection. Please try reloading the page.')).toBeVisible(); - }); }); diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx index b1171dab..1d81da50 100644 --- a/website/src/components/collections/detail/CollectionDetail.tsx +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -1,41 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; - -import { getBackendServiceForClientside } from '../../../backendApi/backendService.ts'; -import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx'; -import { getClientLogger } from '../../../clientLogger.ts'; import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; import type { Collection, Variant } from '../../../types/Collection.ts'; import { organismConfig, type Organism } from '../../../types/Organism.ts'; -import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts'; - -export const CollectionDetail = withQueryProvider(CollectionDetailInner); - -const logger = getClientLogger('CollectionDetail'); - -function CollectionDetailInner({ id }: { organism: Organism; id: string; userId?: string }) { - const { - isLoading, - isError, - data: collection, - error, - } = useQuery({ - queryKey: ['collection', id], - queryFn: () => getBackendServiceForClientside().getCollection({ id }), - }); - - if (isLoading) { - return ; - } - - if (isError) { - logger.error(`Failed to fetch collection: ${getErrorLogMessage(error)}`); - return
Failed to load collection. Please try reloading the page.
; - } - - if (collection === undefined) { - return null; - } +export function CollectionDetail({ collection }: { collection: Collection }) { const organismName = organismConfig[collection.organism as Organism].label; return ( @@ -51,24 +18,18 @@ function CollectionDetailInner({ id }: { organism: Organism; id: string; userId?

- -
- ); -} - -function VariantsCard({ collection }: { collection: Collection }) { - return ( -
-

Variants ({collection.variants.length})

- {collection.variants.length === 0 ? ( -

No variants defined.

- ) : ( -
- {collection.variants.map((variant) => ( - - ))} -
- )} +
+

Variants ({collection.variants.length})

+ {collection.variants.length === 0 ? ( +

No variants defined.

+ ) : ( +
+ {collection.variants.map((variant) => ( + + ))} +
+ )} +
); } @@ -104,7 +65,12 @@ function QueryVariantDetails({ variant }: { variant: Extract }) { const arrayFields: { key: (typeof KNOWN_FILTER_OBJECT_KEYS)[number]; label: string }[] = [ diff --git a/website/src/pages/collections/[organism]/[id]/index.astro b/website/src/pages/collections/[organism]/[id]/index.astro index 7572747a..5142ea01 100644 --- a/website/src/pages/collections/[organism]/[id]/index.astro +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -1,11 +1,16 @@ --- -import { getSession } from 'auth-astro/server'; - +import { BackendService, BackendError } from '../../../../backendApi/backendService.ts'; import { CollectionDetail } from '../../../../components/collections/detail/CollectionDetail'; +import { getBackendHost } from '../../../../config.ts'; import { defaultBreadcrumbs } from '../../../../layouts/Breadcrumbs'; import ContaineredPageLayout from '../../../../layouts/ContaineredPage/ContaineredPageLayout.astro'; +import { getInstanceLogger } from '../../../../logger.ts'; +import type { Collection } from '../../../../types/Collection.ts'; import { organismConfig, organismSchema } from '../../../../types/Organism'; import { Page } from '../../../../types/pages'; +import { getErrorLogMessage } from '../../../../util/getErrorLogMessage.ts'; + +const logger = getInstanceLogger('CollectionDetailPage'); const { organism, id } = Astro.params; @@ -19,22 +24,38 @@ if (id === undefined) { } const orgConfig = organismConfig[parsedOrganism.data]; -const session = await getSession(Astro.request); + +let collection: Collection | undefined; +let fetchError = false; + +try { + collection = await new BackendService(getBackendHost()).getCollection({ id }); +} catch (error) { + if (error instanceof BackendError && error.status === 404) { + return Astro.redirect('/404'); + } + logger.error(`Failed to fetch collection ${id}: ${getErrorLogMessage(error)}`); + fetchError = true; +} + +const collectionTitle = collection?.name ?? `Collection #${id}`; --- - + { + fetchError ? ( +
Failed to load collection. Please try reloading the page.
+ ) : ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + + ) + }
From fb998621c4c4b790dbc2bffd113ba7aa738e9bc4 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 24 Mar 2026 14:49:14 +0100 Subject: [PATCH 4/8] chore(collections): remove mockGetCollection and collection detail browser spec The collection detail is now server-rendered so the browser spec and the astro route mock for fetching a single collection are no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- website/routeMocker.ts | 14 ----- .../detail/CollectionDetail.browser.spec.tsx | 56 ------------------- 2 files changed, 70 deletions(-) delete mode 100644 website/src/components/collections/detail/CollectionDetail.browser.spec.tsx diff --git a/website/routeMocker.ts b/website/routeMocker.ts index 75917db5..b347bc63 100644 --- a/website/routeMocker.ts +++ b/website/routeMocker.ts @@ -38,12 +38,6 @@ export class AstroApiRouteMocker { ), ); } - - mockGetCollection(id: string, response: Collection, statusCode = 200) { - this.workerOrServer.use( - http.get(`${ASTRO_SERVER_URL}/collections/${id}`, resolver([{ statusCode, response }])), - ); - } } type ReferenceSequence = { name: string; sequence: string }; @@ -73,14 +67,6 @@ export class CovSpectrumRouteMocker { }), ); } - - 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 }); - }), - ); - } } /** diff --git a/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx b/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx deleted file mode 100644 index c8f6d2e1..00000000 --- a/website/src/components/collections/detail/CollectionDetail.browser.spec.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render } from 'vitest-browser-react'; - -import { CollectionDetail } from './CollectionDetail'; -import type { FilterObject } from '../../../types/Collection'; -import { Organisms } from '../../../types/Organism'; - -const ORGANISM = Organisms.covid; -const LINEAGE_FIELD = 'pangoLineage'; - -const mockCollection = { - id: 1, - name: 'My first collection', - ownedBy: 'user1', - organism: ORGANISM, - description: 'A test collection', - variants: [ - { - type: 'query' as const, - id: 10, - collectionId: 1, - name: 'Alpha', - description: 'A query-based variant', - countQuery: 'variantQuery=ABC', - coverageQuery: null, - }, - { - type: 'filterObject' as const, - id: 11, - collectionId: 1, - name: 'Beta', - description: 'A filter-object variant', - filterObject: { - nucleotideMutations: ['A123T', 'G456C'], - [LINEAGE_FIELD]: 'A.B.1', - } as unknown as FilterObject, - }, - ], -}; - -describe('CollectionDetail', () => { - it('shows the collection name and variants on success', async () => { - const { getByText } = render(); - - await expect.element(getByText('My first collection')).toBeVisible(); - await expect.element(getByText('A test collection')).toBeVisible(); - await expect.element(getByText('SARS-CoV-2 collection owned by user1')).toBeVisible(); - await expect.element(getByText('Alpha')).toBeVisible(); - await expect.element(getByText('A query-based variant')).toBeVisible(); - await expect.element(getByText('variantQuery=ABC')).toBeVisible(); - await expect.element(getByText('Beta')).toBeVisible(); - await expect.element(getByText('A filter-object variant')).toBeVisible(); - await expect.element(getByText('A123T, G456C')).toBeVisible(); - await expect.element(getByText('A.B.1')).toBeVisible(); - }); -}); From 7c6bd285a34ea437057f42e16ed8c39c750402a1 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 24 Mar 2026 14:53:48 +0100 Subject: [PATCH 5/8] refactor(collections): replace fetchError flag with undefined check Drop the separate fetchError boolean; check collection !== undefined directly in the template, which TypeScript narrows correctly without a non-null assertion. Co-Authored-By: Claude Sonnet 4.6 --- .../src/pages/collections/[organism]/[id]/index.astro | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/website/src/pages/collections/[organism]/[id]/index.astro b/website/src/pages/collections/[organism]/[id]/index.astro index 5142ea01..3dfdfa33 100644 --- a/website/src/pages/collections/[organism]/[id]/index.astro +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -26,7 +26,6 @@ if (id === undefined) { const orgConfig = organismConfig[parsedOrganism.data]; let collection: Collection | undefined; -let fetchError = false; try { collection = await new BackendService(getBackendHost()).getCollection({ id }); @@ -35,7 +34,6 @@ try { return Astro.redirect('/404'); } logger.error(`Failed to fetch collection ${id}: ${getErrorLogMessage(error)}`); - fetchError = true; } const collectionTitle = collection?.name ?? `Collection #${id}`; @@ -51,11 +49,10 @@ const collectionTitle = collection?.name ?? `Collection #${id}`; ]} > { - fetchError ? ( -
Failed to load collection. Please try reloading the page.
+ collection !== undefined ? ( + ) : ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - +
Failed to load collection. Please try reloading the page.
) } From 7032254c08e95636c79d14a9bcace63e22bd17b2 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 24 Mar 2026 15:08:42 +0100 Subject: [PATCH 6/8] refactor(collections): move filter object helpers to Collection.ts Add FILTER_OBJECT_ARRAY_FIELD_LABELS map and getLineageFields() to Collection.ts as the single source of truth for known filter object keys. Simplify FilterObjectVariantDetails to iterate over known fields explicitly, removing the duplicated key/label arrays and the non-null assertion. Co-Authored-By: Claude Sonnet 4.6 --- .../collections/detail/CollectionDetail.tsx | 74 ++++++++++--------- website/src/types/Collection.ts | 12 +++ 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx index 1d81da50..24959c61 100644 --- a/website/src/components/collections/detail/CollectionDetail.tsx +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -1,5 +1,10 @@ import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; -import type { Collection, Variant } from '../../../types/Collection.ts'; +import { + FILTER_OBJECT_ARRAY_FIELD_LABELS, + getLineageFields, + type Collection, + type Variant, +} from '../../../types/Collection.ts'; import { organismConfig, type Organism } from '../../../types/Organism.ts'; export function CollectionDetail({ collection }: { collection: Collection }) { @@ -65,49 +70,48 @@ function QueryVariantDetails({ variant }: { variant: Extract }) { - const arrayFields: { key: (typeof KNOWN_FILTER_OBJECT_KEYS)[number]; label: string }[] = [ - { key: 'aminoAcidMutations', label: 'AA mutations' }, - { key: 'nucleotideMutations', label: 'Nucleotide mutations' }, - { key: 'aminoAcidInsertions', label: 'AA insertions' }, - { key: 'nucleotideInsertions', label: 'Nucleotide insertions' }, - ]; + const { filterObject } = variant; + const lineageFields = getLineageFields(filterObject); - const presentArrayFields = arrayFields.filter(({ key }) => { - const val = variant.filterObject[key]; - return Array.isArray(val) && val.length > 0; - }); + const { aminoAcidMutations, nucleotideMutations, aminoAcidInsertions, nucleotideInsertions } = filterObject; - const lineageFields = Object.entries(variant.filterObject).filter( - ([key]) => !(KNOWN_FILTER_OBJECT_KEYS as readonly string[]).includes(key), - ) as [string, string][]; + const hasArrayFields = + (aminoAcidMutations?.length ?? 0) > 0 || + (nucleotideMutations?.length ?? 0) > 0 || + (aminoAcidInsertions?.length ?? 0) > 0 || + (nucleotideInsertions?.length ?? 0) > 0; - if (presentArrayFields.length === 0 && lineageFields.length === 0) { + if (!hasArrayFields && lineageFields.length === 0) { return

No mutations defined.

; } return (
- {presentArrayFields.map(({ key, label }) => { - const val = variant.filterObject[key]; - return ( - <> -
- {label} -
-
- {Array.isArray(val) ? val.join(', ') : ''} -
- - ); - })} + {aminoAcidMutations !== undefined && aminoAcidMutations.length > 0 && ( + <> +
{FILTER_OBJECT_ARRAY_FIELD_LABELS.aminoAcidMutations}
+
{aminoAcidMutations.join(', ')}
+ + )} + {nucleotideMutations !== undefined && nucleotideMutations.length > 0 && ( + <> +
{FILTER_OBJECT_ARRAY_FIELD_LABELS.nucleotideMutations}
+
{nucleotideMutations.join(', ')}
+ + )} + {aminoAcidInsertions !== undefined && aminoAcidInsertions.length > 0 && ( + <> +
{FILTER_OBJECT_ARRAY_FIELD_LABELS.aminoAcidInsertions}
+
{aminoAcidInsertions.join(', ')}
+ + )} + {nucleotideInsertions !== undefined && nucleotideInsertions.length > 0 && ( + <> +
{FILTER_OBJECT_ARRAY_FIELD_LABELS.nucleotideInsertions}
+
{nucleotideInsertions.join(', ')}
+ + )} {lineageFields.map(([key, val]) => ( <>
diff --git a/website/src/types/Collection.ts b/website/src/types/Collection.ts index 6506e885..01532c9d 100644 --- a/website/src/types/Collection.ts +++ b/website/src/types/Collection.ts @@ -43,6 +43,18 @@ export type Collection = z.infer; export type Variant = z.infer; export type FilterObject = z.infer; +export const FILTER_OBJECT_ARRAY_FIELD_LABELS = { + aminoAcidMutations: 'Amino acid mutations', + nucleotideMutations: 'Nucleotide mutations', + aminoAcidInsertions: 'Amino acid insertions', + nucleotideInsertions: 'Nucleotide insertions', +} as const; + +export function getLineageFields(filterObject: FilterObject): [string, string][] { + const knownKeys = Object.keys(FILTER_OBJECT_ARRAY_FIELD_LABELS); + return Object.entries(filterObject).filter(([key]) => !knownKeys.includes(key)) as [string, string][]; +} + // Request schemas (create) const queryVariantRequestSchema = z.object({ type: z.literal('query'), From d3d9c25984ca36eff231381f0237da506cc5ea8d Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 26 Mar 2026 11:31:07 +0100 Subject: [PATCH 7/8] feat(collections): replace variant cards with table and add LAPIS counts Variants in the collection detail view are now displayed in a table with Name, Description, Query, Total, Last 30d, and Last 90d columns. Counts are fetched client-side from LAPIS using React Query. Co-Authored-By: Claude Sonnet 4.6 --- .../collections/detail/CollectionDetail.tsx | 194 ++++++++++-------- .../collections/[organism]/[id]/index.astro | 5 +- website/src/types/Collection.ts | 8 + 3 files changed, 125 insertions(+), 82 deletions(-) diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx index 24959c61..af69dd93 100644 --- a/website/src/components/collections/detail/CollectionDetail.tsx +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -1,14 +1,30 @@ +import type { LapisFilter } from '@genspectrum/dashboard-components/util'; +import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; + +import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx'; +import { getTotalCount } from '../../../lapis/getTotalCount.ts'; import { PageHeadline } from '../../../styles/containers/PageHeadline.tsx'; import { FILTER_OBJECT_ARRAY_FIELD_LABELS, getLineageFields, + getVariantFilter, type Collection, + type FilterObject, type Variant, } from '../../../types/Collection.ts'; import { organismConfig, type Organism } from '../../../types/Organism.ts'; -export function CollectionDetail({ collection }: { collection: Collection }) { +type LapisConfig = { + url: string; + mainDateField: string; + additionalFilters?: Record; +}; + +function CollectionDetailInner({ collection, lapisConfig }: { collection: Collection; lapisConfig: LapisConfig }) { const organismName = organismConfig[collection.organism as Organism].label; + const dateFrom30 = dayjs().subtract(30, 'day').format('YYYY-MM-DD'); + const dateFrom90 = dayjs().subtract(90, 'day').format('YYYY-MM-DD'); return (
@@ -28,100 +44,118 @@ export function CollectionDetail({ collection }: { collection: Collection }) { {collection.variants.length === 0 ? (

No variants defined.

) : ( -
- {collection.variants.map((variant) => ( - - ))} -
+
{collection.id}{collection.name} - {collection.description ? ( - collection.description.length > 80 ? ( - collection.description.slice(0, 80) + '…' +
{collection.id}{collection.name} + {collection.description ? ( + collection.description.length > 80 ? ( + collection.description.slice(0, 80) + '…' + ) : ( + collection.description + ) ) : ( - collection.description - ) - ) : ( - - )} - {collection.variants.length}{collection.variants.length}
+ + + + + + + + + + + + {collection.variants.map((variant) => ( + + ))} + +
NameDescriptionQueryTotalLast 30dLast 90d
)}
); } -function VariantCard({ variant }: { variant: Variant }) { +export const CollectionDetail = withQueryProvider(CollectionDetailInner); + +function VariantRow({ + variant, + lapisConfig, + dateFrom30, + dateFrom90, +}: { + variant: Variant; + lapisConfig: LapisConfig; + dateFrom30: string; + dateFrom90: string; +}) { + const { url: lapisUrl, mainDateField, additionalFilters } = lapisConfig; + const variantFilter = getVariantFilter(variant); + + const totalQuery = useQuery({ + queryKey: ['variantCount', lapisUrl, variant.id, 'total'], + queryFn: () => getTotalCount(lapisUrl, { ...additionalFilters, ...variantFilter } as LapisFilter), + }); + + const last30Query = useQuery({ + queryKey: ['variantCount', lapisUrl, variant.id, '30d', dateFrom30], + queryFn: () => + getTotalCount(lapisUrl, { + ...additionalFilters, + ...variantFilter, + [`${mainDateField}From`]: dateFrom30, + } as LapisFilter), + }); + + const last90Query = useQuery({ + queryKey: ['variantCount', lapisUrl, variant.id, '90d', dateFrom90], + queryFn: () => + getTotalCount(lapisUrl, { + ...additionalFilters, + ...variantFilter, + [`${mainDateField}From`]: dateFrom90, + } as LapisFilter), + }); + + const queryDisplay = + variant.type === 'query' ? ( + {variant.countQuery} + ) : ( + {formatFilterObjectQuery(variant.filterObject)} + ); + return ( -
-
- {variant.name} -
- {variant.description !== null &&

{variant.description}

} - {variant.type === 'query' ? ( - - ) : ( - - )} -
+ + {variant.name} + {variant.description ?? '—'} + {queryDisplay} + + + + ); } -function QueryVariantDetails({ variant }: { variant: Extract }) { - return ( -
-
Count query
-
{variant.countQuery}
- {variant.coverageQuery !== null && ( - <> -
Coverage query
-
{variant.coverageQuery}
- - )} -
- ); +function CountCell({ isPending, isError, data }: { isPending: boolean; isError: boolean; data?: number }) { + if (isPending) return …; + if (isError) return error; + return {data?.toLocaleString()}; } -function FilterObjectVariantDetails({ variant }: { variant: Extract }) { - const { filterObject } = variant; +function formatFilterObjectQuery(filterObject: FilterObject): string { const lineageFields = getLineageFields(filterObject); + const parts: string[] = []; - const { aminoAcidMutations, nucleotideMutations, aminoAcidInsertions, nucleotideInsertions } = filterObject; - - const hasArrayFields = - (aminoAcidMutations?.length ?? 0) > 0 || - (nucleotideMutations?.length ?? 0) > 0 || - (aminoAcidInsertions?.length ?? 0) > 0 || - (nucleotideInsertions?.length ?? 0) > 0; + for (const [key, val] of lineageFields) { + parts.push(`${key}: ${val}`); + } - if (!hasArrayFields && lineageFields.length === 0) { - return

No mutations defined.

; + const arrayFields = Object.keys( + FILTER_OBJECT_ARRAY_FIELD_LABELS, + ) as (keyof typeof FILTER_OBJECT_ARRAY_FIELD_LABELS)[]; + for (const field of arrayFields) { + const values = filterObject[field]; + if (values && values.length > 0) { + parts.push(values.join(', ')); + } } - return ( -
- {aminoAcidMutations !== undefined && aminoAcidMutations.length > 0 && ( - <> -
{FILTER_OBJECT_ARRAY_FIELD_LABELS.aminoAcidMutations}
-
{aminoAcidMutations.join(', ')}
- - )} - {nucleotideMutations !== undefined && nucleotideMutations.length > 0 && ( - <> -
{FILTER_OBJECT_ARRAY_FIELD_LABELS.nucleotideMutations}
-
{nucleotideMutations.join(', ')}
- - )} - {aminoAcidInsertions !== undefined && aminoAcidInsertions.length > 0 && ( - <> -
{FILTER_OBJECT_ARRAY_FIELD_LABELS.aminoAcidInsertions}
-
{aminoAcidInsertions.join(', ')}
- - )} - {nucleotideInsertions !== undefined && nucleotideInsertions.length > 0 && ( - <> -
{FILTER_OBJECT_ARRAY_FIELD_LABELS.nucleotideInsertions}
-
{nucleotideInsertions.join(', ')}
- - )} - {lineageFields.map(([key, val]) => ( - <> -
- {key} -
-
- {val} -
- - ))} -
- ); + return parts.join(' · ') || '—'; } diff --git a/website/src/pages/collections/[organism]/[id]/index.astro b/website/src/pages/collections/[organism]/[id]/index.astro index 3dfdfa33..d3afeb25 100644 --- a/website/src/pages/collections/[organism]/[id]/index.astro +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -1,7 +1,7 @@ --- import { BackendService, BackendError } from '../../../../backendApi/backendService.ts'; import { CollectionDetail } from '../../../../components/collections/detail/CollectionDetail'; -import { getBackendHost } from '../../../../config.ts'; +import { getBackendHost, getOrganismConfig } from '../../../../config.ts'; import { defaultBreadcrumbs } from '../../../../layouts/Breadcrumbs'; import ContaineredPageLayout from '../../../../layouts/ContaineredPage/ContaineredPageLayout.astro'; import { getInstanceLogger } from '../../../../logger.ts'; @@ -24,6 +24,7 @@ if (id === undefined) { } const orgConfig = organismConfig[parsedOrganism.data]; +const lapisConfig = getOrganismConfig(parsedOrganism.data).lapis; let collection: Collection | undefined; @@ -50,7 +51,7 @@ const collectionTitle = collection?.name ?? `Collection #${id}`; > { collection !== undefined ? ( - + ) : (
Failed to load collection. Please try reloading the page.
) diff --git a/website/src/types/Collection.ts b/website/src/types/Collection.ts index 01532c9d..25611a26 100644 --- a/website/src/types/Collection.ts +++ b/website/src/types/Collection.ts @@ -50,6 +50,14 @@ export const FILTER_OBJECT_ARRAY_FIELD_LABELS = { nucleotideInsertions: 'Nucleotide insertions', } as const; +/** Returns a filter object for the given variant that can be used as the body of a LAPIS API request. */ +export function getVariantFilter(variant: Variant): Record { + if (variant.type === 'query') { + return { advancedQuery: variant.countQuery }; + } + return { ...variant.filterObject }; +} + export function getLineageFields(filterObject: FilterObject): [string, string][] { const knownKeys = Object.keys(FILTER_OBJECT_ARRAY_FIELD_LABELS); return Object.entries(filterObject).filter(([key]) => !knownKeys.includes(key)) as [string, string][]; From 34eef585492333e9d00de6e8ffcf4c9fecd9ede8 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 26 Mar 2026 11:50:59 +0100 Subject: [PATCH 8/8] feat(collections): link variant names to single-variant analysis page Adds Page.singleVariantView(organism, variant) to the Page helpers and uses it to make variant names in the collection detail table link to the single-variant analysis page with the variant filter pre-populated. Co-Authored-By: Claude Sonnet 4.6 --- .../collections/detail/CollectionDetail.tsx | 10 ++++++++- website/src/types/pages.ts | 21 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/website/src/components/collections/detail/CollectionDetail.tsx b/website/src/components/collections/detail/CollectionDetail.tsx index af69dd93..7e05c094 100644 --- a/website/src/components/collections/detail/CollectionDetail.tsx +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -14,6 +14,7 @@ import { type Variant, } from '../../../types/Collection.ts'; import { organismConfig, type Organism } from '../../../types/Organism.ts'; +import { Page } from '../../../types/pages.ts'; type LapisConfig = { url: string; @@ -60,6 +61,7 @@ function CollectionDetailInner({ collection, lapisConfig }: { collection: Collec - {variant.name} + + + {variant.name} + + {variant.description ?? '—'} {queryDisplay} diff --git a/website/src/types/pages.ts b/website/src/types/pages.ts index d7e473ee..d3d3db9d 100644 --- a/website/src/types/pages.ts +++ b/website/src/types/pages.ts @@ -1,4 +1,6 @@ -import type { Organism } from './Organism.ts'; +import type { Variant } from './Collection.ts'; +import { paths, type Organism } from './Organism.ts'; +import { advancedQueryUrlParamForVariant } from '../components/genspectrum/AdvancedQueryFilter.tsx'; export const Page = { createSubscription: '/subscriptions/create', @@ -7,4 +9,21 @@ export const Page = { collectionsOverview: '/collections', collectionsForOrganism: (organism: Organism) => `/collections/${organism}`, viewCollection: (organism: Organism, id: string) => `/collections/${organism}/${id}`, + singleVariantView: (organism: Organism, variant: Variant) => { + const basePath = paths[organism].basePath; + const search = new URLSearchParams(); + if (variant.type === 'query') { + search.set(advancedQueryUrlParamForVariant, variant.countQuery); + } else { + for (const [key, value] of Object.entries(variant.filterObject)) { + if (Array.isArray(value)) { + if (value.length > 0) search.set(key, value.join(',')); + } else { + search.set(key, value); + } + } + } + const params = search.toString(); + return `${basePath}/single-variant${params ? `?${params}` : ''}`; + }, } as const;