SOA as document: replace export with publish workflow
Statements of Applicability are no longer exported as one-off PDFs. Instead, each SOA owns a persistent document that accumulates versions over time, following the same publish/approve lifecycle as authored documents. Publishing without approvers publishes immediately; publishing with approvers creates a draft pending approval via the existing quorum system. SOAs can also store default approvers that are pre-populated in the publish dialog. The SOA is removed from the snapshot system — applicability statements are now queried directly (snapshot_id IS NULL) rather than through snapshot copies. A standalone migration script (cmd/migrate-soa-snapshots-to-documents) converts existing SOA snapshots into documents with proper ProseMirror content, preserving version history and approval decisions. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -67,6 +67,7 @@ export const documentLayoutQuery = graphql`
|
||||
... on Document {
|
||||
id
|
||||
status
|
||||
writeMode
|
||||
canPublish: permission(action: "core:document-version:publish")
|
||||
...PublishDialog_documentFragment
|
||||
controlInfo: controls(first: 0) {
|
||||
@@ -146,6 +147,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
const isPendingApproval = currentVersion.status === "PENDING_APPROVAL";
|
||||
const isDraft = currentVersion.status === "DRAFT";
|
||||
const isPublished = currentVersion.status === "PUBLISHED";
|
||||
const isGenerated = document.writeMode === "GENERATED";
|
||||
const isEditable = isLatestVersion && !isPendingApproval;
|
||||
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const hasApprovals = lastQuorum != null;
|
||||
@@ -223,11 +225,12 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
fKey={currentVersion}
|
||||
documentId={document.id}
|
||||
documentStatus={document.status}
|
||||
isEditable={isEditable}
|
||||
isEditable={isEditable && !isGenerated}
|
||||
onDocumentUpdated={handleDocumentUpdated}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{isGenerated && <Badge variant="neutral">{__("Generated")}</Badge>}
|
||||
<Badge
|
||||
variant={currentVersion.status === "PUBLISHED" ? "success" : currentVersion.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
|
||||
>
|
||||
@@ -239,6 +242,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
documentFragmentRef={document}
|
||||
versionFragmentRef={currentVersion}
|
||||
isEditable={isEditable}
|
||||
isGenerated={isGenerated}
|
||||
isLatestVersion={isLatestVersion}
|
||||
onDocumentUpdated={handleDocumentUpdated}
|
||||
/>
|
||||
|
||||
|
||||
@@ -124,9 +124,18 @@ export function DocumentDetailsCard(props: {
|
||||
documentFragmentRef: DocumentDetailsCard_documentFragment$key;
|
||||
versionFragmentRef: DocumentDetailsCard_versionFragment$key;
|
||||
isEditable: boolean;
|
||||
isGenerated?: boolean;
|
||||
isLatestVersion?: boolean;
|
||||
onDocumentUpdated: () => void;
|
||||
}) {
|
||||
const { documentFragmentRef, versionFragmentRef, isEditable, onDocumentUpdated } = props;
|
||||
const {
|
||||
documentFragmentRef,
|
||||
versionFragmentRef,
|
||||
isEditable,
|
||||
isGenerated = false,
|
||||
isLatestVersion = true,
|
||||
onDocumentUpdated,
|
||||
} = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
@@ -140,6 +149,8 @@ export function DocumentDetailsCard(props: {
|
||||
const version = useFragment<DocumentDetailsCard_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
|
||||
const canEdit = document.canUpdate && isEditable;
|
||||
const canEditVersionFields = canEdit && !isGenerated;
|
||||
const canEditApprovers = document.canUpdate && isLatestVersion;
|
||||
|
||||
const { control, handleSubmit, reset } = useFormWithSchema(
|
||||
schema,
|
||||
@@ -272,59 +283,6 @@ export function DocumentDetailsCard(props: {
|
||||
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")}
|
||||
@@ -362,7 +320,7 @@ export function DocumentDetailsCard(props: {
|
||||
<div className="text-sm text-txt-primary">
|
||||
{getDocumentTypeLabel(__, version.documentType)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
{canEditVersionFields && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
@@ -409,7 +367,7 @@ export function DocumentDetailsCard(props: {
|
||||
<div className="text-sm text-txt-primary">
|
||||
{getDocumentClassificationLabel(__, version.classification)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
{canEditVersionFields && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
@@ -419,6 +377,61 @@ export function DocumentDetailsCard(props: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLatestVersion && (
|
||||
<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>
|
||||
{canEditApprovers && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() => setIsEditingApprovers(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { documentClassifications, documentTypes, getDocumentClassificationLabel, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||
import { documentClassifications, documentTypes, documentWriteModes, getDocumentClassificationLabel, getDocumentTypeLabel, getDocumentWriteModeLabel, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
@@ -23,7 +23,7 @@ import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql";
|
||||
import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql";
|
||||
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
|
||||
import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql";
|
||||
import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType, DocumentWriteMode } from "#/__generated__/core/DocumentsListQuery.graphql";
|
||||
import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog";
|
||||
import { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
|
||||
@@ -50,6 +50,7 @@ const fragment = graphql`
|
||||
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
|
||||
documentTypes: { type: "[DocumentType!]", defaultValue: null }
|
||||
classifications: { type: "[DocumentClassification!]", defaultValue: null }
|
||||
writeModes: { type: "[DocumentWriteMode!]", defaultValue: null }
|
||||
) {
|
||||
documents(
|
||||
first: $first
|
||||
@@ -57,7 +58,7 @@ const fragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { status: $status documentTypes: $documentTypes classifications: $classifications }
|
||||
filter: { status: $status documentTypes: $documentTypes classifications: $classifications writeModes: $writeModes }
|
||||
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
|
||||
__id
|
||||
edges {
|
||||
@@ -130,6 +131,7 @@ export function DocumentList(props: {
|
||||
|
||||
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
|
||||
const [classificationFilter, setClassificationFilter] = useState<DocumentClassification | null>(null);
|
||||
const [writeModeFilter, setWriteModeFilter] = useState<DocumentWriteMode | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const refetch = pagination.refetch;
|
||||
@@ -140,11 +142,12 @@ export function DocumentList(props: {
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
classifications: classificationFilter ? [classificationFilter] : null,
|
||||
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||
},
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
});
|
||||
}, [tab, refetch, documentTypeFilter, classificationFilter]);
|
||||
}, [tab, refetch, documentTypeFilter, classificationFilter, writeModeFilter]);
|
||||
|
||||
const documents = pagination.data.documents.edges.map(({ node }) => node);
|
||||
const connectionId = pagination.data.documents.__id;
|
||||
@@ -194,6 +197,7 @@ export function DocumentList(props: {
|
||||
status: [tab],
|
||||
documentTypes: newType ? [newType] : null,
|
||||
classifications: classificationFilter ? [classificationFilter] : null,
|
||||
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -214,6 +218,28 @@ export function DocumentList(props: {
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
classifications: newClassification ? [newClassification] : null,
|
||||
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleWriteModeFilterChange = (value: string) => {
|
||||
const newWriteMode = value === "ALL" ? null : (value as DocumentWriteMode);
|
||||
clear();
|
||||
setWriteModeFilter(newWriteMode);
|
||||
onConnectionIdChange(
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
"DocumentsListQuery_documents",
|
||||
{
|
||||
orderBy: { direction: "ASC", field: "TITLE" },
|
||||
filter: {
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
classifications: classificationFilter ? [classificationFilter] : null,
|
||||
writeModes: newWriteMode ? [newWriteMode] : null,
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -297,6 +323,7 @@ export function DocumentList(props: {
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
classifications: classificationFilter ? [classificationFilter] : null,
|
||||
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -309,12 +336,24 @@ export function DocumentList(props: {
|
||||
status: [tab],
|
||||
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
|
||||
classifications: classificationFilter ? [classificationFilter] : null,
|
||||
writeModes: writeModeFilter ? [writeModeFilter] : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={writeModeFilter ?? "ALL"}
|
||||
onValueChange={handleWriteModeFilterChange}
|
||||
>
|
||||
<Option value="ALL">{__("All sources")}</Option>
|
||||
{documentWriteModes.map(source => (
|
||||
<Option key={source} value={source}>
|
||||
{getDocumentWriteModeLabel(__, source) ?? source}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={documentTypeFilter ?? "ALL"}
|
||||
onValueChange={handleDocumentTypeFilterChange}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const documentDescriptionPageQuery = graphql`
|
||||
... on Document {
|
||||
id
|
||||
status
|
||||
writeMode
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
# We use this on /documents/:documentId/description
|
||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) {
|
||||
@@ -146,7 +147,8 @@ export function DocumentDescriptionPage(props: {
|
||||
|
||||
const canEdit = isEditable
|
||||
&& document.canUpdate
|
||||
&& document.status !== "ARCHIVED";
|
||||
&& document.status !== "ARCHIVED"
|
||||
&& document.writeMode !== "GENERATED";
|
||||
|
||||
// The editor key must change on explicit actions (delete draft, edit
|
||||
// title/type) but NOT on auto-save side effects (cursor preservation).
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDate, formatError, type GraphQLError, promisifyMutation, sprintf, validateSnapshotConsistency } from "@probo/helpers";
|
||||
import { formatDate, formatError, type GraphQLError, promisifyMutation, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -21,17 +21,18 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DropdownItem,
|
||||
IconArrowDown,
|
||||
IconCheckmark1,
|
||||
IconCrossLargeX,
|
||||
IconPageTextLine,
|
||||
IconPencil,
|
||||
IconTrashCan,
|
||||
IconUpload,
|
||||
Input,
|
||||
PageHeader,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
graphql,
|
||||
@@ -39,19 +40,16 @@ import {
|
||||
useMutation,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { StatementOfApplicabilityDetailPageDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageDeleteMutation.graphql";
|
||||
import type { StatementOfApplicabilityDetailPageExportMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageExportMutation.graphql";
|
||||
import type { StatementOfApplicabilityDetailPageQuery } from "#/__generated__/core/StatementOfApplicabilityDetailPageQuery.graphql";
|
||||
import type { StatementOfApplicabilityDetailPageUpdateMutation } from "#/__generated__/core/StatementOfApplicabilityDetailPageUpdateMutation.graphql";
|
||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { PublishStatementOfApplicabilityDialog } from "./dialogs/PublishStatementOfApplicabilityDialog";
|
||||
import StatementOfApplicabilityControlsTab from "./tabs/StatementOfApplicabilityControlsTab";
|
||||
|
||||
export const statementOfApplicabilityDetailPageQuery = graphql`
|
||||
@@ -60,32 +58,23 @@ export const statementOfApplicabilityDetailPageQuery = graphql`
|
||||
... on StatementOfApplicability {
|
||||
id
|
||||
name
|
||||
snapshotId
|
||||
createdAt
|
||||
updatedAt
|
||||
document {
|
||||
id
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
canUpdate: permission(action: "core:statement-of-applicability:update")
|
||||
canDelete: permission(action: "core:statement-of-applicability:delete")
|
||||
canExport: permission(action: "core:statement-of-applicability:export")
|
||||
owner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
canPublish: permission(action: "core:statement-of-applicability:publish")
|
||||
...StatementOfApplicabilityControlsTabFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportMutation = graphql`
|
||||
mutation StatementOfApplicabilityDetailPageExportMutation(
|
||||
$input: ExportStatementOfApplicabilityPDFInput!
|
||||
) {
|
||||
exportStatementOfApplicabilityPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const updateMutation = graphql`
|
||||
mutation StatementOfApplicabilityDetailPageUpdateMutation(
|
||||
$input: UpdateStatementOfApplicabilityInput!
|
||||
@@ -94,14 +83,8 @@ const updateMutation = graphql`
|
||||
statementOfApplicability {
|
||||
id
|
||||
name
|
||||
sourceId
|
||||
snapshotId
|
||||
createdAt
|
||||
updatedAt
|
||||
owner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,16 +108,14 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
const { statementOfApplicabilityId, snapshotId } = useParams<{
|
||||
const { statementOfApplicabilityId } = useParams<{
|
||||
statementOfApplicabilityId: string;
|
||||
snapshotId?: string;
|
||||
}>();
|
||||
const organizationId = useOrganizationId();
|
||||
const data = usePreloadedQuery(statementOfApplicabilityDetailPageQuery, props.queryRef);
|
||||
const statementOfApplicability = data.node;
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -144,12 +125,9 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
validateSnapshotConsistency(statementOfApplicability, snapshotId);
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
StatementOfApplicabilityConnectionKey,
|
||||
{ filter: { snapshotId: snapshotId ?? null } },
|
||||
);
|
||||
|
||||
const [deleteStatementOfApplicability]
|
||||
@@ -196,60 +174,16 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
usePageTitle(statementOfApplicability.name || __("Statement of Applicability"));
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isEditingOwner, setIsEditingOwner] = useState(false);
|
||||
const [updateStatementOfApplicability, isUpdating]
|
||||
= useMutationWithToasts<StatementOfApplicabilityDetailPageUpdateMutation>(
|
||||
updateMutation,
|
||||
{
|
||||
successMessage: __("Statement of Applicability updated successfully."),
|
||||
errorMessage: __("Failed to update Statement of Applicability"),
|
||||
},
|
||||
);
|
||||
= useMutation<StatementOfApplicabilityDetailPageUpdateMutation>(updateMutation);
|
||||
|
||||
const canUpdate = !isSnapshotMode && statementOfApplicability.canUpdate;
|
||||
const canDelete = !isSnapshotMode && statementOfApplicability.canDelete;
|
||||
|
||||
const [exportStatementOfApplicabilityPDF, isExporting]
|
||||
= useMutationWithToasts<StatementOfApplicabilityDetailPageExportMutation>(
|
||||
exportMutation,
|
||||
{
|
||||
successMessage: __(
|
||||
"Statement of Applicability exported successfully.",
|
||||
),
|
||||
errorMessage: __("Failed to export Statement of Applicability"),
|
||||
},
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!statementOfApplicability.id) return;
|
||||
|
||||
await exportStatementOfApplicabilityPDF({
|
||||
variables: {
|
||||
input: {
|
||||
statementOfApplicabilityId: statementOfApplicability.id,
|
||||
},
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
if (data.exportStatementOfApplicabilityPDF?.data) {
|
||||
const link = window.document.createElement("a");
|
||||
link.href = data.exportStatementOfApplicabilityPDF.data;
|
||||
link.download = `${statementOfApplicability.name || "statement-of-applicability"}.pdf`;
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const canUpdate = statementOfApplicability.canUpdate;
|
||||
const canDelete = statementOfApplicability.canDelete;
|
||||
|
||||
const nameSchema = z.object({
|
||||
name: z.string().min(1, __("Name is required")),
|
||||
});
|
||||
|
||||
const ownerSchema = z.object({
|
||||
ownerId: z.string().min(1, __("Owner is required")),
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerName,
|
||||
handleSubmit: handleSubmitName,
|
||||
@@ -260,46 +194,34 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
control: controlOwner,
|
||||
handleSubmit: handleSubmitOwner,
|
||||
reset: resetOwner,
|
||||
} = useFormWithSchema(ownerSchema, {
|
||||
defaultValues: {
|
||||
ownerId: statementOfApplicability.owner?.id || "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateName = handleSubmitName(async (data) => {
|
||||
const handleUpdateName = handleSubmitName((data) => {
|
||||
if (!statementOfApplicability.id) return;
|
||||
|
||||
await updateStatementOfApplicability({
|
||||
updateStatementOfApplicability({
|
||||
variables: {
|
||||
input: {
|
||||
id: statementOfApplicability.id,
|
||||
name: data.name,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted() {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Statement of Applicability updated successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
setIsEditingName(false);
|
||||
resetName({ name: data.name });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpdateOwner = handleSubmitOwner(async (data) => {
|
||||
if (!statementOfApplicability.id) return;
|
||||
|
||||
await updateStatementOfApplicability({
|
||||
variables: {
|
||||
input: {
|
||||
id: statementOfApplicability.id,
|
||||
ownerId: data.ownerId,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsEditingOwner(false);
|
||||
resetOwner({ ownerId: data.ownerId });
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update Statement of Applicability"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -311,20 +233,12 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelOwnerEdit = () => {
|
||||
setIsEditingOwner(false);
|
||||
resetOwner({
|
||||
ownerId: statementOfApplicability.owner?.id || "",
|
||||
});
|
||||
};
|
||||
const defaultApproverIds = (statementOfApplicability.document?.defaultApprovers ?? []).map(a => a.id);
|
||||
|
||||
const listUrl = snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/statements-of-applicability`
|
||||
: `/organizations/${organizationId}/statements-of-applicability`;
|
||||
const listUrl = `/organizations/${organizationId}/statements-of-applicability`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
@@ -385,16 +299,33 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{statementOfApplicability.canExport && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconArrowDown}
|
||||
onClick={() => void handleExport()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{__("Export")}
|
||||
{statementOfApplicability.document?.id && (
|
||||
<Button variant="secondary" asChild>
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/documents/${statementOfApplicability.document.id}`}
|
||||
>
|
||||
<IconPageTextLine size={16} />
|
||||
{__("Document")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{statementOfApplicability.canPublish && statementOfApplicability.id && (
|
||||
<PublishStatementOfApplicabilityDialog
|
||||
statementOfApplicabilityId={statementOfApplicability.id}
|
||||
defaultApproverIds={defaultApproverIds}
|
||||
onPublished={(documentId) => {
|
||||
void navigate(
|
||||
`/organizations/${organizationId}/documents/${documentId}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={IconUpload}
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishStatementOfApplicabilityDialog>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
@@ -412,56 +343,6 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Details")}</h2>
|
||||
<Card className="space-y-4" padded>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{__("Owner")}
|
||||
</div>
|
||||
{isEditingOwner && canUpdate
|
||||
? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div>{__("Loading...")}</div>
|
||||
}
|
||||
>
|
||||
<PeopleSelectField
|
||||
organizationId={organizationId}
|
||||
control={controlOwner}
|
||||
name="ownerId"
|
||||
/>
|
||||
</Suspense>
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconCheckmark1}
|
||||
onClick={() => void handleUpdateOwner()}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={handleCancelOwnerEdit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-txt-primary">
|
||||
{statementOfApplicability.owner
|
||||
?.fullName || "-"}
|
||||
</div>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconPencil}
|
||||
onClick={() =>
|
||||
setIsEditingOwner(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
@@ -494,7 +375,6 @@ export default function StatementOfApplicabilityDetailPage(props: Props) {
|
||||
id: string;
|
||||
}
|
||||
}
|
||||
isSnapshotMode={isSnapshotMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,19 +25,16 @@ import {
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { StatementsOfApplicabilityPageFragment$key } from "#/__generated__/core/StatementsOfApplicabilityPageFragment.graphql";
|
||||
import type { StatementsOfApplicabilityPagePaginationQuery } from "#/__generated__/core/StatementsOfApplicabilityPagePaginationQuery.graphql";
|
||||
import type { StatementsOfApplicabilityPageQuery } from "#/__generated__/core/StatementsOfApplicabilityPageQuery.graphql";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
|
||||
import { StatementOfApplicabilityRow } from "./_components/StatementOfApplicabilityRow";
|
||||
import { CreateStatementOfApplicabilityDialog } from "./dialogs/CreateStatementOfApplicabilityDialog";
|
||||
@@ -67,7 +64,6 @@ const paginatedFragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "StatementOfApplicabilityFilter", defaultValue: { snapshotId: null } }
|
||||
) {
|
||||
statementsOfApplicability(
|
||||
first: $first
|
||||
@@ -75,8 +71,7 @@ const paginatedFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: $filter
|
||||
) @connection(key: "StatementsOfApplicabilityPage_statementsOfApplicability", filters: ["filter"]) {
|
||||
) @connection(key: "StatementsOfApplicabilityPage_statementsOfApplicability") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
@@ -94,8 +89,6 @@ export default function StatementsOfApplicabilityPage({
|
||||
queryRef: PreloadedQuery<StatementsOfApplicabilityPageQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
usePageTitle(__("Statements of Applicability"));
|
||||
|
||||
@@ -109,33 +102,21 @@ export default function StatementsOfApplicabilityPage({
|
||||
data: { statementsOfApplicability },
|
||||
loadNext,
|
||||
hasNext,
|
||||
refetch,
|
||||
isLoadingNext,
|
||||
} = usePaginationFragment<
|
||||
StatementsOfApplicabilityPagePaginationQuery,
|
||||
StatementsOfApplicabilityPageFragment$key
|
||||
>(paginatedFragment, organization);
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshotId) {
|
||||
refetch(
|
||||
{ filter: { snapshotId } },
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
}
|
||||
}, [snapshotId, refetch]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
<PageHeader
|
||||
title={__("Statements of Applicability")}
|
||||
description={__(
|
||||
"Manage statements of applicability for your organization's frameworks.",
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode
|
||||
&& organization.canCreateStatementOfApplicability && (
|
||||
{organization.canCreateStatementOfApplicability && (
|
||||
<CreateStatementOfApplicabilityDialog
|
||||
connectionId={statementsOfApplicability.__id}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { StatementOfApplicabilityRowDeleteMutation } from "#/__generated__/core/StatementOfApplicabilityRowDeleteMutation.graphql";
|
||||
@@ -62,13 +61,11 @@ type Props = {
|
||||
export function StatementOfApplicabilityRow({ fKey, connectionId }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
const statementOfApplicability = useFragment(fragment, fKey);
|
||||
const canDelete = !isSnapshotMode && statementOfApplicability.canDelete;
|
||||
const canDelete = statementOfApplicability.canDelete;
|
||||
|
||||
const [deleteStatementOfApplicability] = useMutation<StatementOfApplicabilityRowDeleteMutation>(deleteMutation);
|
||||
|
||||
@@ -106,9 +103,7 @@ export function StatementOfApplicabilityRow({ fKey, connectionId }: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
const detailUrl = snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/statements-of-applicability/${statementOfApplicability.id}`
|
||||
: `/organizations/${organizationId}/statements-of-applicability/${statementOfApplicability.id}`;
|
||||
const detailUrl = `/organizations/${organizationId}/statements-of-applicability/${statementOfApplicability.id}`;
|
||||
|
||||
return (
|
||||
<Tr to={detailUrl}>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -21,17 +22,16 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
import { useMutation } from "react-relay";
|
||||
import { useNavigate } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateStatementOfApplicabilityDialogMutation } from "#/__generated__/core/CreateStatementOfApplicabilityDialogMutation.graphql";
|
||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const createMutation = graphql`
|
||||
@@ -44,8 +44,6 @@ const createMutation = graphql`
|
||||
node {
|
||||
id
|
||||
name
|
||||
sourceId
|
||||
snapshotId
|
||||
createdAt
|
||||
updatedAt
|
||||
canDelete: permission(action: "core:statement-of-applicability:delete")
|
||||
@@ -63,7 +61,6 @@ type Props = {
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
ownerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export function CreateStatementOfApplicabilityDialog({
|
||||
@@ -71,41 +68,37 @@ export function CreateStatementOfApplicabilityDialog({
|
||||
connectionId,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
const { control, register, handleSubmit, reset } = useFormWithSchema(
|
||||
const { register, handleSubmit, reset } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
name: "",
|
||||
ownerId: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
const ref = useDialogRef();
|
||||
|
||||
const [createStatementOfApplicability, isCreating]
|
||||
= useMutationWithToasts<CreateStatementOfApplicabilityDialogMutation>(
|
||||
createMutation,
|
||||
{
|
||||
successMessage: __(
|
||||
"Statement of applicability created successfully.",
|
||||
),
|
||||
errorMessage: __("Failed to create statement of applicability"),
|
||||
},
|
||||
);
|
||||
= useMutation<CreateStatementOfApplicabilityDialogMutation>(createMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await createStatementOfApplicability({
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
createStatementOfApplicability({
|
||||
variables: {
|
||||
input: {
|
||||
name: data.name,
|
||||
organizationId,
|
||||
ownerId: data.ownerId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (response) => {
|
||||
onCompleted(response) {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Statement of applicability created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
reset();
|
||||
ref.current?.close();
|
||||
const statementOfApplicabilityId
|
||||
@@ -115,6 +108,16 @@ export function CreateStatementOfApplicabilityDialog({
|
||||
`/organizations/${organizationId}/statements-of-applicability/${statementOfApplicabilityId}`,
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create statement of applicability"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -139,15 +142,6 @@ export function CreateStatementOfApplicabilityDialog({
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<Field label={__("Owner")}>
|
||||
<Suspense fallback={<div>{__("Loading...")}</div>}>
|
||||
<PeopleSelectField
|
||||
organizationId={organizationId}
|
||||
control={control}
|
||||
name="ownerId"
|
||||
/>
|
||||
</Suspense>
|
||||
</Field>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishStatementOfApplicabilityDialogMutation } from "#/__generated__/core/PublishStatementOfApplicabilityDialogMutation.graphql";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const publishMutation = graphql`
|
||||
mutation PublishStatementOfApplicabilityDialogMutation(
|
||||
$input: PublishStatementOfApplicabilityInput!
|
||||
) {
|
||||
publishStatementOfApplicability(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
statementOfApplicabilityId: string;
|
||||
defaultApproverIds?: string[];
|
||||
onPublished?: (documentId: string) => void;
|
||||
};
|
||||
|
||||
export function PublishStatementOfApplicabilityDialog({
|
||||
children,
|
||||
statementOfApplicabilityId,
|
||||
defaultApproverIds = [],
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const schema = useMemo(() => z.object({
|
||||
approverIds: z.array(z.string()),
|
||||
}), []);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
approverIds: defaultApproverIds,
|
||||
},
|
||||
});
|
||||
|
||||
const [publish, isPublishing]
|
||||
= useMutation<PublishStatementOfApplicabilityDialogMutation>(publishMutation);
|
||||
|
||||
const approverIds = watch("approverIds");
|
||||
const hasApprovers = approverIds.length > 0;
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
publish({
|
||||
variables: {
|
||||
input: {
|
||||
statementOfApplicabilityId,
|
||||
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
|
||||
},
|
||||
},
|
||||
onCompleted(response) {
|
||||
const documentId = response.publishStatementOfApplicability?.documentEdge?.node?.id;
|
||||
if (documentId) {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: hasApprovers
|
||||
? __("Approval requested successfully.")
|
||||
: __("Statement of Applicability published successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
onPublished?.(documentId);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to publish Statement of Applicability"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-xl"
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={__("Publish Statement of Applicability")}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={hasApprovers ? IconSend : IconUpload}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
{hasApprovers ? __("Request approval") : __("Publish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -117,12 +117,10 @@ const deleteApplicabilityStatementMutation = graphql`
|
||||
|
||||
export default function StatementOfApplicabilityControlsTab({
|
||||
statementOfApplicability,
|
||||
isSnapshotMode = false,
|
||||
}: {
|
||||
statementOfApplicability: StatementOfApplicabilityControlsTabFragment$key & {
|
||||
id: string;
|
||||
};
|
||||
isSnapshotMode?: boolean;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useFragment(controlsFragment, statementOfApplicability);
|
||||
@@ -166,12 +164,9 @@ export default function StatementOfApplicabilityControlsTab({
|
||||
},
|
||||
);
|
||||
|
||||
const canCreate
|
||||
= !isSnapshotMode && data.canCreateApplicabilityStatement;
|
||||
const canUpdate
|
||||
= !isSnapshotMode && data.canUpdateApplicabilityStatement;
|
||||
const canDelete
|
||||
= !isSnapshotMode && data.canDeleteApplicabilityStatement;
|
||||
const canCreate = data.canCreateApplicabilityStatement;
|
||||
const canUpdate = data.canUpdateApplicabilityStatement;
|
||||
const canDelete = data.canDeleteApplicabilityStatement;
|
||||
|
||||
const handleOpenAddStatementDialog = () => {
|
||||
if (!data.organization || !connectionId) return;
|
||||
|
||||
@@ -25,13 +25,6 @@ export const statementsOfApplicabilityRoutes = [
|
||||
() => import("#/pages/organizations/statements-of-applicability/StatementsOfApplicabilityPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/statements-of-applicability",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/statements-of-applicability/StatementsOfApplicabilityPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "statements-of-applicability/:statementOfApplicabilityId",
|
||||
Fallback: PageSkeleton,
|
||||
@@ -39,11 +32,4 @@ export const statementsOfApplicabilityRoutes = [
|
||||
() => import("#/pages/organizations/statements-of-applicability/StatementOfApplicabilityDetailPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/statements-of-applicability/:statementOfApplicabilityId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/statements-of-applicability/StatementOfApplicabilityDetailPageLoader"),
|
||||
),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user