minor fixes

pull/1764/head
gabrielburnworth 2020-04-20 14:07:40 -07:00
parent 0bd6d9a967
commit 6f484ab2e3
12 changed files with 323 additions and 112 deletions

View File

@ -7,4 +7,5 @@ jest.mock("../toast/toast", () => ({
error: jest.fn(),
warning: jest.fn(),
busy: jest.fn(),
removeToast: jest.fn(),
}));

View File

@ -37,7 +37,9 @@ import { getDevice } from "../../../device";
import { talk } from "browser-speech";
import { MessageType } from "../../../sequences/interfaces";
import { FbjsEventName } from "farmbot/dist/constants";
import { info, error, success, warning, fun, busy } from "../../../toast/toast";
import {
info, error, success, warning, fun, busy, removeToast,
} from "../../../toast/toast";
import { onLogs } from "../../log_handlers";
import { fakeState } from "../../../__test_support__/fake_state";
import { globalQueue } from "../../batch_queue";
@ -177,7 +179,8 @@ describe("onOffline", () => {
jest.resetAllMocks();
onOffline();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(error).toHaveBeenCalledWith(Content.MQTT_DISCONNECTED);
expect(error).toHaveBeenCalledWith(
Content.MQTT_DISCONNECTED, "Error", "red", "offline");
});
});
@ -186,13 +189,17 @@ describe("onOnline", () => {
jest.resetAllMocks();
onOnline();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(removeToast).toHaveBeenCalledWith("offline");
});
});
describe("onReconnect", () => {
onReconnect();
expect(warning).toHaveBeenCalledWith(
"Attempting to reconnect to the message broker", "Offline", "yellow");
describe("onReconnect()", () => {
it("sends reconnect toast", () => {
onReconnect();
expect(warning).toHaveBeenCalledWith(
"Attempting to reconnect to the message broker",
"Offline", "yellow", "offline");
});
});
describe("changeLastClientConnected", () => {
@ -268,7 +275,8 @@ describe("onPublicBroadcast", () => {
console.log = jest.fn();
onPublicBroadcast({});
expectBroadcastLog();
expect(window.alert).toHaveBeenCalledWith(Content.FORCE_REFRESH_CANCEL_WARNING);
expect(window.alert).toHaveBeenCalledWith(
Content.FORCE_REFRESH_CANCEL_WARNING);
expect(location.assign).not.toHaveBeenCalled();
});
});

View File

@ -4,7 +4,9 @@ import { Log } from "farmbot/dist/resources/api_resources";
import { Farmbot, BotStateTree, TaggedResource } from "farmbot";
import { FbjsEventName } from "farmbot/dist/constants";
import { noop } from "lodash";
import { success, error, info, warning, fun, busy } from "../toast/toast";
import {
success, error, info, warning, fun, busy, removeToast,
} from "../toast/toast";
import { HardwareState } from "../devices/interfaces";
import { GetState, ReduxAction } from "../redux/interfaces";
import { Content, Actions } from "../constants";
@ -102,11 +104,6 @@ export function readStatus() {
.then(() => { commandOK(noun); }, commandErr(noun));
}
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED));
};
export const changeLastClientConnected = (bot: Farmbot) => () => {
bot.setUserEnv({
"LAST_CLIENT_CONNECTED": JSON.stringify(new Date())
@ -157,14 +154,20 @@ export function onMalformed() {
}
}
export const onOnline =
() => {
success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", now());
};
export const onReconnect =
() => warning(t("Attempting to reconnect to the message broker"),
t("Offline"), "yellow");
export const onOnline = () => {
removeToast("offline");
success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", now());
};
export const onReconnect = () =>
warning(t("Attempting to reconnect to the message broker"),
t("Offline"), "yellow", "offline");
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED), t("Error"), "red", "offline");
};
export function onPublicBroadcast(payl: unknown) {
console.log(FbjsEventName.publicBroadcast, payl);

View File

@ -291,10 +291,19 @@
.panel-action-buttons {
position: absolute;
z-index: 9;
height: 25rem;
height: 16rem;
width: 100%;
background: $panel_medium_light_gray;
padding: 0.5rem;
&.status {
height: 20rem;
}
&.more {
height: 23rem;
}
&.more.status {
height: 26rem;
}
button {
margin: 0.5rem;
float: left;
@ -303,11 +312,12 @@
min-width: -webkit-fill-available;
margin-bottom: 0px;
margin-left: .5rem;
margin-top: 1rem;
margin-top: 0;
}
.button-row {
float: left;
width: 100%;
margin-bottom: 1rem;
}
.filter-search {
padding-right: 1rem;
@ -324,15 +334,35 @@
line-height: 4.1rem;
}
}
.more {
float: right;
cursor: pointer;
margin-right: 1rem;
line-height: 2.5rem;
p {
display: inline;
font-size: 1.4rem;
margin-right: 1rem;
}
}
}
.panel-content {
padding-top: 25rem;
padding-top: 16rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 5rem;
max-height: calc(100vh - 13rem);
overflow-y: auto;
overflow-x: hidden;
&.status {
padding-top: 20rem;
}
&.more {
padding-top: 23rem;
}
&.more.status {
padding-top: 26rem;
}
}
}

