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:
Sacha Al Himdani
2026-04-10 13:38:42 +02:00
parent 53edc5ba26
commit c635492f75
74 changed files with 3509 additions and 1595 deletions

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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).

View File

@@ -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>
)}

View File

@@ -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}
>

View File

@@ -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}>

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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[];