diff --git a/ui/src/app/machines/views/MachineDetails/MachineDetails.tsx b/ui/src/app/machines/views/MachineDetails/MachineDetails.tsx index 39dfda6a1e..33a63c19be 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineDetails.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineDetails.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from "react"; import type { RouteParams } from "app/base/types"; import { machine as machineActions } from "app/base/actions"; import MachineHeader from "./MachineHeader"; -import MachineNotifications from "./MachineNotifications"; +import SummaryNotifications from "./MachineSummary/SummaryNotifications"; import machineSelectors from "app/store/machine/selectors"; import MachineStorage from "./MachineStorage"; import MachineSummary, { SelectedAction } from "./MachineSummary"; @@ -51,10 +51,10 @@ const MachineDetails = (): JSX.Element => { } headerClassName="u-no-padding--bottom" > - {machine && ( + diff --git a/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.test.tsx b/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.test.tsx index 575a7de3e5..ba32e940be 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.test.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.test.tsx @@ -1,213 +1,39 @@ -import { MemoryRouter, Route } from "react-router-dom"; import { mount } from "enzyme"; -import { Provider } from "react-redux"; -import configureStore from "redux-mock-store"; import React from "react"; -import { - architecturesState as architecturesStateFactory, - generalState as generalStateFactory, - machine as machineFactory, - machineEvent as machineEventFactory, - machineState as machineStateFactory, - powerType as powerTypeFactory, - powerTypesState as powerTypesStateFactory, - rootState as rootStateFactory, -} from "testing/factories"; import MachineNotifications from "./MachineNotifications"; -import type { RootState } from "app/store/root/types"; - -const mockStore = configureStore(); describe("MachineNotifications", () => { - let state: RootState; - beforeEach(() => { - state = rootStateFactory({ - general: generalStateFactory({ - architectures: architecturesStateFactory({ - data: ["amd64"], - }), - powerTypes: powerTypesStateFactory({ - data: [powerTypeFactory()], - }), - }), - machine: machineStateFactory({ - items: [ - machineFactory({ - architecture: "amd64", - events: [machineEventFactory()], - system_id: "abc123", - }), - ], - }), - }); - }); - - it("handles no notifications", () => { - const store = mockStore(state); + it("can render", () => { const wrapper = mount( - - - } - /> - - + ); - expect(wrapper.find("Notification").exists()).toBe(false); + expect(wrapper.find("MachineNotifications")).toMatchSnapshot(); }); - it("can display a power error", () => { - state.machine.items = [ - machineFactory({ - architecture: "amd64", - events: [ - machineEventFactory({ - description: "machine timed out", - }), - ], - power_state: "error", - system_id: "abc123", - }), - ]; - const store = mockStore(state); + it("ignores inactive notifications", () => { const wrapper = mount( - - - } - /> - - + ); - expect( - wrapper - .findWhere( - (n) => - n.name() === "Notification" && - n.text().includes("Script - machine timed out") - ) - .exists() - ).toBe(true); - }); - - it("can display a rack connection error", () => { - state.general.powerTypes.data = []; - const store = mockStore(state); - const wrapper = mount( - - - } - /> - - - ); - expect( - wrapper - .findWhere( - (n) => - n.name() === "Notification" && - n.text().includes("no rack controller is currently connected") - ) - .exists() - ).toBe(true); - }); - - it("can display an architecture error", () => { - state.machine.items[0].architecture = ""; - const store = mockStore(state); - const wrapper = mount( - - - } - /> - - - ); - expect( - wrapper - .findWhere( - (n) => - n.name() === "Notification" && - n - .text() - .includes("This machine currently has an invalid architecture") - ) - .exists() - ).toBe(true); - }); - - it("can display a boot images error", () => { - state.general.architectures = architecturesStateFactory({ - data: [], - }); - const store = mockStore(state); - const wrapper = mount( - - - } - /> - - - ); - expect( - wrapper - .findWhere( - (n) => - n.name() === "Notification" && - n.text().includes("No boot images have been imported") - ) - .exists() - ).toBe(true); - }); - - it("can display a hardware error", () => { - state.machine.items[0].cpu_count = 0; - const store = mockStore(state); - const wrapper = mount( - - - } - /> - - - ); - expect( - wrapper - .findWhere( - (n) => - n.name() === "Notification" && - n.text().includes("Commission this machine to get CPU") - ) - .exists() - ).toBe(true); + expect(wrapper.find("Notification").exists()).toBe(false); }); }); diff --git a/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.tsx b/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.tsx index 2a02db8273..4f9f8405c5 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineNotifications/MachineNotifications.tsx @@ -1,130 +1,36 @@ -import { Link } from "react-router-dom"; -import { Notification, Spinner } from "@canonical/react-components"; -import { useParams } from "react-router"; -import { useDispatch, useSelector } from "react-redux"; -import React, { useEffect } from "react"; - -import { general as generalActions } from "app/base/actions"; -import { - useCanEdit, - useHasInvalidArchitecture, - useIsRackControllerConnected, -} from "app/store/machine/utils"; -import generalSelectors from "app/store/general/selectors"; -import LegacyLink from "app/base/components/LegacyLink"; -import machineSelectors from "app/store/machine/selectors"; -import type { RootState } from "app/store/root/types"; -import type { Event } from "app/store/machine/types"; - -type RouteParams = { - id: string; +import { Notification } from "@canonical/react-components"; +import React from "react"; +import type { ReactNode } from "react"; + +type MachineNotification = { + active: boolean; + content: ReactNode; + status?: string; + type?: "caution" | "negative" | "positive" | "information"; }; -const formatEventText = (event: Event) => { - if (!event) { - return ""; - } - const text = []; - if (event.type?.description) { - text.push(event.type.description); - } - if (event.description) { - text.push(event.description); - } - return text.join(" - "); +type Props = { + notifications: MachineNotification[]; }; -const MachineNotifications = (): JSX.Element => { - const dispatch = useDispatch(); - const params = useParams(); - const { id } = params; - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, id) - ); - const architectures = useSelector(generalSelectors.architectures.get); - const hasUsableArchitectures = architectures.length > 0; - const canEdit = useCanEdit(machine, true); - const isRackControllerConnected = useIsRackControllerConnected(); - const hasInvalidArchitecture = useHasInvalidArchitecture(machine); - - useEffect(() => { - dispatch(generalActions.fetchArchitectures()); - }, [dispatch]); - - // Confirm that the full machine details have been fetched. This also allows - // TypeScript know we're using the right union type (otherwise it will - // complain that events don't exist on the base machine type). - if (!machine || !("events" in machine)) { - return ; - } - const notifications = [ - { - active: machine.power_state === "error" && machine.events?.length > 0, - content: ( - <> - {formatEventText(machine.events[0])}.{" "} - - See logs - - - ), - status: "Error:", - type: "negative", - }, - { - active: canEdit && !isRackControllerConnected, - content: - "Editing is currently disabled because no rack controller is currently connected to the region.", - status: "Error:", - type: "negative", - }, - { - active: - canEdit && - hasInvalidArchitecture && - isRackControllerConnected && - hasUsableArchitectures, - content: - "This machine currently has an invalid architecture. Update the architecture of this machine to make it deployable.", - status: "Error:", - type: "negative", - }, - { - active: - canEdit && - hasInvalidArchitecture && - isRackControllerConnected && - !hasUsableArchitectures, - content: ( - <> - No boot images have been imported for a valid architecture to be - selected. Visit the{" "} - images page to start the - import process. - - ), - status: "Error:", - type: "negative", - }, - { - active: machine.cpu_count === 0, - content: - "Commission this machine to get CPU, Memory and Storage information.", +const MachineNotifications = ({ notifications }: Props): JSX.Element => { + const notificationList = notifications.reduce( + (collection, { active, content, status, type }, i) => { + if (active) { + collection.push( + + {content} + + ); + } + return collection; }, - ].filter(({ active }) => active); + [] + ); return (
-
- {notifications.map(({ content, status, type }, i) => ( - - {content} - - ))} -
+
{notificationList}
); }; diff --git a/ui/src/app/machines/views/MachineDetails/MachineNotifications/__snapshots__/MachineNotifications.test.tsx.snap b/ui/src/app/machines/views/MachineDetails/MachineNotifications/__snapshots__/MachineNotifications.test.tsx.snap new file mode 100644 index 0000000000..008131102f --- /dev/null +++ b/ui/src/app/machines/views/MachineDetails/MachineNotifications/__snapshots__/MachineNotifications.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MachineNotifications can render 1`] = ` + +
+
+ +
+

