From b6781d3de086c67ed318af9c055de03aa0f96f19 Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Fri, 29 May 2026 16:22:54 +0200 Subject: [PATCH] Scope sub-third-parties per parent Replace the many-to-many junction table with a direct parent_third_party_id foreign key on third_parties. Each sub-third-party now belongs to exactly one parent, making duplicates across parents independent entities. Replace the firstLevel boolean with an integer level field (1 = direct, 2+ = parent level + 1) to support arbitrary nesting depth. Remove the createThirdPartyThirdPartyMapping and deleteThirdPartyThirdPartyMapping mutations, the CLI link/unlink commands, and the corresponding MCP tools. Creating a child third party now just requires passing parentThirdPartyId on the existing createThirdParty mutation. The frontend walks the parentThirdParty chain to build display names like "Name (Ancestor1/Ancestor2)" and shows clickable ancestor links on the detail page. Signed-off-by: Sacha Al Himdani --- .../form/ThirdPartiesMultiSelectField.tsx | 2 +- .../src/hooks/graph/ThirdPartyGraph.ts | 27 +- .../_components/LinkedThirdPartiesDialog.tsx | 4 +- .../third-parties/ThirdPartiesPage.tsx | 38 +- .../third-parties/ThirdPartyDetailPage.tsx | 24 +- .../dialogs/AddChildThirdPartyDialog.tsx | 100 ++--- .../dialogs/CreateThirdPartyDialog.tsx | 79 +--- .../ThirdPartyThirdPartiesPage.tsx | 47 +- e2e/console/third_party_relation_test.go | 376 ++++++++-------- .../actions/thirdParty/create.operation.ts | 23 +- .../actions/thirdParty/getAll.operation.ts | 26 +- pkg/cmd/thirdpartymgmt/link/link.go | 108 ----- pkg/cmd/thirdpartymgmt/list/list.go | 22 +- pkg/cmd/thirdpartymgmt/thirdpartymgmt.go | 4 - pkg/cmd/thirdpartymgmt/unlink/unlink.go | 84 ---- pkg/cookiebanner/tracker_mapping_worker.go | 10 +- .../tracker_mapping_worker_test.go | 2 +- pkg/coredata/migrations/20260608T120000Z.sql | 251 +++++++++++ pkg/coredata/third_party.go | 418 ++++++++++++++++-- pkg/coredata/third_party_filter.go | 17 +- pkg/coredata/third_party_third_party.go | 240 ---------- pkg/coredata/third_party_vetting.go | 3 +- pkg/iam/organization_service.go | 1 + pkg/probo/actions.go | 1 - pkg/probo/generated_document_service.go | 10 +- pkg/probo/third_party_service.go | 97 ++-- .../console/v1/graphql/third_party.graphql | 34 +- .../api/console/v1/organization_resolvers.go | 8 +- .../api/console/v1/third_party_resolvers.go | 96 ++-- .../api/console/v1/types/third_party.go | 8 +- pkg/server/api/mcp/v1/schema.resolvers.go | 28 +- pkg/server/api/mcp/v1/specification.yaml | 62 +-- pkg/server/api/mcp/v1/types/third_party.go | 2 +- pkg/thirdparty/match.go | 4 +- pkg/vetting/persist.go | 190 +++++--- pkg/vetting/tools.go | 22 +- 36 files changed, 1276 insertions(+), 1192 deletions(-) delete mode 100644 pkg/cmd/thirdpartymgmt/link/link.go delete mode 100644 pkg/cmd/thirdpartymgmt/unlink/unlink.go create mode 100644 pkg/coredata/migrations/20260608T120000Z.sql delete mode 100644 pkg/coredata/third_party_third_party.go diff --git a/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx b/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx index 11de64ba8..581d00f60 100644 --- a/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx +++ b/apps/console/src/components/form/ThirdPartiesMultiSelectField.tsx @@ -24,7 +24,7 @@ type ThirdParty = { id: string; name: string; websiteUrl: string | null | undefined; - firstLevel?: boolean; + level?: number; }; type Props = { diff --git a/apps/console/src/hooks/graph/ThirdPartyGraph.ts b/apps/console/src/hooks/graph/ThirdPartyGraph.ts index b85db47c3..f4b806c71 100644 --- a/apps/console/src/hooks/graph/ThirdPartyGraph.ts +++ b/apps/console/src/hooks/graph/ThirdPartyGraph.ts @@ -22,8 +22,7 @@ import { graphql } from "relay-runtime"; import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql"; import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql"; import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql"; - -import { useMutationWithToasts } from "../useMutationWithToasts"; +import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; /* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */ @@ -51,14 +50,10 @@ const createThirdPartyMutation = graphql` export function useCreateThirdPartyMutation() { const { __ } = useTranslate(); - - return useMutationWithToasts( - createThirdPartyMutation, - { - successMessage: __("Third party created successfully."), - errorMessage: __("Failed to create third party"), - }, - ); + return useMutationWithToasts(createThirdPartyMutation, { + successMessage: __("Third party created successfully"), + errorMessage: __("Failed to create third party"), + }); } const deleteThirdPartyMutation = graphql` @@ -138,7 +133,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 } } + filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } } ) { thirdParties( first: $first @@ -154,6 +149,7 @@ export const paginatedThirdPartiesFragment = graphql` id name websiteUrl + level updatedAt riskAssessments( first: 1 @@ -184,7 +180,11 @@ export const thirdPartyNodeQuery = graphql` ... on ThirdParty { name websiteUrl - firstLevel + level + ancestors { + id + name + } vettingStatus canVet: permission(action: "core:thirdParty:vet") canUpdate: permission(action: "core:thirdParty:update") @@ -229,14 +229,13 @@ export const thirdPartiesSelectQuery = graphql` thirdParties( first: 100 orderBy: { direction: ASC, field: NAME } - filter: { firstLevel: true } ) { edges { node { id name websiteUrl - firstLevel + level } } } diff --git a/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx index 61cb0e8c8..785d1485f 100644 --- a/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx +++ b/apps/console/src/pages/organizations/measures/_components/LinkedThirdPartiesDialog.tsx @@ -65,7 +65,7 @@ const thirdPartiesFragment = graphql` last: { type: "Int", defaultValue: null } before: { type: "CursorKey", defaultValue: null } order: { type: "ThirdPartyOrder", defaultValue: null } - filter: { type: "ThirdPartyFilter", defaultValue: { firstLevel: true } } + filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } } ) @refetchable(queryName: "LinkedThirdPartiesDialogRefetchQuery") { thirdParties( @@ -159,7 +159,7 @@ function LinkedThirdPartiesDialogContent({ refetch({ first: 20, filter: { - firstLevel: true, + level: 1, query: v, }, }); diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx index 4ab151c04..eca54a646 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartiesPage.tsx @@ -26,15 +26,12 @@ import { IconUpload, PageHeader, RiskBadge, - TabItem, - Tabs, Tbody, Td, Th, Thead, Tr, } from "@probo/ui"; -import { useState, useTransition } from "react"; import { type PreloadedQuery, usePaginationFragment, @@ -61,6 +58,12 @@ import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDial type ThirdParty = NodeOf; +function thirdPartyDisplayName(tp: ThirdParty): string { + // Sub-third-parties are persisted with their fully-qualified name + // (e.g. "aws (Probo/Level2/Level3)"), so the stored name is shown as-is. + return tp.name; +} + type Props = { queryRef: PreloadedQuery; }; @@ -79,21 +82,9 @@ 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(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); @@ -144,20 +135,6 @@ export default function ThirdPartiesPage(props: Props) { )} - - handleFilterChange(true)} - > - {__("First Level")} - - handleFilterChange(null)} - > - {__("All")} - - @@ -198,6 +175,7 @@ function ThirdPartyRow({ const { __ } = useTranslate(); const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node; const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId); + const displayName = thirdPartyDisplayName(thirdParty); const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`; @@ -207,7 +185,7 @@ function ThirdPartyRow({
-
{thirdParty.name}
+
{displayName}
diff --git a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx index 57983938d..dfe40e1ed 100644 --- a/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/ThirdPartyDetailPage.tsx @@ -34,7 +34,7 @@ import { usePreloadedQuery, useRelayEnvironment, } from "react-relay"; -import { Outlet } from "react-router"; +import { Link, Outlet } from "react-router"; import { fetchQuery } from "relay-runtime"; import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql"; @@ -104,6 +104,8 @@ export default function ThirdPartyDetailPage(props: Props) { const isVettingFailed = thirdParty.vettingStatus === "FAILED"; + const ancestors = thirdParty.ancestors ?? []; + return (
{isVetting && ( @@ -142,10 +144,26 @@ export default function ThirdPartyDetailPage(props: Props) { )}
{thirdParty.name}
- - {thirdParty.firstLevel ? __("First Level") : __("Indirect")} + + {`${__("Level")} ${thirdParty.level}`}
+ {ancestors.length > 0 && ( +
+ {__("From:")} + {ancestors.map((ancestor, i) => ( + + {i > 0 && " / "} + + {ancestor.name} + + + ))} +
+ )}
{thirdParty.canVet && !isVetting && ( diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx index 7d22cebc8..b5a773d7a 100644 --- a/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx +++ b/apps/console/src/pages/organizations/third-parties/dialogs/AddChildThirdPartyDialog.tsx @@ -12,15 +12,14 @@ // 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, + IconPlusLarge, useDialogRef, } from "@probo/ui"; import { type ReactNode, Suspense, useCallback, useState } from "react"; @@ -28,20 +27,18 @@ 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! +const createChildMutation = graphql` + mutation AddChildThirdPartyDialogCreateMutation( + $input: CreateThirdPartyInput! $connections: [ID!]! ) { - createThirdPartyThirdPartyMapping(input: $input) { + createThirdParty(input: $input) { thirdPartyEdge @prependEdge(connections: $connections) { node { id @@ -54,40 +51,24 @@ const createMappingMutation = graphql` } `; -const createThirdPartyMutation = graphql` - mutation AddChildThirdPartyDialogCreateMutation( - $input: CreateThirdPartyInput! - ) { - createThirdParty(input: $input) { - thirdPartyEdge { - node { - id - } - } - } - } -`; - type Props = { children: ReactNode; parentThirdPartyId: string; + parentNamePath: string[]; organizationId: string; connectionId: string; - existingChildIds: string[]; }; export function AddChildThirdPartyDialog({ children, parentThirdPartyId, + parentNamePath, organizationId, connectionId, - existingChildIds, }: Props) { const { __ } = useTranslate(); const dialogRef = useDialogRef(); - const thirdParties = useThirdParties(organizationId); - const [createMapping] = useMutation(createMappingMutation); - const [createThirdParty] = useMutation(createThirdPartyMutation); + const [createChild] = useMutation(createChildMutation); const [searchQuery, setSearchQuery] = useState(""); const [queryRef, loadQuery] = useQueryLoader(commonThirdPartiesQuery); @@ -109,19 +90,18 @@ export function AddChildThirdPartyDialog({ } }; - const existingThirdParties = thirdParties.filter( - tp => - tp.id !== parentThirdPartyId - && !existingChildIds.includes(tp.id) - && tp.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); + const handleCreate = (input: Omit) => { + const qualifiedName = parentNamePath.length > 0 + ? `${input.name} (${parentNamePath.join("/")})` + : input.name; - const handleSelectExisting = (childId: string) => { - createMapping({ + void createChild({ variables: { input: { + ...input, + name: qualifiedName, + organizationId, parentThirdPartyId, - childThirdPartyId: childId, }, connections: [connectionId], }, @@ -131,54 +111,32 @@ export function AddChildThirdPartyDialog({ }); }; - const handleSelectCommon = (common: Omit) => { - 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 handleCreateNew = (name: string) => { + handleCreate({ name, category: null }); }; - const existingNames = new Set(thirdParties.map(tp => tp.name.toLowerCase())); - return ( - {existingThirdParties.map(tp => ( - handleSelectExisting(tp.id)}> - - {tp.name} - - ))} {searchQuery.trim().length >= 2 && queryRef && ( )} + {searchQuery.trim().length >= 2 && ( + handleCreateNew(searchQuery.trim())}> + + {__("Create a new third party")} + {" "} + : + {searchQuery} + + )} diff --git a/apps/console/src/pages/organizations/third-parties/dialogs/CreateThirdPartyDialog.tsx b/apps/console/src/pages/organizations/third-parties/dialogs/CreateThirdPartyDialog.tsx index 444a50b79..9302fc436 100644 --- a/apps/console/src/pages/organizations/third-parties/dialogs/CreateThirdPartyDialog.tsx +++ b/apps/console/src/pages/organizations/third-parties/dialogs/CreateThirdPartyDialog.tsx @@ -12,10 +12,8 @@ // 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, @@ -25,31 +23,15 @@ import { useDialogRef, } from "@probo/ui"; import { type ReactNode, Suspense, useCallback, useState } from "react"; -import { useMutation, useQueryLoader } from "react-relay"; -import { ConnectionHandler, graphql } from "relay-runtime"; +import { useQueryLoader } from "react-relay"; 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, useThirdParties } from "#/hooks/graph/ThirdPartyGraph"; +import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph"; import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox"; -const promoteMutation = graphql` - mutation CreateThirdPartyDialogPromoteMutation( - $input: UpdateThirdPartyInput! - ) { - updateThirdParty(input: $input) { - thirdParty { - id - name - firstLevel - } - } - } -`; - type Props = { children: ReactNode; organizationId: string; @@ -63,48 +45,12 @@ export function CreateThirdPartyDialog({ }: Props) { const { __ } = useTranslate(); const [createThirdParty] = useCreateThirdPartyMutation(); - const [promoteThirdParty] = useMutation(promoteMutation); - const thirdParties = useThirdParties(organizationId); const dialogRef = useDialogRef(); const [searchQuery, setSearchQuery] = useState(""); const [queryRef, loadQuery] = useQueryLoader(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 | 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 onSelect = (thirdParty: Omit | string) => { const input = typeof thirdParty === "string" ? { @@ -116,7 +62,7 @@ export function CreateThirdPartyDialog({ ...thirdParty, organizationId, }; - await createThirdParty({ + void createThirdParty({ variables: { input, connections: [connection], @@ -149,26 +95,11 @@ export function CreateThirdPartyDialog({ - {searchQuery.trim().length >= 2 && ( - <> - {thirdParties - .filter(tp => - !tp.firstLevel - && tp.name.toLowerCase().includes(searchQuery.toLowerCase()), - ) - .map(tp => ( - void onSelect(tp.name)}> - - {tp.name} - - ))} - - )} {searchQuery.trim().length >= 2 && queryRef && ( void onSelect(thirdPartyRef)} /> diff --git a/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx index 643b1b360..dc474b9e3 100644 --- a/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx +++ b/apps/console/src/pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPage.tsx @@ -38,7 +38,7 @@ import { } from "react-relay"; import { graphql } from "relay-runtime"; -import type { ThirdPartyThirdPartiesPageDeleteMappingMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMappingMutation.graphql"; +import type { ThirdPartyThirdPartiesPageDeleteMutation } from "#/__generated__/core/ThirdPartyThirdPartiesPageDeleteMutation.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"; @@ -47,6 +47,9 @@ import { useOrganizationId } from "#/hooks/useOrganizationId"; import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog"; +// Keep in sync with coredata.MaxThirdPartyLevel on the backend. +const MAX_THIRD_PARTY_LEVEL = 4; + export const thirdPartyThirdPartiesPageQuery = graphql` query ThirdPartyThirdPartiesPageQuery($thirdPartyId: ID!) { node(id: $thirdPartyId) { @@ -54,6 +57,10 @@ export const thirdPartyThirdPartiesPageQuery = graphql` ... on ThirdParty { id name + level + ancestors { + name + } canUpdate: permission(action: "core:thirdParty:update") ...ThirdPartyThirdPartiesPageFragment } @@ -103,13 +110,13 @@ const paginatedFragment = graphql` } `; -const deleteMappingMutation = graphql` - mutation ThirdPartyThirdPartiesPageDeleteMappingMutation( - $input: DeleteThirdPartyThirdPartyMappingInput! +const deleteChildMutation = graphql` + mutation ThirdPartyThirdPartiesPageDeleteMutation( + $input: DeleteThirdPartyInput! $connections: [ID!]! ) { - deleteThirdPartyThirdPartyMapping(input: $input) { - removedThirdPartyId @deleteEdge(connections: $connections) + deleteThirdParty(input: $input) { + deletedThirdPartyId @deleteEdge(connections: $connections) } } `; @@ -129,7 +136,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) { ThirdPartyThirdPartiesPagePaginationQuery, ThirdPartyThirdPartiesPageFragment$key >(paginatedFragment, thirdParty as ThirdPartyThirdPartiesPageFragment$key); - const [deleteMapping] = useMutation(deleteMappingMutation); + const [deleteChild] = useMutation(deleteChildMutation); usePageTitle((thirdParty?.name ?? "") + " - " + __("Third Parties")); @@ -140,16 +147,22 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) { const connectionId = pagination.data.childThirdParties.__id; const childThirdParties = pagination.data.childThirdParties.edges.map(edge => edge.node); - const handleRemove = (childId: string, childName: string) => { + // Strip any existing " (...)" suffix so the chain stays clean even when a + // parent's stored name is itself already qualified. + const baseName = (name: string) => name.replace(/\s*\([^)]*\)\s*$/, ""); + const parentAncestors = thirdParty.ancestors ?? []; + const parentNamePath = [ + ...parentAncestors.map(ancestor => baseName(ancestor.name)), + baseName(thirdParty.name), + ]; + + const handleDelete = (childId: string, childName: string) => { confirm( () => new Promise((resolve, reject) => { - deleteMapping({ + deleteChild({ variables: { - input: { - parentThirdPartyId: thirdParty.id, - childThirdPartyId: childId, - }, + input: { thirdPartyId: childId }, connections: [connectionId], }, onCompleted: () => resolve(), @@ -157,7 +170,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) { }); }), { - message: `${__("Remove")} "${childName}" ${__("from this third party?")}`, + message: `${__("Delete")} "${childName}"?`, }, ); }; @@ -168,12 +181,12 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) { title={__("Third Parties")} description={__("Manage third parties linked to this third party.")} > - {thirdParty.canUpdate && ( + {thirdParty.canUpdate && thirdParty.level < MAX_THIRD_PARTY_LEVEL && ( c.id)} > @@ -223,7 +236,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {