diff --git a/ui/src/components/DeletePanelButton/DeletePanelButton.test.tsx b/ui/src/components/DeletePanelButton/DeletePanelButton.test.tsx new file mode 100644 index 000000000..baa677660 --- /dev/null +++ b/ui/src/components/DeletePanelButton/DeletePanelButton.test.tsx @@ -0,0 +1,224 @@ +import { screen, waitFor } from "@testing-library/dom"; +import * as reactQuery from "@tanstack/react-query"; +import userEvent from "@testing-library/user-event"; +import { + NotificationConsumer, + NotificationProvider, +} from "@canonical/react-components"; + +import { renderComponent } from "test/utils"; + +import DeletePanelButton from "./DeletePanelButton"; +import { Label } from "./types"; +import { Location } from "react-router-dom"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +beforeEach(() => { + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries: vi.fn(), + } as unknown as reactQuery.QueryClient); +}); + +test("displays the delete button", () => { + renderComponent( + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + />, + ); + expect( + screen.getByRole("button", { name: Label.DELETE }), + ).toBeInTheDocument(); +}); + +test("displays a confirmation", async () => { + renderComponent( + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + />, + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + expect(screen.getByRole("dialog", { name: "Define" })).toBeInTheDocument(); +}); + +test("can disable the confirm button", async () => { + renderComponent( + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + />, + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled(); +}); + +test("starts deletion", async () => { + renderComponent( + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + />, + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect(document.querySelector(".u-animation--spin")).toBeInTheDocument(); +}); + +test("calls the delete method", async () => { + const onDelete = vi.fn().mockImplementation(() => Promise.resolve()); + renderComponent( + , + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect(onDelete).toHaveBeenCalled(); +}); + +test("handles a successful delete call", async () => { + let location: Location | null = null; + renderComponent( + + + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + /> + , + { + setLocation: (newLocation) => { + location = newLocation; + }, + }, + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect( + screen + .getByText("successfully formed") + .closest(".p-notification--positive"), + ).toBeInTheDocument(); + expect((location as Location | null)?.pathname).toBe("/nebulous"); +}); + +test("notifies on error", async () => { + renderComponent( + + + Promise.reject("Oops")} + successPath="/nebulous" + successMessage="successfully formed" + /> + , + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect( + screen + .getByText("Nebulous deletion failed") + .closest(".p-notification--negative"), + ).toBeInTheDocument(); + expect(screen.getByText("Oops")).toHaveClass("p-notification__message"); +}); + +test("notifies on error object", async () => { + renderComponent( + + + Promise.reject(new Error("Oops"))} + successPath="/nebulous" + successMessage="successfully formed" + /> + , + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + expect( + screen + .getByText("Nebulous deletion failed") + .closest(".p-notification--negative"), + ).toBeInTheDocument(); + expect(screen.getByText("Oops")).toHaveClass("p-notification__message"); +}); + +test("invlidates queries and hides the spinner on success", async () => { + const invalidateQueries = vi.fn(); + vi.spyOn(reactQuery, "useQueryClient").mockReturnValue({ + invalidateQueries, + } as unknown as reactQuery.QueryClient); + renderComponent( + Promise.resolve()} + successPath="/nebulous" + successMessage="successfully formed" + />, + ); + await userEvent.click(screen.getByRole("button", { name: Label.DELETE })); + await userEvent.click(screen.getByRole("button", { name: "Confirm" })); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["nebulous"], + }), + ); + await waitFor(() => + expect( + document.querySelector(".u-animation--spin"), + ).not.toBeInTheDocument(), + ); +}); diff --git a/ui/src/components/DeletePanelButton/DeletePanelButton.tsx b/ui/src/components/DeletePanelButton/DeletePanelButton.tsx new file mode 100644 index 000000000..8b939f07c --- /dev/null +++ b/ui/src/components/DeletePanelButton/DeletePanelButton.tsx @@ -0,0 +1,62 @@ +import { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { Label, Props } from "./types"; + +const DeletePanelButton: FC = ({ + confirmButtonDisabled, + confirmButtonLabel, + confirmContent, + confirmTitle = "Confirm delete", + invalidateQuery, + entityName, + onDelete, + successMessage, + successPath, +}) => { + const notify = useNotify(); + const queryClient = useQueryClient(); + const [isLoading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleDelete = () => { + setLoading(true); + onDelete() + .then(() => { + navigate(successPath, notify.queue(notify.success(successMessage))); + }) + .catch((error: unknown) => { + notify.failure( + `${entityName} deletion failed`, + error instanceof Error ? error : null, + typeof error === "string" ? error : null, + ); + }) + .finally(() => { + setLoading(false); + void queryClient.invalidateQueries({ + queryKey: [invalidateQuery], + }); + }); + }; + + return ( + + {Label.DELETE} + + ); +}; + +export default DeletePanelButton; diff --git a/ui/src/components/DeletePanelButton/index.ts b/ui/src/components/DeletePanelButton/index.ts new file mode 100644 index 000000000..8bfe6fd14 --- /dev/null +++ b/ui/src/components/DeletePanelButton/index.ts @@ -0,0 +1,2 @@ +export { default } from "./DeletePanelButton"; +export { Label as DeletePanelButtonLabel } from "./types"; diff --git a/ui/src/components/DeletePanelButton/types.ts b/ui/src/components/DeletePanelButton/types.ts new file mode 100644 index 000000000..33dfc7bf4 --- /dev/null +++ b/ui/src/components/DeletePanelButton/types.ts @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; + +export type Props = { + confirmButtonLabel: string; + confirmButtonDisabled?: boolean; + confirmTitle?: string; + confirmContent: ReactNode; + entityName: string; + onDelete: () => Promise; + successMessage: string; + successPath: string; + invalidateQuery: string; +}; + +export enum Label { + DELETE = "Delete", +} diff --git a/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.test.tsx b/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.test.tsx new file mode 100644 index 000000000..e8dca66a6 --- /dev/null +++ b/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.test.tsx @@ -0,0 +1,27 @@ +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { renderComponent } from "test/utils"; + +import DeleteClientBtn from "./DeleteClientBtn"; +import { mockClient } from "test/mocks/clients"; +import { DeletePanelButtonLabel } from "components/DeletePanelButton"; +import MockAdapter from "axios-mock-adapter"; +import { axiosInstance } from "api/axios"; +import { Label } from "./types"; + +const mock = new MockAdapter(axiosInstance); + +beforeEach(() => { + mock.reset(); +}); + +test("deletes the client", async () => { + const client = mockClient(); + renderComponent(); + await userEvent.click( + screen.getByRole("button", { name: DeletePanelButtonLabel.DELETE }), + ); + await userEvent.click(screen.getByRole("button", { name: Label.CONFIRM })); + expect(mock.history.delete[0].url).toBe(`/clients/${client.client_id}`); +}); diff --git a/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.tsx b/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.tsx index 75bba83e5..ef99a8fbc 100644 --- a/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.tsx +++ b/ui/src/pages/clients/DeleteClientBtn/DeleteClientBtn.tsx @@ -1,59 +1,30 @@ -import { FC, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { FC } from "react"; import { queryKeys } from "util/queryKeys"; -import { useQueryClient } from "@tanstack/react-query"; -import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { deleteClient } from "api/client"; import { Client } from "types/client"; +import DeletePanelButton from "components/DeletePanelButton"; +import { urls } from "urls"; +import { Label } from "./types"; interface Props { client: Client; } const DeleteClientBtn: FC = ({ client }) => { - const notify = useNotify(); - const queryClient = useQueryClient(); - const [isLoading, setLoading] = useState(false); - const navigate = useNavigate(); - - const handleDelete = () => { - setLoading(true); - deleteClient(client.client_id) - .then(() => { - navigate( - "/client", - notify.queue(notify.success(`Client ${client.client_name} deleted.`)), - ); - }) - .catch((e) => { - notify.failure("Client deletion failed", e); - }) - .finally(() => { - setLoading(false); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.clients], - }); - }); - }; - return ( - - This will permanently delete client {client.client_name}. -

- ), - confirmButtonLabel: "Delete client", - onConfirm: handleDelete, - }} - title="Confirm delete" - > - Delete -
+ + This will permanently delete client {client.client_name}. +

