Upgrade documents

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-16 09:51:17 +02:00
parent ce613ca453
commit 04e3eb81a7
24 changed files with 1910 additions and 523 deletions

View File

@@ -4,6 +4,7 @@ import { useMutationWithToasts } from "../useMutationWithToasts";
import type { DocumentGraphDeleteMutation } from "./__generated__/DocumentGraphDeleteMutation.graphql";
import type { DocumentGraphSendSigningNotificationsMutation } from "./__generated__/DocumentGraphSendSigningNotificationsMutation.graphql";
import type { DocumentGraphDeleteDraftMutation } from "./__generated__/DocumentGraphDeleteDraftMutation.graphql";
import type { DocumentGraphBulkExportDocumentsMutation } from "./__generated__/DocumentGraphBulkExportDocumentsMutation.graphql";
export const documentsQuery = graphql`
query DocumentGraphListQuery($organizationId: ID!) {
@@ -61,6 +62,28 @@ export function useDeleteDraftDocumentVersionMutation() {
);
}
const bulkDeleteDocumentsMutation = graphql`
mutation DocumentGraphBulkDeleteDocumentsMutation(
$input: BulkDeleteDocumentsInput!
) {
bulkDeleteDocuments(input: $input) {
deletedDocumentIds @deleteRecord
}
}
`;
export function useBulkDeleteDocumentsMutation() {
const { __ } = useTranslate();
return useMutationWithToasts(
bulkDeleteDocumentsMutation,
{
successMessage: __("Documents deleted successfully."),
errorMessage: __("Failed to delete documents. Please try again."),
}
);
}
const sendSigningNotificationsMutation = graphql`
mutation DocumentGraphSendSigningNotificationsMutation(
$input: SendSigningNotificationsInput!
@@ -83,6 +106,28 @@ export function useSendSigningNotificationsMutation() {
);
}
const bulkExportDocumentsMutation = graphql`
mutation DocumentGraphBulkExportDocumentsMutation(
$input: BulkExportDocumentsInput!
) {
bulkExportDocuments(input: $input) {
exportJobId
}
}
`;
export function useBulkExportDocumentsMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<DocumentGraphBulkExportDocumentsMutation>(
bulkExportDocumentsMutation,
{
successMessage: __("Document export started successfully. You will receive an email when the export is ready."),
errorMessage: __("Failed to start document export. Please try again."),
}
);
}
export const documentNodeQuery = graphql`
query DocumentGraphNodeQuery($documentId: ID!) {
node(id: $documentId) {

View File

@@ -0,0 +1,115 @@
/**
* @generated SignedSource<<a26f31885b4f92b2ad9398bce7f538cf>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type BulkDeleteDocumentsInput = {
documentIds: ReadonlyArray<string>;
};
export type DocumentGraphBulkDeleteDocumentsMutation$variables = {
input: BulkDeleteDocumentsInput;
};
export type DocumentGraphBulkDeleteDocumentsMutation$data = {
readonly bulkDeleteDocuments: {
readonly deletedDocumentIds: ReadonlyArray<string>;
};
};
export type DocumentGraphBulkDeleteDocumentsMutation = {
response: DocumentGraphBulkDeleteDocumentsMutation$data;
variables: DocumentGraphBulkDeleteDocumentsMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedDocumentIds",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "DocumentGraphBulkDeleteDocumentsMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "BulkDeleteDocumentsPayload",
"kind": "LinkedField",
"name": "bulkDeleteDocuments",
"plural": false,
"selections": [
(v2/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "DocumentGraphBulkDeleteDocumentsMutation",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": "BulkDeleteDocumentsPayload",
"kind": "LinkedField",
"name": "bulkDeleteDocuments",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteRecord",
"key": "",
"kind": "ScalarHandle",
"name": "deletedDocumentIds"
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "655f43d282b8e0007f3b93e381e7ff84",
"id": null,
"metadata": {},
"name": "DocumentGraphBulkDeleteDocumentsMutation",
"operationKind": "mutation",
"text": "mutation DocumentGraphBulkDeleteDocumentsMutation(\n $input: BulkDeleteDocumentsInput!\n) {\n bulkDeleteDocuments(input: $input) {\n deletedDocumentIds\n }\n}\n"
}
};
})();
(node as any).hash = "919857a24f366b8ebe004fcadf503c3d";
export default node;

View File

@@ -0,0 +1,92 @@
/**
* @generated SignedSource<<a398c636bba3d35aa7caef66c62c9ac6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type BulkExportDocumentsInput = {
documentIds: ReadonlyArray<string>;
};
export type DocumentGraphBulkExportDocumentsMutation$variables = {
input: BulkExportDocumentsInput;
};
export type DocumentGraphBulkExportDocumentsMutation$data = {
readonly bulkExportDocuments: {
readonly exportJobId: string;
};
};
export type DocumentGraphBulkExportDocumentsMutation = {
response: DocumentGraphBulkExportDocumentsMutation$data;
variables: DocumentGraphBulkExportDocumentsMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "BulkExportDocumentsPayload",
"kind": "LinkedField",
"name": "bulkExportDocuments",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "exportJobId",
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "DocumentGraphBulkExportDocumentsMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "DocumentGraphBulkExportDocumentsMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "2f4eb206613f8fba307aeee5bad27b93",
"id": null,
"metadata": {},
"name": "DocumentGraphBulkExportDocumentsMutation",
"operationKind": "mutation",
"text": "mutation DocumentGraphBulkExportDocumentsMutation(\n $input: BulkExportDocumentsInput!\n) {\n bulkExportDocuments(input: $input) {\n exportJobId\n }\n}\n"
}
};
})();
(node as any).hash = "0a7bae4b58713bcc824a774c31bd6dca";
export default node;

View File

@@ -524,12 +524,14 @@ export default function DocumentDetailPage(props: Props) {
{__("Controls")}
<TabBadge>{controlsCount}</TabBadge>
</TabLink>
<TabLink to={`${urlPrefix}/signatures`}>
{__("Signatures")}
<TabBadge>
{signedSignatures.length}/{signatures.length}
</TabBadge>
</TabLink>
{!isDraft && (
<TabLink to={`${urlPrefix}/signatures`}>
{__("Signatures")}
<TabBadge>
{signedSignatures.length}/{signatures.length}
</TabBadge>
</TabLink>
)}
</Tabs>
<Outlet context={{ document, version: currentVersion }} />

View File

@@ -19,6 +19,8 @@ import {
IconCrossLargeX,
IconSignature,
IconCheckmark1,
IconArrowDown,
Card,
} from "@probo/ui";
import {
useFragment,
@@ -32,6 +34,8 @@ import {
documentsQuery,
useDeleteDocumentMutation,
useSendSigningNotificationsMutation,
useBulkDeleteDocumentsMutation,
useBulkExportDocumentsMutation,
} from "/hooks/graph/DocumentGraph";
import type { DocumentsPageListFragment$key } from "./__generated__/DocumentsPageListFragment.graphql";
import { useList, usePageTitle } from "@probo/hooks";
@@ -94,7 +98,10 @@ export default function DocumentsPage(props: Props) {
.filter(Boolean);
const connectionId = pagination.data.documents.__id;
const [sendSigningNotifications] = useSendSigningNotificationsMutation();
const [bulkDeleteDocuments] = useBulkDeleteDocumentsMutation();
const [bulkExportDocuments] = useBulkExportDocumentsMutation();
const { list: selection, toggle, clear, reset } = useList<string>([]);
const confirm = useConfirm();
usePageTitle(__("Documents"));
@@ -106,6 +113,39 @@ export default function DocumentsPage(props: Props) {
});
};
const handleBulkDelete = () => {
const documentCount = selection.length;
confirm(
() =>
bulkDeleteDocuments({
variables: {
input: { documentIds: selection },
},
}).then(() => {
clear();
}),
{
message: sprintf(
__(
'This will permanently delete %s document%s. This action cannot be undone.'
),
documentCount,
documentCount > 1 ? 's' : ''
),
}
);
};
const handleBulkExport = () => {
bulkExportDocuments({
variables: {
input: { documentIds: selection },
},
}).then(() => {
clear();
});
};
return (
<div className="space-y-6">
<PageHeader
@@ -126,72 +166,103 @@ export default function DocumentsPage(props: Props) {
/>
</div>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
{selection.length === 0 ? (
<Tr>
<Th>
<Checkbox
checked={selection.length === documents.length && documents.length > 0}
onChange={() => reset(documents.map((d) => d.id))}
/>
</Th>
<SortableTh field="TITLE">{__("Name")}</SortableTh>
<Th>{__("Status")}</Th>
<SortableTh field="DOCUMENT_TYPE">{__("Type")}</SortableTh>
<Th>{__("Owner")}</Th>
<Th>{__("Last update")}</Th>
<Th>{__("Signatures")}</Th>
<Th></Th>
</Tr>
) : (
<Tr>
<Th colspan={8}>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
{sprintf(__("%s documents selected"), selection.length)} -
<button
onClick={clear}
className="flex gap-1 items-center hover:text-txt-primary"
>
<IconCrossLargeX size={12} />
{__("Clear selection")}
</button>
</div>
<div className="flex gap-2">
<PublishDocumentsDialog
documentIds={selection}
onSave={clear}
>
<Button icon={IconCheckmark1}>{__("Publish")}</Button>
</PublishDocumentsDialog>
{documents.length > 0 ? (
<SortableTable {...pagination}>
<Thead>
{selection.length === 0 ? (
<Tr>
<Th className="w-18">
<Checkbox
checked={selection.length === documents.length && documents.length > 0}
onChange={() => reset(documents.map((d) => d.id))}
/>
</Th>
<SortableTh field="TITLE" className="min-w-0">{__("Name")}</SortableTh>
<Th className="w-24">{__("Status")}</Th>
<SortableTh field="DOCUMENT_TYPE" className="w-28">{__("Type")}</SortableTh>
<Th className="w-60">{__("Owner")}</Th>
<Th className="w-60">{__("Last update")}</Th>
<Th className="w-20">{__("Signatures")}</Th>
<Th className="w-18"></Th>
</Tr>
) : (
<Tr>
<Th colspan={8} compact>
<div className="flex justify-between items-center h-8">
<div className="flex gap-2 items-center">
{sprintf(__("%s documents selected"), selection.length)} -
<button
onClick={clear}
className="flex gap-1 items-center hover:text-txt-primary"
>
<IconCrossLargeX size={12} />
{__("Clear selection")}
</button>
</div>
<div className="flex gap-2 items-center">
<PublishDocumentsDialog
documentIds={selection}
onSave={clear}
>
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
{__("Publish")}
</Button>
</PublishDocumentsDialog>
<SignatureDocumentsDialog
documentIds={selection}
onSave={clear}
>
<Button variant="secondary" icon={IconSignature}>
<Button variant="secondary" icon={IconSignature} className="py-0.5 px-2 text-xs h-6 min-h-6">
{__("Request signature")}
</Button>
</SignatureDocumentsDialog>
<Button
variant="secondary"
icon={IconArrowDown}
onClick={handleBulkExport}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Export")}
</Button>
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleBulkDelete}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Delete")}
</Button>
</div>
</div>
</div>
</Th>
</Tr>
)}
</Thead>
<Tbody>
{documents.map((document) => (
<DocumentRow
checked={selection.includes(document.id)}
onCheck={() => toggle(document.id)}
key={document.id}
document={document}
organizationId={organization.id}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
</Th>
</Tr>
)}
</Thead>
<Tbody>
{documents.map((document) => (
<DocumentRow
checked={selection.includes(document.id)}
onCheck={() => toggle(document.id)}
key={document.id}
document={document}
organizationId={organization.id}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>
) : (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No documents yet")}
</h3>
<p className="text-txt-tertiary mb-4">
{__("Create your first document to get started.")}
</p>
</div>
</Card>
)}
</div>
);
}
@@ -278,34 +349,27 @@ function DocumentRow({
return (
<Tr to={`/organizations/${organizationId}/documents/${document.id}`}>
<Td noLink>
<Td noLink className="w-18">
<Checkbox checked={checked} onChange={onCheck} />
</Td>
<Td>
<Td className="min-w-0">
<div className="flex gap-4 items-center">
<img
src="/document.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{document.title}
</div>
</Td>
<Td>
<Td className="w-24">
<Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")}
</Badge>
</Td>
<Td>{getDocumentTypeLabel(__, document.documentType)}</Td>
<Td>
<Td className="w-28">{getDocumentTypeLabel(__, document.documentType)}</Td>
<Td className="w-60">
<div className="flex gap-2 items-center">
<Avatar name={document.owner?.fullName ?? ""} />
{document.owner?.fullName}
</div>
</Td>
<Td>
<Td className="w-60">
{dateFormat(document.updatedAt, {
year: "numeric",
month: "short",
@@ -313,10 +377,10 @@ function DocumentRow({
weekday: "short",
})}
</Td>
<Td>
<Td className="w-20">
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end">
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"

View File

@@ -118,7 +118,7 @@ export default function FrameworkDetailPage(props: Props) {
exportFrameworkMutation,
{
errorMessage: "Failed to export framework",
successMessage: "Framework export started successfully",
successMessage: "Framework export started successfully. You will receive an email when the export is ready.",
}
);