From 170c15fa3ebc243e6962f246bb1239b73a1486b1 Mon Sep 17 00:00:00 2001 From: Caleb Ellis Date: Thu, 5 Nov 2020 10:13:59 +1000 Subject: [PATCH] feat(ui): add power actions to machine details header (#1840) --- ui/.betterer.results | 11 +- ui/src/app/base/hooks.js | 55 +++++---- .../MachineHeader/MachineHeader.test.tsx | 111 +++++++++++++++++- .../MachineHeader/MachineHeader.tsx | 32 ++++- 4 files changed, 174 insertions(+), 35 deletions(-) diff --git a/ui/.betterer.results b/ui/.betterer.results index c307f15773..572d24cc13 100644 --- a/ui/.betterer.results +++ b/ui/.betterer.results @@ -7,7 +7,7 @@ exports[`stricter compilation`] = { [162, 4, 36, "Object is possibly \'null\'.", "1039669632"] ], "src/app/App.tsx:3872899616": [ - [21, 7, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/Users/kit/src/canonical/local/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"], + [21, 7, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/home/caleb/Projects/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"], [188, 17, 17, "Object is possibly \'null\'.", "2133029343"], [193, 7, 7, "Variable \'content\' is used before being assigned.", "3716929964"] ], @@ -44,7 +44,7 @@ exports[`stricter compilation`] = { [75, 4, 5, "Argument of type \'boolean | undefined\' is not assignable to parameter of type \'boolean\'.\\n Type \'undefined\' is not assignable to type \'boolean\'.", "195688512"] ], "src/app/base/components/LegacyLink/LegacyLink.tsx:2706551295": [ - [4, 52, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/Users/kit/src/canonical/local/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"] + [4, 52, 25, "Could not find a declaration file for module \'@maas-ui/maas-ui-shared\'. \'/home/caleb/Projects/maas-ui/shared/dist/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/maas-ui__maas-ui-shared\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@maas-ui/maas-ui-shared\';\`", "1778274862"] ], "src/app/base/components/NotificationGroup/Notification/Notification.tsx:122297593": [ [26, 26, 12, "Argument of type \'Notification | null\' is not assignable to parameter of type \'Notification\'.\\n Type \'null\' is not assignable to type \'Notification\'.\\n Type \'null\' is not assignable to type \'Model\'.", "148512008"], @@ -86,7 +86,7 @@ exports[`stricter compilation`] = { [214, 7, 11, "Property \'placeholder\' is missing in type \'{ disabledTags: { id: number; name: string; }[]; initialSelected: { id: number; name: string; }[]; tags: { id: number; name: string; }[]; }\' but required in type \'Props\'.", "3766634306"] ], "src/app/base/components/TagSelector/TagSelector.tsx:2755544058": [ - [1, 18, 51, "Could not find a declaration file for module \'@canonical/react-components/dist/components/Field\'. \'/Users/kit/src/canonical/local/maas-ui/node_modules/@canonical/react-components/dist/components/Field/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/canonical__react-components\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@canonical/react-components/dist/components/Field\';\`", "1535046059"], + [1, 18, 51, "Could not find a declaration file for module \'@canonical/react-components/dist/components/Field\'. \'/home/caleb/Projects/maas-ui/node_modules/@canonical/react-components/dist/components/Field/index.js\' implicitly has an \'any\' type.\\n Try \`npm install @types/canonical__react-components\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'@canonical/react-components/dist/components/Field\';\`", "1535046059"], [37, 2, 12, "Binding element \'allowNewTags\' implicitly has an \'any\' type.", "3979358209"], [38, 2, 6, "Binding element \'filter\' implicitly has an \'any\' type.", "1355726373"], [39, 2, 12, "Binding element \'selectedTags\' implicitly has an \'any\' type.", "2698915821"], @@ -342,8 +342,9 @@ exports[`stricter compilation`] = { [29, 28, 9, "Property \'setActive\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "1891567915"], [33, 30, 9, "Property \'setActive\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "1891567915"] ], - "src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx:2666992142": [ - [37, 28, 3, "Property \'get\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "193411891"] + "src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx:914617473": [ + [40, 28, 3, "Property \'get\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "193411891"], + [105, 42, 10, "Property \'checkPower\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "953482588"] ], "src/app/machines/views/MachineDetails/MachineSummary/MachineSummary.tsx:564355202": [ [42, 28, 3, "Property \'get\' does not exist on type \'{ fetch: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { model: any; method: string; }>; create: ActionCreatorWithPreparedPayload<[params?: any], { params: any; }, string, never, { ...; }>; update: ActionCreatorWithPreparedPayload<...>; delete: ActionCreatorWithPreparedPayload<......\'.", "193411891"] diff --git a/ui/src/app/base/hooks.js b/ui/src/app/base/hooks.js index e5d2aad525..2e099207bf 100644 --- a/ui/src/app/base/hooks.js +++ b/ui/src/app/base/hooks.js @@ -184,7 +184,12 @@ export const useSendAnalyticsWhen = ( * @param {String} noneMessage - The message to display if there are no items. * @param {Function} onClick - A function to call when the item is clicked. */ -export const useMachineActions = (systemId, actions, noneMessage, onClick) => { +export const useMachineActions = ( + systemId, + actions, + noneMessage = null, + onClick = null +) => { const dispatch = useDispatch(); const generalMachineActions = useSelector( generalSelectors.machineActions.get @@ -193,32 +198,34 @@ export const useMachineActions = (systemId, actions, noneMessage, onClick) => { machineSelectors.getById(state, systemId) ); let actionLinks = []; - actions.forEach((action) => { - if (machine.actions.includes(action)) { - let actionLabel = action; - generalMachineActions.forEach((machineAction) => { - if (machineAction.name === action) { - actionLabel = machineAction.title; - } - }); + if (machine) { + actions.forEach((action) => { + if (machine.actions.includes(action)) { + let actionLabel = action; + generalMachineActions.forEach((machineAction) => { + if (machineAction.name === action) { + actionLabel = machineAction.title; + } + }); - actionLinks.push({ - children: actionLabel, - onClick: () => { - const actionMethod = kebabToCamelCase(action); - dispatch(machineActions[actionMethod](systemId)); - onClick && onClick(); + actionLinks.push({ + children: actionLabel, + onClick: () => { + const actionMethod = kebabToCamelCase(action); + dispatch(machineActions[actionMethod](systemId)); + onClick && onClick(); + }, + }); + } + }); + if (actionLinks.length === 0 && noneMessage) { + return [ + { + children: noneMessage, + disabled: true, }, - }); + ]; } - }); - if (actionLinks.length === 0 && noneMessage) { - return [ - { - children: noneMessage, - disabled: true, - }, - ]; } return actionLinks; }; diff --git a/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.test.tsx b/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.test.tsx index e501634e8f..c89955a771 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.test.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.test.tsx @@ -129,12 +129,121 @@ describe("MachineHeader", () => { ); - expect(wrapper.find(".p-icon--power-checking").exists()).toBe(true); + expect(wrapper.find(".p-icon--spinner").exists()).toBe(true); expect(wrapper.find("[data-test='machine-header-power']").text()).toBe( "Checking power" ); }); + describe("power menu", () => { + it("can dispatch the power on action", () => { + state.machine.items[0].actions = ["on"]; + const store = mockStore(state); + const wrapper = mount( + + + ( + + )} + /> + + + ); + + // Open the power menu dropdown + wrapper.find("TableMenu Button").simulate("click"); + // Click the "Power on" link + wrapper + .find("TableMenu .p-contextual-menu__link") + .at(0) + .simulate("click"); + + expect( + store.getActions().some((action) => action.type === "TURN_MACHINE_ON") + ).toBe(true); + }); + + it("can dispatch the power off action", () => { + state.machine.items[0].actions = ["off"]; + const store = mockStore(state); + const wrapper = mount( + + + ( + + )} + /> + + + ); + + // Open the power menu dropdown + wrapper.find("TableMenu Button").simulate("click"); + // Click the "Power off" link + wrapper + .find("TableMenu .p-contextual-menu__link") + .at(0) + .simulate("click"); + + expect( + store.getActions().some((action) => action.type === "TURN_MACHINE_OFF") + ).toBe(true); + }); + + it("can dispatch the check power action", () => { + state.machine.items[0].actions = []; + const store = mockStore(state); + const wrapper = mount( + + + ( + + )} + /> + + + ); + + // Open the power menu dropdown + wrapper.find("TableMenu Button").simulate("click"); + // Click the "Check power" link + wrapper + .find("TableMenu .p-contextual-menu__link") + .at(0) + .simulate("click"); + + expect( + store + .getActions() + .some((action) => action.type === "CHECK_MACHINE_POWER") + ).toBe(true); + }); + }); + it("includes a tab for instances if machine has any", () => { state.machine.items[0] = machineDetailsFactory({ devices: [machineDeviceFactory()], diff --git a/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx b/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx index e38f46e620..d91cd38fea 100644 --- a/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx +++ b/ui/src/app/machines/views/MachineDetails/MachineHeader/MachineHeader.tsx @@ -2,13 +2,15 @@ import { Icon, Tooltip } from "@canonical/react-components"; import { Link, useLocation } from "react-router-dom"; import { useDispatch, useSelector } from "react-redux"; import { useParams } from "react-router"; -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { SelectedAction, SetSelectedAction } from "../MachineSummary"; import { machine as machineActions } from "app/base/actions"; +import { useMachineActions } from "app/base/hooks"; import ActionFormWrapper from "app/machines/components/ActionFormWrapper"; import machineSelectors from "app/store/machine/selectors"; import SectionHeader from "app/base/components/SectionHeader"; +import TableMenu from "app/base/components/TableMenu"; import TakeActionMenu from "app/machines/components/TakeActionMenu"; import type { RootState } from "app/store/root/types"; import type { RouteParams } from "app/base/types"; @@ -32,7 +34,8 @@ const MachineHeader = ({ const statuses = useSelector((state: RootState) => machineSelectors.getStatuses(state, id) ); - const checkingPower = statuses?.checkingPower; + const powerMenuRef = useRef(null); + const powerMenuLinks = useMachineActions(id, ["off", "on"]); useEffect(() => { dispatch(machineActions.get(id)); @@ -44,6 +47,7 @@ const MachineHeader = ({ const { devices, fqdn, system_id } = machine; const urlBase = `/machine/${system_id}`; + const checkingPower = statuses?.checkingPower; return ( , <> - - + {checkingPower ? "Checking power" : `Power ${machine.power_state}`} + { + dispatch(machineActions.checkPower(system_id)); + }, + }, + ]} + positionNode={powerMenuRef?.current} + title="Take action:" + /> , ]} tabLinks={[