Add finding and obligation publish to document system

Replace the old snapshot-based approach with the new publish document
system for findings and obligations. Includes GraphQL mutations, MCP
tools, CLI commands, e2e tests, frontend publish dialogs, and
snapshot-to-document migration tools.

Remove snapshot mode entirely from findings and obligations: drop
snapshotId from GraphQL schemas, filters, resolvers, MCP spec, frontend
routes, pages, and helpers. The snapshot_id column remains in the
database but is now filtered out with snapshot_id IS NULL.

Remove auditor's ability to publish SoA.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-23 10:53:57 +02:00
parent e473884b31
commit bdb16d4abe
57 changed files with 4241 additions and 609 deletions

View File

@@ -36,7 +36,6 @@ import {
import { clsx } from "clsx";
import { useMemo, useState } from "react";
import { useFragment } from "react-relay";
import { useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { LinkedObligationsCardFragment$key } from "#/__generated__/core/LinkedObligationsCardFragment.graphql";
@@ -203,16 +202,12 @@ function ObligationRow(props: {
const { __ } = useTranslate();
const obligation = useFragment(linkedObligationFragment, props.obligation);
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const onDetach = () => {
props.onClick(obligation.id);
};
const detailsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations/${obligation.id}`
: `/organizations/${organizationId}/obligations/${obligation.id}`;
const detailsUrl = `/organizations/${organizationId}/obligations/${obligation.id}`;
return (
<Tr to={detailsUrl}>

View File

@@ -25,11 +25,18 @@ import { useMutationWithToasts } from "../useMutationWithToasts";
export const ObligationsConnectionKey = "ObligationsPage_obligations";
export const obligationsQuery = graphql`
query ObligationGraphListQuery($organizationId: ID!, $snapshotId: ID) {
query ObligationGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateObligation: permission(action: "core:obligation:create")
...ObligationsPageFragment @arguments(snapshotId: $snapshotId)
canPublishObligations: permission(action: "core:obligation:publish")
obligationsDocument {
id
defaultApprovers {
id
}
}
...ObligationsPageFragment
}
}
}
@@ -40,8 +47,6 @@ export const obligationNodeQuery = graphql`
node(id: $obligationId) {
... on Obligation {
id
snapshotId
sourceId
area
source
requirement

View File

@@ -20,7 +20,6 @@ import {
getStatusVariant,
type GraphQLError,
sprintf,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
@@ -48,7 +47,6 @@ import {
useMutation,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { FindingDetailsPageDeleteMutation } from "#/__generated__/core/FindingDetailsPageDeleteMutation.graphql";
@@ -56,7 +54,6 @@ import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetail
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -67,7 +64,6 @@ export const findingDetailsPageQuery = graphql`
node(id: $findingId) {
... on Finding {
id
snapshotId
kind
referenceId
description
@@ -166,12 +162,8 @@ export default function FindingDetailsPage(props: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const confirm = useConfirm();
validateSnapshotConsistency(finding, snapshotId);
const [updateFinding] = useMutation<FindingDetailsPageUpdateMutation>(updateFindingMutation);
const [deleteFinding] = useMutation<FindingDetailsPageDeleteMutation>(deleteFindingMutation);
@@ -181,7 +173,6 @@ export default function FindingDetailsPage(props: Props) {
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: null,
status: null,
priority: null,
@@ -194,7 +185,6 @@ export default function FindingDetailsPage(props: Props) {
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: finding.kind,
status: null,
priority: null,
@@ -322,15 +312,10 @@ export default function FindingDetailsPage(props: Props) {
{ value: "HIGH", label: __("High") },
];
const breadcrumbFindingsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/findings`
: `/organizations/${organizationId}/findings`;
const breadcrumbFindingsUrl = `/organizations/${organizationId}/findings`;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<Breadcrumb
items={[
{
@@ -370,19 +355,17 @@ export default function FindingDetailsPage(props: Props) {
: __("Low")}
</Badge>
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
{finding.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
<ActionDropdown variant="secondary">
{finding.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</div>
<div className="max-w-4xl">
@@ -393,7 +376,6 @@ export default function FindingDetailsPage(props: Props) {
{...register("description")}
placeholder={__("Enter description")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
@@ -405,7 +387,6 @@ export default function FindingDetailsPage(props: Props) {
<Input
{...register("source")}
placeholder={__("Enter source")}
disabled={isSnapshotMode}
/>
</Field>
@@ -416,7 +397,6 @@ export default function FindingDetailsPage(props: Props) {
label={__("Owner")}
error={formState.errors.ownerId?.message}
optional
disabled={isSnapshotMode}
/>
</div>
@@ -427,7 +407,6 @@ export default function FindingDetailsPage(props: Props) {
type="select"
label={__("Status")}
required
disabled={isSnapshotMode}
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
@@ -449,7 +428,6 @@ export default function FindingDetailsPage(props: Props) {
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isSnapshotMode}
>
{priorityOptions.map(option => (
<Option key={option.value} value={option.value}>
@@ -472,7 +450,6 @@ export default function FindingDetailsPage(props: Props) {
<Input
{...register("identifiedOn")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
@@ -480,7 +457,6 @@ export default function FindingDetailsPage(props: Props) {
<Input
{...register("dueDate")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
</div>
@@ -490,7 +466,6 @@ export default function FindingDetailsPage(props: Props) {
{...register("rootCause")}
placeholder={__("Enter root cause")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
@@ -499,7 +474,6 @@ export default function FindingDetailsPage(props: Props) {
{...register("correctiveAction")}
placeholder={__("Enter corrective action")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
@@ -508,13 +482,11 @@ export default function FindingDetailsPage(props: Props) {
{...register("effectivenessCheck")}
placeholder={__("Enter effectiveness check details")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<div className="flex justify-end">
{formState.isDirty
&& !isSnapshotMode
&& finding.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}

View File

@@ -30,8 +30,10 @@ import {
Button,
Card,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
Option,
PageHeader,
Select,
@@ -54,7 +56,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type { FindingsPageDeleteMutation } from "#/__generated__/core/FindingsPageDeleteMutation.graphql";
import type { FindingsPageFragment$key } from "#/__generated__/core/FindingsPageFragment.graphql";
@@ -66,20 +68,27 @@ import type {
FindingStatus,
} from "#/__generated__/core/FindingsPageRefetchQuery.graphql";
import type { FindingsPageRowFragment$key } from "#/__generated__/core/FindingsPageRowFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { usePeople } from "#/hooks/graph/PeopleGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CreateFindingDialog } from "./dialogs/CreateFindingDialog";
import { PublishFindingListDialog } from "./dialogs/PublishFindingListDialog";
export const FindingsConnectionKey = "FindingsPage_findings";
export const findingsPageQuery = graphql`
query FindingsPageListQuery($organizationId: ID!, $snapshotId: ID) {
query FindingsPageListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateFinding: permission(action: "core:finding:create")
...FindingsPageFragment @arguments(snapshotId: $snapshotId)
canPublishFindings: permission(action: "core:finding:publish")
findingsDocument {
id
defaultApprovers {
id
}
}
...FindingsPageFragment
}
}
}
@@ -120,7 +129,6 @@ const findingsPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 500 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
kind: { type: "FindingKind", defaultValue: null }
status: { type: "FindingStatus", defaultValue: null }
priority: { type: "FindingPriority", defaultValue: null }
@@ -131,7 +139,6 @@ const findingsPageFragment = graphql`
first: $first
after: $after
filter: {
snapshotId: $snapshotId
kind: $kind
status: $status
priority: $priority
@@ -165,12 +172,12 @@ interface FindingsPageProps {
export default function FindingsPage({ queryRef }: FindingsPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(__("Findings"));
const navigate = useNavigate();
const organization = usePreloadedQuery(findingsPageQuery, queryRef);
const defaultApproverIds = (organization.node.findingsDocument?.defaultApprovers ?? []).map(a => a.id);
const [isPending, startTransition] = useTransition();
const [kindFilter, setKindFilter] = useState<FindingKind | null>(null);
@@ -192,7 +199,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
status: statusFilter,
priority: priorityFilter,
ownerId: ownerFilter,
snapshotId: snapshotId || null,
...overrides,
},
{ fetchPolicy: "network-only" },
@@ -225,7 +231,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
};
const currentFilter = {
snapshotId: snapshotId || null,
kind: kindFilter,
status: statusFilter,
priority: priorityFilter,
@@ -242,7 +247,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: null,
status: null,
priority: null,
@@ -257,26 +261,49 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
const findings = data?.findings?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
= !isSnapshotMode
&& findings.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
= findings.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Findings")}
description={__("Manage your organization's findings.")}
>
{!isSnapshotMode && organization.node.canCreateFinding && (
<CreateFindingDialog
organizationId={organizationId}
connectionIds={createConnectionIds}
>
<Button icon={IconPlusLarge}>{__("Add finding")}</Button>
</CreateFindingDialog>
)}
<div className="flex gap-2">
{organization.node.findingsDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${organization.node.findingsDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{organization.node.canPublishFindings && (
<PublishFindingListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={(documentId) => {
void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
);
}}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishFindingListDialog>
)}
{organization.node.canCreateFinding && (
<CreateFindingDialog
organizationId={organizationId}
connectionIds={createConnectionIds}
>
<Button icon={IconPlusLarge}>{__("Add finding")}</Button>
</CreateFindingDialog>
)}
</div>
</PageHeader>
<div className="flex items-center gap-4">
@@ -342,7 +369,6 @@ export default function FindingsPage({ queryRef }: FindingsPageProps) {
key={finding.id}
findingKey={finding}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -397,7 +423,6 @@ function getKindLabel(kind: string, __: (s: string) => string): string {
type FindingRowProps = {
findingKey: FindingsPageRowFragment$key;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
};
@@ -408,7 +433,6 @@ function FindingRow(props: FindingRowProps) {
const [deleteFinding] = useMutation<FindingsPageDeleteMutation>(deleteFindingMutation);
const { toast } = useToast();
const confirm = useConfirm();
const isSnapshotMode = Boolean(props.snapshotId);
const handleDelete = () => {
confirm(
@@ -464,9 +488,7 @@ function FindingRow(props: FindingRowProps) {
);
};
const detailsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${props.snapshotId}/findings/${finding.id}`
: `/organizations/${organizationId}/findings/${finding.id}`;
const detailsUrl = `/organizations/${organizationId}/findings/${finding.id}`;
return (
<Tr to={detailsUrl}>

View File

@@ -14,7 +14,6 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
@@ -24,16 +23,12 @@ import FindingsPage, { findingsPageQuery } from "./FindingsPage";
export default function FindingsPageLoader() {
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const [queryRef, loadQuery]
= useQueryLoader<FindingsPageListQuery>(findingsPageQuery);
useEffect(() => {
loadQuery({
organizationId,
snapshotId: snapshotId ?? null,
});
}, [loadQuery, organizationId, snapshotId]);
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) {
return <PageSkeleton />;

View File

@@ -0,0 +1,159 @@
// 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 { PublishFindingListDialogMutation } from "#/__generated__/core/PublishFindingListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishFindingListDialogMutation(
$input: PublishFindingListInput!
) {
publishFindingList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishFindingListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
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<PublishFindingListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishFindingList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Finding list published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish finding list"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Finding List")}
>
<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

@@ -19,7 +19,6 @@ import {
getObligationStatusOptions,
getObligationStatusVariant,
getObligationTypeOptions,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
@@ -43,12 +42,10 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { ObligationGraphNodeQuery } from "#/__generated__/core/ObligationGraphNodeQuery.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -85,12 +82,8 @@ export default function ObligationDetailsPage(props: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const disabled = isSnapshotMode || !obligation.canUpdate;
validateSnapshotConsistency(obligation, snapshotId);
const disabled = !obligation.canUpdate;
const updateObligation = useUpdateObligation();
const statusOptions = getObligationStatusOptions(__);
@@ -165,15 +158,10 @@ export default function ObligationDetailsPage(props: Props) {
}
});
const breadcrumbObligationsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations`
: `/organizations/${organizationId}/obligations`;
const breadcrumbObligationsUrl = `/organizations/${organizationId}/obligations`;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<div className="flex justify-between items-start">
<div>
<Breadcrumb
@@ -201,7 +189,7 @@ export default function ObligationDetailsPage(props: Props) {
</div>
</div>
{!isSnapshotMode && obligation.canDelete && (
{obligation.canDelete && (
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
@@ -378,20 +366,18 @@ export default function ObligationDetailsPage(props: Props) {
/>
</Field>
{!isSnapshotMode && (
<div className="flex justify-end">
{obligation.canUpdate && (
<Button
type="submit"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
)}
<div className="flex justify-end">
{obligation.canUpdate && (
<Button
type="submit"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
</form>
</Card>
</div>

View File

@@ -26,8 +26,10 @@ import {
Button,
Card,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
Table,
Tbody,
@@ -44,7 +46,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type { ObligationGraphDeleteMutation } from "#/__generated__/core/ObligationGraphDeleteMutation.graphql";
import type { ObligationGraphListQuery } from "#/__generated__/core/ObligationGraphListQuery.graphql";
@@ -53,7 +55,6 @@ import type {
ObligationsPageFragment$key,
} from "#/__generated__/core/ObligationsPageFragment.graphql";
import type { ObligationsPageRefetchQuery } from "#/__generated__/core/ObligationsPageRefetchQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
@@ -62,6 +63,7 @@ import {
} from "../../../hooks/graph/ObligationGraph";
import { CreateObligationDialog } from "./dialogs/CreateObligationDialog";
import { PublishObligationListDialog } from "./dialogs/PublishObligationListDialog";
type Obligation
= ObligationsPageFragment$data["obligations"]["edges"][number]["node"];
@@ -76,19 +78,16 @@ const obligationsPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 500 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
obligations(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
) @connection(key: "ObligationsPage_obligations", filters: ["filter"]) {
) @connection(key: "ObligationsPage_obligations") {
__id
edges {
node {
id
snapshotId
area
source
status
@@ -112,12 +111,12 @@ const obligationsPageFragment = graphql`
export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
usePageTitle(__("Obligations"));
const organization = usePreloadedQuery(obligationsQuery, queryRef);
const defaultApproverIds = (organization.node.obligationsDocument?.defaultApprovers ?? []).map(a => a.id);
const {
data: obligationsData,
@@ -133,26 +132,49 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
= obligationsData?.obligations?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
= !isSnapshotMode
&& obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
= obligations.some(({ canUpdate, canDelete }) => canDelete || canUpdate);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Obligations")}
description={__("Manage your organization's obligations.")}
>
{!snapshotId && organization.node.canCreateObligation && (
<CreateObligationDialog
organizationId={organizationId}
connection={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
</CreateObligationDialog>
)}
<div className="flex gap-2">
{organization.node.obligationsDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${organization.node.obligationsDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{organization.node.canPublishObligations && (
<PublishObligationListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={(documentId) => {
void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
);
}}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishObligationListDialog>
)}
{organization.node.canCreateObligation && (
<CreateObligationDialog
organizationId={organizationId}
connection={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
</CreateObligationDialog>
)}
</div>
</PageHeader>
{obligations.length === 0
@@ -187,7 +209,6 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
key={obligation.id}
obligation={obligation}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -214,19 +235,16 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
function ObligationRow({
obligation,
connectionId,
snapshotId,
hasAnyAction,
}: {
obligation: Obligation;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [deleteObligation] = useMutation<ObligationGraphDeleteMutation>(deleteObligationMutation);
const confirm = useConfirm();
const isSnapshotMode = Boolean(snapshotId);
const handleDelete = () => {
confirm(
@@ -247,9 +265,7 @@ function ObligationRow({
);
};
const detailsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/obligations/${obligation.id}`
: `/organizations/${organizationId}/obligations/${obligation.id}`;
const detailsUrl = `/organizations/${organizationId}/obligations/${obligation.id}`;
return (
<Tr to={detailsUrl}>

View File

@@ -0,0 +1,159 @@
// 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 { PublishObligationListDialogMutation } from "#/__generated__/core/PublishObligationListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishObligationListDialogMutation(
$input: PublishObligationListInput!
) {
publishObligationList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishObligationListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
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<PublishObligationListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishObligationList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Obligation list published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish obligation list"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Obligation List")}
>
<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

@@ -26,14 +26,6 @@ export const findingRoutes = [
import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
{
path: "snapshots/:snapshotId/findings",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
{
path: "findings/:findingId",
Fallback: PageSkeleton,
@@ -42,12 +34,4 @@ export const findingRoutes = [
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
),
},
{
path: "snapshots/:snapshotId/findings/:findingId",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
),
},
] satisfies AppRoute[];

View File

@@ -36,20 +36,6 @@ export const obligationRoutes = [
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ObligationGraphListQuery>(coreEnvironment, obligationsQuery, {
organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/obligations/ObligationsPage")),
),
},
{
path: "snapshots/:snapshotId/obligations",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<ObligationGraphListQuery>(coreEnvironment, obligationsQuery, {
organizationId,
snapshotId,
}),
),
Component: withQueryRef(
@@ -74,22 +60,4 @@ export const obligationRoutes = [
),
),
},
{
path: "snapshots/:snapshotId/obligations/:obligationId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ obligationId }) =>
loadQuery<ObligationGraphNodeQuery>(
coreEnvironment,
obligationNodeQuery,
{
obligationId,
},
),
),
Component: withQueryRef(
lazy(
() => import("#/pages/organizations/obligations/ObligationDetailsPage"),
),
),
},
] satisfies AppRoute[];