Add document delete confirmation

Route document deletion through explicit confirmation dialogs so single and bulk delete actions require users to acknowledge the destructive operation before the mutation runs.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Sacha Al Himdani <SachaProbo@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-06-16 13:35:48 +00:00
parent a37dd4d332
commit c195320649
4 changed files with 299 additions and 172 deletions

View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Spinner,
useDialogRef,
} from "@probo/ui";
import { forwardRef, useImperativeHandle } from "react";
import { ConnectionHandler, type DataID } from "relay-runtime";
import {
useBulkDeleteDocumentsMutation,
useDeleteDocumentMutation,
} from "#/hooks/graph/DocumentGraph";
export type DeleteDocumentDialogRef = {
open: () => void;
close: () => void;
};
type DeleteDocumentDialogProps = {
documentId: string;
documentTitle: string;
connections: DataID[];
onSuccess?: () => void;
};
export const DeleteDocumentDialog = forwardRef<
DeleteDocumentDialogRef,
DeleteDocumentDialogProps
>(({ documentId, documentTitle, connections, onSuccess }, ref) => {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
useImperativeHandle(ref, () => ({
open: () => dialogRef.current?.open(),
close: () => dialogRef.current?.close(),
}));
const handleDelete = async () => {
try {
await deleteDocument({
variables: {
input: { documentId },
connections,
},
});
dialogRef.current?.close();
onSuccess?.();
} catch {
// The mutation helper already displays the error toast.
}
};
return (
<Dialog
ref={dialogRef}
title={__("Delete document")}
className="max-w-md"
>
<DialogContent padded>
<p className="text-txt-secondary">
{sprintf(
__("Are you sure you want to delete the document \"%s\"?"),
documentTitle,
)}
</p>
<p className="text-txt-secondary mt-2">
{__("This action cannot be undone.")}
</p>
</DialogContent>
<DialogFooter>
<Button
variant="danger"
onClick={() => void handleDelete()}
disabled={isDeleting}
icon={isDeleting ? Spinner : undefined}
>
{__("Delete")}
</Button>
</DialogFooter>
</Dialog>
);
});
DeleteDocumentDialog.displayName = "DeleteDocumentDialog";
type DeleteDocumentsDialogProps = {
documentIds: string[];
connectionId: DataID;
onSuccess?: () => void;
};
export const DeleteDocumentsDialog = forwardRef<
DeleteDocumentDialogRef,
DeleteDocumentsDialogProps
>(({ documentIds, connectionId, onSuccess }, ref) => {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [deleteDocuments, isDeleting] = useBulkDeleteDocumentsMutation();
const documentCount = documentIds.length;
useImperativeHandle(ref, () => ({
open: () => dialogRef.current?.open(),
close: () => dialogRef.current?.close(),
}));
const handleDelete = async () => {
try {
await deleteDocuments({
variables: { input: { documentIds } },
updater: (store) => {
const conn = store.get(connectionId);
if (conn) {
documentIds.forEach(id => ConnectionHandler.deleteNode(conn, id));
}
},
});
dialogRef.current?.close();
onSuccess?.();
} catch {
// The mutation helper already displays the error toast.
}
};
return (
<Dialog
ref={dialogRef}
title={__("Delete documents")}
className="max-w-md"
>
<DialogContent padded>
<p className="text-txt-secondary">
{documentCount === 1
? __("Are you sure you want to delete 1 selected document?")
: sprintf(
__("Are you sure you want to delete %s selected documents?"),
documentCount,
)}
</p>
<p className="text-txt-secondary mt-2">
{__("This action cannot be undone.")}
</p>
</DialogContent>
<DialogFooter>
<Button
variant="danger"
onClick={() => void handleDelete()}
disabled={isDeleting || documentCount === 0}
icon={isDeleting ? Spinner : undefined}
>
{__("Delete")}
</Button>
</DialogFooter>
</Dialog>
);
});
DeleteDocumentsDialog.displayName = "DeleteDocumentsDialog";

View File

