Merge pull request #1652 from gabrielburnworth/staging

Form updates and refactoring
log_problems_iii
Rick Carlino 2020-01-07 08:02:20 -06:00 committed by GitHub
commit 37d4d27dce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 1146 additions and 1177 deletions

View File

@ -26,6 +26,7 @@ module CeleryScript
FRIENDLY_ERRORS = {
nothing: {
write_pin: "You must select a Peripheral in the Control Peripheral step.",
toggle_pin: "You must select a Peripheral in the Toggle Peripheral step.",
variable_declaration: MISSING_VAR,
parameter_declaration: MISSING_PARAM,
read_pin: "You must select a Sensor in the Read Sensor step.",

View File

@ -7,11 +7,11 @@ export function clickButton(
position: number,
text: string,
options?: { partial_match?: boolean, button_tag?: string }) {
const btnTag = options && options.button_tag ? options.button_tag : "button";
const btnTag = options?.button_tag ? options.button_tag : "button";
const button = wrapper.find(btnTag).at(position);
const expectedText = text.toLowerCase();
const actualText = button.text().toLowerCase();
options && options.partial_match
options?.partial_match
? expect(actualText).toContain(expectedText)
: expect(actualText).toEqual(expectedText);
button.simulate("click");

View File

@ -45,29 +45,29 @@ const fakeProps = (): AppProps => ({
});
describe("<App />: Controls Pop-Up", () => {
function controlsPopUp(page: string, exists: boolean) {
it(`doesn't render controls pop-up on ${page} page`, () => {
mockPath = "/app/" + page;
const wrapper = mount(<App {...fakeProps()} />);
if (exists) {
expect(wrapper.html()).toContain("controls-popup");
} else {
expect(wrapper.html()).not.toContain("controls-popup");
}
});
}
controlsPopUp("designer", true);
controlsPopUp("designer/plants", true);
controlsPopUp("controls", false);
controlsPopUp("device", true);
controlsPopUp("sequences", true);
controlsPopUp("sequences/for_regimens", true);
controlsPopUp("regimens", false);
controlsPopUp("tools", true);
controlsPopUp("farmware", true);
controlsPopUp("account", false);
it.each<["renders" | "doesn't render", string]>([
["renders", "designer"],
["renders", "designer/plants"],
["doesn't render", "controls"],
["renders", "device"],
["renders", "sequences"],
["renders", "sequences/for_regimens"],
["doesn't render", "regimens"],
["renders", "tools"],
["renders", "farmware"],
["renders", "messages"],
["renders", "logs"],
["renders", "help"],
["doesn't render", "account"],
])("%s controls pop-up on %s page", (expected, page) => {
mockPath = "/app/" + page;
const wrapper = mount(<App {...fakeProps()} />);
if (expected == "renders") {
expect(wrapper.html()).toContain("controls-popup");
} else {
expect(wrapper.html()).not.toContain("controls-popup");
}
});
});
describe("<App />: Loading", () => {

View File

@ -32,7 +32,7 @@ test("buildResourceIndex - add a FarmEvent", () => {
const key = Object.keys(db.index.byKind.FarmEvent)[0];
const fe = db.index.references[key];
expect(fe).toBeTruthy();
if (fe && fe.kind === "FarmEvent") {
if (fe?.kind === "FarmEvent") {
const { body } = fe;
expect(body).toEqual(STUB_RESOURCE.body);
} else {

View File

@ -300,7 +300,9 @@ export namespace ToolTips {
trim(`Power cycle FarmBot's onboard computer or microcontroller.`);
export const SET_SERVO_ANGLE =
trim(`Move a servo to the provided angle.`);
trim(`Move a servo to the provided angle. An angle of 90 degrees
corresponds to the servo midpoint (or, for a continuous rotation
servo, no movement).`);
export const TOGGLE_PIN =
trim(`Toggle a digital pin on or off.`);
@ -818,11 +820,9 @@ export namespace Content {
Doing so will limit the functionality of your FarmBot and
may cause unexpected behavior.`);
export const SET_TIMEZONE_HEADER =
trim(`You must set a timezone before using the event feature.`);
export const SET_TIMEZONE_BODY =
trim(`Set device timezone here.`);
export const MISSING_EXECUTABLE =
trim(`You haven't made any sequences or regimens yet. To add an event,
first create a sequence or regimen.`);
// Farmware
export const NO_IMAGES_YET =

View File

@ -1,12 +1,11 @@
import * as React from "react";
import { AxisInputBoxProps } from "./interfaces";
import { Col, BlurableInput } from "../ui/index";
import { isUndefined } from "lodash";
export const AxisInputBox = ({ onChange, value, axis }: AxisInputBoxProps) => {
return <Col xs={3}>
<BlurableInput
value={(isUndefined(value) ? "" : value)}
value={value ?? ""}
type="number"
allowEmpty={true}
onCommit={e => {

View File

@ -431,12 +431,32 @@
.location-form {
width: 100% !important;
}
.note {
margin-top: 4rem;
}
.bp3-popover-wrapper {
display: inline;
margin-left: 0.5rem;
}
}
.farm-event-form {
.farm-event-repeat-options {
input[type=checkbox] {
margin-right: 0.5rem;
margin-top: 0;
vertical-align: middle;
}
.farm-event-repeat-form {
.add-event-repeat-frequency {
min-height: 34px;
}
}
}
}
.add-farm-event-panel button.red,
.edit-farm-event-panel button.red,
.add-farm-event-panel button.magenta,
.edit-farm-event-panel button.magenta {
.edit-farm-event-panel button.red {
margin-top: 1rem;
margin-left: 1rem;
}

View File

@ -78,13 +78,3 @@
background: $gray;
}
}
.add-farm-event-panel {
.note {
margin-top: 4rem;
}
}
.add-event-repeat-frequency {
min-height: 34px;
}

View File

@ -23,6 +23,12 @@
margin-left: 1rem;
box-shadow: none;
}
input[type="radio"] {
margin-right: 0.25rem;
margin-bottom: 0.25rem;
margin-top: 0;
vertical-align: middle;
}
}
}
}
@ -107,7 +113,7 @@
background: $blue;
}
&.toggle-pin-step {
background: $yellow;
background: $orange;
}
&.set-zero-step {
background: $blue;
@ -124,6 +130,12 @@
&.system-action-step {
background: $brown;
}
&.emergency-stop-step {
background: $brown;
}
&.reboot-step {
background: $brown;
}
&.unknown-step {
background: $gray;
}
@ -221,7 +233,7 @@
background: $light_blue;
}
&.toggle-pin-step {
background: $light_yellow;
background: $light_orange;
}
&.set-zero-step {
background: $light_blue;
@ -238,6 +250,12 @@
&.system-action-step {
background: $light_brown;
}
&.emergency-stop-step {
background: $light_brown;
}
&.reboot-step {
background: $light_brown;
}
&.unknown-step {
background: $light_gray;
}

View File

@ -383,21 +383,20 @@ describe("fetchReleases()", () => {
});
describe("fetchLatestGHBetaRelease()", () => {
const testFetchBeta = (tag_name: string, version: string) =>
it(`fetches latest beta OS release version: ${tag_name}`, async () => {
mockGetRelease = Promise.resolve({ data: [{ tag_name }] });
const dispatch = jest.fn();
await actions.fetchLatestGHBetaRelease("url/001")(dispatch);
expect(axios.get).toHaveBeenCalledWith("url");
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: { version, commit: undefined },
type: Actions.FETCH_BETA_OS_UPDATE_INFO_OK
});
it.each<[string, string]>([
["v1.0.0-beta", "1.0.0-beta"],
["v1.0.0-rc1", "1.0.0-rc1"],
])("fetches latest beta OS release version: %s", async (tag_name, version) => {
mockGetRelease = Promise.resolve({ data: [{ tag_name }] });
const dispatch = jest.fn();
await actions.fetchLatestGHBetaRelease("url/001")(dispatch);
expect(axios.get).toHaveBeenCalledWith("url");
expect(error).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
payload: { version, commit: undefined },
type: Actions.FETCH_BETA_OS_UPDATE_INFO_OK
});
testFetchBeta("v1.0.0-beta", "1.0.0-beta");
testFetchBeta("v1.0.0-rc1", "1.0.0-rc1");
});
it("fails to fetches latest beta OS release version", async () => {
mockGetRelease = Promise.reject("error");

View File

@ -36,8 +36,8 @@ export function BooleanMCUInputGroup(props: BooleanMCUInputGroupProps) {
</Col>
<Col xs={2} className={"centered-button-div"}>
<ToggleButton
grayscale={grayscale && grayscale.x}
disabled={disable && disable.x}
grayscale={grayscale?.x}
disabled={disable?.x}
dim={!xParam.consistent}
toggleValue={xParam.value}
toggleAction={() =>
@ -45,8 +45,8 @@ export function BooleanMCUInputGroup(props: BooleanMCUInputGroupProps) {
</Col>
<Col xs={2} className={"centered-button-div"}>
<ToggleButton
grayscale={grayscale && grayscale.y}
disabled={disable && disable.y}
grayscale={grayscale?.y}
disabled={disable?.y}
dim={!yParam.consistent}
toggleValue={yParam.value}
toggleAction={() =>
@ -54,8 +54,8 @@ export function BooleanMCUInputGroup(props: BooleanMCUInputGroupProps) {
</Col>
<Col xs={2} className={"centered-button-div"}>
<ToggleButton
grayscale={grayscale && grayscale.z}
disabled={disable && disable.z}
grayscale={grayscale?.z}
disabled={disable?.z}
dim={!zParam.consistent}
toggleValue={zParam.value}
toggleAction={() =>

View File

@ -355,21 +355,20 @@ describe("<OsUpdateButton/>", () => {
expect(osUpdateButton.text()).toEqual("UP TO DATE");
});
function bytesProgressTest(unit: string, progress: number, text: string) {
it(`shows update progress: ${unit}`, () => {
bot.hardware.jobs = {
"FBOS_OTA": { status: "working", bytes: progress, unit: "bytes" }
};
const buttons = mount(<OsUpdateButton {...fakeProps()} />);
const osUpdateButton = buttons.find("button").first();
expect(osUpdateButton.text()).toBe(text);
});
}
bytesProgressTest("bytes", 300, "300B");
bytesProgressTest("kilobytes", 30000, "29kB");
bytesProgressTest("megabytes", 3e6, "3MB");
it.each<[string, number]>([
["300B", 300],
["29kB", 30000],
["3MB", 3e6],
])("shows bytes update progress: %s", (expected, progress) => {
bot.hardware.jobs = {
"FBOS_OTA": { status: "working", bytes: progress, unit: "bytes" }
};
const buttons = mount(<OsUpdateButton {...fakeProps()} />);
const osUpdateButton = buttons.find("button").first();
expect(osUpdateButton.text()).toBe(expected);
});
it("shows update progress: percent", () => {
it("shows percent update progress: 10%", () => {
bot.hardware.jobs = {
"FBOS_OTA": { status: "working", percent: 10, unit: "percent" }
};

View File

@ -8,15 +8,13 @@ import { Dictionary } from "farmbot";
describe("<EncodersAndEndStops />", () => {
const mockFeatures: Dictionary<boolean> = {};
const fakeProps = (): EncodersProps => {
return {
dispatch: jest.fn(),
controlPanelState: panelState(),
sourceFwConfig: x =>
({ value: bot.hardware.mcu_params[x], consistent: true }),
shouldDisplay: jest.fn(key => mockFeatures[key]),
};
};
const fakeProps = (): EncodersProps => ({
dispatch: jest.fn(),
controlPanelState: panelState(),
sourceFwConfig: x =>
({ value: bot.hardware.mcu_params[x], consistent: true }),
shouldDisplay: jest.fn(key => mockFeatures[key]),
});
it("shows new inversion param", () => {
mockFeatures.endstop_invert = true;
@ -24,16 +22,15 @@ describe("<EncodersAndEndStops />", () => {
expect(wrapper.text().toLowerCase()).not.toContain("invert endstops");
});
const intSizeTest = (size: "short" | "long") =>
it(`uses ${size} int scaling factor`, () => {
mockFeatures.long_scaling_factor = size === "short" ? false : true;
const wrapper = shallow(<EncodersAndEndStops {...fakeProps()} />);
const sfProps = wrapper.find("NumericMCUInputGroup").at(2)
.props() as NumericMCUInputGroupProps;
expect(sfProps.name).toEqual("Encoder Scaling");
expect(sfProps.intSize).toEqual(size);
});
intSizeTest("short");
intSizeTest("long");
it.each<["short" | "long"]>([
["short"],
["long"],
])("uses %s int scaling factor", (size) => {
mockFeatures.long_scaling_factor = size === "short" ? false : true;
const wrapper = shallow(<EncodersAndEndStops {...fakeProps()} />);
const sfProps = wrapper.find("NumericMCUInputGroup").at(2)
.props() as NumericMCUInputGroupProps;
expect(sfProps.name).toEqual("Encoder Scaling");
expect(sfProps.intSize).toEqual(size);
});
});

View File

@ -115,7 +115,7 @@ describe("<PinBindingInputGroup/>", () => {
const p = fakeProps();
const key = Object.keys(p.resources.byKind.Sequence)[0];
const s = p.resources.references[key];
const id = s && s.body.id;
const id = s?.body.id;
const wrapper = mount<PinBindingInputGroup>(<PinBindingInputGroup {...p} />);
expect(wrapper.instance().state.sequenceIdInput).toEqual(undefined);
wrapper.instance().setSequenceIdInput({ label: "label", value: "" + id });

View File

@ -79,8 +79,8 @@ export const mcuParamValidator =
(ok: () => void, no?: (message: string) => void): void => {
const validator = edgeCases[key];
const result = validator && validator(key, val, state);
if (result && result.outcome === "NO") {
return (no && no(result.errorMessage));
if (result?.outcome === "NO") {
return (no?.(result.errorMessage));
} else {
return ok();
}

View File

@ -26,7 +26,7 @@ describe("draggableReducer", () => {
const dt = nextState.dataTransfer;
expect(Object.keys(dt)).toContain(payload.uuid);
const entry = dt[payload.uuid];
expect(entry && entry.uuid).toEqual(payload.uuid);
expect(entry?.uuid).toEqual(payload.uuid);
});
it("drops a step", () => {

View File

@ -56,7 +56,7 @@ export const DesignerPanelHeader = (props: DesignerPanelHeaderProps) => {
title={t("go back") + backToText(props.backTo)}
onClick={() => {
props.backTo ? routeHistory.push(props.backTo) : history.back();
props.onBack && props.onBack();
props.onBack?.();
}} />
{props.title &&
<span className={`title ${textColor}-text`}>

View File

@ -1,7 +1,14 @@
jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
init: jest.fn(() => ({ payload: { uuid: "fakeUuid" } })),
}));
jest.mock("../../../resources/actions", () => ({ destroyOK: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { RawAddFarmEvent as AddFarmEvent } from "../add_farm_event";
import { AddEditFarmEventProps } from "../../interfaces";
import {
@ -10,8 +17,13 @@ import {
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { Actions } from "../../../constants";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { destroyOK } from "../../../resources/actions";
import { init, destroy } from "../../../api/crud";
import { DesignerPanelHeader } from "../../designer_panel";
import { Content } from "../../../constants";
import { error } from "../../../toast/toast";
import { FarmEventForm } from "../edit_fe_form";
describe("<AddFarmEvent />", () => {
function fakeProps(): AddEditFarmEventProps {
@ -49,21 +61,24 @@ describe("<AddFarmEvent />", () => {
expect(deleteBtn.props().hidden).toBeTruthy();
});
it("redirects", () => {
const p = fakeProps();
p.findFarmEventByUuid = jest.fn();
const wrapper = mount(<AddFarmEvent {...p} />);
expect(wrapper.text()).toContain("Loading");
});
it("renders with no executables", () => {
const p = fakeProps();
p.findFarmEventByUuid = jest.fn();
p.sequencesById = {};
p.regimensById = {};
const wrapper = mount(<AddFarmEvent {...p} />);
expect(wrapper.text())
.toContain("You haven't made any regimens or sequences yet.");
expect(wrapper.html()).toContain("fa-exclamation-triangle");
});
it("changes temporary values", () => {
const p = fakeProps();
p.findFarmEventByUuid = jest.fn();
p.sequencesById = {};
p.regimensById = {};
const wrapper = mount<AddFarmEvent>(<AddFarmEvent {...p} />);
expect(wrapper.instance().getField("repeat")).toEqual("1");
wrapper.instance().setField("repeat", "2");
expect(wrapper.state().temporaryValues.repeat).toEqual("2");
});
it("renders with no sequences", () => {
@ -74,27 +89,57 @@ describe("<AddFarmEvent />", () => {
p.regimensById = { "1": regimen };
const wrapper = mount(<AddFarmEvent {...p} />);
wrapper.mount();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.INIT_RESOURCE,
payload: expect.objectContaining({
kind: "FarmEvent",
body: expect.objectContaining({ executable_type: "Regimen" })
})
});
expect(init).toHaveBeenCalledWith("FarmEvent",
expect.objectContaining({ executable_type: "Regimen" }));
});
it("cleans up when unmounting", () => {
const props = fakeProps();
const wrapper = mount(<AddFarmEvent {...props} />);
wrapper.update();
const uuid: string = wrapper.state("uuid");
props.farmEvents[0].uuid = uuid;
props.farmEvents[0].body.id = undefined;
wrapper.setProps(props);
wrapper.update();
jest.resetAllMocks();
const p = fakeProps();
const farmEvent = fakeFarmEvent("Sequence", 1);
farmEvent.body.id = 0;
p.findFarmEventByUuid = () => farmEvent;
const wrapper = mount(<AddFarmEvent {...p} />);
wrapper.unmount();
expect(props.dispatch).toHaveBeenCalled();
expect(props.dispatch).toHaveBeenCalledWith(expect.any(Function));
expect(destroy).toHaveBeenCalledWith(farmEvent.uuid, true);
});
it("doesn't delete saved farm events when unmounting", () => {
const p = fakeProps();
const farmEvent = fakeFarmEvent("Sequence", 1);
farmEvent.body.id = 1;
p.findFarmEventByUuid = () => farmEvent;
const wrapper = mount(<AddFarmEvent {...p} />);
wrapper.unmount();
expect(destroy).not.toHaveBeenCalled();
});
it("cleans up on back", () => {
const p = fakeProps();
const farmEvent = fakeFarmEvent("Sequence", 1);
farmEvent.body.id = 0;
p.findFarmEventByUuid = () => farmEvent;
const wrapper = shallow(<AddFarmEvent {...p} />);
wrapper.find(DesignerPanelHeader).simulate("back");
expect(destroyOK).toHaveBeenCalledWith(farmEvent);
});
it("doesn't delete saved farm events on back", () => {
const p = fakeProps();
const farmEvent = fakeFarmEvent("Sequence", 1);
farmEvent.body.id = 1;
p.findFarmEventByUuid = () => farmEvent;
const wrapper = shallow(<AddFarmEvent {...p} />);
wrapper.find(DesignerPanelHeader).simulate("back");
expect(destroyOK).not.toHaveBeenCalled();
});
it("shows error on save", () => {
const p = fakeProps();
p.findFarmEventByUuid = jest.fn();
p.sequencesById = {};
p.regimensById = {};
const wrapper = shallow(<AddFarmEvent {...p} />);
wrapper.find(FarmEventForm).simulate("save");
expect(error).toHaveBeenCalledWith(Content.MISSING_EXECUTABLE);
});
});

View File

@ -41,7 +41,7 @@ describe("<EditFarmEvent />", () => {
it("renders", () => {
const wrapper = mount(<EditFarmEvent {...fakeProps()} />);
["Edit Event", "Sequence or Regimen", "fake", "Save"]
["Sequence or Regimen", "fake", "Save"]
.map(string => expect(wrapper.text()).toContain(string));
const deleteBtn = wrapper.find("button").last();
expect(deleteBtn.text()).toEqual("Delete");

View File

@ -17,7 +17,14 @@ import {
FarmEventViewModel,
recombine,
destructureFarmEvent,
offsetTime
offsetTime,
FarmEventDeleteButtonProps,
FarmEventDeleteButton,
RepeatForm,
RepeatFormProps,
StartTimeForm,
StartTimeFormProps,
FarmEventForm
} from "../edit_fe_form";
import { isString, isFunction } from "lodash";
import { repeatOptions } from "../map_state_to_props_add_edit";
@ -28,15 +35,14 @@ import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
import { clickButton } from "../../../__test_support__/helpers";
import { destroy, save } from "../../../api/crud";
import { save, destroy } from "../../../api/crud";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { error, success } from "../../../toast/toast";
const mockSequence = fakeSequence();
describe("<FarmEventForm/>", () => {
const props = (): EditFEProps => ({
describe("<EditFEForm />", () => {
const fakeProps = (): EditFEProps => ({
deviceTimezone: undefined,
executableOptions: [],
repeatOptions: [],
@ -53,34 +59,18 @@ describe("<FarmEventForm/>", () => {
function instance(p: EditFEProps) {
return mount(<EditFEForm {...p} />).instance() as EditFEForm;
}
const context = { form: new EditFEForm(props()) };
const context = { form: new EditFEForm(fakeProps()) };
beforeEach(() => {
context.form = new EditFEForm(props());
context.form = new EditFEForm(fakeProps());
});
it("sets defaults", () => {
expect(context.form.state.fe).toMatchObject({});
});
it("determines if it is a one time event", () => {
const i = instance(props());
expect(i.repeats).toBe(false);
i.mergeState("timeUnit", "daily");
i.forceUpdate();
expect(i.repeats).toBe(true);
});
it("has a dispatch", () => {
const p = props();
const i = instance(p);
expect(i.dispatch).toBe(p.dispatch);
i.dispatch();
expect(p.dispatch).toHaveBeenCalledTimes(1);
});
it("has a view model", () => {
const p = props();
const p = fakeProps();
const i = instance(p);
i.forceUpdate();
const vm = i.viewModel;
@ -100,7 +90,7 @@ describe("<FarmEventForm/>", () => {
});
it("has an executable", () => {
const p = props();
const p = fakeProps();
const i = instance(p);
i.forceUpdate();
expect(i.executableGet().value).toEqual(mockSequence.body.id);
@ -108,7 +98,7 @@ describe("<FarmEventForm/>", () => {
});
it("sets the executable", () => {
const p = props();
const p = fakeProps();
const i = instance(p);
i.forceUpdate();
i.executableSet({ value: "wow", label: "hey", headingId: "Sequence" });
@ -118,7 +108,7 @@ describe("<FarmEventForm/>", () => {
});
it("allows proper changes to the executable", () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.id = 0;
p.farmEvent.body.executable_type = "Sequence";
const i = instance(p);
@ -128,7 +118,7 @@ describe("<FarmEventForm/>", () => {
});
it("doesn't allow improper changes to the executable", () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.id = 1;
p.farmEvent.body.executable_type = "Regimen";
const i = instance(p);
@ -139,7 +129,7 @@ describe("<FarmEventForm/>", () => {
});
it("gets executable info", () => {
const p = props();
const p = fakeProps();
const i = instance(p);
i.forceUpdate();
const exe = i.executableGet();
@ -149,13 +139,12 @@ describe("<FarmEventForm/>", () => {
});
it("sets a subfield of state.fe", () => {
const p = props();
const p = fakeProps();
const i = instance(p);
i.forceUpdate();
// tslint:disable-next-line:no-any
i.fieldSet("repeat")(({ currentTarget: { value: "4" } } as any));
i.fieldSet("executable_id", "1");
i.forceUpdate();
expect(i.state.fe.repeat).toEqual("4");
expect(i.state.fe.executable_id).toEqual("1");
});
it("sets regimen repeat to `never` as needed", () => {
@ -245,7 +234,7 @@ describe("<FarmEventForm/>", () => {
});
it("displays success message on save: manual sync", async () => {
const p = props();
const p = fakeProps();
p.autoSyncEnabled = false;
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
@ -256,7 +245,7 @@ describe("<FarmEventForm/>", () => {
});
it("displays success message on save: auto sync", async () => {
const p = props();
const p = fakeProps();
p.autoSyncEnabled = true;
p.farmEvent.body.executable_type = "Regimen";
const regimen = fakeRegimen();
@ -273,7 +262,7 @@ describe("<FarmEventForm/>", () => {
});
it("warns about missed regimen items", async () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.executable_type = "Regimen";
const regimen = fakeRegimen();
regimen.body.regimen_items = [
@ -293,7 +282,7 @@ describe("<FarmEventForm/>", () => {
});
it("sends toast with regimen start time", async () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.executable_type = "Regimen";
const regimen = fakeRegimen();
regimen.body.regimen_items = [{ sequence_id: -1, time_offset: 1000000000 }];
@ -308,7 +297,7 @@ describe("<FarmEventForm/>", () => {
});
it("sends toast with next sequence run time", async () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.executable_type = "Sequence";
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-06-22T06:00:00.000Z";
@ -328,7 +317,7 @@ describe("<FarmEventForm/>", () => {
};
it("displays error message on save (add): start time has passed", () => {
const p = props();
const p = fakeProps();
p.title = "add";
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
@ -338,7 +327,7 @@ describe("<FarmEventForm/>", () => {
});
it("displays error message on edit: start time has passed", () => {
const p = props();
const p = fakeProps();
p.title = "edit";
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
@ -349,7 +338,7 @@ describe("<FarmEventForm/>", () => {
});
it("displays error message on save: no items", async () => {
const p = props();
const p = fakeProps();
p.shouldDisplay = () => true;
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
@ -360,7 +349,7 @@ describe("<FarmEventForm/>", () => {
});
it("displays error message on save: save error", async () => {
const p = props();
const p = fakeProps();
p.dispatch = jest.fn()
.mockResolvedValueOnce("")
.mockRejectedValueOnce("error");
@ -374,7 +363,7 @@ describe("<FarmEventForm/>", () => {
});
it("allows start time: edit with unsupported OS", () => {
const p = props();
const p = fakeProps();
p.shouldDisplay = () => false;
p.farmEvent.body.executable_type = "Regimen";
p.farmEvent.body.start_time = "2017-06-01T01:00:00.000Z";
@ -385,7 +374,7 @@ describe("<FarmEventForm/>", () => {
});
it("allows start time: add with supported OS", () => {
const p = props();
const p = fakeProps();
p.title = "add";
p.shouldDisplay = () => true;
p.farmEvent.body.executable_type = "Regimen";
@ -396,7 +385,7 @@ describe("<FarmEventForm/>", () => {
});
it("rejects start time: add sequence event", () => {
const p = props();
const p = fakeProps();
p.title = "add";
p.farmEvent.body.executable_type = "Sequence";
p.farmEvent.body.start_time = "2017-06-01T01:00:00.000Z";
@ -406,7 +395,7 @@ describe("<FarmEventForm/>", () => {
});
it("allows start time: edit sequence event", () => {
const p = props();
const p = fakeProps();
p.farmEvent.body.executable_type = "Sequence";
p.farmEvent.body.start_time = "2017-06-01T01:00:00.000Z";
const fakeNow = moment("2017-06-01T02:00:00.000Z");
@ -416,7 +405,7 @@ describe("<FarmEventForm/>", () => {
});
it("allows start time in the future", () => {
const p = props();
const p = fakeProps();
p.title = "add";
p.farmEvent.body.executable_type = "Sequence";
p.farmEvent.body.start_time = "2017-06-01T01:00:00.000Z";
@ -426,7 +415,7 @@ describe("<FarmEventForm/>", () => {
});
it("edits a variable", () => {
const p = props();
const p = fakeProps();
const oldVariable: ParameterApplication = {
kind: "parameter_application",
args: {
@ -455,7 +444,7 @@ describe("<FarmEventForm/>", () => {
});
it("saves an updated variable", () => {
const p = props();
const p = fakeProps();
const oldVariable: ParameterApplication = {
kind: "parameter_application",
args: {
@ -481,7 +470,7 @@ describe("<FarmEventForm/>", () => {
});
it("saves the current variable", () => {
const p = props();
const p = fakeProps();
const sequence = fakeSequence();
p.findExecutable = () => sequence;
const plant = fakePlant();
@ -504,44 +493,11 @@ describe("<FarmEventForm/>", () => {
expect(inst.updatedFarmEvent.body).toEqual([oldVariable]);
});
it("deletes a farmEvent", async () => {
const p = props();
p.dispatch = jest.fn(() => Promise.resolve());
const inst = instance(p);
const wrapper = shallow(<inst.FarmEventDeleteButton />);
clickButton(wrapper, 0, "delete");
await expect(destroy).toHaveBeenCalledWith(p.farmEvent.uuid);
expect(history.push).toHaveBeenCalledWith("/app/designer/events");
expect(success).toHaveBeenCalledWith("Deleted event.", "Deleted");
});
it("sets repeat", () => {
const p = props();
p.dispatch = jest.fn(() => Promise.resolve());
const e = {
currentTarget: { checked: true }
} as React.ChangeEvent<HTMLInputElement>;
const inst = instance(p);
inst.toggleRepeat(e);
expect(inst.state).toEqual({
fe: { timeUnit: "daily" },
specialStatusLocal: SpecialStatus.DIRTY
});
});
it("sets repeat: regimen", () => {
const p = props();
p.farmEvent.body.executable_type = "Regimen";
p.dispatch = jest.fn(() => Promise.resolve());
const e = {
currentTarget: { checked: true }
} as React.ChangeEvent<HTMLInputElement>;
const inst = instance(p);
inst.toggleRepeat(e);
expect(inst.state).toEqual({
fe: { timeUnit: "never" },
specialStatusLocal: SpecialStatus.DIRTY
});
it("shows error message upon farm event save", () => {
const p = fakeProps();
const wrapper = shallow(<EditFEForm {...p} />);
wrapper.find(FarmEventForm).simulate("save");
expect(error).toHaveBeenCalled();
});
});
@ -557,3 +513,75 @@ describe("destructureFarmEvent", () => {
expect(endTime).toBe("23:32");
});
});
describe("<StartTimeForm />", () => {
const fakeProps = (): StartTimeFormProps => ({
isRegimen: false,
fieldGet: jest.fn(),
fieldSet: jest.fn(),
timeSettings: fakeTimeSettings(),
});
it("changes start date", () => {
const p = fakeProps();
const wrapper = shallow(<StartTimeForm {...p} />);
wrapper.find("BlurableInput").first().simulate("commit", {
currentTarget: { value: "2017-07-26" }
});
expect(p.fieldSet).toHaveBeenCalledWith("startDate", "2017-07-26");
});
it("changes start time", () => {
const p = fakeProps();
const wrapper = shallow(<StartTimeForm {...p} />);
wrapper.find("EventTimePicker").simulate("commit", {
currentTarget: { value: "08:57" }
});
expect(p.fieldSet).toHaveBeenCalledWith("startTime", "08:57");
});
});
describe("<RepeatForm />", () => {
const fakeProps = (): RepeatFormProps => ({
isRegimen: false,
fieldGet: jest.fn(key =>
"" + ({ endDate: "2017-07-26" } as FarmEventViewModel)[key]),
fieldSet: jest.fn(),
timeSettings: fakeTimeSettings(),
});
it("toggles repeat on", () => {
const p = fakeProps();
const wrapper = shallow(<RepeatForm {...p} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { checked: true }
});
expect(p.fieldSet).toHaveBeenCalledWith("timeUnit", "daily");
});
it("toggles repeat off", () => {
const p = fakeProps();
const wrapper = shallow(<RepeatForm {...p} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { checked: false }
});
expect(p.fieldSet).toHaveBeenCalledWith("timeUnit", "never");
});
});
describe("<FarmEventDeleteButton />", () => {
const fakeProps = (): FarmEventDeleteButtonProps => ({
hidden: false,
farmEvent: fakeFarmEvent("Sequence", 1),
dispatch: jest.fn(() => Promise.resolve()),
});
it("deletes farm event", async () => {
const p = fakeProps();
const wrapper = shallow(<FarmEventDeleteButton {...p} />);
await wrapper.find("button").simulate("click");
expect(destroy).toHaveBeenCalledWith(p.farmEvent.uuid);
expect(history.push).toHaveBeenCalledWith("/app/designer/events");
expect(success).toHaveBeenCalledWith("Deleted event.", "Deleted");
});
});

View File

@ -1,20 +1,21 @@
import * as React from "react";
import { RepeatFormProps, FarmEventRepeatForm } from "../farm_event_repeat_form";
import { betterMerge } from "../../../util";
import {
FarmEventRepeatFormProps, FarmEventRepeatForm
} from "../farm_event_repeat_form";
import { shallow, ShallowWrapper, render } from "enzyme";
import { get } from "lodash";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
const DEFAULTS: RepeatFormProps = {
const fakeProps = (): FarmEventRepeatFormProps => ({
disabled: false,
hidden: false,
onChange: jest.fn(),
fieldSet: jest.fn(),
timeUnit: "daily",
repeat: "1",
endDate: "2017-07-26",
endTime: "08:57",
timeSettings: fakeTimeSettings(),
};
});
enum Selectors {
REPEAT = "BlurableInput[name=\"repeat\"]",
@ -23,10 +24,6 @@ enum Selectors {
TIME_UNIT = "FBSelect"
}
function props(i?: Partial<RepeatFormProps>): RepeatFormProps {
return betterMerge(DEFAULTS, i || {});
}
function formVal(el: ShallowWrapper<{}, {}>, query: string) {
return getProp(el, query, "value");
}
@ -37,8 +34,8 @@ function getProp(el: ShallowWrapper<{}, {}>, query: string, prop: string) {
describe("<FarmEventRepeatForm/>", () => {
it("shows proper values", () => {
const p = props();
const el = shallow<RepeatFormProps>(<FarmEventRepeatForm {...p} />);
const p = fakeProps();
const el = shallow<FarmEventRepeatFormProps>(<FarmEventRepeatForm {...p} />);
expect(formVal(el, Selectors.REPEAT)).toEqual(p.repeat);
expect(formVal(el, Selectors.END_DATE)).toEqual(p.endDate);
expect(formVal(el, Selectors.END_TIME)).toEqual(p.endTime);
@ -47,7 +44,7 @@ describe("<FarmEventRepeatForm/>", () => {
});
it("defaults to `daily` when a bad input it passed", () => {
const p = props();
const p = fakeProps();
p.timeUnit = "never";
const el = shallow(<FarmEventRepeatForm {...p} />);
expect(formVal(el, Selectors.REPEAT)).toEqual(p.repeat);
@ -55,7 +52,7 @@ describe("<FarmEventRepeatForm/>", () => {
});
it("disables all inputs via the `disabled` prop", () => {
const p = props();
const p = fakeProps();
p.disabled = true;
const el = shallow(<FarmEventRepeatForm {...p} />);
expect(getProp(el, Selectors.END_DATE, "disabled")).toBeTruthy();
@ -65,9 +62,37 @@ describe("<FarmEventRepeatForm/>", () => {
});
it("hides", () => {
const p = props();
const p = fakeProps();
p.hidden = true;
const el = render(<FarmEventRepeatForm {...p} />);
expect(el.text()).toEqual("");
});
const testBlurable = (input: string, field: string, value: string) => {
const p = fakeProps();
const wrapper = shallow(<FarmEventRepeatForm {...p} />);
wrapper.find(input).simulate("commit", {
currentTarget: { value }
});
expect(p.fieldSet).toHaveBeenCalledWith(field, value);
};
it("changes repeat frequency", () => {
testBlurable(Selectors.REPEAT, "repeat", "1");
});
it("changes time unit", () => {
const p = fakeProps();
const wrapper = shallow(<FarmEventRepeatForm {...p} />);
wrapper.find(Selectors.TIME_UNIT).simulate("change", { value: "daily" });
expect(p.fieldSet).toHaveBeenCalledWith("timeUnit", "daily");
});
it("changes end date", () => {
testBlurable(Selectors.END_DATE, "endDate", "2017-07-26");
});
it("changes end time", () => {
testBlurable(Selectors.END_TIME, "endTime", "08:57");
});
});

View File

@ -5,7 +5,6 @@ import {
} from "../../../__test_support__/farm_event_calendar_support";
import { render, shallow, mount } from "enzyme";
import { get } from "lodash";
import { Content } from "../../../constants";
import { defensiveClone } from "../../../util";
import { FarmEventProps } from "../../interfaces";
@ -29,15 +28,6 @@ describe("<PureFarmEvents/>", () => {
expect(rows[2]).toEqual("02:00pm");
});
it("warns about unset timezones", () => {
const p = fakeProps();
p.timezoneIsSet = false;
const results = render(<PureFarmEvents {...p} />);
const txt = results.text();
expect(txt).toContain(Content.SET_TIMEZONE_HEADER);
expect(txt).toContain(Content.SET_TIMEZONE_BODY);
});
it("renders FarmEvent lacking a subheading", () => {
const p = fakeProps();
const row = [defensiveClone(calendarRows[0])];

View File

@ -1,36 +1,53 @@
import * as React from "react";
import moment from "moment";
import { connect } from "react-redux";
import { mapStateToPropsAddEdit, } from "./map_state_to_props_add_edit";
import {
mapStateToPropsAddEdit, formatDate, formatTime,
} from "./map_state_to_props_add_edit";
import { init, destroy } from "../../api/crud";
import { EditFEForm } from "./edit_fe_form";
import { betterCompact } from "../../util";
import {
EditFEForm, FarmEventForm, FarmEventViewModel, NEVER
} from "./edit_fe_form";
import { betterCompact, betterMerge } from "../../util";
import { entries } from "../../resources/util";
import {
AddEditFarmEventProps,
TaggedExecutable
} from "../interfaces";
import { ExecutableType } from "farmbot/dist/resources/api_resources";
import { Link } from "../../link";
import {
DesignerPanel, DesignerPanelHeader, DesignerPanelContent
} from "../designer_panel";
import { variableList } from "../../sequences/locals_list/variable_support";
import { t } from "../../i18next_wrapper";
import { Panel } from "../panel_header";
import { SpecialStatus } from "farmbot";
import { destroyOK } from "../../resources/actions";
import { Content } from "../../constants";
import { error } from "../../toast/toast";
interface State {
uuid: string;
temporaryValues: Partial<FarmEventViewModel>;
}
export class RawAddFarmEvent
extends React.Component<AddEditFarmEventProps, Partial<State>> {
constructor(props: AddEditFarmEventProps) {
super(props);
this.state = {};
extends React.Component<AddEditFarmEventProps, State> {
temporaryValueDefaults = () => {
const now = new Date();
const later = new Date(now.getTime() + 60000);
return {
startDate: formatDate(now.toString(), this.props.timeSettings),
startTime: formatTime(now.toString(), this.props.timeSettings),
endDate: formatDate(now.toString(), this.props.timeSettings),
endTime: formatTime(later.toString(), this.props.timeSettings),
repeat: "1",
timeUnit: NEVER,
};
}
state: State = { uuid: "", temporaryValues: this.temporaryValueDefaults() };
get sequences() { return betterCompact(entries(this.props.sequencesById)); }
get regimens() { return betterCompact(entries(this.props.regimensById)); }
@ -71,55 +88,51 @@ export class RawAddFarmEvent
if (fe && unsaved) { this.props.dispatch(destroy(fe.uuid, true)); }
}
/** No executables. Can't load form. */
none() {
return <p>
{t("You haven't made any regimens or sequences yet. Please create a ")}
<Link to="/app/sequences">{t("sequence")}</Link> {t(" or ")}
<Link to="/app/regimens">{t("regimen")}</Link> {t(" first.")}
</p>;
}
/** User has executables to create FarmEvents with, has not loaded yet. */
loading() {
return <p>{t("Loading")}...</p>;
}
placeholderTemplate(children: React.ReactChild | React.ReactChild[]) {
return <DesignerPanel panelName={"add-farm-event"} panel={Panel.FarmEvents}>
<DesignerPanelHeader
panelName={"add-farm-event"}
panel={Panel.FarmEvents}
title={t("No Executables")} />
<DesignerPanelContent panelName={"add-farm-event"}>
<label>
{children}
</label>
</DesignerPanelContent>
</DesignerPanel>;
}
getField = (field: keyof State["temporaryValues"]): string =>
"" + this.state.temporaryValues[field];
setField = (field: keyof State["temporaryValues"], value: string) =>
this.setState(betterMerge(this.state, {
temporaryValues: { [field]: value }
}))
render() {
const { uuid } = this.state;
const fe = this.props.findFarmEventByUuid(uuid);
if (fe) {
return <EditFEForm
farmEvent={fe}
deviceTimezone={this.props.deviceTimezone}
repeatOptions={this.props.repeatOptions}
executableOptions={this.props.executableOptions}
dispatch={this.props.dispatch}
findExecutable={this.props.findExecutable}
const farmEvent = this.props.findFarmEventByUuid(this.state.uuid);
const panelName = "add-farm-event";
return <DesignerPanel panelName={panelName} panel={Panel.FarmEvents}>
<DesignerPanelHeader
panelName={panelName}
panel={Panel.FarmEvents}
title={t("Add Event")}
timeSettings={this.props.timeSettings}
autoSyncEnabled={this.props.autoSyncEnabled}
resources={this.props.resources}
shouldDisplay={this.props.shouldDisplay}
/>;
} else {
return this
.placeholderTemplate(this.executable ? this.loading() : this.none());
}
onBack={(farmEvent && !farmEvent.body.id)
? () => this.props.dispatch(destroyOK(farmEvent))
: undefined} />
<DesignerPanelContent panelName={panelName}>
{farmEvent
? <EditFEForm
farmEvent={farmEvent}
deviceTimezone={this.props.deviceTimezone}
repeatOptions={this.props.repeatOptions}
executableOptions={this.props.executableOptions}
dispatch={this.props.dispatch}
findExecutable={this.props.findExecutable}
title={t("Add Event")}
timeSettings={this.props.timeSettings}
autoSyncEnabled={this.props.autoSyncEnabled}
resources={this.props.resources}
shouldDisplay={this.props.shouldDisplay} />
: <FarmEventForm
isRegimen={false}
fieldGet={this.getField}
fieldSet={this.setField}
timeSettings={this.props.timeSettings}
executableOptions={[]}
executableSet={() => { }}
executableGet={() => undefined}
dispatch={this.props.dispatch}
specialStatus={SpecialStatus.DIRTY}
onSave={() => error(t(Content.MISSING_EXECUTABLE))} />}
</DesignerPanelContent>
</DesignerPanel>;
}
}

View File

@ -16,10 +16,10 @@ export function occurrence(
CalendarOccurrence {
const normalHeading = fe.executable.name || fe.executable_type;
const heading = () => {
if (modifiers && modifiers.empty) {
if (modifiers?.empty) {
return "*Empty*";
}
if (modifiers && modifiers.numHidden) {
if (modifiers?.numHidden) {
return `+ ${modifiers.numHidden} more: ` + normalHeading;
}
return normalHeading;

View File

@ -3,7 +3,7 @@ import moment from "moment";
import { success, error } from "../../toast/toast";
import {
TaggedFarmEvent, SpecialStatus, TaggedSequence, TaggedRegimen,
ParameterApplication
ParameterApplication,
} from "farmbot";
import { ExecutableQuery } from "../interfaces";
import { formatTime, formatDate } from "./map_state_to_props_add_edit";
@ -12,18 +12,17 @@ import {
Col, Row,
SaveBtn,
FBSelect,
DropDownItem
DropDownItem,
Help,
} from "../../ui";
import { destroy, save, overwrite } from "../../api/crud";
import { history } from "../../history";
// TIL: https://stackoverflow.com/a/24900248/1064917
import { betterMerge, parseIntInput } from "../../util";
import { maybeWarnAboutMissedTasks } from "./util";
import { FarmEventRepeatForm } from "./farm_event_repeat_form";
import { scheduleForFarmEvent } from "./calendar/scheduler";
import { executableType } from "../util";
import { Content } from "../../constants";
import { destroyOK } from "../../resources/actions";
import { EventTimePicker } from "./event_time_picker";
import { TzWarning } from "./tz_warning";
import { nextRegItemTimes } from "./map_state_to_props";
@ -31,9 +30,6 @@ import { first } from "lodash";
import {
TimeUnit, ExecutableType, FarmEvent
} from "farmbot/dist/resources/api_resources";
import {
DesignerPanel, DesignerPanelHeader, DesignerPanelContent
} from "../designer_panel";
import { LocalsList } from "../../sequences/locals_list/locals_list";
import { ResourceIndex } from "../../resources/interfaces";
import { ShouldDisplay } from "../../devices/interfaces";
@ -41,13 +37,11 @@ import {
addOrEditParamApps, variableList, getRegimenVariableData
} from "../../sequences/locals_list/variable_support";
import {
AllowedVariableNodes
AllowedVariableNodes,
} from "../../sequences/locals_list/locals_list_support";
import { t } from "../../i18next_wrapper";
import { TimeSettings } from "../../interfaces";
import { Panel } from "../panel_header";
type FormEvent = React.SyntheticEvent<HTMLInputElement>;
export const NEVER: TimeUnit = "never";
/** Separate each of the form fields into their own interface. Recombined later
* on save.
@ -66,6 +60,8 @@ export interface FarmEventViewModel {
body?: ParameterApplication[];
}
export type FarmEventViewModelKey = keyof FarmEventViewModel;
/** Breaks up a TaggedFarmEvent into a structure that can easily be used
* by the edit form.
* USE CASE EXAMPLE: We have a "date" and "time" field that are created from
@ -148,7 +144,7 @@ export interface EditFEProps {
shouldDisplay: ShouldDisplay;
}
interface State {
export interface EditFEFormState {
/**
* Hold a partial FarmEvent locally containing only updates made by the form.
*/
@ -162,12 +158,8 @@ interface State {
specialStatusLocal: SpecialStatus;
}
export class EditFEForm extends React.Component<EditFEProps, State> {
state: State = { fe: {}, specialStatusLocal: SpecialStatus.SAVED };
get repeats() { return this.fieldGet("timeUnit") !== NEVER; }
get dispatch() { return this.props.dispatch; }
export class EditFEForm extends React.Component<EditFEProps, EditFEFormState> {
state: EditFEFormState = { fe: {}, specialStatusLocal: SpecialStatus.SAVED };
/** API data for the FarmEvent to which form updates can be applied. */
get viewModel() {
@ -240,7 +232,7 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
const { uuid } = this.props.findExecutable(
next_executable_type, parseInt("" + ddi.value));
const varData = this.props.resources.sequenceMetas[uuid];
const update: State = {
const update: EditFEFormState = {
fe: {
executable_type: next_executable_type,
executable_id: (ddi.value || "").toString(),
@ -263,28 +255,15 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
};
}
fieldSet = (name: keyof State["fe"]) => (e: FormEvent) => {
fieldSet = (name: FarmEventViewModelKey, value: string) =>
// A merge is required to not overwrite `fe`.
this.setState(betterMerge(this.state, {
fe: { [name]: e.currentTarget.value },
fe: { [name]: value },
specialStatusLocal: SpecialStatus.DIRTY
}));
}
}))
fieldGet = (name: keyof State["fe"]): string => {
return (this.state.fe[name] || this.viewModel[name] || "").toString();
}
mergeState = (k: keyof FarmEventViewModel, v: string) => {
this.setState(betterMerge(this.state, {
fe: { [k]: v },
specialStatusLocal: SpecialStatus.DIRTY
}));
}
toggleRepeat = (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked } = e.currentTarget;
this.mergeState("timeUnit", (!checked || this.isReg) ? "never" : "daily");
};
fieldGet = (name: FarmEventViewModelKey): string =>
(this.state.fe[name] || this.viewModel[name] || "").toString()
nextItemTime = (fe: FarmEvent, now: moment.Moment
): moment.Moment | undefined => {
@ -362,11 +341,12 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
if (!this.nextItemTime(this.updatedFarmEvent, now)) {
return nothingToRunWarning();
}
this.dispatch(overwrite(this.props.farmEvent, this.updatedFarmEvent));
this.dispatch(save(this.props.farmEvent.uuid))
const { dispatch } = this.props;
dispatch(overwrite(this.props.farmEvent, this.updatedFarmEvent));
dispatch(save(this.props.farmEvent.uuid))
.then(() => {
this.setState({ specialStatusLocal: SpecialStatus.SAVED });
this.dispatch(maybeWarnAboutMissedTasks(this.props.farmEvent,
dispatch(maybeWarnAboutMissedTasks(this.props.farmEvent,
() => alert(t(Content.REGIMEN_TODAY_SKIPPED_ITEM_RISK)), now));
this.nextRunTimeActions(now);
history.push("/app/designer/events");
@ -377,123 +357,184 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
});
}
StartTimeForm = () => {
const forceMidnight = this.isReg;
return <div>
<label>
{t("Starts")}
</label>
<Row>
<Col xs={6}>
<BlurableInput
type="date"
className="add-event-start-date"
name="start_date"
value={this.fieldGet("startDate")}
onCommit={this.fieldSet("startDate")} />
</Col>
<Col xs={6}>
<EventTimePicker
className="add-event-start-time"
name="start_time"
timeSettings={this.props.timeSettings}
value={this.fieldGet("startTime")}
onCommit={this.fieldSet("startTime")}
disabled={forceMidnight}
hidden={forceMidnight} />
</Col>
</Row>
</div>;
}
RepeatCheckbox = ({ allowRepeat }: { allowRepeat: boolean }) =>
!this.isReg ?
<label>
<input type="checkbox"
onChange={this.toggleRepeat}
disabled={this.isReg}
checked={allowRepeat} />
&nbsp;{t("Repeats?")}
</label> : <div />
dateCheck = (): string | undefined => {
const startDate = this.fieldGet("startDate");
const endDate = this.fieldGet("endDate");
if (!moment(endDate).isSameOrAfter(moment(startDate))) {
return t("End date must not be before start date.");
}
}
timeCheck = (): string | undefined => {
const startDate = this.fieldGet("startDate");
const startTime = this.fieldGet("startTime");
const endDate = this.fieldGet("endDate");
const endTime = this.fieldGet("endTime");
const start = offsetTime(startDate, startTime, this.props.timeSettings);
const end = offsetTime(endDate, endTime, this.props.timeSettings);
if (moment(start).isSameOrAfter(moment(end))) {
return t("End time must be after start time.");
}
}
RepeatForm = () => {
const allowRepeat = !this.isReg && this.repeats;
return <div>
<this.RepeatCheckbox allowRepeat={allowRepeat} />
<FarmEventRepeatForm
timeSettings={this.props.timeSettings}
disabled={!allowRepeat}
hidden={!allowRepeat}
onChange={this.mergeState}
timeUnit={this.fieldGet("timeUnit") as TimeUnit}
repeat={this.fieldGet("repeat")}
endDate={this.fieldGet("endDate")}
endTime={this.fieldGet("endTime")}
dateError={this.dateCheck()}
timeError={this.timeCheck()} />
</div>;
}
FarmEventDeleteButton = () =>
<button className="fb-button red" hidden={!this.props.deleteBtn}
onClick={() => {
this.dispatch(destroy(this.props.farmEvent.uuid))
.then(() => {
history.push("/app/designer/events");
success(t("Deleted event."), t("Deleted"));
});
}}>
{t("Delete")}
</button>
render() {
const { farmEvent } = this.props;
return <DesignerPanel panelName={"add-farm-event"} panel={Panel.FarmEvents}>
<DesignerPanelHeader
panelName={"add-farm-event"}
panel={Panel.FarmEvents}
title={this.props.title}
onBack={!farmEvent.body.id ? () =>
// Throw out unsaved farmevents.
this.props.dispatch(destroyOK(farmEvent))
: undefined} />
<DesignerPanelContent panelName={"add-farm-event"}>
<label>
{t("Sequence or Regimen")}
</label>
<FBSelect
list={this.props.executableOptions}
onChange={this.executableSet}
selectedItem={this.executableGet()} />
return <div className="edit-farm-event-form">
<FarmEventForm
isRegimen={this.isReg}
fieldGet={this.fieldGet}
fieldSet={this.fieldSet}
timeSettings={this.props.timeSettings}
executableOptions={this.props.executableOptions}
executableSet={this.executableSet}
executableGet={this.executableGet}
dispatch={this.props.dispatch}
specialStatus={farmEvent.specialStatus || this.state.specialStatusLocal}
onSave={() => this.commitViewModel()}>
<this.LocalsList />
<this.StartTimeForm />
<this.RepeatForm />
<SaveBtn
status={farmEvent.specialStatus || this.state.specialStatusLocal}
onClick={() => this.commitViewModel()} />
<this.FarmEventDeleteButton />
<TzWarning deviceTimezone={this.props.deviceTimezone} />
</DesignerPanelContent>
</DesignerPanel>;
</FarmEventForm>
<FarmEventDeleteButton
hidden={!this.props.deleteBtn}
farmEvent={this.props.farmEvent}
dispatch={this.props.dispatch} />
<TzWarning deviceTimezone={this.props.deviceTimezone} />
</div>;
}
}
export interface StartTimeFormProps {
isRegimen: boolean;
fieldGet(name: FarmEventViewModelKey): string;
fieldSet(name: FarmEventViewModelKey, value: string): void;
timeSettings: TimeSettings;
}
export const StartTimeForm = (props: StartTimeFormProps) => {
const forceMidnight = props.isRegimen;
return <div className="start-time-form">
<label>
{t("Starts")}
</label>
<Row>
<Col xs={6}>
<BlurableInput
type="date"
className="add-event-start-date"
name="start_date"
value={props.fieldGet("startDate")}
onCommit={e => props.fieldSet("startDate", e.currentTarget.value)} />
</Col>
<Col xs={6}>
<EventTimePicker
className="add-event-start-time"
name="start_time"
timeSettings={props.timeSettings}
value={props.fieldGet("startTime")}
onCommit={e => props.fieldSet("startTime", e.currentTarget.value)}
disabled={forceMidnight}
hidden={forceMidnight} />
</Col>
</Row>
</div>;
};
export interface RepeatFormProps {
isRegimen: boolean;
fieldGet(name: FarmEventViewModelKey): string;
fieldSet(name: FarmEventViewModelKey, value: string): void;
timeSettings: TimeSettings;
}
export const RepeatForm = (props: RepeatFormProps) => {
const allowRepeat = !props.isRegimen && props.fieldGet("timeUnit") !== NEVER;
return <div className="farm-event-repeat-options">
{!props.isRegimen
? <label>
<input type="checkbox"
onChange={e => props.fieldSet("timeUnit",
(!e.currentTarget.checked || props.isRegimen) ? "never" : "daily")}
disabled={props.isRegimen}
checked={allowRepeat} />
{t("Repeats?")}
</label>
: <div />}
<FarmEventRepeatForm
timeSettings={props.timeSettings}
disabled={!allowRepeat}
hidden={!allowRepeat}
fieldSet={props.fieldSet}
timeUnit={props.fieldGet("timeUnit") as TimeUnit}
repeat={props.fieldGet("repeat")}
endDate={props.fieldGet("endDate")}
endTime={props.fieldGet("endTime")}
dateError={dateCheck(props.fieldGet)}
timeError={timeCheck(props.fieldGet, props.timeSettings)} />
</div>;
};
export const dateCheck = (
fieldGet: (name: FarmEventViewModelKey) => string
): string | undefined => {
const startDate = fieldGet("startDate");
const endDate = fieldGet("endDate");
if (!moment(endDate).isSameOrAfter(moment(startDate))) {
return t("End date must not be before start date.");
}
};
export const timeCheck = (
fieldGet: (name: FarmEventViewModelKey) => string,
timeSettings: TimeSettings
): string | undefined => {
const startDate = fieldGet("startDate");
const startTime = fieldGet("startTime");
const endDate = fieldGet("endDate");
const endTime = fieldGet("endTime");
const start = offsetTime(startDate, startTime, timeSettings);
const end = offsetTime(endDate, endTime, timeSettings);
if (moment(start).isSameOrAfter(moment(end))) {
return t("End time must be after start time.");
}
};
export interface FarmEventDeleteButtonProps {
hidden: boolean;
farmEvent: TaggedFarmEvent;
dispatch: Function;
}
export const FarmEventDeleteButton = (props: FarmEventDeleteButtonProps) =>
<button className="fb-button red" hidden={props.hidden}
onClick={() =>
props.dispatch(destroy(props.farmEvent.uuid))
.then(() => {
history.push("/app/designer/events");
success(t("Deleted event."), t("Deleted"));
})}>
{t("Delete")}
</button>;
export interface FarmEventFormProps {
isRegimen: boolean;
fieldGet(name: FarmEventViewModelKey): string;
fieldSet(name: FarmEventViewModelKey, value: string): void;
timeSettings: TimeSettings;
executableOptions: DropDownItem[];
executableSet(ddi: DropDownItem): void;
executableGet(): DropDownItem | undefined;
dispatch: Function;
specialStatus: SpecialStatus;
onSave(): void;
children?: React.ReactChild;
}
export const FarmEventForm = (props: FarmEventFormProps) => {
const { isRegimen, fieldGet, fieldSet, timeSettings } = props;
return <div className="farm-event-form">
<label>
{t("Sequence or Regimen")}
</label>
{props.executableOptions.length < 1 &&
<Help
text={Content.MISSING_EXECUTABLE}
customIcon={"exclamation-triangle"} />}
<FBSelect
list={props.executableOptions}
onChange={props.executableSet}
selectedItem={props.executableGet()} />
{props.children}
<StartTimeForm
isRegimen={isRegimen}
fieldGet={fieldGet}
fieldSet={fieldSet}
timeSettings={timeSettings} />
<RepeatForm
isRegimen={isRegimen}
fieldGet={fieldGet}
fieldSet={fieldSet}
timeSettings={props.timeSettings} />
<SaveBtn
status={props.specialStatus}
onClick={props.onSave} />
</div>;
};

View File

@ -10,15 +10,12 @@ import { TimeUnit } from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { TimeSettings } from "../../interfaces";
type Ev = React.SyntheticEvent<HTMLInputElement>;
type Key = keyof FarmEventViewModel;
export interface RepeatFormProps {
export interface FarmEventRepeatFormProps {
/** Should the form controls be grayed out? */
disabled: boolean;
/** Should the form be shown _at all_? */
hidden: boolean;
onChange(key: Key, value: string): void;
fieldSet(name: keyof FarmEventViewModel, value: string): void;
timeUnit: TimeUnit;
repeat: string;
endDate: string;
@ -31,11 +28,9 @@ export interface RepeatFormProps {
const indexKey: keyof DropDownItem = "value";
const OPTN_LOOKUP = keyBy(repeatOptions, indexKey);
export function FarmEventRepeatForm(props: RepeatFormProps) {
const { disabled, onChange, repeat, endDate, endTime, timeUnit } = props;
const changeHandler =
(key: Key) => (e: Ev) => onChange(key, e.currentTarget.value);
return props.hidden ? <div /> : <div>
export function FarmEventRepeatForm(props: FarmEventRepeatFormProps) {
const { disabled, fieldSet, repeat, endDate, endTime, timeUnit } = props;
return props.hidden ? <div /> : <div className="farm-event-repeat-form">
<label>
{t("Every")}
</label>
@ -48,13 +43,13 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
className="add-event-repeat-frequency"
name="repeat"
value={repeat}
onCommit={changeHandler("repeat")}
onCommit={e => fieldSet("repeat", e.currentTarget.value)}
min={1} />
</Col>
<Col xs={8}>
<FBSelect
list={repeatOptions}
onChange={(e) => onChange("timeUnit", "" + e.value)}
onChange={ddi => fieldSet("timeUnit", "" + ddi.value)}
selectedItem={OPTN_LOOKUP[timeUnit] || OPTN_LOOKUP["daily"]} />
</Col>
</Row>
@ -69,7 +64,7 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
className="add-event-end-date"
name="endDate"
value={endDate}
onCommit={changeHandler("endDate")}
onCommit={e => fieldSet("endDate", e.currentTarget.value)}
error={props.dateError} />
</Col>
<Col xs={6}>
@ -79,7 +74,7 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
name="endTime"
timeSettings={props.timeSettings}
value={endTime}
onCommit={changeHandler("endTime")}
onCommit={e => fieldSet("endTime", e.currentTarget.value)}
error={props.timeError} />
</Col>
</Row>

View File

@ -1,6 +1,5 @@
import * as React from "react";
import { connect } from "react-redux";
import { Row } from "../../ui/index";
import { mapStateToProps } from "./map_state_to_props";
import {
FarmEventProps, CalendarOccurrence, FarmEventState
@ -100,28 +99,9 @@ export class PureFarmEvents
});
}
/** FarmEvents will generate some very unexpected results if the user has
* not set a timezone for the bot (defaults to 0 UTC offset, which could be
* far from user's local time). */
tzwarning = () => {
return <DesignerPanelContent panelName={"farm-event"}>
<Row>
</Row>
<div className="farm-events">
<h2>Timezone Required</h2>
<p>
{t(Content.SET_TIMEZONE_HEADER)}
</p>
<p>
<Link to="/app/device">{t(Content.SET_TIMEZONE_BODY)}</Link>
</p>
</div>
</DesignerPanelContent>;
};
normalContent = () => {
return <div className="farm-event-panel-normal-content">
render() {
return <DesignerPanel panelName={"farm-event"} panel={Panel.FarmEvents}>
<DesignerNavTabs />
<DesignerPanelTop
panel={Panel.FarmEvents}
linkTo={"/app/designer/events/add"}
@ -134,7 +114,6 @@ export class PureFarmEvents
placeholder={t("Search events...")} />
</DesignerPanelTop>
<DesignerPanelContent panelName={"farm-event"}>
<div className="farm-events">
<EmptyStateWrapper
notEmpty={this.props.calendarRows.length > 0}
@ -146,19 +125,8 @@ export class PureFarmEvents
</EmptyStateWrapper>
</div>
</DesignerPanelContent>
</div>;
};
render() {
return <DesignerPanel panelName={"farm-event"} panel={Panel.FarmEvents}>
<DesignerNavTabs />
{this.props.timezoneIsSet ? this.normalContent() : this.tzwarning()}
</DesignerPanel>;
}
}
/** This is intentional. It is not a hack or a work around.
* It avoids mocking `connect` in unit tests.
* See testing pattern noted here: https://github.com/airbnb/enzyme/issues/98
*/
export const FarmEvents = connect(mapStateToProps)(PureFarmEvents);

View File

@ -1,7 +1,7 @@
let mockPath = "/app/designer/plants";
jest.mock("../../../history", () => ({
history: { push: jest.fn() },
getPathArray: jest.fn(() => { return mockPath.split("/"); })
getPathArray: jest.fn(() => mockPath.split("/")),
}));
jest.mock("../../../api/crud", () => ({
@ -32,11 +32,12 @@ import {
} from "../../../__test_support__/resource_index_builder";
describe("movePlant", () => {
function movePlantTest(
caseDescription: string,
attempted: { x: number, y: number },
expected: { x: number, y: number }) {
it(`restricts plant to grid area: ${caseDescription}`, () => {
it.each<[string, Record<"x" | "y", number>, Record<"x" | "y", number>]>([
["within bounds", { x: 1, y: 2 }, { x: 101, y: 202 }],
["too high", { x: 10000, y: 10000 }, { x: 3000, y: 1500 }],
["too low", { x: -10000, y: -10000 }, { x: 0, y: 0 }],
])("restricts plant to grid area: %s",
(_test_description, attempted, expected) => {
const payload: MovePlantProps = {
deltaX: attempted.x,
deltaY: attempted.y,
@ -57,10 +58,6 @@ describe("movePlant", () => {
})
);
});
}
movePlantTest("within bounds", { x: 1, y: 2 }, { x: 101, y: 202 });
movePlantTest("too high", { x: 10000, y: 10000 }, { x: 3000, y: 1500 });
movePlantTest("too low", { x: -10000, y: -10000 }, { x: 0, y: 0 });
});
describe("closePlantInfo()", () => {

View File

@ -36,7 +36,7 @@ const addOrRemoveFromGroup =
const group = fetchGroupFromUrl(resources);
const point =
resources.references[clickedPlantUuid] as TaggedPoint | undefined;
if (group && point && point.body.id) {
if (group && point?.body.id) {
type Body = (typeof group)["body"];
const nextGroup: Body = ({
...group.body,
@ -54,7 +54,7 @@ const addOrRemoveFromSelection =
(clickedPlantUuid: UUID, selectedPlants: UUID[] | undefined) => {
const nextSelected =
(selectedPlants || []).filter(uuid => uuid !== clickedPlantUuid);
if (!(selectedPlants && selectedPlants.includes(clickedPlantUuid))) {
if (!(selectedPlants?.includes(clickedPlantUuid))) {
nextSelected.push(clickedPlantUuid);
}
return selectPlant(nextSelected);

View File

@ -8,22 +8,27 @@ import {
} from "../../../../../__test_support__/map_transform_props";
describe("<BotFigure/>", () => {
function fakeProps(): BotFigureProps {
return {
name: "",
position: { x: 0, y: 0, z: 0 },
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 }
};
}
const fakeProps = (): BotFigureProps => ({
name: "",
position: { x: 0, y: 0, z: 0 },
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 },
});
function checkPositionForQuadrant(
quadrant: BotOriginQuadrant,
xySwap: boolean,
expected: { x: number, y: number },
name: string,
opacity: number) {
it(`shows ${name} in correct location for quadrant ${quadrant}`, () => {
it.each<[
string, BotOriginQuadrant, Record<"x" | "y", number>, boolean, number
]>([
["motors", 1, { x: 3000, y: 0 }, false, 0.75],
["motors", 2, { x: 0, y: 0 }, false, 0.75],
["motors", 3, { x: 0, y: 1500 }, false, 0.75],
["motors", 4, { x: 3000, y: 1500 }, false, 0.75],
["motors", 1, { x: 0, y: 1500 }, true, 0.75],
["motors", 2, { x: 0, y: 0 }, true, 0.75],
["motors", 3, { x: 3000, y: 0 }, true, 0.75],
["motors", 4, { x: 3000, y: 1500 }, true, 0.75],
["encoders", 2, { x: 0, y: 0 }, false, 0.25],
])("shows %s in correct location for quadrant %i",
(name, quadrant, expected, xySwap, opacity) => {
const p = fakeProps();
p.mapTransformProps.quadrant = quadrant;
p.mapTransformProps.xySwap = xySwap;
@ -53,17 +58,6 @@ describe("<BotFigure/>", () => {
const UTMProps = result.find("circle").props();
expect(UTMProps).toEqual(expectedUTMProps);
});
}
checkPositionForQuadrant(1, false, { x: 3000, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(2, false, { x: 0, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(3, false, { x: 0, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(4, false, { x: 3000, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(1, true, { x: 0, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(2, true, { x: 0, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(3, true, { x: 3000, y: 0 }, "motors", 0.75);
checkPositionForQuadrant(4, true, { x: 3000, y: 1500 }, "motors", 0.75);
checkPositionForQuadrant(2, false, { x: 0, y: 0 }, "encoders", 0.25);
it("changes location", () => {
const p = fakeProps();

View File

@ -6,25 +6,25 @@ import {
} from "../../../../../__test_support__/map_transform_props";
describe("<BotPeripherals/>", () => {
function fakeProps(): BotPeripheralsProps {
return {
peripherals: [{ label: "", value: false }],
position: { x: 0, y: 0, z: 0 },
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 },
getConfigValue: jest.fn(),
};
}
const fakeProps = (): BotPeripheralsProps => ({
peripherals: [{ label: "", value: false }],
position: { x: 0, y: 0, z: 0 },
mapTransformProps: fakeMapTransformProps(),
plantAreaOffset: { x: 100, y: 100 },
getConfigValue: jest.fn(),
});
function notDisplayed(name: string) {
it(`doesn't display ${name}`, () => {
const p = fakeProps();
p.peripherals[0].label = name;
p.peripherals[0].value = false;
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find(`#${name}`).length).toEqual(0);
});
}
it.each<[string]>([
["lights"],
["vacuum"],
["water"],
])("doesn't display %s", (peripheralName) => {
const p = fakeProps();
p.peripherals[0].label = peripheralName;
p.peripherals[0].value = false;
const wrapper = shallow(<BotPeripherals {...p} />);
expect(wrapper.find(`#${peripheralName}`).length).toEqual(0);
});
function animationToggle(
props: BotPeripheralsProps, enabled: number, disabled: number) {
@ -101,8 +101,4 @@ describe("<BotPeripherals/>", () => {
});
animationToggle(p, 3, 1);
});
notDisplayed("lights");
notDisplayed("vacuum");
notDisplayed("water");
});

View File

@ -21,7 +21,7 @@ function getNewTrailArray(update: TrailRecord, watering: boolean): TrailRecord[]
const arr: TrailRecord[] = JSON.parse(get(sessionStorage, key, "[]"));
if (arr.length > (trailLength - 1)) { arr.shift(); } // max length reached
const last = arr[arr.length - 1]; // most recent item in array
if (update && update.coord &&
if (update?.coord &&
(!last || !isEqual(last.coord, update.coord))) { // coordinate comparison
arr.push(update); // unique addition
} else { // nothing new to add, increase water circle size if watering

View File

@ -11,31 +11,25 @@ import {
} from "../../../../../__test_support__/fake_time_settings";
const mockConfig = fakeWebAppConfig();
jest.mock("../../../../../resources/selectors", () => {
return {
getWebAppConfig: () => mockConfig,
assertUuid: jest.fn()
};
});
jest.mock("../../../../../resources/selectors", () => ({
getWebAppConfig: () => mockConfig,
assertUuid: jest.fn(),
}));
jest.mock("../../../../../config_storage/actions", () => {
return {
setWebAppConfigValue: jest.fn()
};
});
jest.mock("../../../../../config_storage/actions", () => ({
setWebAppConfigValue: jest.fn(),
}));
describe("<ImageFilterMenu />", () => {
mockConfig.body.photo_filter_begin = "";
mockConfig.body.photo_filter_end = "";
const fakeProps = (): ImageFilterMenuProps => {
return {
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
getConfigValue: jest.fn(x => mockConfig.body[x as StringConfigKey]),
imageAgeInfo: { newestDate: "", toOldest: 1 }
};
};
const fakeProps = (): ImageFilterMenuProps => ({
timeSettings: fakeTimeSettings(),
dispatch: jest.fn(),
getConfigValue: jest.fn(x => mockConfig.body[x as StringConfigKey]),
imageAgeInfo: { newestDate: "", toOldest: 1 },
});
it("renders", () => {
const p = fakeProps();
@ -44,44 +38,38 @@ describe("<ImageFilterMenu />", () => {
expect(wrapper.text()).toContain(string));
});
const testFilterSetDate =
(filter: "beginDate" | "endDate",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "2001-01-03" }
});
expect(wrapper.instance().state[filter]).toEqual("2001-01-03");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T00:00:00.000Z");
});
};
it.each<[
"beginDate" | "endDate", "photo_filter_begin" | "photo_filter_end", number
]>([
["beginDate", "photo_filter_begin", 0],
["endDate", "photo_filter_end", 2],
])("sets filter: %s", (filter, key, i) => {
const p = fakeProps();
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "2001-01-03" }
});
expect(wrapper.instance().state[filter]).toEqual("2001-01-03");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T00:00:00.000Z");
});
testFilterSetDate("beginDate", "photo_filter_begin", 0);
testFilterSetDate("endDate", "photo_filter_end", 2);
const testFilterSetTime =
(filter: "beginTime" | "endTime",
key: "photo_filter_begin" | "photo_filter_end",
i: number) => {
it(`sets filter: ${filter}`, () => {
const p = fakeProps();
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.instance().state[filter]).toEqual("05:00");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T05:00:00.000Z");
});
};
testFilterSetTime("beginTime", "photo_filter_begin", 1);
testFilterSetTime("endTime", "photo_filter_end", 3);
it.each<[
"beginTime" | "endTime", "photo_filter_begin" | "photo_filter_end", number
]>([
["beginTime", "photo_filter_begin", 1],
["endTime", "photo_filter_end", 3],
])("sets filter: %s", (filter, key, i) => {
const p = fakeProps();
const wrapper = shallow<ImageFilterMenu>(<ImageFilterMenu {...p} />);
wrapper.setState({ beginDate: "2001-01-03", endDate: "2001-01-03" });
wrapper.find("BlurableInput").at(i).simulate("commit", {
currentTarget: { value: "05:00" }
});
expect(wrapper.instance().state[filter]).toEqual("05:00");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(key, "2001-01-03T05:00:00.000Z");
});
it("loads values from config", () => {
mockConfig.body.photo_filter_begin = "2001-01-03T05:00:00.000Z";

View File

@ -17,44 +17,39 @@ describe("<ToolbaySlot />", () => {
xySwap: false,
});
const checkSlotDirection =
(direction: number,
quadrant: BotOriginQuadrant,
xySwap: boolean,
expected: string) => {
it(`renders slot, pullout: ${direction} quad: ${quadrant} yx: ${xySwap}`,
() => {
const p = fakeProps();
p.pulloutDirection = direction;
p.quadrant = quadrant;
p.xySwap = xySwap;
const wrapper = svgMount(<ToolbaySlot {...p} />);
expect(wrapper.find("use").props().transform).toEqual(expected);
});
};
checkSlotDirection(0, 2, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 1, false, "rotate(180, 10, 20)");
checkSlotDirection(1, 2, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 3, false, "rotate(0, 10, 20)");
checkSlotDirection(1, 4, false, "rotate(180, 10, 20)");
checkSlotDirection(2, 3, false, "rotate(180, 10, 20)");
checkSlotDirection(3, 1, false, "rotate(90, 10, 20)");
checkSlotDirection(3, 2, false, "rotate(90, 10, 20)");
checkSlotDirection(3, 3, false, "rotate(270, 10, 20)");
checkSlotDirection(3, 4, false, "rotate(270, 10, 20)");
checkSlotDirection(4, 3, false, "rotate(90, 10, 20)");
it.each<[number, BotOriginQuadrant, boolean, string]>([
[0, 2, false, "rotate(0, 10, 20)"],
[1, 1, false, "rotate(180, 10, 20)"],
[1, 2, false, "rotate(0, 10, 20)"],
[1, 3, false, "rotate(0, 10, 20)"],
[1, 4, false, "rotate(180, 10, 20)"],
[2, 3, false, "rotate(180, 10, 20)"],
[3, 1, false, "rotate(90, 10, 20)"],
[3, 2, false, "rotate(90, 10, 20)"],
[3, 3, false, "rotate(270, 10, 20)"],
[3, 4, false, "rotate(270, 10, 20)"],
[4, 3, false, "rotate(90, 10, 20)"],
checkSlotDirection(0, 2, true, "rotate(180, 10, 20)");
checkSlotDirection(1, 1, true, "rotate(90, 10, 20)");
checkSlotDirection(1, 2, true, "rotate(90, 10, 20)");
checkSlotDirection(1, 3, true, "rotate(270, 10, 20)");
checkSlotDirection(1, 4, true, "rotate(270, 10, 20)");
checkSlotDirection(2, 3, true, "rotate(90, 10, 20)");
checkSlotDirection(3, 1, true, "rotate(180, 10, 20)");
checkSlotDirection(3, 2, true, "rotate(0, 10, 20)");
checkSlotDirection(3, 3, true, "rotate(0, 10, 20)");
checkSlotDirection(3, 4, true, "rotate(180, 10, 20)");
checkSlotDirection(4, 3, true, "rotate(180, 10, 20)");
[0, 2, true, "rotate(180, 10, 20)"],
[1, 1, true, "rotate(90, 10, 20)"],
[1, 2, true, "rotate(90, 10, 20)"],
[1, 3, true, "rotate(270, 10, 20)"],
[1, 4, true, "rotate(270, 10, 20)"],
[2, 3, true, "rotate(90, 10, 20)"],
[3, 1, true, "rotate(180, 10, 20)"],
[3, 2, true, "rotate(0, 10, 20)"],
[3, 3, true, "rotate(0, 10, 20)"],
[3, 4, true, "rotate(180, 10, 20)"],
[4, 3, true, "rotate(180, 10, 20)"],
])("renders slot, pullout: %s quad: %s yx: %s",
(direction, quadrant, xySwap, expected) => {
const p = fakeProps();
p.pulloutDirection = direction;
p.quadrant = quadrant;
p.xySwap = xySwap;
const wrapper = svgMount(<ToolbaySlot {...p} />);
expect(wrapper.find("use").props().transform).toEqual(expected);
});
});
describe("<Tool/>", () => {

View File

@ -1,6 +1,6 @@
let mockDev = false;
jest.mock("../../../../../account/dev/dev_support", () => ({
DevSettings: { futureFeaturesEnabled: () => mockDev, }
DevSettings: { futureFeaturesEnabled: () => mockDev }
}));
jest.mock("../../../../../history", () => ({ history: { push: jest.fn() } }));
@ -29,21 +29,20 @@ describe("<ToolSlotPoint/>", () => {
hoveredToolSlot: undefined,
});
const testToolSlotGraphics = (tool: 0 | 1, slot: 0 | 1) => {
it(`renders ${tool ? "" : "no"} tool and ${slot ? "" : "no"} slot`, () => {
if (!tool && !slot) { tool = 1; }
const p = fakeProps();
if (!tool) { p.slot.tool = undefined; }
p.slot.toolSlot.body.pullout_direction = slot;
const wrapper = svgMount(<ToolSlotPoint {...p} />);
expect(wrapper.find("circle").length).toEqual(tool);
expect(wrapper.find("use").length).toEqual(slot);
});
};
testToolSlotGraphics(0, 0);
testToolSlotGraphics(0, 1);
testToolSlotGraphics(1, 0);
testToolSlotGraphics(1, 1);
it.each<[0 | 1, 0 | 1]>([
[0, 0],
[0, 1],
[1, 0],
[1, 1],
])("renders %s tool and %s slot", (tool, slot) => {
if (!tool && !slot) { tool = 1; }
const p = fakeProps();
if (!tool) { p.slot.tool = undefined; }
p.slot.toolSlot.body.pullout_direction = slot;
const wrapper = svgMount(<ToolSlotPoint {...p} />);
expect(wrapper.find("circle").length).toEqual(tool);
expect(wrapper.find("use").length).toEqual(slot);
});
it("opens tool info", () => {
const p = fakeProps();

View File

@ -4,7 +4,8 @@ jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => mockPath.split("/"))
}));
jest.mock("../../../api/crud", () => ({ destroy: jest.fn() }));
let mockDestroy = jest.fn(() => Promise.resolve());
jest.mock("../../../api/crud", () => ({ destroy: mockDestroy }));
let mockDev = false;
jest.mock("../../../account/dev/dev_support", () => ({
@ -41,7 +42,7 @@ describe("<SelectPlants />", () => {
return {
selected: ["plant.1"],
plants: [plant1, plant2],
dispatch: jest.fn(),
dispatch: jest.fn(x => x),
gardenOpen: undefined,
};
}
@ -118,7 +119,7 @@ describe("<SelectPlants />", () => {
it("deletes selected plants", () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.resolve());
mockDestroy = jest.fn(() => Promise.resolve());
p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");
@ -130,7 +131,7 @@ describe("<SelectPlants />", () => {
it("does not delete if selection is empty", () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.resolve());
mockDestroy = jest.fn(() => Promise.resolve());
p.selected = undefined;
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");
@ -140,7 +141,7 @@ describe("<SelectPlants />", () => {
it("errors when deleting selected plants", () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.reject());
mockDestroy = jest.fn(() => Promise.reject());
p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");

View File

@ -7,7 +7,7 @@ import { TaggedPlant } from "../map/interfaces";
import { DesignerPanel, DesignerPanelHeader } from "../designer_panel";
import { t } from "../../i18next_wrapper";
import { EditPlantInfoProps, PlantOptions } from "../interfaces";
import { isString, isUndefined } from "lodash";
import { isString } from "lodash";
import { history, getPathArray } from "../../history";
import { destroy, edit, save } from "../../api/crud";
import { BooleanSetting } from "../../session_keys";
@ -20,7 +20,7 @@ export class RawPlantInfo extends React.Component<EditPlantInfoProps, {}> {
get confirmDelete() {
const confirmSetting = this.props.getConfigValue(
BooleanSetting.confirm_plant_deletion);
return isUndefined(confirmSetting) ? true : confirmSetting;
return confirmSetting ?? true;
}
destroy = (plantUUID: string) => {

View File

@ -51,7 +51,7 @@ describe("", () => {
const sort = (sortType: PointGroupSortType): string[] => {
const array = SORT_OPTIONS[sortType](plants as TaggedPlant[]);
return array.map(x => x && x.body && (x.body.name || "NA"));
return array.map(x => x?.body?.name || "NA");
};
it("sorts randomly", () => {

View File

@ -66,7 +66,7 @@ export const mapStateToProps = (props: Everything): EditGardenProps => {
const savedGarden = findSavedGardenByUrl(props.resources.index);
return {
savedGarden,
gardenIsOpen: !!(savedGarden && savedGarden.uuid === openedSavedGarden),
gardenIsOpen: !!(savedGarden?.uuid === openedSavedGarden),
dispatch: props.dispatch,
plantPointerCount: selectAllPlantPointers(props.resources.index).length,
};

View File

@ -71,7 +71,7 @@ const Setting = (props: SettingProps) => {
toggleValue={props.invert ? !value : value}
toggleAction={() => {
props.dispatch(setWebAppConfigValue(setting, !value));
callback && callback();
callback?.();
}}
title={`${t("toggle")} ${title}`}
customText={{ textFalse: t("off"), textTrue: t("on") }} />}

View File

@ -100,7 +100,7 @@ export class RawAddToolSlot
: "initializing"}
<SaveBtn onClick={this.save} status={SpecialStatus.DIRTY} />
</DesignerPanelContent>
</DesignerPanel >;
</DesignerPanel>;
}
}

View File

@ -67,24 +67,21 @@ describe("<FarmwarePage />", () => {
expect(wrapper.text()).toContain("Take Photo");
});
const TEST_DATA = {
"Photos": ["Take Photo"],
"take-photo": ["Take Photo"],
"Weed Detector": ["detect weeds", "CLEAR WEEDS", "Color Range"],
"plant-detection": ["detect weeds", "CLEAR WEEDS", "Color Range"],
"Camera Calibration": ["Calibrate", "Color Range", "Invert Hue Range Selection"],
"camera-calibration": ["Calibrate", "Color Range", "Invert Hue Range Selection"],
};
it.each<[string, string[]]>([
["Photos", ["Take Photo"]],
["take-photo", ["Take Photo"]],
["Weed Detector", ["detect weeds", "CLEAR WEEDS", "Color Range"]],
["plant-detection", ["detect weeds", "CLEAR WEEDS", "Color Range"]],
["Camera Calibration", ["Calibrate", "Color Range", "Invert Hue Range Selection"]],
["camera-calibration", ["Calibrate", "Color Range", "Invert Hue Range Selection"]],
])("renders %s Farmware page", (farmware, expectedText) => {
const p = fakeProps();
p.currentFarmware = farmware;
const wrapper = mount(<FarmwarePage {...p} />);
expectedText.map(string =>
expect(wrapper.text()).toContain(string));
});
Object.entries(TEST_DATA).map(([farmware, expectedText]) =>
it(`renders ${farmware} Farmware page`, () => {
const p = fakeProps();
p.currentFarmware = farmware;
const wrapper = mount(<FarmwarePage {...p} />);
expectedText.map(string =>
expect(wrapper.text()).toContain(string));
})
);
it("renders installed Farmware page", () => {
const p = fakeProps();
const farmware = fakeFarmware();

View File

@ -24,7 +24,7 @@ export function FarmwareConfigMenu(props: FarmwareConfigMenuProps) {
className="fb-button gray fa fa-download"
onClick={() => {
const p = getDevice().installFirstPartyFarmware();
p && p.catch(commandErr("Farmware installation"));
p?.catch(commandErr("Farmware installation"));
}}
disabled={props.firstPartyFwsInstalled} />
</fieldset>

View File

@ -108,6 +108,6 @@ export function FarmwareForm(props: FarmwareFormProps): JSX.Element {
/** Determine if a Farmware has requested inputs. */
export function needsFarmwareForm(farmware: FarmwareManifestInfo): Boolean {
const needsWidget = farmware.config && farmware.config.length > 0;
const needsWidget = farmware.config?.length > 0;
return needsWidget;
}

View File

@ -57,7 +57,7 @@ const PendingInstallNameError =
}): JSX.Element => {
const installation: TaggedFarmwareInstallation | undefined =
installations.filter(x => x.body.url === url)[0];
const packageError = installation && installation.body.package_error;
const packageError = installation?.body.package_error;
return (url && installation && packageError)
? <div className="error-with-button">
<label>{t("Could not fetch package name")}</label>

View File

@ -116,7 +116,7 @@ export class Photos extends React.Component<PhotosProps, {}> {
deletePhoto = () => {
const img = this.props.currentImage || this.props.images[0];
if (img && img.uuid) {
if (img?.uuid) {
this.props.dispatch(destroy(img.uuid))
.then(() => success(t("Image Deleted.")))
.catch(() => error(t("Could not delete image.")));

View File

@ -61,7 +61,7 @@ export class ImageWorkspace extends React.Component<ImageWorkspaceProps, {}> {
maybeProcessPhoto = () => {
const img = this.props.currentImage || this.props.images[0];
if (img && img.body.id) {
if (img?.body.id) {
this.props.onProcessPhoto(img.body.id);
}
};

View File

@ -6,7 +6,7 @@ jest.mock("../../draggable/actions", () => ({
stepGet: jest.fn(() => () => mockStepGetResult),
}));
import { FolderNode } from "../constants";
import { FolderNode } from "../interfaces";
import { ingest } from "../data_transfer";
import {
collapseAll,
@ -240,11 +240,11 @@ describe("deleteFolder", () => {
describe("updateSearchTerm", () => {
it("updates a search term", () => {
const argss =
const args =
(payload: string | undefined) => ({ type: "FOLDER_SEARCH", payload });
[undefined, "foo"].map(term => {
updateSearchTerm(term);
expect(store.dispatch).toHaveBeenCalledWith(argss(term));
expect(store.dispatch).toHaveBeenCalledWith(args(term));
});
});
});

View File

@ -18,6 +18,21 @@ jest.mock("../../history", () => ({
history: { getCurrentLocation: () => ({ pathname: mockPath }) }
}));
jest.mock("@blueprintjs/core", () => ({
Popover: jest.fn(p => <div>{p.children}</div>),
Position: jest.fn(),
PopoverInteractionKind: jest.fn(),
Button: jest.fn(p => <button>{p.text}</button>),
Classes: jest.fn(),
MenuItem: jest.fn(),
Alignment: jest.fn(),
}));
jest.mock("@blueprintjs/select", () => ({
Select: { ofType: jest.fn() },
ItemRenderer: jest.fn(),
}));
import * as React from "react";
import { mount, shallow } from "enzyme";
import {
@ -30,7 +45,7 @@ import {
FolderNameInputProps,
FolderNodeMedial,
FolderNodeTerminal,
} from "../constants";
} from "../interfaces";
import {
updateSearchTerm, toggleAll, moveSequence, dropSequence,
sequenceEditMaybeSave,

View File

@ -1,4 +1,4 @@
import { FolderNode } from "../constants";
import { FolderNode } from "../interfaces";
import { ingest } from "../data_transfer";
import { climb } from "../climb";

View File

@ -1,7 +1,7 @@
import { TEST_GRAPH } from "./actions_test";
import { searchFolderTree, FolderSearchProps } from "../search_folder_tree";
import { TaggedResource } from "farmbot";
import { FolderUnion } from "../constants";
import { FolderUnion } from "../interfaces";
describe("searchFolderTree", () => {
const searchFor = (input: string) => searchFolderTree({
@ -19,7 +19,7 @@ describe("searchFolderTree", () => {
expect(before).toEqual(after); // Prevent mutation of original data.
});
it("finds an `inital` folder", () => {
it("finds an `initial` folder", () => {
const results = searchFor("one").map(x => x.name);
expect(results.length).toEqual(1);
expect(results).toContain("One");

View File

@ -1,4 +1,4 @@
import { RootFolderNode as Tree } from "./constants";
import { RootFolderNode as Tree } from "./interfaces";
import { cloneAndClimb } from "./climb";
import { Color, SpecialStatus, TaggedSequence } from "farmbot";
import { store } from "../redux/store";

View File

@ -1,4 +1,6 @@
import { RootFolderNode, FolderUnion, FolderNodeMedial, FolderNodeInitial } from "./constants";
import {
RootFolderNode, FolderUnion, FolderNodeMedial, FolderNodeInitial
} from "./interfaces";
import { defensiveClone } from "../util";
interface TreeClimberState {

View File

@ -20,7 +20,7 @@ import {
FolderButtonClusterProps,
FolderNameInputProps,
SequenceDropAreaState,
} from "./constants";
} from "./interfaces";
import {
createFolder,
deleteFolder,
@ -80,7 +80,7 @@ export const FolderListItem = (props: FolderItemProps) => {
onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
</div>
</li>
</StepDragger >;
</StepDragger>;
};
const ToggleFolderBtn = (props: ToggleFolderBtnProps) => {

View File

@ -4,7 +4,7 @@ import {
FolderNodeTerminal,
RootFolderNode,
FolderMeta,
} from "./constants";
} from "./interfaces";
import { sortBy } from "lodash";
type FoldersIndexedByParentId = Record<number, FolderNode[]>;

View File

@ -1,4 +1,4 @@
import { FolderProps } from "./constants";
import { FolderProps } from "./interfaces";
import { selectAllSequences } from "../resources/selectors";
import { TaggedSequence } from "farmbot";
import { resourceUsageList } from "../resources/in_use";

View File

@ -1,6 +1,5 @@
import { TaggedResource, TaggedSequence } from "farmbot";
import { RootFolderNode, FolderUnion } from "./constants";
import { RootFolderNode, FolderUnion } from "./interfaces";
export interface FolderSearchProps {
references: Record<string, TaggedResource | undefined>;

View File

@ -18,7 +18,7 @@ export const clickHandler =
/** BEGIN LEGACY SHIMS */
const { onClick, to } = props;
navigate(maybeStripLegacyUrl(to));
onClick && onClick(e);
onClick?.(e);
};
export class Link extends React.Component<LinkProps, {}> {

View File

@ -180,7 +180,7 @@ const FirmwareChoiceTable = () =>
export const changeFirmwareHardware = (dispatch: Function | undefined) =>
(ddi: DropDownItem) => {
if (isFwHardwareValue(ddi.value)) {
dispatch && dispatch(updateConfig({ firmware_hardware: ddi.value }));
dispatch?.(updateConfig({ firmware_hardware: ddi.value }));
}
};

View File

@ -11,7 +11,7 @@ export const computeEditorUrlFromState =
: resources.consumers.regimens.currentRegimen;
const r = resources.index.references[current || ""];
const base = `/app/${resource === "Sequence" ? "sequences" : "regimens"}/`;
if (r && r.kind == resource) {
if (r?.kind == resource) {
return base + urlFriendly(r.body.name);
} else {
return base;

View File

@ -59,7 +59,7 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
BooleanSetting.disable_emergency_unlock_confirmation)} />
AccountMenu = () => {
const hasName = this.props.user && this.props.user.body.name;
const hasName = this.props.user?.body.name;
const firstName = hasName ?
`${hasName.split(" ")[0].slice(0, 9)}` : `${t("Menu")}`;
return <div className="menu-popover">

View File

@ -43,10 +43,7 @@ const promiseCache: Dictionary<Promise<Readonly<OFCropAttrs>>> = {};
const cacheTheIcon = (slug: string) =>
(resp: AxiosResponse<OFCropResponse>): OFIcon => {
if (resp
&& resp.data
&& resp.data.data
&& resp.data.data.attributes) {
if (resp?.data?.data?.attributes) {
const icon = {
slug: resp.data.data.attributes.slug,
spread: resp.data.data.attributes.spread,

View File

@ -35,7 +35,7 @@ export function getMiddleware(env: EnvName) {
.map((mwc) => mwc.fn);
// tslint:disable-next-line:no-any
const wow = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__;
const dtCompose = wow && wow({
const dtCompose = wow?.({
actionsBlacklist: [
Actions.NETWORK_EDGE_CHANGE,
Actions.PING_NO,

View File

@ -12,10 +12,8 @@ const WEB_APP_CONFIG: ResourceName = "WebAppConfig";
* resources, downloading the filtered log list as required from the API. */
// tslint:disable-next-line:no-any
export const fn: Middleware = () => (dispatch) => (action: any) => {
const needsRefresh = action
&& action.payload
&& action.type === Actions.SAVE_RESOURCE_OK
&& action.payload.kind === WEB_APP_CONFIG;
const needsRefresh = action?.payload?.kind === WEB_APP_CONFIG
&& action.type === Actions.SAVE_RESOURCE_OK;
needsRefresh && throttledLogRefresh(dispatch);

View File

@ -23,16 +23,11 @@ const WEB_APP_CONFIG: ResourceName = "WebAppConfig";
// tslint:disable-next-line:no-any
const fn: Middleware = () => (dispatch) => (action: any) => {
const x: DeepPartial<SyncResponse<TaggedWebAppConfig>> = action;
if (x
&& x.type === Actions.RESOURCE_READY
&& x.payload
&& x.payload.body
if (x?.type === Actions.RESOURCE_READY
&& x.payload?.body
&& x.payload.kind === WEB_APP_CONFIG) {
const conf = arrayUnwrap(x.payload.body);
conf
&& conf.body
&& conf.body.disable_i18n
&& revertToEnglish();
conf?.body?.disable_i18n && revertToEnglish();
}
return dispatch(action);

View File

@ -12,10 +12,9 @@ export function dontStopThem() { }
const shouldStop =
(allResources: TaggedResource[], config: TaggedWebAppConfig | undefined) => {
const loggedIn = !!localStorage.getItem("session");
const discardUnsaved = config && config.body.discard_unsaved;
const discardUnsaved = config?.body.discard_unsaved;
const sequenceResources = allResources.filter(r => r.kind === "Sequence");
const discardUnsavedSequences =
config && config.body.discard_unsaved_sequences;
const discardUnsavedSequences = config?.body.discard_unsaved_sequences;
/**
* For the unsaved notification to show, a user must:
@ -59,6 +58,6 @@ export function registerSubscribers(store: Store) {
subscriptions.forEach(function (s) {
ENV_LIST.includes &&
ENV_LIST.includes(s.env) &&
store.subscribe(() => s.fn && s.fn(store.getState()));
store.subscribe(() => s.fn?.(store.getState()));
});
}

View File

@ -23,7 +23,7 @@ const fn: MW =
// tslint:disable-next-line:no-any
(action: any) => {
const fbos = getVersionFromState(store.getState());
window.Rollbar && window.Rollbar.configure({ payload: { fbos } });
window.Rollbar?.configure({ payload: { fbos } });
return dispatch(action);
};

View File

@ -17,7 +17,7 @@ const BAD_UUID = "WARNING: Not a sequence UUID.";
export class BulkScheduler extends React.Component<BulkEditorProps, {}> {
selected = (): DropDownItem => {
const s = this.props.selectedSequence;
return (s && s.body.id)
return (s?.body.id)
? { label: s.body.name, value: s.uuid }
: NULL_CHOICE;
};
@ -66,7 +66,7 @@ export class BulkScheduler extends React.Component<BulkEditorProps, {}> {
render() {
const { dispatch, weeks, sequences } = this.props;
const active = !!(sequences && sequences.length);
const active = !!(sequences?.length);
return <div className="bulk-scheduler-content">
<AddButton
active={active}

View File

@ -66,7 +66,7 @@ describe("<RegimenEditor />", () => {
p.dispatch = jest.fn(() => Promise.resolve());
const wrapper = mount(<RegimenEditor {...p} />);
clickButton(wrapper, 2, "delete");
const expectedUuid = p.current && p.current.uuid;
const expectedUuid = p.current?.uuid;
expect(destroy).toHaveBeenCalledWith(expectedUuid);
});
@ -74,7 +74,7 @@ describe("<RegimenEditor />", () => {
const p = fakeProps();
const wrapper = mount(<RegimenEditor {...p} />);
clickButton(wrapper, 0, "save", { partial_match: true });
const expectedUuid = p.current && p.current.uuid;
const expectedUuid = p.current?.uuid;
expect(save).toHaveBeenCalledWith(expectedUuid);
});
});

View File

@ -177,7 +177,7 @@ describe("maybeGetSequence", () => {
const i = buildResourceIndex([s]);
const result = Selector.maybeGetSequence(i.index, s.uuid);
expect(result).toBeTruthy();
result && expect(result.uuid).toBe(s.uuid);
expect(result?.uuid).toBe(s.uuid);
});
});

View File

@ -14,7 +14,7 @@ import { HelpState } from "../help/reducer";
import { UsageIndex } from "./in_use";
import { SequenceMeta } from "./sequence_meta";
import { AlertReducerState } from "../messages/interfaces";
import { RootFolderNode, FolderMeta } from "../folders/constants";
import { RootFolderNode, FolderMeta } from "../folders/interfaces";
export type UUID = string;
export type VariableNameSet = Record<string, SequenceMeta | undefined>;

View File

@ -33,7 +33,9 @@ import { get } from "lodash";
import { Actions } from "../constants";
import { getFbosConfig } from "./getters";
import { ingest, PARENTLESS as NO_PARENT } from "../folders/data_transfer";
import { FolderNode, FolderMeta, FolderNodeTerminal, FolderNodeMedial } from "../folders/constants";
import {
FolderNode, FolderMeta, FolderNodeTerminal, FolderNodeMedial
} from "../folders/interfaces";
import { climb } from "../folders/climb";
export function findByUuid(index: ResourceIndex, uuid: string): TaggedResource {
@ -63,10 +65,12 @@ export const reindexFolders = (i: ResourceIndex) => {
const allSequences = selectAllSequences(i);
const oldMeta = i.sequenceFolders.localMetaAttributes;
/** Open folder edit mode when adding a new folder (& not during init all). */
const editing = !!oldMeta[-1];
const localMetaAttributes: Record<number, FolderMeta> = {};
folders.map(x => {
localMetaAttributes[x.id] = {
...(oldMeta[x.id] || {}),
...(oldMeta[x.id] || { editing }),
sequences: [], // Clobber and re-init
};
});
@ -188,7 +192,7 @@ const reindexAllSequences = (i: ResourceIndex) => {
const mapper = reindexSequences(i);
betterCompact(Object.keys(i.byKind["Sequence"]).map(uuid => {
const resource = i.references[uuid];
return (resource && resource.kind == "Sequence") ? resource : undefined;
return (resource?.kind == "Sequence") ? resource : undefined;
})).map(mapper);
};
@ -283,7 +287,7 @@ const AFTER_HOOKS: IndexerHook = {
FbosConfig: (i) => {
const conf = getFbosConfig(i);
if (conf && conf.body.boot_sequence_id) {
if (conf?.body.boot_sequence_id) {
const { boot_sequence_id } = conf.body;
const tracker = i.inUse["Sequence.FbosConfig"];
const uuid = i.byKindAndId[joinKindAndId("Sequence", boot_sequence_id)];
@ -345,7 +349,7 @@ export const indexUpsert: IndexUpsert = (db, resources, strategy) => {
const { kind } = arrayUnwrap(resources);
// Clean up indexes (if needed)
const before = BEFORE_HOOKS[kind];
before && before(db, strategy);
before?.(db, strategy);
// Run indexers
ups.map(callback => {
@ -354,14 +358,14 @@ export const indexUpsert: IndexUpsert = (db, resources, strategy) => {
// Finalize indexing (if needed)
const after = AFTER_HOOKS[kind];
after && after(db, strategy);
after?.(db, strategy);
};
export function indexRemove(db: ResourceIndex, resource: TaggedResource) {
downs.map(callback => arrayWrap(resource).map(r => callback(r, db)));
// Finalize indexing (if needed)
const after = AFTER_HOOKS[resource.kind];
after && after(db, "ongoing");
after?.(db, "ongoing");
}
export const beforeEach = (state: RestResources,

View File

@ -77,7 +77,7 @@ export function findPointerByTypeAndId(index: ResourceIndex,
const uuid = "" + index.byKindAndId[pni];
const resource = index.references[uuid];
if (resource && resource.kind === "Point") {
if (resource?.kind === "Point") {
return resource;
} else {
// We might have a sequence dependency leak if this exception is ever
@ -204,7 +204,7 @@ export function maybeGetTimeSettings(index: ResourceIndex): TimeSettings {
export function maybeGetDevice(index: ResourceIndex): TaggedDevice | undefined {
const dev = index.references[Object.keys(index.byKind.Device)[0] || "nope"];
return (dev && dev.kind === "Device") ?
return (dev?.kind === "Device") ?
dev : undefined;
}
@ -227,7 +227,7 @@ export function maybeFetchUser(index: ResourceIndex):
if (user && sanityCheck(user) && list.length > 1) {
throw new Error("PROBLEM: Expected 1 user. Got: " + list.length);
}
if ((list.length === 1) && user && user.kind === "User") {
if ((list.length === 1) && user?.kind === "User") {
return user;
} else {
return undefined;

View File

@ -50,7 +50,7 @@ export const determineVector =
return ts ? ts.body : undefined;
case "identifier":
const variable = maybeFindVariable(node.args.label, resources, uuid);
return variable && variable.vector;
return variable?.vector;
}
return undefined;
};

View File

@ -127,7 +127,7 @@ describe("<SequenceEditorMiddleActive/>", () => {
p.dispatch = dispatch;
const wrapper = mount(<SequenceEditorMiddleActive {...p} />);
const props = wrapper.find("DropArea").props() as DropAreaProps;
props.callback && props.callback("key");
props.callback?.("key");
dispatch.mock.calls[0][0](() =>
({ value: 1, intent: "step_splice", draggerId: 2 }));
expect(splice).toHaveBeenCalledWith(expect.objectContaining({

View File

@ -148,7 +148,7 @@ const SequenceBtnGroup = ({
onClick={() => {
const confirm = getWebAppConfigValue(
BooleanSetting.confirm_sequence_deletion);
const force = isUndefined(confirm) ? false : !confirm;
const force = !(confirm ?? true);
dispatch(destroy(sequence.uuid, force))
.then(() => push("/app/sequences/"));
}}>

View File

@ -19,7 +19,7 @@ import {
export function mapStateToProps(props: Everything): Props {
const uuid = props.resources.consumers.sequences.current;
const sequence = uuid ? findSequence(props.resources.index, uuid) : undefined;
sequence && (sequence.body.body || []).map(x => getStepTag(x));
(sequence?.body.body || []).map(x => getStepTag(x));
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const { mcu_params } = props.bot.hardware;

View File

@ -52,7 +52,7 @@ export function StepButtonCluster(props: StepButtonProps) {
step={{
kind: "toggle_pin",
args: {
pin_number: 4
pin_number: NOTHING_SELECTED
}
}}
color="orange">

View File

@ -97,61 +97,6 @@ describe("renderCeleryNode()", () => {
}
const TEST_DATA: TestData[] = [
{
expected: "MarkPlantasx = 300",
node: {
kind: "resource_update",
args: {
resource_id: 23,
resource_type: "Plant",
label: "x",
value: 300
}
}
},
{
expected: "System",
node: {
kind: "check_updates",
args: {
package: "farmbot_os"
}
}
},
{
expected: "System",
node: {
kind: "factory_reset",
args: {
package: "farmbot_os"
}
}
},
{
expected: "Unlocking a device requires user intervention",
node: {
kind: "emergency_lock",
args: {}
}
},
{
expected: "Unable to properly display this step",
node: {
kind: "power_off",
args: {}
}
},
{
expected: "Unable to properly display this step",
node: { kind: "read_status", args: {} }
},
{
expected: "",
node: {
kind: "install_first_party_farmware",
args: {}
}
},
{
node: {
kind: "_if",
@ -166,31 +111,13 @@ describe("renderCeleryNode()", () => {
expected: "Then Execute"
},
{
node: {
kind: "execute_script",
args: { label: "farmware-to-execute" }
},
node: { kind: "execute_script", args: { label: "farmware-to-execute" } },
expected: "Manual Input"
},
{
node: {
kind: "execute",
args: {
sequence_id: 0
}
},
node: { kind: "execute", args: { sequence_id: 0 } },
expected: "Select a sequence"
},
{
node: {
kind: "find_home",
args: {
speed: 100,
axis: "all"
}
},
expected: "Find x"
},
{
node: {
kind: "move_absolute",
@ -213,40 +140,37 @@ describe("renderCeleryNode()", () => {
expected: "Message"
},
{
node: {
kind: "take_photo",
args: {}
},
node: { kind: "take_photo", args: {} },
expected: "Photo"
},
{
node: {
kind: "wait",
args: {
milliseconds: 100
}
},
node: { kind: "wait", args: { milliseconds: 100 } },
expected: "milliseconds"
},
{
node: {
kind: "set_servo_angle",
kind: "resource_update",
args: {
pin_number: 4,
pin_value: 90,
resource_id: 23,
resource_type: "Plant",
label: "x",
value: 300
}
},
expected: "MarkPlantasx = 300"
},
{
node: { kind: "set_servo_angle", args: { pin_number: 4, pin_value: 90, } },
expected: "Servo"
},
{
node: {
kind: "toggle_pin",
args: {
pin_number: 13
}
},
node: { kind: "toggle_pin", args: { pin_number: 13 } },
expected: "Pin"
},
{
node: { kind: "find_home", args: { speed: 100, axis: "all" } },
expected: "Find x"
},
{
node: { kind: "zero", args: { axis: "all" } },
expected: "Zero x"
@ -259,52 +183,36 @@ describe("renderCeleryNode()", () => {
node: { kind: "home", args: { axis: "all", speed: 100, } },
expected: "Home x"
},
{
node: { kind: "reboot", args: { package: "farmbot_os" } },
expected: "System"
},
{
node: {
kind: "check_updates", args: { package: "farmbot_os" }
},
expected: "System"
},
{
node: {
kind: "factory_reset", args: { package: "farmbot_os" }
},
expected: "System"
},
{
node: {
kind: "sync",
args: {}
},
expected: ""
},
{
node: {
kind: "dump_info",
args: {}
},
expected: ""
},
{
node: {
kind: "power_off",
args: {}
},
expected: ""
},
{
node: {
kind: "read_status",
args: {}
},
expected: ""
},
{
node: { kind: "emergency_lock", args: {} },
expected: "Unlocking a device requires user intervention"
},
{
node: { kind: "reboot", args: { package: "farmbot_os" } },
expected: "entire system"
},
{
node: { kind: "check_updates", args: { package: "farmbot_os" } },
expected: "System"
},
{
node: { kind: "factory_reset", args: { package: "farmbot_os" } },
expected: "System"
},
{
node: { kind: "sync", args: {} },
expected: ""
},
{
node: { kind: "dump_info", args: {} },
expected: ""
},
{
node: { kind: "power_off", args: {} },
expected: ""
},
{
node: { kind: "read_status", args: {} },
expected: ""
},
{
@ -312,19 +220,12 @@ describe("renderCeleryNode()", () => {
expected: ""
},
{
node: {
kind: "install_first_party_farmware", args: {}
},
node: { kind: "install_first_party_farmware", args: {} },
expected: ""
},
{
node: {
kind: "unknown",
args: {
unknown: 0
}
// tslint:disable-next-line:no-any
} as any,
// tslint:disable-next-line: no-any
node: { kind: "unknown", args: { unknown: 0 } } as any,
expected: "unknown"
},
];

View File

@ -205,21 +205,20 @@ describe("Pin and Peripheral support files", () => {
expect(result).toEqual(expected);
});
Object.values(BoxLed).map(boxLed => {
it(`converts ${boxLed} named pin to DropDownItem`, () => {
const ri = buildResourceIndex([]).index;
const namedPin: NamedPin = {
kind: "named_pin",
args: { pin_type: boxLed, pin_id: -1 }
};
const result = namedPin2DropDown(ri, namedPin);
const expected: DropDownItem = {
label: expect.stringContaining("LED"),
value: boxLed,
headingId: PinGroupName.BoxLed
};
expect(result).toEqual(expected);
});
it.each<[BoxLed]>(Object.values(BoxLed).map(x => [x])
)("converts %s named pin to DropDownItem", (boxLed) => {
const ri = buildResourceIndex([]).index;
const namedPin: NamedPin = {
kind: "named_pin",
args: { pin_type: boxLed, pin_id: -1 }
};
const result = namedPin2DropDown(ri, namedPin);
const expected: DropDownItem = {
label: expect.stringContaining("LED"),
value: boxLed,
headingId: PinGroupName.BoxLed
};
expect(result).toEqual(expected);
});
it("converts nothing to DropDownItems", () => {

View File

@ -83,7 +83,7 @@ describe("<TileExecuteScript/>", () => {
it("shows special 1st-party Farmware name", () => {
const p = fakeProps();
(p.currentStep as ExecuteScript).args.label = "plant-detection";
p.farmwareData && p.farmwareData.farmwareNames.push("plant-detection");
p.farmwareData?.farmwareNames.push("plant-detection");
const wrapper = mount(<TileExecuteScript {...p} />);
expect(wrapper.find("label").length).toEqual(1);
expect(wrapper.text()).toContain("Weed Detector");

View File

@ -1,13 +1,12 @@
jest.mock("../../api/crud", () => {
return { editStep: jest.fn() };
});
import { TileReboot, editTheRebootStep, rebootExecutor, MultiChoiceRadio } from "../step_tiles/tile_reboot";
import { render, mount } from "enzyme";
jest.mock("../../../api/crud", () => ({ editStep: jest.fn() }));
import { TileReboot, editTheRebootStep, rebootExecutor } from "../tile_reboot";
import { render } from "enzyme";
import React from "react";
import { StepParams } from "../interfaces";
import { fakeSequence } from "../../__test_support__/fake_state/resources";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { editStep } from "../../api/crud";
import { StepParams } from "../../interfaces";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { buildResourceIndex } from "../../../__test_support__/resource_index_builder";
import { editStep } from "../../../api/crud";
import { Reboot } from "farmbot";
const fakeProps = (): StepParams => {
@ -66,21 +65,3 @@ describe("<TileReboot/>", () => {
expect(step.args.package).toBe("arduino_firmware");
});
});
describe("MultiChoiceRadio", () => {
it("triggers callbacks", () => {
const PACKAGE_CHOICES = {
"a": "1",
"b": "2"
};
const onChange = jest.fn();
const el = mount(<MultiChoiceRadio
uuid={"WHATEVER"}
choices={PACKAGE_CHOICES}
currentChoice={"a"}
onChange={onChange} />);
const radio = el.find("input[type='radio']").first();
radio.simulate("change", "a");
expect(onChange).toHaveBeenCalledWith("a");
});
});

View File

@ -1,6 +1,13 @@
let mockStep = {} as SendMessage;
jest.mock("../../../api/crud", () => ({
editStep: jest.fn(x => x.executor(mockStep)),
}));
import * as React from "react";
import { TileSendMessage, RefactoredSendMessage } from "../tile_send_message";
import { mount } from "enzyme";
import {
TileSendMessage, RefactoredSendMessage, SendMessageParams
} from "../tile_send_message";
import { mount, shallow } from "enzyme";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { SendMessage, Channel } from "farmbot/dist";
import { channel } from "../tile_send_message_support";
@ -8,7 +15,7 @@ import { emptyState } from "../../../resources/reducer";
import { MessageType } from "../../interfaces";
describe("<TileSendMessage/>", () => {
function props() {
const fakeProps = (): SendMessageParams => {
const currentStep: SendMessage = {
kind: "send_message",
args: {
@ -31,16 +38,17 @@ describe("<TileSendMessage/>", () => {
resources: emptyState().index,
confirmStepDeletion: false,
};
}
};
function bootstrapTest() {
return {
component: mount(<TileSendMessage {...props()} />)
};
}
it("throws error upon wrong step type", () => {
const p = fakeProps();
p.currentStep.kind = "nope" as SendMessage["kind"];
expect(() => shallow(<TileSendMessage {...p} />))
.toThrowError("TileSendMessage expects send_message");
});
it("renders inputs", () => {
const block = bootstrapTest().component;
const block = mount(<TileSendMessage {...fakeProps()} />);
const inputs = block.find("input");
const labels = block.find("label");
const buttons = block.find("button");
@ -72,14 +80,37 @@ describe("<TileSendMessage/>", () => {
});
it("adds and removes channels", () => {
const i = new RefactoredSendMessage(props());
const i = new RefactoredSendMessage(fakeProps());
const addEmail = i.add("email");
const removeEmail = i.remove("email");
const { currentStep } = i.props;
currentStep.body = [];
addEmail(currentStep);
expect(currentStep.body).toContainEqual(channel("email"));
removeEmail(currentStep);
expect(currentStep.body).not.toContainEqual(channel("email"));
});
it("adds and removes channels via toggle", () => {
const i = new RefactoredSendMessage(fakeProps());
delete i.props.currentStep.body;
mockStep = i.props.currentStep;
i.toggle("email")();
expect(mockStep.body).toContainEqual(channel("email"));
i.toggle("email")();
expect(mockStep.body).not.toContainEqual(channel("email"));
});
it("sets message type", () => {
const i = new RefactoredSendMessage(fakeProps());
mockStep = i.props.currentStep;
i.setMsgType({ label: "", value: "fun" });
expect(mockStep.args.message_type).toEqual("fun");
});
it("doesn't set incorrect message type", () => {
const i = new RefactoredSendMessage(fakeProps());
mockStep = i.props.currentStep;
expect(() => i.setMsgType({ label: "", value: "nope" }))
.toThrowError("message_type must be one of ALLOWED_MESSAGE_TYPES.");
});
});

View File

@ -1,7 +1,9 @@
jest.mock("../../../api/crud", () => ({ editStep: jest.fn() }));
import * as React from "react";
import { TileSetServoAngle, pinNumberChanger, createServoEditFn, ServoPinSelection } from "../tile_set_servo_angle";
import {
TileSetServoAngle, pinNumberChanger, createServoEditFn, ServoPinSelection
} from "../tile_set_servo_angle";
import { mount } from "enzyme";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { SetServoAngle } from "farmbot";
@ -33,8 +35,8 @@ describe("<TileSetServoAngle/>", () => {
const inputs = block.find("input");
const labels = block.find("label");
const stepArgs = props.currentStep.args as SetServoAngle["args"];
expect(inputs.length).toEqual(4);
expect(labels.length).toEqual(4);
expect(inputs.length).toEqual(6);
expect(labels.length).toEqual(6);
expect(inputs.first().props().placeholder).toEqual("Control Servo");
expect(labels.at(0).text()).toContain("Servo angle (0-180)");
expect(inputs.at(1).props().value).toEqual(stepArgs.pin_value);
@ -53,7 +55,7 @@ describe("<TileSetServoAngle/>", () => {
});
});
it("dissallows named_pins", () => {
it("disallows named_pins", () => {
const p = fakeProps();
const step = p.currentStep;
if (step.kind === "set_servo_angle") {

View File

@ -157,10 +157,11 @@ export function renderCeleryNode(props: StepParams) {
case "home": return <TileMoveHome {...props} />;
case "reboot": return <TileReboot {...props} />;
case "emergency_lock": return <TileEmergencyStop {...props} />;
case "install_first_party_farmware": return <TileSystemAction {...props} />;
case "assertion": return <TileAssertion {...props} />;
case "check_updates":
case "factory_reset":
case "sync": case "dump_info": case "power_off": case "read_status":
case "emergency_unlock": case "install_first_party_farmware":
return <TileSystemAction {...props} />;
case "check_updates": case "factory_reset":
return <TileFirmwareAction {...props} />;
default:
return <TileUnknown {...props} />;

View File

@ -4,7 +4,7 @@ import { ToolTips, Content } from "../../constants";
import {
StepWrapper, StepHeader, StepContent, conflictsString, StepWarning
} from "../step_ui/index";
import { StepRadio } from "../step_ui/step_radio";
import { AxisStepRadio } from "../step_ui/step_radio";
import { t } from "../../i18next_wrapper";
import { Xyz, Calibrate, TaggedSequence } from "farmbot";
import { some } from "lodash";
@ -81,7 +81,7 @@ class InnerTileCalibrate extends React.Component<CalibrateParams, {}> {
conflicts={this.settingConflicts} />}
</StepHeader>
<StepContent className={className}>
<StepRadio
<AxisStepRadio
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}

View File

@ -7,7 +7,7 @@ import { t } from "../../i18next_wrapper";
export function TileEmergencyStop(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
const className = "take-photo-step";
const className = "emergency-stop-step";
return <StepWrapper>
<StepHeader
className={className}

View File

@ -54,7 +54,7 @@ export function TileExecuteScript(props: StepParams) {
/** Configs (inputs) from Farmware manifest for <FarmwareInputs />. */
const currentFarmwareConfigDefaults = (fwName: string): FarmwareConfig[] => {
return farmwareData && farmwareData.farmwareConfigs[fwName]
return farmwareData?.farmwareConfigs[fwName]
? farmwareData.farmwareConfigs[fwName]
: [];
};

View File

@ -7,7 +7,7 @@ import {
StepWrapper, StepHeader, StepContent, StepWarning, conflictsString
} from "../step_ui/index";
import { some } from "lodash";
import { StepRadio } from "../step_ui/step_radio";
import { AxisStepRadio } from "../step_ui/step_radio";
import { t } from "../../i18next_wrapper";
export function TileFindHome(props: StepParams) {
@ -80,7 +80,7 @@ class InnerFindHome extends React.Component<FindHomeParams, {}> {
conflicts={this.settingConflicts} />}
</StepHeader>
<StepContent className={className}>
<StepRadio
<AxisStepRadio
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}

View File

@ -24,8 +24,8 @@ import {
const fakeResourceIndex = buildResourceIndex(FAKE_RESOURCES).index;
const fakeTaggedSequence = fakeResourceIndex
.references[Object.keys(fakeResourceIndex.byKind.Sequence)[0]] as TaggedSequence;
const fakeId = fakeTaggedSequence && fakeTaggedSequence.body.id || 0;
const fakeName = fakeTaggedSequence && fakeTaggedSequence.body.name || "";
const fakeId = fakeTaggedSequence.body.id || 0;
const fakeName = fakeTaggedSequence.body.name || "";
const expectedItem = { label: fakeName, value: fakeId };
function fakeProps(): IfParams {

View File

@ -2,10 +2,11 @@ import * as React from "react";
import { StepParams } from "../interfaces";
import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { StepRadio } from "../step_ui/step_radio";
import { AxisStepRadio } from "../step_ui/step_radio";
import { StepInputBox } from "../inputs/step_input_box";
import { Row, Col } from "../../ui";
import { t } from "../../i18next_wrapper";
import { Home } from "farmbot";
export function TileMoveHome(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
@ -20,9 +21,9 @@ export function TileMoveHome(props: StepParams) {
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<StepRadio
<AxisStepRadio
currentSequence={currentSequence}
currentStep={currentStep}
currentStep={currentStep as Home}
dispatch={dispatch}
index={index}
label={t("Home")} />

View File

@ -3,52 +3,18 @@ import { StepParams } from "../interfaces";
import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { t } from "../../i18next_wrapper";
import { Row, Col } from "../../ui";
import { ALLOWED_PACKAGES, SequenceBodyItem, Reboot } from "farmbot";
import { editStep } from "../../api/crud";
import { StepRadio } from "../step_ui/step_radio";
type StringMap = Record<string, string>;
interface MultiChoiceRadioProps<T extends StringMap> {
uuid: string;
choices: T;
currentChoice: keyof T;
onChange(key: keyof T): void;
}
export const MultiChoiceRadio =
<T extends StringMap>(props: MultiChoiceRadioProps<T>) => {
const choices = Object.keys(props.choices);
return <Row>
<Col xs={12}>
<div className="bottom-content">
<div className="channel-fields">
<form>
{choices.map((choice, i) =>
<div key={`${props.uuid} ${i}`} style={{ display: "inline" }}>
<label>
<input type="radio"
value={choice}
onChange={() => props.onChange(choice)}
checked={props.currentChoice === choice} />
{t(props.choices[choice])}
</label>
</div>)}
</form>
</div>
</div>
</Col>
</Row>;
};
const PACKAGE_CHOICES: Record<ALLOWED_PACKAGES, string> = {
"arduino_firmware": "Just the Arduino",
"farmbot_os": "Entire system"
};
const PACKAGE_CHOICES = (): Record<ALLOWED_PACKAGES, string> => ({
"arduino_firmware": t("Just the Arduino"),
"farmbot_os": t("Entire system")
});
function assertReboot(x: SequenceBodyItem): asserts x is Reboot {
if (x.kind !== "reboot") {
throw new Error("Impossible");
throw new Error(`${x.kind} is not "reboot"`);
}
}
@ -74,7 +40,7 @@ export const editTheRebootStep =
export function TileReboot(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
const className = "take-photo-step";
const className = "reboot-step";
assertReboot(currentStep);
return <StepWrapper>
<StepHeader
@ -86,9 +52,9 @@ export function TileReboot(props: StepParams) {
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<MultiChoiceRadio
uuid={currentSequence.uuid + index}
choices={PACKAGE_CHOICES}
<StepRadio
choices={Object.keys(PACKAGE_CHOICES())}
choiceLabelLookup={PACKAGE_CHOICES()}
currentChoice={currentStep.args.package as ALLOWED_PACKAGES}
onChange={editTheRebootStep(props)} />
</StepContent>

View File

@ -29,7 +29,7 @@ export function TileSendMessage(props: StepParams) {
}
}
interface SendMessageParams {
export interface SendMessageParams {
currentStep: SendMessage;
currentSequence: TaggedSequence;
dispatch: Function;
@ -40,19 +40,12 @@ interface SendMessageParams {
export class RefactoredSendMessage
extends React.Component<SendMessageParams, {}> {
get args() { return this.props.currentStep.args; }
get message() { return this.args.message; }
get message_type() { return this.args.message_type; }
get step() { return this.props.currentStep; }
get dispatch() { return this.props.dispatch; }
get sequence() { return this.props.currentSequence; }
get index() { return this.props.index; }
get currentSelection() {
return MESSAGE_STATUSES_DDI[this.message_type];
return MESSAGE_STATUSES_DDI[this.props.currentStep.args.message_type];
}
get channels() {
return (this.step.body || []).map(x => x.args.channel_name);
return (this.props.currentStep.body || []).map(x => x.args.channel_name);
}
hasChannel = (name: ChannelName) => {
@ -69,19 +62,19 @@ export class RefactoredSendMessage
}
toggle = (n: ChannelName) => () => {
this.dispatch(editStep({
sequence: this.sequence,
step: this.step,
index: this.index,
this.props.dispatch(editStep({
sequence: this.props.currentSequence,
step: this.props.currentStep,
index: this.props.index,
executor: this.hasChannel(n) ? this.remove(n) : this.add(n)
}));
}
setMsgType = (x: DropDownItem) => {
this.dispatch(editStep({
sequence: this.sequence,
step: this.step,
index: this.index,
this.props.dispatch(editStep({
sequence: this.props.currentSequence,
step: this.props.currentStep,
index: this.props.index,
executor: (step: SendMessage) => {
if (isMessageType(x.value)) {
step.args.message_type = x.value;
@ -110,7 +103,7 @@ export class RefactoredSendMessage
<Col xs={12}>
<label>{t("Message")}</label>
<span className="char-limit">
{this.message.length}/300
{this.props.currentStep.args.message.length}/300
</span>
<StepInputBox dispatch={dispatch}
step={currentStep}

View File

@ -2,17 +2,18 @@ import * as React from "react";
import { StepInputBox } from "../inputs/step_input_box";
import { StepParams } from "../interfaces";
import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { StepWrapper, StepHeader, StepContent } from "../step_ui";
import { Row, Col } from "../../ui/index";
import { t } from "../../i18next_wrapper";
import { MultiChoiceRadio } from "./tile_reboot";
import { SetServoAngle } from "farmbot";
import { editStep } from "../../api/crud";
import { StepRadio } from "../step_ui/step_radio";
const PACKAGE_CHOICES: Record<string, string> = {
"4": "Pin 4",
"5": "Pin 5",
};
const PIN_CHOICES = ["4", "5", "6", "11"];
const CHOICE_LABELS = () => PIN_CHOICES.reduce((acc, pinNumber) => {
acc[pinNumber] = `${t("Pin")} ${pinNumber}`;
return acc;
}, {} as Record<string, string>);
type Keys =
| "dispatch"
@ -35,14 +36,14 @@ export const pinNumberChanger = (props: Props) => (y: string) => {
};
export function ServoPinSelection(props: Props) {
const { currentSequence, index, currentStep } = props;
const { currentStep } = props;
const num = (currentStep as SetServoAngle).args.pin_number;
if (typeof num !== "number") { throw new Error("NO!"); }
const onChange = pinNumberChanger(props);
return <MultiChoiceRadio
uuid={currentSequence.uuid + index}
choices={PACKAGE_CHOICES}
return <StepRadio
choices={PIN_CHOICES}
choiceLabelLookup={CHOICE_LABELS()}
currentChoice={"" + num}
onChange={onChange} />;
}
@ -79,5 +80,4 @@ export function TileSetServoAngle(props: StepParams) {
</Row>
</StepContent>
</StepWrapper>;
}

View File

@ -2,8 +2,9 @@ import * as React from "react";
import { StepParams } from "../interfaces";
import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { StepRadio } from "../step_ui/step_radio";
import { t } from "../../i18next_wrapper";
import { AxisStepRadio } from "../step_ui/step_radio";
import { Zero } from "farmbot";
export function TileSetZero(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
@ -18,9 +19,9 @@ export function TileSetZero(props: StepParams) {
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<StepRadio
<AxisStepRadio
currentSequence={currentSequence}
currentStep={currentStep}
currentStep={currentStep as Zero}
dispatch={dispatch}
index={index}
label={t("Zero")} />

View File

@ -1,14 +1,16 @@
jest.mock("../../../api/crud", () => ({ overwrite: jest.fn() }));
let mockStep = {};
jest.mock("../../../api/crud", () => ({
editStep: jest.fn(x => x.executor(mockStep)),
}));
import * as React from "react";
import { mount } from "enzyme";
import { StepRadio, StepRadioProps } from "../step_radio";
import { AxisStepRadio, AxisStepRadioProps } from "../step_radio";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { FindHome, Calibrate, Zero } from "farmbot";
import { overwrite } from "../../../api/crud";
describe("<StepRadio />", () => {
const currentStep: FindHome = {
const findHomeStep: FindHome = {
kind: "find_home",
args: {
speed: 100,
@ -16,55 +18,55 @@ describe("<StepRadio />", () => {
}
};
const fakeProps = (): StepRadioProps => ({
const fakeProps = (): AxisStepRadioProps => ({
currentSequence: fakeSequence(),
currentStep,
currentStep: findHomeStep,
dispatch: jest.fn(),
index: 0,
label: "Find",
});
it("renders", () => {
const wrapper = mount(<StepRadio {...fakeProps()} />);
const wrapper = mount(<AxisStepRadio {...fakeProps()} />);
expect(wrapper.find("input").length).toEqual(4);
expect(wrapper.text()).toContain("Find");
});
it("handles update for find_home", () => {
const p = fakeProps();
const wrapper = mount(<StepRadio {...p} />);
mockStep = p.currentStep;
const wrapper = mount(<AxisStepRadio {...p} />);
wrapper.find("input").last().simulate("change");
const expectedStep: FindHome = {
kind: "find_home",
args: { speed: 100, axis: "all" }
};
expect(overwrite).toHaveBeenCalledWith(p.currentSequence,
expect.objectContaining({ body: [expectedStep] }));
expect(mockStep).toEqual(expectedStep);
});
it("handles update for calibrate", () => {
const p = fakeProps();
p.currentStep = { kind: "calibrate", args: { axis: "x" } };
const wrapper = mount(<StepRadio {...p} />);
mockStep = p.currentStep;
const wrapper = mount(<AxisStepRadio {...p} />);
wrapper.find("input").last().simulate("change");
const expectedStep: Calibrate = {
kind: "calibrate",
args: { axis: "all" }
};
expect(overwrite).toHaveBeenCalledWith(p.currentSequence,
expect.objectContaining({ body: [expectedStep] }));
expect(mockStep).toEqual(expectedStep);
});
it("handles update for zero", () => {
const p = fakeProps();
p.currentStep = { kind: "zero", args: { axis: "x" } };
const wrapper = mount(<StepRadio {...p} />);
mockStep = p.currentStep;
const wrapper = mount(<AxisStepRadio {...p} />);
wrapper.find("input").last().simulate("change");
const expectedStep: Zero = {
kind: "zero",
args: { axis: "all" }
};
expect(overwrite).toHaveBeenCalledWith(p.currentSequence,
expect.objectContaining({ body: [expectedStep] }));
expect(mockStep).toEqual(expectedStep);
});
});

View File

@ -1,57 +1,32 @@
import * as React from "react";
import { Row, Col } from "../../ui/index";
import {
TaggedSequence, SequenceBodyItem, ALLOWED_AXIS
} from "farmbot";
import { overwrite } from "../../api/crud";
import { defensiveClone } from "../../util";
import { t } from "../../i18next_wrapper";
import {
TaggedSequence, ALLOWED_AXIS, FindHome, Home, Calibrate, Zero
} from "farmbot";
import { editStep } from "../../api/crud";
export interface StepRadioProps {
currentSequence: TaggedSequence;
currentStep: SequenceBodyItem;
dispatch: Function;
index: number;
label: string;
export interface StepRadioProps<T extends string> {
choices: T[];
choiceLabelLookup: Record<T, string>;
currentChoice: T;
onChange(key: T): void;
}
const AXIS_CHOICES: ALLOWED_AXIS[] = ["x", "y", "z", "all"];
export function StepRadio(props: StepRadioProps) {
const isSelected = (choice: ALLOWED_AXIS) => {
if (props.currentStep.kind === "find_home"
|| props.currentStep.kind === "calibrate"
|| props.currentStep.kind === "zero") {
return props.currentStep.args.axis === choice;
}
};
const handleUpdate = (choice: ALLOWED_AXIS) => {
const update = defensiveClone(props.currentStep);
if (update.kind === "find_home"
|| update.kind === "calibrate"
|| update.kind === "zero") {
const nextSequence = defensiveClone(props.currentSequence).body;
update.args.axis = choice;
(nextSequence.body || [])[props.index] = update;
props.dispatch(overwrite(props.currentSequence, nextSequence));
}
};
return <Row>
export const StepRadio = <T extends string>(props: StepRadioProps<T>) =>
<Row>
<Col xs={12}>
<div className="bottom-content">
<div className="channel-fields">
<form>
{AXIS_CHOICES.map((choice, i) =>
{props.choices.map((choice, i) =>
<div key={i} style={{ display: "inline" }}>
<label>
<input type="radio"
value={choice}
onChange={e =>
handleUpdate(e.currentTarget.value as typeof choice)}
checked={isSelected(choice)} />
{` ${t(props.label)} ${choice}`}
onChange={() => props.onChange(choice)}
checked={props.currentChoice === choice} />
{t(props.choiceLabelLookup[choice])}
</label>
</div>)}
</form>
@ -59,4 +34,37 @@ export function StepRadio(props: StepRadioProps) {
</div>
</Col>
</Row>;
type AxisStep = FindHome | Home | Calibrate | Zero;
export interface AxisStepRadioProps {
currentSequence: TaggedSequence;
currentStep: AxisStep;
dispatch: Function;
index: number;
label: string;
}
export const AxisStepRadio = (props: AxisStepRadioProps) => {
const AXIS_CHOICES: ALLOWED_AXIS[] = ["x", "y", "z", "all"];
const CHOICE_LABELS = AXIS_CHOICES.reduce((acc, axis) => {
acc[axis] = `${t(props.label)} ${axis}`;
return acc;
}, {} as Record<ALLOWED_AXIS, string>);
const handleUpdate = (axis: ALLOWED_AXIS) => {
const { currentStep, index, currentSequence } = props;
props.dispatch(editStep({
step: currentStep,
index,
sequence: currentSequence,
executor: (step: AxisStep) => step.args.axis = axis,
}));
};
return <StepRadio
choices={AXIS_CHOICES}
choiceLabelLookup={CHOICE_LABELS}
currentChoice={props.currentStep.args.axis}
onChange={handleUpdate} />;
};

View File

@ -30,7 +30,7 @@ export const newTaggedResource = <T extends TR>(kind: T["kind"],
return {
kind: kind as TaggedResource["kind"],
body: body as TaggedResource["body"],
uuid: generateUuid(body && body.id ? body.id : undefined, kind),
uuid: generateUuid(body?.id, kind),
specialStatus
} as T;
});

Some files were not shown because too many files have changed in this diff Show More