View File

@ -215,6 +215,18 @@ describe("<SelectPlants />", () => {
{ payload: undefined, type: Actions.SELECT_POINT });
});
it("toggles more", () => {
const p = fakeProps();
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
expect(wrapper.state().more).toEqual(false);
expect(wrapper.find(".select-more").props().hidden).toBeTruthy();
expect(wrapper.html()).not.toContain(" more status");
wrapper.find(".more").simulate("click");
expect(wrapper.state().more).toEqual(true);
expect(wrapper.find(".select-more").props().hidden).toBeFalsy();
expect(wrapper.html()).toContain(" more status");
});
it("selects group items", () => {
const p = fakeProps();
p.selected = undefined;
@ -236,7 +248,7 @@ describe("<SelectPlants />", () => {
expect(wrapper.state().group_id).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: POINTER_TYPES,
payload: ["Plant"],
});
expect(p.dispatch).toHaveBeenLastCalledWith({
type: Actions.SELECT_POINT,
@ -265,6 +277,30 @@ describe("<SelectPlants />", () => {
});
});
it("selects selection type without criteria", () => {
const p = fakeProps();
const group = fakePointGroup();
group.body.id = 1;
group.body.criteria.string_eq = {};
const plant = fakePlant();
plant.body.id = 1;
const weed = fakeWeed();
weed.body.id = 2;
group.body.point_ids = [1, 2];
p.groups = [group];
const dispatch = jest.fn();
p.dispatch = mockDispatch(dispatch);
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").at(1).simulate("change", {
label: "", value: 1
});
expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: POINTER_TYPES,
});
});
const DELETE_BTN_INDEX = 4;
it("confirms deletion of selected plants", () => {

View File

@ -25,7 +25,8 @@ import {
} from "farmbot";
import { UUID } from "../../resources/interfaces";
import {
selectAllActivePoints, selectAllToolSlotPointers, selectAllTools, selectAllPointGroups,
selectAllActivePoints, selectAllToolSlotPointers, selectAllTools,
selectAllPointGroups,
} from "../../resources/selectors";
import { PointInventoryItem } from "../points/point_inventory_item";
import { ToolSlotInventoryItem } from "../tools";
@ -108,11 +109,12 @@ export interface SelectPlantsProps {
interface SelectPlantsState {
group_id: number | undefined;
more: boolean;
}
export class RawSelectPlants
extends React.Component<SelectPlantsProps, SelectPlantsState> {
state: SelectPlantsState = { group_id: undefined };
state: SelectPlantsState = { group_id: undefined, more: false };
componentDidMount() {
const { dispatch, selected } = this.props;
@ -163,16 +165,24 @@ export class RawSelectPlants
this.setState({ group_id });
const group = this.props.groups
.filter(pg => pg.body.id == group_id)[0];
const pointUuids = pointsSelectedByGroup(group, this.props.allPoints)
.map(p => p.uuid);
const points = pointsSelectedByGroup(group, this.props.allPoints);
const pointUuids = points.map(p => p.uuid);
const pointerTypes =
group.body.criteria.string_eq.pointer_type as PointType[] | undefined;
this.props.dispatch(setSelectionPointType(pointerTypes || POINTER_TYPES));
const uniqPointTypes = uniq(points.map(p => p.body.pointer_type));
const pointTypes =
uniqPointTypes.length == 1 ? [uniqPointTypes[0]] : undefined;
this.props.dispatch(setSelectionPointType(
pointerTypes || pointTypes || POINTER_TYPES));
this.props.dispatch(selectPoint(pointUuids));
}
ActionButtons = () =>
<div className="panel-action-buttons">
<div className={["panel-action-buttons",
this.state.more ? "more" : "",
["Plant", "Weed"].includes(this.selectionPointType) ? "status" : "",
].join(" ")}>
<label>{t("selection type")}</label>
<FBSelect key={this.selectionPointType}
list={POINTER_TYPE_LIST()}
selectedItem={POINTER_TYPE_DDI_LOOKUP()[this.selectionPointType]}
@ -185,22 +195,36 @@ export class RawSelectPlants
<div className="button-row">
<button className="fb-button gray"
title={t("Select none")}
onClick={() => this.props.dispatch(selectPoint(undefined))}>
onClick={() => {
this.setState({ group_id: undefined });
this.props.dispatch(selectPoint(undefined));
}}>
{t("Select none")}
</button>
<button className="fb-button gray"
title={t("Select all")}
onClick={() => this.props.dispatch(selectPoint(this.allPointUuids))}>
onClick={() => {
this.setState({ group_id: undefined });
this.props.dispatch(selectPoint(this.allPointUuids));
}}>
{t("Select all")}
</button>
<label>{t("select all in group")}</label>
<FBSelect key={this.selectionPointType}
list={Object.values(this.groupDDILookup)}
selectedItem={this.state.group_id
? this.groupDDILookup[this.state.group_id]
: undefined}
customNullLabel={t("Select a group")}
onChange={this.selectGroup} />
<div className="more"
onClick={() => this.setState({ more: !this.state.more })}>
<p>{this.state.more ? t("Less") : t("More")}</p>
<i className={`fa fa-caret-${this.state.more ? "up" : "down"}`}
title={this.state.more ? t("less") : t("more")} />
</div>
<div className={"select-more"} hidden={!this.state.more}>
<label>{t("select all in group")}</label>
<FBSelect key={`${this.selectionPointType}-${this.state.group_id}`}
list={Object.values(this.groupDDILookup)}
selectedItem={this.state.group_id
? this.groupDDILookup[this.state.group_id]
: undefined}
customNullLabel={t("Select a group")}
onChange={this.selectGroup} />
</div>
</div>
<label>{t("SELECTION ACTIONS")}</label>
<div className="button-row">
@ -273,7 +297,11 @@ export class RawSelectPlants
description={Content.BOX_SELECT_DESCRIPTION} />
<this.ActionButtons />
<DesignerPanelContent panelName={"plant-selection"}>
<DesignerPanelContent panelName={"plant-selection"}
className={[
this.state.more ? "more" : "",
["Plant", "Weed"].includes(this.selectionPointType) ? "status" : "",
].join(" ")}>
{this.selectedPointData.map(p => {
if (p.kind == "PlantTemplate" || p.body.pointer_type == "Plant") {
return <PlantInventoryItem

View File

@ -2,9 +2,11 @@ import { FBToast } from "../fb_toast";
describe("FBToast", () => {
let count = 0;
const newToast = (): [FBToast, HTMLDivElement] => {
const newToast = (idPrefix = ""): [FBToast, HTMLDivElement] => {
const parent = document.createElement("div");
const child = new FBToast(parent, "title", "message" + (count++), "red");
const child =
new FBToast(parent, "title", "message" + (count++), "red", idPrefix);
parent.appendChild(child.toastEl);
return [child, parent];
};
@ -90,6 +92,30 @@ describe("FBToast", () => {
i.detach();
expect(FBToast.everyMessage[message]).toBeFalsy();
expect(p.removeChild).toHaveBeenCalledWith(i.toastEl);
expect(i.isAttached).toBeFalsy();
});
it("doesn't detach from the DOM", () => {
const [i, p] = newToast();
p.innerHTML = "";
const { message } = i;
FBToast.everyMessage[message] = true;
p.removeChild = jest.fn();
i.isAttached = true;
i.detach();
expect(FBToast.everyMessage[message]).toBeFalsy();
expect(p.removeChild).not.toHaveBeenCalled();
expect(i.isAttached).toBeTruthy();
});
it("sets id", () => {
const toast = newToast("id-prefix")[0];
expect(toast.toastEl.id).toEqual(expect.stringMatching("^id-prefix-toast-"));
});
it("doesn't set id", () => {
const toast = newToast()[0];
expect(toast.toastEl.id).toEqual("");
});
it("does polling", () => {

View File

@ -20,20 +20,34 @@ describe("toast internal support files", () => {
container.className = "toast-container";
document.body.appendChild(container);
createToastOnce(msg, "bar", "baz", fallback);
createToastOnce(msg, "bar", "baz", "id-prefix", fallback);
expect(FBToast.everyMessage[msg]).toBe(true);
expect(fallback).not.toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
createToastOnce(msg, "bar", "baz", fallback);
createToastOnce(msg, "bar", "baz", "id-prefix", fallback);
expect(fallback).toHaveBeenCalled();
});
it("uses default fallback logger", () => {
document.body.innerHTML = "";
console.warn = jest.fn();
const container = document.createElement("DIV");
container.className = "toast-container";
document.body.appendChild(container);
const msg = "foo";
delete FBToast.everyMessage[msg];
createToastOnce(msg, "bar", "baz", "");
expect(console.warn).not.toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
createToastOnce(msg, "bar", "baz", "");
expect(console.warn).toHaveBeenCalled();
});
it("crashes if you don't attach .toast-container", () => {
document.body.innerHTML = "";
expect(() => createToast("x", "y", "z"))
.toThrow();
expect(() => createToast("x", "y", "z", "id-prefix")).toThrow();
});
});

View File

@ -14,93 +14,113 @@ const {
info,
fun,
init,
removeToast,
busy,
}: typeof import("../toast") = jest.requireActual("../toast");
describe("toasts", () => {
it("pops a warning() toast", () => {
warning("test suite msg 1");
expect(createToastOnce).toHaveBeenCalledWith("test suite msg 1",
"Warning",
"orange",
console.warn);
expect(createToastOnce).toHaveBeenCalledWith(
"test suite msg 1", "Warning", "orange", "", console.warn);
});
it("pops a warning() toast with different title and color", () => {
warning("test suite msg", "new title", "purple");
expect(createToastOnce)
.toHaveBeenCalledWith("test suite msg", "new title", "purple",
console.warn);
warning("test suite msg", "new title", "purple", "id-prefix");
expect(createToastOnce).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix", console.warn);
});
it("pops a error() toast", () => {
error("test suite msg 2");
expect(createToastOnce).toHaveBeenCalledWith("test suite msg 2",
"Error",
"red",
console.error);
expect(createToastOnce).toHaveBeenCalledWith(
"test suite msg 2", "Error", "red", "", console.error);
});
it("pops a error() toast with different title and color", () => {
error("test suite msg", "new title", "purple");
expect(createToastOnce)
.toHaveBeenCalledWith("test suite msg", "new title", "purple",
console.error);
error("test suite msg", "new title", "purple", "id-prefix");
expect(createToastOnce).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix", console.error);
});
it("pops a success() toast", () => {
success("test suite msg");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "Success", "green");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "Success", "green", "");
});
it("pops a success() toast with different title and color", () => {
success("test suite msg", "new title", "purple");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "new title", "purple");
success("test suite msg", "new title", "purple", "id-prefix");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix");
});
it("pops a info() toast", () => {
info("test suite msg");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "FYI", "blue");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "FYI", "blue", "");
});
it("pops a info() toast with different title and color", () => {
info("test suite msg", "new title", "purple");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "new title", "purple");
info("test suite msg", "new title", "purple", "id-prefix");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix");
});
it("pops a busy() toast", () => {
busy("test suite msg");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "Busy", "yellow");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "Busy", "yellow", "");
});
it("pops a busy() toast with different title and color", () => {
busy("test suite msg", "new title", "purple");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "new title", "purple");
busy("test suite msg", "new title", "purple", "id-prefix");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix");
});
it("pops a fun() toast", () => {
fun("test suite msg");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "Did you know?", "dark-blue");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "Did you know?", "dark-blue", "");
});
it("pops a fun() toast with different title and color", () => {
fun("test suite msg", "new title", "purple");
expect(createToast)
.toHaveBeenCalledWith("test suite msg", "new title", "purple");
fun("test suite msg", "new title", "purple", "id-prefix");
expect(createToast).toHaveBeenCalledWith(
"test suite msg", "new title", "purple", "id-prefix");
});
const getToastContainerCount = () =>
Object.values(document.querySelectorAll(".toast-container")).length;
const getToastCount = () =>
document.querySelector(".toast-container")?.childElementCount;
it("adds the appropriate div to the DOM", () => {
const count1 = document.querySelectorAll(".toast-container").item.length;
expect(count1).toEqual(1);
document.body.innerHTML = "";
expect(getToastContainerCount()).toEqual(0);
init();
const count2 = document.querySelectorAll(".toast-container").item.length;
expect(count2).toEqual(1);
expect(getToastContainerCount()).toEqual(1);
});
it("removes a toast message", () => {
document.body.innerHTML = "";
init();
expect(getToastCount()).toEqual(0);
const toast = document.createElement("div");
toast.id = "id-prefix-123";
document.querySelector(".toast-container")?.appendChild(toast);
expect(getToastCount()).toEqual(1);
removeToast("id-prefix");
expect(getToastCount()).toEqual(0);
});
it("doesn't remove a toast message: parent missing", () => {
document.body.innerHTML = "";
expect(getToastContainerCount()).toEqual(0);
console.error = jest.fn();
removeToast("id-prefix");
expect(console.error).toHaveBeenCalledWith("toast-container is null.");
});
});

