Replace document properties drawer with inline details card

- Remove the right-side drawer and display document properties in a
  3-column Card below the page header
- Move status badge to the PageHeader (right-aligned, matching
  compliance page style)

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-14 16:50:35 +02:00
parent e3ab373a0c
commit 7a5d4c851a
10 changed files with 585 additions and 508 deletions

View File

@@ -13,17 +13,17 @@
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Breadcrumb, Button, IconUpload, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
import { Badge, Breadcrumb, Button, IconUpload, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
import { useCallback, useRef, useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Outlet, useParams } from "react-router";
import { Outlet, useLocation, useNavigate, useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { DocumentActionsDropdown } from "./_components/DocumentActionsDropdown";
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
import { DocumentDetailsCard } from "./_components/DocumentDetailsCard";
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
import { PublishDialog, type PublishDialogRef } from "./_components/PublishDialog";
@@ -39,7 +39,7 @@ export const documentLayoutQuery = graphql`
status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment
...DocumentDetailsCard_versionFragment
signatures(first: 0 filter: { activeContract: true }) {
totalCount
}
@@ -73,9 +73,9 @@ export const documentLayoutQuery = graphql`
totalCount
}
...DocumentActionsDropdown_documentFragment
...DocumentLayoutDrawer_documentFragment
# We use this on /documents/:documentId
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
...DocumentDetailsCard_documentFragment
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC })
@connection(key: "DocumentLayout_lastVersion") {
edges {
node {
id
@@ -83,7 +83,7 @@ export const documentLayoutQuery = graphql`
status
...DocumentTitleFormFragment
...DocumentActionsDropdown_versionFragment
...DocumentLayoutDrawer_versionFragment
...DocumentDetailsCard_versionFragment
signatures(first: 0 filter: { activeContract: true }) {
totalCount
}
@@ -117,6 +117,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
const organizationId = useOrganizationId();
const { versionId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { __ } = useTranslate();
@@ -129,28 +131,54 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
setApprovalRequestedAt(Date.now());
}, [onRefetch]);
const handleVersionChanged = useCallback(() => {
onRefetch();
setVersionChangedAt(Date.now());
}, [onRefetch]);
const { document, version } = usePreloadedQuery<DocumentLayoutQuery>(documentLayoutQuery, queryRef);
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
throw new Error("invalid node type");
}
const lastVersion = document.lastVersion?.edges[0].node;
const lastVersion = document.lastVersion?.edges[0]?.node;
if (!version && !lastVersion) {
throw new Error("current version not specified");
}
// It is ok to cas as NonNullable here since we know we have either version or lastVersion
const currentVersion = version ?? lastVersion as NonNullable<typeof version | typeof lastVersion>;
const currentVersion = version ?? lastVersion;
const isLatestVersion = currentVersion.id === lastVersion?.id;
const isPendingApproval = currentVersion.status === "PENDING_APPROVAL";
const isDraft = currentVersion.status === "DRAFT";
const isPublished = currentVersion.status === "PUBLISHED";
const isEditable = isLatestVersion && !isPendingApproval;
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
const hasApprovals = lastQuorum != null;
const currentTab = location.pathname.split("/").at(-1);
// For changes on the current version (type, classification, title, content).
// Refreshes layout data but does NOT remount the editor.
const handleDocumentUpdated = useCallback(() => {
if (versionId) {
void navigate(
`/organizations/${organizationId}/documents/${document.id}/${currentTab}`,
{ replace: true },
);
} else {
onRefetch();
}
}, [versionId, currentTab, navigate, organizationId, document.id, onRefetch]);
// For structural version changes (delete draft, revert).
// Refreshes layout data AND remounts the editor via versionChangedAt.
const handleVersionChanged = useCallback(() => {
if (versionId) {
void navigate(
`/organizations/${organizationId}/documents/${document.id}/${currentTab}`,
{ replace: true },
);
} else {
onRefetch();
setVersionChangedAt(Date.now());
}
}, [versionId, currentTab, navigate, organizationId, document.id, onRefetch]);
const urlPrefix = versionId
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
: `/organizations/${organizationId}/documents/${document.id}`;
@@ -180,7 +208,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
{__("Publish")}
</Button>
)}
<DocumentVersionsDropdown />
<DocumentVersionsDropdown currentTab={currentTab} />
<DocumentActionsDropdown
documentFragmentRef={document}
versionFragmentRef={currentVersion}
@@ -195,9 +223,23 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
fKey={currentVersion}
documentId={document.id}
documentStatus={document.status}
onVersionChanged={handleVersionChanged}
isEditable={isEditable}
onDocumentUpdated={handleDocumentUpdated}
/>
)}
>
<Badge
variant={currentVersion.status === "PUBLISHED" ? "success" : currentVersion.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
>
{currentVersion.status === "PUBLISHED" ? __("Published") : currentVersion.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")}
</Badge>
</PageHeader>
<DocumentDetailsCard
documentFragmentRef={document}
versionFragmentRef={currentVersion}
isEditable={isEditable}
onDocumentUpdated={handleDocumentUpdated}
/>
<Tabs>
@@ -228,15 +270,17 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
)}
</Tabs>
<Outlet context={{ onRefetch, approvalRequestedAt, versionChangedAt }} />
<Outlet
context={{
onRefetch,
onDocumentUpdated: handleDocumentUpdated,
approvalRequestedAt,
versionChangedAt,
isEditable,
}}
/>
</div>
<DocumentLayoutDrawer
documentFragmentRef={document}
versionFragmentRef={currentVersion}
onVersionChanged={handleVersionChanged}
/>
<PublishDialog
ref={publishDialogRef}
documentId={document.id}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Suspense, useCallback, useEffect } from "react";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
@@ -29,13 +29,28 @@ function DocumentLayoutQueryLoader() {
}
const [queryRef, loadQuery] = useQueryLoader<DocumentLayoutQuery>(documentLayoutQuery);
// Detect param changes (e.g. navigating from versioned to versionless URL)
// and refetch without remounting the component tree.
const paramsKey = `${documentId}-${versionId}`;
const [prevParamsKey, setPrevParamsKey] = useState(paramsKey);
if (queryRef && paramsKey !== prevParamsKey) {
setPrevParamsKey(paramsKey);
loadQuery(
{ documentId, versionId: versionId ?? "", versionSpecified: !!versionId },
{ fetchPolicy: "store-and-network" },
);
}
useEffect(() => {
if (!queryRef) {
loadQuery({
documentId,
versionId: versionId ?? "",
versionSpecified: !!versionId,
});
loadQuery(
{
documentId,
versionId: versionId ?? "",
versionSpecified: !!versionId,
},
{ fetchPolicy: "store-and-network" },
);
}
});
@@ -52,11 +67,11 @@ function DocumentLayoutQueryLoader() {
}
export default function DocumentLayoutLoader() {
const { documentId, versionId } = useParams();
const { documentId } = useParams();
return (
<CoreRelayProvider>
<Suspense key={`${documentId}-${versionId}`} fallback={<PageSkeleton />}>
<Suspense key={documentId} fallback={<PageSkeleton />}>
<DocumentLayoutQueryLoader />
</Suspense>
</CoreRelayProvider>

View File

@@ -0,0 +1,467 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Button, Card, IconCheckmark1, IconCrossLargeX, IconPencil, useToast } from "@probo/ui";
import { useState } from "react";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { DocumentDetailsCard_documentFragment$key } from "#/__generated__/core/DocumentDetailsCard_documentFragment.graphql";
import type { DocumentDetailsCard_updateApproversMutation } from "#/__generated__/core/DocumentDetailsCard_updateApproversMutation.graphql";
import type { DocumentDetailsCard_updateClassificationMutation } from "#/__generated__/core/DocumentDetailsCard_updateClassificationMutation.graphql";
import type { DocumentDetailsCard_versionFragment$key } from "#/__generated__/core/DocumentDetailsCard_versionFragment.graphql";
import type { DocumentDetailsCardMutation } from "#/__generated__/core/DocumentDetailsCardMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const documentFragment = graphql`
fragment DocumentDetailsCard_documentFragment on Document {
id
archivedAt
canUpdate: permission(action: "core:document:update")
defaultApprovers {
id
fullName
emailAddress
}
}
`;
const versionFragment = graphql`
fragment DocumentDetailsCard_versionFragment on DocumentVersion {
id
documentType
classification
major
minor
updatedAt
publishedAt
}
`;
const updateDocumentTypeMutation = graphql`
mutation DocumentDetailsCardMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
documentType
}
}
}
}
}
}
`;
const updateClassificationMutation = graphql`
mutation DocumentDetailsCard_updateClassificationMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
edges {
node {
id
classification
}
}
}
}
}
}
`;
const updateApproversMutation = graphql`
mutation DocumentDetailsCard_updateApproversMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
defaultApprovers {
id
fullName
emailAddress
}
}
}
}
`;
const schema = z.object({
documentType: z.enum(documentTypes),
});
const classificationSchema = z.object({
classification: z.enum(documentClassifications),
});
const approversSchema = z.object({
approverIds: z.array(z.string()),
});
export function DocumentDetailsCard(props: {
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
isEditable: boolean;
onDocumentUpdated: () => void;
}) {
const { documentFragmentRef, versionFragmentRef, isEditable, onDocumentUpdated } = props;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [isEditingType, setIsEditingType] = useState(false);
const [isEditingClassification, setIsEditingClassification] = useState(false);
const [isEditingApprovers, setIsEditingApprovers] = useState(false);
const { toast } = useToast();
const document = useFragment<DocumentDetailsCard_documentFragment$key>(documentFragment, documentFragmentRef);
const version = useFragment<DocumentDetailsCard_versionFragment$key>(versionFragment, versionFragmentRef);
const canEdit = document.canUpdate && isEditable;
const { control, handleSubmit, reset } = useFormWithSchema(
schema,
{
values: {
documentType: version.documentType,
},
},
);
const {
control: classificationControl,
handleSubmit: handleClassificationSubmit,
reset: resetClassification,
} = useFormWithSchema(
classificationSchema,
{
values: {
classification: version.classification,
},
},
);
const {
control: approversControl,
handleSubmit: handleApproversSubmit,
reset: resetApprovers,
} = useFormWithSchema(
approversSchema,
{
values: {
approverIds: document.defaultApprovers.map(a => a.id),
},
},
);
const [updateDocumentType, isUpdatingDocumentType]
= useMutation<DocumentDetailsCardMutation>(updateDocumentTypeMutation);
const [updateClassification, isUpdatingClassification]
= useMutation<DocumentDetailsCard_updateClassificationMutation>(updateClassificationMutation);
const [updateApprovers, isUpdatingApprovers]
= useMutation<DocumentDetailsCard_updateApproversMutation>(updateApproversMutation);
const handleUpdateDocumentType = (data: {
documentType: (typeof documentTypes)[number];
}) => {
updateDocumentType({
variables: {
input: {
id: document.id,
documentType: data.documentType,
},
},
onCompleted: () => {
setIsEditingType(false);
onDocumentUpdated();
toast({
title: __("Success"),
description: __("Document type updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update document type"),
variant: "error",
});
},
});
};
const handleUpdateClassification = (data: {
classification: (typeof documentClassifications)[number];
}) => {
updateClassification({
variables: {
input: {
id: document.id,
classification: data.classification,
},
},
onCompleted: () => {
setIsEditingClassification(false);
onDocumentUpdated();
toast({
title: __("Success"),
description: __("Document classification updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update document classification"),
variant: "error",
});
},
});
};
const handleUpdateApprovers = (data: { approverIds: string[] }) => {
updateApprovers({
variables: {
input: {
id: document.id,
defaultApproverIds: data.approverIds,
},
},
onCompleted: () => {
setIsEditingApprovers(false);
toast({
title: __("Success"),
description: __("Approvers updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update approvers"),
variant: "error",
});
},
});
};
return (
<Card className="space-y-4" padded>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Approvers")}
</div>
{isEditingApprovers
? (
<div className="flex items-center gap-2">
<div className="flex-1">
<PeopleMultiSelectField
name="approverIds"
control={approversControl}
organizationId={organizationId}
selectedPeople={document.defaultApprovers.map(a => ({
id: a.id,
fullName: a.fullName,
emailAddress: a.emailAddress,
}))}
placeholder={__("Add approvers...")}
/>
</div>
<Button
variant="quaternary"
icon={IconCheckmark1}
onClick={() => void handleApproversSubmit(handleUpdateApprovers)()}
disabled={isUpdatingApprovers}
/>
<Button
variant="quaternary"
icon={IconCrossLargeX}
onClick={() => {
setIsEditingApprovers(false);
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
}}
/>
</div>
)
: (
<div className="flex items-center gap-2">
<div className="text-sm text-txt-primary">
{document.defaultApprovers.length > 0
? document.defaultApprovers.map(a => a.fullName).join(", ")
: __("None")}
</div>
{canEdit && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditingApprovers(true)}
/>
)}
</div>
)}
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Type")}
</div>
{isEditingType
? (
<div className="flex items-center gap-2">
<div className="flex-1">
<ControlledField
name="documentType"
control={control}
type="select"
>
<DocumentTypeOptions />
</ControlledField>
</div>
<Button
variant="quaternary"
icon={IconCheckmark1}
onClick={() => void handleSubmit(handleUpdateDocumentType)()}
disabled={isUpdatingDocumentType}
/>
<Button
variant="quaternary"
icon={IconCrossLargeX}
onClick={() => {
setIsEditingType(false);
reset();
}}
/>
</div>
)
: (
<div className="flex items-center gap-2">
<div className="text-sm text-txt-primary">
{getDocumentTypeLabel(__, version.documentType)}
</div>
{canEdit && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditingType(true)}
/>
)}
</div>
)}
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Classification")}
</div>
{isEditingClassification
? (
<div className="flex items-center gap-2">
<div className="flex-1">
<ControlledField
name="classification"
control={classificationControl}
type="select"
>
<DocumentClassificationOptions />
</ControlledField>
</div>
<Button
variant="quaternary"
icon={IconCheckmark1}
onClick={() => void handleClassificationSubmit(handleUpdateClassification)()}
disabled={isUpdatingClassification}
/>
<Button
variant="quaternary"
icon={IconCrossLargeX}
onClick={() => {
setIsEditingClassification(false);
resetClassification();
}}
/>
</div>
)
: (
<div className="flex items-center gap-2">
<div className="text-sm text-txt-primary">
{getDocumentClassificationLabel(__, version.classification)}
</div>
{canEdit && (
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditingClassification(true)}
/>
)}
</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Version")}
</div>
<div className="text-sm text-txt-primary">
{version.major}
.
{version.minor}
</div>
</div>
<div>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Last modified")}
</div>
<div className="text-sm text-txt-primary">
{formatDate(version.updatedAt)}
</div>
</div>
<div>
{version.publishedAt && (
<>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Published Date")}
</div>
<div className="text-sm text-txt-primary">
{formatDate(version.publishedAt)}
</div>
</>
)}
{document.archivedAt && (
<>
<div className="text-xs text-txt-tertiary font-semibold mb-1">
{__("Archived on")}
</div>
<Badge variant="danger" size="md" className="gap-2">
{formatDate(document.archivedAt)}
</Badge>
</>
)}
</div>
</div>
</Card>
);
}

View File

@@ -1,445 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow, useToast } from "@probo/ui";
import { useState } from "react";
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_updateApproversMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateApproversMutation.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 { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const documentFragment = graphql`
fragment DocumentLayoutDrawer_documentFragment on Document {
id
status
archivedAt
canUpdate: permission(action: "core:document:update")
defaultApprovers {
id
fullName
emailAddress
}
}
`;
const versionFragment = graphql`
fragment DocumentLayoutDrawer_versionFragment on DocumentVersion {
id
documentType
classification
major
minor
status
updatedAt
publishedAt
}
`;
const updateDocumentMutation = graphql`
mutation DocumentLayoutDrawerMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
}
documentVersion {
id
documentType
classification
major
minor
status
updatedAt
publishedAt
}
}
}
`;
const updateApproversMutation = graphql`
mutation DocumentLayoutDrawer_updateApproversMutation($input: UpdateDocumentInput!) {
updateDocument(input: $input) {
document {
id
defaultApprovers {
id
fullName
emailAddress
}
}
}
}
`;
const schema = z.object({
documentType: z.enum(documentTypes),
});
const classificationSchema = z.object({
classification: z.enum(documentClassifications),
});
const approversSchema = z.object({
approverIds: z.array(z.string()),
});
export function DocumentLayoutDrawer(props: {
documentFragmentRef: DocumentLayoutDrawer_documentFragment$key;
versionFragmentRef: DocumentLayoutDrawer_versionFragment$key;
onVersionChanged: () => void;
}) {
const { documentFragmentRef, versionFragmentRef, onVersionChanged } = props;
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [isEditingType, setIsEditingType] = useState(false);
const [isEditingClassification, setIsEditingClassification] = useState(false);
const [isEditingApprovers, setIsEditingApprovers] = useState(false);
const { toast } = useToast();
const document = useFragment<DocumentLayoutDrawer_documentFragment$key>(documentFragment, documentFragmentRef);
const version = useFragment<DocumentLayoutDrawer_versionFragment$key>(versionFragment, versionFragmentRef);
const isDraft = version.status === "DRAFT";
const canEdit = document.canUpdate && document.status !== "ARCHIVED";
const { control, handleSubmit, reset } = useFormWithSchema(
schema,
{
values: {
documentType: version.documentType,
},
},
);
const {
control: classificationControl,
handleSubmit: handleClassificationSubmit,
reset: resetClassification,
} = useFormWithSchema(
classificationSchema,
{
values: {
classification: version.classification,
},
},
);
const {
control: approversControl,
handleSubmit: handleApproversSubmit,
reset: resetApprovers,
} = useFormWithSchema(
approversSchema,
{
values: {
approverIds: document.defaultApprovers.map(a => a.id),
},
},
);
const [updateDocument, isUpdatingDocument]
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentMutation);
const [updateApprovers, isUpdatingApprovers]
= useMutation<DocumentLayoutDrawer_updateApproversMutation>(updateApproversMutation);
const handleUpdateDocumentType = (data: {
documentType: (typeof documentTypes)[number];
}) => {
updateDocument({
variables: {
input: {
id: document.id,
documentType: data.documentType,
},
},
onCompleted: (data) => {
setIsEditingType(false);
const draftReturned = !!data.updateDocument.documentVersion;
if (isDraft !== draftReturned) {
onVersionChanged();
}
toast({
title: __("Success"),
description: __("Document type updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update document type"),
variant: "error",
});
},
});
};
const handleUpdateClassification = (data: {
classification: (typeof documentClassifications)[number];
}) => {
updateDocument({
variables: {
input: {
id: document.id,
classification: data.classification,
},
},
onCompleted: (data) => {
setIsEditingClassification(false);
const draftReturned = !!data.updateDocument.documentVersion;
if (isDraft !== draftReturned) {
onVersionChanged();
}
toast({
title: __("Success"),
description: __("Document classification updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update document classification"),
variant: "error",
});
},
});
};
const handleUpdateApprovers = (data: { approverIds: string[] }) => {
updateApprovers({
variables: {
input: {
id: document.id,
defaultApproverIds: data.approverIds,
},
},
onCompleted: () => {
setIsEditingApprovers(false);
toast({
title: __("Success"),
description: __("Approvers updated successfully"),
variant: "success",
});
},
onError: () => {
toast({
title: __("Error"),
description: __("Failed to update approvers"),
variant: "error",
});
},
});
};
return (
<Drawer>
<div className="text-base text-txt-primary font-medium mb-4">
{__("Properties")}
</div>
<PropertyRow label={__("Approvers")}>
{isEditingApprovers
? (
<EditablePropertyContent
onSave={() => void handleApproversSubmit(handleUpdateApprovers)()}
onCancel={() => {
setIsEditingApprovers(false);
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
}}
disabled={isUpdatingApprovers}
>
<PeopleMultiSelectField
name="approverIds"
control={approversControl}
organizationId={organizationId}
selectedPeople={document.defaultApprovers.map(a => ({
id: a.id,
fullName: a.fullName,
emailAddress: a.emailAddress,
}))}
placeholder={__("Add approvers...")}
/>
</EditablePropertyContent>
)
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingApprovers(true)}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{document.defaultApprovers.length > 0
? document.defaultApprovers.map(a => a.fullName).join(", ")
: __("None")}
</div>
</ReadOnlyPropertyContent>
)}
</PropertyRow>
<PropertyRow label={__("Type")}>
{isEditingType
? (
<EditablePropertyContent
onSave={() => void handleSubmit(handleUpdateDocumentType)()}
onCancel={() => {
setIsEditingType(false);
reset({ documentType: version.documentType });
}}
disabled={isUpdatingDocument}
>
<ControlledField
name="documentType"
control={control}
type="select"
>
<DocumentTypeOptions />
</ControlledField>
</EditablePropertyContent>
)
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingType(true)}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{getDocumentTypeLabel(__, version.documentType)}
</div>
</ReadOnlyPropertyContent>
)}
</PropertyRow>
<PropertyRow label={__("Classification")}>
{isEditingClassification
? (
<EditablePropertyContent
onSave={() => void handleClassificationSubmit(handleUpdateClassification)()}
onCancel={() => {
setIsEditingClassification(false);
resetClassification({ classification: version.classification });
}}
disabled={isUpdatingDocument}
>
<ControlledField
name="classification"
control={classificationControl}
type="select"
>
<DocumentClassificationOptions />
</ControlledField>
</EditablePropertyContent>
)
: (
<ReadOnlyPropertyContent
onEdit={() => setIsEditingClassification(true)}
canEdit={canEdit}
>
<div className="text-sm text-txt-secondary">
{getDocumentClassificationLabel(__, version.classification)}
</div>
</ReadOnlyPropertyContent>
)}
</PropertyRow>
<PropertyRow label={__("Status")}>
<Badge
variant={version.status === "PUBLISHED" ? "success" : version.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
size="md"
className="gap-2"
>
{version.status === "PUBLISHED" ? __("Published") : version.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")}
</Badge>
</PropertyRow>
<PropertyRow label={__("Version")}>
<div className="text-sm text-txt-secondary">
{version.major}
.
{version.minor}
</div>
</PropertyRow>
<PropertyRow label={__("Last modified")}>
<div className="text-sm text-txt-secondary">
{formatDate(version.updatedAt)}
</div>
</PropertyRow>
{version.publishedAt && (
<PropertyRow label={__("Published Date")}>
<div className="text-sm text-txt-secondary">
{formatDate(version.publishedAt)}
</div>
</PropertyRow>
)}
{document.archivedAt && (
<PropertyRow label={__("Archived on")}>
<Badge variant="danger" size="md" className="gap-2">
{formatDate(document.archivedAt)}
</Badge>
</PropertyRow>
)}
</Drawer>
);
}
function EditablePropertyContent({
children,
onSave,
onCancel,
disabled,
}: {
children: React.ReactNode;
onSave: () => void;
onCancel: () => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center gap-2">
<div className="flex-1">{children}</div>
<Button
variant="quaternary"
icon={IconCheckmark1}
onClick={onSave}
disabled={disabled}
/>
<Button variant="quaternary" icon={IconCrossLargeX} onClick={onCancel} />
</div>
);
}
function ReadOnlyPropertyContent({
children,
onEdit,
canEdit = true,
}: {
children: React.ReactNode;
onEdit: () => void;
canEdit?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-3">
{children}
{canEdit && (
<Button variant="quaternary" icon={IconPencil} onClick={onEdit} />
)}
</div>
);
}

View File

@@ -50,9 +50,10 @@ export function DocumentTitleForm(props: {
fKey: DocumentTitleFormFragment$key;
documentId: string;
documentStatus: string;
onVersionChanged: () => void;
isEditable: boolean;
onDocumentUpdated: () => void;
}) {
const { fKey, documentId, documentStatus, onVersionChanged } = props;
const { fKey, documentId, documentStatus, isEditable, onDocumentUpdated } = props;
const { __ } = useTranslate();
const { toast } = useToast();
@@ -72,7 +73,7 @@ export function DocumentTitleForm(props: {
);
const isDraft = version.status === "DRAFT";
const canEdit = version.canUpdate && documentStatus !== "ARCHIVED";
const canEdit = version.canUpdate && isEditable && documentStatus !== "ARCHIVED";
const handleUpdateTitle = (data: { title: string }) => {
updateDocument({
@@ -90,7 +91,7 @@ export function DocumentTitleForm(props: {
setIsEditingTitle(false);
const draftReturned = !!data.updateDocument.documentVersion;
if (isDraft !== draftReturned) {
onVersionChanged();
onDocumentUpdated();
}
},
onError(error) {

View File

@@ -22,7 +22,8 @@ import type { DocumentVersionsDropdownMenuQuery } from "#/__generated__/core/Doc
import { DocumentVersionsDropdownMenu, documentVersionsDropdownMenuQuery } from "./DocumentVersionsDropdownMenu";
export function DocumentVersionsDropdown() {
export function DocumentVersionsDropdown(props: { currentTab: string | undefined }) {
const { currentTab } = props;
const { documentId, versionId } = useParams();
if (!documentId) {
throw new Error(":documentId missing in route params");
@@ -33,7 +34,7 @@ export function DocumentVersionsDropdown() {
return (
<Dropdown
onOpenChange={open => open && !queryRef && loadQuery({ documentId, versionId: versionId ?? "", versionSpecified: !!versionId })}
onOpenChange={open => open && loadQuery({ documentId, versionId: versionId ?? "", versionSpecified: !!versionId }, { fetchPolicy: "network-only" })}
toggle={(
<Button icon={IconClock} variant="secondary">
{__("Version history")}
@@ -44,7 +45,7 @@ export function DocumentVersionsDropdown() {
<Suspense>
{queryRef
&& (
<DocumentVersionsDropdownMenu queryRef={queryRef} />
<DocumentVersionsDropdownMenu queryRef={queryRef} currentTab={currentTab} />
)}
</Suspense>
</Dropdown>

View File

@@ -16,7 +16,7 @@ import { useTranslate } from "@probo/i18n";
import { Badge, DropdownItem } from "@probo/ui";
import { clsx } from "clsx";
import { useFragment } from "react-relay";
import { Link, useLocation, useParams } from "react-router";
import { Link, useParams } from "react-router";
import { graphql } from "relay-runtime";
import type {
@@ -38,8 +38,9 @@ const fragment = graphql`
export function DocumentVersionsDropdownItem(props: {
fragmentRef: DocumentVersionsDropdownItemFragment$key;
active?: boolean;
currentTab: string | undefined;
}) {
const { fragmentRef, active } = props;
const { fragmentRef, active, currentTab } = props;
const { dateTimeFormat, __ } = useTranslate();
const organizationId = useOrganizationId();
@@ -50,12 +51,10 @@ export function DocumentVersionsDropdownItem(props: {
const version = useFragment<DocumentVersionsDropdownItemFragment$key>(fragment, fragmentRef);
const suffix = useLocation().pathname.split("/").at(-1);
return (
<DropdownItem asChild>
<Link
to={`/organizations/${organizationId}/documents/${documentId}/versions/${version.id}/${suffix}`}
to={`/organizations/${organizationId}/documents/${documentId}/versions/${version.id}/${currentTab}`}
className="flex items-center gap-2 py-2 px-[10px] w-full hover:bg-tertiary-hover cursor-pointer rounded"
>
<div className="flex gap-3 w-full overflow-hidden">

View File

@@ -56,8 +56,9 @@ export const documentVersionsDropdownMenuQuery = graphql`
export function DocumentVersionsDropdownMenu(props: {
queryRef: PreloadedQuery<DocumentVersionsDropdownMenuQuery>;
currentTab: string | undefined;
}) {
const { queryRef } = props;
const { queryRef, currentTab } = props;
const { document, version } = usePreloadedQuery<DocumentVersionsDropdownMenuQuery>(
documentVersionsDropdownMenuQuery,
@@ -77,6 +78,7 @@ export function DocumentVersionsDropdownMenu(props: {
key={version.id}
fragmentRef={version}
active={version.id === currentVersion.id}
currentTab={currentTab}
/>
))}
</>

View File

@@ -17,7 +17,7 @@ import { useTranslate } from "@probo/i18n";
import { RichEditor, useToast } from "@probo/ui";
import { useCallback, useState } from "react";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { useOutletContext, useParams } from "react-router";
import { useOutletContext } from "react-router";
import { graphql } from "relay-runtime";
import { useDebounceCallback } from "usehooks-ts";
@@ -81,8 +81,10 @@ export function DocumentDescriptionPage(props: {
const { __ } = useTranslate();
const { toast } = useToast();
const { versionId } = useParams();
const { onRefetch } = useOutletContext<{ onRefetch: () => void }>();
const { onDocumentUpdated, isEditable } = useOutletContext<{
onDocumentUpdated: () => void;
isEditable: boolean;
}>();
const { document, version } = usePreloadedQuery<DocumentDescriptionPageQuery>(
documentDescriptionPageQuery,
@@ -119,13 +121,9 @@ export function DocumentDescriptionPage(props: {
return;
}
// Refetch the layout when draft status changes (draft created
// or auto-deleted) so the drawer and header reflect the current
// version. This does NOT remount the editor because the editor
// key is based on versionChangedAt (explicit actions only).
const draftReturned = !!data.updateDocument.documentVersion;
if (wasDraft !== draftReturned) {
onRefetch();
onDocumentUpdated();
}
toast({
@@ -142,16 +140,11 @@ export function DocumentDescriptionPage(props: {
});
},
});
}, [documentId, wasDraft, updateContent, toast, __, onRefetch]),
}, [documentId, wasDraft, updateContent, toast, __, onDocumentUpdated]),
autoSaveIntervalMs,
);
// When viewing a specific historical version, the editor is read-only.
// When viewing the latest version, editing is allowed if the user has
// update permission and the document is not archived — the backend
// will auto-create a draft if needed.
const isViewingSpecificVersion = !!version;
const canEdit = !isViewingSpecificVersion
const canEdit = isEditable
&& document.canUpdate
&& document.status !== "ARCHIVED";
@@ -187,7 +180,7 @@ export function DocumentDescriptionPage(props: {
// Otherwise auto-save changed the version — don't bump generation.
}
const editorKey = `${versionId ?? "latest"}-${dataGeneration}`;
const editorKey = `${version?.id ?? document.id}-${dataGeneration}`;
return (
<RichEditor

View File

@@ -34,7 +34,7 @@ function DocumentDescriptionPageQueryLoader() {
useEffect(() => {
loadQuery(
{ documentId, versionId: versionId ?? "", versionSpecified: !!versionId },
{ fetchPolicy: versionChangedAt > 0 ? "network-only" : "store-or-network" },
{ fetchPolicy: "network-only" },
);
}, [documentId, versionId, versionChangedAt, loadQuery]);