Add third-party self-referential relations
Introduce a self-referential many-to-many relation table so a third party can have child third parties. Each relation is directional (parent to child); both directions can coexist as independent rows. Add a first_level boolean on third_parties (default true) with a filter on the list page that defaults to showing only first-level third parties. Frontend adds a "Third Parties" tab on the detail page where users can link existing third parties or create new ones from the common third party catalog (created as non-first-level). The list page gets a First Level/All toggle filter. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -24,6 +24,7 @@ type ThirdParty = {
|
||||
id: string;
|
||||
name: string;
|
||||
websiteUrl: string | null | undefined;
|
||||
firstLevel?: boolean;
|
||||
};
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
@@ -66,7 +67,7 @@ function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const allThirdParties = [...thirdParties];
|
||||
const allThirdParties: ThirdParty[] = [...thirdParties];
|
||||
if (props.disabled) {
|
||||
selectedThirdParties.forEach((selectedThirdParty) => {
|
||||
if (!allThirdParties.find(v => v.id === selectedThirdParty.id)) {
|
||||
|
||||
@@ -138,6 +138,7 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } }
|
||||
) {
|
||||
thirdParties(
|
||||
first: $first
|
||||
@@ -145,7 +146,8 @@ export const paginatedThirdPartiesFragment = graphql`
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartiesListQuery_thirdParties") {
|
||||
filter: $filter
|
||||
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
@@ -182,6 +184,7 @@ export const thirdPartyNodeQuery = graphql`
|
||||
... on ThirdParty {
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
canAssess: permission(action: "core:thirdParty:assess")
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
@@ -224,6 +227,7 @@ export const thirdPartiesSelectQuery = graphql`
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
firstLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,12 +26,15 @@ import {
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
TabItem,
|
||||
Tabs,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
@@ -76,9 +79,21 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
const [, startTransition] = useTransition();
|
||||
const [firstLevelFilter, setFirstLevelFilter] = useState<boolean | null>(true);
|
||||
|
||||
usePageTitle(__("Third parties"));
|
||||
|
||||
const handleFilterChange = (firstLevel: boolean | null) => {
|
||||
setFirstLevelFilter(firstLevel);
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{ filter: firstLevel !== null ? { firstLevel } : {} },
|
||||
{ fetchPolicy: "store-and-network" },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyAction
|
||||
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
|
||||
@@ -129,6 +144,20 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<Tabs>
|
||||
<TabItem
|
||||
active={firstLevelFilter === true}
|
||||
onClick={() => handleFilterChange(true)}
|
||||
>
|
||||
{__("First Level")}
|
||||
</TabItem>
|
||||
<TabItem
|
||||
active={firstLevelFilter === null}
|
||||
onClick={() => handleFilterChange(null)}
|
||||
>
|
||||
{__("All")}
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
<SortableTable {...pagination}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
DropdownItem,
|
||||
@@ -91,7 +92,12 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
className="shadow-mid rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
<div className="text-2xl">{thirdParty.name}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">{thirdParty.name}</div>
|
||||
<Badge variant={thirdParty.firstLevel ? "info" : "neutral"}>
|
||||
{thirdParty.firstLevel ? __("First Level") : __("Indirect")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{thirdParty.canAssess && (
|
||||
@@ -127,6 +133,9 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
<TabLink to={`${baseThirdPartyUrl}/risks`}>{__("Risk Assessment")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/contacts`}>{__("Contacts")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/services`}>{__("Services")}</TabLink>
|
||||
<TabLink to={`${baseThirdPartyUrl}/third-parties`}>
|
||||
{__("Third Parties")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{ thirdParty }} />
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2025-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 { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||
import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { AddChildThirdPartyDialogCreateMappingMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMappingMutation.graphql";
|
||||
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const createMappingMutation = graphql`
|
||||
mutation AddChildThirdPartyDialogCreateMappingMutation(
|
||||
$input: CreateThirdPartyThirdPartyMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createThirdPartyThirdPartyMapping(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createThirdPartyMutation = graphql`
|
||||
mutation AddChildThirdPartyDialogCreateMutation(
|
||||
$input: CreateThirdPartyInput!
|
||||
) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
parentThirdPartyId: string;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
existingChildIds: string[];
|
||||
};
|
||||
|
||||
export function AddChildThirdPartyDialog({
|
||||
children,
|
||||
parentThirdPartyId,
|
||||
organizationId,
|
||||
connectionId,
|
||||
existingChildIds,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const dialogRef = useDialogRef();
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const [createMapping] = useMutation<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
|
||||
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
||||
|
||||
const debouncedLoadQuery = useDebounceCallback(
|
||||
useCallback(
|
||||
(name: string) => {
|
||||
loadQuery({ name });
|
||||
},
|
||||
[loadQuery],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const handleSearch = (name: string) => {
|
||||
setSearchQuery(name);
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length >= 2) {
|
||||
debouncedLoadQuery(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const existingThirdParties = thirdParties.filter(
|
||||
tp =>
|
||||
tp.id !== parentThirdPartyId
|
||||
&& !existingChildIds.includes(tp.id)
|
||||
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelectExisting = (childId: string) => {
|
||||
createMapping({
|
||||
variables: {
|
||||
input: {
|
||||
parentThirdPartyId,
|
||||
childThirdPartyId: childId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectCommon = (common: Omit<CreateThirdPartyInput, "organizationId">) => {
|
||||
createThirdParty({
|
||||
variables: {
|
||||
input: {
|
||||
...common,
|
||||
organizationId,
|
||||
firstLevel: false,
|
||||
},
|
||||
},
|
||||
onCompleted: (response) => {
|
||||
const newId = response.createThirdParty.thirdPartyEdge.node.id;
|
||||
createMapping({
|
||||
variables: {
|
||||
input: {
|
||||
parentThirdPartyId,
|
||||
childThirdPartyId: newId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
|
||||
<DialogContent className="p-6">
|
||||
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
|
||||
{existingThirdParties.map(tp => (
|
||||
<ComboboxItem key={tp.id} onClick={() => handleSelectExisting(tp.id)}>
|
||||
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
|
||||
{tp.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{searchQuery.trim().length >= 2 && queryRef && (
|
||||
<Suspense>
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={existingNames}
|
||||
onSelect={handleSelectCommon}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogContent>
|
||||
<DialogFooter />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraph
|
||||
export type CommonThirdPartyRef
|
||||
= CommonThirdPartyCombobox_commonThirdParty$data;
|
||||
|
||||
const commonThirdPartyFragment = graphql`
|
||||
export const commonThirdPartyFragment = graphql`
|
||||
fragment CommonThirdPartyCombobox_commonThirdParty on CommonThirdParty @inline {
|
||||
name
|
||||
logoUrl
|
||||
@@ -57,20 +57,44 @@ export const commonThirdPartiesQuery = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
function toCreateInput(tp: CommonThirdPartyRef): Omit<CreateThirdPartyInput, "organizationId"> {
|
||||
return {
|
||||
name: tp.name,
|
||||
headquarterAddress: tp.headquarterAddress,
|
||||
legalName: tp.legalName,
|
||||
websiteUrl: tp.websiteUrl,
|
||||
category: tp.category,
|
||||
privacyPolicyUrl: tp.privacyPolicyUrl,
|
||||
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
|
||||
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
|
||||
certifications: tp.certifications,
|
||||
securityPageUrl: tp.securityPageUrl,
|
||||
trustPageUrl: tp.trustPageUrl,
|
||||
statusPageUrl: tp.statusPageUrl,
|
||||
termsOfServiceUrl: tp.termsOfServiceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
interface CommonThirdPartyComboboxProps {
|
||||
queryRef: PreloadedQuery<CommonThirdPartyComboboxQuery>;
|
||||
onSelect: (thridParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
|
||||
onSelect: (thirdParty: Omit<CreateThirdPartyInput, "organizationId">) => void;
|
||||
excludeNames?: Set<string>;
|
||||
}
|
||||
|
||||
export function CommonThirdPartyCombobox({
|
||||
queryRef,
|
||||
onSelect,
|
||||
excludeNames,
|
||||
}: CommonThirdPartyComboboxProps) {
|
||||
const data = usePreloadedQuery(commonThirdPartiesQuery, queryRef);
|
||||
|
||||
const items = excludeNames
|
||||
? data.commonThirdParties.filter(tp => !excludeNames.has(tp.name.toLowerCase()))
|
||||
: data.commonThirdParties;
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.commonThirdParties.map(thirdParty => (
|
||||
{items.map(thirdParty => (
|
||||
<ComboboxItem
|
||||
key={thirdParty.id}
|
||||
onClick={() => {
|
||||
@@ -78,21 +102,7 @@ export function CommonThirdPartyCombobox({
|
||||
commonThirdPartyFragment,
|
||||
thirdParty,
|
||||
);
|
||||
onSelect({
|
||||
name: tp.name,
|
||||
headquarterAddress: tp.headquarterAddress,
|
||||
legalName: tp.legalName,
|
||||
websiteUrl: tp.websiteUrl,
|
||||
category: tp.category,
|
||||
privacyPolicyUrl: tp.privacyPolicyUrl,
|
||||
serviceLevelAgreementUrl: tp.serviceLevelAgreementUrl,
|
||||
dataProcessingAgreementUrl: tp.dataProcessingAgreementUrl,
|
||||
certifications: tp.certifications,
|
||||
securityPageUrl: tp.securityPageUrl,
|
||||
trustPageUrl: tp.trustPageUrl,
|
||||
statusPageUrl: tp.statusPageUrl,
|
||||
termsOfServiceUrl: tp.termsOfServiceUrl,
|
||||
});
|
||||
onSelect(toCreateInput(tp));
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ComboboxItem,
|
||||
Dialog,
|
||||
@@ -23,17 +25,30 @@ import {
|
||||
useDialogRef,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyDialogPromoteMutation } from "#/__generated__/core/CreateThirdPartyDialogPromoteMutation.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
||||
import { useCreateThirdPartyMutation, useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
import {
|
||||
commonThirdPartiesQuery,
|
||||
CommonThirdPartyCombobox,
|
||||
} from "./CommonThirdPartyCombobox";
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const promoteMutation = graphql`
|
||||
mutation CreateThirdPartyDialogPromoteMutation(
|
||||
$input: UpdateThirdPartyInput!
|
||||
) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
name
|
||||
firstLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -48,12 +63,48 @@ export function CreateThirdPartyDialog({
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const [createThirdParty] = useCreateThirdPartyMutation();
|
||||
const [promoteThirdParty] = useMutation<CreateThirdPartyDialogPromoteMutation>(promoteMutation);
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const dialogRef = useDialogRef();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<CommonThirdPartyComboboxQuery>(commonThirdPartiesQuery);
|
||||
|
||||
const nonFirstLevelByName = new Map(
|
||||
thirdParties
|
||||
.filter(tp => !tp.firstLevel)
|
||||
.map(tp => [tp.name.toLowerCase(), tp]),
|
||||
);
|
||||
|
||||
const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase()));
|
||||
|
||||
const onSelect = async (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | string) => {
|
||||
const name = typeof thirdParty === "string" ? thirdParty : thirdParty.name;
|
||||
const existing = nonFirstLevelByName.get(name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
promoteThirdParty({
|
||||
variables: {
|
||||
input: { id: existing.id, firstLevel: true },
|
||||
},
|
||||
updater: (store) => {
|
||||
const payload = store.getRootField("updateThirdParty");
|
||||
const node = payload?.getLinkedRecord("thirdParty");
|
||||
if (!node) return;
|
||||
|
||||
const connectionRecord = store.get(connection);
|
||||
if (!connectionRecord) return;
|
||||
|
||||
const edge = ConnectionHandler.createEdge(store, connectionRecord, node, "ThirdPartyEdge");
|
||||
ConnectionHandler.insertEdgeBefore(connectionRecord, edge);
|
||||
},
|
||||
onCompleted: () => {
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const input
|
||||
= typeof thirdParty === "string"
|
||||
? {
|
||||
@@ -98,10 +149,26 @@ export function CreateThirdPartyDialog({
|
||||
<Dialog ref={dialogRef} trigger={children} title={__("Add a third party")}>
|
||||
<DialogContent className="p-6">
|
||||
<Combobox onSearch={handleSearch} placeholder={__("Type third party's name")}>
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<>
|
||||
{thirdParties
|
||||
.filter(tp =>
|
||||
!tp.firstLevel
|
||||
&& tp.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
.map(tp => (
|
||||
<ComboboxItem key={tp.id} onClick={() => void onSelect(tp.name)}>
|
||||
<Avatar name={tp.name} src={faviconUrl(tp.websiteUrl)} size="s" />
|
||||
{tp.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{searchQuery.trim().length >= 2 && queryRef && (
|
||||
<Suspense>
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={existingNames}
|
||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2025-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 { faviconUrl, formatDate } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useMutation,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql";
|
||||
import type { ThirdPartyThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartyThirdPartiesPageFragment.graphql";
|
||||
import type { ThirdPartyThirdPartiesPagePaginationQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPagePaginationQuery.graphql";
|
||||
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
||||
|
||||
export const thirdPartyThirdPartiesPageQuery = graphql`
|
||||
query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
id
|
||||
name
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
...ThirdPartyThirdPartiesPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const paginatedFragment = graphql`
|
||||
fragment ThirdPartyThirdPartiesPageFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyThirdPartiesPagePaginationQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
childThirdParties(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyThirdPartiesPageFragment_childThirdParties", filters: []) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
riskAssessments(
|
||||
first: 1
|
||||
orderBy: { direction: DESC, field: CREATED_AT }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
createdAt
|
||||
dataSensitivity
|
||||
businessImpact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteMappingMutation = graphql`
|
||||
mutation ThirdPartyThirdPartiesPageDeleteMappingMutation(
|
||||
$input: DeleteThirdPartyThirdPartyMappingInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyThirdPartyMapping(input: $input) {
|
||||
removedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
queryRef: PreloadedQuery<ThirdPartyThirdPartiesPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
||||
const { node } = usePreloadedQuery(thirdPartyThirdPartiesPageQuery, queryRef);
|
||||
const thirdParty = node.__typename === "ThirdParty" ? node : null;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const pagination = usePaginationFragment<
|
||||
ThirdPartyThirdPartiesPagePaginationQuery,
|
||||
ThirdPartyThirdPartiesPageFragment$key
|
||||
>(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key);
|
||||
const [deleteMapping] = useMutation<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
|
||||
|
||||
usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties"));
|
||||
|
||||
if (!thirdParty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionId = pagination.data.childThirdParties.__id;
|
||||
const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node);
|
||||
|
||||
const handleRemove = (childId: string, childName: string) => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
deleteMapping({
|
||||
variables: {
|
||||
input: {
|
||||
parentThirdPartyId: thirdParty.id,
|
||||
childThirdPartyId: childId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: () => resolve(),
|
||||
onError: err => reject(err),
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: `${__("Remove")} "${childName}" ${__("from this third party?")}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Third Parties")}
|
||||
description={__("Manage third parties linked to this third party.")}
|
||||
>
|
||||
{thirdParty.canUpdate && (
|
||||
<AddChildThirdPartyDialog
|
||||
parentThirdPartyId={thirdParty.id}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
existingChildIds={childThirdParties.map(c => c.id)}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
|
||||
</AddChildThirdPartyDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable
|
||||
refetch={pagination.refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="NAME">{__("Third party")}</SortableTh>
|
||||
<Th>{__("Accessed At")}</Th>
|
||||
<Th>{__("Data Risk")}</Th>
|
||||
<Th>{__("Business Risk")}</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{childThirdParties.map((child) => {
|
||||
const latestAssessment = child.riskAssessments?.edges[0]?.node;
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={child.id}
|
||||
to={`/organizations/${organizationId}/third-parties/${child.id}/overview`}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar name={child.name} src={faviconUrl(child.websiteUrl)} />
|
||||
<div>{child.name}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{latestAssessment?.createdAt
|
||||
? formatDate(latestAssessment.createdAt)
|
||||
: __("Not assessed")}
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{thirdParty.canUpdate && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => handleRemove(child.id, child.name)}
|
||||
/>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2025-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 { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartyThirdPartiesPageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
|
||||
import ThirdPartyThirdPartiesPage, { thirdPartyThirdPartiesPageQuery } from "./ThirdPartyThirdPartiesPage";
|
||||
|
||||
export default function ThirdPartyThirdPartiesPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery] = useQueryLoader<ThirdPartyThirdPartiesPageQuery>(thirdPartyThirdPartiesPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ThirdPartyThirdPartiesPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,14 @@ export const thirdPartyRoutes = [
|
||||
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user