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(), error: jest.fn(),
warning: jest.fn(), warning: jest.fn(),
busy: jest.fn(), busy: jest.fn(),
removeToast: jest.fn(),
})); }));

View File

@ -37,7 +37,9 @@ import { getDevice } from "../../../device";
import { talk } from "browser-speech"; import { talk } from "browser-speech";
import { MessageType } from "../../../sequences/interfaces"; import { MessageType } from "../../../sequences/interfaces";
import { FbjsEventName } from "farmbot/dist/constants"; 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 { onLogs } from "../../log_handlers";
import { fakeState } from "../../../__test_support__/fake_state"; import { fakeState } from "../../../__test_support__/fake_state";
import { globalQueue } from "../../batch_queue"; import { globalQueue } from "../../batch_queue";
@ -177,7 +179,8 @@ describe("onOffline", () => {
jest.resetAllMocks(); jest.resetAllMocks();
onOffline(); onOffline();
expect(dispatchNetworkDown).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER); 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(); jest.resetAllMocks();
onOnline(); onOnline();
expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER); expect(dispatchNetworkUp).toHaveBeenCalledWith("user.mqtt", ANY_NUMBER);
expect(removeToast).toHaveBeenCalledWith("offline");
}); });
}); });
describe("onReconnect", () => { describe("onReconnect()", () => {
onReconnect(); it("sends reconnect toast", () => {
expect(warning).toHaveBeenCalledWith( onReconnect();
"Attempting to reconnect to the message broker", "Offline", "yellow"); expect(warning).toHaveBeenCalledWith(
"Attempting to reconnect to the message broker",
"Offline", "yellow", "offline");
});
}); });
describe("changeLastClientConnected", () => { describe("changeLastClientConnected", () => {
@ -268,7 +275,8 @@ describe("onPublicBroadcast", () => {
console.log = jest.fn(); console.log = jest.fn();
onPublicBroadcast({}); onPublicBroadcast({});
expectBroadcastLog(); expectBroadcastLog();
expect(window.alert).toHaveBeenCalledWith(Content.FORCE_REFRESH_CANCEL_WARNING); expect(window.alert).toHaveBeenCalledWith(
Content.FORCE_REFRESH_CANCEL_WARNING);
expect(location.assign).not.toHaveBeenCalled(); 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 { Farmbot, BotStateTree, TaggedResource } from "farmbot";
import { FbjsEventName } from "farmbot/dist/constants"; import { FbjsEventName } from "farmbot/dist/constants";
import { noop } from "lodash"; 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 { HardwareState } from "../devices/interfaces";
import { GetState, ReduxAction } from "../redux/interfaces"; import { GetState, ReduxAction } from "../redux/interfaces";
import { Content, Actions } from "../constants"; import { Content, Actions } from "../constants";
@ -102,11 +104,6 @@ export function readStatus() {
.then(() => { commandOK(noun); }, commandErr(noun)); .then(() => { commandOK(noun); }, commandErr(noun));
} }
export const onOffline = () => {
dispatchNetworkDown("user.mqtt", now());
error(t(Content.MQTT_DISCONNECTED));
};
export const changeLastClientConnected = (bot: Farmbot) => () => { export const changeLastClientConnected = (bot: Farmbot) => () => {
bot.setUserEnv({ bot.setUserEnv({
"LAST_CLIENT_CONNECTED": JSON.stringify(new Date()) "LAST_CLIENT_CONNECTED": JSON.stringify(new Date())
@ -157,14 +154,20 @@ export function onMalformed() {
} }
} }
export const onOnline = export const onOnline = () => {
() => { removeToast("offline");
success(t("Reconnected to the message broker."), t("Online")); success(t("Reconnected to the message broker."), t("Online"));
dispatchNetworkUp("user.mqtt", now()); dispatchNetworkUp("user.mqtt", now());
}; };
export const onReconnect =
() => warning(t("Attempting to reconnect to the message broker"), export const onReconnect = () =>
t("Offline"), "yellow"); 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) { export function onPublicBroadcast(payl: unknown) {
console.log(FbjsEventName.publicBroadcast, payl); console.log(FbjsEventName.publicBroadcast, payl);

View File

@ -291,10 +291,19 @@
.panel-action-buttons { .panel-action-buttons {
position: absolute; position: absolute;
z-index: 9; z-index: 9;
height: 25rem; height: 16rem;
width: 100%; width: 100%;
background: $panel_medium_light_gray; background: $panel_medium_light_gray;
padding: 0.5rem; padding: 0.5rem;
&.status {
height: 20rem;
}
&.more {
height: 23rem;
}
&.more.status {
height: 26rem;
}
button { button {
margin: 0.5rem; margin: 0.5rem;
float: left; float: left;
@ -303,11 +312,12 @@
min-width: -webkit-fill-available; min-width: -webkit-fill-available;
margin-bottom: 0px; margin-bottom: 0px;
margin-left: .5rem; margin-left: .5rem;
margin-top: 1rem; margin-top: 0;
} }
.button-row { .button-row {
float: left; float: left;
width: 100%; width: 100%;
margin-bottom: 1rem;
} }
.filter-search { .filter-search {
padding-right: 1rem; padding-right: 1rem;
@ -324,15 +334,35 @@
line-height: 4.1rem; 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 { .panel-content {
padding-top: 25rem; padding-top: 16rem;
padding-right: 0; padding-right: 0;
padding-left: 0; padding-left: 0;
padding-bottom: 5rem; padding-bottom: 5rem;
max-height: calc(100vh - 13rem); max-height: calc(100vh - 13rem);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; 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 }); { 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", () => { it("selects group items", () => {
const p = fakeProps(); const p = fakeProps();
p.selected = undefined; p.selected = undefined;
@ -236,7 +248,7 @@ describe("<SelectPlants />", () => {
expect(wrapper.state().group_id).toEqual(1); expect(wrapper.state().group_id).toEqual(1);
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE, type: Actions.SET_SELECTION_POINT_TYPE,
payload: POINTER_TYPES, payload: ["Plant"],
}); });
expect(p.dispatch).toHaveBeenLastCalledWith({ expect(p.dispatch).toHaveBeenLastCalledWith({
type: Actions.SELECT_POINT, 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; const DELETE_BTN_INDEX = 4;
it("confirms deletion of selected plants", () => { it("confirms deletion of selected plants", () => {

View File

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

View File

@ -2,9 +2,11 @@ import { FBToast } from "../fb_toast";
describe("FBToast", () => { describe("FBToast", () => {
let count = 0; let count = 0;
const newToast = (): [FBToast, HTMLDivElement] => { const newToast = (idPrefix = ""): [FBToast, HTMLDivElement] => {
const parent = document.createElement("div"); 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]; return [child, parent];
}; };
@ -90,6 +92,30 @@ describe("FBToast", () => {
i.detach(); i.detach();
expect(FBToast.everyMessage[message]).toBeFalsy(); expect(FBToast.everyMessage[message]).toBeFalsy();
expect(p.removeChild).toHaveBeenCalledWith(i.toastEl); 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", () => { it("does polling", () => {

View File

@ -20,20 +20,34 @@ describe("toast internal support files", () => {
container.className = "toast-container"; container.className = "toast-container";
document.body.appendChild(container); document.body.appendChild(container);
createToastOnce(msg, "bar", "baz", fallback); createToastOnce(msg, "bar", "baz", "id-prefix", fallback);
expect(FBToast.everyMessage[msg]).toBe(true); expect(FBToast.everyMessage[msg]).toBe(true);
expect(fallback).not.toHaveBeenCalled(); expect(fallback).not.toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled(); expect(mockRun).toHaveBeenCalled();
createToastOnce(msg, "bar", "baz", fallback); createToastOnce(msg, "bar", "baz", "id-prefix", fallback);
expect(fallback).toHaveBeenCalled(); 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", () => { it("crashes if you don't attach .toast-container", () => {
document.body.innerHTML = ""; document.body.innerHTML = "";
expect(() => createToast("x", "y", "z")) expect(() => createToast("x", "y", "z", "id-prefix")).toThrow();
.toThrow();
}); });
}); });

View File

@ -14,93 +14,113 @@ const {
info, info,
fun, fun,
init, init,
removeToast,
busy, busy,
}: typeof import("../toast") = jest.requireActual("../toast"); }: typeof import("../toast") = jest.requireActual("../toast");
describe("toasts", () => { describe("toasts", () => {
it("pops a warning() toast", () => { it("pops a warning() toast", () => {
warning("test suite msg 1"); warning("test suite msg 1");
expect(createToastOnce).toHaveBeenCalledWith("test suite msg 1", expect(createToastOnce).toHaveBeenCalledWith(
"Warning", "test suite msg 1", "Warning", "orange", "", console.warn);
"orange",
console.warn);
}); });
it("pops a warning() toast with different title and color", () => { it("pops a warning() toast with different title and color", () => {
warning("test suite msg", "new title", "purple"); warning("test suite msg", "new title", "purple", "id-prefix");
expect(createToastOnce) expect(createToastOnce).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple", "test suite msg", "new title", "purple", "id-prefix", console.warn);
console.warn);
}); });
it("pops a error() toast", () => { it("pops a error() toast", () => {
error("test suite msg 2"); error("test suite msg 2");
expect(createToastOnce).toHaveBeenCalledWith("test suite msg 2", expect(createToastOnce).toHaveBeenCalledWith(
"Error", "test suite msg 2", "Error", "red", "", console.error);
"red",
console.error);
}); });
it("pops a error() toast with different title and color", () => { it("pops a error() toast with different title and color", () => {
error("test suite msg", "new title", "purple"); error("test suite msg", "new title", "purple", "id-prefix");
expect(createToastOnce) expect(createToastOnce).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple", "test suite msg", "new title", "purple", "id-prefix", console.error);
console.error);
}); });
it("pops a success() toast", () => { it("pops a success() toast", () => {
success("test suite msg"); success("test suite msg");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "Success", "green"); "test suite msg", "Success", "green", "");
}); });
it("pops a success() toast with different title and color", () => { it("pops a success() toast with different title and color", () => {
success("test suite msg", "new title", "purple"); success("test suite msg", "new title", "purple", "id-prefix");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple"); "test suite msg", "new title", "purple", "id-prefix");
}); });
it("pops a info() toast", () => { it("pops a info() toast", () => {
info("test suite msg"); info("test suite msg");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "FYI", "blue"); "test suite msg", "FYI", "blue", "");
}); });
it("pops a info() toast with different title and color", () => { it("pops a info() toast with different title and color", () => {
info("test suite msg", "new title", "purple"); info("test suite msg", "new title", "purple", "id-prefix");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple"); "test suite msg", "new title", "purple", "id-prefix");
}); });
it("pops a busy() toast", () => { it("pops a busy() toast", () => {
busy("test suite msg"); busy("test suite msg");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "Busy", "yellow"); "test suite msg", "Busy", "yellow", "");
}); });
it("pops a busy() toast with different title and color", () => { it("pops a busy() toast with different title and color", () => {
busy("test suite msg", "new title", "purple"); busy("test suite msg", "new title", "purple", "id-prefix");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple"); "test suite msg", "new title", "purple", "id-prefix");
}); });
it("pops a fun() toast", () => { it("pops a fun() toast", () => {
fun("test suite msg"); fun("test suite msg");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "Did you know?", "dark-blue"); "test suite msg", "Did you know?", "dark-blue", "");
}); });
it("pops a fun() toast with different title and color", () => { it("pops a fun() toast with different title and color", () => {
fun("test suite msg", "new title", "purple"); fun("test suite msg", "new title", "purple", "id-prefix");
expect(createToast) expect(createToast).toHaveBeenCalledWith(
.toHaveBeenCalledWith("test suite msg", "new title", "purple"); "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", () => { it("adds the appropriate div to the DOM", () => {
const count1 = document.querySelectorAll(".toast-container").item.length; document.body.innerHTML = "";
expect(count1).toEqual(1); expect(getToastContainerCount()).toEqual(0);
init(); init();
const count2 = document.querySelectorAll(".toast-container").item.length; expect(getToastContainerCount()).toEqual(1);
expect(count2).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. /** This is a [surprisingly reliable] legacy component.
* TODO: Convert this to React. */ * TODO: Convert this to React. */
export class FBToast { export class FBToast {
@ -29,7 +31,10 @@ export class FBToast {
constructor(public parent: Element, constructor(public parent: Element,
title: string, title: string,
raw_message: string, raw_message: string,
color: string) { color: string,
idPrefix: string) {
idPrefix && (this.toastEl.id = `${idPrefix}-toast-${uuid()}`);
this.message = raw_message.replace(/\s+/g, " "); this.message = raw_message.replace(/\s+/g, " ");
/** Fill contents. */ /** Fill contents. */
@ -84,7 +89,7 @@ export class FBToast {
detach = () => { detach = () => {
clearInterval(this.intervalId); clearInterval(this.intervalId);
delete FBToast.everyMessage[this.message]; delete FBToast.everyMessage[this.message];
if (this.isAttached) { if (this.isAttached && this.parent.contains(this.toastEl)) {
this.parent.removeChild(this.toastEl); this.parent.removeChild(this.toastEl);
this.isAttached = false; this.isAttached = false;
} }

View File

@ -4,45 +4,70 @@ import { t } from "../i18next_wrapper";
/** /**
* Orange message with "Warning" as the default title. * Orange message with "Warning" as the default title.
*/ */
export const warning = export const warning = (
(message: string, title = t("Warning"), color = "orange") => { message: string,
createToastOnce(message, title, color, console.warn); title = t("Warning"),
}; color = "orange",
idPrefix = "",
) => {
createToastOnce(message, title, color, idPrefix, console.warn);
};
/** /**
* Red message with "Error" as the default title. * Red message with "Error" as the default title.
*/ */
export const error = (message: string, title = t("Error"), color = "red") => { export const error = (
createToastOnce(message, title, color, console.error); message: string,
title = t("Error"),
color = "red",
idPrefix = "",
) => {
createToastOnce(message, title, color, idPrefix, console.error);
}; };
/** /**
* Green message with "Success" as the default title. * Green message with "Success" as the default title.
*/ */
export const success = export const success = (
(message: string, title = t("Success"), color = "green") => message: string,
createToast(message, title, color); title = t("Success"),
color = "green",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/** /**
* Blue message with "FYI" as the default title. * Blue message with "FYI" as the default title.
*/ */
export const info = export const info = (
(message: string, title = t("FYI"), color = "blue") => message: string,
createToast(message, title, color); title = t("FYI"),
color = "blue",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/** /**
* Yellow message with "Busy" as the default title. * Yellow message with "Busy" as the default title.
*/ */
export const busy = export const busy = (
(message: string, title = t("Busy"), color = "yellow") => message: string,
createToast(message, title, color); title = t("Busy"),
color = "yellow",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/** /**
* Dark blue message with "Did you know?" as the default title. * Dark blue message with "Did you know?" as the default title.
*/ */
export const fun = export const fun = (
(message: string, title = t("Did you know?"), color = "dark-blue") => message: string,
createToast(message, title, color); title = t("Did you know?"),
color = "dark-blue",
idPrefix = "",
) =>
createToast(message, title, color, idPrefix);
/** /**
* Adds a hidden container div for holding toast messages. * Adds a hidden container div for holding toast messages.
@ -52,3 +77,14 @@ export const init = () => {
toastContainer.classList.add("toast-container"); toastContainer.classList.add("toast-container");
document.body.appendChild(toastContainer); 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. * 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(). * 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. * Create elements.
*/ */
const t = new FBToast(parent, title, message, color); const t = new FBToast(parent, title, message, color, idPrefix);
t.run(); t.run();
}; };
export const createToastOnce = (message: string, export const createToastOnce = (message: string,
title: string, title: string,
color: string, color: string,
fallbackLogger = console.warn) => { idPrefix: string,
fallbackLogger = console.warn,
) => {
if (FBToast.everyMessage[message]) { if (FBToast.everyMessage[message]) {
fallbackLogger(message); fallbackLogger(message);
} else { } else {
createToast(message, title, color); createToast(message, title, color, idPrefix);
FBToast.everyMessage[message] = true; FBToast.everyMessage[message] = true;
} }
}; };