From 1480b3d022afe1e04a592f591b3d7612e8651cab Mon Sep 17 00:00:00 2001 From: Ahmet Can Buyukyilmaz Date: Fri, 24 Jan 2025 13:08:38 +0300 Subject: [PATCH] feat(zones): Add delete zones as a table action MAASENG-4308 (#5590) --- src/app/store/utils/node/base.ts | 1 + src/app/zones/constants.ts | 4 +- .../DeleteConfirm/DeleteConfirm.test.tsx | 49 ----------- .../DeleteConfirm/DeleteConfirm.tsx | 50 ----------- .../ZoneDetailsHeader/DeleteConfirm/index.ts | 1 - .../ZoneDetailsHeader.test.tsx | 40 --------- .../ZoneDetailsHeader/ZoneDetailsHeader.tsx | 85 +------------------ src/app/zones/views/ZonesList/ZonesList.tsx | 24 +++++- .../DeleteZone/DeleteZone.test.tsx | 19 +++++ .../ZonesListTable/DeleteZone/DeleteZone.tsx | 42 +++++++++ .../ZonesListTable/DeleteZone/index.ts | 1 + .../ZonesListTable/ZonesListTable.tsx | 18 +++- .../useZonesTableColumns.tsx | 30 ++++++- 13 files changed, 137 insertions(+), 227 deletions(-) delete mode 100644 src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx delete mode 100644 src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.tsx delete mode 100644 src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/index.ts create mode 100644 src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.test.tsx create mode 100644 src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.tsx create mode 100644 src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/index.ts diff --git a/src/app/store/utils/node/base.ts b/src/app/store/utils/node/base.ts index d7c7dcac5b..c25bf50326 100644 --- a/src/app/store/utils/node/base.ts +++ b/src/app/store/utils/node/base.ts @@ -213,6 +213,7 @@ const sidePanelTitleMap: Record = { [SidePanelViews.DOWNLOAD_IMAGE[1]]: "Download image", [SidePanelViews.EDIT_INTERFACE[1]]: "Edit interface", [SidePanelViews.CREATE_ZONE[1]]: "Add AZ", + [SidePanelViews.DELETE_ZONE[1]]: "Delete AZ", [SidePanelViews.EDIT_DISK[1]]: "Edit disk", [SidePanelViews.EDIT_PARTITION[1]]: "Edit partition", [SidePanelViews.EDIT_PHYSICAL[1]]: "Edit physical", diff --git a/src/app/zones/constants.ts b/src/app/zones/constants.ts index a9181e27ef..a35f77eb4a 100644 --- a/src/app/zones/constants.ts +++ b/src/app/zones/constants.ts @@ -4,8 +4,10 @@ import type { SidePanelContent } from "@/app/base/types"; export const ZoneActionSidePanelViews = { CREATE_ZONE: ["zoneForm", "createZone"], + DELETE_ZONE: ["zoneForm", "deleteZone"], } as const; export type ZoneSidePanelContent = SidePanelContent< - ValueOf + ValueOf, + { zoneId: number } >; diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx deleted file mode 100644 index 636931a284..0000000000 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import DeleteConfirm from "./DeleteConfirm"; - -import * as factory from "@/testing/factories"; -import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; - -describe("DeleteConfirm", () => { - const queryData = { - zones: [ - factory.zone({ - id: 1, - name: "zone-name", - }), - ], - }; - - it("runs onConfirm function when Delete AZ is clicked", async () => { - const closeExpanded = vi.fn(); - const onConfirm = vi.fn(); - renderWithBrowserRouter( - , - { route: "/zones", queryData } - ); - - await userEvent.click(screen.getByRole("button", { name: "Delete AZ" })); - expect(onConfirm).toHaveBeenCalled(); - }); - - it("runs closeExpanded function when cancel is clicked", async () => { - const closeExpanded = vi.fn(); - const onConfirm = vi.fn(); - renderWithBrowserRouter( - , - { route: "/zones", queryData } - ); - - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(closeExpanded).toHaveBeenCalled(); - }); -}); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.tsx deleted file mode 100644 index ed2cad3a2b..0000000000 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/DeleteConfirm.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { ReactNode } from "react"; - -import { ActionButton, Button, Col, Row } from "@canonical/react-components"; -import type { ActionButtonProps } from "@canonical/react-components"; - -type Props = { - closeExpanded: () => void; - confirmLabel: string; - deleting: boolean; - message?: ReactNode; - onConfirm: () => void; - submitAppearance?: ActionButtonProps["appearance"]; -}; - -const DeleteConfirm = ({ - closeExpanded, - confirmLabel, - deleting, - message, - onConfirm, - submitAppearance = "negative", -}: Props): JSX.Element => { - return ( - - - {message && ( -

- Warning - {message} -

- )} - - - - - {confirmLabel} - - -
- ); -}; - -export default DeleteConfirm; diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/index.ts b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/index.ts deleted file mode 100644 index f26ade6809..0000000000 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/DeleteConfirm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./DeleteConfirm"; diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx index 845dcf4cae..d8a26f882b 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.test.tsx @@ -57,44 +57,4 @@ describe("ZoneDetailsHeader", () => { ); expect(await findByText("Availability zone not found")).toBeInTheDocument(); }); - - it("shows delete az button when zone id isn't 1", async () => { - renderWithBrowserRouter(, { - state, - queryData, - route: "/zone/2", - }); - - expect( - await screen.findByRole("button", { name: "Delete AZ" }) - ).toBeInTheDocument(); - }); - - it("hides delete button when zone id is 1 (as this is the default)", () => { - renderWithBrowserRouter(, { - state, - queryData, - route: "/zone/1", - }); - - expect(screen.queryByTestId("delete-zone")).not.toBeInTheDocument(); - }); - - it("hides delete button for all zones when user isn't admin", () => { - const nonAdminState = factory.rootState({ - user: factory.userState({ - auth: factory.authState({ - user: factory.user({ is_superuser: false }), - }), - }), - }); - - renderWithBrowserRouter(, { - state: nonAdminState, - queryData, - route: "/zone/2", - }); - - expect(screen.queryByTestId("delete-zone")).not.toBeInTheDocument(); - }); }); diff --git a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx index dc76f7371b..400ff41e6b 100644 --- a/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx +++ b/src/app/zones/views/ZoneDetails/ZoneDetailsHeader/ZoneDetailsHeader.tsx @@ -1,32 +1,15 @@ -import { useEffect, useState } from "react"; - -import { Button } from "@canonical/react-components"; -import { useSelector, useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; - -import DeleteConfirm from "./DeleteConfirm"; +import React from "react"; import { useZoneById } from "@/app/api/query/zones"; import SectionHeader from "@/app/base/components/SectionHeader"; -import urls from "@/app/base/urls"; -import authSelectors from "@/app/store/auth/selectors"; -import type { RootState } from "@/app/store/root/types"; -import { zoneActions } from "@/app/store/zone"; -import { ZONE_ACTIONS } from "@/app/store/zone/constants"; -import zoneSelectors from "@/app/store/zone/selectors"; type Props = { id: number; }; -const ZoneDetailsHeader = ({ id }: Props): JSX.Element => { - const [showConfirm, setShowConfirm] = useState(false); - const deleteStatus = useSelector((state: RootState) => - zoneSelectors.getModelActionStatus(state, ZONE_ACTIONS.delete, id) - ); +const ZoneDetailsHeader: React.FC = ({ id }) => { const zone = useZoneById(id); - const dispatch = useDispatch(); - const navigate = useNavigate(); + let title = ""; if (!zone.isPending) { @@ -35,73 +18,13 @@ const ZoneDetailsHeader = ({ id }: Props): JSX.Element => { : "Availability zone not found"; } - useEffect(() => { - if (deleteStatus === "success") { - dispatch(zoneActions.cleanup([ZONE_ACTIONS.delete])); - navigate({ pathname: urls.zones.index }); - } - }, [dispatch, deleteStatus, navigate]); - - const isAdmin = useSelector(authSelectors.isAdmin); - const isDefaultZone = id === 1; - - const deleteZone = () => { - if (isAdmin && !isDefaultZone) { - dispatch(zoneActions.delete({ id })); - } - }; - - const closeExpanded = () => setShowConfirm(false); - - let buttons: JSX.Element[] | null = [ - , - ]; - - if (showConfirm || isDefaultZone || !isAdmin) { - buttons = null; - } - - let confirmDelete = null; - - if (showConfirm && isAdmin && !isDefaultZone) { - confirmDelete = ( - <> -
- - - ); - } - if (!zone.isPending && zone.data) { title = `Availability zone: ${zone.data.name}`; } else if (zone.isFetched) { title = "Availability zone not found"; - buttons = null; } - return ( - <> - - - {confirmDelete} - - ); + return ; }; export default ZoneDetailsHeader; diff --git a/src/app/zones/views/ZonesList/ZonesList.tsx b/src/app/zones/views/ZonesList/ZonesList.tsx index 78b005d8ea..2448599d5f 100644 --- a/src/app/zones/views/ZonesList/ZonesList.tsx +++ b/src/app/zones/views/ZonesList/ZonesList.tsx @@ -1,3 +1,5 @@ +import React from "react"; + import ZonesListForm from "./ZonesListForm"; import ZonesListHeader from "./ZonesListHeader"; import ZonesListTable from "./ZonesListTable"; @@ -6,9 +8,11 @@ import { useZoneCount } from "@/app/api/query/zones"; import PageContent from "@/app/base/components/PageContent"; import { useWindowTitle } from "@/app/base/hooks"; import { useSidePanel } from "@/app/base/side-panel-context"; +import { getSidePanelTitle } from "@/app/store/utils/node/base"; import { ZoneActionSidePanelViews } from "@/app/zones/constants"; +import DeleteZone from "@/app/zones/views/ZonesList/ZonesListTable/DeleteZone"; -const ZonesList = (): JSX.Element => { +const ZonesList: React.FC = () => { const zonesCount = useZoneCount(); const { sidePanelContent, setSidePanelContent } = useSidePanel(); @@ -28,13 +32,29 @@ const ZonesList = (): JSX.Element => { key="add-zone-form" /> ); + } else if ( + sidePanelContent && + sidePanelContent.view === ZoneActionSidePanelViews.DELETE_ZONE + ) { + const zoneId = + sidePanelContent.extras && "zoneId" in sidePanelContent.extras + ? sidePanelContent.extras.zoneId + : null; + content = zoneId ? ( + { + setSidePanelContent(null); + }} + id={zoneId as number} + /> + ) : null; } return ( } sidePanelContent={content} - sidePanelTitle="Add AZ" + sidePanelTitle={getSidePanelTitle("Zones", sidePanelContent)} > {zonesCount?.data && zonesCount.data > 0 && } diff --git a/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.test.tsx b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.test.tsx new file mode 100644 index 0000000000..5c65fda2e7 --- /dev/null +++ b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.test.tsx @@ -0,0 +1,19 @@ +import { Formik } from "formik"; + +import DeleteZone from "./DeleteZone"; + +import { userEvent, screen, renderWithBrowserRouter } from "@/testing/utils"; + +describe("DeleteZone", () => { + it("calls closeForm on cancel click", async () => { + const closeForm = vi.fn(); + renderWithBrowserRouter( + + + + ); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(closeForm).toHaveBeenCalled(); + }); +}); diff --git a/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.tsx b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.tsx new file mode 100644 index 0000000000..944fcae9e0 --- /dev/null +++ b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/DeleteZone.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +import { useDispatch, useSelector } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import type { RootState } from "@/app/store/root/types"; +import { zoneActions } from "@/app/store/zone"; +import { ZONE_ACTIONS } from "@/app/store/zone/constants"; +import zoneSelectors from "@/app/store/zone/selectors"; + +type DeleteZoneProps = { + closeForm: () => void; + id: number; +}; + +const DeleteZone: React.FC = ({ closeForm, id }) => { + const dispatch = useDispatch(); + const deleteStatus = useSelector((state: RootState) => + zoneSelectors.getModelActionStatus(state, ZONE_ACTIONS.delete, id) + ); + + return ( + { + dispatch(zoneActions.delete({ id })); + }} + onSuccess={() => { + dispatch(zoneActions.cleanup([ZONE_ACTIONS.delete])); + closeForm(); + }} + saved={deleteStatus === "success"} + saving={deleteStatus === "loading"} + /> + ); +}; + +export default DeleteZone; diff --git a/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/index.ts b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/index.ts new file mode 100644 index 0000000000..f06708551e --- /dev/null +++ b/src/app/zones/views/ZonesList/ZonesListTable/DeleteZone/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteZone"; diff --git a/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx b/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx index f44367040e..87348d36af 100644 --- a/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx +++ b/src/app/zones/views/ZonesList/ZonesListTable/ZonesListTable.tsx @@ -1,17 +1,33 @@ import React from "react"; import { TableCaption } from "@canonical/maas-react-components"; +import { useSelector } from "react-redux"; import { useZones } from "@/app/api/query/zones"; import GenericTable from "@/app/base/components/GenericTable"; +import { useSidePanel } from "@/app/base/side-panel-context"; +import authSelectors from "@/app/store/auth/selectors"; +import { ZoneActionSidePanelViews } from "@/app/zones/constants"; import useZonesTableColumns from "@/app/zones/views/ZonesList/ZonesListTable/useZonesTableColumns/useZonesTableColumns"; import "./_index.scss"; const ZonesListTable: React.FC = () => { + const { setSidePanelContent } = useSidePanel(); const zones = useZones(); - const columns = useZonesTableColumns(); + const isAdmin = useSelector(authSelectors.isAdmin); + const columns = useZonesTableColumns({ + isAdmin, + onDelete: (row) => { + setSidePanelContent({ + view: ZoneActionSidePanelViews.DELETE_ZONE, + extras: { + zoneId: row.original.id, + }, + }); + }, + }); return ( zone: [name], }); -const useZonesTableColumns = (): ZoneColumnDef[] => { +const useZonesTableColumns = ({ + isAdmin, + onDelete, +}: { + isAdmin: boolean; + onDelete: (row: Row) => void; +}): ZoneColumnDef[] => { return [ { id: "name", @@ -84,6 +91,25 @@ const useZonesTableColumns = (): ZoneColumnDef[] => { ); }, }, + { + id: "action", + accessorKey: "id", + enableSorting: false, + header: "Action", + cell: ({ row }: { row: Row }) => { + const canBeDeleted = isAdmin && row.original.id !== 1; + return ( + onDelete(row)} + /> + ); + }, + }, ]; };