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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user