Skip to content

Commit ff55965

Browse files
authored
Wrap IPAM UI with IpNamespaceProvider to ensure correct IP namespace logic and data (#6528)
* integrate namespace into IPAM left panel * sticky tabs * add ip namespace provider
1 parent c43add3 commit ff55965

File tree

16 files changed

+247
-189
lines changed

16 files changed

+247
-189
lines changed

frontend/app/src/app/router.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,19 @@ export const router = createBrowserRouter([
378378
},
379379
},
380380
},
381+
{
382+
path: "/ipam/namespaces",
383+
lazy: () => import("@/pages/ipam/namespaces/ip-namespace-list-page"),
384+
handle: {
385+
breadcrumb: () => {
386+
return {
387+
type: "link",
388+
label: "namespaces",
389+
to: constructPath("/ipam/namespaces"),
390+
} satisfies BreadcrumbItem;
391+
},
392+
},
393+
},
381394
{
382395
path: IPAM_ROUTE.INDEX,
383396
lazy: () => import("@/pages/ipam/layout"),
@@ -387,27 +400,14 @@ export const router = createBrowserRouter([
387400
type: "link",
388401
label: "IP Address Manager",
389402
to: constructPathForIpam("/ipam"),
390-
};
403+
} as BreadcrumbItem;
391404
},
392405
},
393406
children: [
394407
{
395408
index: true,
396409
lazy: () => import("@/entities/ipam/ipam-router"),
397410
},
398-
{
399-
path: "namespaces",
400-
lazy: () => import("@/pages/ipam/namespaces/ip-namespace-list-page"),
401-
handle: {
402-
breadcrumb: () => {
403-
return {
404-
type: "link",
405-
label: "namespaces",
406-
to: constructPath("/ipam/namespaces"),
407-
} satisfies BreadcrumbItem;
408-
},
409-
},
410-
},
411411
{
412412
path: IPAM_ROUTE.ADDRESSES,
413413
lazy: () => import("@/entities/ipam/ipam-router"),

frontend/app/src/entities/ipam/api/prefixes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { gql } from "@apollo/client";
22

33
export const GET_PREFIXES_ONLY = gql`
4-
query GET_PREFIXES_ONLY($parentIds: [ID!], $search: String) {
5-
BuiltinIPPrefix(parent__ids: $parentIds, any__value: $search, partial_match: true) {
4+
query GET_PREFIXES_ONLY($parentIds: [ID!], $search: String, $ipNamespaceIds: [ID!]) {
5+
BuiltinIPPrefix(parent__ids: $parentIds, any__value: $search, partial_match: true, ip_namespace__ids: $ipNamespaceIds) {
66
edges {
77
node {
88
id

frontend/app/src/entities/ipam/common/namespace.state.ts

Lines changed: 0 additions & 3 deletions
This file was deleted.

frontend/app/src/entities/ipam/ip-addresses/ipam-ip-address-list.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { DEFAULT_BRANCH_NAME } from "@/config/constants";
22
import { currentBranchAtom } from "@/entities/branches/stores";
33
import { GET_IP_ADDRESSES } from "@/entities/ipam/api/ip-address";
44
import { GET_PREFIX_KIND } from "@/entities/ipam/api/prefixes";
5-
import { defaultIpNamespaceAtom } from "@/entities/ipam/common/namespace.state";
65
import { constructPathForIpam } from "@/entities/ipam/common/utils";
76
import {
87
IPAM_QSP,
@@ -11,6 +10,7 @@ import {
1110
IP_ADDRESS_GENERIC,
1211
IP_PREFIX_GENERIC,
1312
} from "@/entities/ipam/constants";
13+
import { useCurrentIpNamespace } from "@/entities/ipam/namespaces/ui/ip-namespace-provider";
1414
import { deleteObject } from "@/entities/nodes/api/deleteObject";
1515
import ObjectItemEditComponent from "@/entities/nodes/object-item-edit/object-item-edit-paginated";
1616
import { getPermission } from "@/entities/permission/utils";
@@ -32,15 +32,13 @@ import { useAtomValue } from "jotai";
3232
import { forwardRef, useImperativeHandle, useState } from "react";
3333
import { useParams } from "react-router";
3434
import { toast } from "react-toastify";
35-
import { StringParam, useQueryParam } from "use-query-params";
3635

3736
const IpamIPAddressesList = forwardRef((_, ref) => {
3837
const { prefix } = useParams();
39-
const [namespace] = useQueryParam(IPAM_QSP.NAMESPACE, StringParam);
4038
const [isLoading, setIsLoading] = useState(false);
4139
const branch = useAtomValue(currentBranchAtom);
42-
const defaultIpNamespace = useAtomValue(defaultIpNamespaceAtom);
4340
const date = useAtomValue(datetimeAtom);
41+
const { currentIpNamespace } = useCurrentIpNamespace();
4442
const [relatedRowToDelete, setRelatedRowToDelete] = useState();
4543
const [relatedObjectToEdit, setRelatedObjectToEdit] = useState();
4644

@@ -55,16 +53,15 @@ const IpamIPAddressesList = forwardRef((_, ref) => {
5553
const { loading, error, data, refetch } = useQuery(GET_IP_ADDRESSES, {
5654
variables: {
5755
prefixIds: prefix ? [prefix] : null,
58-
namespaces: namespace ? [namespace] : [defaultIpNamespace],
56+
namespaces: currentIpNamespace.id,
5957
},
60-
skip: !defaultIpNamespace,
6158
});
6259

6360
const permission = getPermission(data?.[IP_ADDRESS_GENERIC]?.permissions?.edges);
6461

6562
const { data: getPrefixKindData } = useQuery(GET_PREFIX_KIND, {
6663
variables: { ids: [prefix] },
67-
skip: !prefix || !defaultIpNamespace,
64+
skip: !prefix,
6865
});
6966

7067
const prefixData = getPrefixKindData?.[IP_PREFIX_GENERIC]?.edges?.[0]?.node;
@@ -165,7 +162,7 @@ const IpamIPAddressesList = forwardRef((_, ref) => {
165162
</div>
166163
)}
167164

168-
{(loading || !defaultIpNamespace) && <LoadingIndicator />}
165+
{loading && <LoadingIndicator />}
169166

170167
{data && (
171168
<Table
Lines changed: 95 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,118 @@
1-
import { GET_IP_NAMESPACES } from "@/entities/ipam/api/ip-namespaces";
2-
import { IpamNamespace } from "@/shared/api/graphql/generated/graphql";
3-
import useQuery from "@/shared/api/graphql/useQuery";
1+
import { useGetIpNamespaceList } from "@/entities/ipam/namespaces/domain/get-ip-namespace-list.query";
2+
import { useCurrentIpNamespace } from "@/entities/ipam/namespaces/ui/ip-namespace-provider";
3+
import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label";
44
import { constructPath } from "@/shared/api/rest/fetch";
5+
import { Popover, PopoverTrigger } from "@/shared/components/aria/popover";
56
import { LinkButton } from "@/shared/components/buttons/button-primitive";
6-
import { Col } from "@/shared/components/container";
7-
import { Skeleton } from "@/shared/components/skeleton";
8-
import {
9-
Combobox,
10-
ComboboxContent,
11-
ComboboxItem,
12-
ComboboxList,
13-
ComboboxTrigger,
14-
} from "@/shared/components/ui/combobox";
15-
import { Icon } from "@iconify-icon/react";
16-
import { useSetAtom } from "jotai";
17-
import { useEffect, useId } from "react";
18-
import { useNavigate, useParams } from "react-router";
19-
import { StringParam, useQueryParam } from "use-query-params";
20-
import { defaultIpNamespaceAtom } from "./common/namespace.state";
21-
import { constructPathForIpam } from "./common/utils";
22-
import { IPAM_QSP, IPAM_ROUTE, IPAM_TABS, NAMESPACE_GENERIC } from "./constants";
7+
import { Col, Row } from "@/shared/components/container";
8+
import ErrorScreen from "@/shared/components/errors/error-screen";
9+
import { ComboboxEmpty, ComboboxItem, ComboboxList } from "@/shared/components/ui/combobox";
10+
import { Spinner } from "@/shared/components/ui/spinner";
11+
import { focusVisibleStyle } from "@/shared/components/ui/style";
12+
import { classNames, debounce } from "@/shared/utils/common";
13+
import { ChevronsUpDownIcon } from "lucide-react";
14+
import React from "react";
15+
import { Button as AriaButton } from "react-aria-components";
2316

24-
export default function IpNamespaceSelector() {
25-
const { loading, data, error } = useQuery(GET_IP_NAMESPACES);
17+
interface IpNamespaceSelectorProps {
18+
className?: string;
19+
}
2620

27-
if (loading) {
28-
return <Skeleton className="h-10 w-80" />;
29-
}
21+
export default function IpNamespaceSelector({ className }: IpNamespaceSelectorProps) {
22+
const { currentIpNamespace } = useCurrentIpNamespace();
3023

31-
if (error) {
32-
return null;
33-
}
24+
return (
25+
<div className={classNames("flex gap-2 items-center", className)}>
26+
<PopoverTrigger>
27+
<AriaButton
28+
data-testid="namespace-select"
29+
className={classNames(
30+
focusVisibleStyle,
31+
"flex flex-col w-full rounded-md p-1 m-1",
32+
"border border-transparent",
33+
"hover:bg-gray-100"
34+
)}
35+
>
36+
<Row className="text-xs text-gray-600">IP Namespace</Row>
37+
<Row className="text-sm">
38+
{getNodeLabel(currentIpNamespace)}
39+
<ChevronsUpDownIcon className="ml-auto text-gray-600 size-3.5" />
40+
</Row>
41+
</AriaButton>
3442

35-
const namespaces = data?.[NAMESPACE_GENERIC]?.edges.map((edge: any) => edge.node) ?? [];
43+
<Popover placement="bottom start">
44+
<IpNamespaceComboboxList />
3645

37-
return <IpNamespaceSelectorContent namespaces={namespaces} />;
46+
<Col className="border-t border-neutral-200">
47+
<LinkButton
48+
to={constructPath("/ipam/namespaces")}
49+
variant="ghost"
50+
size="sm"
51+
className="text-xs justify-start m-2"
52+
>
53+
View all IP namespaces
54+
</LinkButton>
55+
</Col>
56+
</Popover>
57+
</PopoverTrigger>
58+
</div>
59+
);
3860
}
3961

40-
type IpNamespaceSelectorContentProps = {
41-
namespaces: Array<IpamNamespace>;
42-
};
43-
44-
const IpNamespaceSelectorContent = ({ namespaces }: IpNamespaceSelectorContentProps) => {
45-
const { prefix, ip_address } = useParams();
46-
const navigate = useNavigate();
47-
const [ipamTab] = useQueryParam(IPAM_QSP.TAB, StringParam);
48-
const [namespaceQSP, setNamespaceQSP] = useQueryParam(IPAM_QSP.NAMESPACE, StringParam);
49-
const setDefaultIpNamespace = useSetAtom(defaultIpNamespaceAtom);
50-
const selectedNamespace = namespaces.find((result) => result.id === namespaceQSP);
51-
const defaultNamespace = namespaces.find((result) => result.default?.value === true);
52-
const currentNamespace = selectedNamespace || defaultNamespace;
53-
const id = useId();
62+
function IpNamespaceComboboxList({ ...props }) {
63+
const [search, setSearch] = React.useState("");
64+
const { currentIpNamespace, setCurrentIpNamespace } = useCurrentIpNamespace();
65+
const { isPending, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
66+
useGetIpNamespaceList({
67+
filters: search ? [{ name: "any__value", value: search }] : undefined,
68+
});
5469

55-
useEffect(() => {
56-
if (defaultNamespace) {
57-
setDefaultIpNamespace(defaultNamespace.id);
58-
}
59-
}, []);
60-
61-
const handleNamespaceChange = (newValue: IpamNamespace) => {
62-
if (!newValue.id || newValue.id === defaultNamespace?.id) {
63-
setNamespaceQSP(undefined); // Removes QSP for default namespace
64-
} else {
65-
setNamespaceQSP(newValue.id);
66-
}
70+
if (error) {
71+
return <ErrorScreen message={error.message} />;
72+
}
6773

68-
if (prefix || ip_address) {
69-
// Redirects to main lists on namespace switch
70-
if (ipamTab === IPAM_TABS.IP_DETAILS) {
71-
// Redirects to main IP Addresses view
72-
navigate(constructPathForIpam(IPAM_ROUTE.ADDRESSES));
73-
} else {
74-
// Redirects to main Prefixes view
75-
navigate(constructPathForIpam(IPAM_ROUTE.PREFIXES));
76-
}
77-
}
78-
};
74+
const setSearchDebounced = debounce(setSearch, 300);
7975

8076
return (
81-
<div className="flex gap-2 items-center">
82-
<Icon icon="mdi:chevron-right" />
83-
<label htmlFor={id}>Namespace</label>
77+
<ComboboxList
78+
onValueChange={(newValue) => setSearchDebounced(newValue)}
79+
shouldFilter={false}
80+
{...props}
81+
>
82+
{isPending ? (
83+
<Spinner className="flex justify-center m-2" />
84+
) : (
85+
<>
86+
<ComboboxEmpty>No IP namespace found</ComboboxEmpty>
8487

85-
<Combobox>
86-
<ComboboxTrigger id={id} data-testid="namespace-select">
87-
{selectedNamespace?.display_label ?? defaultNamespace?.display_label}
88-
</ComboboxTrigger>
89-
90-
<ComboboxContent align="start" fitTriggerWidth={false}>
91-
<ComboboxList className="max-w-md">
92-
{namespaces.map((namespace) => (
88+
{data.pages.map((page) => {
89+
return page.map((namespace) => (
9390
<ComboboxItem
9491
key={namespace.id}
9592
value={namespace.id}
96-
selectedValue={currentNamespace?.id}
97-
onSelect={() => handleNamespaceChange(namespace)}
93+
selectedValue={currentIpNamespace.id}
94+
onSelect={() => setCurrentIpNamespace(namespace)}
9895
>
9996
<div className="overflow-hidden">
100-
<div className="truncate font-semibold">{namespace.display_label}</div>
97+
<div className="truncate font-semibold">{getNodeLabel(namespace)}</div>
10198
<p className="text-xs truncate text-gray-500">{namespace.description?.value}</p>
10299
</div>
103100
</ComboboxItem>
104-
))}
105-
</ComboboxList>
106-
<Col className="border-t border-neutral-200">
107-
<LinkButton
108-
to={constructPath("/ipam/namespaces")}
109-
variant="ghost"
110-
size="sm"
111-
className="text-xs justify-start m-2"
112-
>
113-
View all IP namespaces
114-
</LinkButton>
115-
</Col>
116-
</ComboboxContent>
117-
</Combobox>
118-
</div>
101+
));
102+
})}
103+
</>
104+
)}
105+
106+
{hasNextPage && (
107+
<ComboboxItem
108+
value="Load more"
109+
onSelect={() => fetchNextPage()}
110+
disabled={!hasNextPage || isFetchingNextPage}
111+
className="justify-center text-custom-blue-700"
112+
>
113+
{isFetchingNextPage ? "Loading more..." : "Load more"}
114+
</ComboboxItem>
115+
)}
116+
</ComboboxList>
119117
);
120-
};
118+
}

frontend/app/src/entities/ipam/ipam-router.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DEFAULT_BRANCH_NAME } from "@/config/constants";
22
import { currentBranchAtom } from "@/entities/branches/stores";
3+
import { useCurrentIpNamespace } from "@/entities/ipam/namespaces/ui/ip-namespace-provider";
34
import { getPermission } from "@/entities/permission/utils";
45
import { genericSchemasAtom, nodeSchemasAtom } from "@/entities/schema/stores/schema.atom";
56
import useQuery from "@/shared/api/graphql/useQuery";
@@ -17,7 +18,6 @@ import { useRef, useState } from "react";
1718
import { useNavigate, useParams } from "react-router";
1819
import { StringParam, useQueryParam } from "use-query-params";
1920
import { getObjectPermissionsQuery } from "../permission/queries/getObjectPermissions";
20-
import { defaultIpNamespaceAtom } from "./common/namespace.state";
2121
import {
2222
IPAM_QSP,
2323
IPAM_ROUTE,
@@ -41,8 +41,7 @@ function IpamRouter() {
4141
const branch = useAtomValue(currentBranchAtom);
4242
const schemaList = useAtomValue(nodeSchemasAtom);
4343
const genericList = useAtomValue(genericSchemasAtom);
44-
const [namespace] = useQueryParam(IPAM_QSP.NAMESPACE, StringParam);
45-
const defaultIpNamespace = useAtomValue(defaultIpNamespaceAtom);
44+
const { currentIpNamespace } = useCurrentIpNamespace();
4645
const reloadIpamTree = useSetAtom(reloadIpamTreeAtom);
4746
const refetchRef = useRef(null);
4847
const [showCreateDrawer, setShowCreateDrawer] = useState(false);
@@ -190,10 +189,7 @@ function IpamRouter() {
190189
refetchRef?.current?.refetch();
191190
setShowCreateDrawer(false);
192191

193-
const currentIpNamespace = namespace ?? defaultIpNamespace;
194-
if (currentIpNamespace) {
195-
reloadIpamTree(currentIpNamespace, prefix);
196-
}
192+
reloadIpamTree(currentIpNamespace.id, prefix);
197193
}}
198194
onCancel={() => setShowCreateDrawer(false)}
199195
/>

0 commit comments

Comments
 (0)