From 272d34b3cfa3555c216ac80b2e492cbbc6509370 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Tue, 10 Dec 2024 16:35:55 +0100 Subject: [PATCH] feat(website): Change data use terms in bulk (#3322) * Add TODO * Add dialog stub * More stub progress * Factor out BaseDialog * Factor out BaseDialog in new Dialog * Add todos * Add download parameters * clean up * Add some notes * Rename * refactoring SequenceFilter * add docs * partial test fixes * Test fix * More test fix * test fix * WIP * Data can be downloaded! * Remove dead code * Can edit just selected sequences now * Remove TODO * Update todo * WIP * Add more skeleton stuff * Remove TODOs * progress * a bit of cleanup * Add button * Layout progress * WIP * Refactor DataUseTermsSelector * Add DataUseTermsSelector to EditDataUseTermsModal * format * Getting close! * Fix a timing issue * remove ':' from active filters for consistency * Show button only on 'released' page * set min date in popup * rename, cleanup * rename, cleanup * rename, cleanup * remove debug statement * Add unit test stub * Better 'not logged in' handling * Add stub test * Add DataUseTermsSelector tests * Update website/src/components/DataUseTerms/EditDataUseTermsModal.tsx Co-authored-by: Theo Sanderson * don't select anything by default * Update website/src/components/DataUseTerms/EditDataUseTermsModal.tsx Co-authored-by: Theo Sanderson * Make 'Open Data Use Terms' a link to the data use terms page * fix mobile layout: use flex-col for screensoption * format * Show data use terms column when editing data use terms is possible * Only update date where date needs to be updated * Disable button if no sequences would get updated --------- Co-authored-by: Theo Sanderson --- .../DataUseTermsSelector.spec.tsx | 140 ++++++++ .../DataUseTerms/DataUseTermsSelector.tsx | 118 ++++++- .../DateChangeModal.tsx | 62 ++-- .../DataUseTerms/EditDataUseTermsButton.tsx | 65 +--- .../DataUseTerms/EditDataUseTermsModal.tsx | 303 ++++++++++++++++++ .../DataUseTerms/EditDataUseTermsToasts.ts | 16 + .../src/components/ReviewPage/ReviewCard.tsx | 6 +- .../ActiveDownloadFilters.spec.tsx | 76 ----- .../DownloadDialog/ActiveDownloadFilters.tsx | 58 ---- .../DownloadDialog/DowloadDialogButton.tsx | 27 +- .../DownloadDialog/DownloadButton.tsx | 10 +- .../DownloadDialog/DownloadDialog.spec.tsx | 17 +- .../DownloadDialog/DownloadDialog.tsx | 115 +++---- .../DownloadDialog/DownloadParameters.tsx | 18 -- .../DownloadDialog/DownloadUrlGenerator.ts | 48 +-- .../DownloadDialog/SequenceFilters.tsx | 151 +++++++++ .../components/SearchPage/SearchFullUI.tsx | 42 +-- .../DataUseTermsHistoryModal.tsx | 4 +- .../components/Submission/DataUploadForm.tsx | 59 ++-- .../components/common/ActiveFilters.spec.tsx | 44 +++ .../src/components/common/ActiveFilters.tsx | 24 ++ website/src/components/common/BaseDialog.tsx | 43 +++ .../submission/[groupId]/released.astro | 1 + website/src/settings.ts | 1 + website/src/types/backend.ts | 14 +- website/tests/e2e.fixture.ts | 4 +- website/tests/util/backendCalls.ts | 6 +- 27 files changed, 1009 insertions(+), 463 deletions(-) create mode 100644 website/src/components/DataUseTerms/DataUseTermsSelector.spec.tsx rename website/src/components/{Submission => DataUseTerms}/DateChangeModal.tsx (72%) create mode 100644 website/src/components/DataUseTerms/EditDataUseTermsModal.tsx create mode 100644 website/src/components/DataUseTerms/EditDataUseTermsToasts.ts delete mode 100644 website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.spec.tsx delete mode 100644 website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx delete mode 100644 website/src/components/SearchPage/DownloadDialog/DownloadParameters.tsx create mode 100644 website/src/components/SearchPage/DownloadDialog/SequenceFilters.tsx create mode 100644 website/src/components/common/ActiveFilters.spec.tsx create mode 100644 website/src/components/common/ActiveFilters.tsx create mode 100644 website/src/components/common/BaseDialog.tsx diff --git a/website/src/components/DataUseTerms/DataUseTermsSelector.spec.tsx b/website/src/components/DataUseTerms/DataUseTermsSelector.spec.tsx new file mode 100644 index 0000000000..1126587406 --- /dev/null +++ b/website/src/components/DataUseTerms/DataUseTermsSelector.spec.tsx @@ -0,0 +1,140 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DateTime } from 'luxon'; +import { describe, expect, test, vi } from 'vitest'; + +import DataUseTermsSelector from './DataUseTermsSelector'; +import { openDataUseTermsOption, restrictedDataUseTermsOption } from '../../types/backend.ts'; + +describe('DataUseTermsSelector', () => { + test('calls setDataUseTerms when an input is clicked', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.now().plus({ days: 30 }); + + render( + , + ); + + // Restricted radio input + const restrictedInput = screen.getByLabelText('Restricted'); + fireEvent.click(restrictedInput); + + expect(mockSetDataUseTerms).toHaveBeenCalledWith({ + type: restrictedDataUseTermsOption, + restrictedUntil: maxRestrictedUntil.toFormat('yyyy-MM-dd'), + }); + + // Open radio input + const openInput = screen.getByLabelText('Open'); + fireEvent.click(openInput); + + expect(mockSetDataUseTerms).toHaveBeenCalledWith({ type: openDataUseTermsOption }); + }); + + test('opens the modal when calendarUseModal is true and "Change date" button is clicked', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.now().plus({ days: 30 }); + + render( + , + ); + + const changeDateButton = screen.getByText('Change date'); + fireEvent.click(changeDateButton); + + expect(screen.getByText('Change date until which sequences are restricted')).toBeInTheDocument(); + }); + + test('does not use the modal when calendarUseModal is false and renders the inline datepicker instead', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.now().plus({ days: 30 }); + + render( + , + ); + + expect(screen.queryByText('Mon')).toBeInTheDocument(); + expect(screen.queryByText('Tue')).toBeInTheDocument(); + expect(screen.queryByText('Wed')).toBeInTheDocument(); + expect(screen.queryByText('Change date')).not.toBeInTheDocument(); + }); + + test('updates the date when a date is clicked in the inline datepicker', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.fromISO('2077-07-15'); + + render( + , + ); + + const dateButton = screen.getByText('14'); + fireEvent.click(dateButton); + + expect(mockSetDataUseTerms).toHaveBeenCalledWith({ + type: restrictedDataUseTermsOption, + restrictedUntil: '2077-07-14', + }); + }); + + test('updates the date via modal when "Change date" is clicked, a date is selected, and submitted', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.fromISO('2077-07-15'); + + render( + , + ); + + // Open the modal + const changeDateButton = screen.getByText('Change date'); + fireEvent.click(changeDateButton); + + // Select a date in the modal + const dateButton = screen.getByText('14'); + fireEvent.click(dateButton); + + // Submit the modal + const submitButton = screen.getByText('Save'); + fireEvent.click(submitButton); + + expect(mockSetDataUseTerms).toHaveBeenCalledWith({ + type: restrictedDataUseTermsOption, + restrictedUntil: '2077-07-14', + }); + }); + + test('renders with no radio input selected when initialDataUseTermsType is not set', () => { + const mockSetDataUseTerms = vi.fn(); + const maxRestrictedUntil = DateTime.fromISO('2077-07-15'); + + render(); + + const openInput = screen.getByLabelText('Open'); + const restrictedInput = screen.getByLabelText('Restricted'); + + expect(openInput).not.toBeChecked(); + expect(restrictedInput).not.toBeChecked(); + }); +}); diff --git a/website/src/components/DataUseTerms/DataUseTermsSelector.tsx b/website/src/components/DataUseTerms/DataUseTermsSelector.tsx index 12f3b81e96..9224e1ac01 100644 --- a/website/src/components/DataUseTerms/DataUseTermsSelector.tsx +++ b/website/src/components/DataUseTerms/DataUseTermsSelector.tsx @@ -1,25 +1,85 @@ -import { type FC } from 'react'; +import { Datepicker } from 'flowbite-react'; +import { DateTime } from 'luxon'; +import { useState, type FC } from 'react'; +import { DateChangeModal, datePickerTheme } from './DateChangeModal.tsx'; +import { getClientLogger } from '../../clientLogger.ts'; import { routes } from '../../routes/routes.ts'; -import { type DataUseTermsType, openDataUseTermsType, restrictedDataUseTermsType } from '../../types/backend.ts'; +import { + type DataUseTermsOption, + openDataUseTermsOption, + restrictedDataUseTermsOption, + type DataUseTerms, +} from '../../types/backend.ts'; import Locked from '~icons/fluent-emoji-high-contrast/locked'; import Unlocked from '~icons/fluent-emoji-high-contrast/unlocked'; +const logger = getClientLogger('DatauseTermsSelector'); + type DataUseTermsSelectorProps = { - dataUseTermsType: DataUseTermsType; - setDataUseTermsType: (dataUseTermsType: DataUseTermsType) => void; + initialDataUseTermsOption?: DataUseTermsOption | null; + maxRestrictedUntil: DateTime; + calendarUseModal?: boolean; + calendarDescription?: React.ReactNode; + setDataUseTerms: (dataUseTerms: DataUseTerms) => void; }; -const DataUseTermsSelector: FC = ({ dataUseTermsType, setDataUseTermsType }) => { +const DataUseTermsSelector: FC = ({ + initialDataUseTermsOption = null, + maxRestrictedUntil, + calendarUseModal = false, + setDataUseTerms, + calendarDescription = null, +}) => { + const setDataUseTermsWithValues = (newOption: DataUseTermsOption, newDate: DateTime) => { + switch (newOption) { + case openDataUseTermsOption: + setDataUseTerms({ type: openDataUseTermsOption }); + break; + case restrictedDataUseTermsOption: + setDataUseTerms({ + type: restrictedDataUseTermsOption, + restrictedUntil: newDate.toFormat('yyyy-MM-dd'), + }); + break; + } + }; + + const [selectedOption, setSelectedOptionInternal] = useState(initialDataUseTermsOption); + const [selectedDate, setSelectedDateInternal] = useState(maxRestrictedUntil); + + const setSelectedOption = (newOption: DataUseTermsOption) => { + setSelectedOptionInternal(newOption); + setDataUseTermsWithValues(newOption, selectedDate); + }; + + const setSelectedDate = (newDate: DateTime) => { + setSelectedOptionInternal(restrictedDataUseTermsOption); + setSelectedDateInternal(newDate); + setDataUseTermsWithValues(restrictedDataUseTermsOption, newDate); + }; + + const [dateChangeModalOpen, setDateChangeModalOpen] = useState(false); + return ( <> -
+ {dateChangeModalOpen && ( + + )} +
setDataUseTermsType(openDataUseTermsType)} + onChange={() => setSelectedOption(openDataUseTermsOption)} type='radio' - checked={dataUseTermsType === openDataUseTermsType} + checked={selectedOption === openDataUseTermsOption} className='h-4 w-4 p-2 border-gray-300 text-iteal-600 focus:ring-iteal-600 inline-block' />
-
+
setDataUseTermsType(restrictedDataUseTermsType)} + onChange={() => setSelectedOption(restrictedDataUseTermsOption)} type='radio' - checked={dataUseTermsType === restrictedDataUseTermsType} + checked={selectedOption === restrictedDataUseTermsOption} className='h-4 w-4 border-gray-300 text-iteal-600 focus:ring-iteal-600 inline-block' />
+ {selectedOption === restrictedDataUseTermsOption && !calendarUseModal && ( + <> + {calendarDescription !== null && ( +

{calendarDescription}

+ )} + { + if (date !== null) { + setSelectedDate(DateTime.fromJSDate(date)); + } else { + void logger.warn( + "Datepicker onChange received a null value, this shouldn't happen!", + ); + } + }} + inline + /> + + )} + {selectedOption === restrictedDataUseTermsOption && ( + + Data use will be restricted until {selectedDate.toFormat('yyyy-MM-dd')}.{' '} + {calendarUseModal && ( + + )} + + )}
); diff --git a/website/src/components/Submission/DateChangeModal.tsx b/website/src/components/DataUseTerms/DateChangeModal.tsx similarity index 72% rename from website/src/components/Submission/DateChangeModal.tsx rename to website/src/components/DataUseTerms/DateChangeModal.tsx index 94ec7f8400..683aa690c1 100644 --- a/website/src/components/Submission/DateChangeModal.tsx +++ b/website/src/components/DataUseTerms/DateChangeModal.tsx @@ -35,8 +35,8 @@ export const datePickerTheme: FlowbiteDatepickerTheme = { footer: { base: 'flex mt-2 space-x-2', button: { - base: 'w-full rounded-lg px-5 py-2 text-center text-sm font-medium focus:ring-4 focus:ring-cyan-300', - today: 'bg-cyan-700 text-white hover:bg-cyan-800 dark:bg-cyan-600 dark:hover:bg-cyan-700', + base: 'w-full rounded-lg px-5 py-2 text-center text-sm font-medium focus:ring-4 focus:ring-primary-300', + today: 'bg-primary-700 text-white hover:bg-primary-800 dark:bg-primary-600 dark:hover:bg-primary-700', clear: 'border border-gray-300 bg-white text-gray-900 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600', }, }, @@ -51,7 +51,7 @@ export const datePickerTheme: FlowbiteDatepickerTheme = { base: 'grid w-64 grid-cols-7', item: { base: 'block flex-1 cursor-pointer rounded-lg border-0 text-center text-sm font-semibold leading-9 text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600 ', - selected: 'bg-cyan-700 text-white hover:bg-cyan-600', + selected: 'bg-primary-700 text-white hover:bg-primary-600', disabled: 'text-gray-300 disabled', }, }, @@ -61,7 +61,7 @@ export const datePickerTheme: FlowbiteDatepickerTheme = { base: 'grid w-64 grid-cols-4', item: { base: 'block flex-1 cursor-pointer rounded-lg border-0 text-center text-sm font-semibold leading-9 text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600', - selected: 'bg-cyan-700 text-white hover:bg-cyan-600', + selected: 'bg-primary-700 text-white hover:bg-primary-600', disabled: 'text-gray-300 disabled', }, }, @@ -71,7 +71,7 @@ export const datePickerTheme: FlowbiteDatepickerTheme = { base: 'grid w-64 grid-cols-4', item: { base: 'block flex-1 cursor-pointer rounded-lg border-0 text-center text-sm font-semibold leading-9 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600 text-gray-900', - selected: 'bg-cyan-700 text-white hover:bg-cyan-600', + selected: 'bg-primary-700 text-white hover:bg-primary-600', disabled: 'text-gray-300 disabled', }, }, @@ -81,7 +81,7 @@ export const datePickerTheme: FlowbiteDatepickerTheme = { base: 'grid w-64 grid-cols-4', item: { base: 'block flex-1 cursor-pointer rounded-lg border-0 text-center text-sm font-semibold leading-9 hover:bg-gray-100 dark:text-white dark:hover:bg-gray-600 text-gray-900', - selected: 'bg-cyan-700 text-white hover:bg-cyan-600', + selected: 'bg-primary-700 text-white hover:bg-primary-600', disabled: 'text-gray-300 disabled', }, }, @@ -93,45 +93,47 @@ export const DateChangeModal = ({ restrictedUntil, setRestrictedUntil, setDateChangeModalOpen, - minDate, maxDate, + title, + description = null, }: { restrictedUntil: DateTime; setRestrictedUntil: (datetime: DateTime) => void; setDateChangeModalOpen: (isOpen: boolean) => void; - minDate: DateTime; maxDate: DateTime; + title: string; + description?: React.ReactNode; }) => { const [date, setDate] = useState(restrictedUntil.toJSDate()); return (
-
-

Change date until which sequences are restricted

- { - // "bg-cyan-700" - WE NEED TO KEEP THIS COMMENT OR tailwind removes this color we need for the datepicker - } - { - if (date !== null) { - setDate(date); - } else { - void logger.warn("Datepicker onChange received a null value, this shouldn't happen!"); - } - }} - inline - /> +
+

{title}

+ {description !== null &&

{description}

} +
+ { + if (date !== null) { + setDate(date); + } else { + void logger.warn("Datepicker onChange received a null value, this shouldn't happen!"); + } + }} + inline + /> +
+

+ Currently restricted until {restrictedUntil.toFormat('yyyy-MM-dd')} +

- {dataUseTermsType === restrictedDataUseTermsType && ( - <> -
- Currently restricted until {restrictedUntil.toFormat('yyyy-MM-dd')}.
- New restriction will be set to {newRestrictedDate.toFormat('yyyy-MM-dd')}. -
- { - if (date !== null) { - setNewRestrictedDate(DateTime.fromJSDate(date)); - } else { - void logger.warn( - "Datepicker onChange received a null value, this shouldn't happen!", - ); - } - }} - inline - /> - - )}
@@ -116,10 +78,7 @@ const InnerEditDataUseTermsButton: FC = ({ closeDialog(); useSetDataUseTerms.mutate({ accessions: accessionVersion, - newDataUseTerms: { - type: dataUseTermsType, - restrictedUntil: newRestrictedDate.toFormat('yyyy-MM-dd'), - }, + newDataUseTerms: selectedDataUseTerms, }); }} > diff --git a/website/src/components/DataUseTerms/EditDataUseTermsModal.tsx b/website/src/components/DataUseTerms/EditDataUseTermsModal.tsx new file mode 100644 index 0000000000..bea20f4986 --- /dev/null +++ b/website/src/components/DataUseTerms/EditDataUseTermsModal.tsx @@ -0,0 +1,303 @@ +import { DateTime } from 'luxon'; +import { useEffect, useState } from 'react'; + +import DataUseTermsSelector from './DataUseTermsSelector'; +import { errorToast, successToast } from './EditDataUseTermsToasts'; +import { routes } from '../../routes/routes'; +import { backendClientHooks, lapisClientHooks } from '../../services/serviceHooks'; +import { DATA_USE_TERMS_FIELD, DATA_USE_TERMS_RESTRICTED_UNTIL_FIELD } from '../../settings'; +import { + openDataUseTermsOption, + restrictedDataUseTermsOption, + type DataUseTerms, + type DataUseTermsOption, +} from '../../types/backend'; +import type { ClientConfig } from '../../types/runtimeConfig'; +import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; +import type { SequenceFilter } from '../SearchPage/DownloadDialog/SequenceFilters'; +import { ActiveFilters } from '../common/ActiveFilters'; +import { BaseDialog } from '../common/BaseDialog'; + +interface EditDataUseTermsModalProps { + lapisUrl: string; + clientConfig: ClientConfig; + accessToken?: string; + sequenceFilter: SequenceFilter; +} + +type LoadingState = { + type: 'loading'; +}; + +type ErrorState = { + type: 'error'; + error: any; // not too happy about the 'any' here, but I think it's fine +}; + +type ResultType = 'allOpen' | 'mixed' | 'allRestricted'; + +type LoadedState = { + type: 'loaded'; + resultType: ResultType; + totalCount: number; + openCount: number; + restrictedCount: number; + openAccessions: string[]; + restrictedAccessions: Map; // accession -> date + earliestRestrictedUntil: DateTime | null; +}; + +function getLoadedState(rows: Record[]): LoadedState { + const openAccessions: string[] = []; + const restrictedAccessions: Map = new Map(); + let earliestRestrictedUntil: DateTime | null = null; + + rows.forEach((row) => { + switch (row[DATA_USE_TERMS_FIELD] as DataUseTermsOption) { + case openDataUseTermsOption: + openAccessions.push(row.accession); + break; + case restrictedDataUseTermsOption: + const date = DateTime.fromFormat(row[DATA_USE_TERMS_RESTRICTED_UNTIL_FIELD], 'yyyy-MM-dd'); + if (earliestRestrictedUntil === null || date < earliestRestrictedUntil) { + earliestRestrictedUntil = date; + } + restrictedAccessions.set(row.accession, row[DATA_USE_TERMS_RESTRICTED_UNTIL_FIELD]); + break; + } + }); + + const totalCount = rows.length; + const openCount = openAccessions.length; + const restrictedCount = totalCount - openCount; + const resultType: ResultType = + openCount === totalCount ? 'allOpen' : restrictedCount === totalCount ? 'allRestricted' : 'mixed'; + + return { + type: 'loaded', + resultType, + totalCount, + openCount, + restrictedCount, + openAccessions, + restrictedAccessions, + earliestRestrictedUntil, + }; +} + +type DataState = LoadingState | ErrorState | LoadedState; + +export const EditDataUseTermsModal: React.FC = ({ + lapisUrl, + clientConfig, + accessToken, + sequenceFilter, +}) => { + const [isOpen, setIsOpen] = useState(false); + const openDialog = () => setIsOpen(true); + const closeDialog = () => setIsOpen(false); + + const detailsHook = lapisClientHooks(lapisUrl).zodiosHooks.useDetails({}, {}); + + useEffect(() => { + detailsHook.mutate({ + ...sequenceFilter.toApiParams(), + fields: ['accession', DATA_USE_TERMS_FIELD, DATA_USE_TERMS_RESTRICTED_UNTIL_FIELD], + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequenceFilter]); + + const [state, setState] = useState({ type: 'loading' }); + + useEffect(() => { + if (detailsHook.isLoading) { + return; + } + if (detailsHook.error !== null && state.type !== 'error') { + setState({ type: 'error', error: detailsHook.error }); + return; + } + if (detailsHook.data) { + const newState = getLoadedState(detailsHook.data.data); + setState(newState); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [detailsHook.data, detailsHook.error, detailsHook.isLoading]); + + return ( + <> + + + {state.type === 'loading' && 'loading'} + {state.type === 'error' && `error: ${state.error}`} + {state.type === 'loaded' && + (accessToken === undefined ? ( +

You need to be logged in to edit data use terms.

+ ) : ( + + ))} +
+ + ); +}; + +interface EditControlProps { + clientConfig: ClientConfig; + accessToken: string; + state: LoadedState; + sequenceFilter: SequenceFilter; + closeDialog: () => void; +} + +const EditControl: React.FC = ({ clientConfig, accessToken, state, closeDialog, sequenceFilter }) => { + const [dataUseTerms, setDataUseTerms] = useState(null); + + let affectedAccessions: string[] = []; + if (dataUseTerms != null) { + switch (dataUseTerms.type) { + case openDataUseTermsOption: + affectedAccessions = Array.from(state.restrictedAccessions.keys()); + break; + case restrictedDataUseTermsOption: + affectedAccessions = Array.from(state.restrictedAccessions.entries()) + .filter(([, date]) => date !== dataUseTerms.restrictedUntil) + .map(([accession]) => accession); + } + } + + switch (state.resultType) { + case 'allOpen': + return ( + <> + +

All selected sequences are already open, nothing to edit.

+ + ); + case 'mixed': + return ( +
+ +

+ {state.openCount} open and {state.restrictedCount} restricted sequences selected. +

+

+ You can release all the {state.restrictedCount} restricted sequences, moving them to the{' '} + + Open Data Use Terms + + . If you want to pick a date for the restricted sequences, please narrow your selection down to + just restricted sequences. You can use the filters to do so. +

+ +
+ ); + case 'allRestricted': + const earliestDateDisplay = state.earliestRestrictedUntil!.toFormat('yyyy-MM-dd'); + return ( +
+ +

+ Choose the new data use terms for {state.restrictedCount} restricted sequence + {state.restrictedCount > 1 ? 's' : ''} +

+
+ + The release date of a sequence cannot be updated to be later than the date that is + currently set. This means that the new release date can only be between now and the + earliest release date for any of the selected sequences, which is{' '} + {earliestDateDisplay}. + + } + /> +
+ +
+ ); + } +}; + +interface CancelSubmitButtonProps { + clientConfig: ClientConfig; + accessToken: string; + newTerms: DataUseTerms | null; + affectedAccessions: string[]; + closeDialog: () => void; +} + +const CancelSubmitButtons: React.FC = ({ + clientConfig, + accessToken, + closeDialog, + newTerms, + affectedAccessions, +}) => { + const setDataUseTermsHook = backendClientHooks(clientConfig).useSetDataUseTerms( + { headers: createAuthorizationHeader(accessToken) }, + { onError: errorToast, onSuccess: successToast }, + ); + + const updatePossible = newTerms !== null && affectedAccessions.length !== 0; + + const maybeS = affectedAccessions.length > 1 ? 's' : ''; + let buttonText = 'Update'; + if (newTerms) { + switch (newTerms.type) { + case restrictedDataUseTermsOption: + if (affectedAccessions.length !== 0) { + buttonText = `Update release date on ${affectedAccessions.length} sequence${maybeS}`; + } else { + buttonText = 'Nothing to update'; + } + break; + case openDataUseTermsOption: + buttonText = `Release ${affectedAccessions.length} sequence${maybeS} now`; + break; + } + } + + return ( +
+ + +
+ ); +}; diff --git a/website/src/components/DataUseTerms/EditDataUseTermsToasts.ts b/website/src/components/DataUseTerms/EditDataUseTermsToasts.ts new file mode 100644 index 0000000000..ceb68eae03 --- /dev/null +++ b/website/src/components/DataUseTerms/EditDataUseTermsToasts.ts @@ -0,0 +1,16 @@ +import { toast } from 'react-toastify'; + +import { stringifyMaybeAxiosError } from '../../utils/stringifyMaybeAxiosError'; + +export function successToast() { + toast.success('Data use terms updated successfully. Changes take some time propagate and become visible here.', { + autoClose: 4000, + }); +} + +export function errorToast(error: unknown) { + toast.error('Failed to edit terms of use: ' + stringifyMaybeAxiosError(error), { + position: 'top-center', + autoClose: false, + }); +} diff --git a/website/src/components/ReviewPage/ReviewCard.tsx b/website/src/components/ReviewPage/ReviewCard.tsx index 0c5498e75c..20b7fe5848 100644 --- a/website/src/components/ReviewPage/ReviewCard.tsx +++ b/website/src/components/ReviewPage/ReviewCard.tsx @@ -7,7 +7,7 @@ import { inProcessingStatus, type ProcessingAnnotation, receivedStatus, - restrictedDataUseTermsType, + restrictedDataUseTermsOption, type SequenceEntryStatus, type SequenceEntryStatusNames, type SequenceEntryToEdit, @@ -257,14 +257,14 @@ type DataUseTermsIconProps = { }; const DataUseTermsIcon: FC = ({ dataUseTerms, accession }) => { const hintText = - dataUseTerms.type === restrictedDataUseTermsType + dataUseTerms.type === restrictedDataUseTermsOption ? `Under the Restricted Use Terms until ${dataUseTerms.restrictedUntil}` : `To be released as open data`; return ( <>
- {dataUseTerms.type === restrictedDataUseTermsType ? : } + {dataUseTerms.type === restrictedDataUseTermsOption ? : }
diff --git a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.spec.tsx b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.spec.tsx deleted file mode 100644 index bf3735cee8..0000000000 --- a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { ActiveDownloadFilters } from './ActiveDownloadFilters'; - -describe('ActiveDownloadFilters', () => { - describe('with LAPIS filters', () => { - it('renders empty filters as null', () => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders filters correctly', () => { - render( - , - ); - expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); - expect(screen.queryByText('field1: value1')).toBeInTheDocument(); - expect(screen.queryByText(/A123T,G234C/)).toBeInTheDocument(); - }); - }); - - describe('with selected sequences', () => { - it('renders an empty selection as null', () => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders a single selected sequence correctly', () => { - render( - , - ); - expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); - expect(screen.getByText('1 sequence selected')).toBeInTheDocument(); - }); - - it('renders a two selected sequences correctly', () => { - render( - , - ); - expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); - expect(screen.getByText('2 sequences selected')).toBeInTheDocument(); - }); - }); -}); diff --git a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx b/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx deleted file mode 100644 index bf828a1572..0000000000 --- a/website/src/components/SearchPage/DownloadDialog/ActiveDownloadFilters.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { FC } from 'react'; - -import type { DownloadParameters } from './DownloadParameters'; - -type ActiveDownloadFiltersProps = { - downloadParameters: DownloadParameters; -}; - -export const ActiveDownloadFilters: FC = ({ downloadParameters }) => { - let badges = null; - const badgeClasses = 'border-black border rounded-full px-2 py-1 text-sm'; - - switch (downloadParameters.type) { - case 'filter': { - let filterValues = Object.entries(downloadParameters.lapisSearchParameters) - .filter((vals) => vals[1] !== undefined && vals[1] !== '') - .filter( - ([name, val]) => - !( - Object.keys(downloadParameters.hiddenFieldValues).includes(name) && - downloadParameters.hiddenFieldValues[name] === val - ), - ) - .map(([name, filterValue]) => ({ name, filterValue: filterValue !== null ? filterValue : '' })); - - filterValues = filterValues.filter(({ filterValue }) => filterValue.length > 0); - - if (filterValues.length > 0) { - badges = filterValues.map(({ name, filterValue }) => ( -
- {name}: {typeof filterValue === 'object' ? filterValue.join(', ') : filterValue} -
- )); - } - break; - } - case 'select': { - const count = downloadParameters.selectedSequences.size; - if (count > 0) { - badges = ( -
- {count.toLocaleString()} sequence{count === 1 ? '' : 's'} selected -
- ); - } - break; - } - } - - if (badges === null) return null; - - return ( -
-

Active filters:

-
{badges}
-
- ); -}; diff --git a/website/src/components/SearchPage/DownloadDialog/DowloadDialogButton.tsx b/website/src/components/SearchPage/DownloadDialog/DowloadDialogButton.tsx index 96dc741fb5..53fd8368e0 100644 --- a/website/src/components/SearchPage/DownloadDialog/DowloadDialogButton.tsx +++ b/website/src/components/SearchPage/DownloadDialog/DowloadDialogButton.tsx @@ -1,32 +1,29 @@ import type { FC } from 'react'; -import type { DownloadParameters } from './DownloadParameters'; +import { type SequenceFilter } from './SequenceFilters'; import { formatNumberWithDefaultLocale } from '../../../utils/formatNumber'; type DownloadDialogButtonProps = { onClick: () => void; - downloadParams: DownloadParameters; + sequenceFilter: SequenceFilter; }; /** * The button that is displayed above the table and used to open the dialog. * Also shows the number of selected entries, if a selection is made in the table. */ -export const DownloadDialogButton: FC = ({ onClick, downloadParams }) => { +export const DownloadDialogButton: FC = ({ onClick, sequenceFilter }) => { let buttonText = ''; let buttonWidthClass = ''; // fix the width so we don't get layout shifts with changing number of selected entries - switch (downloadParams.type) { - case 'filter': - buttonText = 'Download all entries'; - buttonWidthClass = 'w-44'; - break; - case 'select': - const sequenceCount = downloadParams.selectedSequences.size; - const formattedCount = formatNumberWithDefaultLocale(sequenceCount); - const entries = sequenceCount === 1 ? 'entry' : 'entries'; - buttonText = `Download ${formattedCount} selected ${entries}`; - buttonWidthClass = 'w-[15rem]'; // this width is fine for up to two digit numbers - break; + const sequenceCount = sequenceFilter.sequenceCount(); + if (sequenceCount === undefined) { + buttonText = 'Download all entries'; + buttonWidthClass = 'w-44'; + } else { + const formattedCount = formatNumberWithDefaultLocale(sequenceCount); + const entries = sequenceCount === 1 ? 'entry' : 'entries'; + buttonText = `Download ${formattedCount} selected ${entries}`; + buttonWidthClass = 'w-[15rem]'; // this width is fine for up to two digit numbers } return ( - - + + +
+ + +
+
+
- +
); }; diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadParameters.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadParameters.tsx deleted file mode 100644 index b4fd078370..0000000000 --- a/website/src/components/SearchPage/DownloadDialog/DownloadParameters.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { FieldValues } from '../../../types/config.ts'; - -export type FilterDownload = { - type: 'filter'; - lapisSearchParameters: Record; - hiddenFieldValues: FieldValues; -}; - -export type SelectDownload = { - type: 'select'; - selectedSequences: Set; -}; - -/** - * Either the sequences to download are specified as a bunch of filters, - * or sequences are specified directly by ID. - */ -export type DownloadParameters = FilterDownload | SelectDownload; diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts b/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts index 0c63973d3c..2f9b24bac7 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts +++ b/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts @@ -1,7 +1,7 @@ import kebabCase from 'just-kebab-case'; import { getEndpoint, dataTypeForFilename, type DownloadDataType } from './DownloadDataType.ts'; -import type { DownloadParameters } from './DownloadParameters.tsx'; +import type { SequenceFilter } from './SequenceFilters.tsx'; import { IS_REVOCATION_FIELD, metadataDefaultDownloadDataFormat, VERSION_STATUS_FIELD } from '../../../settings.ts'; import { versionStatuses } from '../../../types/lapis.ts'; @@ -32,7 +32,7 @@ export class DownloadUrlGenerator { this.lapisUrl = lapisUrl; } - public generateDownloadUrl(downloadParameters: DownloadParameters, option: DownloadOption) { + public generateDownloadUrl(downloadParameters: SequenceFilter, option: DownloadOption) { const baseUrl = `${this.lapisUrl}${getEndpoint(option.dataType)}`; const params = new URLSearchParams(); @@ -52,47 +52,9 @@ export class DownloadUrlGenerator { params.set('compression', option.compression); } - switch (downloadParameters.type) { - case 'filter': - const lapisSearchParameters = downloadParameters.lapisSearchParameters; - if (lapisSearchParameters.accession !== undefined) { - for (const accession of lapisSearchParameters.accession) { - params.append('accession', accession); - } - } - - const mutationKeys = [ - 'nucleotideMutations', - 'aminoAcidMutations', - 'nucleotideInsertions', - 'aminoAcidInsertions', - ]; - - for (const [key, value] of Object.entries(lapisSearchParameters)) { - // Skip accession and mutations - if (key === 'accession' || mutationKeys.includes(key)) { - continue; - } - const stringValue = String(value); - const trimmedValue = stringValue.trim(); - if (trimmedValue.length > 0) { - params.set(key, trimmedValue); - } - } - - mutationKeys.forEach((key) => { - if (lapisSearchParameters[key] !== undefined) { - params.set(key, lapisSearchParameters[key].join(',')); - } - }); - break; - case 'select': - const sortedIds = Array.from(downloadParameters.selectedSequences).sort(); - sortedIds.forEach((accessionVersion) => { - params.append('accessionVersion', accessionVersion); - }); - break; - } + downloadParameters.toUrlSearchParams().forEach(([name, value]) => { + params.append(name, value); + }); return { url: `${baseUrl}?${params}`, diff --git a/website/src/components/SearchPage/DownloadDialog/SequenceFilters.tsx b/website/src/components/SearchPage/DownloadDialog/SequenceFilters.tsx new file mode 100644 index 0000000000..35891823df --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/SequenceFilters.tsx @@ -0,0 +1,151 @@ +import type { FieldValues } from '../../../types/config.ts'; + +export interface SequenceFilter { + /** + * Whether this filter is actually filtering anything or not. + */ + isEmpty(): boolean; + + /** + * The count of sequences that match the filter, if known. + */ + sequenceCount(): number | undefined; + + /** + * Return the filter as params to use in API Queries. + */ + toApiParams(): Record; + + /** + * Return the filter as params to build a URL from. + */ + toUrlSearchParams(): [string, string][]; + + /** + * Return a map of keys to human readable descriptions of the filters to apply. + */ + toDisplayStrings(): Map; +} + +/** + * Filter sequences based on certain fields that have to match, i.e. 'country == China' or + * 'data use terms == OPEN'. + */ +export class FieldFilter implements SequenceFilter { + private readonly lapisSearchParameters: Record; + private readonly hiddenFieldValues: FieldValues; + + constructor(lapisSearchParamters: Record, hiddenFieldValues: FieldValues) { + this.lapisSearchParameters = lapisSearchParamters; + this.hiddenFieldValues = hiddenFieldValues; + } + + public sequenceCount(): number | undefined { + return undefined; // sequence count not known + } + + public isEmpty(): boolean { + return this.toDisplayStrings().size === 0; + } + + public toApiParams(): Record { + return this.lapisSearchParameters; + } + + public toUrlSearchParams(): [string, string][] { + const result: [string, string][] = []; + + // keys that need special handling + const accessionKey = 'accession'; + const mutationKeys = [ + 'nucleotideMutations', + 'aminoAcidMutations', + 'nucleotideInsertions', + 'aminoAcidInsertions', + ]; + const skipKeys = mutationKeys.concat([accessionKey]); + + // accession + if (this.lapisSearchParameters.accession !== undefined) { + this.lapisSearchParameters.accession.forEach((a: any) => result.push(['accession', String(a)])); + } + + // mutations + mutationKeys.forEach((key) => { + if (this.lapisSearchParameters[key] !== undefined) { + result.push([key, this.lapisSearchParameters[key].join(',')]); + } + }); + + // default keys + for (const [key, value] of Object.entries(this.lapisSearchParameters)) { + if (skipKeys.includes(key)) { + continue; + } + const stringValue = String(value); + const trimmedValue = stringValue.trim(); + if (trimmedValue.length > 0) { + result.push([key, trimmedValue]); + } + } + + return result; + } + + public toDisplayStrings(): Map { + return new Map( + Object.entries(this.lapisSearchParameters) + .filter((vals) => vals[1] !== undefined && vals[1] !== '') + .filter( + ([name, val]) => + !(Object.keys(this.hiddenFieldValues).includes(name) && this.hiddenFieldValues[name] === val), + ) + .map(([name, filterValue]) => ({ name, filterValue: filterValue !== null ? filterValue : '' })) + .filter(({ filterValue }) => filterValue.length > 0) + .map(({ name, filterValue }) => [ + name, + `${name}: ${typeof filterValue === 'object' ? filterValue.join(', ') : filterValue}`, + ]), + ); + } +} + +/** + * Filter sequences based on an explicit set of accessionVersions. + */ +export class SelectFilter implements SequenceFilter { + private readonly selectedSequences: Set; + + constructor(selectedSequences: Set) { + this.selectedSequences = selectedSequences; + } + + public sequenceCount(): number | undefined { + return this.selectedSequences.size; + } + + public isEmpty(): boolean { + return this.selectedSequences.size === 0; + } + + public toApiParams(): Record { + return { accessionVersion: Array.from(this.selectedSequences).sort() }; + } + + public toUrlSearchParams(): [string, string][] { + const result: [string, string][] = []; + Array.from(this.selectedSequences) + .sort() + .forEach((sequence) => { + result.push(['accessionVersion', sequence]); + }); + return result; + } + + public toDisplayStrings(): Map { + const count = this.selectedSequences.size; + if (count === 0) return new Map(); + const description = `${count.toLocaleString()} sequence${count === 1 ? '' : 's'} selected`; + return new Map([['selectedSequences', description]]); + } +} diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index f73b62630a..1838d70212 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { CustomizeModal } from './CustomizeModal.tsx'; import { DownloadDialog } from './DownloadDialog/DownloadDialog.tsx'; import { DownloadUrlGenerator } from './DownloadDialog/DownloadUrlGenerator.ts'; +import { FieldFilter, SelectFilter, type SequenceFilter } from './DownloadDialog/SequenceFilters.tsx'; import { RecentSequencesBanner } from './RecentSequencesBanner.tsx'; import { SearchForm } from './SearchForm'; import { SearchPagination } from './SearchPagination'; @@ -13,7 +14,7 @@ import { Table, type TableSequenceData } from './Table'; import useQueryAsState from './useQueryAsState.js'; import { getLapisUrl } from '../../config.ts'; import { lapisClientHooks } from '../../services/serviceHooks.ts'; -import { pageSize } from '../../settings'; +import { DATA_USE_TERMS_FIELD, pageSize } from '../../settings'; import type { Group } from '../../types/backend.ts'; import { type Schema, type FieldValues } from '../../types/config.ts'; import { type OrderBy } from '../../types/lapis.ts'; @@ -30,6 +31,7 @@ import { getMetadataSchemaWithExpandedRanges, consolidateGroupedFields, } from '../../utils/search.ts'; +import { EditDataUseTermsModal } from '../DataUseTerms/EditDataUseTermsModal.tsx'; import ErrorBox from '../common/ErrorBox.tsx'; interface InnerSearchFullUIProps { @@ -43,6 +45,7 @@ interface InnerSearchFullUIProps { initialData: TableSequenceData[]; initialCount: number; initialQueryDict: QueryState; + showEditDataUseTermsControls?: boolean; } interface QueryState { [key: string]: string; @@ -68,6 +71,7 @@ export const InnerSearchFullUI = ({ initialData, initialCount, initialQueryDict, + showEditDataUseTermsControls = false, }: InnerSearchFullUIProps) => { if (!hiddenFieldValues) { hiddenFieldValues = {}; @@ -90,9 +94,10 @@ export const InnerSearchFullUI = ({ return getFieldVisibilitiesFromQuery(schema, state); }, [schema, state]); - const columnVisibilities = useMemo(() => { - return getColumnVisibilitiesFromQuery(schema, state); - }, [schema, state]); + const columnVisibilities = useMemo( + () => getColumnVisibilitiesFromQuery(schema, state).set(DATA_USE_TERMS_FIELD, showEditDataUseTermsControls), + [schema, state, showEditDataUseTermsControls], + ); const columnsToShow = useMemo(() => { return schema.metadata @@ -199,6 +204,10 @@ export const InnerSearchFullUI = ({ return getLapisSearchParameters(fieldValues, referenceGenomesSequenceNames, schema); }, [fieldValues, referenceGenomesSequenceNames, schema]); + const sequencesFilter: SequenceFilter = sequencesSelected + ? new SelectFilter(selectedSeqs) + : new FieldFilter(lapisSearchParameters, hiddenFieldValues); + useEffect(() => { aggregatedHook.mutate({ ...lapisSearchParameters, @@ -340,15 +349,23 @@ export const InnerSearchFullUI = ({
+ {showEditDataUseTermsControls && ( + + )} {sequencesSelected ? (
diff --git a/website/src/components/SequenceDetailsPage/DataUseTermsHistoryModal.tsx b/website/src/components/SequenceDetailsPage/DataUseTermsHistoryModal.tsx index 7966d469e4..b22ebf6054 100644 --- a/website/src/components/SequenceDetailsPage/DataUseTermsHistoryModal.tsx +++ b/website/src/components/SequenceDetailsPage/DataUseTermsHistoryModal.tsx @@ -1,7 +1,7 @@ import { DateTime, FixedOffsetZone } from 'luxon'; import { type FC, useRef } from 'react'; -import { type DataUseTermsHistoryEntry, restrictedDataUseTermsType } from '../../types/backend.ts'; +import { type DataUseTermsHistoryEntry, restrictedDataUseTermsOption } from '../../types/backend.ts'; export type DataUseTermsHistoryProps = { dataUseTermsHistory: DataUseTermsHistoryEntry[]; @@ -62,7 +62,7 @@ const DataUseTermsHistoryDialog: FC = ({ data {row.userName} {row.dataUseTerms.type} - {row.dataUseTerms.type === restrictedDataUseTermsType + {row.dataUseTerms.type === restrictedDataUseTermsOption ? ' until ' + row.dataUseTerms.restrictedUntil : ''} diff --git a/website/src/components/Submission/DataUploadForm.tsx b/website/src/components/Submission/DataUploadForm.tsx index 638675b55b..daabfa1f5a 100644 --- a/website/src/components/Submission/DataUploadForm.tsx +++ b/website/src/components/Submission/DataUploadForm.tsx @@ -1,9 +1,8 @@ import { isErrorFromAlias } from '@zodios/core'; import type { AxiosError } from 'axios'; -import { type DateTime } from 'luxon'; +import { DateTime } from 'luxon'; import { type ElementType, type FormEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { DateChangeModal } from './DateChangeModal'; import { dataUploadDocsUrl } from './dataUploadDocsUrl.ts'; import { getClientLogger } from '../../clientLogger.ts'; import DataUseTermsSelector from '../../components/DataUseTerms/DataUseTermsSelector'; @@ -12,10 +11,10 @@ import { routes } from '../../routes/routes.ts'; import { backendApi } from '../../services/backendApi.ts'; import { backendClientHooks } from '../../services/serviceHooks.ts'; import { - type DataUseTermsType, + type DataUseTermsOption, type Group, - openDataUseTermsType, - restrictedDataUseTermsType, + openDataUseTermsOption, + restrictedDataUseTermsOption, } from '../../types/backend.ts'; import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; @@ -45,27 +44,14 @@ const logger = getClientLogger('DataUploadForm'); const DataUseTerms = ({ dataUseTermsType, setDataUseTermsType, - restrictedUntil, setRestrictedUntil, }: { - dataUseTermsType: DataUseTermsType; - setDataUseTermsType: (dataUseTermsType: DataUseTermsType) => void; - restrictedUntil: DateTime; + dataUseTermsType: DataUseTermsOption; + setDataUseTermsType: (dataUseTermsType: DataUseTermsOption) => void; setRestrictedUntil: (restrictedUntil: DateTime) => void; }) => { - const [dateChangeModalOpen, setDateChangeModalOpen] = useState(false); - return (
- {dateChangeModalOpen && ( - - )}

Data use terms

Choose how your data can be used

@@ -78,20 +64,16 @@ const DataUseTerms = ({
{ + setDataUseTermsType(terms.type); + if (terms.type === restrictedDataUseTermsOption) { + setRestrictedUntil(DateTime.fromFormat(terms.restrictedUntil, 'yyyy-MM-dd')); + } + }} /> - {dataUseTermsType === restrictedDataUseTermsType && ( -
- Data use will be restricted until {restrictedUntil.toFormat('yyyy-MM-dd')}.{' '} - -
- )}
@@ -283,7 +265,7 @@ const InnerDataUploadForm = ({ const [exampleEntries, setExampleEntries] = useState(10); const { submit, revise, isLoading } = useSubmitFiles(accessToken, organism, clientConfig, onSuccess, onError); - const [dataUseTermsType, setDataUseTermsType] = useState(openDataUseTermsType); + const [dataUseTermsType, setDataUseTermsType] = useState(openDataUseTermsOption); const [restrictedUntil, setRestrictedUntil] = useState(dateTimeInMonths(6)); const [agreedToINSDCUploadTerms, setAgreedToINSDCUploadTerms] = useState(false); @@ -337,7 +319,9 @@ const InnerDataUploadForm = ({ groupId, dataUseTermsType, restrictedUntil: - dataUseTermsType === restrictedDataUseTermsType ? restrictedUntil.toFormat('yyyy-MM-dd') : null, + dataUseTermsType === restrictedDataUseTermsOption + ? restrictedUntil.toFormat('yyyy-MM-dd') + : null, }); break; case 'revise': @@ -443,7 +427,6 @@ const InnerDataUploadForm = ({ )} @@ -454,7 +437,7 @@ const InnerDataUploadForm = ({
- {dataUseTermsType === restrictedDataUseTermsType && ( + {dataUseTermsType === restrictedDataUseTermsOption && (

Your data will be available on Pathoplexus, under the restricted use terms until{' '} {restrictedUntil.toFormat('yyyy-MM-dd')}. After the restricted period your data will @@ -465,7 +448,7 @@ const InnerDataUploadForm = ({ databases (ENA, DDBJ, NCBI).

)} - {dataUseTermsType === openDataUseTermsType && ( + {dataUseTermsType === openDataUseTermsOption && (

Your data will be available on Pathoplexus under the open use terms. It will additionally be made publicly available through the{' '} diff --git a/website/src/components/common/ActiveFilters.spec.tsx b/website/src/components/common/ActiveFilters.spec.tsx new file mode 100644 index 0000000000..1d27977179 --- /dev/null +++ b/website/src/components/common/ActiveFilters.spec.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { ActiveFilters } from './ActiveFilters'; +import { FieldFilter, SelectFilter } from '../SearchPage/DownloadDialog/SequenceFilters'; + +describe('ActiveDownloadFilters', () => { + describe('with LAPIS filters', () => { + it('renders empty filters as null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders filters correctly', () => { + render( + , + ); + expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); + expect(screen.queryByText('field1: value1')).toBeInTheDocument(); + expect(screen.queryByText(/A123T,G234C/)).toBeInTheDocument(); + }); + }); + + describe('with selected sequences', () => { + it('renders an empty selection as null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a single selected sequence correctly', () => { + render(); + expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); + expect(screen.getByText('1 sequence selected')).toBeInTheDocument(); + }); + + it('renders a two selected sequences correctly', () => { + render(); + expect(screen.queryByText(/Active filters/)).toBeInTheDocument(); + expect(screen.getByText('2 sequences selected')).toBeInTheDocument(); + }); + }); +}); diff --git a/website/src/components/common/ActiveFilters.tsx b/website/src/components/common/ActiveFilters.tsx new file mode 100644 index 0000000000..dd48e4acae --- /dev/null +++ b/website/src/components/common/ActiveFilters.tsx @@ -0,0 +1,24 @@ +import type { FC } from 'react'; + +import type { SequenceFilter } from '../SearchPage/DownloadDialog/SequenceFilters'; + +type ActiveFiltersProps = { + sequenceFilter: SequenceFilter; +}; + +export const ActiveFilters: FC = ({ sequenceFilter: downloadParameters }) => { + if (downloadParameters.isEmpty()) return null; + + return ( +

+

Active filters

+
+ {[...downloadParameters.toDisplayStrings()].map(([key, desc]) => ( +
+ {desc} +
+ ))} +
+
+ ); +}; diff --git a/website/src/components/common/BaseDialog.tsx b/website/src/components/common/BaseDialog.tsx new file mode 100644 index 0000000000..8787673f42 --- /dev/null +++ b/website/src/components/common/BaseDialog.tsx @@ -0,0 +1,43 @@ +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; +import React, { type ReactNode } from 'react'; + +interface BaseDialogProps { + title: string; + isOpen: boolean; + onClose: () => void; + children: ReactNode; +} + +export const BaseDialog: React.FC = ({ title, isOpen, onClose, children }) => { + return ( + +
+
+
+ + + {title} + + + {children} + +
+
+
+ ); +}; + +interface CloseButtonProps { + onClick: () => void; +} + +const CloseButton: React.FC = ({ onClick }) => { + return ( + + ); +}; diff --git a/website/src/pages/[organism]/submission/[groupId]/released.astro b/website/src/pages/[organism]/submission/[groupId]/released.astro index 1281bc5556..50617c827d 100644 --- a/website/src/pages/[organism]/submission/[groupId]/released.astro +++ b/website/src/pages/[organism]/submission/[groupId]/released.astro @@ -62,5 +62,6 @@ const { data, totalCount } = await performLapisSearchQueries( initialData={data} initialCount={totalCount} initialQueryDict={initialQueryDict} + showEditDataUseTermsControls /> diff --git a/website/src/settings.ts b/website/src/settings.ts index d5cd9b960a..9d0836a5e9 100644 --- a/website/src/settings.ts +++ b/website/src/settings.ts @@ -11,6 +11,7 @@ export const SUBMITTER_FIELD = 'submitter'; export const GROUP_NAME_FIELD = 'groupName'; export const GROUP_ID_FIELD = 'groupId'; export const DATA_USE_TERMS_FIELD = 'dataUseTerms'; +export const DATA_USE_TERMS_RESTRICTED_UNTIL_FIELD = 'dataUseTermsRestrictedUntil'; export const VERSION_COMMENT_FIELD = 'versionComment'; export const SUBMISSION_ID_FIELD = 'submissionId'; diff --git a/website/src/types/backend.ts b/website/src/types/backend.ts index 63d834fdc2..dc31c353e9 100644 --- a/website/src/types/backend.ts +++ b/website/src/types/backend.ts @@ -91,16 +91,16 @@ export const accessionVersionsFilterWithDeletionScope = accessionVersionsFilter. }), ); -export const openDataUseTermsType = 'OPEN'; +export const openDataUseTermsOption = 'OPEN'; -export const restrictedDataUseTermsType = 'RESTRICTED'; +export const restrictedDataUseTermsOption = 'RESTRICTED'; -export const dataUseTermsTypes = [restrictedDataUseTermsType, openDataUseTermsType] as const; +export const dataUseTermsOptions = [restrictedDataUseTermsOption, openDataUseTermsOption] as const; -export type DataUseTermsType = typeof openDataUseTermsType | typeof restrictedDataUseTermsType; +export type DataUseTermsOption = typeof openDataUseTermsOption | typeof restrictedDataUseTermsOption; export const restrictedDataUseTerms = z.object({ - type: z.literal(restrictedDataUseTermsType), + type: z.literal(restrictedDataUseTermsOption), restrictedUntil: z.string(), }); @@ -109,7 +109,7 @@ export type RestrictedDataUseTerms = z.infer; export const dataUseTerms = z.union([ restrictedDataUseTerms, z.object({ - type: z.literal(openDataUseTermsType), + type: z.literal(openDataUseTermsOption), }), ]); @@ -228,7 +228,7 @@ export const uploadFiles = z.object({ export const submitFiles = uploadFiles.merge( z.object({ groupId: z.number(), - dataUseTermsType: z.enum(dataUseTermsTypes), + dataUseTermsType: z.enum(dataUseTermsOptions), restrictedUntil: z.string().nullable(), }), ); diff --git a/website/tests/e2e.fixture.ts b/website/tests/e2e.fixture.ts index cbac0b5281..aedb608a2d 100644 --- a/website/tests/e2e.fixture.ts +++ b/website/tests/e2e.fixture.ts @@ -20,7 +20,7 @@ import { throwOnConsole } from './util/throwOnConsole.ts'; import { ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE } from '../src/middleware/authMiddleware'; import { BackendClient } from '../src/services/backendClient'; import { GroupManagementClient } from '../src/services/groupManagementClient.ts'; -import { type DataUseTerms, type NewGroup, openDataUseTermsType } from '../src/types/backend.ts'; +import { type DataUseTerms, type NewGroup, openDataUseTermsOption } from '../src/types/backend.ts'; import { getClientMetadata } from '../src/utils/clientMetadata.ts'; import { realmPath } from '../src/utils/realmPath.ts'; @@ -40,7 +40,7 @@ type E2EFixture = { export const dummyOrganism = { key: 'dummy-organism', displayName: 'Test Dummy Organism' }; export const openDataUseTerms: DataUseTerms = { - type: openDataUseTermsType, + type: openDataUseTermsOption, }; export const baseUrl = 'http://localhost:3000'; diff --git a/website/tests/util/backendCalls.ts b/website/tests/util/backendCalls.ts index 9e2a10a3f8..c2ab8c47bb 100644 --- a/website/tests/util/backendCalls.ts +++ b/website/tests/util/backendCalls.ts @@ -4,8 +4,8 @@ import { createFileContent, createModifiedFileContent } from './createFileConten import { type Accession, type AccessionVersion, - openDataUseTermsType, - restrictedDataUseTermsType, + openDataUseTermsOption, + restrictedDataUseTermsOption, } from '../../src/types/backend.ts'; import { createAuthorizationHeader } from '../../src/utils/createAuthorizationHeader.ts'; import { backendClient, dummyOrganism, testSequenceCount } from '../e2e.fixture.ts'; @@ -24,7 +24,7 @@ export const submitViaApi = async ( metadataFile: new File([fileContent.metadataContent], 'metadata.tsv'), sequenceFile: new File([fileContent.sequenceFileContent], 'sequences.fasta'), groupId, - dataUseTermsType: restricted === true ? restrictedDataUseTermsType : openDataUseTermsType, + dataUseTermsType: restricted === true ? restrictedDataUseTermsOption : openDataUseTermsOption, restrictedUntil: restricted === true ? DateTime.now().plus({ days: 1 }).toFormat('yyyy-MM-dd') : null, }, {