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 <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-14 14:37:24 +02:00
parent 74d7d3ff25
commit 03708d45c3
21 changed files with 1054 additions and 1087 deletions

View File

@@ -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<DocumentGraphDeleteDraftMutation>(
deleteDraftDocumentVersionMutation,
{
successMessage: __("Draft deleted successfully."),
errorMessage: __("Failed to delete draft"),
},
);
}
const bulkDeleteDocumentsMutation = graphql`
mutation DocumentGraphBulkDeleteDocumentsMutation(
$input: BulkDeleteDocumentsInput!

View File

@@ -122,12 +122,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
const publishDialogRef = useRef<PublishDialogRef>(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>(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<DocumentLayoutQ
<DocumentActionsDropdown
documentFragmentRef={document}
versionFragmentRef={currentVersion}
onRefetch={onRefetch}
onVersionChanged={handleVersionChanged}
/>
</div>
</div>
<PageHeader
title={<DocumentTitleForm fKey={currentVersion} />}
title={(
<DocumentTitleForm
fKey={currentVersion}
documentId={document.id}
documentStatus={document.status}
onVersionChanged={handleVersionChanged}
/>
)}
/>
<Tabs>
@@ -207,18 +220,22 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
<TabLink to={`${urlPrefix}/signatures`}>
{__("Signatures")}
<TabBadge>
{currentVersion.signedSignatures.totalCount}
{currentVersion.signedSignatures?.totalCount ?? 0}
/
{currentVersion.signatures.totalCount}
{currentVersion.signatures?.totalCount ?? 0}
</TabBadge>
</TabLink>
)}
</Tabs>
<Outlet context={{ onRefetch, approvalRequestedAt }} />
<Outlet context={{ onRefetch, approvalRequestedAt, versionChangedAt }} />
</div>
<DocumentLayoutDrawer documentFragmentRef={document} versionFragmentRef={currentVersion} />
<DocumentLayoutDrawer
documentFragmentRef={document}
versionFragmentRef={currentVersion}
onVersionChanged={handleVersionChanged}
/>
<PublishDialog
ref={publishDialogRef}

View File

@@ -42,7 +42,7 @@ function DocumentLayoutQueryLoader() {
const onRefetch = useCallback(() => {
loadQuery(
{ documentId, versionId: versionId ?? "", versionSpecified: !!versionId },
{ fetchPolicy: "network-only" },
{ fetchPolicy: "store-and-network" },
);
}, [documentId, versionId, loadQuery]);

View File

@@ -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<PdfDownloadDialogRef>(null);
@@ -157,53 +127,16 @@ export function DocumentActionsDropdown(props: {
const document = useFragment<DocumentActionsDropdown_documentFragment$key>(documentFragment, documentFragmentRef);
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
const lastVersion = document.versions.edges[0].node;
const isLastVersionPublished = lastVersion.status === "PUBLISHED";
const isDraft = version.status === "DRAFT";
const [createDraftDocumentVersion, isCreatingDraft]
= useMutation<DocumentActionsDropdown_createDraftMutation>(createDraftDocumentVersionMutation);
const [deleteDocument, isDeleting] = useDeleteDocumentMutation();
const [archiveDocument, isArchiving]
= useMutation<DocumentActionsDropdown_archiveMutation>(archiveDocumentMutation);
const [unarchiveDocument, isUnarchiving]
= useMutation<DocumentActionsDropdown_unarchiveMutation>(unarchiveDocumentMutation);
const [deleteDraftDocumentVersion, isDeletingDraft]
= useDeleteDraftDocumentVersionMutation();
const [deleteDocumentDraft, isDeletingDraft]
= useMutation<DocumentActionsDropdown_deleteDocumentDraftMutation>(deleteDocumentDraftMutation);
const [exportDocumentVersion, isExporting]
= useMutation<DocumentActionsDropdown_exportVersionMutation>(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<void>((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}
/>
<ActionDropdown variant="secondary">
{document.canUpdate && isLastVersionPublished && (
<DropdownItem
onClick={handleCreateDraft}
icon={IconPencil}
disabled={isCreatingDraft}
>
{__("Create new draft")}
</DropdownItem>
)}
{isDraft
&& document.versions.totalCount > 1
&& version.canDeleteDraft && (
<DropdownItem
onClick={handleDeleteDraft}
icon={IconTrashCan}
disabled={isDeletingDraft}
>
{__("Delete draft document")}
</DropdownItem>
)}
<DropdownItem
onClick={() => pdfDownloadDialogRef.current?.open()}
icon={IconArrowDown}
@@ -389,6 +298,15 @@ export function DocumentActionsDropdown(props: {
>
{__("Download PDF")}
</DropdownItem>
{document.canDeleteDraft && version.status === "DRAFT" && !(version.major === 0 && version.minor === 1) && (
<DropdownItem
icon={IconTrashCan}
disabled={isDeletingDraft}
onClick={handleDeleteDraft}
>
{__("Delete draft")}
</DropdownItem>
)}
{document.canArchive && document.status === "ACTIVE" && (
<DropdownItem
icon={IconArchive}

View File

@@ -22,7 +22,6 @@ import { z } from "zod";
import type { DocumentLayoutDrawer_documentFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_documentFragment.graphql";
import type { DocumentLayoutDrawer_updateApproversMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateApproversMutation.graphql";
import type { DocumentLayoutDrawer_updateClassificationMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateClassificationMutation.graphql";
import type { DocumentLayoutDrawer_versionFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_versionFragment.graphql";
import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/DocumentLayoutDrawerMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
@@ -31,6 +30,7 @@ import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const documentFragment = graphql`
fragment DocumentLayoutDrawer_documentFragment on Document {
id
@@ -58,23 +58,21 @@ const versionFragment = graphql`
}
`;
const updateDocumentTypeMutation = graphql`
mutation DocumentLayoutDrawerMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
const updateDocumentMutation = graphql`
mutation DocumentLayoutDrawerMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
documentType
}
}
}
`;
const updateClassificationMutation = graphql`
mutation DocumentLayoutDrawer_updateClassificationMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
documentVersion {
id
classification
major
minor
status
updatedAt
publishedAt
}
}
}
@@ -110,8 +108,9 @@ const approversSchema = z.object({
export function DocumentLayoutDrawer(props: {
documentFragmentRef: DocumentLayoutDrawer_documentFragment$key;
versionFragmentRef: DocumentLayoutDrawer_versionFragment$key;
onVersionChanged: () => 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<DocumentLayoutDrawer_versionFragment$key>(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<DocumentLayoutDrawerMutation>(updateDocumentTypeMutation);
const [updateClassification, isUpdatingClassification]
= useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation);
const [updateDocument, isUpdatingDocument]
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentMutation);
const [updateApprovers, isUpdatingApprovers]
= useMutation<DocumentLayoutDrawer_updateApproversMutation>(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}
>
<ControlledField
name="documentType"
@@ -318,7 +322,7 @@ export function DocumentLayoutDrawer(props: {
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingType(true)}
canEdit={canEdit && isDraft}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{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}
>
<ControlledField
name="classification"
@@ -349,7 +353,7 @@ export function DocumentLayoutDrawer(props: {
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingClassification(true)}
canEdit={canEdit && isDraft}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{getDocumentClassificationLabel(__, version.classification)}

View File

@@ -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<DocumentTitleFormFragment$key>(fragment, fKey);
const [updateDocumentVersion, isUpdating]
= useMutation<DocumentTitleFormMutation>(updateDocumentVersionTitleMutation);
const [updateDocument, isUpdating]
= useMutation<DocumentTitleFormMutation>(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 });
}}
/>
</div>
@@ -125,7 +136,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
: (
<div className="flex items-center gap-2">
<span>{version.title}</span>
{version.canUpdate && version.status === "DRAFT" && (
{canEdit && (
<Button
variant="quaternary"
icon={IconPencil}

View File

@@ -15,8 +15,9 @@
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { RichEditor, useToast } from "@probo/ui";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
@@ -39,6 +40,9 @@ export const documentDescriptionPageQuery = graphql`
document: node(id: $documentId) {
__typename
... on Document {
id
status
canUpdate: permission(action: "core:document:update")
# We use this on /documents/:documentId/description
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
edges {
@@ -55,20 +59,30 @@ export const documentDescriptionPageQuery = graphql`
`;
const updateContentMutation = graphql`
mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
content
status
}
}
}
`;
export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<DocumentDescriptionPageQuery> }) {
const { queryRef } = props;
export function DocumentDescriptionPage(props: {
queryRef: PreloadedQuery<DocumentDescriptionPageQuery>;
versionChangedAt: number;
}) {
const { queryRef, versionChangedAt } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const { versionId } = useParams();
const { onRefetch } = useOutletContext<{ onRefetch: () => void }>();
const { document, version } = usePreloadedQuery<DocumentDescriptionPageQuery>(
documentDescriptionPageQuery,
@@ -81,18 +95,21 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
const lastVersion = document.lastVersion?.edges[0].node;
const currentVersion = lastVersion ?? version as NonNullable<typeof lastVersion | typeof version>;
const [updateContent, _] = useMutation<DocumentDescriptionPage_updateContentMutation>(updateContentMutation);
const [updateContent] = useMutation<DocumentDescriptionPage_updateContentMutation>(updateContentMutation);
const documentId = document.id;
const wasDraft = currentVersion.status === "DRAFT";
const handleUpdate = useDebounceCallback(
useCallback((content: string) => {
updateContent({
variables: {
input: {
documentVersionId: currentVersion.id,
id: documentId,
content,
},
},
onCompleted: (_, errors) => {
onCompleted: (data, errors) => {
if (errors?.length) {
toast({
title: __("Error"),
@@ -102,6 +119,15 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
return;
}
// Refetch the layout when draft status changes (draft created
// or auto-deleted) so the drawer and header reflect the current
// version. This does NOT remount the editor because the editor
// key is based on versionChangedAt (explicit actions only).
const draftReturned = !!data.updateDocument.documentVersion;
if (wasDraft !== draftReturned) {
onRefetch();
}
toast({
title: __("Success"),
description: __("Content saved"),
@@ -116,16 +142,60 @@ export function DocumentDescriptionPage(props: { queryRef: PreloadedQuery<Docume
});
},
});
}, [currentVersion.id, updateContent, toast, __]),
}, [documentId, wasDraft, updateContent, toast, __, onRefetch]),
autoSaveIntervalMs,
);
// When viewing a specific historical version, the editor is read-only.
// When viewing the latest version, editing is allowed if the user has
// update permission and the document is not archived — the backend
// will auto-create a draft if needed.
const isViewingSpecificVersion = !!version;
const canEdit = !isViewingSpecificVersion
&& document.canUpdate
&& document.status !== "ARCHIVED";
// The editor key must change on explicit actions (delete draft, edit
// title/type) but NOT on auto-save side effects (cursor preservation).
// We track a "data generation" that only increments when an explicit
// action (versionChangedAt change) is followed by fresh data arriving
// (currentVersion.id change). This uses React's "adjust state during
// render" pattern so we avoid refs-during-render and setState-in-effects.
const [prevVCA, setPrevVCA] = useState(versionChangedAt);
const [prevVersionId, setPrevVersionId] = useState(currentVersion.id);
const [dataGeneration, setDataGeneration] = useState(0);
const [pendingExplicit, setPendingExplicit] = useState(false);
if (versionChangedAt !== prevVCA) {
setPrevVCA(versionChangedAt);
if (currentVersion.id !== prevVersionId) {
// Both changed at once — data was already available.
setPrevVersionId(currentVersion.id);
setDataGeneration(g => g + 1);
setPendingExplicit(false);
} else {
// Explicit action fired but data hasn't arrived yet.
setPendingExplicit(true);
}
} else if (currentVersion.id !== prevVersionId) {
setPrevVersionId(currentVersion.id);
if (pendingExplicit) {
// Fresh data arrived for a pending explicit action — remount.
setDataGeneration(g => g + 1);
setPendingExplicit(false);
}
// Otherwise auto-save changed the version — don't bump generation.
}
const editorKey = `${versionId ?? "latest"}-${dataGeneration}`;
return (
<RichEditor
key={editorKey}
className="flex-1"
content={currentVersion.content}
data-theme="document"
disabled={currentVersion.status !== "DRAFT"}
disabled={!canEdit}
onChangeContent={handleUpdate}
/>
);

View File

@@ -14,7 +14,7 @@
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import { useOutletContext, useParams } from "react-router";
import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
@@ -28,23 +28,26 @@ function DocumentDescriptionPageQueryLoader() {
throw new Error(":documentId missing in route params");
}
const { versionChangedAt } = useOutletContext<{ versionChangedAt: number }>();
const [queryRef, loadQuery] = useQueryLoader<DocumentDescriptionPageQuery>(documentDescriptionPageQuery);
useEffect(() => {
if (!queryRef) {
loadQuery({
documentId: documentId,
versionId: versionId ?? "",
versionSpecified: !!versionId,
});
}
});
loadQuery(
{ documentId, versionId: versionId ?? "", versionSpecified: !!versionId },
{ fetchPolicy: versionChangedAt > 0 ? "network-only" : "store-or-network" },
);
}, [documentId, versionId, versionChangedAt, loadQuery]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return <DocumentDescriptionPage queryRef={queryRef} />;
return (
<DocumentDescriptionPage
queryRef={queryRef}
versionChangedAt={versionChangedAt}
/>
);
}
export default function DocumentDescriptionPageLoader() {