Allow deleting access review campaigns from the UI

Add a delete action to the access review campaigns list and the
campaign detail page. The action is gated on the
core:access-review-campaign:delete permission and only exposed
for campaigns whose status is DRAFT or CANCELLED, matching the
backend constraint enforced by CampaignService.Delete.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-05-07 10:17:32 +02:00
parent 189c9eef1c
commit 177bc5e825
2 changed files with 205 additions and 17 deletions

View File

@@ -12,22 +12,29 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown,
Badge, Badge,
Button, Button,
Card, Card,
DropdownItem,
IconPlusLarge, IconPlusLarge,
IconTrashCan,
Table, Table,
Tbody, Tbody,
Td, Td,
Th, Th,
Thead, Thead,
Tr, Tr,
useConfirm,
useToast,
} from "@probo/ui"; } from "@probo/ui";
import type { PreloadedQuery } from "react-relay"; import type { PreloadedQuery } from "react-relay";
import { graphql, usePaginationFragment, usePreloadedQuery } from "react-relay"; import { graphql, useMutation, usePaginationFragment, usePreloadedQuery } from "react-relay";
import type { AccessReviewCampaignsTabDeleteMutation } from "#/__generated__/core/AccessReviewCampaignsTabDeleteMutation.graphql";
import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql"; import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql";
import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql"; import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql";
import type { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql"; import type { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql";
@@ -71,12 +78,24 @@ const campaignsFragment = graphql`
name name
status status
createdAt createdAt
canDelete: permission(action: "core:access-review-campaign:delete")
} }
} }
} }
} }
`; `;
const deleteCampaignMutation = graphql`
mutation AccessReviewCampaignsTabDeleteMutation(
$input: DeleteAccessReviewCampaignInput!
$connections: [ID!]!
) {
deleteAccessReviewCampaign(input: $input) {
deletedAccessReviewCampaignId @deleteEdge(connections: $connections)
}
}
`;
type Props = { type Props = {
queryRef: PreloadedQuery<AccessReviewCampaignsTabQuery>; queryRef: PreloadedQuery<AccessReviewCampaignsTabQuery>;
}; };
@@ -84,6 +103,8 @@ type Props = {
export default function AccessReviewCampaignsTab({ queryRef }: Props) { export default function AccessReviewCampaignsTab({ queryRef }: Props) {
const { __, dateFormat } = useTranslate(); const { __, dateFormat } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const confirm = useConfirm();
const { toast } = useToast();
const { organization } = usePreloadedQuery(accessReviewCampaignsTabQuery, queryRef); const { organization } = usePreloadedQuery(accessReviewCampaignsTabQuery, queryRef);
if (organization.__typename !== "Organization") { if (organization.__typename !== "Organization") {
@@ -100,6 +121,65 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) {
AccessReviewCampaignsTabFragment$key AccessReviewCampaignsTabFragment$key
>(campaignsFragment, organization); >(campaignsFragment, organization);
const [deleteCampaign] = useMutation<AccessReviewCampaignsTabDeleteMutation>(
deleteCampaignMutation,
);
// Only DRAFT and CANCELLED campaigns can be deleted (enforced by the backend).
const isDeletableStatus = (status: string) =>
status === "DRAFT" || status === "CANCELLED";
const handleDelete = (campaignId: string, campaignName: string) => {
confirm(
() => {
deleteCampaign({
variables: {
input: { accessReviewCampaignId: campaignId },
connections: [accessReviewCampaigns.__id],
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
errors as GraphQLError[],
),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Campaign deleted successfully."),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
error as GraphQLError,
),
variant: "error",
});
},
});
},
{
message: sprintf(
__("This will permanently delete \"%s\". This action cannot be undone."),
campaignName,
),
},
);
};
const hasActions = accessReviewCampaigns.edges.some(
edge => edge.node.canDelete && isDeletableStatus(edge.node.status),
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
@@ -124,10 +204,14 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) {
<Th>{__("Name")}</Th> <Th>{__("Name")}</Th>
<Th>{__("Status")}</Th> <Th>{__("Status")}</Th>
<Th>{__("Created at")}</Th> <Th>{__("Created at")}</Th>
{hasActions && <Th className="w-12"></Th>}
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{accessReviewCampaigns.edges.map(edge => ( {accessReviewCampaigns.edges.map((edge) => {
const canDeleteRow
= edge.node.canDelete && isDeletableStatus(edge.node.status);
return (
<Tr <Tr
key={edge.node.id} key={edge.node.id}
to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`} to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`}
@@ -141,8 +225,28 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) {
<Td> <Td>
{dateFormat(edge.node.createdAt)} {dateFormat(edge.node.createdAt)}
</Td> </Td>
{hasActions && (
<Td noLink width={50} className="text-end">
{canDeleteRow && (
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(edge.node.id, edge.node.name);
}}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</Td>
)}
</Tr> </Tr>
))} );
})}
</Tbody> </Tbody>
</Table> </Table>

View File

@@ -29,6 +29,7 @@ import {
IconChevronRight, IconChevronRight,
IconPlusLarge, IconPlusLarge,
IconRobot, IconRobot,
IconTrashCan,
Option, Option,
Select, Select,
Tbody, Tbody,
@@ -43,11 +44,13 @@ import {
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay"; import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay";
import { useNavigate } from "react-router";
import { fetchQuery, graphql } from "relay-runtime"; import { fetchQuery, graphql } from "relay-runtime";
import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql"; import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql"; import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql";
import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql"; import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql";
import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql";
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql"; import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql"; import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -95,6 +98,16 @@ const closeCampaignMutation = graphql`
} }
`; `;
const deleteCampaignMutation = graphql`
mutation CampaignDetailPageDeleteMutation(
$input: DeleteAccessReviewCampaignInput!
) {
deleteAccessReviewCampaign(input: $input) {
deletedAccessReviewCampaignId
}
}
`;
const bulkDecisionMutation = graphql` const bulkDecisionMutation = graphql`
mutation CampaignDetailPageBulkDecisionMutation( mutation CampaignDetailPageBulkDecisionMutation(
$input: RecordAccessEntryDecisionsInput! $input: RecordAccessEntryDecisionsInput!
@@ -131,6 +144,7 @@ export const campaignDetailPageQuery = graphql`
id id
name name
status status
canDelete: permission(action: "core:access-review-campaign:delete")
scopeSources { scopeSources {
id id
source { source {
@@ -171,6 +185,7 @@ type Props = {
export default function CampaignDetailPage({ queryRef }: Props) { export default function CampaignDetailPage({ queryRef }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const navigate = useNavigate();
const environment = useRelayEnvironment(); const environment = useRelayEnvironment();
const data = usePreloadedQuery(campaignDetailPageQuery, queryRef); const data = usePreloadedQuery(campaignDetailPageQuery, queryRef);
@@ -183,6 +198,8 @@ export default function CampaignDetailPage({ queryRef }: Props) {
const isInProgress = campaign.status === "IN_PROGRESS"; const isInProgress = campaign.status === "IN_PROGRESS";
const isDraft = campaign.status === "DRAFT"; const isDraft = campaign.status === "DRAFT";
const isPendingActions = campaign.status === "PENDING_ACTIONS"; const isPendingActions = campaign.status === "PENDING_ACTIONS";
const isCancelled = campaign.status === "CANCELLED";
const canDelete = campaign.canDelete && (isDraft || isCancelled);
const campaignIdRef = useRef(campaign.id); const campaignIdRef = useRef(campaign.id);
@@ -216,6 +233,9 @@ export default function CampaignDetailPage({ queryRef }: Props) {
const [closeCampaign, isClosing] const [closeCampaign, isClosing]
= useMutation<CampaignDetailPageCloseMutation>(closeCampaignMutation); = useMutation<CampaignDetailPageCloseMutation>(closeCampaignMutation);
const [deleteCampaign, isDeleting]
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
const allDecided = campaign.scopeSources.length > 0 const allDecided = campaign.scopeSources.length > 0
&& campaign.scopeSources.every(source => && campaign.scopeSources.every(source =>
source.entries source.entries
@@ -262,6 +282,59 @@ export default function CampaignDetailPage({ queryRef }: Props) {
}); });
}; };
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteCampaign({
variables: {
input: { accessReviewCampaignId: campaign.id },
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
errors as GraphQLError[],
),
variant: "error",
});
resolve();
return;
}
toast({
title: __("Success"),
description: __("Campaign deleted successfully."),
variant: "success",
});
resolve();
navigate(`/organizations/${organizationId}/access-reviews`);
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
error as GraphQLError,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__("This will permanently delete \"%s\". This action cannot be undone."),
campaign.name,
),
label: __("Delete"),
variant: "danger",
},
);
};
const handleComplete = () => { const handleComplete = () => {
confirm( confirm(
() => () =>
@@ -338,6 +411,17 @@ export default function CampaignDetailPage({ queryRef }: Props) {
{isClosing ? __("Completing...") : __("Complete campaign")} {isClosing ? __("Completing...") : __("Complete campaign")}
</Button> </Button>
)} )}
{canDelete && (
<Button
icon={IconTrashCan}
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
className="ml-auto"
>
{isDeleting ? __("Deleting...") : __("Delete")}
</Button>
)}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">