diff --git a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx new file mode 100644 index 000000000..d58496991 --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx @@ -0,0 +1,39 @@ +import type { FC } from 'react'; + +import type { FilterValue, MutationFilter } from '../../../types/config.ts'; + +type ActiveDownloadFiltersProps = { + metadataFilter: FilterValue[]; + mutationFilter: MutationFilter; +}; + +export const ActiveDownloadFilters: FC = ({ metadataFilter, mutationFilter }) => { + const filterValues: FilterValue[] = metadataFilter.filter((f) => f.filterValue.length > 0); + [ + { name: 'nucleotideMutations', value: mutationFilter.nucleotideMutationQueries }, + { name: 'aminoAcidMutations', value: mutationFilter.aminoAcidMutationQueries }, + { name: 'nucleotideInsertion', value: mutationFilter.nucleotideInsertionQueries }, + { name: 'aminoAcidInsertions', value: mutationFilter.aminoAcidInsertionQueries }, + ].forEach(({ name, value }) => { + if (value !== undefined) { + filterValues.push({ name, filterValue: value.join(', ') }); + } + }); + + if (filterValues.length === 0) { + return undefined; + } + + return ( +
+

Active filters:

+
+ {filterValues.map(({ name, filterValue }) => ( +
+ {name}: {filterValue} +
+ ))} +
+
+ ); +}; diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx new file mode 100644 index 000000000..51a43ea29 --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, test, vi } from 'vitest'; + +import { DownloadDialog } from './DownloadDialog.tsx'; +import type { FilterValue, MutationFilter } from '../../../types/config.ts'; +import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; + +const defaultReferenceGenome: ReferenceGenomesSequenceNames = { + nucleotideSequences: ['main'], + genes: ['gene1', 'gene2'], +}; + +const defaultLapisUrl = 'https://lapis'; + +async function renderDialog( + metadataFilter: FilterValue[] = [], + mutationFilter: MutationFilter = {}, + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames = defaultReferenceGenome, + lapisUrl: string = defaultLapisUrl, +) { + render( + , + ); + + // Open the panel + const button = screen.getByRole('button', { name: 'Download' }); + await userEvent.click(button); +} + +describe('DownloadDialog', () => { + beforeAll(() => { + // Vitest does not seem to support showModal, yet. + // Workaround from https://github.com/jsdom/jsdom/issues/3294#issuecomment-1268330372 + HTMLDialogElement.prototype.showModal = vi.fn(function mock(this: HTMLDialogElement) { + this.open = true; + }); + + HTMLDialogElement.prototype.close = vi.fn(function mock(this: HTMLDialogElement) { + this.open = false; + }); + }); + + test('should display active filters if there are some', async () => { + await renderDialog([{ name: 'field1', filterValue: 'value1' }], { + nucleotideMutationQueries: ['A123T', 'G234C'], + }); + expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); + expect(screen.queryByText('field1: value1')).toBeInTheDocument(); + expect(screen.queryByText(/A123T, G234C/)).toBeInTheDocument(); + }); + + test('should not display active filters if there are none', async () => { + await renderDialog(); + expect(screen.queryByText(/Active filters/)).not.toBeInTheDocument(); + expect(screen.queryByText('field1: value1')).not.toBeInTheDocument(); + expect(screen.queryByText(/A123T, G234C/)).not.toBeInTheDocument(); + }); + + test('should activate download button only after agreeing to the terms', async () => { + await renderDialog(); + + const downloadButton = screen.getByRole('link', { name: 'Download' }); + expect(downloadButton).toHaveClass('btn-disabled'); + expect(getDownloadHref()).not.toMatch(new RegExp(`^${defaultLapisUrl}`)); + + await checkAgreement(); + expect(downloadButton).not.toHaveClass('btn-disabled'); + expect(getDownloadHref()).toMatch(new RegExp(`^${defaultLapisUrl}`)); + }); + + test('should generate the right download link', async () => { + await renderDialog([{ name: 'field1', filterValue: 'value1' }]); + await checkAgreement(); + + expect(getDownloadHref()).toBe( + `${defaultLapisUrl}/sample/details?versionStatus=LATEST_VERSION&isRevocation=false&dataFormat=tsv&field1=value1`, + ); + + await userEvent.click(screen.getByLabelText(/Yes, include older versions/)); + await userEvent.click(screen.getByLabelText(/Raw nucleotide sequences/)); + expect(getDownloadHref()).toBe(`${defaultLapisUrl}/sample/unalignedNucleotideSequences?field1=value1`); + }); +}); + +async function checkAgreement() { + const agreementCheckbox = screen.getByLabelText('I agree to the data use terms.'); + await userEvent.click(agreementCheckbox); +} + +function getDownloadHref() { + const downloadButton = screen.getByRole('link', { name: 'Download' }); + return downloadButton.getAttribute('href'); +} diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx new file mode 100644 index 000000000..e72f43582 --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx @@ -0,0 +1,96 @@ +import { type FC, useMemo, useRef, useState } from 'react'; + +import { ActiveDownloadFilters } from './ActiveDownloadFilters.tsx'; +import { DownloadForm } from './DownloadForm.tsx'; +import { type DownloadOption, generateDownloadUrl } from './generateDownloadUrl.ts'; +import type { FilterValue, MutationFilter } from '../../../types/config.ts'; +import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; + +type DownloadDialogProps = { + metadataFilter: FilterValue[]; + mutationFilter: MutationFilter; + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + lapisUrl: string; +}; + +export const DownloadDialog: FC = ({ + metadataFilter, + mutationFilter, + referenceGenomesSequenceNames, + lapisUrl, +}) => { + const dialogRef = useRef(null); + const [downloadOption, setDownloadOption] = useState(); + const [agreedToDataUseTerms, setAgreedToDataUseTerms] = useState(false); + + const openDialog = () => { + if (dialogRef.current) { + dialogRef.current.showModal(); + } + }; + + const closeDialog = () => { + if (dialogRef.current) { + dialogRef.current.close(); + } + }; + + const downloadUrl = useMemo(() => { + if (downloadOption === undefined || !agreedToDataUseTerms) { + return '#'; + } + return generateDownloadUrl(metadataFilter, mutationFilter, downloadOption, lapisUrl); + }, [downloadOption, lapisUrl, metadataFilter, mutationFilter, agreedToDataUseTerms]); + + return ( + <> + + + +
+
+ +
+ +

Download

+ + + + +
+ +
+ + + Download + +
+
+ + ); +}; diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadForm.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadForm.tsx new file mode 100644 index 000000000..56f1a848a --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/DownloadForm.tsx @@ -0,0 +1,166 @@ +import { type FC, useEffect, useState } from 'react'; + +import { DropdownOptionBlock, RadioOptionBlock } from './OptionBlock.tsx'; +import type { DownloadDataType, DownloadOption } from './generateDownloadUrl.ts'; +import type { ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes.ts'; + +type DownloadFormProps = { + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + onChange: (value: DownloadOption) => void; +}; + +export const DownloadForm: FC = ({ referenceGenomesSequenceNames, onChange }) => { + const [includeRestricted, setIncludeRestricted] = useState(0); + const [includeOldData, setIncludeOldData] = useState(0); + const [dataType, setDataType] = useState(0); + const [unalignedNucleotideSequence, setUnalignedNucleotideSequence] = useState(0); + const [alignedNucleotideSequence, setAlignedNucleotideSequence] = useState(0); + const [alignedAminoAcidSequence, setAlignedAminoAcidSequence] = useState(0); + + const isMultiSegmented = referenceGenomesSequenceNames.nucleotideSequences.length > 1; + + useEffect(() => { + let downloadDataType: DownloadDataType; + switch (dataType) { + case 0: + downloadDataType = { type: 'metadata' }; + break; + case 1: + downloadDataType = { + type: 'unalignedNucleotideSequences', + segment: isMultiSegmented + ? referenceGenomesSequenceNames.nucleotideSequences[unalignedNucleotideSequence] + : undefined, + }; + break; + case 2: + downloadDataType = { + type: 'alignedNucleotideSequences', + segment: isMultiSegmented + ? referenceGenomesSequenceNames.nucleotideSequences[alignedNucleotideSequence] + : undefined, + }; + break; + case 3: + downloadDataType = { + type: 'alignedAminoAcidSequences', + gene: referenceGenomesSequenceNames.genes[alignedAminoAcidSequence], + }; + break; + default: + throw new Error(`Invalid state error: DownloadForm dataType=${dataType}`); + } + onChange({ + dataType: downloadDataType, + includeOldData: includeOldData === 1, + includeRestricted: includeRestricted === 1, + }); + }, [ + includeRestricted, + includeOldData, + dataType, + unalignedNucleotideSequence, + alignedNucleotideSequence, + alignedAminoAcidSequence, + isMultiSegmented, + referenceGenomesSequenceNames.nucleotideSequences, + referenceGenomesSequenceNames.genes, + onChange, + ]); + + return ( +
+ No, only download open data }, + { + label: ( + <> + Yes, include restricted data +
({/* TODO(862) */} + + What does it mean? + + ) + + ), + }, + ]} + selected={includeRestricted} + onSelect={setIncludeRestricted} + /> + No, only download latest version }, + { label: <>Yes, include older versions and revoked sequences }, + ]} + selected={includeOldData} + onSelect={setIncludeOldData} + /> + Metadata }, + { + label: <>Raw nucleotide sequences, + subOptions: isMultiSegmented ? ( +
+ ({ + // eslint-disable-next-line react/jsx-no-useless-fragment + label: <>{segment}, + }))} + selected={unalignedNucleotideSequence} + onSelect={setUnalignedNucleotideSequence} + disabled={dataType !== 1} + /> +
+ ) : undefined, + }, + { + label: <>Aligned nucleotide sequences, + subOptions: isMultiSegmented ? ( +
+ ({ + // eslint-disable-next-line react/jsx-no-useless-fragment + label: <>{gene}, + }))} + selected={alignedNucleotideSequence} + onSelect={setAlignedNucleotideSequence} + disabled={dataType !== 2} + /> +
+ ) : undefined, + }, + { + label: <>Aligned amino acid sequences, + subOptions: ( +
+ ({ + // eslint-disable-next-line react/jsx-no-useless-fragment + label: <>{gene}, + }))} + selected={alignedAminoAcidSequence} + onSelect={setAlignedAminoAcidSequence} + disabled={dataType !== 3} + /> +
+ ), + }, + ]} + selected={dataType} + onSelect={setDataType} + /> +
+ ); +}; diff --git a/website/src/components/SearchPage/DownloadDialog/OptionBlock.tsx b/website/src/components/SearchPage/DownloadDialog/OptionBlock.tsx new file mode 100644 index 000000000..5fe902f3a --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/OptionBlock.tsx @@ -0,0 +1,76 @@ +import type { FC, ReactElement } from 'react'; + +export type OptionBlockProps = { + name: string; + title?: string; + options: { + label: ReactElement; + subOptions?: ReactElement; + }[]; + selected: number; + onSelect: (index: number) => void; + disabled?: boolean; +}; + +export const RadioOptionBlock: FC = ({ + name, + title, + options, + selected, + onSelect, + disabled = false, +}) => { + return ( +
+ {title !== undefined &&

{title}

} + {options.map((option, index) => ( +
+ + {option.subOptions} +
+ ))} +
+ ); +}; + +export const DropdownOptionBlock: FC = ({ + name, + title, + options, + selected, + onSelect, + disabled = false, +}) => { + return ( +
+ +
+ ); +}; diff --git a/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts b/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts new file mode 100644 index 000000000..1f42e46c6 --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/generateDownloadUrl.ts @@ -0,0 +1,75 @@ +import { IS_REVOCATION_FIELD, metadataDefaultDownloadDataFormat, VERSION_STATUS_FIELD } from '../../../settings.ts'; +import type { FilterValue, MutationFilter } from '../../../types/config.ts'; +import { siloVersionStatuses } from '../../../types/lapis.ts'; + +export type DownloadDataType = + | { type: 'metadata' } + | { type: 'unalignedNucleotideSequences'; segment: string | undefined } + | { type: 'alignedNucleotideSequences'; segment: string | undefined } + | { type: 'alignedAminoAcidSequences'; gene: string }; + +export type DownloadOption = { + includeOldData: boolean; + includeRestricted: boolean; + dataType: DownloadDataType; +}; + +export const generateDownloadUrl = ( + metadataFilter: FilterValue[], + mutationFilter: MutationFilter, + option: DownloadOption, + lapisUrl: string, +) => { + const baseUrl = `${lapisUrl}${getEndpoint(option.dataType)}`; + const params = new URLSearchParams(); + // TODO(#848) + // params.set('downloadAsFile', 'true'); + if (!option.includeOldData) { + params.set(VERSION_STATUS_FIELD, siloVersionStatuses.latestVersion); + params.set(IS_REVOCATION_FIELD, 'false'); + } + if (!option.includeRestricted) { + // TODO(#852) Filter for sequences with an open Data Use Term. + } + if (option.dataType.type === 'metadata') { + params.set('dataFormat', metadataDefaultDownloadDataFormat); + } + for (const { name, filterValue } of metadataFilter) { + if (filterValue.trim().length > 0) { + params.set(name, filterValue); + } + } + if (mutationFilter.nucleotideMutationQueries !== undefined) { + params.set('nucleotideMutations', mutationFilter.nucleotideMutationQueries.join(',')); + } + if (mutationFilter.aminoAcidMutationQueries !== undefined) { + params.set('aminoAcidMutations', mutationFilter.aminoAcidMutationQueries.join(',')); + } + if (mutationFilter.nucleotideInsertionQueries !== undefined) { + params.set('nucleotideInsertions', mutationFilter.nucleotideInsertionQueries.join(',')); + } + if (mutationFilter.aminoAcidInsertionQueries !== undefined) { + params.set('aminoAcidInsertions', mutationFilter.aminoAcidInsertionQueries.join(',')); + } + return `${baseUrl}?${params}`; +}; + +const getEndpoint = (dataType: DownloadDataType) => { + switch (dataType.type) { + case 'metadata': + return '/sample/details'; + case 'unalignedNucleotideSequences': + return '/sample/unalignedNucleotideSequences' + segmentPathIfDefined(dataType.segment); + case 'alignedNucleotideSequences': + return '/sample/alignedNucleotideSequences' + segmentPathIfDefined(dataType.segment); + case 'alignedAminoAcidSequences': + return '/sample/alignedAminoAcidSequences' + segmentPathIfDefined(dataType.gene); + } +}; + +const segmentPathIfDefined = (segment: string | undefined) => { + if (segment === undefined) { + return ''; + } + return `/${segment}`; +}; diff --git a/website/src/pages/[organism]/search/index.astro b/website/src/pages/[organism]/search/index.astro index f68054e11..f1bc4bde1 100644 --- a/website/src/pages/[organism]/search/index.astro +++ b/website/src/pages/[organism]/search/index.astro @@ -1,11 +1,12 @@ --- import { getData, getReferenceGenomesSequenceNames, getMetadataFilters, getMutationFilter, getOrderBy } from './search'; import { cleanOrganism } from '../../../components/Navigation/cleanOrganism'; +import { DownloadDialog } from '../../../components/SearchPage/DownloadDialog/DownloadDialog'; import { Pagination } from '../../../components/SearchPage/Pagination'; import { SearchForm } from '../../../components/SearchPage/SearchForm'; import { Table } from '../../../components/SearchPage/Table'; import ErrorBox from '../../../components/common/ErrorBox.astro'; -import { getRuntimeConfig, getSchema } from '../../../config'; +import { getLapisUrl, getRuntimeConfig, getSchema } from '../../../config'; import BaseLayout from '../../../layouts/BaseLayout.astro'; import { pageSize } from '../../../settings'; @@ -13,6 +14,7 @@ const organism = Astro.params.organism!; const { organism: cleanedOrganism } = cleanOrganism(organism); const schema = getSchema(organism); const clientConfig = getRuntimeConfig().public; +const lapisUrl = getLapisUrl(clientConfig, organism); const getSearchParams = (field: string): string => { return Astro.url.searchParams.get(field) ?? ''; }; @@ -47,7 +49,14 @@ const data = await getData(organism, metadataFilter, mutationFilter, offset, pag data.match( (data) => (
-
+ +
Search returned {data.totalCount.toLocaleString()} sequence{data.totalCount === 1 ? '' : 's'}
diff --git a/website/src/settings.ts b/website/src/settings.ts index a21d35b06..ac3126121 100644 --- a/website/src/settings.ts +++ b/website/src/settings.ts @@ -16,3 +16,5 @@ export const hiddenDefaultSearchFilters = [ { name: VERSION_STATUS_FIELD, filterValue: siloVersionStatuses.latestVersion, type: 'string' as const }, { name: IS_REVOCATION_FIELD, filterValue: 'false', type: 'string' as const }, ]; + +export const metadataDefaultDownloadDataFormat = 'tsv';