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:
Sacha Al Himdani
2026-05-29 16:22:54 +02:00
parent ec858e58df
commit b6781d3de0
36 changed files with 1276 additions and 1192 deletions

View File

@@ -24,7 +24,7 @@ type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
firstLevel?: boolean;
level?: number;
};
type Props<T extends FieldValues = FieldValues> = {

View File

@@ -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<ThirdPartyGraphCreateMutation>(
createThirdPartyMutation,
{
successMessage: __("Third party created successfully."),
errorMessage: __("Failed to create third party"),
},
);
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(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
}
}
}

View File

@@ -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,
},
});

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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>