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