diff --git a/cypress/e2e/with-users/subnets/add.spec.ts b/cypress/e2e/with-users/subnets/add.spec.ts index bc51a22b55..6eab330e33 100644 --- a/cypress/e2e/with-users/subnets/add.spec.ts +++ b/cypress/e2e/with-users/subnets/add.spec.ts @@ -30,21 +30,6 @@ context("Subnets - Add", () => { cy.findByRole("button", { name: "Add VLAN" }).click(); }; - const completeAddSubnetForm = ( - subnetName: string, - cidr: string, - fabric: string, - vid: string, - vlan: string - ) => { - openAddForm("Subnet"); - cy.findByRole("textbox", { name: "CIDR" }).type(cidr); - cy.findByRole("textbox", { name: "Name" }).type(subnetName); - cy.findByRole("combobox", { name: "Fabric" }).select(fabric); - cy.findByRole("combobox", { name: "VLAN" }).select(`${vid} (${vlan})`); - cy.findByRole("button", { name: "Add Subnet" }).click(); - }; - const completeForm = (formName: string, name: string) => { openAddForm(formName); cy.findByRole("textbox", { name: "Name (optional)" }).type(name); @@ -115,7 +100,7 @@ context("Subnets - Add", () => { completeForm("Fabric", fabricName); completeForm("Space", spaceName); completeAddVlanForm(vid, vlanName, fabricName, spaceName); - completeAddSubnetForm(subnetName, cidr, fabricName, vid, vlanName); + cy.addSubnet(subnetName, cidr, fabricName, vid, vlanName); cy.findAllByRole("link", { name: fabricName }).should("have.length", 2); diff --git a/cypress/e2e/with-users/subnets/staticroutes.spec.ts b/cypress/e2e/with-users/subnets/staticroutes.spec.ts new file mode 100644 index 0000000000..5a1a9828c7 --- /dev/null +++ b/cypress/e2e/with-users/subnets/staticroutes.spec.ts @@ -0,0 +1,80 @@ +import { generateMAASURL } from "../../utils"; + +context("Static Routes", () => { + beforeEach(() => { + cy.login(); + cy.visit(generateMAASURL("/networks?by=fabric")); + cy.waitForPageToLoad(); + cy.viewport("macbook-11"); + }); + it("allows adding, editing, and deleting a static route", () => { + // Add static route + cy.findByRole("grid", { name: "Subnets by Fabric" }).within(() => { + cy.get("tbody").find('td[aria-label="subnet"]').find("a").first().click(); + }); + cy.findByRole("heading", { level: 1 }).invoke("text").as("subnet"); + + cy.findByRole("button", { name: /add static route/i }).click(); + cy.get("@subnet").then((subnet: unknown) => { + cy.findByRole("complementary", { name: /Add static route/i }).within( + () => { + const staticRoute = (subnet as string).split("/")[0]; + cy.wrap(staticRoute).as("staticRoute"); + cy.findByLabelText(/gateway ip/i).type(staticRoute); + cy.findByLabelText(/destination/i).select(1); + } + ); + }); + cy.findByRole("button", { name: /save/i }).click(); + cy.findByRole("complementary", { name: /Add static route/i }).should( + "not.exist" + ); + + // Edit static route + cy.findByRole("region", { name: /Static routes/i }).within(() => { + cy.get("tbody tr") + .first() + .findByRole("button", { name: /edit/i }) + .click(); + }); + + cy.findByRole("complementary", { name: /Edit static route/i }).within( + () => { + cy.findByLabelText(/gateway ip/i).type("{Backspace}1"); + cy.findByRole("button", { name: /save/i }).click(); + } + ); + + // Verify the change has been saved and side panel closed + cy.get("@staticRoute").then((staticRoute: unknown) => { + cy.findByRole("region", { name: /Static routes/i }).within(() => { + cy.findByText(staticRoute as string); + }); + }); + cy.findByRole("complementary", { name: /Add static route/i }).should( + "not.exist" + ); + + // Delete the static route + cy.findByRole("region", { name: /Static routes/i }).within(() => { + cy.get("tbody tr") + .first() + .findByRole("gridcell", { name: /actions/i }) + .findByRole("button", { name: /delete/i }) + .click(); + }); + + // Verify it's been deleted and side panel closed + cy.findByRole("complementary", { name: /Delete static route/i }).within( + () => { + cy.findByRole("button", { name: /delete/i }).click(); + } + ); + cy.get("@staticRoute").then((staticRoute: unknown) => { + cy.findByRole("region", { name: /Static routes/i }).within(() => { + cy.findByText(staticRoute as string).should("not.exist"); + }); + }); + cy.findByRole("region", { name: /side panel/i }).should("not.exist"); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 62270f420c..43228ab1af 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,7 +1,13 @@ import "@testing-library/cypress/add-commands"; import type { Result } from "axe-core"; import { LONG_TIMEOUT } from "../constants"; -import { generateMAASURL, generateMac, generateName } from "../e2e/utils"; +import { + generateId, + generateMAASURL, + generateMac, + generateName, + generateVid, +} from "../e2e/utils"; import type { A11yPageContext } from "./e2e"; Cypress.Commands.add("login", (options) => { @@ -101,6 +107,26 @@ Cypress.Commands.add("addMachines", (hostnames: string[]) => { }); }); +Cypress.Commands.add( + "addSubnet", + ({ + subnetName = `cy-subnet-${generateId()}`, + cidr = "192.168.122.18", + fabric = `cy-fabric-${generateId()}`, + vid = generateVid(), + vlan = `cy-vlan-${vid}`, + }) => { + cy.visit(generateMAASURL("/networks?by=fabric")); + cy.findByRole("button", { name: "Add" }).click(); + cy.findByRole("button", { name: "Subnet" }).click(); + cy.findByRole("textbox", { name: "CIDR" }).type(cidr); + cy.findByRole("textbox", { name: "Name" }).type(subnetName); + cy.findByRole("combobox", { name: "Fabric" }).select(fabric); + cy.findByRole("combobox", { name: "VLAN" }).select(`${vid} (${vlan})`); + cy.findByRole("button", { name: "Add Subnet" }).click(); + } +); + function logViolations(violations: Result[], pageContext: A11yPageContext) { const divider = "\n====================================================================================================\n"; diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index c240aa1415..18cf436bee 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -6,6 +6,13 @@ import "@percy/cypress"; import "./commands"; export type A11yPageContext = { url?: string; title?: string }; +export type SubnetOptions = { + subnetName?: string; + cidr?: string; + fabric?: string; + vid?: string; + vlan?: string; +}; declare global { namespace Cypress { interface Chainable { @@ -27,6 +34,7 @@ declare global { }): Cypress.Chainable>; getMainNavigation(): Cypress.Chainable>; expandMainNavigation(): void; + addSubnet(options: SubnetOptions): void; } } } diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx index da6a323414..86f4fc5f7d 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx @@ -12,6 +12,7 @@ const mockStore = configureStore(); const subnet = factory.subnet({ id: 1, cidr: "172.16.1.0/24" }); const destinationSubnet = factory.subnet({ id: 2, cidr: "223.16.1.0/24" }); +const staticroute = factory.staticRoute({ id: 1, destination: subnet.id }); state = factory.rootState({ user: factory.userState({ auth: factory.authState({ @@ -21,7 +22,7 @@ state = factory.rootState({ }), staticroute: factory.staticRouteState({ loaded: true, - items: [], + items: [staticroute], }), subnet: factory.subnetState({ loaded: true, @@ -31,7 +32,10 @@ state = factory.rootState({ it("renders", () => { renderWithBrowserRouter( - , + , { state } ); @@ -41,7 +45,10 @@ it("renders", () => { it("dispatches the correct action to delete a static route", async () => { const store = mockStore(state); renderWithBrowserRouter( - , + , { store } ); @@ -51,5 +58,5 @@ it("dispatches the correct action to delete a static route", async () => { .getActions() .find((action) => action.type === staticRouteActions.delete.type); - expect(action).toStrictEqual(staticRouteActions.delete(subnet.id)); + expect(action).toStrictEqual(staticRouteActions.delete(staticroute.id)); }); diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx index 1c8c4dee25..ba1b121359 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx @@ -4,17 +4,24 @@ import ModelActionForm from "@/app/base/components/ModelActionForm"; import type { SetSidePanelContent } from "@/app/base/side-panel-context"; import { staticRouteActions } from "@/app/store/staticroute"; import staticRouteSelectors from "@/app/store/staticroute/selectors"; -import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; +import type { + StaticRoute, + StaticRouteMeta, +} from "@/app/store/staticroute/types"; type Props = { - id: Subnet[SubnetMeta.PK]; + staticRouteId?: StaticRoute[StaticRouteMeta.PK]; setActiveForm: SetSidePanelContent; }; -const DeleteStaticRouteForm = ({ id, setActiveForm }: Props) => { +const DeleteStaticRouteForm = ({ staticRouteId, setActiveForm }: Props) => { const dispatch = useDispatch(); const saved = useSelector(staticRouteSelectors.saved); const saving = useSelector(staticRouteSelectors.saving); + + if (!staticRouteId) { + return null; + } return ( { modelType="static route" onCancel={() => setActiveForm(null)} onSubmit={() => { - dispatch(staticRouteActions.delete(id)); + dispatch(staticRouteActions.delete(staticRouteId)); + }} + onSuccess={() => { + setActiveForm(null); }} saved={saved} saving={saving} diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx index fe079aabfe..c05b90804d 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx @@ -28,7 +28,7 @@ it("displays loading text on load", async () => { render( - + ); @@ -69,7 +69,10 @@ it("dispatches a correct action on edit static route form submit", async () => { render( - + ); diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx index 3cd9c20c2b..50886b46a6 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx @@ -39,11 +39,11 @@ const editStaticRouteSchema = Yup.object().shape({ }); export type Props = { - id: StaticRoute[StaticRouteMeta.PK]; + staticRouteId?: StaticRoute[StaticRouteMeta.PK]; setActiveForm: SetSidePanelContent; }; const EditStaticRouteForm = ({ - id, + staticRouteId, setActiveForm, }: Props): JSX.Element | null => { const staticRouteErrors = useSelector(staticRouteSelectors.errors); @@ -55,7 +55,7 @@ const EditStaticRouteForm = ({ const subnetsLoading = useSelector(subnetSelectors.loading); const loading = staticRoutesLoading || subnetsLoading; const staticRoute = useSelector((state: RootState) => - staticRouteSelectors.getById(state, id) + staticRouteSelectors.getById(state, staticRouteId) ); const source = useSelector((state: RootState) => subnetSelectors.getById(state, staticRoute?.source) @@ -63,7 +63,7 @@ const EditStaticRouteForm = ({ useFetchActions([staticRouteActions.fetch, subnetActions.fetch]); - if (!staticRoute || loading) { + if (!staticRouteId || !staticRoute || loading) { return ( ); @@ -90,7 +90,7 @@ const EditStaticRouteForm = ({ dispatch(staticRouteActions.cleanup()); dispatch( staticRouteActions.update({ - id: id, + id: staticRouteId, source: staticRoute.source, gateway_ip, destination: toFormikNumber(destination) as number, diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx index d5774cbba4..36ba566066 100644 --- a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx +++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx @@ -67,6 +67,7 @@ const generateRows = ( view: SubnetDetailsSidePanelViews[ SubnetActionTypes.DeleteStaticRoute ], + extras: { staticRouteId: staticRoute.id }, }); }} onEdit={() => { @@ -74,6 +75,7 @@ const generateRows = ( view: SubnetDetailsSidePanelViews[ SubnetActionTypes.EditStaticRoute ], + extras: { staticRouteId: staticRoute.id }, }); }} /> diff --git a/src/app/subnets/views/SubnetDetails/SubnetDetails.tsx b/src/app/subnets/views/SubnetDetails/SubnetDetails.tsx index 47de74085e..2dc2d67a1f 100644 --- a/src/app/subnets/views/SubnetDetails/SubnetDetails.tsx +++ b/src/app/subnets/views/SubnetDetails/SubnetDetails.tsx @@ -88,6 +88,7 @@ const SubnetDetails = (): JSX.Element => { activeForm={activeForm} id={subnet.id} setActiveForm={setSidePanelContent} + {...sidePanelContent?.extras} /> ) : null } diff --git a/src/app/subnets/views/SubnetDetails/SubnetDetailsHeader/SubnetActionForms/SubnetActionForms.tsx b/src/app/subnets/views/SubnetDetails/SubnetDetailsHeader/SubnetActionForms/SubnetActionForms.tsx index d4788b7c19..e3caeed2bb 100644 --- a/src/app/subnets/views/SubnetDetails/SubnetDetailsHeader/SubnetActionForms/SubnetActionForms.tsx +++ b/src/app/subnets/views/SubnetDetails/SubnetDetailsHeader/SubnetActionForms/SubnetActionForms.tsx @@ -32,6 +32,7 @@ const SubnetActionForms = ({ id, activeForm, setActiveForm, + staticRouteId, }: SubnetActionProps): JSX.Element => { const FormComponent = activeForm ? FormComponents[activeForm] : () => null; @@ -40,6 +41,7 @@ const SubnetActionForms = ({ activeForm={activeForm} id={id} setActiveForm={setActiveForm} + staticRouteId={staticRouteId} /> ); }; diff --git a/src/app/subnets/views/SubnetDetails/constants.ts b/src/app/subnets/views/SubnetDetails/constants.ts index 8511d6270f..6990177f4f 100644 --- a/src/app/subnets/views/SubnetDetails/constants.ts +++ b/src/app/subnets/views/SubnetDetails/constants.ts @@ -47,5 +47,5 @@ export const SubnetDetailsSidePanelViews = { export type SubnetDetailsSidePanelContent = SidePanelContent< ValueOf, - { createType?: IPRangeType; ipRangeId?: number } + { createType?: IPRangeType; ipRangeId?: number; staticRouteId?: number } >; diff --git a/src/app/subnets/views/SubnetDetails/types.ts b/src/app/subnets/views/SubnetDetails/types.ts index dded4a3050..4cbc3f9432 100644 --- a/src/app/subnets/views/SubnetDetails/types.ts +++ b/src/app/subnets/views/SubnetDetails/types.ts @@ -1,12 +1,17 @@ import type { SubnetActionTypes } from "./constants"; import type { SetSidePanelContent } from "@/app/base/side-panel-context"; +import type { + StaticRoute, + StaticRouteMeta, +} from "@/app/store/staticroute/types"; import type { Subnet, SubnetMeta } from "@/app/store/subnet/types"; export type SubnetAction = keyof typeof SubnetActionTypes; export interface SubnetActionProps { id: Subnet[SubnetMeta.PK]; + staticRouteId?: StaticRoute[StaticRouteMeta.PK]; activeForm: SubnetAction; setActiveForm: SetSidePanelContent; }