Add document archiving
Documents can be archived and unarchived. Archived documents are read-only, excluded from the trust center, and moved to a dedicated Archived tab in the document list. - Add archived_at timestamp and status (ACTIVE/ARCHIVED) PG enum column - Rename DocumentStatus → DocumentVersionStatus, introduce DocumentStatus - Archive/unarchive mutations in GraphQL, MCP, and CLI - Bulk archive/unarchive mutations with Active/Archived tabs in the list - ABAC policies: write actions denied on archived docs, unarchive denied on active docs - Remove control/risk mappings and reset trust center visibility on archive - Exclude archived documents from mapping dialogs and trust center tab Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -52,6 +52,7 @@ const documentsFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { status: [ACTIVE] }
|
||||
) @connection(key: "LinkedDocumentsDialogQuery_documents") {
|
||||
edges {
|
||||
node {
|
||||
|
||||
@@ -61,7 +61,7 @@ const bulkDeleteDocumentsMutation = graphql`
|
||||
$input: BulkDeleteDocumentsInput!
|
||||
) {
|
||||
bulkDeleteDocuments(input: $input) {
|
||||
deletedDocumentIds @deleteRecord
|
||||
deletedDocumentIds
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -12,7 +12,7 @@ const fragment = graphql`
|
||||
compliancePage: trustCenter @required(action: THROW) {
|
||||
...CompliancePageDocumentListItem_compliancePageFragment
|
||||
}
|
||||
documents(first: 100) {
|
||||
documents(first: 100 filter: { status: [ACTIVE] }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
|
||||
@@ -36,6 +36,7 @@ export const documentLayoutQuery = graphql`
|
||||
... on Document {
|
||||
id
|
||||
title
|
||||
status
|
||||
canPublish: permission(action: "core:document-version:publish")
|
||||
controlInfo: controls(first: 0) {
|
||||
totalCount
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
IconBell2,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
TabItem,
|
||||
Tabs,
|
||||
} from "@probo/ui";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePreloadedQuery,
|
||||
@@ -29,15 +31,6 @@ export const documentsPageQuery = graphql`
|
||||
... on Organization {
|
||||
canCreateDocument: permission(action: "core:document:create")
|
||||
...DocumentListFragment @arguments(first: 50, order: { field: TITLE, direction: ASC })
|
||||
allDocuments: documents(first: 50, orderBy: { field: TITLE, direction: ASC }) {
|
||||
edges {
|
||||
node {
|
||||
canSendSigningNotifications: permission(
|
||||
action: "core:document:send-signing-notifications"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,25 +56,16 @@ export default function DocumentsPage(props: {
|
||||
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
const canSendAnySignatureNotifications = organization.allDocuments.edges.some(
|
||||
({ node: { canSendSigningNotifications } }) => canSendSigningNotifications,
|
||||
const [canSendAnySignatureNotifications, setCanSendAnySignatureNotifications] = useState(false);
|
||||
const [tab, setTab] = useState<"ACTIVE" | "ARCHIVED">("ACTIVE");
|
||||
const [documentListConnectionId, setDocumentListConnectionId] = useState(
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
"DocumentsListQuery_documents",
|
||||
{ orderBy: { direction: "ASC", field: "TITLE" } },
|
||||
),
|
||||
);
|
||||
|
||||
const unfilteredConnectionId = useMemo(
|
||||
() =>
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
orderBy: { direction: "ASC", field: "TITLE" },
|
||||
filter: { documentTypes: null },
|
||||
},
|
||||
),
|
||||
[organizationId],
|
||||
);
|
||||
|
||||
const [, setDocumentListConnectionId] = useState(unfilteredConnectionId);
|
||||
|
||||
const handleSendSigningNotifications = async () => {
|
||||
await sendSigningNotifications({
|
||||
variables: {
|
||||
@@ -106,9 +90,9 @@ export default function DocumentsPage(props: {
|
||||
{__("Send signing notifications")}
|
||||
</Button>
|
||||
)}
|
||||
{organization.canCreateDocument && (
|
||||
{organization.canCreateDocument && tab === "ACTIVE" && (
|
||||
<CreateDocumentDialog
|
||||
connection={unfilteredConnectionId}
|
||||
connection={documentListConnectionId}
|
||||
trigger={
|
||||
<Button icon={IconPlusLarge}>{__("New document")}</Button>
|
||||
}
|
||||
@@ -116,9 +100,19 @@ export default function DocumentsPage(props: {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs>
|
||||
<TabItem active={tab === "ACTIVE"} onClick={() => setTab("ACTIVE")}>
|
||||
{__("Active")}
|
||||
</TabItem>
|
||||
<TabItem active={tab === "ARCHIVED"} onClick={() => setTab("ARCHIVED")}>
|
||||
{__("Archived")}
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<DocumentList
|
||||
fKey={organization}
|
||||
onConnectionIdChange={setDocumentListConnectionId}
|
||||
onCanSendNotificationsChange={setCanSendAnySignatureNotifications}
|
||||
tab={tab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,12 @@ const createDocumentMutation = graphql`
|
||||
documentEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
canRequestSignatures: permission(action: "core:document-version:request-signature")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canSendSigningNotifications: permission(action: "core:document:send-signing-notifications")
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { ActionDropdown, DropdownItem, IconArrowDown, IconPencil, IconTrashCan, useConfirm } from "@probo/ui";
|
||||
import { ActionDropdown, DropdownItem, IconArchive, IconArrowDown, IconPencil, IconTrashCan, useConfirm, useToast } from "@probo/ui";
|
||||
import { use, useRef } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql";
|
||||
import type { DocumentActionsDropdown_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
||||
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
||||
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
||||
@@ -21,7 +23,10 @@ const documentFragment = graphql`
|
||||
fragment DocumentActionsDropdown_documentFragment on Document {
|
||||
id
|
||||
title
|
||||
status
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
versions(first: 20) {
|
||||
__id
|
||||
@@ -31,6 +36,42 @@ const documentFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const archiveDocumentMutation = graphql`
|
||||
mutation DocumentActionsDropdown_archiveMutation(
|
||||
$input: ArchiveDocumentInput!
|
||||
) {
|
||||
archiveDocument(input: $input) {
|
||||
document {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const unarchiveDocumentMutation = graphql`
|
||||
mutation DocumentActionsDropdown_unarchiveMutation(
|
||||
$input: UnarchiveDocumentInput!
|
||||
) {
|
||||
unarchiveDocument(input: $input) {
|
||||
document {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
|
||||
id
|
||||
@@ -65,6 +106,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const updateDialogRef = useRef<{ open: () => void }>(null);
|
||||
const pdfDownloadDialogRef = useRef<PdfDownloadDialogRef>(null);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
|
||||
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
@@ -72,6 +114,10 @@ export function DocumentActionsDropdownn(props: {
|
||||
const isDraft = version.status === "DRAFT";
|
||||
|
||||
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
|
||||
const [archiveDocument, isArchiving]
|
||||
= useMutation<DocumentActionsDropdown_archiveMutation>(archiveDocumentMutation);
|
||||
const [unarchiveDocument, isUnarchiving]
|
||||
= useMutation<DocumentActionsDropdown_unarchiveMutation>(unarchiveDocumentMutation);
|
||||
const [deleteDraftDocumentVersion, isDeletingDraft]
|
||||
= useDeleteDraftDocumentVersionMutation();
|
||||
const [exportDocumentVersion, isExporting]
|
||||
@@ -83,6 +129,53 @@ export function DocumentActionsDropdownn(props: {
|
||||
},
|
||||
);
|
||||
|
||||
const handleArchive = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
archiveDocument({
|
||||
variables: { input: { documentId: document.id } },
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({ title: __("Error"), description: formatError(__("Failed to archive document"), errors), variant: "error" });
|
||||
} else {
|
||||
toast({ title: __("Success"), description: __("Document archived successfully."), variant: "success" });
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__("This will archive the document \"%s\". It will no longer be editable."),
|
||||
document.title,
|
||||
),
|
||||
variant: "danger",
|
||||
label: __("Archive"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnarchive = () => {
|
||||
unarchiveDocument({
|
||||
variables: { input: { documentId: document.id } },
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({ title: __("Error"), description: formatError(__("Failed to unarchive document"), errors), variant: "error" });
|
||||
} else {
|
||||
toast({ title: __("Success"), description: __("Document unarchived successfully."), variant: "success" });
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
@@ -214,6 +307,24 @@ export function DocumentActionsDropdownn(props: {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</DropdownItem>
|
||||
{document.canArchive && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isArchiving}
|
||||
onClick={handleArchive}
|
||||
>
|
||||
{__("Archive document")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{document.canUnarchive && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isUnarchiving}
|
||||
onClick={handleUnarchive}
|
||||
>
|
||||
{__("Unarchive document")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{document.canDelete && (
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
|
||||
@@ -21,6 +21,8 @@ const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
id
|
||||
documentType
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
@@ -87,6 +89,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
const version = useFragment<DocumentLayoutDrawer_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
|
||||
const isDraft = version.status === "DRAFT";
|
||||
const canEdit = document.canUpdate;
|
||||
|
||||
const approvers = document.approvers.edges.map(e => e.node);
|
||||
|
||||
@@ -184,7 +187,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingApprover(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{approvers.map(approver => (
|
||||
@@ -220,7 +223,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingType(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
@@ -251,7 +254,7 @@ export function DocumentLayoutDrawer(props: {
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingClassification(true)}
|
||||
canEdit={document.canUpdate}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{getDocumentClassificationLabel(
|
||||
@@ -288,6 +291,13 @@ export function DocumentLayoutDrawer(props: {
|
||||
</div>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{document.archivedAt && (
|
||||
<PropertyRow label={__("Archived on")}>
|
||||
<Badge variant="danger" size="md" className="gap-2">
|
||||
{formatDate(document.archivedAt)}
|
||||
</Badge>
|
||||
</PropertyRow>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Checkbox, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { type ComponentProps, use, useRef, useState, useTransition } from "react";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePaginationFragment } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql";
|
||||
import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql";
|
||||
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
|
||||
import type { DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql";
|
||||
import type { DocumentType, DocumentsListQuery } 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 { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CurrentUser } from "#/providers/CurrentUser";
|
||||
|
||||
@@ -30,6 +33,7 @@ const fragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
||||
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
||||
) {
|
||||
documents(
|
||||
@@ -38,7 +42,7 @@ const fragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { documentTypes: $documentTypes }
|
||||
filter: { status: $status documentTypes: $documentTypes }
|
||||
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
||||
__id
|
||||
edges {
|
||||
@@ -49,6 +53,11 @@ const fragment = graphql`
|
||||
canRequestSignatures: permission(
|
||||
action: "core:document-version:request-signature"
|
||||
)
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
canSendSigningNotifications: permission(
|
||||
action: "core:document:send-signing-notifications"
|
||||
)
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
}
|
||||
@@ -56,11 +65,43 @@ const fragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const bulkArchiveMutation = graphql`
|
||||
mutation DocumentListBulkArchiveMutation($input: BulkArchiveDocumentsInput!) {
|
||||
bulkArchiveDocuments(input: $input) {
|
||||
documents {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const bulkUnarchiveMutation = graphql`
|
||||
mutation DocumentListBulkUnarchiveMutation($input: BulkUnarchiveDocumentsInput!) {
|
||||
bulkUnarchiveDocuments(input: $input) {
|
||||
documents {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
canArchive: permission(action: "core:document:archive")
|
||||
canUnarchive: permission(action: "core:document:unarchive")
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentList(props: {
|
||||
fKey: DocumentListFragment$key;
|
||||
onConnectionIdChange: (connectionId: string) => void;
|
||||
onCanSendNotificationsChange?: (can: boolean) => void;
|
||||
tab: "ACTIVE" | "ARCHIVED";
|
||||
}) {
|
||||
const { fKey, onConnectionIdChange } = props;
|
||||
const { fKey, onConnectionIdChange, onCanSendNotificationsChange, tab } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { email: defaultEmail } = use(CurrentUser);
|
||||
@@ -72,17 +113,48 @@ export function DocumentList(props: {
|
||||
fKey,
|
||||
);
|
||||
|
||||
const documents = pagination.data.documents.edges
|
||||
.map(({ node }) => node);
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const refetch = pagination.refetch;
|
||||
useEffect(() => {
|
||||
refetch(
|
||||
{ status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
}, [tab, refetch]);
|
||||
|
||||
const documents = pagination.data.documents.edges.map(({ node }) => node);
|
||||
const connectionId = pagination.data.documents.__id;
|
||||
|
||||
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
|
||||
const [bulkExportDocuments, isBulkExporting]
|
||||
= useBulkExportDocumentsMutation();
|
||||
const [bulkExportDocuments, isBulkExporting] = useBulkExportDocumentsMutation();
|
||||
const [bulkArchiveDocuments, isBulkArchiving] = useMutationWithToasts<DocumentListBulkArchiveMutation>(
|
||||
bulkArchiveMutation,
|
||||
{ successMessage: __("Documents archived successfully."), errorMessage: __("Failed to archive documents") },
|
||||
);
|
||||
const [bulkUnarchiveDocuments, isBulkUnarchiving] = useMutationWithToasts<DocumentListBulkUnarchiveMutation>(
|
||||
bulkUnarchiveMutation,
|
||||
{ successMessage: __("Documents unarchived successfully."), errorMessage: __("Failed to unarchive documents") },
|
||||
);
|
||||
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
||||
const confirm = useConfirm();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||
|
||||
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
|
||||
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
|
||||
const canRequestAnySignatures = documents.some(({ canRequestSignatures }) => canRequestSignatures);
|
||||
const canArchiveAny = documents.some(({ canArchive }) => canArchive);
|
||||
const canUnarchiveAny = documents.some(({ canUnarchive }) => canUnarchive);
|
||||
const canSendAnySignatureNotifications = documents.some(({ canSendSigningNotifications }) => canSendSigningNotifications);
|
||||
const hasAnyAction = tab === "ARCHIVED" ? canUnarchiveAny || canDeleteAny : canDeleteAny || canUpdateAny;
|
||||
|
||||
useEffect(() => {
|
||||
onConnectionIdChange(connectionId);
|
||||
}, [connectionId, onConnectionIdChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onCanSendNotificationsChange?.(canSendAnySignatureNotifications);
|
||||
}, [canSendAnySignatureNotifications, onCanSendNotificationsChange]);
|
||||
|
||||
const handleDocumentTypeFilterChange = (value: string) => {
|
||||
const newType = value === "ALL" ? null : (value as DocumentType);
|
||||
@@ -94,41 +166,36 @@ export function DocumentList(props: {
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
orderBy: { direction: "ASC", field: "TITLE" },
|
||||
filter: { documentTypes: newType ? [newType] : null },
|
||||
filter: { status: [tab], documentTypes: newType ? [newType] : null },
|
||||
},
|
||||
),
|
||||
);
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{ documentTypes: newType ? [newType] : null },
|
||||
{ fetchPolicy: "network-only" },
|
||||
{ status: [tab], documentTypes: newType ? [newType] : null },
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const canDeleteAny = documents.some(({ canDelete }) => canDelete);
|
||||
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
|
||||
const canRequestAnySignatures = documents.some(
|
||||
({ canRequestSignatures }) => canRequestSignatures,
|
||||
);
|
||||
const hasAnyAction = canDeleteAny || canUpdateAny;
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
const documentCount = selection.length;
|
||||
confirm(
|
||||
() =>
|
||||
bulkDeleteDocuments({
|
||||
variables: {
|
||||
input: { documentIds: selection },
|
||||
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.",
|
||||
),
|
||||
__("This will permanently delete %s document%s. This action cannot be undone."),
|
||||
documentCount,
|
||||
documentCount > 1 ? "s" : "",
|
||||
),
|
||||
@@ -136,6 +203,32 @@ export function DocumentList(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkArchive = () => {
|
||||
void bulkArchiveDocuments({
|
||||
variables: { input: { documentIds: selection } },
|
||||
updater: (store) => {
|
||||
const conn = store.get(connectionId);
|
||||
if (conn) {
|
||||
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||
}
|
||||
},
|
||||
onSuccess: clear,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkUnarchive = () => {
|
||||
void bulkUnarchiveDocuments({
|
||||
variables: { input: { documentIds: selection } },
|
||||
updater: (store) => {
|
||||
const conn = store.get(connectionId);
|
||||
if (conn) {
|
||||
selection.forEach(id => ConnectionHandler.deleteNode(conn, id));
|
||||
}
|
||||
},
|
||||
onSuccess: clear,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkExport = async (options: {
|
||||
withWatermark: boolean;
|
||||
withSignatures: boolean;
|
||||
@@ -145,13 +238,9 @@ export function DocumentList(props: {
|
||||
documentIds: selection,
|
||||
withWatermark: options.withWatermark,
|
||||
withSignatures: options.withSignatures,
|
||||
...(options.withWatermark
|
||||
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
...(options.withWatermark && options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
};
|
||||
|
||||
await bulkExportDocuments({
|
||||
variables: { input },
|
||||
});
|
||||
await bulkExportDocuments({ variables: { input } });
|
||||
clear();
|
||||
};
|
||||
|
||||
@@ -162,12 +251,21 @@ export function DocumentList(props: {
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
orderBy: order,
|
||||
filter: { documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
||||
filter: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
|
||||
pagination.refetch({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
order: order as any,
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -188,7 +286,7 @@ export function DocumentList(props: {
|
||||
? (
|
||||
<SortableTable
|
||||
{...pagination}
|
||||
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
refetch={refetchWithFilters}
|
||||
>
|
||||
<Thead>
|
||||
{selection.length === 0
|
||||
@@ -196,10 +294,7 @@ export function DocumentList(props: {
|
||||
<Tr>
|
||||
<Th className="w-18">
|
||||
<Checkbox
|
||||
checked={
|
||||
selection.length === documents.length
|
||||
&& documents.length > 0
|
||||
}
|
||||
checked={selection.length === documents.length && documents.length > 0}
|
||||
onChange={() => reset(documents.map(d => d.id))}
|
||||
/>
|
||||
</Th>
|
||||
@@ -235,58 +330,90 @@ export function DocumentList(props: {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
icon={IconCheckmark1}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
)}
|
||||
{canRequestAnySignatures && (
|
||||
<SignatureDocumentsDialog
|
||||
documentIds={selection}
|
||||
onSave={clear}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
)}
|
||||
<BulkExportDialog
|
||||
ref={bulkExportDialogRef}
|
||||
onExport={handleBulkExport}
|
||||
isLoading={isBulkExporting}
|
||||
defaultEmail={defaultEmail}
|
||||
selectedCount={selection.length}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArrowDown}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Export")}
|
||||
</Button>
|
||||
</BulkExportDialog>
|
||||
{canDeleteAny && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
{tab === "ARCHIVED"
|
||||
? (
|
||||
<>
|
||||
{canUnarchiveAny && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArchive}
|
||||
onClick={handleBulkUnarchive}
|
||||
disabled={isBulkUnarchiving}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Unarchive")}
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteAny && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
)}
|
||||
{canRequestAnySignatures && (
|
||||
<SignatureDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconSignature}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Request signature")}
|
||||
</Button>
|
||||
</SignatureDocumentsDialog>
|
||||
)}
|
||||
<BulkExportDialog
|
||||
ref={bulkExportDialogRef}
|
||||
onExport={handleBulkExport}
|
||||
isLoading={isBulkExporting}
|
||||
defaultEmail={defaultEmail}
|
||||
selectedCount={selection.length}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArrowDown}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Export")}
|
||||
</Button>
|
||||
</BulkExportDialog>
|
||||
{canArchiveAny && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArchive}
|
||||
onClick={handleBulkArchive}
|
||||
disabled={isBulkArchiving}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Archive")}
|
||||
</Button>
|
||||
)}
|
||||
{canDeleteAny && (
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={handleBulkDelete}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Delete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Th>
|
||||
@@ -311,11 +438,13 @@ export function DocumentList(props: {
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No documents yet")}
|
||||
{tab === "ARCHIVED" ? __("No archived documents") : __("No documents yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Create your first document to get started.")}
|
||||
</p>
|
||||
{tab !== "ARCHIVED" && (
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("Create your first document to get started.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -109,7 +109,9 @@ export function DocumentListItem(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
|
||||
<Tr
|
||||
to={`/organizations/${organizationId}/documents/${document.id}`}
|
||||
>
|
||||
<Td noLink className="w-18">
|
||||
<Checkbox checked={checked} onChange={onCheck} />
|
||||
</Td>
|
||||
|
||||
Reference in New Issue
Block a user