Move document classification from document to document version

Classification now lives exclusively on DocumentVersion. The field is
removed from the Document model, all SQL queries, GraphQL Document
type, SignableDocument type, UpdateDocumentInput, and MCP Document
schema.

New documents still accept classification in CreateDocumentInput,
applied to the first version. New drafts inherit classification from
the previous version. PDF generation uses the version classification.

The drawer allows editing classification on draft versions via the
updateDocumentVersion mutation. Classification is read-only on
published versions.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-27 16:04:38 +01:00
parent 851e585b9b
commit 5d6d0bdd7f
16 changed files with 155 additions and 151 deletions

View File

@@ -16,19 +16,18 @@ import { documentClassifications, documentTypes, formatDate, getDocumentClassifi
import { useTranslate } from "@probo/i18n";
import { Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
import { useState } from "react";
import { useFragment } from "react-relay";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { DocumentLayoutDrawer_documentFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_documentFragment.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";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const documentFragment = graphql`
fragment DocumentLayoutDrawer_documentFragment on Document {
id
@@ -57,6 +56,16 @@ const updateDocumentMutation = graphql`
document {
id
documentType
}
}
}
`;
const updateClassificationMutation = graphql`
mutation DocumentLayoutDrawer_updateClassificationMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
documentVersion {
id
classification
}
}
@@ -65,6 +74,9 @@ const updateDocumentMutation = graphql`
const schema = z.object({
documentType: z.enum(documentTypes),
});
const classificationSchema = z.object({
classification: z.enum(documentClassifications),
});
@@ -90,47 +102,56 @@ export function DocumentLayoutDrawer(props: {
{
defaultValues: {
documentType: document.documentType,
},
},
);
const {
control: classificationControl,
handleSubmit: handleClassificationSubmit,
reset: resetClassification,
} = useFormWithSchema(
classificationSchema,
{
defaultValues: {
classification: version.classification,
},
},
);
const [updateDocument, isUpdatingDocument]
= useMutationWithToasts<DocumentLayoutDrawerMutation>(
updateDocumentMutation,
{
successMessage: __("Document updated successfully."),
errorMessage: __("Failed to update document"),
},
);
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentMutation);
const handleUpdateDocumentType = async (data: {
const [updateClassification, isUpdatingClassification]
= useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation);
const handleUpdateDocumentType = (data: {
documentType: (typeof documentTypes)[number];
}) => {
await updateDocument({
updateDocument({
variables: {
input: {
id: document.id,
documentType: data.documentType,
},
},
onSuccess: () => {
onCompleted: () => {
setIsEditingType(false);
},
});
};
const handleUpdateClassification = async (data: {
const handleUpdateClassification = (data: {
classification: (typeof documentClassifications)[number];
}) => {
await updateDocument({
updateClassification({
variables: {
input: {
id: document.id,
documentVersionId: version.id,
classification: data.classification,
},
},
onSuccess: () => {
onCompleted: () => {
setIsEditingClassification(false);
},
});
@@ -176,16 +197,16 @@ export function DocumentLayoutDrawer(props: {
{isEditingClassification
? (
<EditablePropertyContent
onSave={() => void handleSubmit(handleUpdateClassification)()}
onSave={() => void handleClassificationSubmit(handleUpdateClassification)()}
onCancel={() => {
setIsEditingClassification(false);
reset();
resetClassification();
}}
disabled={isUpdatingDocument}
disabled={isUpdatingClassification}
>
<ControlledField
name="classification"
control={control}
control={classificationControl}
type="select"
>
<DocumentClassificationOptions />
@@ -195,13 +216,10 @@ export function DocumentLayoutDrawer(props: {
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingClassification(true)}
canEdit={canEdit}
canEdit={canEdit && isDraft}
>
<div className="text-sm text-txt-secondary">
{getDocumentClassificationLabel(
__,
version.classification,
)}
{getDocumentClassificationLabel(__, version.classification)}
</div>
</ReadOnlyPropertyContent>
)}

View File

@@ -15,12 +15,11 @@
import { formatDate, getDocumentClassificationLabel, getDocumentTypeLabel, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { ActionDropdown, Badge, Checkbox, DropdownItem, IconTrashCan, Td, Tr, useConfirm } from "@probo/ui";
import { useFragment } from "react-relay";
import { useFragment, useMutation } from "react-relay";
import { type DataID, graphql } from "relay-runtime";
import type { DocumentListItem_deleteMutation } from "#/__generated__/core/DocumentListItem_deleteMutation.graphql";
import type { DocumentListItemFragment$key } from "#/__generated__/core/DocumentListItemFragment.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
@@ -28,7 +27,6 @@ const fragment = graphql`
id
title
documentType
classification
updatedAt
canDelete: permission(action: "core:document:delete")
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
@@ -38,6 +36,7 @@ const fragment = graphql`
status
major
minor
classification
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
@@ -117,23 +116,21 @@ export function DocumentListItem(props: {
PUBLISHED: __("Published"),
} as const;
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
deleteDocumentMutation,
{
successMessage: __("Document deleted successfully."),
errorMessage: __("Failed to delete document"),
},
);
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
const confirm = useConfirm();
const handleDelete = () => {
confirm(
() =>
deleteDocument({
variables: {
connections: [connectionId],
input: { documentId: document.id },
},
new Promise<void>((resolve, reject) => {
deleteDocument({
variables: {
connections: [connectionId],
input: { documentId: document.id },
},
onCompleted: () => resolve(),
onError: err => reject(err),
});
}),
{
message: sprintf(
@@ -171,7 +168,7 @@ export function DocumentListItem(props: {
{getDocumentTypeLabel(__, document.documentType)}
</Td>
<Td className="w-32">
{getDocumentClassificationLabel(__, document.classification)}
{getDocumentClassificationLabel(__, lastVersion.classification)}
</Td>
<Td className="w-60">
{(() => {

View File

@@ -28,9 +28,15 @@ const fragment = graphql`
id
title
documentType
classification
approvalState
updatedAt
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
edges {
node {
classification
}
}
}
}
`;
@@ -42,6 +48,7 @@ export function ApprovableDocumentRow({
organizationId: string;
}) {
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
const lastVersion = document.lastVersion.edges[0].node;
const { __ } = useTranslate();
const stateVariant = document.approvalState === "APPROVED"
@@ -64,7 +71,7 @@ export function ApprovableDocumentRow({
</Td>
<Td className="w-36">
<Badge variant="neutral">
{getDocumentClassificationLabel(__, document.classification)}
{getDocumentClassificationLabel(__, lastVersion.classification)}
</Badge>
</Td>
<Td className="w-40">{formatDate(document.updatedAt)}</Td>

View File

@@ -28,9 +28,15 @@ const fragment = graphql`
id
title
documentType
classification
signed
updatedAt
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
edges {
node {
classification
}
}
}
}
`;
@@ -42,6 +48,7 @@ export function DocumentRow({
organizationId: string;
}) {
const document = useFragment<DocumentRowFragment$key>(fragment, fKey);
const lastVersion = document.lastVersion.edges[0].node;
const { __ } = useTranslate();
return (
@@ -52,7 +59,7 @@ export function DocumentRow({
</Td>
<Td className="w-36">
<Badge variant="neutral">
{getDocumentClassificationLabel(__, document.classification)}
{getDocumentClassificationLabel(__, lastVersion.classification)}
</Badge>
</Td>
<Td className="w-40">{formatDate(document.updatedAt)}</Td>