Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ dashboards:
lapis:
url: "https://gs-staging-1.int.genspectrum.org/open/v2"
mainDateField: "date"
lineageFields:
- "pangoLineage"
- "nextcladePangoLineage"
externalNavigationLinks:
- url: "https://cov-spectrum.org"
label: "CoV-Spectrum"
Expand Down
53 changes: 53 additions & 0 deletions website/src/components/collections/create/CollectionCreate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useMutation } from '@tanstack/react-query';

import { getBackendServiceForClientside } from '../../../backendApi/backendService.ts';
import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx';
import { getClientLogger } from '../../../clientLogger.ts';
import type { DashboardsConfig } from '../../../config.ts';
import type { Organism } from '../../../types/Organism.ts';
import { Page } from '../../../types/pages.ts';
import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts';
import { CollectionForm, type CollectionFormValues } from '../form/CollectionForm.tsx';

export const CollectionCreate = withQueryProvider(CollectionCreateInner);

const logger = getClientLogger('CollectionCreate');

function CollectionCreateInner({ organism, config }: { organism: Organism; config: DashboardsConfig }) {
const createMutation = useMutation({
mutationFn: (values: CollectionFormValues) =>
getBackendServiceForClientside().postCollection({
collection: {
name: values.name,
organism,
description: values.description || undefined,
variants: values.variants,
},
}),
onSuccess: () => {
window.location.href = Page.collectionsForOrganism(organism);
},
onError: (err) => {
logger.error(`Failed to create collection: ${getErrorLogMessage(err)}`);
},
});

return (
<div className='pb-6'>
<CollectionForm
onSubmit={(values) => createMutation.mutate(values)}
isSubmitting={createMutation.isPending}
isSuccess={createMutation.isSuccess}
successMessage='Collection created.'
submitLabel='Create collection'
organism={organism}
config={config}
/>
{createMutation.isError && (
<div className='alert alert-error mt-4'>
Failed to create collection: {getErrorLogMessage(createMutation.error)}
</div>
)}
</div>
);
}
158 changes: 158 additions & 0 deletions website/src/components/collections/edit/CollectionEdit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useState } from 'react';

import { getBackendServiceForClientside } from '../../../backendApi/backendService.ts';
import { withQueryProvider } from '../../../backendApi/withQueryProvider.tsx';
import { getClientLogger } from '../../../clientLogger.ts';
import type { DashboardsConfig } from '../../../config.ts';
import type { VariantUpdate } from '../../../types/Collection.ts';
import type { Organism } from '../../../types/Organism.ts';
import { Page } from '../../../types/pages.ts';
import { getErrorLogMessage } from '../../../util/getErrorLogMessage.ts';
import { CollectionForm, type CollectionFormValues } from '../form/CollectionForm.tsx';

export const CollectionEdit = withQueryProvider(CollectionEditInner);

const logger = getClientLogger('CollectionEdit');

function CollectionEditInner({ id, organism, config }: { id: string; organism: Organism; config: DashboardsConfig }) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);

const {
isLoading,
isError,
data: collection,
error,
} = useQuery({
queryKey: ['collection', id],
queryFn: () => getBackendServiceForClientside().getCollection({ id }),
});

const updateMutation = useMutation({
mutationFn: (values: CollectionFormValues) =>
getBackendServiceForClientside().putCollection({
id,
collection: {
name: values.name,
description: values.description || undefined,
variants: values.variants,
},
}),
onSuccess: (updated) => {
window.location.href = Page.viewCollection(organism, String(updated.id));
},
onError: (err) => {
logger.error(`Failed to update collection: ${getErrorLogMessage(err)}`);
},
});

const deleteMutation = useMutation({
mutationFn: () => getBackendServiceForClientside().deleteCollection({ id }),
onSuccess: () => {
window.location.href = Page.collectionsForOrganism(organism);
},
onError: (err) => {
logger.error(`Failed to delete collection: ${getErrorLogMessage(err)}`);
},
});

if (isLoading) {
return <span className='loading loading-spinner loading-sm' />;
}

if (isError) {
logger.error(`Failed to fetch collection: ${getErrorLogMessage(error)}`);
return <div className='text-error'>Failed to load collection. Please try reloading the page.</div>;
}

if (collection === undefined) {
return null;
}

const initialValues: CollectionFormValues = {
name: collection.name,
description: collection.description ?? '',
variants: collection.variants.map((v): VariantUpdate => {
if (v.type === 'query') {
return {
type: 'query',
id: v.id,
name: v.name,
description: v.description ?? undefined,
countQuery: v.countQuery,
coverageQuery: v.coverageQuery ?? undefined,
};
}
return {
type: 'filterObject',
id: v.id,
name: v.name,
description: v.description ?? undefined,
filterObject: v.filterObject,
};
}),
};

