Skip to content

Commit

Permalink
feat(ui): add power actions to machine details header (#1840)
Browse files Browse the repository at this point in the history
  • Loading branch information
Caleb Ellis authored Nov 5, 2020
1 parent 4e66346 commit 170c15f
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 35 deletions.
11 changes: 6 additions & 5 deletions ui/.betterer.results
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
],
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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"]
Expand Down
55 changes: 31 additions & 24 deletions ui/src/app/base/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,121 @@ describe("MachineHeader", () => {
</MemoryRouter>
</Provider>
);
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(
<Provider store={store}>
<MemoryRouter
initialEntries={[{ pathname: "/machine/abc123", key: "testKey" }]}
>
<Route
exact
path="/machine/:id"
component={() => (
<MachineHeader
selectedAction={null}
setSelectedAction={jest.fn()}
/>
)}
/>
</MemoryRouter>
</Provider>
);

// 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(
<Provider store={store}>
<MemoryRouter
initialEntries={[{ pathname: "/machine/abc123", key: "testKey" }]}
>
<Route
exact
path="/machine/:id"
component={() => (
<MachineHeader
selectedAction={null}
setSelectedAction={jest.fn()}
/>
)}
/>
</MemoryRouter>
</Provider>
);

// 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(
<Provider store={store}>
<MemoryRouter
initialEntries={[{ pathname: "/machine/abc123", key: "testKey" }]}
>
<Route
exact
path="/machine/:id"
component={() => (
<MachineHeader
selectedAction={null}
setSelectedAction={jest.fn()}
/>
)}
/>
</MemoryRouter>
</Provider>
);

// 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()],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,7 +34,8 @@ const MachineHeader = ({
const statuses = useSelector((state: RootState) =>
machineSelectors.getStatuses(state, id)
);
const checkingPower = statuses?.checkingPower;
const powerMenuRef = useRef<HTMLSpanElement>(null);
const powerMenuLinks = useMachineActions(id, ["off", "on"]);

useEffect(() => {
dispatch(machineActions.get(id));
Expand All @@ -44,6 +47,7 @@ const MachineHeader = ({

const { devices, fqdn, system_id } = machine;
const urlBase = `/machine/${system_id}`;
const checkingPower = statuses?.checkingPower;

return (
<SectionHeader
Expand Down Expand Up @@ -81,13 +85,31 @@ const MachineHeader = ({
</>,
<>
<span className="u-nudge-left--small">
<Icon
name={`power-${checkingPower ? "checking" : machine.power_state}`}
<i
className={
checkingPower
? "p-icon--spinner u-animation--spin"
: `p-icon--power-${machine.power_state}`
}
/>
</span>
<span data-test="machine-header-power">
<span data-test="machine-header-power" ref={powerMenuRef}>
{checkingPower ? "Checking power" : `Power ${machine.power_state}`}
</span>
<TableMenu
className="u-nudge-right--small"
links={[
...powerMenuLinks,
{
children: "Check power",
onClick: () => {
dispatch(machineActions.checkPower(system_id));
},
},
]}
positionNode={powerMenuRef?.current}
title="Take action:"
/>
</>,
]}
tabLinks={[
Expand Down

0 comments on commit 170c15f

Please sign in to comment.