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-6749] - Subnet delete dialog #9640

Merged
merged 19 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Subnet delete dialog ([#9640](https://github.com/linode/manager/pull/9640))
77 changes: 76 additions & 1 deletion packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
mockGetVPCs,
mockDeleteVPC,
mockUpdateVPC,
mockDeleteSubnet,
mockGetSubnets,
} from 'support/intercepts/vpc';
import {
mockAppendFeatureFlags,
mockGetFeatureFlagClientstream,
} from 'support/intercepts/feature-flags';
import { vpcFactory } from '@src/factories';
import { vpcFactory, subnetFactory } from '@src/factories';
import { randomLabel, randomNumber, randomPhrase } from 'support/util/random';
import { makeFeatureFlagData } from 'support/util/feature-flags';
import type { VPC } from '@linode/api-v4';
Expand Down Expand Up @@ -115,4 +117,77 @@ describe('VPC details page', () => {
cy.url().should('endWith', '/vpcs');
cy.findByText('Create a private and isolated network.');
});

/**
* - Confirms Subnets section and table is shown on the VPC details page
* - Confirms UI flow when deleting a subnet from a VPC's detail page
*/
it('can delete a subnet from the VPC details page', () => {
const mockSubnet = subnetFactory.build({
id: randomNumber(),
label: randomLabel(),
});
const mockVPC = vpcFactory.build({
id: randomNumber(),
label: randomLabel(),
subnets: [mockSubnet],
});

const mockVPCAfterSubnetDeletion = vpcFactory.build({
...mockVPC,
subnets: [],
});

mockAppendFeatureFlags({
vpc: makeFeatureFlagData(true),
}).as('getFeatureFlags');

mockGetVPC(mockVPC).as('getVPC');
mockGetFeatureFlagClientstream().as('getClientStream');
mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets');
mockDeleteSubnet(mockVPC.id, mockSubnet.id).as('deleteSubnet');

cy.visitWithLogin(`/vpcs/${mockVPC.id}`);
cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']);

// confirm that vpc and subnet details get displayed
cy.findByText(mockVPC.label).should('be.visible');
cy.findByText('Subnets (1)').should('be.visible');
cy.findByText(mockSubnet.label).should('be.visible');

// confirm that subnet can be deleted and that page reflects changes
ui.actionMenu
.findByTitle(`Action menu for Subnet ${mockSubnet.label}`)
.should('be.visible')
.click();
ui.actionMenuItem.findByTitle('Delete').should('be.visible').click();

mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC');
mockGetSubnets(mockVPC.id, []).as('getSubnets');

ui.dialog
.findByTitle(`Delete Subnet ${mockSubnet.label}`)
.should('be.visible')
.within(() => {
cy.findByLabelText('Subnet label')
.should('be.visible')
.click()
.type(mockSubnet.label);

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

cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']);

// confirm that user should still be on VPC's detail page
// confirm there are no remaining subnets
cy.url().should('endWith', `/${mockVPC.id}`);
cy.findByText('Subnets (0)');
cy.findByText('No Subnets');
cy.findByText(mockSubnet.label).should('not.exist');
});
});
40 changes: 39 additions & 1 deletion packages/manager/cypress/support/intercepts/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { apiMatcher } from 'support/util/intercepts';
import { paginateResponse } from 'support/util/paginate';

import type { VPC } from '@linode/api-v4';
import type { Subnet, VPC } from '@linode/api-v4';
import { makeResponse } from 'support/util/response';

/**
Expand Down Expand Up @@ -55,3 +55,41 @@ export const mockUpdateVPC = (
export const mockDeleteVPC = (vpcId: number): Cypress.Chainable<null> => {
return cy.intercept('DELETE', apiMatcher(`vpcs/${vpcId}`), {});
};

/**
* Intercepts GET request to get a VPC's subnets and mocks response.
*
* @param vpcId - ID of VPC for which to mock response.
* @param subnets - Array of subnets for which to mock response
*
* @returns Cypress chainable.
*/
export const mockGetSubnets = (
vpcId: number,
subnets: Subnet[]
): Cypress.Chainable<null> => {
return cy.intercept(
'GET',
apiMatcher(`vpcs/${vpcId}/subnets*`),
paginateResponse(subnets)
);
};

/**
* Intercepts DELETE request to delete a subnet of a VPC and mocks response
*
* @param vpcId - ID of VPC for which to mock response.
* @param subnetId - ID of subnet for which to mock response
*
* @returns Cypress chainable.
*/
export const mockDeleteSubnet = (
vpcId: number,
subnetId: number
): Cypress.Chainable<null> => {
return cy.intercept(
'DELETE',
apiMatcher(`vpcs/${vpcId}/subnets/${subnetId}`),
{}
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface EntityInfo {
| 'Linode'
| 'Load Balancer'
| 'NodeBalancer'
| 'Subnet'
| 'VPC'
| 'Volume';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent } from '@testing-library/react';
import * as React from 'react';

import { subnetFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { SubnetActionMenu } from './SubnetActionMenu';

afterEach(() => {
jest.clearAllMocks();
});

const props = {
handleDelete: jest.fn(),
numLinodes: 1,
subnet: subnetFactory.build({ label: 'subnet-1' }),
vpcId: 1,
};

describe('SubnetActionMenu', () => {
it('should render the subnet action menu', () => {
const screen = renderWithTheme(<SubnetActionMenu {...props} />);
const actionMenu = screen.getByLabelText(`Action menu for Subnet subnet-1`);
fireEvent.click(actionMenu);
screen.getByText('Assign Linode');
screen.getByText('Unassign Linode');
screen.getByText('Edit');
screen.getByText('Delete');
});

it('should not allow the delete button to be clicked', () => {
const screen = renderWithTheme(<SubnetActionMenu {...props} />);
const actionMenu = screen.getByLabelText(`Action menu for Subnet subnet-1`);
fireEvent.click(actionMenu);

const deleteButton = screen.getByText('Delete');
fireEvent.click(deleteButton);
expect(props.handleDelete).not.toHaveBeenCalled();
const tooltipText = screen.getByLabelText(
'Linodes assigned to a subnet must be unassigned before the subnet can be deleted.'
);
expect(tooltipText).toBeInTheDocument();
});

it('should allow the delete button to be clicked', () => {
const screen = renderWithTheme(
<SubnetActionMenu {...props} numLinodes={0} />
);
const actionMenu = screen.getByLabelText(`Action menu for Subnet subnet-1`);
fireEvent.click(actionMenu);

const deleteButton = screen.getByText('Delete');
fireEvent.click(deleteButton);
expect(props.handleDelete).toHaveBeenCalled();
const tooltipText = screen.queryByLabelText(
'Linodes assigned to a subnet must be unassigned before the subnet can be deleted.'
);
expect(tooltipText).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { Subnet } from '@linode/api-v4';
import * as React from 'react';

import { Action, ActionMenu } from 'src/components/ActionMenu';

export const SubnetsActionMenu = ({}) => {
interface SubnetsActionHandlers {
handleDelete: (subnet: Subnet) => void;
}

interface Props extends SubnetsActionHandlers {
numLinodes: number;
subnet: Subnet;
vpcId: number;
}

export const SubnetActionMenu = (props: Props) => {
const { handleDelete, numLinodes, subnet } = props;

const handleAssignLinode = () => {};

const handleUnassignLinode = () => {};

const handleEdit = () => {};

const handleDelete = () => {};

const actions: Action[] = [
{
onClick: () => {
Expand All @@ -33,15 +44,23 @@ export const SubnetsActionMenu = ({}) => {
},
{
onClick: () => {
handleDelete();
handleDelete(subnet);
},
title: 'Delete',
disabled: numLinodes !== 0,
tooltip:
numLinodes > 0
? 'Linodes assigned to a subnet must be unassigned before the subnet can be deleted.'
: '',
},
];

return (
<ActionMenu actionsList={actions} ariaLabel={`Action menu for Subnet`} />
<ActionMenu
actionsList={actions}
ariaLabel={`Action menu for Subnet ${subnet.label}`}
/>
);
};

export default SubnetsActionMenu;
export default SubnetActionMenu;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react';

import { subnetFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { SubnetDeleteDialog } from './SubnetDeleteDialog';

const props = {
onClose: jest.fn(),
open: true,
subnet: subnetFactory.build({ label: 'some subnet' }),
vpcId: 1,
};

describe('Delete Subnet dialog', () => {
it('should render a SubnetDeleteDialog', () => {
const { getByText } = renderWithTheme(<SubnetDeleteDialog {...props} />);

getByText('Delete Subnet some subnet');
getByText('Subnet label');
getByText('Cancel');
getByText('Delete');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Subnet } from '@linode/api-v4';
import { useSnackbar } from 'notistack';
import * as React from 'react';

import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog';
import { useDeleteSubnetMutation } from 'src/queries/vpcs';

interface Props {
onClose: () => void;
open: boolean;
subnet?: Subnet;
vpcId: number;
}

export const SubnetDeleteDialog = (props: Props) => {
const { onClose, open, subnet, vpcId } = props;
const { enqueueSnackbar } = useSnackbar();
const {
error,
isLoading,
mutateAsync: deleteSubnet,
} = useDeleteSubnetMutation(vpcId, subnet?.id ?? -1);

const onDeleteSubnet = async () => {
await deleteSubnet();
enqueueSnackbar('Subnet deleted successfully.', {
variant: 'success',
});
onClose();
};

return (
<TypeToConfirmDialog
entity={{
action: 'deletion',
name: subnet?.label,
primaryBtnText: 'Delete',
type: 'Subnet',
}}
errors={error}
label="Subnet label"
loading={isLoading}
onClick={onDeleteSubnet}
onClose={onClose}
open={open}
title={`Delete Subnet ${subnet?.label}`}
/>
);
};
Loading