diff --git a/website/routeMocker.ts b/website/routeMocker.ts index 0c4e7adc..b347bc63 100644 --- a/website/routeMocker.ts +++ b/website/routeMocker.ts @@ -67,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.tsx b/website/src/components/collections/detail/CollectionDetail.tsx new file mode 100644 index 00000000..7e05c094 --- /dev/null +++ b/website/src/components/collections/detail/CollectionDetail.tsx @@ -0,0 +1,169 @@ +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'; +import { Page } from '../../../types/pages.ts'; + +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 ( +
+
+ + #{collection.id} + {collection.name} + + {collection.description !== null &&

{collection.description}

} +

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

+
+ +
+

Variants ({collection.variants.length})

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

No variants defined.

+ ) : ( + + + + + + + + + + + + + {collection.variants.map((variant) => ( + + ))} + +
NameDescriptionQueryTotalLast 30dLast 90d
+ )} +
+
+ ); +} + +export const CollectionDetail = withQueryProvider(CollectionDetailInner); + +function VariantRow({ + variant, + organism, + lapisConfig, + dateFrom30, + dateFrom90, +}: { + variant: Variant; + organism: Organism; + 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 ?? '—'} + {queryDisplay} + + + + + ); +} + +function CountCell({ isPending, isError, data }: { isPending: boolean; isError: boolean; data?: number }) { + if (isPending) return …; + if (isError) return error; + return {data?.toLocaleString()}; +} + +function formatFilterObjectQuery(filterObject: FilterObject): string { + const lineageFields = getLineageFields(filterObject); + const parts: string[] = []; + + for (const [key, val] of lineageFields) { + parts.push(`${key}: ${val}`); + } + + 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 parts.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..d3afeb25 --- /dev/null +++ b/website/src/pages/collections/[organism]/[id]/index.astro @@ -0,0 +1,59 @@ +--- +import { BackendService, BackendError } from '../../../../backendApi/backendService.ts'; +import { CollectionDetail } from '../../../../components/collections/detail/CollectionDetail'; +import { getBackendHost, getOrganismConfig } 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; + +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 lapisConfig = getOrganismConfig(parsedOrganism.data).lapis; + +let collection: Collection | undefined; + +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)}`); +} + +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 6506e885..25611a26 100644 --- a/website/src/types/Collection.ts +++ b/website/src/types/Collection.ts @@ -43,6 +43,26 @@ 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; + +/** 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][]; +} + // Request schemas (create) const queryVariantRequestSchema = z.object({ type: z.literal('query'), diff --git a/website/src/types/pages.ts b/website/src/types/pages.ts index a3e48b27..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', @@ -6,4 +8,22 @@ export const Page = { dataSources: '/data', 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;
{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}