Skip to content

Commit bb2a467

Browse files
upcoming: [UIE-9564] - Implement Listener Detail - Nodes table (#13147)
* upcoming: [UIE-9564] - Implement Listener Detail - Nodes table. * Added changeset: Implement Nodes table in listener detail page * Address review comments. * Fix unit test.
1 parent 6d8b9c4 commit bb2a467

File tree

7 files changed

+480
-9
lines changed

7 files changed

+480
-9
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Implement Nodes table in Network LoadBalancer Listener detail page ([#13147](https://github.com/linode/manager/pull/13147))

packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersDetail/NetworkLoadBalancersListenerDetail/NetworkLoadBalancersListenerDetail.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle';
1010
import { EntityDetail } from 'src/components/EntityDetail/EntityDetail';
1111
import { LandingHeader } from 'src/components/LandingHeader';
1212

13+
import { NLB_API_DOCS_LINK } from '../../constants';
1314
import { NetworkLoadBalancersListenerDetailBody } from './NetworkLoadBalancersListenerDetailBody';
1415
import { NetworkLoadBalancersListenerDetailHeader } from './NetworkLoadBalancersListenerDetailHeader';
16+
import { NodesTable } from './NodesTable/NodesTable';
1517

1618
const NetworkLoadBalancersListenerDetail = () => {
1719
const { id, listenerId } = useParams({
@@ -50,12 +52,13 @@ const NetworkLoadBalancersListenerDetail = () => {
5052
{
5153
label: nlb.label,
5254
position: 2,
55+
linkTo: '/netloadbalancers/$id/listeners',
5356
},
5457
],
5558
pathname: `/netloadbalancers/${id}/listeners/${listenerId}`,
5659
}}
5760
docsLabel="Docs"
58-
docsLink="https://techdocs.akamai.com/linode-api/changelog/network-load-balancers"
61+
docsLink={NLB_API_DOCS_LINK}
5962
removeCrumbX={2}
6063
title={`${listener.label}`}
6164
/>
@@ -75,6 +78,7 @@ const NetworkLoadBalancersListenerDetail = () => {
7578
}
7679
noBodyBottomBorder={true}
7780
/>
81+
<NodesTable listenerId={listener.id} nlbId={nlb.id} />
7882
</>
7983
);
8084
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import * as React from 'react';
2+
import { vi } from 'vitest';
3+
4+
import { networkLoadBalancerNodeFactory } from 'src/factories';
5+
import { renderWithTheme } from 'src/utilities/testHelpers';
6+
7+
import { NodesTable } from './NodesTable';
8+
9+
const queryMocks = vi.hoisted(() => ({
10+
useNetworkLoadBalancerNodesQuery: vi.fn().mockReturnValue({}),
11+
useNavigate: vi.fn(),
12+
useSearch: vi.fn().mockReturnValue({ query: '' }),
13+
}));
14+
15+
vi.mock('@linode/queries', async () => {
16+
const actual = await vi.importActual('@linode/queries');
17+
return {
18+
...actual,
19+
useNetworkLoadBalancerNodesQuery:
20+
queryMocks.useNetworkLoadBalancerNodesQuery,
21+
};
22+
});
23+
24+
vi.mock('@tanstack/react-router', async () => {
25+
const actual = await vi.importActual('@tanstack/react-router');
26+
return {
27+
...actual,
28+
useNavigate: queryMocks.useNavigate,
29+
useSearch: queryMocks.useSearch,
30+
};
31+
});
32+
33+
vi.mock('src/hooks/usePaginationV2', () => ({
34+
usePaginationV2: () => ({
35+
page: 1,
36+
pageSize: 25,
37+
handlePageChange: vi.fn(),
38+
handlePageSizeChange: vi.fn(),
39+
}),
40+
}));
41+
42+
vi.mock('src/hooks/useOrderV2', () => ({
43+
useOrderV2: () => ({
44+
order: 'asc',
45+
orderBy: 'id',
46+
handleOrderChange: vi.fn(),
47+
}),
48+
}));
49+
50+
vi.mock('@linode/search', () => ({
51+
getAPIFilterFromQuery: (query: string) => ({
52+
filter: {},
53+
error: null,
54+
}),
55+
}));
56+
57+
describe('NodesTable', () => {
58+
const mockNodes = networkLoadBalancerNodeFactory.buildList(5);
59+
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
});
63+
64+
it('renders a loading state', () => {
65+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
66+
isLoading: true,
67+
});
68+
69+
const { getByTestId } = renderWithTheme(
70+
<NodesTable listenerId={2} nlbId={1} />
71+
);
72+
73+
expect(getByTestId('circle-progress')).toBeVisible();
74+
});
75+
76+
it('renders nodes table with correct columns', () => {
77+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
78+
isLoading: false,
79+
data: { data: mockNodes, results: 5 },
80+
});
81+
82+
const { getByText } = renderWithTheme(
83+
<NodesTable listenerId={2} nlbId={1} />
84+
);
85+
86+
expect(getByText('Node Label')).toBeVisible();
87+
expect(getByText('ID')).toBeVisible();
88+
expect(getByText('Linode ID')).toBeVisible();
89+
expect(getByText('VPC IPv6')).toBeVisible();
90+
});
91+
92+
it('renders nodes count in header', () => {
93+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
94+
isLoading: false,
95+
data: { data: mockNodes, results: 5 },
96+
});
97+
98+
const { getByText } = renderWithTheme(
99+
<NodesTable listenerId={2} nlbId={1} />
100+
);
101+
102+
expect(getByText(/Nodes \(5\)/)).toBeVisible();
103+
});
104+
105+
it('renders a Nodes table', () => {
106+
const nodeFactory = networkLoadBalancerNodeFactory.build({
107+
id: 123,
108+
linode_id: 456,
109+
});
110+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
111+
isLoading: false,
112+
data: { data: [nodeFactory], results: 1, page: 1, page_size: 25 },
113+
});
114+
115+
const { getByText, getByTestId, getByRole } = renderWithTheme(
116+
<NodesTable listenerId={2} nlbId={1} />
117+
);
118+
119+
const link = getByRole('link', { name: `${nodeFactory.linode_id}` });
120+
expect(link).toHaveAttribute('href', `/linodes/${nodeFactory.linode_id}`);
121+
122+
expect(getByText('Nodes (1)')).toBeVisible();
123+
expect(getByTestId(`nlb-node-row-${nodeFactory.id}`)).toBeVisible();
124+
expect(getByText(nodeFactory.label)).toBeVisible();
125+
expect(getByText(nodeFactory.id)).toBeVisible();
126+
expect(getByText(nodeFactory.linode_id)).toBeVisible();
127+
});
128+
129+
it('renders table rows for each node', () => {
130+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
131+
isLoading: false,
132+
data: { data: mockNodes, results: 5 },
133+
});
134+
135+
const { getByText } = renderWithTheme(
136+
<NodesTable listenerId={2} nlbId={1} />
137+
);
138+
139+
mockNodes.forEach((node) => {
140+
expect(getByText(node.label)).toBeVisible();
141+
});
142+
});
143+
144+
it('renders an empty Nodes table if there are no nodes', () => {
145+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
146+
isLoading: false,
147+
data: { data: [], results: 0 },
148+
});
149+
150+
const { getByText } = renderWithTheme(
151+
<NodesTable listenerId={2} nlbId={1} />
152+
);
153+
154+
expect(getByText('Nodes (0)')).toBeVisible();
155+
expect(getByText('No nodes are assigned to this listener')).toBeVisible();
156+
});
157+
158+
it('renders error state when query fails', () => {
159+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
160+
isLoading: false,
161+
error: [{ reason: 'Failed to fetch nodes' }],
162+
});
163+
164+
const { getByText } = renderWithTheme(
165+
<NodesTable listenerId={2} nlbId={1} />
166+
);
167+
168+
expect(getByText('Failed to fetch nodes')).toBeVisible();
169+
});
170+
171+
it('renders search field with correct placeholder', () => {
172+
queryMocks.useNetworkLoadBalancerNodesQuery.mockReturnValue({
173+
isLoading: false,
174+
data: { data: mockNodes, results: 5 },
175+
});
176+
177+
const { getByPlaceholderText } = renderWithTheme(
178+
<NodesTable listenerId={2} nlbId={1} />
179+
);
180+
181+
const searchField = getByPlaceholderText(
182+
'Search Node ID, Linode ID or IP address'
183+
);
184+
expect(searchField).toBeVisible();
185+
});
186+
});

0 commit comments

Comments
 (0)