Skip to content
8 changes: 0 additions & 8 deletions website/routeMocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}),
);
}
}

/**
Expand Down
169 changes: 169 additions & 0 deletions website/src/components/collections/detail/CollectionDetail.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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 (
<div className='flex flex-col gap-4'>
<div>
<PageHeadline>
<span className='mr-2 font-normal text-gray-400'>#{collection.id}</span>
{collection.name}
</PageHeadline>
{collection.description !== null && <p className='mt-1 text-gray-500'>{collection.description}</p>}
<p className='mt-1 text-sm text-gray-500'>
{organismName} collection owned by {collection.ownedBy}
</p>
</div>

<div>
<h2 className='mb-3 text-lg font-semibold'>Variants ({collection.variants.length})</h2>
{collection.variants.length === 0 ? (
<p className='text-sm text-gray-500'>No variants defined.</p>
) : (
<table className='w-full border-collapse text-sm'>
<thead>
<tr className='border-b border-gray-200 text-left text-gray-500'>
<th className='pr-4 pb-2 font-medium'>Name</th>
<th className='pr-4 pb-2 font-medium'>Description</th>
<th className='pr-4 pb-2 font-medium'>Query</th>
<th className='pr-4 pb-2 text-right font-medium'>Total</th>
<th className='pr-4 pb-2 text-right font-medium'>Last 30d</th>
<th className='pb-2 text-right font-medium'>Last 90d</th>
</tr>
</thead>
<tbody>
{collection.variants.map((variant) => (
<VariantRow
key={variant.id}
variant={variant}
organism={collection.organism as Organism}
lapisConfig={lapisConfig}
dateFrom30={dateFrom30}
dateFrom90={dateFrom90}
/>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

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' ? (
<span className='font-mono text-xs'>{variant.countQuery}</span>
) : (
<span className='text-xs'>{formatFilterObjectQuery(variant.filterObject)}</span>
);

return (
<tr className='border-b border-gray-100 last:border-0'>
<td className='py-2 pr-4 font-medium'>
<a className='underline' href={Page.singleVariantView(organism, variant)} title='Analyze this variant'>
{variant.name}
</a>
</td>
<td className='py-2 pr-4 text-sm text-gray-500'>{variant.description ?? '—'}</td>
<td className='py-2 pr-4'>{queryDisplay}</td>
<CountCell {...totalQuery} />
<CountCell {...last30Query} />
<CountCell {...last90Query} />
</tr>
);
}

function CountCell({ isPending, isError, data }: { isPending: boolean; isError: boolean; data?: number }) {
if (isPending) return <td className='px-4 py-2 text-right text-gray-400'>…</td>;
if (isError) return <td className='text-error px-4 py-2 text-right'>error</td>;
return <td className='px-4 py-2 text-right tabular-nums'>{data?.toLocaleString()}</td>;
}

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(' · ') || '—';
}
35 changes: 19 additions & 16 deletions website/src/components/collections/overview/CollectionsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -37,13 +38,13 @@ function CollectionsOverviewInner({ organism, isLoggedIn: _isLoggedIn }: { organ
) : collections === undefined || collections.length === 0 ? (
<div className='mt-6 text-gray-500'>No collections yet.</div>
) : (
<CollectionsTable collections={collections} />
<CollectionsTable collections={collections} organism={organism} />
)}
</div>
);
}

function CollectionsTable({ collections }: { collections: Collection[] }) {
function CollectionsTable({ collections, organism }: { collections: Collection[]; organism: Organism }) {
return (
<div className='my-6 overflow-x-auto'>
<table className='table-zebra table w-full'>
Expand All @@ -57,21 +58,23 @@ function CollectionsTable({ collections }: { collections: Collection[] }) {
</thead>
<tbody>
{collections.map((collection) => (
<tr key={collection.id}>
<td className='font-mono text-xs text-gray-500'>{collection.id}</td>
<td className='font-medium'>{collection.name}</td>
<td className='max-w-sm text-gray-500'>
{collection.description ? (
collection.description.length > 80 ? (
collection.description.slice(0, 80) + '…'
<tr key={collection.id} className='hover:bg-base-300'>
<a href={Page.viewCollection(organism, String(collection.id))} className='contents'>
<td className='font-mono text-xs text-gray-500'>{collection.id}</td>
<td className='font-medium'>{collection.name}</td>
<td className='max-w-sm text-gray-500'>
{collection.description ? (
collection.description.length > 80 ? (
collection.description.slice(0, 80) + '…'
) : (
collection.description
)
) : (
collection.description
)
) : (
<span className='text-gray-300'>—</span>
)}
</td>
<td className='text-right'>{collection.variants.length}</td>
<span className='text-gray-300'>—</span>
)}
</td>
<td className='text-right'>{collection.variants.length}</td>
</a>
</tr>
))}
</tbody>
Expand Down
59 changes: 59 additions & 0 deletions website/src/pages/collections/[organism]/[id]/index.astro
Original file line number Diff line number Diff line change
@@ -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}`;
---

<ContaineredPageLayout
title={collectionTitle}
breadcrumbs={[
...defaultBreadcrumbs,
{ name: 'Collections', href: Page.collectionsOverview },
{ name: orgConfig.label, href: Page.collectionsForOrganism(parsedOrganism.data) },
{ name: collectionTitle, href: Page.viewCollection(parsedOrganism.data, id) },
]}
>
{
collection !== undefined ? (
<CollectionDetail collection={collection} lapisConfig={lapisConfig} client:load />
) : (
<div class='text-error'>Failed to load collection. Please try reloading the page.</div>
)
}
</ContaineredPageLayout>
20 changes: 20 additions & 0 deletions website/src/types/Collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ export type Collection = z.infer<typeof collectionSchema>;
export type Variant = z.infer<typeof variantSchema>;
export type FilterObject = z.infer<typeof filterObjectSchema>;

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<string, unknown> {
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'),
Expand Down
22 changes: 21 additions & 1 deletion website/src/types/pages.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
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',
subscriptionsOverview: '/subscriptions',
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;