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 <sacha@getprobo.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
|
||||
|
||||
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<ThirdPartyGraphListQuery>;
|
||||
};
|
||||
@@ -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<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);
|
||||
|
||||
@@ -144,20 +135,6 @@ 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>
|
||||
@@ -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({
|
||||
<Td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
||||
<div>{thirdParty.name}</div>
|
||||
<div>{displayName}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{isVetting && (
|
||||
@@ -142,10 +144,26 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
)}
|
||||
<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 variant={thirdParty.level === 1 ? "info" : "neutral"}>
|
||||
{`${__("Level")} ${thirdParty.level}`}
|
||||
</Badge>
|
||||
</div>
|
||||
{ancestors.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-sm text-txt-secondary">
|
||||
<span className="text-txt-tertiary">{__("From:")}</span>
|
||||
{ancestors.map((ancestor, i) => (
|
||||
<span key={ancestor.id}>
|
||||
{i > 0 && " / "}
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/third-parties/${ancestor.id}/overview`}
|
||||
className="text-txt-primary underline hover:no-underline"
|
||||
>
|
||||
{ancestor.name}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{thirdParty.canVet && !isVetting && (
|
||||
|
||||
@@ -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<AddChildThirdPartyDialogCreateMappingMutation>(createMappingMutation);
|
||||
const [createThirdParty] = useMutation<AddChildThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
||||
const [createChild] = useMutation<AddChildThirdPartyDialogCreateMutation>(createChildMutation);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery] = useQueryLoader<CommonThirdPartyComboboxQuery>(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<CreateThirdPartyInput, "organizationId">) => {
|
||||
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<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 handleCreateNew = (name: string) => {
|
||||
handleCreate({ name, category: null });
|
||||
};
|
||||
|
||||
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}
|
||||
excludeNames={new Set()}
|
||||
onSelect={handleCreate}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<ComboboxItem onClick={() => handleCreateNew(searchQuery.trim())}>
|
||||
<IconPlusLarge size={20} />
|
||||
{__("Create a new third party")}
|
||||
{" "}
|
||||
:
|
||||
{searchQuery}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogContent>
|
||||
<DialogFooter />
|
||||
|
||||
@@ -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<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 onSelect = (thirdParty: Omit<CreateThirdPartyInput, "organizationId"> | 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({
|
||||
<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}
|
||||
excludeNames={new Set()}
|
||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -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<ThirdPartyThirdPartiesPageDeleteMappingMutation>(deleteMappingMutation);
|
||||
const [deleteChild] = useMutation<ThirdPartyThirdPartiesPageDeleteMutation>(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<void>((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 && (
|
||||
<AddChildThirdPartyDialog
|
||||
parentThirdPartyId={thirdParty.id}
|
||||
parentNamePath={parentNamePath}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
existingChildIds={childThirdParties.map(c => c.id)}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add third party")}</Button>
|
||||
</AddChildThirdPartyDialog>
|
||||
@@ -223,7 +236,7 @@ export default function ThirdPartyThirdPartiesPage({ queryRef }: Props) {
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={IconTrashCan}
|
||||
onClick={() => handleRemove(child.id, child.name)}
|
||||
onClick={() => handleDelete(child.id, child.name)}
|
||||
/>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
Reference in New Issue
Block a user