+ } + entityName="Client" + invalidateQuery={queryKeys.clients} + onDelete={() => deleteClient(client.client_id)} + successPath={urls.clients.index} + successMessage={`Client ${client.client_name} deleted.`} + /> ); }; diff --git a/ui/src/pages/clients/DeleteClientBtn/types.ts b/ui/src/pages/clients/DeleteClientBtn/types.ts new file mode 100644 index 000000000..daae85977 --- /dev/null +++ b/ui/src/pages/clients/DeleteClientBtn/types.ts @@ -0,0 +1,3 @@ +export enum Label { + CONFIRM = "Delete client", +} diff --git a/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.test.tsx b/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.test.tsx new file mode 100644 index 000000000..7d0e8575f --- /dev/null +++ b/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.test.tsx @@ -0,0 +1,27 @@ +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { renderComponent } from "test/utils"; + +import DeleteIdentityBtn from "./DeleteIdentityBtn"; +import { Label } from "./types"; +import { mockIdentity } from "test/mocks/identities"; +import { DeletePanelButtonLabel } from "components/DeletePanelButton"; +import MockAdapter from "axios-mock-adapter"; +import { axiosInstance } from "api/axios"; + +const mock = new MockAdapter(axiosInstance); + +beforeEach(() => { + mock.reset(); +}); + +test("deletes the identity", async () => { + const identity = mockIdentity(); + renderComponent(); + await userEvent.click( + screen.getByRole("button", { name: DeletePanelButtonLabel.DELETE }), + ); + await userEvent.click(screen.getByRole("button", { name: Label.CONFIRM })); + expect(mock.history.delete[0].url).toBe(`/identities/${identity.id}`); +}); diff --git a/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.tsx b/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.tsx index 9cca023e7..73e4fded3 100644 --- a/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.tsx +++ b/ui/src/pages/identities/DeleteIdentityBtn/DeleteIdentityBtn.tsx @@ -1,62 +1,30 @@ -import { FC, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { FC } from "react"; import { queryKeys } from "util/queryKeys"; -import { useQueryClient } from "@tanstack/react-query"; -import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { Identity } from "types/identity"; import { deleteIdentity } from "api/identities"; +import { urls } from "urls"; +import DeletePanelButton from "components/DeletePanelButton"; +import { Label } from "./types"; interface Props { identity: Identity; } const DeleteIdentityBtn: FC = ({ identity }) => { - const notify = useNotify(); - const queryClient = useQueryClient(); - const [isLoading, setLoading] = useState(false); - const navigate = useNavigate(); - - const handleDelete = () => { - setLoading(true); - deleteIdentity(identity.id) - .then(() => { - navigate( - "/identity", - notify.queue( - notify.success(`Identity ${identity.traits?.email} deleted.`), - ), - ); - }) - .catch((e) => { - notify.failure("Identity deletion failed", e); - }) - .finally(() => { - setLoading(false); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.identities], - }); - }); - }; - return ( - - This will permanently delete identity{" "} - {identity.traits?.email}. -

- ), - confirmButtonLabel: "Delete identity", - onConfirm: handleDelete, - }} - title="Confirm delete" - > - Delete -
+ + This will permanently delete identity {identity.traits?.email}. +

