From a10267507b8bd4e26d0766665dc6a33e02036762 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 3 Jan 2020 12:17:56 -0800 Subject: [PATCH] no executables farm event form --- frontend/constants.ts | 8 +- .../farm_designer/farm_designer_panels.scss | 26 +- frontend/css/farm_designer/farm_events.scss | 10 - .../__tests__/add_farm_event_test.tsx | 103 +++-- .../__tests__/edit_farm_event_test.tsx | 2 +- .../__tests__/edit_fe_form_test.tsx | 204 +++++----- ...rm.tsx => farm_event_repeat_form_test.tsx} | 53 ++- .../__tests__/farm_events_test.tsx | 10 - .../farm_events/add_farm_event.tsx | 123 +++--- .../farm_events/edit_fe_form.tsx | 351 ++++++++++-------- .../farm_events/farm_event_repeat_form.tsx | 23 +- .../farm_designer/farm_events/farm_events.tsx | 38 +- frontend/ui/help.tsx | 3 +- 13 files changed, 534 insertions(+), 420 deletions(-) rename frontend/farm_designer/farm_events/__tests__/{farm_event_repeat_form.tsx => farm_event_repeat_form_test.tsx} (61%) diff --git a/frontend/constants.ts b/frontend/constants.ts index ec0416989..52de71984 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -820,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 = diff --git a/frontend/css/farm_designer/farm_designer_panels.scss b/frontend/css/farm_designer/farm_designer_panels.scss index c79f31920..d2d096eb4 100644 --- a/frontend/css/farm_designer/farm_designer_panels.scss +++ b/frontend/css/farm_designer/farm_designer_panels.scss @@ -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; } diff --git a/frontend/css/farm_designer/farm_events.scss b/frontend/css/farm_designer/farm_events.scss index e77eea68a..b755330af 100644 --- a/frontend/css/farm_designer/farm_events.scss +++ b/frontend/css/farm_designer/farm_events.scss @@ -78,13 +78,3 @@ background: $gray; } } - -.add-farm-event-panel { - .note { - margin-top: 4rem; - } -} - -.add-event-repeat-frequency { - min-height: 34px; -} diff --git a/frontend/farm_designer/farm_events/__tests__/add_farm_event_test.tsx b/frontend/farm_designer/farm_events/__tests__/add_farm_event_test.tsx index 46692e8eb..04dfb8a2d 100644 --- a/frontend/farm_designer/farm_events/__tests__/add_farm_event_test.tsx +++ b/frontend/farm_designer/farm_events/__tests__/add_farm_event_test.tsx @@ -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("", () => { function fakeProps(): AddEditFarmEventProps { @@ -49,21 +61,24 @@ describe("", () => { expect(deleteBtn.props().hidden).toBeTruthy(); }); - it("redirects", () => { - const p = fakeProps(); - p.findFarmEventByUuid = jest.fn(); - const wrapper = mount(); - expect(wrapper.text()).toContain("Loading"); - }); - it("renders with no executables", () => { const p = fakeProps(); p.findFarmEventByUuid = jest.fn(); p.sequencesById = {}; p.regimensById = {}; const wrapper = mount(); - 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(); + 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("", () => { p.regimensById = { "1": regimen }; const wrapper = mount(); 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(); - 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(); 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(); + 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(); + 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(); + 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(); + wrapper.find(FarmEventForm).simulate("save"); + expect(error).toHaveBeenCalledWith(Content.MISSING_EXECUTABLE); }); }); diff --git a/frontend/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx b/frontend/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx index d6b3b94ba..757d92acd 100644 --- a/frontend/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx +++ b/frontend/farm_designer/farm_events/__tests__/edit_farm_event_test.tsx @@ -41,7 +41,7 @@ describe("", () => { it("renders", () => { const wrapper = mount(); - ["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"); diff --git a/frontend/farm_designer/farm_events/__tests__/edit_fe_form_test.tsx b/frontend/farm_designer/farm_events/__tests__/edit_fe_form_test.tsx index 8b601b4a8..e7baf2a78 100644 --- a/frontend/farm_designer/farm_events/__tests__/edit_fe_form_test.tsx +++ b/frontend/farm_designer/farm_events/__tests__/edit_fe_form_test.tsx @@ -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("", () => { - const props = (): EditFEProps => ({ +describe("", () => { + const fakeProps = (): EditFEProps => ({ deviceTimezone: undefined, executableOptions: [], repeatOptions: [], @@ -53,34 +59,18 @@ describe("", () => { function instance(p: EditFEProps) { return mount().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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }; 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); 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("", () => { }); it("edits a variable", () => { - const p = props(); + const p = fakeProps(); const oldVariable: ParameterApplication = { kind: "parameter_application", args: { @@ -455,7 +444,7 @@ describe("", () => { }); it("saves an updated variable", () => { - const p = props(); + const p = fakeProps(); const oldVariable: ParameterApplication = { kind: "parameter_application", args: { @@ -481,7 +470,7 @@ describe("", () => { }); 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("", () => { 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(); - 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; - 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; - 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(); + wrapper.find(FarmEventForm).simulate("save"); + expect(error).toHaveBeenCalled(); }); }); @@ -557,3 +513,75 @@ describe("destructureFarmEvent", () => { expect(endTime).toBe("23:32"); }); }); + +describe("", () => { + const fakeProps = (): StartTimeFormProps => ({ + isRegimen: false, + fieldGet: jest.fn(), + fieldSet: jest.fn(), + timeSettings: fakeTimeSettings(), + }); + + it("changes start date", () => { + const p = fakeProps(); + const wrapper = shallow(); + 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(); + wrapper.find("EventTimePicker").simulate("commit", { + currentTarget: { value: "08:57" } + }); + expect(p.fieldSet).toHaveBeenCalledWith("startTime", "08:57"); + }); +}); + +describe("", () => { + 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(); + 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(); + wrapper.find("input").first().simulate("change", { + currentTarget: { checked: false } + }); + expect(p.fieldSet).toHaveBeenCalledWith("timeUnit", "never"); + }); +}); + +describe("", () => { + 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(); + 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"); + }); +}); diff --git a/frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form.tsx b/frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form_test.tsx similarity index 61% rename from frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form.tsx rename to frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form_test.tsx index a4cf2bb96..240f64a57 100644 --- a/frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form.tsx +++ b/frontend/farm_designer/farm_events/__tests__/farm_event_repeat_form_test.tsx @@ -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 { - 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("", () => { it("shows proper values", () => { - const p = props(); - const el = shallow(); + const p = fakeProps(); + const el = shallow(); 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("", () => { }); it("defaults to `daily` when a bad input it passed", () => { - const p = props(); + const p = fakeProps(); p.timeUnit = "never"; const el = shallow(); expect(formVal(el, Selectors.REPEAT)).toEqual(p.repeat); @@ -55,7 +52,7 @@ describe("", () => { }); it("disables all inputs via the `disabled` prop", () => { - const p = props(); + const p = fakeProps(); p.disabled = true; const el = shallow(); expect(getProp(el, Selectors.END_DATE, "disabled")).toBeTruthy(); @@ -65,9 +62,37 @@ describe("", () => { }); it("hides", () => { - const p = props(); + const p = fakeProps(); p.hidden = true; const el = render(); expect(el.text()).toEqual(""); }); + + const testBlurable = (input: string, field: string, value: string) => { + const p = fakeProps(); + const wrapper = shallow(); + 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(); + 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"); + }); }); diff --git a/frontend/farm_designer/farm_events/__tests__/farm_events_test.tsx b/frontend/farm_designer/farm_events/__tests__/farm_events_test.tsx index 6199e5919..dfe90c87a 100644 --- a/frontend/farm_designer/farm_events/__tests__/farm_events_test.tsx +++ b/frontend/farm_designer/farm_events/__tests__/farm_events_test.tsx @@ -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("", () => { expect(rows[2]).toEqual("02:00pm"); }); - it("warns about unset timezones", () => { - const p = fakeProps(); - p.timezoneIsSet = false; - const results = render(); - 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])]; diff --git a/frontend/farm_designer/farm_events/add_farm_event.tsx b/frontend/farm_designer/farm_events/add_farm_event.tsx index a86997eb3..95e11f9af 100644 --- a/frontend/farm_designer/farm_events/add_farm_event.tsx +++ b/frontend/farm_designer/farm_events/add_farm_event.tsx @@ -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; } export class RawAddFarmEvent - extends React.Component> { - - constructor(props: AddEditFarmEventProps) { - super(props); - this.state = {}; + extends React.Component { + 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

- {t("You haven't made any regimens or sequences yet. Please create a ")} - {t("sequence")} {t(" or ")} - {t("regimen")} {t(" first.")} -

; - } - - /** User has executables to create FarmEvents with, has not loaded yet. */ - loading() { - return

{t("Loading")}...

; - } - - placeholderTemplate(children: React.ReactChild | React.ReactChild[]) { - return - - - - - ; - } + 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 + ; - } else { - return this - .placeholderTemplate(this.executable ? this.loading() : this.none()); - } + onBack={(farmEvent && !farmEvent.body.id) + ? () => this.props.dispatch(destroyOK(farmEvent)) + : undefined} /> + + {farmEvent + ? + : { }} + executableGet={() => undefined} + dispatch={this.props.dispatch} + specialStatus={SpecialStatus.DIRTY} + onSave={() => error(t(Content.MISSING_EXECUTABLE))} />} + + ; } } diff --git a/frontend/farm_designer/farm_events/edit_fe_form.tsx b/frontend/farm_designer/farm_events/edit_fe_form.tsx index 5acff865c..d2bb3d66b 100644 --- a/frontend/farm_designer/farm_events/edit_fe_form.tsx +++ b/frontend/farm_designer/farm_events/edit_fe_form.tsx @@ -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; 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 { - 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 { + 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 { 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 { }; } - 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) => { - 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 { 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 { }); } - StartTimeForm = () => { - const forceMidnight = this.isReg; - return
- - - - - - - -
; - } - - RepeatCheckbox = ({ allowRepeat }: { allowRepeat: boolean }) => - !this.isReg ? - :
- - 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
- -
; - } - - FarmEventDeleteButton = () => - - render() { const { farmEvent } = this.props; - return - - // Throw out unsaved farmevents. - this.props.dispatch(destroyOK(farmEvent)) - : undefined} /> - - - + return
+ this.commitViewModel()}> - - - this.commitViewModel()} /> - - - - ; + +
; } } + +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
+ + + + props.fieldSet("startDate", e.currentTarget.value)} /> + + + props.fieldSet("startTime", e.currentTarget.value)} + disabled={forceMidnight} + hidden={forceMidnight} /> + + +
; +}; + +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
+ {!props.isRegimen + ? + :
} +
; +}; + +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) => + ; + +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
+ + {props.executableOptions.length < 1 && + } + + {props.children} + + + +
; +}; diff --git a/frontend/farm_designer/farm_events/farm_event_repeat_form.tsx b/frontend/farm_designer/farm_events/farm_event_repeat_form.tsx index a823adc87..8dd1883e0 100644 --- a/frontend/farm_designer/farm_events/farm_event_repeat_form.tsx +++ b/frontend/farm_designer/farm_events/farm_event_repeat_form.tsx @@ -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; -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 ?
:
+export function FarmEventRepeatForm(props: FarmEventRepeatFormProps) { + const { disabled, fieldSet, repeat, endDate, endTime, timeUnit } = props; + return props.hidden ?
:
@@ -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} /> onChange("timeUnit", "" + e.value)} + onChange={ddi => fieldSet("timeUnit", "" + ddi.value)} selectedItem={OPTN_LOOKUP[timeUnit] || OPTN_LOOKUP["daily"]} /> @@ -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} /> @@ -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} /> diff --git a/frontend/farm_designer/farm_events/farm_events.tsx b/frontend/farm_designer/farm_events/farm_events.tsx index 57dfb8d61..cd7853cd1 100644 --- a/frontend/farm_designer/farm_events/farm_events.tsx +++ b/frontend/farm_designer/farm_events/farm_events.tsx @@ -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 - - - -
-

Timezone Required

-

- {t(Content.SET_TIMEZONE_HEADER)} -

-

- {t(Content.SET_TIMEZONE_BODY)} -

-
-
; - }; - - normalContent = () => { - return
+ render() { + return + -
0} @@ -146,19 +125,8 @@ export class PureFarmEvents
-
; - }; - - render() { - return - - {this.props.timezoneIsSet ? this.normalContent() : this.tzwarning()} ; } } -/** 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); diff --git a/frontend/ui/help.tsx b/frontend/ui/help.tsx index d242517aa..2c00d046d 100644 --- a/frontend/ui/help.tsx +++ b/frontend/ui/help.tsx @@ -6,6 +6,7 @@ interface HelpProps { text: string; requireClick?: boolean; position?: PopoverPosition; + customIcon?: string; } export function Help(props: HelpProps) { @@ -14,7 +15,7 @@ export function Help(props: HelpProps) { interactionKind={props.requireClick ? PopoverInteractionKind.CLICK : PopoverInteractionKind.HOVER} popoverClassName={"help"}> - +
{t(props.text)}
; }