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
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Button,
Card,
DropdownItem,
IconPlusLarge,
IconTrashCan,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
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 { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql";
import type { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql";
@@ -71,12 +78,24 @@ const campaignsFragment = graphql`
name
status
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 = {
queryRef: PreloadedQuery<AccessReviewCampaignsTabQuery>;
};
@@ -84,6 +103,8 @@ type Props = {
export default function AccessReviewCampaignsTab({ queryRef }: Props) {
const { __, dateFormat } = useTranslate();
const organizationId = useOrganizationId();
const confirm = useConfirm();
const { toast } = useToast();
const { organization } = usePreloadedQuery(accessReviewCampaignsTabQuery, queryRef);
if (organization.__typename !== "Organization") {
@@ -100,6 +121,65 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) {
AccessReviewCampaignsTabFragment$key
>(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 (
<div className="space-y-4">
<div className="flex items-center justify-end">
@@ -124,25 +204,49 @@ export default function AccessReviewCampaignsTab({ queryRef }: Props) {
<Th>{__("Name")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Created at")}</Th>
{hasActions && <Th className="w-12"></Th>}
</Tr>
</Thead>
<Tbody>
{accessReviewCampaigns.edges.map(edge => (
<Tr
key={edge.node.id}
to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`}
>
<Td>{edge.node.name}</Td>
<Td>
<Badge variant={statusBadgeVariant(edge.node.status)}>
{statusLabel(__, edge.node.status)}
</Badge>
</Td>
<Td>
{dateFormat(edge.node.createdAt)}
</Td>
</Tr>
))}
{accessReviewCampaigns.edges.map((edge) => {
const canDeleteRow
= edge.node.canDelete && isDeletableStatus(edge.node.status);
return (
<Tr
key={edge.node.id}
to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`}
>
<Td>{edge.node.name}</Td>
<Td>
<Badge variant={statusBadgeVariant(edge.node.status)}>
{statusLabel(__, edge.node.status)}
</Badge>
</Td>
<Td>
{dateFormat(edge.node.createdAt)}
</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>
);
})}
</Tbody>
</Table>

View File

@@ -29,6 +29,7 @@ import {
IconChevronRight,
IconPlusLarge,
IconRobot,
IconTrashCan,
Option,
Select,
Tbody,
@@ -43,11 +44,13 @@ import {
import * as Popover from "@radix-ui/react-popover";
import { useEffect, useMemo, useRef, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay";
import { useNavigate } from "react-router";
import { fetchQuery, graphql } from "relay-runtime";
import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.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 { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
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`
mutation CampaignDetailPageBulkDecisionMutation(
$input: RecordAccessEntryDecisionsInput!
@@ -131,6 +144,7 @@ export const campaignDetailPageQuery = graphql`
id
name
status
canDelete: permission(action: "core:access-review-campaign:delete")
scopeSources {
id
source {
@@ -171,6 +185,7 @@ type Props = {
export default function CampaignDetailPage({ queryRef }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const environment = useRelayEnvironment();
const data = usePreloadedQuery(campaignDetailPageQuery, queryRef);
@@ -183,6 +198,8 @@ export default function CampaignDetailPage({ queryRef }: Props) {
const isInProgress = campaign.status === "IN_PROGRESS";
const isDraft = campaign.status === "DRAFT";
const isPendingActions = campaign.status === "PENDING_ACTIONS";
const isCancelled = campaign.status === "CANCELLED";
const canDelete = campaign.canDelete && (isDraft || isCancelled);
const campaignIdRef = useRef(campaign.id);
@@ -216,6 +233,9 @@ export default function CampaignDetailPage({ queryRef }: Props) {
const [closeCampaign, isClosing]
= useMutation<CampaignDetailPageCloseMutation>(closeCampaignMutation);
const [deleteCampaign, isDeleting]
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
const allDecided = campaign.scopeSources.length > 0
&& campaign.scopeSources.every(source =>
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 = () => {
confirm(
() =>
@@ -338,6 +411,17 @@ export default function CampaignDetailPage({ queryRef }: Props) {
{isClosing ? __("Completing...") : __("Complete campaign")}
</Button>
)}
{canDelete && (
<Button
icon={IconTrashCan}
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
className="ml-auto"
>
{isDeleting ? __("Deleting...") : __("Delete")}
</Button>
)}
</div>
<div className="space-y-4">