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