Skip to content

Commit

Permalink
fix: [M3-7741] - Hide error notices for $0 regions in Resize Pool and…
Browse files Browse the repository at this point in the history
… Add a Node Pool drawers (#10157)

* Allow -zsh LKE prices without error notices in Resize Pool and Add Pool drawers

* Fix loading spinner displaying above what was supposed to be loading

* Fix conditional to render notice if either price is invalid

* Add test coverage

* Added changeset: Hide error notices for /bin/sh regions for LKE Resize and Add Node Pools

* Fix changeset wording

* Address feedback: use invalid price util
  • Loading branch information
mjac0bs authored Feb 8, 2024
1 parent 731a274 commit 49ea132
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 70 deletions.
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-10157-fixed-1707328749030.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Error notices for $0 regions in LKE Resize and Add Node Pools drawers ([#10157](https://github.com/linode/manager/pull/10157))
225 changes: 225 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 @@ -1011,4 +1011,229 @@ describe('LKE cluster updates for DC-specific prices', () => {
// Confirm total price updates in Kube Specs: $14.40/mo existing pool + $28.80/mo new pool.
cy.findByText('$43.20/month').should('be.visible');
});

/*
* - Confirms node pool resize UI flow using mocked API responses.
* - Confirms that pool size can be changed.
* - Confirms that drawer reflects $0 pricing.
* - Confirms that details page still shows $0 pricing after resizing.
*/
it('can resize pools with region prices of $0', () => {
const dcSpecificPricingRegion = getRegionById('us-southeast');

const mockCluster = kubernetesClusterFactory.build({
k8s_version: latestKubernetesVersion,
region: dcSpecificPricingRegion.id,
control_plane: {
high_availability: false,
},
});

const mockNodePoolResized = nodePoolFactory.build({
count: 3,
type: dcPricingMockLinodeTypes[2].id,
nodes: kubeLinodeFactory.buildList(3),
});

const mockNodePoolInitial = {
...mockNodePoolResized,
count: 1,
nodes: [mockNodePoolResized.nodes[0]],
};

const mockLinodes: Linode[] = mockNodePoolResized.nodes.map(
(node: PoolNodeResponse): Linode => {
return linodeFactory.build({
id: node.instance_id ?? undefined,
ipv4: [randomIp()],
region: dcSpecificPricingRegion.id,
type: dcPricingMockLinodeTypes[2].id,
});
}
);

const mockNodePoolDrawerTitle = 'Resize Pool: Linode 2 GB Plan';

mockGetCluster(mockCluster).as('getCluster');
mockGetClusterPools(mockCluster.id, [mockNodePoolInitial]).as(
'getNodePools'
);
mockGetLinodes(mockLinodes).as('getLinodes');
mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType');
mockGetKubernetesVersions().as('getVersions');
mockGetDashboardUrl(mockCluster.id);
mockGetApiEndpoints(mockCluster.id);

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

// Confirm that nodes are visible.
mockNodePoolInitial.nodes.forEach((node: PoolNodeResponse) => {
cy.get(`tr[data-qa-node-row="${node.id}"]`)
.should('be.visible')
.within(() => {
const nodeLinode = mockLinodes.find(
(linode: Linode) => linode.id === node.instance_id
);
if (nodeLinode) {
cy.findByText(nodeLinode.label).should('be.visible');
}
});
});

// Confirm total price is listed in Kube Specs.
cy.findByText('$0.00/month').should('be.visible');

// Click "Resize Pool" and increase size to 4 nodes.
ui.button
.findByTitle('Resize Pool')
.should('be.visible')
.should('be.enabled')
.click();

mockUpdateNodePool(mockCluster.id, mockNodePoolResized).as(
'resizeNodePool'
);
mockGetClusterPools(mockCluster.id, [mockNodePoolResized]).as(
'getNodePools'
);

ui.drawer
.findByTitle(mockNodePoolDrawerTitle)
.should('be.visible')
.within(() => {
ui.button
.findByTitle('Save Changes')
.should('be.visible')
.should('be.disabled');

cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
'be.visible'
);
cy.findByText('Resized pool: $0/month (1 node at $0/month)').should(
'be.visible'
);

cy.findByLabelText('Add 1')
.should('be.visible')
.should('be.enabled')
.click()
.click()
.click();

cy.findByLabelText('Edit Quantity').should('have.value', '4');
cy.findByText('Current pool: $0/month (1 node at $0/month)').should(
'be.visible'
);
cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should(
'be.visible'
);

ui.button
.findByTitle('Save Changes')
.should('be.visible')
.should('be.enabled')
.click();
});

cy.wait(['@resizeNodePool', '@getNodePools']);

// Confirm total price is still $0 in Kube Specs.
cy.findByText('$0.00/month').should('be.visible');
});

/*
* - Confirms UI flow when adding node pools using mocked API responses.
* - Confirms that drawer reflects $0 prices.
* - Confirms that details page still shows $0 pricing after adding node pool.
*/
it('can add node pools with region prices of $0', () => {
const dcSpecificPricingRegion = getRegionById('us-southeast');

const mockCluster = kubernetesClusterFactory.build({
k8s_version: latestKubernetesVersion,
region: dcSpecificPricingRegion.id,
control_plane: {
high_availability: false,
},
});

const mockNewNodePool = nodePoolFactory.build({
count: 2,
type: dcPricingMockLinodeTypes[2].id,
nodes: kubeLinodeFactory.buildList(2),
});

const mockNodePool = nodePoolFactory.build({
count: 1,
type: dcPricingMockLinodeTypes[2].id,
nodes: kubeLinodeFactory.buildList(1),
});

mockGetCluster(mockCluster).as('getCluster');
mockGetClusterPools(mockCluster.id, [mockNodePool]).as('getNodePools');
mockGetKubernetesVersions().as('getVersions');
mockAddNodePool(mockCluster.id, mockNewNodePool).as('addNodePool');
mockGetLinodeType(dcPricingMockLinodeTypes[2]).as('getLinodeType');
mockGetLinodeTypes(dcPricingMockLinodeTypes);
mockGetDashboardUrl(mockCluster.id);
mockGetApiEndpoints(mockCluster.id);

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

// Assert that initial node pool is shown on the page.
cy.findByText('Linode 2 GB', { selector: 'h2' }).should('be.visible');

// Confirm total price of $0 is listed in Kube Specs.
cy.findByText('$0.00/month').should('be.visible');

// Add a new node pool, select plan, submit form in drawer.
ui.button
.findByTitle('Add a Node Pool')
.should('be.visible')
.should('be.enabled')
.click();

mockGetClusterPools(mockCluster.id, [mockNodePool, mockNewNodePool]).as(
'getNodePools'
);

ui.drawer
.findByTitle(`Add a Node Pool: ${mockCluster.label}`)
.should('be.visible')
.within(() => {
cy.findByText('Linode 2 GB')
.should('be.visible')
.closest('tr')
.within(() => {
// Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes.
cy.findAllByText('$0').should('have.length', 2);
cy.findByLabelText('Add 1').should('be.visible').click().click();
});

// Assert that $0 prices are displayed as helper text.
cy.contains(
'This pool will add $0/month (2 nodes at $0/month) to this cluster.'
).should('be.visible');

ui.button
.findByTitle('Add pool')
.should('be.visible')
.should('be.enabled')
.click();
});

// Wait for API responses.
cy.wait(['@addNodePool', '@getNodePools']);

// Confirm total price is still $0 in Kube Specs.
cy.findByText('$0.00/month').should('be.visible');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, {
monthly: 12.2,
},
{
hourly: 0.006,
// Mock a DC with $0 region prices, which is possible in some circumstances (e.g. Limited Availability).
hourly: 0.0,
id: 'us-southeast',
monthly: 4.67,
monthly: 0.0,
},
],
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Theme } from '@mui/material/styles';
import { isNumber } from 'lodash';
import * as React from 'react';
import { makeStyles } from 'tss-react/mui';

Expand All @@ -20,6 +21,7 @@ import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView';

import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel';
import { nodeWarning } from '../../kubeUtils';
import { hasInvalidNodePoolPrice } from './utils';

import type { Region } from '@linode/api-v4';

Expand Down Expand Up @@ -102,10 +104,12 @@ export const AddNodePoolDrawer = (props: Props) => {
?.monthly;

const totalPrice =
selectedTypeInfo && pricePerNode
selectedTypeInfo && isNumber(pricePerNode)
? selectedTypeInfo.count * pricePerNode
: undefined;

const hasInvalidPrice = hasInvalidNodePoolPrice(pricePerNode, totalPrice);

React.useEffect(() => {
if (open) {
resetDrawer();
Expand Down Expand Up @@ -199,7 +203,7 @@ export const AddNodePoolDrawer = (props: Props) => {
/>
)}

{selectedTypeInfo && !totalPrice && !pricePerNode && (
{selectedTypeInfo && hasInvalidPrice && (
<Notice
spacingBottom={16}
spacingTop={8}
Expand Down Expand Up @@ -229,7 +233,7 @@ export const AddNodePoolDrawer = (props: Props) => {
)}
<ActionsPanel
primaryButtonProps={{
disabled: !selectedTypeInfo,
disabled: !selectedTypeInfo || hasInvalidPrice,
label: 'Add pool',
loading: isLoading,
onClick: handleAdd,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@ describe('ResizeNodePoolDrawer', () => {
await findByText(/linode 1 GB/i);
});

it('should display a warning if the user tries to resize a node pool to < 3 nodes', () => {
const { getByText } = renderWithTheme(
it('should display a warning if the user tries to resize a node pool to < 3 nodes', async () => {
const { findByText } = renderWithTheme(
<ResizeNodePoolDrawer {...props} nodePool={smallPool} />
);
expect(getByText(/minimum of 3 nodes/i));
expect(await findByText(/minimum of 3 nodes/i));
});

it('should display a warning if the user tries to resize to a smaller node count', () => {
const { getByTestId, getByText } = renderWithTheme(
it('should display a warning if the user tries to resize to a smaller node count', async () => {
const { findByTestId, getByText } = renderWithTheme(
<ResizeNodePoolDrawer {...props} />
);
const decrement = getByTestId('decrement-button');

const decrement = await findByTestId('decrement-button');
fireEvent.click(decrement);
expect(getByText(/resizing to fewer nodes/i));
});
Expand Down
Loading

0 comments on commit 49ea132

Please sign in to comment.