@@ -27,10 +27,12 @@ import type { DocumentActionsDropdown_exportVersionMutation } from "#/__generate
import type { DocumentActionsDropdown_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
import { DocumentsConnectionKey, useDeleteDocumentMutation } from "#/hooks/graph/DocumentGraph";
import { DocumentsConnectionKey } from "#/hooks/graph/DocumentGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CurrentUser } from "#/providers/CurrentUser";
import { DeleteDocumentDialog, type DeleteDocumentDialogRef } from "./DeleteDocumentDialog";
const documentFragment = graphql`
fragment DocumentActionsDropdown_documentFragment on Document {
id
@@ -121,13 +123,13 @@ export function DocumentActionsDropdown(props: {
const { __ } = useTranslate();
const { email: defaultEmail } = use(CurrentUser);
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
const deleteDocumentDialogRef = useRef<DeleteDocumentDialogRef>(null);
const confirm = useConfirm();
const { toast } = useToast();
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
const [archiveDocument, isArchiving]
= useMutation<DocumentActionsDropdown_archiveMutation>(archiveDocumentMutation);
const [unarchiveDocument, isUnarchiving]
@@ -214,33 +216,11 @@ export function DocumentActionsDropdown(props: {
);
};
const handleDelete = () => {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
DocumentsConnectionKey,
{ orderBy: { direction: "ASC", field: "TITLE" } },
);
confirm(
() =>
deleteDocument({
variables: {
input: { documentId: document.id },
connections: [connectionId],
},
onSuccess() {
void navigate(`/organizations/${organizationId}/documents`);
},
}),
{
message: sprintf(
__(
"This will permanently delete the document \"%s\". This action cannot be undone.",
),
version.title,
),
},
);
};
const documentsConnectionId = ConnectionHandler.getConnectionID(
organizationId,
DocumentsConnectionKey,
{ orderBy: { direction: "ASC", field: "TITLE" } },
);
const handleExportDocumentVersion = (options: {
withWatermark: boolean;
@@ -290,6 +270,13 @@ export function DocumentActionsDropdown(props: {
isLoading={isExporting}
defaultEmail={defaultEmail}
/>
<DeleteDocumentDialog
ref={deleteDocumentDialogRef}
documentId={document.id}
documentTitle={version.title}
connections={[documentsConnectionId]}
onSuccess={() => void navigate(`/organizations/${organizationId}/documents`)}
/>
<ActionDropdown variant="secondary">
<DropdownItem
onClick={() => pdfDownloadDialogRef.current?.open()}
@@ -329,8 +316,7 @@ export function DocumentActionsDropdown(props: {
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
onClick={() => deleteDocumentDialogRef.current?.open()}
>
{__("Delete document")}
</DropdownItem>

View File

@@ -15,7 +15,7 @@
import { documentClassifications, documentTypes, documentWriteModes, getDocumentClassificationLabel, getDocumentTypeLabel, getDocumentWriteModeLabel, sprintf } from "@probo/helpers";
import { useList } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr } from "@probo/ui";
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
import { usePaginationFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
@@ -26,11 +26,12 @@ import type { DocumentListFragment$key } from "#/__generated__/core/DocumentList
import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType, DocumentWriteMode } from "#/__generated__/core/DocumentsListQuery.graphql";
import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog";
import { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
import { useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CurrentUser } from "#/providers/CurrentUser";
import { type DeleteDocumentDialogRef, DeleteDocumentsDialog } from "./DeleteDocumentDialog";
import { DocumentListItem } from "./DocumentListItem";
import { PublishDocumentsDialog } from "./PublishDocumentsDialog";
import { SignatureDocumentsDialog } from "./SignatureDocumentsDialog";
@@ -122,6 +123,7 @@ export function DocumentList(props: {
const organizationId = useOrganizationId();
const { email: defaultEmail } = use(CurrentUser);
const bulkExportDialogRef = useRef<BulkExportDialogRef>(null);
const deleteDocumentsDialogRef = useRef<DeleteDocumentDialogRef>(null);
const { __ } = useTranslate();
const pagination = usePaginationFragment<DocumentsListQuery, DocumentListFragment$key>(
@@ -152,7 +154,6 @@ export function DocumentList(props: {
const documents = pagination.data.documents.edges.map(({ node }) => node);
const connectionId = pagination.data.documents.__id;
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
const [bulkExportDocuments, isBulkExporting] = useBulkExportDocumentsMutation();
const [bulkArchiveDocuments, isBulkArchiving] = useMutationWithToasts<DocumentListBulkArchiveMutation>(
bulkArchiveMutation,
@@ -163,7 +164,6 @@ export function DocumentList(props: {
{ successMessage: __("Documents unarchived successfully."), errorMessage: __("Failed to unarchive documents") },
);
const { list: selection, toggle, clear, reset } = useList<string>([]);
const confirm = useConfirm();
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
@@ -247,28 +247,7 @@ export function DocumentList(props: {
};
const handleBulkDelete = () => {
const documentCount = selection.length;
confirm(
() =>
bulkDeleteDocuments({
variables: { input: { documentIds: selection } },
updater: (store) => {
const conn = store.get(connectionId);
if (conn) {
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
}
},
}).then(() => {
clear();
}),
{
message: sprintf(
__("This will permanently delete %s document%s. This action cannot be undone."),
documentCount,
documentCount > 1 ? "s" : "",
),
},
);
deleteDocumentsDialogRef.current?.open();
};
const handleBulkArchive = () => {
@@ -342,6 +321,12 @@ export function DocumentList(props: {
return (
<div className="space-y-4">
<DeleteDocumentsDialog
ref={deleteDocumentsDialogRef}
documentIds={selection}
connectionId={connectionId}
onSuccess={clear}
/>
<div className="flex items-center gap-4">
<Select
value={writeModeFilter ?? "ALL"}

View File

@@ -32,15 +32,17 @@ import {
useConfirm,
useToast,
} from "@probo/ui";
import { useRef } from "react";
import { useFragment, useMutation } from "react-relay";
import { ConnectionHandler, type DataID, graphql } from "relay-runtime";
import type { DocumentListItem_archiveMutation } from "#/__generated__/core/DocumentListItem_archiveMutation.graphql";
import type { DocumentListItem_deleteMutation } from "#/__generated__/core/DocumentListItem_deleteMutation.graphql";
import type { DocumentListItem_unarchiveMutation } from "#/__generated__/core/DocumentListItem_unarchiveMutation.graphql";
import type { DocumentListItemFragment$key } from "#/__generated__/core/DocumentListItemFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DeleteDocumentDialog, type DeleteDocumentDialogRef } from "./DeleteDocumentDialog";
const fragment = graphql`
fragment DocumentListItemFragment on Document {
id
@@ -105,17 +107,6 @@ const archiveDocumentMutation = graphql`
}
`;
const deleteDocumentMutation = graphql`
mutation DocumentListItem_deleteMutation(
$input: DeleteDocumentInput!
$connections: [ID!]!
) {
deleteDocument(input: $input) {
deletedDocumentId @deleteEdge(connections: $connections)
}
}
`;
const unarchiveDocumentMutation = graphql`
mutation DocumentListItem_unarchiveMutation(
$input: UnarchiveDocumentInput!
@@ -152,9 +143,9 @@ export function DocumentListItem(props: {
const { __ } = useTranslate();
const { toast } = useToast();
const [archiveDocument, isArchiving] = useMutation<DocumentListItem_archiveMutation>(archiveDocumentMutation);
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
const [unarchiveDocument, isUnarchiving] = useMutation<DocumentListItem_unarchiveMutation>(unarchiveDocumentMutation);
const confirm = useConfirm();
const deleteDialogRef = useRef<DeleteDocumentDialogRef>(null);
const document = useFragment<DocumentListItemFragment$key>(
fragment,
fragmentRef,
@@ -222,27 +213,7 @@ export function DocumentListItem(props: {
};
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve, reject) => {
deleteDocument({
variables: {
connections: [connectionId],
input: { documentId: document.id },
},
onCompleted: () => resolve(),
onError: err => reject(err),
});
}),
{
message: sprintf(
__(
"This will permanently delete the document \"%s\". This action cannot be undone.",
),
lastVersion.title,
),
},
);
deleteDialogRef.current?.open();
};
const handleUnarchive = () => {
@@ -281,87 +252,95 @@ export function DocumentListItem(props: {
|| document.canDelete;
return (
<Tr
to={`/organizations/${organizationId}/documents/${document.id}`}
>
<Td noLink className="w-18">
<Checkbox checked={checked} onChange={onCheck} />
</Td>
<Td className="min-w-0">
<div className="flex gap-4 items-center">{lastVersion.title}</div>
</Td>
<Td className="w-24">
<Badge variant={statusVariant[lastVersion.status]}>
{statusLabel[lastVersion.status]}
</Badge>
</Td>
<Td className="w-20">
v
{lastVersion.major}
.
{lastVersion.minor}
</Td>
<Td className="w-28">
{getDocumentTypeLabel(__, lastVersion.documentType)}
</Td>
<Td className="w-32">
{getDocumentClassificationLabel(__, lastVersion.classification)}
</Td>
<Td className="w-60">
{(() => {
if (lastVersion.status === "PENDING_APPROVAL") {
const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
if (quorum) {
if (quorum.status === "REJECTED") return __("Rejected");
return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`;
}
return "—";
}
if (!document.defaultApprovers.length) return "—";
return document.defaultApprovers.map(a => a.fullName).join(", ");
})()}
</Td>
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
<Td className="w-20">
{lastVersion.signedSignatures.totalCount}
/
{lastVersion.signatures.totalCount}
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end w-18">
{hasRowAction && (
<ActionDropdown>
{document.canArchive && document.status === "ACTIVE" && (
<DropdownItem
icon={IconArchive}
disabled={isArchiving}
onClick={handleArchive}
>
{__("Archive")}
</DropdownItem>
)}
{document.canUnarchive && document.status === "ARCHIVED" && (
<DropdownItem
icon={IconArchive}
disabled={isUnarchiving}
onClick={handleUnarchive}
>
{__("Unarchive")}
</DropdownItem>
)}
{document.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
<>
<Tr
to={`/organizations/${organizationId}/documents/${document.id}`}
>
<Td noLink className="w-18">
<Checkbox checked={checked} onChange={onCheck} />
</Td>
)}
</Tr>
<Td className="min-w-0">
<div className="flex gap-4 items-center">{lastVersion.title}</div>
</Td>
<Td className="w-24">
<Badge variant={statusVariant[lastVersion.status]}>
{statusLabel[lastVersion.status]}
</Badge>
</Td>
<Td className="w-20">
v
{lastVersion.major}
.
{lastVersion.minor}
</Td>
<Td className="w-28">
{getDocumentTypeLabel(__, lastVersion.documentType)}
</Td>
<Td className="w-32">
{getDocumentClassificationLabel(__, lastVersion.classification)}
</Td>
<Td className="w-60">
{(() => {
if (lastVersion.status === "PENDING_APPROVAL") {
const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
if (quorum) {
if (quorum.status === "REJECTED") return __("Rejected");
return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`;
}
return "—";
}
if (!document.defaultApprovers.length) return "—";
return document.defaultApprovers.map(a => a.fullName).join(", ");
})()}
</Td>
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
<Td className="w-20">
{lastVersion.signedSignatures.totalCount}
/
{lastVersion.signatures.totalCount}
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end w-18">
{hasRowAction && (
<ActionDropdown>
{document.canArchive && document.status === "ACTIVE" && (
<DropdownItem
icon={IconArchive}
disabled={isArchiving}
onClick={handleArchive}
>
{__("Archive")}
</DropdownItem>
)}
{document.canUnarchive && document.status === "ARCHIVED" && (
<DropdownItem
icon={IconArchive}
disabled={isUnarchiving}
onClick={handleUnarchive}
>
{__("Unarchive")}
</DropdownItem>
)}
{document.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
</Td>
)}
</Tr>
<DeleteDocumentDialog
ref={deleteDialogRef}
documentId={document.id}
documentTitle={lastVersion.title}
connections={[connectionId]}
/>
</>
);
}