Move document title ownership from document to version

Title is now owned by document_versions, following the same pattern as
classification and document_type. The documents.title column is made
nullable with a TODO to drop it. Backend loads title from a
latest_versions CTE for ordering purposes only. The frontend resolves
title from the latest version, and DocumentTitleForm now operates on
DocumentVersion using UpdateDocumentVersion mutation.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-07 18:45:46 +02:00
parent ba386d188b
commit b63f043849
20 changed files with 329 additions and 333 deletions

View File

@@ -44,10 +44,10 @@ const documentAccessFragment = graphql`
status
document {
id
title
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
title
documentType
}
}
@@ -87,7 +87,7 @@ function toDocumentAccessInfo(
return {
persisted: node.id !== node.document.id,
variant: "info",
name: node.document.title,
name: node.document.versions?.edges[0]?.node.title ?? "",
type: "document",
typeLabel: __("Document"),
category: node.document.versions?.edges[0]?.node.documentType ?? "",

View File

@@ -35,7 +35,9 @@ export const documentLayoutQuery = graphql`
__typename
... on DocumentVersion {
id
title
status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment
signatures(first: 0 filter: { activeContract: true }) {
@@ -64,14 +66,12 @@ export const documentLayoutQuery = graphql`
__typename
... on Document {
id
title
status
canPublish: permission(action: "core:document-version:publish")
...PublishDialog_documentFragment
controlInfo: controls(first: 0) {
totalCount
}
...DocumentTitleFormFragment
...DocumentActionsDropdown_documentFragment
...DocumentLayoutDrawer_documentFragment
# We use this on /documents/:documentId
@@ -79,7 +79,9 @@ export const documentLayoutQuery = graphql`
edges {
node {
id
title
status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment
signatures(first: 0 filter: { activeContract: true }) {
@@ -158,7 +160,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
to: `/organizations/${organizationId}/documents`,
},
{
label: document.title,
label: currentVersion.title,
},
]}
/>
@@ -182,7 +184,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
</div>
<PageHeader
title={<DocumentTitleForm fKey={document} />}
title={<DocumentTitleForm fKey={currentVersion} />}
/>
<Tabs>

View File

@@ -34,7 +34,6 @@ import { CurrentUser } from "#/providers/CurrentUser";
const documentFragment = graphql`
fragment DocumentActionsDropdown_documentFragment on Document {
id
title
status
canUpdate: permission(action: "core:document:update")
canArchive: permission(action: "core:document:archive")
@@ -45,6 +44,7 @@ const documentFragment = graphql`
edges {
node {
id
title
status
}
}
@@ -120,6 +120,7 @@ const unarchiveDocumentMutation = graphql`
const versionFragment = graphql`
fragment DocumentActionsDropdown_versionFragment on DocumentVersion {
id
title
major
minor
status
@@ -226,7 +227,7 @@ export function DocumentActionsDropdown(props: {
{
message: sprintf(
__("This will archive the document \"%s\". It will no longer be editable."),
document.title,
lastVersion.title,
),
variant: "danger",
label: __("Archive"),
@@ -272,7 +273,7 @@ export function DocumentActionsDropdown(props: {
__(
"This will permanently delete the document \"%s\". This action cannot be undone.",
),
document.title,
lastVersion.title,
),
},
);
@@ -306,7 +307,7 @@ export function DocumentActionsDropdown(props: {
"This will permanently delete the draft version %s of \"%s\". This action cannot be undone.",
),
`${version.major}.${version.minor}`,
document.title,
lastVersion.title,
),
},
);
@@ -340,7 +341,7 @@ export function DocumentActionsDropdown(props: {
if (data.exportDocumentVersionPDF) {
const link = window.document.createElement("a");
link.href = data.exportDocumentVersionPDF.data;
link.download = `${document.title}-v${version.major}.${version.minor}.pdf`;
link.download = `${version.title}-v${version.major}.${version.minor}.pdf`;
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);

View File

@@ -25,7 +25,6 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
fragment DocumentListItemFragment on Document {
id
title
updatedAt
canDelete: permission(action: "core:document:delete")
defaultApprovers {
@@ -36,6 +35,7 @@ const fragment = graphql`
edges {
node {
id
title
status
major
minor
@@ -135,7 +135,7 @@ export function DocumentListItem(props: {
__(
"This will permanently delete the document \"%s\". This action cannot be undone.",
),
document.title,
lastVersion.title,
),
},
);
@@ -149,7 +149,7 @@ export function DocumentListItem(props: {
<Checkbox checked={checked} onChange={onCheck} />
</Td>
<Td className="min-w-0">
<div className="flex gap-4 items-center">{document.title}</div>
<div className="flex gap-4 items-center">{lastVersion.title}</div>
</Td>
<Td className="w-24">
<Badge variant={statusVariant[lastVersion.status]}>

View File

@@ -12,22 +12,22 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, IconCheckmark1, IconCrossLargeX, IconPencil, Input } from "@probo/ui";
import { Button, IconCheckmark1, IconCrossLargeX, IconPencil, Input, useToast } 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 { DocumentTitleFormFragment$key } from "#/__generated__/core/DocumentTitleFormFragment.graphql";
import type { DocumentTitleFormMutation } from "#/__generated__/core/DocumentTitleFormMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const updateDocumentTitleMutation = graphql`
mutation DocumentTitleFormMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
const updateDocumentVersionTitleMutation = graphql`
mutation DocumentTitleFormMutation($input: UpdateDocumentVersionInput!) {
updateDocumentVersion(input: $input) {
documentVersion {
...DocumentTitleFormFragment
}
}
@@ -35,10 +35,11 @@ const updateDocumentTitleMutation = graphql`
`;
const fragment = graphql`
fragment DocumentTitleFormFragment on Document {
fragment DocumentTitleFormFragment on DocumentVersion {
id
title
canUpdate: permission(action: "core:document:update")
status
canUpdate: permission(action: "core:document-version:update")
}
`;
@@ -50,38 +51,40 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
const { fKey } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const document = useFragment<DocumentTitleFormFragment$key>(fragment, fKey);
const [updateDocument, isUpdatingDocument]
= useMutationWithToasts<DocumentTitleFormMutation>(
updateDocumentTitleMutation,
{
successMessage: __("Document updated successfully."),
errorMessage: __("Failed to update document"),
},
);
const version = useFragment<DocumentTitleFormFragment$key>(fragment, fKey);
const [updateDocumentVersion, isUpdating]
= useMutation<DocumentTitleFormMutation>(updateDocumentVersionTitleMutation);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const { register, handleSubmit, reset } = useFormWithSchema(
schema,
{
defaultValues: {
title: document.title,
title: version.title,
},
},
);
const handleUpdateTitle = async (data: { title: string }) => {
await updateDocument({
const handleUpdateTitle = (data: { title: string }) => {
updateDocumentVersion({
variables: {
input: {
id: document.id,
documentVersionId: version.id,
title: data.title,
},
},
onSuccess: () => {
onCompleted(_, errors) {
if (errors?.length) {
toast({ title: __("Error"), description: formatError(__("Failed to update document"), errors), variant: "error" });
return;
}
setIsEditingTitle(false);
},
onError(error) {
toast({ title: __("Error"), description: error.message, variant: "error" });
},
});
};
@@ -107,7 +110,7 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
variant="quaternary"
icon={IconCheckmark1}
onClick={() => void handleSubmit(handleUpdateTitle)()}
disabled={isUpdatingDocument}
disabled={isUpdating}
/>
<Button
variant="quaternary"
@@ -121,8 +124,8 @@ export function DocumentTitleForm(props: { fKey: DocumentTitleFormFragment$key }
)
: (
<div className="flex items-center gap-2">
<span>{document.title}</span>
{document.canUpdate && (
<span>{version.title}</span>
{version.canUpdate && version.status === "DRAFT" && (
<Button
variant="quaternary"
icon={IconPencil}