From 03708d45c321191a7d4f266e2364537e3953dc93 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Tue, 14 Apr 2026 14:37:24 +0200 Subject: [PATCH] Consolidate document draft management into updateDocument Replace the three separate draft mutations (createDraftDocumentVersion, updateDocumentVersion, deleteDraftDocumentVersion) with automatic draft lifecycle management inside updateDocument. The backend now auto-creates a draft when a published document is edited, updates the existing draft on subsequent edits, and auto-deletes the draft when content reverts to match the published version. A new deleteDocumentDraft mutation provides explicit draft deletion. Backend: - Merge version-level fields (content, title, classification, documentType) into UpdateDocumentRequest - Convert CreateDraft, UpdateVersion, DeleteDraft into private transaction helpers called from Update - Update returns (*Document, *DocumentVersion, error) with the version present only when a draft exists Frontend: - Remove all create/update/delete draft mutations from components - Auto-save via updateDocument with layout refetch on draft status transitions while preserving editor cursor (data-generation key) - Title, type, and classification editable on published versions (backend auto-creates draft) - Forms use react-hook-form values option to stay synced with Relay fragment data across draft/publish transitions API surface (GraphQL, MCP, CLI, n8n) updated consistently: - Removed: createDraftDocumentVersion, updateDocumentVersion, deleteDraftDocumentVersion - Added: deleteDocumentDraft (document-level) - Updated: updateDocument accepts content, classification, documentType Signed-off-by: Sacha Al Himdani --- apps/console/src/hooks/graph/DocumentGraph.ts | 24 -- .../documents/DocumentLayout.tsx | 29 +- .../documents/DocumentLayoutLoader.tsx | 2 +- .../_components/DocumentActionsDropdown.tsx | 208 +++------- .../_components/DocumentLayoutDrawer.tsx | 76 ++-- .../_components/DocumentTitleForm.tsx | 43 +- .../description/DocumentDescriptionPage.tsx | 90 +++- .../DocumentDescriptionPageLoader.tsx | 23 +- e2e/console/document_test.go | 80 ++-- e2e/console/document_version_test.go | 390 ++++++++++++++++-- pkg/cmd/document/create-draft/create_draft.go | 111 ----- pkg/cmd/document/delete-draft/delete_draft.go | 18 +- pkg/cmd/document/document.go | 4 - .../document/update-version/update_version.go | 154 ------- pkg/cmd/document/update/update.go | 94 +++-- pkg/probo/actions.go | 4 +- pkg/probo/document_service.go | 341 ++++++++------- pkg/server/api/console/v1/schema.graphql | 52 +-- pkg/server/api/console/v1/v1_resolver.go | 144 +++---- pkg/server/api/mcp/v1/schema.resolvers.go | 121 ++---- pkg/server/api/mcp/v1/specification.yaml | 133 ++---- 21 files changed, 1054 insertions(+), 1087 deletions(-) delete mode 100644 pkg/cmd/document/create-draft/create_draft.go delete mode 100644 pkg/cmd/document/update-version/update_version.go diff --git a/apps/console/src/hooks/graph/DocumentGraph.ts b/apps/console/src/hooks/graph/DocumentGraph.ts index 24901d56e..8d0dc6738 100644 --- a/apps/console/src/hooks/graph/DocumentGraph.ts +++ b/apps/console/src/hooks/graph/DocumentGraph.ts @@ -16,7 +16,6 @@ import { useTranslate } from "@probo/i18n"; import { graphql } from "relay-runtime"; import type { DocumentGraphBulkExportDocumentsMutation } from "#/__generated__/core/DocumentGraphBulkExportDocumentsMutation.graphql"; -import type { DocumentGraphDeleteDraftMutation } from "#/__generated__/core/DocumentGraphDeleteDraftMutation.graphql"; import type { DocumentGraphDeleteMutation } from "#/__generated__/core/DocumentGraphDeleteMutation.graphql"; import type { DocumentGraphSendSigningNotificationsMutation } from "#/__generated__/core/DocumentGraphSendSigningNotificationsMutation.graphql"; @@ -47,29 +46,6 @@ export function useDeleteDocumentMutation() { ); } -const deleteDraftDocumentVersionMutation = graphql` - mutation DocumentGraphDeleteDraftMutation( - $input: DeleteDraftDocumentVersionInput! - $connections: [ID!]! - ) { - deleteDraftDocumentVersion(input: $input) { - deletedDocumentVersionId @deleteEdge(connections: $connections) - } - } -`; - -export function useDeleteDraftDocumentVersionMutation() { - const { __ } = useTranslate(); - - return useMutationWithToasts( - deleteDraftDocumentVersionMutation, - { - successMessage: __("Draft deleted successfully."), - errorMessage: __("Failed to delete draft"), - }, - ); -} - const bulkDeleteDocumentsMutation = graphql` mutation DocumentGraphBulkDeleteDocumentsMutation( $input: BulkDeleteDocumentsInput! diff --git a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx index 751b267db..312ac7005 100644 --- a/apps/console/src/pages/organizations/documents/DocumentLayout.tsx +++ b/apps/console/src/pages/organizations/documents/DocumentLayout.tsx @@ -122,12 +122,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery(null); const [approvalRequestedAt, setApprovalRequestedAt] = useState(0); + const [versionChangedAt, setVersionChangedAt] = useState(0); const handlePublishOrApproval = useCallback(() => { onRefetch(); setApprovalRequestedAt(Date.now()); }, [onRefetch]); + const handleVersionChanged = useCallback(() => { + onRefetch(); + setVersionChangedAt(Date.now()); + }, [onRefetch]); + const { document, version } = usePreloadedQuery(documentLayoutQuery, queryRef); if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) { throw new Error("invalid node type"); @@ -178,13 +184,20 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery } + title={( + + )} /> @@ -207,18 +220,22 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery {__("Signatures")} - {currentVersion.signedSignatures.totalCount} + {currentVersion.signedSignatures?.totalCount ?? 0} / - {currentVersion.signatures.totalCount} + {currentVersion.signatures?.totalCount ?? 0} )} - + - + { loadQuery( { documentId, versionId: versionId ?? "", versionSpecified: !!versionId }, - { fetchPolicy: "network-only" }, + { fetchPolicy: "store-and-network" }, ); }, [documentId, versionId, loadQuery]); diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx index 4e0a43836..d5252b3ed 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentActionsDropdown.tsx @@ -14,20 +14,20 @@ import { formatError, sprintf } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; -import { ActionDropdown, DropdownItem, IconArchive, IconArrowDown, IconPencil, IconTrashCan, useConfirm, useToast } from "@probo/ui"; +import { ActionDropdown, DropdownItem, IconArchive, IconArrowDown, IconTrashCan, useConfirm, useToast } from "@probo/ui"; import { use, useRef } from "react"; import { useFragment, useMutation } from "react-relay"; -import { useNavigate, useParams } from "react-router"; +import { useNavigate } from "react-router"; import { ConnectionHandler, graphql } from "relay-runtime"; import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql"; -import type { DocumentActionsDropdown_createDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_createDraftMutation.graphql"; +import type { DocumentActionsDropdown_deleteDocumentDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_deleteDocumentDraftMutation.graphql"; import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql"; import type { DocumentActionsDropdown_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdown_exportVersionMutation.graphql"; 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, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph"; +import { DocumentsConnectionKey, useDeleteDocumentMutation } from "#/hooks/graph/DocumentGraph"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { CurrentUser } from "#/providers/CurrentUser"; @@ -35,49 +35,10 @@ const documentFragment = graphql` fragment DocumentActionsDropdown_documentFragment on Document { id 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: 1 orderBy: { field: CREATED_AT, direction: DESC }) { - totalCount - edges { - node { - id - title - status - } - } - } - } -`; - -const createDraftDocumentVersionMutation = graphql` - mutation DocumentActionsDropdown_createDraftMutation( - $input: CreateDraftDocumentVersionInput! - $connections: [ID!]! - ) { - createDraftDocumentVersion(input: $input) { - documentVersionEdge @prependEdge(connections: $connections) { - node { - id - content - status - publishedAt - major - minor - updatedAt - signatures(first: 100) { - edges { - node { - id - state - } - } - } - } - } - } + canDeleteDraft: permission(action: "core:document:delete-draft") } `; @@ -90,7 +51,6 @@ const archiveDocumentMutation = graphql` 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") @@ -108,7 +68,6 @@ const unarchiveDocumentMutation = graphql` 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") @@ -117,6 +76,19 @@ const unarchiveDocumentMutation = graphql` } `; +const deleteDocumentDraftMutation = graphql` + mutation DocumentActionsDropdown_deleteDocumentDraftMutation( + $input: DeleteDocumentDraftInput! + ) { + deleteDocumentDraft(input: $input) { + document { + id + status + } + } + } +`; + const versionFragment = graphql` fragment DocumentActionsDropdown_versionFragment on DocumentVersion { id @@ -124,7 +96,6 @@ const versionFragment = graphql` major minor status - canDeleteDraft: permission(action: "core:document-version:delete-draft") } `; @@ -141,13 +112,12 @@ const exportDocumentVersionMutation = graphql` export function DocumentActionsDropdown(props: { documentFragmentRef: DocumentActionsDropdown_documentFragment$key; versionFragmentRef: DocumentActionsDropdown_versionFragment$key; - onRefetch: () => void; + onVersionChanged: () => void; }) { - const { documentFragmentRef, versionFragmentRef, onRefetch } = props; + const { documentFragmentRef, versionFragmentRef, onVersionChanged } = props; const organizationId = useOrganizationId(); const navigate = useNavigate(); - const { versionId } = useParams(); const { __ } = useTranslate(); const { email: defaultEmail } = use(CurrentUser); const pdfDownloadDialogRef = useRef(null); @@ -157,53 +127,16 @@ export function DocumentActionsDropdown(props: { const document = useFragment(documentFragment, documentFragmentRef); const version = useFragment(versionFragment, versionFragmentRef); - const lastVersion = document.versions.edges[0].node; - const isLastVersionPublished = lastVersion.status === "PUBLISHED"; - const isDraft = version.status === "DRAFT"; - - const [createDraftDocumentVersion, isCreatingDraft] - = useMutation(createDraftDocumentVersionMutation); const [deleteDocument, isDeleting] = useDeleteDocumentMutation(); const [archiveDocument, isArchiving] = useMutation(archiveDocumentMutation); const [unarchiveDocument, isUnarchiving] = useMutation(unarchiveDocumentMutation); - const [deleteDraftDocumentVersion, isDeletingDraft] - = useDeleteDraftDocumentVersionMutation(); + const [deleteDocumentDraft, isDeletingDraft] + = useMutation(deleteDocumentDraftMutation); const [exportDocumentVersion, isExporting] = useMutation(exportDocumentVersionMutation); - const handleCreateDraft = () => { - const connectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions"); - createDraftDocumentVersion({ - variables: { - input: { - documentID: document.id, - }, - connections: [connectionId], - }, - onCompleted: (response, errors) => { - if (errors) { - toast({ - variant: "error", - title: __("Error creating draft"), - description: - errors[0]?.message || __("An unknown error occurred"), - }); - return; - } - - const newVersionId - = response.createDraftDocumentVersion.documentVersionEdge.node.id; - - void navigate(`/organizations/${organizationId}/documents/${document.id}/versions/${newVersionId}`); - }, - onError(error) { - toast({ title: __("Error"), description: error.message, variant: "error" }); - }, - }); - }; - const handleArchive = () => { confirm( () => @@ -227,7 +160,7 @@ export function DocumentActionsDropdown(props: { { message: sprintf( __("This will archive the document \"%s\". It will no longer be editable."), - lastVersion.title, + version.title, ), variant: "danger", label: __("Archive"), @@ -251,6 +184,36 @@ export function DocumentActionsDropdown(props: { }); }; + const handleDeleteDraft = () => { + confirm( + () => + new Promise((resolve) => { + deleteDocumentDraft({ + variables: { input: { documentId: document.id } }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: formatError(__("Failed to delete draft"), errors), variant: "error" }); + } else { + toast({ title: __("Success"), description: __("Draft deleted successfully."), variant: "success" }); + onVersionChanged(); + void navigate(`/organizations/${organizationId}/documents/${document.id}/description`); + } + resolve(); + }, + onError(error) { + toast({ title: __("Error"), description: error.message, variant: "error" }); + resolve(); + }, + }); + }), + { + message: __("This will delete the current draft and revert to the last published version."), + variant: "danger", + label: __("Delete draft"), + }, + ); + }; + const handleDelete = () => { const connectionId = ConnectionHandler.getConnectionID( organizationId, @@ -273,41 +236,7 @@ export function DocumentActionsDropdown(props: { __( "This will permanently delete the document \"%s\". This action cannot be undone.", ), - lastVersion.title, - ), - }, - ); - }; - - const handleDeleteDraft = () => { - const versionsConnectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions"); - const lastVersionConnectionId = ConnectionHandler.getConnectionID( - document.id, - "DocumentversionsDropdownMenu_lastVersion", - { orderBy: { field: "CREATED_AT", direction: "DESC" } }, - ); - confirm( - () => - deleteDraftDocumentVersion({ - variables: { - input: { documentVersionId: version.id }, - connections: [versionsConnectionId, lastVersionConnectionId], - }, - onSuccess() { - if (versionId) { - void navigate(`/organizations/${organizationId}/documents/${document.id}`); - } else { - onRefetch(); - } - }, - }), - { - message: sprintf( - __( - "This will permanently delete the draft version %s of \"%s\". This action cannot be undone.", - ), - `${version.major}.${version.minor}`, - lastVersion.title, + version.title, ), }, ); @@ -362,26 +291,6 @@ export function DocumentActionsDropdown(props: { defaultEmail={defaultEmail} /> - {document.canUpdate && isLastVersionPublished && ( - - {__("Create new draft")} - - )} - {isDraft - && document.versions.totalCount > 1 - && version.canDeleteDraft && ( - - {__("Delete draft document")} - - )} pdfDownloadDialogRef.current?.open()} icon={IconArrowDown} @@ -389,6 +298,15 @@ export function DocumentActionsDropdown(props: { > {__("Download PDF")} + {document.canDeleteDraft && version.status === "DRAFT" && !(version.major === 0 && version.minor === 1) && ( + + {__("Delete draft")} + + )} {document.canArchive && document.status === "ACTIVE" && ( void; }) { - const { documentFragmentRef, versionFragmentRef } = props; + const { documentFragmentRef, versionFragmentRef, onVersionChanged } = props; const { __ } = useTranslate(); const organizationId = useOrganizationId(); @@ -125,12 +124,12 @@ export function DocumentLayoutDrawer(props: { const version = useFragment(versionFragment, versionFragmentRef); const isDraft = version.status === "DRAFT"; - const canEdit = document.canUpdate; + const canEdit = document.canUpdate && document.status !== "ARCHIVED"; const { control, handleSubmit, reset } = useFormWithSchema( schema, { - defaultValues: { + values: { documentType: version.documentType, }, }, @@ -143,7 +142,7 @@ export function DocumentLayoutDrawer(props: { } = useFormWithSchema( classificationSchema, { - defaultValues: { + values: { classification: version.classification, }, }, @@ -156,17 +155,14 @@ export function DocumentLayoutDrawer(props: { } = useFormWithSchema( approversSchema, { - defaultValues: { + values: { approverIds: document.defaultApprovers.map(a => a.id), }, }, ); - const [updateDocumentType, isUpdatingDocumentType] - = useMutation(updateDocumentTypeMutation); - - const [updateClassification, isUpdatingClassification] - = useMutation(updateClassificationMutation); + const [updateDocument, isUpdatingDocument] + = useMutation(updateDocumentMutation); const [updateApprovers, isUpdatingApprovers] = useMutation(updateApproversMutation); @@ -174,15 +170,19 @@ export function DocumentLayoutDrawer(props: { const handleUpdateDocumentType = (data: { documentType: (typeof documentTypes)[number]; }) => { - updateDocumentType({ + updateDocument({ variables: { input: { - documentVersionId: version.id, + id: document.id, documentType: data.documentType, }, }, - onCompleted: () => { + onCompleted: (data) => { setIsEditingType(false); + const draftReturned = !!data.updateDocument.documentVersion; + if (isDraft !== draftReturned) { + onVersionChanged(); + } toast({ title: __("Success"), description: __("Document type updated successfully"), @@ -202,15 +202,19 @@ export function DocumentLayoutDrawer(props: { const handleUpdateClassification = (data: { classification: (typeof documentClassifications)[number]; }) => { - updateClassification({ + updateDocument({ variables: { input: { - documentVersionId: version.id, + id: document.id, classification: data.classification, }, }, - onCompleted: () => { + onCompleted: (data) => { setIsEditingClassification(false); + const draftReturned = !!data.updateDocument.documentVersion; + if (isDraft !== draftReturned) { + onVersionChanged(); + } toast({ title: __("Success"), description: __("Document classification updated successfully"), @@ -302,9 +306,9 @@ export function DocumentLayoutDrawer(props: { onSave={() => void handleSubmit(handleUpdateDocumentType)()} onCancel={() => { setIsEditingType(false); - reset(); + reset({ documentType: version.documentType }); }} - disabled={isUpdatingDocumentType} + disabled={isUpdatingDocument} > setIsEditingType(true)} - canEdit={canEdit && isDraft} + canEdit={canEdit} >
{getDocumentTypeLabel(__, version.documentType)} @@ -333,9 +337,9 @@ export function DocumentLayoutDrawer(props: { onSave={() => void handleClassificationSubmit(handleUpdateClassification)()} onCancel={() => { setIsEditingClassification(false); - resetClassification(); + resetClassification({ classification: version.classification }); }} - disabled={isUpdatingClassification} + disabled={isUpdatingDocument} > setIsEditingClassification(true)} - canEdit={canEdit && isDraft} + canEdit={canEdit} >
{getDocumentClassificationLabel(__, version.classification)} diff --git a/apps/console/src/pages/organizations/documents/_components/DocumentTitleForm.tsx b/apps/console/src/pages/organizations/documents/_components/DocumentTitleForm.tsx index 396078208..b12e3b55f 100644 --- a/apps/console/src/pages/organizations/documents/_components/DocumentTitleForm.tsx +++ b/apps/console/src/pages/organizations/documents/_components/DocumentTitleForm.tsx @@ -24,9 +24,9 @@ import type { DocumentTitleFormFragment$key } from "#/__generated__/core/Documen import type { DocumentTitleFormMutation } from "#/__generated__/core/DocumentTitleFormMutation.graphql"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; -const updateDocumentVersionTitleMutation = graphql` - mutation DocumentTitleFormMutation($input: UpdateDocumentVersionInput!) { - updateDocumentVersion(input: $input) { +const updateDocumentTitleMutation = graphql` + mutation DocumentTitleFormMutation($input: UpdateDocumentInput!) { + updateDocument(input: $input) { documentVersion { ...DocumentTitleFormFragment } @@ -36,10 +36,9 @@ const updateDocumentVersionTitleMutation = graphql` const fragment = graphql` fragment DocumentTitleFormFragment on DocumentVersion { - id title status - canUpdate: permission(action: "core:document-version:update") + canUpdate: permission(action: "core:document:update") } `; @@ -47,40 +46,52 @@ const schema = z.object({ title: z.string().min(1, "Title is required").max(255), }); -export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }) { - const { fKey } = props; +export function DocumentTitleForm(props: { + fKey: DocumentTitleFormFragment$key; + documentId: string; + documentStatus: string; + onVersionChanged: () => void; +}) { + const { fKey, documentId, documentStatus, onVersionChanged } = props; const { __ } = useTranslate(); const { toast } = useToast(); const version = useFragment(fragment, fKey); - const [updateDocumentVersion, isUpdating] - = useMutation(updateDocumentVersionTitleMutation); + const [updateDocument, isUpdating] + = useMutation(updateDocumentTitleMutation); const [isEditingTitle, setIsEditingTitle] = useState(false); const { register, handleSubmit, reset } = useFormWithSchema( schema, { - defaultValues: { + values: { title: version.title, }, }, ); + const isDraft = version.status === "DRAFT"; + const canEdit = version.canUpdate && documentStatus !== "ARCHIVED"; + const handleUpdateTitle = (data: { title: string }) => { - updateDocumentVersion({ + updateDocument({ variables: { input: { - documentVersionId: version.id, + id: documentId, title: data.title, }, }, - onCompleted(_, errors) { + onCompleted(data, errors) { if (errors?.length) { toast({ title: __("Error"), description: formatError(__("Failed to update document"), errors), variant: "error" }); return; } setIsEditingTitle(false); + const draftReturned = !!data.updateDocument.documentVersion; + if (isDraft !== draftReturned) { + onVersionChanged(); + } }, onError(error) { toast({ title: __("Error"), description: error.message, variant: "error" }); @@ -99,7 +110,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key } onKeyDown={(e) => { if (e.key === "Escape") { setIsEditingTitle(false); - reset(); + reset({ title: version.title }); } if (e.key === "Enter") { void handleSubmit(handleUpdateTitle)(); @@ -117,7 +128,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key } icon={IconCrossLargeX} onClick={() => { setIsEditingTitle(false); - reset(); + reset({ title: version.title }); }} />
@@ -125,7 +136,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key } : (
{version.title} - {version.canUpdate && version.status === "DRAFT" && ( + {canEdit && (