Add vendor publish to document system
Replace the old snapshot-based system for vendors with the publish document system, mirroring the prior processing activity / DPIA / TIA migration. Includes the GraphQL mutation, MCP tool, CLI command, n8n operation, frontend publish dialog, e2e tests, and a prosemirror register template covering vendor profile fields plus per-vendor sections for services, contacts, risk assessments, compliance reports, BAA and DPA agreements. The vendor register lives as a generated DocumentTypeRegister document on the organization, reused across publishes (the major version bumps on every republish). Approvers can be passed in to create a draft pending approval; otherwise the version is published immediately. The frontend Vendors page exposes a Publish button and a Document link button when the document exists, and pre-fills the previous default approvers. Remove snapshot mode entirely from vendors and their sub-entities: drop snapshotId/sourceId from GraphQL Vendor type and VendorFilter; remove SnapshotsTypeVendors from the snapshot registry and delete Vendors.Snapshot, VendorSnapshotter interface and all *.InsertVendorSnapshots methods on contacts, services, risk assessments, compliance reports, BAA and DPA. Drop the snapshot routes and banner from the frontend. The snapshot_id columns remain in the database but are now filtered out with snapshot_id IS NULL. Add Get/Upsert/Clear GeneratedDocumentID methods on Vendor backed by a new vendors_document_id column on generated_documents, matching the ProcessingActivity/Finding/Obligation pattern. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -109,12 +109,21 @@ export const useDeleteVendor = (
|
||||
export const vendorConnectionKey = "VendorsPage_vendors";
|
||||
|
||||
export const vendorsQuery = graphql`
|
||||
query VendorGraphListQuery($organizationId: ID!, $snapshotId: ID) {
|
||||
query VendorGraphListQuery($organizationId: ID!) {
|
||||
node(id: $organizationId) {
|
||||
... on Organization {
|
||||
id
|
||||
canCreateVendor: permission(action: "core:vendor:create")
|
||||
...VendorGraphPaginatedFragment @arguments(snapshotId: $snapshotId)
|
||||
canPublishVendor: permission(action: "core:vendor:publish")
|
||||
vendorsDocument {
|
||||
id
|
||||
currentPublishedMajor
|
||||
currentPublishedMinor
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
...VendorGraphPaginatedFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +138,6 @@ export const paginatedVendorsFragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
snapshotId: { type: "ID", defaultValue: null }
|
||||
) {
|
||||
vendors(
|
||||
first: $first
|
||||
@@ -137,13 +145,11 @@ export const paginatedVendorsFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: { snapshotId: $snapshotId }
|
||||
) @connection(key: "VendorsListQuery_vendors", filters: ["filter"]) {
|
||||
) @connection(key: "VendorsListQuery_vendors") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
snapshotId
|
||||
name
|
||||
websiteUrl
|
||||
updatedAt
|
||||
@@ -174,7 +180,6 @@ export const vendorNodeQuery = graphql`
|
||||
node(id: $vendorId) {
|
||||
id
|
||||
... on Vendor {
|
||||
snapshotId
|
||||
name
|
||||
websiteUrl
|
||||
canAssess: permission(action: "core:vendor:assess")
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl, validateSnapshotConsistency } from "@probo/helpers";
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -31,11 +31,10 @@ import {
|
||||
useFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import type { VendorComplianceTabFragment$key } from "#/__generated__/core/VendorComplianceTabFragment.graphql";
|
||||
import type { VendorGraphNodeQuery } from "#/__generated__/core/VendorGraphNodeQuery.graphql";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
import {
|
||||
useDeleteVendor,
|
||||
vendorConnectionKey,
|
||||
@@ -54,10 +53,7 @@ export default function VendorDetailPage(props: Props) {
|
||||
const { node: vendor } = usePreloadedQuery(vendorNodeQuery, props.queryRef);
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
|
||||
validateSnapshotConsistency(vendor, snapshotId);
|
||||
const deleteVendor = useDeleteVendor(
|
||||
vendor,
|
||||
ConnectionHandler.getConnectionID(organizationId, vendorConnectionKey),
|
||||
@@ -68,19 +64,13 @@ export default function VendorDetailPage(props: Props) {
|
||||
vendor as VendorComplianceTabFragment$key,
|
||||
).complianceReports.edges.length;
|
||||
|
||||
const vendorsUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors`
|
||||
: `/organizations/${organizationId}/vendors`;
|
||||
const vendorsUrl = `/organizations/${organizationId}/vendors`;
|
||||
|
||||
const baseVendorUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}`
|
||||
: `/organizations/${organizationId}/vendors/${vendor.id}`;
|
||||
= `/organizations/${organizationId}/vendors/${vendor.id}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
@@ -103,28 +93,26 @@ export default function VendorDetailPage(props: Props) {
|
||||
)}
|
||||
<div className="text-2xl">{vendor.name}</div>
|
||||
</div>
|
||||
{!isSnapshotMode && (
|
||||
<div className="flex gap-2 items-center">
|
||||
{vendor.canAssess && (
|
||||
<ImportAssessmentDialog vendorId={vendor.id}>
|
||||
<Button icon={IconPageTextLine} variant="secondary">
|
||||
{__("Assessment From Website")}
|
||||
</Button>
|
||||
</ImportAssessmentDialog>
|
||||
)}
|
||||
{vendor.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteVendor}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
{vendor.canAssess && (
|
||||
<ImportAssessmentDialog vendorId={vendor.id}>
|
||||
<Button icon={IconPageTextLine} variant="secondary">
|
||||
{__("Assessment From Website")}
|
||||
</Button>
|
||||
</ImportAssessmentDialog>
|
||||
)}
|
||||
{vendor.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteVendor}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
Avatar,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPageTextLine,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
Tbody,
|
||||
@@ -35,14 +37,13 @@ import {
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { VendorGraphListQuery } from "#/__generated__/core/VendorGraphListQuery.graphql";
|
||||
import type {
|
||||
VendorGraphPaginatedFragment$data,
|
||||
VendorGraphPaginatedFragment$key,
|
||||
} from "#/__generated__/core/VendorGraphPaginatedFragment.graphql";
|
||||
import { SnapshotBanner } from "#/components/SnapshotBanner";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import {
|
||||
paginatedVendorsFragment,
|
||||
@@ -53,6 +54,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { NodeOf } from "#/types";
|
||||
|
||||
import { CreateVendorDialog } from "./dialogs/CreateVendorDialog";
|
||||
import { PublishVendorListDialog } from "./dialogs/PublishVendorListDialog";
|
||||
|
||||
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
|
||||
|
||||
@@ -63,8 +65,7 @@ type Props = {
|
||||
export default function VendorsPage(props: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const data = usePreloadedQuery(vendorsQuery, props.queryRef);
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
@@ -79,26 +80,54 @@ export default function VendorsPage(props: Props) {
|
||||
usePageTitle(__("Vendors"));
|
||||
|
||||
const hasAnyAction
|
||||
= !isSnapshotMode
|
||||
&& vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
= vendors.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
|
||||
const vendorsDocument = data.node?.vendorsDocument;
|
||||
const defaultApproverIds
|
||||
= vendorsDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
|
||||
<PageHeader
|
||||
title={__("Vendors")}
|
||||
description={__(
|
||||
"Vendors are third-party services that your company uses. Add them to keep track of their risk and compliance status.",
|
||||
)}
|
||||
>
|
||||
{!isSnapshotMode && data.node.canCreateVendor && (
|
||||
<CreateVendorDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
|
||||
</CreateVendorDialog>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{vendorsDocument && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPageTextLine}
|
||||
onClick={() => void navigate(
|
||||
`/organizations/${organizationId}/documents/${vendorsDocument.id}`,
|
||||
)}
|
||||
>
|
||||
{__("Document")}
|
||||
</Button>
|
||||
)}
|
||||
{data.node.canPublishVendor && (
|
||||
<PublishVendorListDialog
|
||||
organizationId={organizationId}
|
||||
defaultApproverIds={defaultApproverIds}
|
||||
onPublished={documentId => void navigate(
|
||||
`/organizations/${organizationId}/documents/${documentId}`,
|
||||
)}
|
||||
>
|
||||
<Button variant="secondary" icon={IconUpload}>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishVendorListDialog>
|
||||
)}
|
||||
{data.node.canCreateVendor && (
|
||||
<CreateVendorDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
|
||||
</CreateVendorDialog>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
@@ -137,16 +166,11 @@ function VendorRow({
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const { snapshotId } = useParams<{ snapshotId?: string }>();
|
||||
const isSnapshotMode = Boolean(snapshotId);
|
||||
const { __ } = useTranslate();
|
||||
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
|
||||
const deleteVendor = useDeleteVendor(vendor, connectionId);
|
||||
|
||||
const vendorUrl
|
||||
= isSnapshotMode && snapshotId
|
||||
? `/organizations/${organizationId}/snapshots/${snapshotId}/vendors/${vendor.id}/overview`
|
||||
: `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
|
||||
const vendorUrl = `/organizations/${organizationId}/vendors/${vendor.id}/overview`;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
160
apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx
vendored
Normal file
160
apps/console/src/pages/organizations/vendors/dialogs/PublishVendorListDialog.tsx
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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 { PublishVendorListDialogMutation } from "#/__generated__/core/PublishVendorListDialogMutation.graphql";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
const publishMutation = graphql`
|
||||
mutation PublishVendorListDialogMutation(
|
||||
$input: PublishVendorListInput!
|
||||
) {
|
||||
publishVendorList(input: $input) {
|
||||
documentEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
defaultApproverIds?: string[];
|
||||
onPublished?: (documentId: string) => void;
|
||||
};
|
||||
|
||||
export function PublishVendorListDialog({
|
||||
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<PublishVendorListDialogMutation>(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.publishVendorList?.documentEdge?.node?.id;
|
||||
if (documentId) {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: hasApprovers
|
||||
? __("Approval requested successfully.")
|
||||
: __("Vendors published successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
onPublished?.(documentId);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to publish vendors"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="max-w-xl"
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={__("Publish Vendors")}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -34,20 +34,6 @@ export const vendorRoutes = [
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
|
||||
organizationId: organizationId,
|
||||
snapshotId: null,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("#/pages/organizations/vendors/VendorsPage")),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/vendors",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
|
||||
loadQuery<VendorGraphListQuery>(coreEnvironment, vendorsQuery, {
|
||||
organizationId: organizationId,
|
||||
snapshotId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
@@ -113,63 +99,4 @@ export const vendorRoutes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "snapshots/:snapshotId/vendors/:vendorId",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ vendorId }) =>
|
||||
loadQuery<VendorGraphNodeQuery>(coreEnvironment, vendorNodeQuery, {
|
||||
vendorId: vendorId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("../pages/organizations/vendors/VendorDetailPage")),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "overview",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/vendors/tabs/VendorOverviewTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "certifications",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/vendors/tabs/VendorCertificationsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "compliance",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/vendors/tabs/VendorComplianceTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "risks",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/vendors/tabs/VendorRiskAssessmentTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "contacts",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/vendors/tabs/VendorContactsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "services",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/vendors/tabs/VendorServicesTab"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user