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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
{(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user