diff --git a/src/pages/EntityDetails/Model/Logs/ActionLogs/ActionPayloadModal/ActionPayloadModal.test.tsx b/src/pages/EntityDetails/Model/Logs/ActionLogs/ActionPayloadModal/ActionPayloadModal.test.tsx index b7feb0cb7..307c06d2d 100644 --- a/src/pages/EntityDetails/Model/Logs/ActionLogs/ActionPayloadModal/ActionPayloadModal.test.tsx +++ b/src/pages/EntityDetails/Model/Logs/ActionLogs/ActionPayloadModal/ActionPayloadModal.test.tsx @@ -25,7 +25,6 @@ describe("ActionPayloadModal", () => { } = renderComponent( , ); - expect(container.tagName).toBe("DIV"); expect(container.children.length).toBe(1); expect(container.firstChild).toBeEmptyDOMElement(); }); diff --git a/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.test.tsx b/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.test.tsx index 497c780e1..96fb35680 100644 --- a/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.test.tsx +++ b/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.test.tsx @@ -19,7 +19,7 @@ import { import { renderComponent } from "testing/utils"; import CharmActionsPanel from "./CharmActionsPanel"; -import { Label } from "./types"; +import { ConfirmationDialogLabel } from "./ConfirmationDialog"; vi.mock("juju/api-hooks/actions", () => { return { @@ -271,16 +271,20 @@ describe("CharmActionsPanel", () => { await screen.findByRole("button", { name: "Run action" }), ); await userEvent.click( - await screen.findByRole("button", { name: Label.CONFIRM_BUTTON }), + await screen.findByRole("button", { + name: ConfirmationDialogLabel.CONFIRM_BUTTON, + }), ); const call = executeActionOnUnitsSpy.mock.calls[0]; expect(call[0]).toEqual(["ceph-0", "ceph-1"]); expect(call[1]).toBe("pause"); expect(call[2]).toEqual({}); // no options - expect(await screen.findByText(Label.ACTION_SUCCESS)).toBeInTheDocument(); + expect( + await screen.findByText(ConfirmationDialogLabel.ACTION_SUCCESS), + ).toBeInTheDocument(); }); - it("submits the action request to the api with options that are required", async () => { + it("should pass the selected action form values to the API call", async () => { const executeActionOnUnitsSpy = vi .fn() .mockImplementation(() => Promise.resolve()); @@ -308,7 +312,9 @@ describe("CharmActionsPanel", () => { await screen.findByRole("button", { name: "Run action" }), ); await userEvent.click( - await screen.findByRole("button", { name: Label.CONFIRM_BUTTON }), + await screen.findByRole("button", { + name: ConfirmationDialogLabel.CONFIRM_BUTTON, + }), ); const call = executeActionOnUnitsSpy.mock.calls[0]; expect(call[0]).toEqual(["ceph-0", "ceph-1"]); @@ -319,40 +325,6 @@ describe("CharmActionsPanel", () => { }); }); - it("handles API errors", async () => { - const executeActionOnUnitsSpy = vi - .fn() - .mockImplementation(() => Promise.reject(new Error())); - vi.spyOn(actionsHooks, "useExecuteActionOnUnits").mockImplementation( - () => executeActionOnUnitsSpy, - ); - renderComponent( - , - { path, url, state }, - ); - expect( - await screen.findByRole("button", { name: "Run action" }), - ).toBeDisabled(); - await userEvent.click(await screen.findByRole("radio", { name: "pause" })); - expect( - await screen.findByRole("button", { name: "Run action" }), - ).not.toBeDisabled(); - await userEvent.click( - await screen.findByRole("button", { name: "Run action" }), - ); - await userEvent.click( - await screen.findByRole("button", { name: Label.CONFIRM_BUTTON }), - ); - const call = executeActionOnUnitsSpy.mock.calls[0]; - expect(call[0]).toEqual(["ceph-0", "ceph-1"]); - expect(call[1]).toBe("pause"); - expect(call[2]).toEqual({}); // no options - expect(await screen.findByText(Label.ACTION_ERROR)).toBeInTheDocument(); - }); - it("should cancel the run selected action confirmation modal", async () => { renderComponent( { screen.queryByRole("dialog", { name: "Run pause?" }), ).toBeInTheDocument(); await userEvent.click( - await screen.findByRole("button", { name: Label.CANCEL_BUTTON }), + await screen.findByRole("button", { + name: ConfirmationDialogLabel.CANCEL_BUTTON, + }), ); expect( screen.queryByRole("dialog", { name: "Run pause?" }), @@ -409,9 +383,13 @@ describe("CharmActionsPanel", () => { await screen.findByRole("button", { name: "Run action" }), ); await userEvent.click( - screen.getByRole("button", { name: Label.CONFIRM_BUTTON }), + screen.getByRole("button", { + name: ConfirmationDialogLabel.CONFIRM_BUTTON, + }), ); expect(executeActionOnUnitsSpy).toHaveBeenCalledTimes(1); - expect(screen.getByText(Label.ACTION_ERROR)).toBeInTheDocument(); + expect( + screen.getByText(ConfirmationDialogLabel.ACTION_ERROR), + ).toBeInTheDocument(); }); }); diff --git a/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.tsx b/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.tsx index 6ab12d6c4..ab39e6b8c 100644 --- a/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.tsx +++ b/src/panels/ActionsPanel/CharmActionsPanel/CharmActionsPanel.tsx @@ -1,17 +1,11 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import { Button, ConfirmationModal } from "@canonical/react-components"; +import { Button } from "@canonical/react-components"; import { useCallback, useMemo, useRef, useState } from "react"; -import reactHotToast from "react-hot-toast"; import { useSelector } from "react-redux"; -import { useParams } from "react-router-dom"; -import usePortal from "react-useportal"; import LoadingHandler from "components/LoadingHandler"; import Panel from "components/Panel"; import RadioInputBox from "components/RadioInputBox"; -import ToastCard from "components/ToastCard"; -import useAnalytics from "hooks/useAnalytics"; -import { useExecuteActionOnUnits } from "juju/api-hooks"; import ActionOptions from "panels/ActionsPanel/ActionOptions"; import type { ActionOptionValue, @@ -26,7 +20,7 @@ import { getSelectedCharm, } from "store/juju/selectors"; -import { Label } from "./types"; +import ConfirmationDialog from "./ConfirmationDialog"; const filterExist = (item: I | null): item is I => !!item; @@ -39,9 +33,6 @@ export default function CharmActionsPanel({ charmURL, onRemovePanelQueryParams, }: Props): JSX.Element { - const sendAnalytics = useAnalytics(); - const { userName, modelName } = useParams(); - const { Portal } = usePortal(); const [disableSubmit, setDisableSubmit] = useState(true); const [confirmType, setConfirmType] = useState(null); const [selectedAction, setSelectedAction] = useState(); @@ -49,7 +40,6 @@ export default function CharmActionsPanel({ const selectedApplications = useSelector(getSelectedApplications(charmURL)); const selectedCharm = useSelector(getSelectedCharm(charmURL)); - const executeActionOnUnits = useExecuteActionOnUnits(userName, modelName); const actionData = useMemo( () => selectedCharm?.actions?.specs || {}, [selectedCharm], @@ -59,47 +49,6 @@ export default function CharmActionsPanel({ 0, ); - const executeAction = () => { - sendAnalytics({ - category: "ApplicationSearch", - action: "Run action (final step)", - }); - - if (!selectedAction) return; - executeActionOnUnits( - // transform applications to unit list for the API - selectedApplications - .map((a) => - Array(a["unit-count"]) - .fill("name" in a ? a.name : null) - .filter(Boolean) - .map((unit, i) => `${unit}-${i}`), - ) - .flat(), - selectedAction, - actionOptionsValues.current[selectedAction], - ) - .then((payload) => { - const error = payload?.actions?.find((e) => e.error); - if (error) { - throw error; - } - reactHotToast.custom((t) => ( - - {Label.ACTION_SUCCESS} - - )); - return; - }) - .catch(() => { - reactHotToast.custom((t) => ( - - {Label.ACTION_ERROR} - - )); - }); - }; - const handleSubmit = () => { setConfirmType(ConfirmType.SUBMIT); }; @@ -136,43 +85,6 @@ export default function CharmActionsPanel({ [actionData], ); - const generateConfirmationModal = () => { - if (confirmType && selectedAction) { - // Allow for adding more confirmation types, like for cancel - // if inputs have been changed. - if (confirmType === "submit") { - const unitCount = selectedApplications.reduce( - (total, app) => total + (app["unit-count"] || 0), - 0, - ); - // Render the submit confirmation modal. - return ( - - { - setConfirmType(null); - executeAction(); - onRemovePanelQueryParams(); - }} - close={() => setConfirmType(null)} - > -

- APPLICATION COUNT (UNIT COUNT) -

-

- {selectedApplications.length} ({unitCount}) -

-
-
- ); - } - } - }; - return ( ))} - {generateConfirmationModal()} + {selectedAction ? ( + + ) : null} ); diff --git a/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.test.tsx b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.test.tsx new file mode 100644 index 000000000..4f2b643a8 --- /dev/null +++ b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.test.tsx @@ -0,0 +1,170 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi } from "vitest"; + +import * as actionsHooks from "juju/api-hooks/actions"; +import { ConfirmType } from "panels/types"; +import type { RootState } from "store/store"; +import { rootStateFactory } from "testing/factories"; +import { applicationCharmActionParamsFactory } from "testing/factories/juju/ActionV7"; +import { + charmInfoFactory, + charmActionSpecFactory, + charmApplicationFactory, +} from "testing/factories/juju/Charms"; +import { jujuStateFactory } from "testing/factories/juju/juju"; +import { renderComponent } from "testing/utils"; + +import ConfirmationDialog from "./ConfirmationDialog"; +import { Label } from "./types"; + +describe("ConfirmationDialog", () => { + let state: RootState; + const path = "/models/:userName/:modelName/app/:appName"; + const url = + "/models/user-eggman@external/group-test/app/kubernetes-master?panel=select-charms-and-actions"; + + const mockSelectedApplcations = [ + charmApplicationFactory.build({ name: "ceph" }), + ]; + + beforeAll(() => { + state = rootStateFactory.build({ + juju: jujuStateFactory.build({ + charms: [ + charmInfoFactory.build({ + url: "ch:ceph", + actions: { + specs: { + "add-disk": charmActionSpecFactory.build({ + params: applicationCharmActionParamsFactory.build({ + properties: { + bucket: { + type: "string", + }, + "osd-devices": { + type: "string", + }, + }, + required: ["osd-devices"], + title: "add-disk", + type: "object", + }), + }), + }, + }, + }), + ], + }), + }); + }); + + afterEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("should return null if the confirm type is not 'submit'", () => { + const { + result: { container }, + } = renderComponent( + , + ); + expect(container.children.length).toBe(1); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it("should display submit confirmation dialog and can cancel running selected action", async () => { + const mockSetConfirmType = vi.fn(); + renderComponent( + , + ); + + expect( + screen.getByRole("dialog", { name: "Run stdout?" }), + ).toBeInTheDocument(); + const cancelButton = screen.getByRole("button", { + name: Label.CANCEL_BUTTON, + }); + expect(cancelButton).toBeInTheDocument(); + expect(screen.getByText("2 (4)")).toBeInTheDocument(); + await userEvent.click(cancelButton); + expect(mockSetConfirmType).toHaveBeenCalledWith(null); + }); + + it("should run selected action and remove panel query params", async () => { + const mockSetConfirmType = vi.fn(); + const mockOnRemovePanelQueryParams = vi.fn(); + const executeActionOnUnitsSpy = vi + .fn() + .mockImplementation(() => Promise.resolve()); + vi.spyOn(actionsHooks, "useExecuteActionOnUnits").mockImplementation( + () => executeActionOnUnitsSpy, + ); + renderComponent( + , + { state, path, url }, + ); + await userEvent.click( + screen.getByRole("button", { name: Label.CONFIRM_BUTTON }), + ); + expect(mockSetConfirmType).toHaveBeenCalledWith(null); + const call = executeActionOnUnitsSpy.mock.calls[0]; + expect(call[1]).toBe("add-disk"); + expect(call[0]).toEqual(["ceph-0", "ceph-1"]); + expect(call[2]).toEqual({ + bucket: "", + "osd-devices": "new device", + }); + expect(await screen.findByText(Label.ACTION_SUCCESS)).toBeInTheDocument(); + expect(mockOnRemovePanelQueryParams).toHaveBeenCalledOnce(); + }); + + it("should display error toast if action fails", async () => { + const executeActionOnUnitsSpy = vi + .fn() + .mockImplementation(() => Promise.reject(new Error())); + vi.spyOn(actionsHooks, "useExecuteActionOnUnits").mockImplementation( + () => executeActionOnUnitsSpy, + ); + renderComponent( + , + { state, path, url }, + ); + await userEvent.click( + screen.getByRole("button", { name: Label.CONFIRM_BUTTON }), + ); + expect(await screen.findByText(Label.ACTION_ERROR)).toBeInTheDocument(); + }); +}); diff --git a/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.tsx b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 000000000..2f87884d9 --- /dev/null +++ b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,122 @@ +import { ConfirmationModal } from "@canonical/react-components"; +import reactHotToast from "react-hot-toast"; +import { useParams } from "react-router-dom"; +import usePortal from "react-useportal"; + +import ToastCard from "components/ToastCard"; +import useAnalytics from "hooks/useAnalytics"; +import { useExecuteActionOnUnits } from "juju/api-hooks"; +import type { ApplicationInfo } from "juju/types"; +import type { ActionOptionValue } from "panels/ActionsPanel/types"; +import { ConfirmType, type ConfirmTypes } from "panels/types"; + +import { Label } from "./types"; + +type Props = { + confirmType: ConfirmTypes; + selectedAction: string; + selectedApplications: ApplicationInfo[]; + setConfirmType: React.Dispatch>; + selectedActionOptionValue: ActionOptionValue; + onRemovePanelQueryParams: () => void; +}; + +const executeAction = ( + sendAnalytics: ReturnType, + selectedAction: string, + selectedActionOptionValue: ActionOptionValue, + executeActionOnUnits: ReturnType, + selectedApplications: ApplicationInfo[], +) => { + sendAnalytics({ + category: "ApplicationSearch", + action: "Run action (final step)", + }); + + executeActionOnUnits( + // transform applications to unit list for the API + selectedApplications + .map((a) => + Array(a["unit-count"]) + .fill("name" in a ? a.name : null) + .filter(Boolean) + .map((unit, i) => `${unit}-${i}`), + ) + .flat(), + selectedAction, + selectedActionOptionValue, + ) + .then((payload) => { + const error = payload?.actions?.find((e) => e.error); + if (error) { + throw error; + } + reactHotToast.custom((t) => ( + + {Label.ACTION_SUCCESS} + + )); + return; + }) + .catch(() => { + reactHotToast.custom((t) => ( + + {Label.ACTION_ERROR} + + )); + }); +}; + +const ConfirmationDialog = ({ + confirmType, + selectedAction, + selectedApplications, + setConfirmType, + selectedActionOptionValue, + onRemovePanelQueryParams, +}: Props): JSX.Element | null => { + const { Portal } = usePortal(); + const { userName, modelName } = useParams(); + const sendAnalytics = useAnalytics(); + const executeActionOnUnits = useExecuteActionOnUnits(userName, modelName); + + if (confirmType === ConfirmType.SUBMIT) { + const unitCount = selectedApplications.reduce( + (total, app) => total + (app["unit-count"] || 0), + 0, + ); + // Render the submit confirmation modal. + return ( + + { + setConfirmType(null); + executeAction( + sendAnalytics, + selectedAction, + selectedActionOptionValue, + executeActionOnUnits, + selectedApplications, + ); + onRemovePanelQueryParams(); + }} + close={() => setConfirmType(null)} + > +

+ APPLICATION COUNT (UNIT COUNT) +

+

+ {selectedApplications.length} ({unitCount}) +

+
+
+ ); + } + return null; +}; + +export default ConfirmationDialog; diff --git a/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/index.ts b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/index.ts new file mode 100644 index 000000000..dcb77806f --- /dev/null +++ b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from "./ConfirmationDialog"; +export { Label as ConfirmationDialogLabel } from "./types"; diff --git a/src/panels/ActionsPanel/CharmActionsPanel/types.ts b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/types.ts similarity index 70% rename from src/panels/ActionsPanel/CharmActionsPanel/types.ts rename to src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/types.ts index 764ca791c..bb3b6e1ba 100644 --- a/src/panels/ActionsPanel/CharmActionsPanel/types.ts +++ b/src/panels/ActionsPanel/CharmActionsPanel/ConfirmationDialog/types.ts @@ -1,5 +1,4 @@ export enum Label { - NONE_SELECTED = "You need to select a charm and applications to continue.", ACTION_ERROR = "Some of the actions failed to execute", ACTION_SUCCESS = "Action successfully executed.", CANCEL_BUTTON = "Cancel", diff --git a/src/panels/ActionsPanel/CharmActionsPanel/index.ts b/src/panels/ActionsPanel/CharmActionsPanel/index.ts index 41aa42e3e..379a3fdbf 100644 --- a/src/panels/ActionsPanel/CharmActionsPanel/index.ts +++ b/src/panels/ActionsPanel/CharmActionsPanel/index.ts @@ -1,2 +1 @@ export { default } from "./CharmActionsPanel"; -export { Label as CharmActionsPanelLabel } from "./types";