Skip to content

Commit d63b7dc

Browse files
upcoming: [UIE-9551] - Implement read-only NLB list table. (#13112)
* upcoming: [UIE-9551] - Implement read-only NLB list table. * Added changeset: Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns * Using theme-sensitive tokens for ShowMore text. * Addressed review comments.
1 parent aac0a42 commit d63b7dc

16 files changed

+855
-28
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+
Add NetworkLoadBalancersLanding component to render NLB list with pagination, loading/error and table columns ([#13112](https://github.com/linode/manager/pull/13112))

packages/manager/src/components/ShowMore/ShowMore.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,15 @@ export const ShowMore = <T extends {}>(props: ShowMoreProps<T>) => {
3737
data-qa-show-more-chip
3838
label={`+${items.length}`}
3939
onClick={handleClick}
40-
sx={
41-
anchorEl
40+
sx={{
41+
...(anchorEl
4242
? {
4343
backgroundColor: theme.palette.primary.main,
4444
color: theme.tokens.color.Neutrals.White,
4545
}
46-
: null
47-
}
46+
: {}),
47+
...(chipProps?.sx || {}), // caller-provided `chipProps.sx` takes precedence and will override the default active styling.
48+
}}
4849
/>
4950

5051
<StyledPopover

packages/manager/src/factories/networkLoadBalancer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
export const networkLoadBalancerFactory =
1010
Factory.Sync.makeFactory<NetworkLoadBalancer>({
1111
id: Factory.each((id) => id),
12-
label: Factory.each((id) => `nlb-${id}`),
12+
label: Factory.each((id) => `netloadbalancer-${id}-test${id}`),
1313
region: 'us-east',
1414
address_v4: '192.168.1.1',
1515
address_v6: '2001:db8:85a3::8a2e:370:7334',
@@ -26,7 +26,7 @@ export const networkLoadBalancerListenerFactory =
2626
id: Factory.each((id) => id),
2727
label: Factory.each((id) => `nlb-listener-${id}`),
2828
updated: '2023-01-01T00:00:00Z',
29-
port: 80,
29+
port: Factory.each((id) => 80 + id),
3030
protocol: 'tcp',
3131
});
3232

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle';
77
import { EntityDetail } from 'src/components/EntityDetail/EntityDetail';
88
import { LandingHeader } from 'src/components/LandingHeader';
99

10+
import { NLB_API_DOCS_LINK } from '../constants';
1011
import { NetworkLoadBalancerDetailBody } from './NetworkLoadBalancerDetailBody';
1112
import { NetworkLoadBalancerDetailHeader } from './NetworkLoadBalancerDetailHeader';
1213

@@ -45,7 +46,7 @@ const NetworkLoadBalancersDetail = () => {
4546
pathname: `/netloadbalancers/${nlb.id}`,
4647
}}
4748
docsLabel="Docs"
48-
docsLink="https://techdocs.akamai.com/linode-api/changelog/network-load-balancers"
49+
docsLink={NLB_API_DOCS_LINK}
4950
title={nlb.label}
5051
/>
5152
<EntityDetail

packages/manager/src/features/NetworkLoadBalancers/NetworkLoadBalancersLanding.tsx

Lines changed: 0 additions & 20 deletions
This file was deleted.
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { breakpoints } from '@linode/ui';
2+
import * as React from 'react';
3+
4+
import {
5+
networkLoadBalancerFactory,
6+
networkLoadBalancerListenerFactory,
7+
} from 'src/factories/networkLoadBalancer';
8+
import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers';
9+
10+
import { NetworkLoadBalancerTableRow } from './NetworkLoadBalancerTableRow';
11+
12+
import type { NetworkLoadBalancer } from '@linode/api-v4/lib/netloadbalancers';
13+
14+
// Use factory-built data. Do not hardcode properties in this file.
15+
const mockNetworkLoadBalancer: NetworkLoadBalancer = (() => {
16+
const base = networkLoadBalancerFactory.build();
17+
const listeners = networkLoadBalancerListenerFactory.buildList(2);
18+
return { ...base, listeners };
19+
})();
20+
21+
describe('NetworkLoadBalancerTableRow', () => {
22+
beforeEach(() => {
23+
vi.resetAllMocks();
24+
resizeScreenSize(breakpoints.values.lg);
25+
});
26+
27+
it('renders the NetworkLoadBalancer table row with label', () => {
28+
const { getByText } = renderWithTheme(
29+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
30+
);
31+
32+
expect(getByText(mockNetworkLoadBalancer.label)).toBeVisible();
33+
});
34+
35+
it('renders the status icon and status text', () => {
36+
const { getByText } = renderWithTheme(
37+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
38+
);
39+
40+
// Status displayed is case-insensitive; match using the factory status value.
41+
expect(
42+
getByText(new RegExp(mockNetworkLoadBalancer.status, 'i'))
43+
).toBeVisible();
44+
});
45+
46+
it('renders the ID in hidden column on small screens', () => {
47+
resizeScreenSize(breakpoints.values.lg);
48+
const { getByText } = renderWithTheme(
49+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
50+
);
51+
52+
expect(getByText(String(mockNetworkLoadBalancer.id))).toBeVisible();
53+
});
54+
55+
it('hides the ID column on small screens', () => {
56+
resizeScreenSize(breakpoints.values.sm - 1);
57+
const { queryByText } = renderWithTheme(
58+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
59+
);
60+
61+
expect(
62+
queryByText(String(mockNetworkLoadBalancer.id))
63+
).not.toBeInTheDocument();
64+
});
65+
66+
it('renders listener ports', () => {
67+
const { getByText } = renderWithTheme(
68+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
69+
);
70+
71+
// Ensure at least one listener port from the factory is rendered
72+
const firstPort = mockNetworkLoadBalancer.listeners?.[0]?.port;
73+
expect(firstPort).toBeDefined();
74+
expect(getByText(new RegExp(String(firstPort)))).toBeInTheDocument();
75+
});
76+
77+
it('renders "None" when there are no listeners', () => {
78+
const nlbWithNoListeners = networkLoadBalancerFactory.build();
79+
80+
const { container } = renderWithTheme(
81+
<NetworkLoadBalancerTableRow {...nlbWithNoListeners} />
82+
);
83+
84+
const portsCell = container.querySelector('[data-qa-ports]');
85+
expect(portsCell?.textContent?.trim()).toBe('None');
86+
});
87+
88+
it('renders IPv4 address', () => {
89+
const { getByText } = renderWithTheme(
90+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
91+
);
92+
93+
expect(getByText(mockNetworkLoadBalancer.address_v4)).toBeVisible();
94+
});
95+
96+
it('renders IPv6 address', () => {
97+
resizeScreenSize(breakpoints.values.lg);
98+
const { getByText } = renderWithTheme(
99+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
100+
);
101+
102+
expect(getByText(mockNetworkLoadBalancer.address_v6!)).toBeVisible();
103+
});
104+
105+
it('renders "None" for IPv6 when address_v6 is not set', () => {
106+
const nlbWithoutIPv6: NetworkLoadBalancer = {
107+
...mockNetworkLoadBalancer,
108+
address_v6: '',
109+
};
110+
111+
resizeScreenSize(breakpoints.values.lg);
112+
renderWithTheme(<NetworkLoadBalancerTableRow {...nlbWithoutIPv6} />);
113+
114+
const noneElements = document.querySelectorAll('td');
115+
const lastNoneElement = Array.from(noneElements).find(
116+
(el) => el.textContent === 'None'
117+
);
118+
expect(lastNoneElement).toBeInTheDocument();
119+
});
120+
121+
it('renders region', () => {
122+
const { getByText } = renderWithTheme(
123+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
124+
);
125+
126+
expect(getByText(mockNetworkLoadBalancer.region)).toBeVisible();
127+
});
128+
129+
it('renders inactive status', () => {
130+
const nlbInactive: NetworkLoadBalancer = {
131+
...mockNetworkLoadBalancer,
132+
status: 'suspended',
133+
};
134+
135+
const { getByText } = renderWithTheme(
136+
<NetworkLoadBalancerTableRow {...nlbInactive} />
137+
);
138+
139+
expect(getByText(/suspended/i)).toBeVisible();
140+
});
141+
142+
it('renders the label as a link', () => {
143+
const { getByRole } = renderWithTheme(
144+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
145+
);
146+
147+
const link = getByRole('link', { name: mockNetworkLoadBalancer.label });
148+
expect(link).toHaveAttribute(
149+
'href',
150+
`/netloadbalancers/${mockNetworkLoadBalancer.id}/listeners`
151+
);
152+
});
153+
154+
it('hides listener ports column on medium screens and below', () => {
155+
resizeScreenSize(breakpoints.values.md - 1);
156+
const { container } = renderWithTheme(
157+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
158+
);
159+
160+
// The ports should be present in the DOM for this implementation
161+
// (component does not hide ports at md); verify ports are rendered
162+
const ports = Array.from(container.querySelectorAll('td'));
163+
const firstPort = String(mockNetworkLoadBalancer.listeners?.[0]?.port);
164+
const hasPortsColumn = ports.some(
165+
(el) =>
166+
el.textContent === firstPort || el.textContent?.includes(firstPort)
167+
);
168+
expect(hasPortsColumn).toBe(true);
169+
});
170+
171+
it('hides IPv6 column on medium screens and below', () => {
172+
resizeScreenSize(breakpoints.values.md - 1);
173+
renderWithTheme(
174+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
175+
);
176+
177+
// IPv6 should not be visible on md screens
178+
const ipv6Cell = document.querySelector('td');
179+
expect(ipv6Cell?.textContent).not.toContain(
180+
mockNetworkLoadBalancer.address_v6!
181+
);
182+
});
183+
184+
it('hides region and ID columns on small screens and below', () => {
185+
resizeScreenSize(breakpoints.values.sm - 1);
186+
renderWithTheme(
187+
<NetworkLoadBalancerTableRow {...mockNetworkLoadBalancer} />
188+
);
189+
190+
// ID and region should not be visible on sm screens
191+
const cells = document.querySelectorAll('td');
192+
const cellTexts = Array.from(cells).map((el) => el.textContent);
193+
expect(cellTexts.join('')).not.toContain(mockNetworkLoadBalancer.region);
194+
});
195+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Hidden } from '@linode/ui';
2+
import { capitalize } from '@linode/utilities';
3+
import * as React from 'react';
4+
5+
import { Link } from 'src/components/Link';
6+
import { StatusIcon } from 'src/components/StatusIcon/StatusIcon';
7+
import { TableCell } from 'src/components/TableCell';
8+
import { TableRow } from 'src/components/TableRow';
9+
import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress';
10+
import { RegionIndicator } from 'src/features/Linodes/LinodesLanding/RegionIndicator';
11+
12+
import { PortsDisplay } from './PortsDisplay';
13+
14+
import type { NetworkLoadBalancer } from '@linode/api-v4/lib/netloadbalancers';
15+
16+
export const NetworkLoadBalancerTableRow = (props: NetworkLoadBalancer) => {
17+
const {
18+
id,
19+
address_v4,
20+
address_v6,
21+
label,
22+
region,
23+
status,
24+
listeners,
25+
lke_cluster,
26+
} = props;
27+
28+
// Memoize port strings to avoid recalculation
29+
const portStrings = React.useMemo(() => {
30+
return listeners?.map((listener) => listener.port.toString()) ?? [];
31+
}, [listeners]);
32+
33+
const [isHovered, setIsHovered] = React.useState(false);
34+
35+
const handleMouseEnter = React.useCallback(() => {
36+
setIsHovered(true);
37+
}, []);
38+
39+
const handleMouseLeave = React.useCallback(() => {
40+
setIsHovered(false);
41+
}, []);
42+
43+
return (
44+
<TableRow
45+
data-qa-nlb={label}
46+
key={id}
47+
onMouseEnter={handleMouseEnter}
48+
onMouseLeave={handleMouseLeave}
49+
>
50+
<TableCell noWrap>
51+
<Link
52+
accessibleAriaLabel={label}
53+
to={`/netloadbalancers/${id}/listeners`}
54+
>
55+
{label}
56+
</Link>
57+
</TableCell>
58+
<Hidden lgDown>
59+
<TableCell data-qa-status statusCell>
60+
<StatusIcon status={status === 'active' ? 'active' : 'inactive'} />
61+
{capitalize(status)}
62+
</TableCell>
63+
</Hidden>
64+
<Hidden data-qa-id lgDown>
65+
<TableCell>{id}</TableCell>
66+
</Hidden>
67+
<TableCell data-qa-ports>
68+
<PortsDisplay ports={portStrings} />
69+
</TableCell>
70+
<TableCell data-qa-ipv4>
71+
<IPAddress ips={[address_v4]} isHovered={isHovered} />
72+
</TableCell>
73+
<Hidden mdDown>
74+
<TableCell data-qa-ipv6>
75+
{address_v6 ? (
76+
<IPAddress ips={[address_v6]} isHovered={isHovered} />
77+
) : (
78+
'None'
79+
)}
80+
</TableCell>
81+
</Hidden>
82+
<TableCell data-qa-lke-cluster>
83+
{lke_cluster ? (
84+
<Link
85+
accessibleAriaLabel={lke_cluster.label}
86+
to={`/kubernetes/clusters/${lke_cluster.id}`}
87+
>
88+
{lke_cluster.label}
89+
</Link>
90+
) : (
91+
'None'
92+
)}
93+
</TableCell>
94+
<Hidden mdDown>
95+
<TableCell data-qa-region>
96+
<RegionIndicator region={region} />
97+
</TableCell>
98+
</Hidden>
99+
</TableRow>
100+
);
101+
};
102+
103+
export default NetworkLoadBalancerTableRow;

0 commit comments

Comments
 (0)