return (
<div className='flex flex-col gap-8 pb-6'>
<CollectionForm
initialValues={initialValues}
onSubmit={(values) => updateMutation.mutate(values)}
isSubmitting={updateMutation.isPending}
isSuccess={updateMutation.isSuccess}
successMessage='Collection updated.'
submitLabel='Save changes'
organism={organism}
config={config}
/>

{updateMutation.isError && (
<div className='alert alert-error'>
Failed to update collection: {getErrorLogMessage(updateMutation.error)}
</div>
)}

<div className='border-t border-gray-200 pt-6'>
<h2 className='text-error mb-3 text-lg font-semibold'>Danger zone</h2>
{!showDeleteConfirm ? (
<button
type='button'
className='btn btn-sm btn-error btn-outline'
onClick={() => setShowDeleteConfirm(true)}
>
Delete collection
</button>
) : (
<div className='border-error rounded-lg border p-4'>
<p className='mb-3 text-sm'>
Are you sure you want to delete <strong>{collection.name}</strong>? This cannot be undone.
</p>
<div className='flex gap-2'>
<button
type='button'
className='btn btn-sm btn-error'
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending && <span className='loading loading-spinner loading-sm' />}
Yes, delete
</button>
<button
type='button'
className='btn btn-sm btn-ghost'
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</button>
</div>
{deleteMutation.isError && (
<div className='text-error mt-2 text-sm'>
Failed to delete: {getErrorLogMessage(deleteMutation.error)}
</div>
)}
</div>
)}
</div>
</div>
);
}
159 changes: 159 additions & 0 deletions website/src/components/collections/form/CollectionForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useCallback, useState } from 'react';

import { VariantEditor } from './VariantEditor.tsx';
import type { DashboardsConfig } from '../../../config.ts';
import type { VariantUpdate } from '../../../types/Collection.ts';
import { organismConfig, type Organism } from '../../../types/Organism.ts';
import { GsApp } from '../../genspectrum/GsApp.tsx';

export type CollectionFormValues = {
name: string;
description: string;
variants: VariantUpdate[];
};

type Props = {
initialValues?: CollectionFormValues;
onSubmit: (values: CollectionFormValues) => void;
isSubmitting: boolean;
isSuccess: boolean;
successMessage: string;
submitLabel: string;
organism: Organism;
config: DashboardsConfig;
};

export function CollectionForm({
initialValues,
onSubmit,
isSubmitting,
isSuccess,
successMessage,
submitLabel,
organism,
config,
}: Props) {
const lapisUrl = config.dashboards.organisms[organism].lapis.url;
const lineageFields = config.dashboards.organisms[organism].lapis.lineageFields ?? [];
const [name, setName] = useState(initialValues?.name ?? '');
const [description, setDescription] = useState(initialValues?.description ?? '');
const [variants, setVariants] = useState(
initialValues?.variants ?? [{ type: 'filterObject', name: 'Variant 1', filterObject: {} }],
);

const addVariant = useCallback(() => {
setVariants((prev) => [
...prev,
{ type: 'filterObject', name: `Variant ${prev.length + 1}`, filterObject: {} },
]);
}, []);

const updateVariant = useCallback((index: number, variant: VariantUpdate) => {
setVariants((prev) => prev.map((v, i) => (i === index ? variant : v)));
}, []);

const removeVariant = useCallback((index: number) => {
setVariants((prev) => prev.filter((_, i) => i !== index));
}, []);

const title = initialValues ? 'Edit collection' : 'New collection';

return (
<GsApp lapis={lapisUrl}>
<div className='flex flex-col gap-6'>
<h1 className='text-2xl font-semibold'>{title}</h1>
<div className='grid grid-cols-3 gap-x-8 pb-16'>
<div className='text-sm text-gray-500'>
General information about this {organismConfig[organism].label} collection.
</div>
<div className='col-span-2 flex flex-col gap-4'>
<div>
<label className='label'>Name</label>
<input
className='input input-bordered w-full'
placeholder='A name to identify this collection.'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<label className='label'>Description</label>
<textarea
className='textarea textarea-bordered min-h-12 w-full'
placeholder='Optional description for this collection.'
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
</div>
</div>
</div>

<div className='grid grid-cols-3 gap-x-8'>
<div className='text-sm text-gray-500'>
<p className='text-lg font-medium text-gray-700'>Variants</p>
<p className='mt-1'>
Define the variants to track in this collection. Each variant can be defined as a query or a
mutation list.
</p>
</div>
<div className='col-span-2 flex flex-col gap-4'>
{variants.map((variant, index) => (
<VariantEditor
key={index}
index={index}
variant={variant}
onChange={updateVariant}
onRemove={removeVariant}
canRemove={variants.length > 1}
lineageFields={lineageFields}
/>
))}
<button type='button' className='btn btn-sm w-full' onClick={addVariant}>
Add variant
</button>
</div>
</div>

<SubmitButton
isSuccess={isSuccess}
isPending={isSubmitting}
isDisabled={name.trim() === ''}
successMessage={successMessage}
submitLabel={submitLabel}
onClick={() => onSubmit({ name, description, variants })}
/>
</div>
</GsApp>
);
}

function SubmitButton({
isSuccess,
isPending,
isDisabled,
successMessage,
submitLabel,
onClick,
}: {
isSuccess: boolean;
isPending: boolean;
isDisabled: boolean;
successMessage: string;
submitLabel: string;
onClick: () => void;
}) {
if (isSuccess) {
return (
<div className='bg-success flex h-12 items-center justify-center rounded-lg'>
{successMessage}
<div className='iconify mdi--check ml-2 size-4' />
</div>
);
}

return (
<button className='btn btn-primary' onClick={onClick} disabled={isDisabled || isPending}>
{isPending ? <span className='loading loading-spinner loading-sm' /> : submitLabel}
</button>
);
}
Loading
Loading