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:
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -51,7 +51,8 @@ const createDraftDocument = graphql`
|
||||
content
|
||||
status
|
||||
publishedAt
|
||||
version
|
||||
major
|
||||
minor
|
||||
updatedAt
|
||||
signatures(first: 100) {
|
||||
edges {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user