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