From 9f40d65eac89a458e6aee445a6d9136f3b3157a9 Mon Sep 17 00:00:00 2001 From: Iddo Date: Mon, 13 Jul 2026 18:48:30 +0200 Subject: [PATCH 1/2] fix(frontend): keep IPAM namespace context on IP links from namespace relationship tabs (closes #9892) IP prefix/address links on a namespace object's relationship tabs went through the generic object-relationship table, which builds links via getObjectDetailsUrl without any namespace context. On those pages the active namespace lives in the route path, not the `namespace` query param that IPAM links rely on, so the links dropped it and the target resolved to the default namespace. Thread an optional identifier override param through the relationship table's column builder and inject `namespace=` when the relationship's parent is an IP namespace. --- changelog/9892.fixed.md | 1 + .../cells/table-identifier-cell.tsx | 5 +- .../utils/get-object-table-columns.tsx | 10 ++-- .../relationship-table/relationship-table.tsx | 14 +++++- frontend/app/src/entities/nodes/utils.test.ts | 49 +++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 changelog/9892.fixed.md create mode 100644 frontend/app/src/entities/nodes/utils.test.ts diff --git a/changelog/9892.fixed.md b/changelog/9892.fixed.md new file mode 100644 index 00000000000..cc3d759dcdb --- /dev/null +++ b/changelog/9892.fixed.md @@ -0,0 +1 @@ +Following an IP prefix or address link from an IPAM namespace's relationship tabs now keeps you in that namespace, instead of redirecting to the default namespace. diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-identifier-cell.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-identifier-cell.tsx index 16bb898b91a..8dec38d8f25 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-identifier-cell.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-identifier-cell.tsx @@ -1,6 +1,7 @@ import { LinkButton } from "@infrahub/ui"; import type { PressEvent } from "react-aria-components"; +import type { overrideQueryParams } from "@/shared/api/rest/fetch"; import { Checkbox } from "@/shared/components/aria/checkbox"; import { useAuth } from "@/entities/authentication/ui/useAuth"; @@ -13,6 +14,7 @@ export interface TableIdentifierCellProps { label: React.ReactNode; isSelected?: boolean; onClickCheckbox?: (e: PressEvent) => void; + overrideParams?: overrideQueryParams[]; } export function TableIdentifierCell({ @@ -21,6 +23,7 @@ export function TableIdentifierCell({ label, isSelected, onClickCheckbox, + overrideParams, }: TableIdentifierCellProps) { const { isAuthenticated } = useAuth(); return ( @@ -36,7 +39,7 @@ export function TableIdentifierCell({ {label} diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/utils/get-object-table-columns.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/utils/get-object-table-columns.tsx index 8fff8bbeff5..0a6b5a0d638 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/utils/get-object-table-columns.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/utils/get-object-table-columns.tsx @@ -1,6 +1,7 @@ import type { PopoverTriggerProps } from "@radix-ui/react-popover"; import { type ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import type { overrideQueryParams } from "@/shared/api/rest/fetch"; import { FROM_RESOURCE_POOL_SUFFIX } from "@/shared/components/form/constants"; import { TableCell } from "@/shared/components/table/table-cell"; import { sortByOrderWeight } from "@/shared/utils/common"; @@ -34,7 +35,8 @@ import { isGenericSchema } from "@/entities/schema/utils/is-generic-schema"; const columnHelper = createColumnHelper(); export function getObjectIdentifierColumns( - schema: ModelSchema + schema: ModelSchema, + identifierOverrideParams?: overrideQueryParams[] ): Array> { return [ columnHelper.accessor((node) => getNodeLabel(node), { @@ -59,6 +61,7 @@ export function getObjectIdentifierColumns( label={label} isSelected={row.getIsSelected()} onClickCheckbox={getToggleSelectedRowHandler({ row, table })} + overrideParams={identifierOverrideParams} /> ); }, @@ -145,10 +148,11 @@ export function getObjectFieldsColumns( export const getObjectTableColumns = ( schema: ModelSchema, - headerProps?: PopoverTriggerProps + headerProps?: PopoverTriggerProps, + identifierOverrideParams?: overrideQueryParams[] ): Array> => { return [ - ...getObjectIdentifierColumns(schema), + ...getObjectIdentifierColumns(schema, identifierOverrideParams), ...getObjectGenericColumns(schema), ...getObjectFieldsColumns(schema, headerProps), ] as Array>; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-table/relationship-table.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-table/relationship-table.tsx index b02f58c8910..d9e96138528 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-table/relationship-table.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-table/relationship-table.tsx @@ -3,6 +3,7 @@ import { DataTable } from "@/shared/components/table/data-table"; import { InfiniteScroll } from "@/shared/components/utils/infinite-scroll"; import useFilters from "@/shared/hooks/useFilters"; +import { IP_NAMESPACE_GENERIC, IPAM_QSP } from "@/entities/ipam/constants"; import { ObjectTableEmpty } from "@/entities/nodes/object/ui/object-table/object-table-empty"; import { getObjectTableColumns } from "@/entities/nodes/object/ui/object-table/utils/get-object-table-columns"; import { @@ -15,6 +16,7 @@ import { ToolbarDissociateAction } from "@/entities/nodes/relationships/ui/relat import { canDissociateRelationship } from "@/entities/nodes/relationships/utils/can-dissociate-relationship"; import { useGetObjectPermissions } from "@/entities/permission/ui/queries/get-object-permissions.query"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; +import { isOfKind } from "@/entities/schema/utils/is-of-kind"; export interface RelationshipTableProps extends UseObjectRelationshipsParams {} @@ -49,8 +51,18 @@ export function RelationshipTable({ } const flatData = data?.pages?.flat() ?? []; + + // On an IP namespace's relationship tabs (e.g. its prefixes/addresses), the active + // namespace lives in the route path, not the `namespace` query param that IPAM links + // rely on. Forward it explicitly so child IP links stay in this namespace instead of + // falling back to the default one. + const identifierOverrideParams = + parentSchema && isOfKind(IP_NAMESPACE_GENERIC, parentSchema) + ? [{ name: IPAM_QSP.NAMESPACE, value: parentId }] + : undefined; + const columns = [ - ...getObjectTableColumns(relationshipSchema, { disabled: true }), + ...getObjectTableColumns(relationshipSchema, { disabled: true }, identifierOverrideParams), getRelationshipActionsColumn({ parentId, parentKind, diff --git a/frontend/app/src/entities/nodes/utils.test.ts b/frontend/app/src/entities/nodes/utils.test.ts new file mode 100644 index 00000000000..037ce8d3c79 --- /dev/null +++ b/frontend/app/src/entities/nodes/utils.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { store } from "@/shared/stores"; + +import { IP_PREFIX_GENERIC, IPAM_QSP } from "@/entities/ipam/constants"; +import { getObjectDetailsUrl } from "@/entities/nodes/utils"; +import { + genericSchemasAtom, + nodeSchemasAtom, + profileSchemasAtom, + templateSchemasAtom, +} from "@/entities/schema/stores/schema.atom"; + +import { generateNodeSchema } from "../../../tests/fake/schema"; + +const ipPrefixSchema = generateNodeSchema({ + kind: "BuiltinIPPrefix", + name: "IPPrefix", + namespace: "Builtin", + inherit_from: [IP_PREFIX_GENERIC], +}); + +describe("getObjectDetailsUrl", () => { + afterEach(() => { + store.set(nodeSchemasAtom, []); + store.set(genericSchemasAtom, []); + store.set(profileSchemasAtom, []); + store.set(templateSchemasAtom, []); + }); + + it("keeps the namespace context for an IP prefix link when passed as an override param", () => { + store.set(nodeSchemasAtom, [ipPrefixSchema]); + + const url = getObjectDetailsUrl("BuiltinIPPrefix", "prefix-1", [ + { name: IPAM_QSP.NAMESPACE, value: "namespace-1" }, + ]); + + expect(url).toContain("/ipam/BuiltinIPPrefix/prefix-1"); + expect(url).toContain(`${IPAM_QSP.NAMESPACE}=namespace-1`); + }); + + it("does not add a namespace param to an IP prefix link when none is provided", () => { + store.set(nodeSchemasAtom, [ipPrefixSchema]); + + const url = getObjectDetailsUrl("BuiltinIPPrefix", "prefix-1"); + + expect(url).toBe("/ipam/BuiltinIPPrefix/prefix-1"); + }); +}); From 231f2930bb0efde4cddd0b7f973169f70313b293 Mon Sep 17 00:00:00 2001 From: Iddo Date: Mon, 13 Jul 2026 18:56:28 +0200 Subject: [PATCH 2/2] ci: reword changelog fragment to satisfy vale spelling check --- changelog/9892.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/9892.fixed.md b/changelog/9892.fixed.md index cc3d759dcdb..3330a8bd45e 100644 --- a/changelog/9892.fixed.md +++ b/changelog/9892.fixed.md @@ -1 +1 @@ -Following an IP prefix or address link from an IPAM namespace's relationship tabs now keeps you in that namespace, instead of redirecting to the default namespace. +Following an IP prefix or address link from the relationship tabs of an IPAM namespace now keeps you in that namespace, instead of redirecting to the default namespace.