Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-9231] - Improve Node Pool Collapsing UX #11619

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-11619-added-1739207937605.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

Improve Node Pool Collapsing UX ([#11619](https://github.com/linode/manager/pull/11619))
85 changes: 85 additions & 0 deletions packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,91 @@ describe('LKE cluster updates', () => {
});
});

it('sets default expanded node pools and has collapse/expand all functionality', () => {
const mockCluster = kubernetesClusterFactory.build({
k8s_version: latestKubernetesVersion,
});
const mockNodePools = [
nodePoolFactory.build({
nodes: kubeLinodeFactory.buildList(10),
count: 10,
}),
nodePoolFactory.build({
nodes: kubeLinodeFactory.buildList(5),
count: 5,
}),
nodePoolFactory.build({ nodes: [kubeLinodeFactory.build()] }),
];
mockGetCluster(mockCluster).as('getCluster');
mockGetClusterPools(mockCluster.id, mockNodePools).as('getNodePools');

cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`);
cy.wait(['@getCluster', '@getNodePools']);

cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => {
// Accordion should be collapsed by default since there are more than 9 nodes
cy.get(`[data-qa-panel-summary]`).should(
'have.attr',
'aria-expanded',
'false'
);
});

cy.get(`[data-qa-node-pool-id="${mockNodePools[1].id}"]`).within(() => {
// Accordion should be expanded by default since there are not more than 9 nodes
cy.get(`[data-qa-panel-summary]`).should(
'have.attr',
'aria-expanded',
'true'
);
});

cy.get(`[data-qa-node-pool-id="${mockNodePools[2].id}"]`).within(() => {
// Accordion should be expanded by default since there are not more than 9 nodes
cy.get(`[data-qa-panel-summary]`).should(
'have.attr',
'aria-expanded',
'true'
);
});

// Collapse all pools
ui.button
.findByTitle('Collapse All Pools')
.should('be.visible')
.should('be.enabled')
.click();

cy.get(`[data-qa-node-pool-id]`).each(($pool) => {
// Accordion should be collapsed
cy.wrap($pool).within(() => {
cy.get(`[data-qa-panel-summary]`).should(
'have.attr',
'aria-expanded',
'false'
);
});
});

// Expand all pools
ui.button
.findByTitle('Expand All Pools')
.should('be.visible')
.should('be.enabled')
.click();

cy.get(`[data-qa-node-pool-id]`).each(($pool) => {
// Accordion should be expanded
cy.wrap($pool).within(() => {
cy.get(`[data-qa-panel-summary]`).should(
'have.attr',
'aria-expanded',
'true'
);
});
});
});

it('filters the node tables based on selected status filter', () => {
const mockCluster = kubernetesClusterFactory.build({
k8s_version: latestKubernetesVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ import type {
import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types';

interface Props {
accordionExpanded: boolean;
autoscaler: AutoscaleSettings;
clusterCreated: string;
clusterId: number;
clusterTier: KubernetesTier;
count: number;
encryptionStatus: EncryptionStatus | undefined;
handleAccordionClick: () => void;
handleClickLabelsAndTaints: (poolId: number) => void;
handleClickResize: (poolId: number) => void;
isOnlyNodePool: boolean;
Expand All @@ -46,12 +48,14 @@ interface Props {

export const NodePool = (props: Props) => {
const {
accordionExpanded,
autoscaler,
clusterCreated,
clusterId,
clusterTier,
count,
encryptionStatus,
handleAccordionClick,
handleClickLabelsAndTaints,
handleClickResize,
isOnlyNodePool,
Expand Down Expand Up @@ -210,7 +214,8 @@ export const NodePool = (props: Props) => {
}
data-qa-node-pool-id={poolId}
data-qa-node-pool-section
defaultExpanded={true}
expanded={accordionExpanded}
onChange={handleAccordionClick}
>
<NodeTable
clusterCreated={clusterCreated}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Button, CircleProgress, Select, Stack, Typography } from '@linode/ui';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import React, { useState } from 'react';
import { Waypoint } from 'react-waypoint';

import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { FormLabel } from 'src/components/FormLabel';
import { useDefaultExpandedNodePools } from 'src/hooks/useDefaultExpandedNodePools';
import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes';
import { useSpecificTypes } from 'src/queries/types';
import { extendTypesQueryResult } from 'src/utilities/extendType';
Expand Down Expand Up @@ -124,46 +127,88 @@ export const NodePoolsDisplay = (props: Props) => {
setIsLabelsAndTaintsDrawerOpen(true);
};

const {
defaultExpandedPools,
expandedAccordions,
handleAccordionClick,
setExpandedAccordions,
} = useDefaultExpandedNodePools(clusterID, _pools);

if (isLoading || pools === undefined) {
return <CircleProgress />;
}

return (
<>
<Stack
sx={{
paddingBottom: 1,
paddingLeft: { md: 0, sm: 1, xs: 1 },
paddingTop: 3,
}}
alignItems="center"
direction="row"
flexWrap="wrap"
justifyContent="space-between"
spacing={2}
sx={{ paddingLeft: { md: 0, sm: 1, xs: 1 }, paddingTop: 3 }}
>
<Typography variant="h2">Node Pools</Typography>
<Stack direction="row" spacing={1}>
<Stack alignItems="end" direction="row">
<FormLabel htmlFor={ariaIdentifier}>
<Typography ml={1} mr={1}>
Status
</Typography>
</FormLabel>
<Select
value={
statusOptions?.find(
(option) => option.value === statusFilter
) ?? null
}
data-qa-status-filter
hideLabel
id={ariaIdentifier}
label="Status"
onChange={(_, item) => setStatusFilter(item?.value)}
options={statusOptions ?? []}
placeholder="Select a status"
sx={{ width: 130 }}
/>
</Stack>
<Stack alignItems="center" direction="row" spacing={2}>
<Typography variant="h2">Node Pools</Typography>
</Stack>
<Stack alignItems="center" direction="row" spacing={1}>
<FormLabel htmlFor={ariaIdentifier}>
<Typography ml={1} mr={1}>
Status
</Typography>
</FormLabel>
<Select
value={
statusOptions?.find((option) => option.value === statusFilter) ??
null
}
data-qa-status-filter
hideLabel
id={ariaIdentifier}
label="Status"
onChange={(_, item) => setStatusFilter(item?.value)}
options={statusOptions ?? []}
placeholder="Select a status"
sx={{ width: 130 }}
/>
{(expandedAccordions === undefined &&
defaultExpandedPools.length > 0) ||
(expandedAccordions && expandedAccordions.length > 0) ? (
<Button
sx={{
'& span': { marginLeft: 0.5 },
paddingLeft: 0.5,
paddingRight: 0.5,
}}
buttonType="secondary"
endIcon={<ExpandLessIcon />}
onClick={() => setExpandedAccordions([])}
>
Collapse All Pools
</Button>
) : (
<Button
onClick={() => {
const expandedAccordions = _pools?.map(({ id }) => id) ?? [];
setExpandedAccordions(expandedAccordions);
}}
sx={{
'& span': { marginLeft: 0.5 },
paddingLeft: 0.5,
paddingRight: 0.5,
}}
buttonType="secondary"
endIcon={<ExpandMoreIcon />}
>
Expand All Pools
</Button>
)}
<Button
buttonType="secondary"
buttonType="outlined"
onClick={() => setIsRecycleClusterOpen(true)}
>
Recycle All Nodes
Expand All @@ -186,6 +231,11 @@ export const NodePoolsDisplay = (props: Props) => {

return (
<NodePool
accordionExpanded={
expandedAccordions === undefined
? defaultExpandedPools.includes(id)
: expandedAccordions.includes(id)
}
openAutoscalePoolDialog={(poolId) => {
setSelectedPoolId(poolId);
setIsAutoscaleDialogOpen(true);
Expand All @@ -208,6 +258,7 @@ export const NodePoolsDisplay = (props: Props) => {
clusterTier={clusterTier}
count={count}
encryptionStatus={disk_encryption}
handleAccordionClick={() => handleAccordionClick(id)}
handleClickLabelsAndTaints={handleOpenLabelsAndTaintsDrawer}
handleClickResize={handleOpenResizeDrawer}
isOnlyNodePool={pools?.length === 1}
Expand Down
64 changes: 64 additions & 0 deletions packages/manager/src/hooks/useDefaultExpandedNodePools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { renderHook } from '@testing-library/react';

import {
kubeLinodeFactory,
nodePoolFactory,
} from 'src/factories/kubernetesCluster';

import { useDefaultExpandedNodePools } from './useDefaultExpandedNodePools';

const clusterID = 1;

describe('useDefaultExpandedNodePools', () => {
it('returns a single node pool as the default expanded pool', () => {
const singleNodePool = [
nodePoolFactory.build({
count: 50,
id: 100,
nodes: kubeLinodeFactory.buildList(50),
}),
];

const { result } = renderHook(() =>
useDefaultExpandedNodePools(clusterID, singleNodePool)
);

expect(result.current.defaultExpandedPools).toStrictEqual([100]);
});

it('returns node pools with less than 10 nodes as the default expanded pools if the user has between 1-3 node pools', () => {
const nodePools = [
nodePoolFactory.build({
count: 1,
id: 100,
nodes: [kubeLinodeFactory.build()],
}),
nodePoolFactory.build({
count: 10,
id: 101,
nodes: kubeLinodeFactory.buildList(10),
}),
nodePoolFactory.build({
count: 6,
id: 102,
nodes: kubeLinodeFactory.buildList(6),
}),
];

const { result } = renderHook(() =>
useDefaultExpandedNodePools(clusterID, nodePools)
);

expect(result.current.defaultExpandedPools).toStrictEqual([100, 102]);
});

it('returns no default expanded pools if the user has more than 3 node pools', () => {
const nodePools = nodePoolFactory.buildList(10);

const { result } = renderHook(() =>
useDefaultExpandedNodePools(clusterID, nodePools)
);

expect(result.current.defaultExpandedPools).toStrictEqual([]);
});
});
Loading