Add major.minor document versioning

Introduce a two-part version scheme (major.minor) for documents.
Drafts start at 0.1 and increment minor on each new draft. Publishing
as minor keeps the current version, publishing as major bumps to the
next major.0. Both current_published_major and current_published_minor
are tracked on the document for exact version lookups.

Signatures and approval quorums aggregate across all versions sharing
the same major number using CTE joins. Approval page mutations spread
the decision fragment so Relay updates the version row state without
requiring a page refresh.

GraphQL, MCP, and service layer expose separate publishMajor and
publishMinor mutations instead of a single mutation with a type enum.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-27 19:57:58 +01:00
parent c5a412ea41
commit 68bc8cf59a
31 changed files with 877 additions and 417 deletions

View File

@@ -16,7 +16,7 @@ const fragment = graphql`
edges {
node {
id
currentPublishedVersion
currentPublishedMajor
...CompliancePageDocumentListItem_documentFragment
}
}
@@ -30,7 +30,7 @@ export function CompliancePageDocumentList(props: { fragmentRef: CompliancePageD
const { __ } = useTranslate();
const { compliancePage, documents } = useFragment<CompliancePageDocumentListFragment$key>(fragment, fragmentRef);
const publishedDocuments = documents.edges.filter(({ node }) => node.currentPublishedVersion != null);
const publishedDocuments = documents.edges.filter(({ node }) => node.currentPublishedMajor != null);
return (
<div className="space-y-[10px]">

View File

@@ -75,7 +75,8 @@ const unarchiveDocumentMutation = graphql`
const versionFragment = graphql`
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
id
version
major
minor
status
canDeleteDraft: permission(action: "core:document-version:delete-draft")
}
@@ -229,7 +230,7 @@ export function DocumentActionsDropdownn(props: {
__(
"This will permanently delete the draft version %s of \"%s\". This action cannot be undone.",
),
version.version,
`${version.major}.${version.minor}`,
document.title,
),
},
@@ -259,7 +260,7 @@ export function DocumentActionsDropdownn(props: {
if (data.exportDocumentVersionPDF) {
const link = window.document.createElement("a");
link.href = data.exportDocumentVersionPDF.data;
link.download = `${document.title}-v${version.version}.pdf`;
link.download = `${document.title}-v${version.major}.${version.minor}.pdf`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);

View File

@@ -29,7 +29,8 @@ const versionFragment = graphql`
fragment DocumentLayoutDrawer_versionFragment on DocumentVersion {
id
classification
version
major
minor
status
updatedAt
publishedAt
@@ -202,7 +203,9 @@ export function DocumentLayoutDrawer(props: {
</PropertyRow>
<PropertyRow label={__("Version")}>
<div className="text-sm text-txt-secondary">
{version.version}
{version.major}
.
{version.minor}
</div>
</PropertyRow>
<PropertyRow label={__("Last modified")}>

View File

@@ -22,7 +22,8 @@ const fragment = graphql`
node {
id
status
version
major
minor
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
@@ -148,7 +149,9 @@ export function DocumentListItem(props: {
</Td>
<Td className="w-20">
v
{lastVersion.version}
{lastVersion.major}
.
{lastVersion.minor}
</Td>
<Td className="w-28">
{getDocumentTypeLabel(__, document.documentType)}

View File

@@ -13,7 +13,8 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
fragment DocumentVersionsDropdownItemFragment on DocumentVersion {
id
version
major
minor
status
publishedAt
updatedAt
@@ -51,7 +52,9 @@ export function DocumentVersionsDropdownItem(props: {
)}
>
<div className="text-base text-txt-primary whitespace-nowrap font-bold text-center">
{version.version}
{version.major}
.
{version.minor}
</div>
</div>
<div className="flex-1 space-y-[2px] overflow-hidden">

View File

@@ -18,7 +18,8 @@ import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDialog_documentFragment$key } from "#/__generated__/core/PublishDialog_documentFragment.graphql";
import type { PublishDialog_publishMutation } from "#/__generated__/core/PublishDialog_publishMutation.graphql";
import type { PublishDialog_publishMajorMutation } from "#/__generated__/core/PublishDialog_publishMajorMutation.graphql";
import type { PublishDialog_publishMinorMutation } from "#/__generated__/core/PublishDialog_publishMinorMutation.graphql";
import type { PublishDialog_requestApprovalMutation } from "#/__generated__/core/PublishDialog_requestApprovalMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
@@ -62,9 +63,24 @@ const documentFragment = graphql`
}
`;
const publishMutation = graphql`
mutation PublishDialog_publishMutation($input: PublishDocumentVersionInput!) {
publishDocumentVersion(input: $input) {
const publishMajorMutation = graphql`
mutation PublishDialog_publishMajorMutation($input: PublishMajorDocumentVersionInput!) {
publishMajorDocumentVersion(input: $input) {
document {
id
status
}
documentVersion {
id
status
}
}
}
`;
const publishMinorMutation = graphql`
mutation PublishDialog_publishMinorMutation($input: PublishMinorDocumentVersionInput!) {
publishMinorDocumentVersion(input: $input) {
document {
id
status
@@ -160,36 +176,52 @@ export function PublishDialog({
},
}));
const [publishVersion, isPublishing] = useMutation<PublishDialog_publishMutation>(publishMutation);
const [requestApproval, isRequesting] = useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
const [publishMajor, isPublishingMajor]
= useMutation<PublishDialog_publishMajorMutation>(publishMajorMutation);
const [publishMinor, isPublishingMinor]
= useMutation<PublishDialog_publishMinorMutation>(publishMinorMutation);
const [requestApproval, isRequesting]
= useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
const isBusy = isPublishing || isRequesting;
const isBusy = isPublishingMajor || isPublishingMinor || isRequesting;
const approverIds = watch("approverIds");
const actionRef = useRef<"publish" | "request-approval">("publish");
const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish");
const handlePublish = (data: z.infer<typeof schema>) => {
publishVersion({
const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish document"), [...errors]),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Document published successfully."),
variant: "success",
});
dialogRef.current?.close();
onSuccess();
}
};
const onPublishError = (error: Error) => {
toast({ title: __("Error"), description: error.message, variant: "error" });
};
const handlePublishMajor = (data: z.infer<typeof schema>) => {
publishMajor({
variables: { input: { documentId, changelog: data.changelog } },
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish document"), errors),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Document published successfully."),
variant: "success",
});
dialogRef.current?.close();
onSuccess();
}
},
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
onCompleted: onPublishCompleted,
onError: onPublishError,
});
};
const handlePublishMinor = (data: z.infer<typeof schema>) => {
publishMinor({
variables: { input: { documentId, changelog: data.changelog } },
onCompleted: onPublishCompleted,
onError: onPublishError,
});
};
@@ -231,7 +263,9 @@ export function PublishDialog({
<form
onSubmit={e => void handleSubmit((data) => {
if (actionRef.current === "publish") {
handlePublish(data);
handlePublishMajor(data);
} else if (actionRef.current === "publish-minor") {
handlePublishMinor(data);
} else {
onRequestApproval(data);
}
@@ -284,38 +318,34 @@ export function PublishDialog({
</div>
</DialogContent>
<DialogFooter>
{hasPendingApproval
? (
<Button
type="submit"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
)
: (
<>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy || approverIds.length === 0}
>
{__("Request approval")}
</Button>
</>
)}
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish-minor"; }}
disabled={isBusy}
>
{__("Publish as minor")}
</Button>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
onClick={() => { actionRef.current = "publish"; }}
disabled={isBusy}
>
{__("Publish now")}
</Button>
{!hasPendingApproval && (
<Button
type="submit"
icon={IconSend}
onClick={() => { actionRef.current = "request-approval"; }}
disabled={isBusy || approverIds.length === 0}
>
{__("Request approval")}
</Button>
)}
</DialogFooter>
</form>
</Dialog>

View File

@@ -5,17 +5,19 @@ import {
Dialog,
DialogContent,
DialogFooter,
IconUpload,
IconWarning,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { type ReactNode, useRef } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
import type { PublishDocumentsDialog_majorMutation } from "#/__generated__/core/PublishDocumentsDialog_majorMutation.graphql";
import type { PublishDocumentsDialog_minorMutation } from "#/__generated__/core/PublishDocumentsDialog_minorMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
type Props = {
@@ -24,11 +26,27 @@ type Props = {
onSave: () => void;
};
const documentsPublishMutation = graphql`
mutation PublishDocumentsDialogMutation(
const publishMajorMutation = graphql`
mutation PublishDocumentsDialog_majorMutation(
$input: BulkPublishDocumentVersionsInput!
) {
bulkPublishDocumentVersions(input: $input) {
bulkPublishMajorDocumentVersions(input: $input) {
documentVersions {
id
}
documents {
id
...DocumentListItemFragment
}
}
}
`;
const publishMinorMutation = graphql`
mutation PublishDocumentsDialog_minorMutation(
$input: BulkPublishDocumentVersionsInput!
) {
bulkPublishMinorDocumentVersions(input: $input) {
documentVersions {
id
}
@@ -48,12 +66,18 @@ export function PublishDocumentsDialog({
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const actionRef = useRef<"major" | "minor">("major");
const schema = z.object({
changelog: z.string().min(1, __("Changelog is required")),
});
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
const [publishMajor, isPublishingMajor]
= useMutation<PublishDocumentsDialog_majorMutation>(publishMajorMutation);
const [publishMinor, isPublishingMinor]
= useMutation<PublishDocumentsDialog_minorMutation>(publishMinorMutation);
const isBusy = isPublishingMajor || isPublishingMinor;
const {
handleSubmit,
@@ -65,41 +89,47 @@ export function PublishDocumentsDialog({
},
});
const onSubmit = (data: z.infer<typeof schema>) => {
publishMutation({
variables: {
input: {
documentIds,
changelog: data.changelog,
},
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish documents"), errors),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: sprintf(__("%s documents published"), documentIds.length),
variant: "success",
});
dialogRef.current?.close();
onSave();
}
},
onError(error) {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
},
const onCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(__("Failed to publish documents"), [...errors]),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: sprintf(__("%s documents published"), documentIds.length),
variant: "success",
});
dialogRef.current?.close();
onSave();
}
};
const onError = (error: Error) => {
toast({
title: __("Error"),
description: error.message,
variant: "error",
});
};
const onSubmit = (data: z.infer<typeof schema>) => {
const variables = {
input: {
documentIds,
changelog: data.changelog,
},
};
if (actionRef.current === "minor") {
publishMinor({ variables, onCompleted, onError });
} else {
publishMajor({ variables, onCompleted, onError });
}
};
return (
<Dialog
className="max-w-xl"
@@ -135,7 +165,20 @@ export function PublishDocumentsDialog({
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isPublishing}>
<Button
type="submit"
variant="secondary"
icon={IconUpload}
disabled={isBusy}
onClick={() => { actionRef.current = "minor"; }}
>
{__("Publish as minor")}
</Button>
<Button
type="submit"
disabled={isBusy}
onClick={() => { actionRef.current = "major"; }}
>
{sprintf(__("Publish %s documents"), documentIds.length)}
</Button>
</DialogFooter>

View File

@@ -51,7 +51,8 @@ const createDraftDocument = graphql`
content
status
publishedAt
version
major
minor
updatedAt
signatures(first: 100) {
edges {

View File

@@ -70,9 +70,11 @@ const documentFragment = graphql`
const versionRowFragment = graphql`
fragment DocumentApprovePageVersionRowFragment on EmployeeDocumentVersion {
id
version
major
minor
publishedAt
approvalDecision {
id
state
}
}
@@ -96,10 +98,7 @@ const approveDocumentVersionMutation = graphql`
) {
approveDocumentVersion(input: $input) {
approvalDecision {
id
state
decidedAt
comment
...DocumentApprovePageDecisionFragment
}
}
}
@@ -111,10 +110,7 @@ const rejectDocumentVersionMutation = graphql`
) {
rejectDocumentVersion(input: $input) {
approvalDecision {
id
state
decidedAt
comment
...DocumentApprovePageDecisionFragment
}
}
}
@@ -192,14 +188,14 @@ function VersionRow({
)}
>
{versionData.publishedAt
? `v${versionData.version} - ${(() => {
? `v${versionData.major}.${versionData.minor} - ${(() => {
const date = new Date(versionData.publishedAt);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
})()}`
: `v${versionData.version}`}
: `v${versionData.major}.${versionData.minor}`}
</p>
</div>
<div className="flex-shrink-0">

View File

@@ -9,7 +9,8 @@ const fragment = graphql`
fragment VersionRowFragment on EmployeeDocumentVersion {
# eslint-disable-next-line relay/unused-fields
id
version
major
minor
signed
publishedAt
}
@@ -55,14 +56,14 @@ export function VersionRow({
)}
>
{versionData.publishedAt
? `v${versionData.version} - ${(() => {
? `v${versionData.major}.${versionData.minor} - ${(() => {
const date = new Date(versionData.publishedAt);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear();
return `${day}/${month}/${year}`;
})()}`
: `v${versionData.version}`}
: `v${versionData.major}.${versionData.minor}`}
</p>
</div>
<div className="flex-shrink-0">