diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt index 761966c09..118749f33 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt @@ -239,7 +239,7 @@ class SubmissionDatabaseService( accessionPreconditionValidator.validateAccessionVersions( submitter, accessionVersionsFilter, - listOf(AWAITING_APPROVAL), + listOf(AWAITING_APPROVAL, AWAITING_APPROVAL_FOR_REVOCATION), organism, ) } @@ -255,7 +255,7 @@ class SubmissionDatabaseService( (DataUseTermsTable.isNewestDataUseTerms) }, ).select { - val statusCondition = table.statusIs(AWAITING_APPROVAL) + val statusCondition = table.statusIsOneOf(listOf(AWAITING_APPROVAL, AWAITING_APPROVAL_FOR_REVOCATION)) val accessionCondition = if (accessionVersionsFilter !== null) { table.accessionVersionIsIn(accessionVersionsFilter) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt index 8d7918488..182b86c46 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ApproveProcessedDataEndpointTest.kt @@ -9,6 +9,7 @@ import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.ApproveDataScope import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE import org.loculus.backend.api.Status.AWAITING_APPROVAL +import org.loculus.backend.api.Status.AWAITING_APPROVAL_FOR_REVOCATION import org.loculus.backend.api.Status.IN_PROCESSING import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest @@ -156,7 +157,8 @@ class ApproveProcessedDataEndpointTest( jsonPath( "$.detail", containsString( - "Accession versions are in not in one of the states [$AWAITING_APPROVAL]: " + + "Accession versions are in not in one of the states " + + "[$AWAITING_APPROVAL, $AWAITING_APPROVAL_FOR_REVOCATION]: " + "${accessionVersionNotInCorrectState.first().displayAccessionVersion()} - $IN_PROCESSING", ), ), diff --git a/website/package-lock.json b/website/package-lock.json index d41c255c8..6bdacaf20 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -34,6 +34,7 @@ "react-chartjs-2": "^5.2.0", "react-confirm-alert": "^3.0.6", "react-dom": "^18.2.0", + "react-toastify": "^10.0.4", "react-tooltip": "^5.26.3", "unplugin-icons": "^0.18.5", "winston": "^3.11.0", @@ -15795,6 +15796,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-toastify": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.4.tgz", + "integrity": "sha512-etR3RgueY8pe88SA67wLm8rJmL1h+CLqUGHuAoNsseW35oTGJEri6eBTyaXnFKNQ80v/eO10hBYLgz036XRGgA==", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-tooltip": { "version": "5.26.3", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.26.3.tgz", diff --git a/website/package.json b/website/package.json index 9741bb81f..605239df5 100644 --- a/website/package.json +++ b/website/package.json @@ -43,6 +43,7 @@ "react-chartjs-2": "^5.2.0", "react-confirm-alert": "^3.0.6", "react-dom": "^18.2.0", + "react-toastify": "^10.0.4", "react-tooltip": "^5.26.3", "unplugin-icons": "^0.18.5", "winston": "^3.11.0", diff --git a/website/playwright.config.ts b/website/playwright.config.ts index 1c1da480f..0f4b5d64e 100644 --- a/website/playwright.config.ts +++ b/website/playwright.config.ts @@ -20,17 +20,19 @@ export default defineConfig({ globalSetup: './tests/playwrightSetup.ts', projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { + ...process.env.ALL_BROWSERS === 'true' ? [{ name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, - }, + }] : [], + ], -}); +}); \ No newline at end of file diff --git a/website/src/components/ReviewPage/ReviewCard.tsx b/website/src/components/ReviewPage/ReviewCard.tsx index 3ff7ecfc9..383db4421 100644 --- a/website/src/components/ReviewPage/ReviewCard.tsx +++ b/website/src/components/ReviewPage/ReviewCard.tsx @@ -4,6 +4,7 @@ import { Tooltip } from 'react-tooltip'; import { backendClientHooks } from '../../services/serviceHooks.ts'; import { awaitingApprovalStatus, + awaitingApprovalForRevocationStatus, type DataUseTerms, hasErrorsStatus, inProcessingStatus, @@ -65,6 +66,14 @@ export const ReviewCard: FC = ({ value={sequenceEntryStatus.submissionId} /> {data !== undefined && } + {sequenceEntryStatus.status === awaitingApprovalForRevocationStatus && ( + + )} = ({ return (
- - + {sequenceEntryStatus.status !== awaitingApprovalForRevocationStatus && ( + + )} = ({ )} @@ -147,7 +152,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo className={menuItemClassName} onClick={() => displayConfirmationDialog({ - dialogText: `Are you sure you want to discard all ${processedCount + errorCount} processed sequences?`, + dialogText: `Are you sure you want to discard all ${finishedCount} processed sequences?`, onConfirmation: () => { hooks.deleteSequenceEntries({ scope: deleteAllDataScope.value, @@ -157,19 +162,19 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo } > - Discard all {processedCount + errorCount} processed sequences + Discard all {finishedCount} processed sequences
)} - {processedCount > 0 && ( + {processedCount + revocationCount > 0 && ( )} diff --git a/website/src/components/SequenceDetailsPage/RevokeButton.tsx b/website/src/components/SequenceDetailsPage/RevokeButton.tsx new file mode 100644 index 000000000..ec3b215d2 --- /dev/null +++ b/website/src/components/SequenceDetailsPage/RevokeButton.tsx @@ -0,0 +1,49 @@ +import { type FC } from 'react'; +import { toast } from 'react-toastify'; + +import { routes } from '../../routes'; +import { backendClientHooks } from '../../services/serviceHooks'; +import type { ClientConfig } from '../../types/runtimeConfig'; +import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; +import { stringifyMaybeAxiosError } from '../../utils/stringifyMaybeAxiosError'; +import { withQueryProvider } from '../common/withQueryProvider'; + +type RevokeSequenceEntryProps = { + organism: string; + accessToken: string; + clientConfig: ClientConfig; + accessionVersion: string; +}; + +const InnerRevokeButton: FC = ({ organism, accessToken, clientConfig, accessionVersion }) => { + const hooks = backendClientHooks(clientConfig); + const useRevokeSequenceEntries = hooks.useRevokeSequences( + { headers: createAuthorizationHeader(accessToken), params: { organism } }, + { + onSuccess: () => { + document.location = routes.userSequenceReviewPage(organism); + }, + onError: (error) => + toast.error(getRevokeSequenceEntryErrorMessage(error), { + position: 'top-center', + autoClose: false, + }), + }, + ); + + const handleRevokeSequenceEntry = () => { + useRevokeSequenceEntries.mutate({ accessions: [accessionVersion] }); + }; + + return ( + + ); +}; + +export const RevokeButton = withQueryProvider(InnerRevokeButton); + +function getRevokeSequenceEntryErrorMessage(error: unknown) { + return 'Failed to revoke sequence entry: ' + stringifyMaybeAxiosError(error); +} diff --git a/website/src/components/SequenceDetailsPage/SequencesDetailsPage.astro b/website/src/components/SequenceDetailsPage/SequencesDetailsPage.astro index f3f175b1a..cc00b9366 100644 --- a/website/src/components/SequenceDetailsPage/SequencesDetailsPage.astro +++ b/website/src/components/SequenceDetailsPage/SequencesDetailsPage.astro @@ -1,10 +1,15 @@ --- import DataTable from './DataTable.astro'; +import { RevokeButton } from './RevokeButton'; import { SequencesContainer } from './SequencesContainer'; import { type TableDataEntry } from './getTableData'; import { getReferenceGenomes, getRuntimeConfig } from '../../config'; import { routes } from '../../routes.ts'; +import { GroupManagementClient } from '../../services/groupManagementClient'; import { type DataUseTermsHistoryEntry } from '../../types/backend'; +import { type Group } from '../../types/backend.ts'; +import { getAccessToken } from '../../utils/getAccessToken'; +import MdiEye from '~icons/mdi/eye'; interface Props { tableData: TableDataEntry[]; @@ -14,6 +19,7 @@ interface Props { } const { tableData, organism, accessionVersion, dataUseTermsHistory } = Astro.props; +const group = tableData.find((entry) => entry.name === 'group')?.value; const runtimeConfig = getRuntimeConfig(); @@ -21,6 +27,19 @@ const referenceGenomes = getReferenceGenomes(organism); const genes = referenceGenomes.genes.map((g) => g.name); const nucleotideSegmentNames = referenceGenomes.nucleotideSequences.map((s) => s.name) as [string, ...string[]]; + +const clientConfig = getRuntimeConfig().public; +const session = Astro.locals.session; +const accessToken = getAccessToken(session); + +const groupsResult = + accessToken !== undefined ? await GroupManagementClient.create().getGroupsOfUser(accessToken) : undefined; + +const isMyGroup = + accessToken !== undefined && + groupsResult !== undefined && + groupsResult.isOk() && + groupsResult.value.some((myGroupItem: Group) => myGroupItem.groupName === group); --- @@ -42,3 +61,24 @@ const nucleotideSegmentNames = referenceGenomes.nucleotideSequences.map((s) => s nucleotideSegmentNames={nucleotideSegmentNames} /> + +{ + isMyGroup && ( +
+
+

Sequence Management

+
+ + Only visible to group members +
+ + +
+ ) +} diff --git a/website/src/hooks/useSubmissionOperations.ts b/website/src/hooks/useSubmissionOperations.ts index 05a0aa749..cd8a0ac73 100644 --- a/website/src/hooks/useSubmissionOperations.ts +++ b/website/src/hooks/useSubmissionOperations.ts @@ -10,6 +10,7 @@ import { inProcessingStatus, type PageQuery, receivedStatus, + awaitingApprovalForRevocationStatus, } from '../types/backend.ts'; import type { ClientConfig } from '../types/runtimeConfig.ts'; import { createAuthorizationHeader } from '../utils/createAuthorizationHeader.ts'; @@ -23,6 +24,13 @@ export function useSubmissionOperations( pageQuery: PageQuery, ) { const hooks = useMemo(() => backendClientHooks(clientConfig), [clientConfig]); + const includedStatuses = [ + receivedStatus, + inProcessingStatus, + awaitingApprovalStatus, + hasErrorsStatus, + awaitingApprovalForRevocationStatus, + ]; const useGetSequences = hooks.useGetSequences( { headers: createAuthorizationHeader(accessToken), @@ -30,8 +38,7 @@ export function useSubmissionOperations( organism, }, queries: { - statusesFilter: - receivedStatus + ',' + inProcessingStatus + ',' + awaitingApprovalStatus + ',' + hasErrorsStatus, + statusesFilter: includedStatuses.join(','), page: pageQuery.page - 1, size: pageQuery.size, }, diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index f784ee848..f2558e1d4 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -1,9 +1,13 @@ --- import '../styles/base.scss'; +import { ToastContainer } from 'react-toastify'; + import Navigation from '../components/Navigation/Navigation.astro'; import OrganismSelector from '../components/Navigation/OrganismSelector.astro'; import { getWebsiteConfig } from '../config'; +import 'react-toastify/dist/ReactToastify.css'; import { navigationItems } from '../routes'; + const websiteConfig = getWebsiteConfig(); const { name: websiteName, logo } = websiteConfig; @@ -25,6 +29,7 @@ const { title } = Astro.props;
+