View File

@ -1,3 +1,5 @@
import { uuid } from "farmbot";
/** This is a [surprisingly reliable] legacy component.
* TODO: Convert this to React. */
export class FBToast {
@ -29,7 +31,10 @@ export class FBToast {
constructor(public parent: Element,
title: string,
raw_message: string,
color: string) {
color: string,
idPrefix: string) {
idPrefix && (this.toastEl.id = `${idPrefix}-toast-${uuid()}`);
this.message = raw_message.replace(/\s+/g, " ");
/** Fill contents. */
@ -84,7 +89,7 @@ export class FBToast {
detach = () => {
clearInterval(this.intervalId);
delete FBToast.everyMessage[this.message];
if (this.isAttached) {
if (this.isAttached && this.parent.contains(this.toastEl)) {
this.parent.removeChild(this.toastEl);
this.isAttached = false;
}

View File

@ -4,45 +4,70 @@ import { t } from "../i18next_wrapper";
/**
* Orange message with "Warning" as the default title.
*/
export const warning =
(message: string, title = t("Warning"), color = "orange") => {
createToastOnce(message, title, color, console.warn);
};
export const warning = (
message: string,
title = t("Warning"),
color = "orange",
idPrefix = "",
) => {
createToastOnce(message, title, color, idPrefix, console.warn);
};
/**
* Red message with "Error" as the default title.
*/
export const error = (message: string, title = t("Error"), color = "red") => {
createToastOnce(message, title, color, console.error);
export const error = (
message: string,
title = t("Error"),
color = "red",
idPrefix = "",
) => {
createToastOnce(message, title, color, idPrefix, console.error);
};
/**
* Green message with "Success" as the default title.
*/
export const success =
(message: string, title = t("Success"), color = "green") =>
createToast(message, title, color);
export const success = (
message: string,
title = t("Success"),
color = "green",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/**
* Blue message with "FYI" as the default title.
*/
export const info =
(message: string, title = t("FYI"), color = "blue") =>
createToast(message, title, color);
export const info = (
message: string,
title = t("FYI"),
color = "blue",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/**
* Yellow message with "Busy" as the default title.
*/
export const busy =
(message: string, title = t("Busy"), color = "yellow") =>
createToast(message, title, color);
export const busy = (
message: string,
title = t("Busy"),
color = "yellow",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/**
* Dark blue message with "Did you know?" as the default title.
*/
export const fun =
(message: string, title = t("Did you know?"), color = "dark-blue") =>
createToast(message, title, color);
export const fun = (
message: string,
title = t("Did you know?"),
color = "dark-blue",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/**
* Adds a hidden container div for holding toast messages.
@ -52,3 +77,14 @@ export const init = () => {
toastContainer.classList.add("toast-container");
document.body.appendChild(toastContainer);
};
/** Remove all toast messages that match the provided id prefix. */
export const removeToast = (idPrefix: string) => {
const parent = document.querySelector(".toast-container");
const toasts = document.querySelectorAll(`[id^="${idPrefix}-"]`);
if (parent) {
Object.values(toasts).map(toast => parent.removeChild(toast));
} else {
console.error("toast-container is null.");
}
};

View File

@ -3,7 +3,9 @@ import { FBToast } from "./fb_toast";
/**
* The function responsible for attaching the messages to the container.
*/
export const createToast = (message: string, title: string, color: string) => {
export const createToast = (
message: string, title: string, color: string, idPrefix: string,
) => {
/**
* Container element for all of the messages created from init().
@ -17,18 +19,20 @@ export const createToast = (message: string, title: string, color: string) => {
/**
* Create elements.
*/
const t = new FBToast(parent, title, message, color);
const t = new FBToast(parent, title, message, color, idPrefix);
t.run();
};
export const createToastOnce = (message: string,
title: string,
color: string,
fallbackLogger = console.warn) => {
idPrefix: string,
fallbackLogger = console.warn,
) => {
if (FBToast.everyMessage[message]) {
fallbackLogger(message);
} else {
createToast(message, title, color);
createToast(message, title, color, idPrefix);
FBToast.everyMessage[message] = true;
}
};