+ + Error: + + Editing is currently disabled because no rack controller is currently connected to the region. +

+
+
+
+
+
+`; diff --git a/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.test.tsx b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.test.tsx new file mode 100644 index 0000000000..2ceb606e09 --- /dev/null +++ b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.test.tsx @@ -0,0 +1,189 @@ +import { MemoryRouter } from "react-router-dom"; +import { mount } from "enzyme"; +import { Provider } from "react-redux"; +import configureStore from "redux-mock-store"; +import React from "react"; + +import { + architecturesState as architecturesStateFactory, + generalState as generalStateFactory, + machine as machineFactory, + machineEvent as machineEventFactory, + machineState as machineStateFactory, + powerType as powerTypeFactory, + powerTypesState as powerTypesStateFactory, + rootState as rootStateFactory, +} from "testing/factories"; +import SummaryNotifications from "./SummaryNotifications"; +import type { RootState } from "app/store/root/types"; + +const mockStore = configureStore(); + +describe("SummaryNotifications", () => { + let state: RootState; + beforeEach(() => { + state = rootStateFactory({ + general: generalStateFactory({ + architectures: architecturesStateFactory({ + data: ["amd64"], + }), + powerTypes: powerTypesStateFactory({ + data: [powerTypeFactory()], + }), + }), + machine: machineStateFactory({ + items: [ + machineFactory({ + architecture: "amd64", + events: [machineEventFactory()], + system_id: "abc123", + }), + ], + }), + }); + }); + + it("handles no notifications", () => { + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect(wrapper.find("Notification").exists()).toBe(false); + }); + + it("can display a power error", () => { + state.machine.items = [ + machineFactory({ + architecture: "amd64", + events: [ + machineEventFactory({ + description: "machine timed out", + }), + ], + power_state: "error", + system_id: "abc123", + }), + ]; + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect( + wrapper + .findWhere( + (n) => + n.name() === "Notification" && + n.text().includes("Script - machine timed out") + ) + .exists() + ).toBe(true); + }); + + it("can display a rack connection error", () => { + state.general.powerTypes.data = []; + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect( + wrapper + .findWhere( + (n) => + n.name() === "Notification" && + n.text().includes("no rack controller is currently connected") + ) + .exists() + ).toBe(true); + }); + + it("can display an architecture error", () => { + state.machine.items[0].architecture = ""; + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect( + wrapper + .findWhere( + (n) => + n.name() === "Notification" && + n + .text() + .includes("This machine currently has an invalid architecture") + ) + .exists() + ).toBe(true); + }); + + it("can display a boot images error", () => { + state.general.architectures = architecturesStateFactory({ + data: [], + }); + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect( + wrapper + .findWhere( + (n) => + n.name() === "Notification" && + n.text().includes("No boot images have been imported") + ) + .exists() + ).toBe(true); + }); + + it("can display a hardware error", () => { + state.machine.items[0].cpu_count = 0; + const store = mockStore(state); + const wrapper = mount( + + + + + + ); + expect( + wrapper + .findWhere( + (n) => + n.name() === "Notification" && + n.text().includes("Commission this machine to get CPU") + ) + .exists() + ).toBe(true); + }); +}); diff --git a/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.tsx b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.tsx new file mode 100644 index 0000000000..c5b0a4944c --- /dev/null +++ b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/SummaryNotifications.tsx @@ -0,0 +1,123 @@ +import { Link } from "react-router-dom"; +import { Spinner } from "@canonical/react-components"; +import { useDispatch, useSelector } from "react-redux"; +import React, { useEffect } from "react"; + +import { general as generalActions } from "app/base/actions"; +import { + useCanEdit, + useHasInvalidArchitecture, + useIsRackControllerConnected, +} from "app/store/machine/utils"; +import generalSelectors from "app/store/general/selectors"; +import LegacyLink from "app/base/components/LegacyLink"; +import MachineNotifications from "app/machines/views/MachineDetails/MachineNotifications"; +import machineSelectors from "app/store/machine/selectors"; +import type { RootState } from "app/store/root/types"; +import type { Event, Machine } from "app/store/machine/types"; + +type Props = { + id: Machine["system_id"]; +}; + +const formatEventText = (event: Event) => { + if (!event) { + return ""; + } + const text = []; + if (event.type?.description) { + text.push(event.type.description); + } + if (event.description) { + text.push(event.description); + } + return text.join(" - "); +}; + +const SummaryNotifications = ({ id }: Props): JSX.Element => { + const dispatch = useDispatch(); + const machine = useSelector((state: RootState) => + machineSelectors.getById(state, id) + ); + const architectures = useSelector(generalSelectors.architectures.get); + const hasUsableArchitectures = architectures.length > 0; + const canEdit = useCanEdit(machine, true); + const isRackControllerConnected = useIsRackControllerConnected(); + const hasInvalidArchitecture = useHasInvalidArchitecture(machine); + + useEffect(() => { + dispatch(generalActions.fetchArchitectures()); + }, [dispatch]); + + // Confirm that the full machine details have been fetched. This also allows + // TypeScript know we're using the right union type (otherwise it will + // complain that events don't exist on the base machine type). + if (!machine || !("events" in machine)) { + return ; + } + + return ( + 0, + content: ( + <> + {formatEventText(machine.events[0])}.{" "} + + See logs + + + ), + status: "Error:", + type: "negative", + }, + { + active: canEdit && !isRackControllerConnected, + content: + "Editing is currently disabled because no rack controller is currently connected to the region.", + status: "Error:", + type: "negative", + }, + { + active: + canEdit && + hasInvalidArchitecture && + isRackControllerConnected && + hasUsableArchitectures, + content: + "This machine currently has an invalid architecture. Update the architecture of this machine to make it deployable.", + status: "Error:", + type: "negative", + }, + { + active: + canEdit && + hasInvalidArchitecture && + isRackControllerConnected && + !hasUsableArchitectures, + content: ( + <> + No boot images have been imported for a valid architecture to be + selected. Visit the{" "} + images page to start the + import process. + + ), + status: "Error:", + type: "negative", + }, + { + active: machine.cpu_count === 0, + content: + "Commission this machine to get CPU, Memory and Storage information.", + }, + ]} + /> + ); +}; + +export default SummaryNotifications; diff --git a/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/index.ts b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/index.ts new file mode 100644 index 0000000000..994d4b0ac4 --- /dev/null +++ b/ui/src/app/machines/views/MachineDetails/MachineSummary/SummaryNotifications/index.ts @@ -0,0 +1 @@ +export { default } from "./SummaryNotifications";