+ } + entityName="Identity" + invalidateQuery={queryKeys.identities} + onDelete={() => deleteIdentity(identity.id)} + successPath={urls.identities.index} + successMessage={`Identity ${identity.traits?.email} deleted.`} + /> ); }; diff --git a/ui/src/pages/identities/DeleteIdentityBtn/types.ts b/ui/src/pages/identities/DeleteIdentityBtn/types.ts new file mode 100644 index 000000000..d0fc23632 --- /dev/null +++ b/ui/src/pages/identities/DeleteIdentityBtn/types.ts @@ -0,0 +1,3 @@ +export enum Label { + CONFIRM = "Delete identity", +} diff --git a/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.test.tsx b/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.test.tsx new file mode 100644 index 000000000..5ef3725c0 --- /dev/null +++ b/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.test.tsx @@ -0,0 +1,62 @@ +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { renderComponent } from "test/utils"; + +import DeleteProviderBtn from "./DeleteProviderBtn"; +import { Label } from "./types"; +import { mockIdentityProvider } from "test/mocks/providers"; +import { DeletePanelButtonLabel } from "components/DeletePanelButton"; +import MockAdapter from "axios-mock-adapter"; +import { axiosInstance } from "api/axios"; +import { + NotificationConsumer, + NotificationProvider, +} from "@canonical/react-components"; + +const mock = new MockAdapter(axiosInstance); + +beforeEach(() => { + mock.reset(); +}); + +test("disables confirmation if the confirm text hasn't been entered", async () => { + const provider = mockIdentityProvider({ id: "provider1" }); + renderComponent(); + await userEvent.click( + screen.getByRole("button", { name: DeletePanelButtonLabel.DELETE }), + ); + expect(screen.getByRole("button", { name: Label.CONFIRM })).toBeDisabled(); +}); + +test("deletes the provider", async () => { + const provider = mockIdentityProvider({ id: "provider1" }); + renderComponent(); + await userEvent.click( + screen.getByRole("button", { name: DeletePanelButtonLabel.DELETE }), + ); + await userEvent.type(screen.getByRole("textbox"), `remove ${provider.id}`); + await userEvent.click(screen.getByRole("button", { name: Label.CONFIRM })); + expect(mock.history.delete[0].url).toBe(`/idps/${provider.id}`); +}); + +test("displays an error if the provider doesn't have an id", async () => { + const provider = mockIdentityProvider({ id: undefined }); + renderComponent( + + + + , + ); + await userEvent.click( + screen.getByRole("button", { name: DeletePanelButtonLabel.DELETE }), + ); + await userEvent.type(screen.getByRole("textbox"), "remove "); + await userEvent.click(screen.getByRole("button", { name: Label.CONFIRM })); + expect(mock.history.delete).toHaveLength(0); + expect( + screen + .getByText("Provider deletion failed") + .closest(".p-notification--negative"), + ).toBeInTheDocument(); +}); diff --git a/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.tsx b/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.tsx index bc660f81b..fdb6e02fb 100644 --- a/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.tsx +++ b/ui/src/pages/providers/DeleteProviderBtn/DeleteProviderBtn.tsx @@ -1,114 +1,59 @@ import { FC, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { queryKeys } from "util/queryKeys"; -import { useQueryClient } from "@tanstack/react-query"; -import { - ActionButton, - Button, - Icon, - Input, - Modal, - useNotify, -} from "@canonical/react-components"; +import { Input } from "@canonical/react-components"; import { deleteProvider } from "api/provider"; import { IdentityProvider } from "types/provider"; -import usePortal from "react-useportal"; +import { urls } from "urls"; +import DeletePanelButton from "components/DeletePanelButton"; +import { Label } from "./types"; interface Props { provider: IdentityProvider; } const DeleteProviderBtn: FC = ({ provider }) => { - const notify = useNotify(); - const queryClient = useQueryClient(); - const [isLoading, setLoading] = useState(false); - const navigate = useNavigate(); - const { openPortal, closePortal, isOpen, Portal } = usePortal(); const [confirmText, setConfirmText] = useState(""); - - const handleDelete = () => { - setLoading(true); - if (!provider.id) { - console.error("Cannot delete provider without id", provider); - return; - } - deleteProvider(provider.id) - .then(() => { - navigate( - "/provider", - notify.queue(notify.success(`Provider ${provider.id} deleted.`)), - ); - }) - .catch((e) => { - notify.failure("Provider deletion failed", e); - }) - .finally(() => { - setLoading(false); - void queryClient.invalidateQueries({ - queryKey: [queryKeys.providers], - }); - }); - }; - - const expectedConfirmText = `remove ${provider.id}`; + const expectedConfirmText = `remove ${provider.id || ""}`; return ( - <> - {isOpen && ( - - +

+ Are you sure you want to remove {'"'} + {provider.id} + {'"'} as an ID provider? The removal of {provider.id} as an ID + provider is irreversible and might adversely affect your system. +

+ setConfirmText(e.target.value)} + value={confirmText} + type="text" + placeholder={expectedConfirmText} + label={ <> - - - - Remove - + Type {expectedConfirmText} to confirm } - > -

- Are you sure you want to remove {'"'} - {provider.id} - {'"'} as an ID provider? The removal of {provider.id} as an ID - provider is irreversible and might adversely affect your system. -

- setConfirmText(e.target.value)} - value={confirmText} - type="text" - placeholder={expectedConfirmText} - label={ - <> - Type {expectedConfirmText} to confirm - - } - /> -
-
- )} - - + /> + + } + confirmTitle="Remove ID provider" + entityName="Provider" + invalidateQuery={queryKeys.providers} + onDelete={() => { + if (!provider.id) { + const error = "Cannot delete provider without id"; + console.error(error, provider); + return Promise.reject(error); + } + return deleteProvider(provider.id); + }} + successPath={urls.providers.index} + successMessage={`Provider ${provider.id} deleted.`} + /> ); }; diff --git a/ui/src/pages/providers/DeleteProviderBtn/types.ts b/ui/src/pages/providers/DeleteProviderBtn/types.ts new file mode 100644 index 000000000..bc7a26ff2 --- /dev/null +++ b/ui/src/pages/providers/DeleteProviderBtn/types.ts @@ -0,0 +1,3 @@ +export enum Label { + CONFIRM = "Remove", +}