add regimen variables UI

pull/1088/head
gabrielburnworth 2019-01-10 19:10:55 -08:00
parent acd764ab1e
commit 540422df0e
29 changed files with 382 additions and 174 deletions

View File

@ -51,7 +51,7 @@
"css-loader": "2.1.0",
"enzyme": "3.8.0",
"enzyme-adapter-react-16": "1.7.1",
"farmbot": "6.6.3-rc5",
"farmbot": "6.6.3-rc6",
"farmbot-toastr": "1.0.3",
"fastclick": "1.0.6",
"file-loader": "3.0.1",

View File

@ -50,7 +50,8 @@ export function fakeRegimen(): TaggedRegimen {
return fakeResource("Regimen", {
name: "Foo",
color: "red",
regimen_items: []
regimen_items: [],
body: [],
});
}

View File

@ -279,7 +279,8 @@ const tr12: TaggedResource = {
"sequence_id": 23,
"time_offset": 345900000
}
]
],
body: [],
},
"uuid": "Regimen.11.46"
};
@ -406,7 +407,8 @@ const blankReg: TaggedRegimen = {
"id": 11,
"name": "Repair Sequence",
"color": "gray",
"regimen_items": []
"regimen_items": [],
body: [],
},
"uuid": "Regimen.11.46"
};

View File

@ -546,10 +546,6 @@ export namespace Content {
trim(`Click one in the Regimens panel to edit, or click "+" to create
a new one.`);
export const NO_PARAMETERS = trim(`Can't directly use this sequence in a
regimen. Consider wrapping it in a parent sequence that calls it via
"execute" instead.`);
// Farm Designer
export const OUTSIDE_PLANTING_AREA =
trim(`Outside of planting area. Plants must be placed within the grid.`);

View File

@ -32,7 +32,9 @@ describe("<Regimens />", () => {
weeks: [],
bot,
calendar: [],
regimenUsageStats: {}
regimenUsageStats: {},
shouldDisplay: () => false,
variableData: {},
};
}

View File

@ -4,6 +4,8 @@ import { TaggedResource } from "farmbot";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { newTaggedResource } from "../../sync/actions";
import { selectAllRegimens } from "../../resources/selectors";
import { fakeVariableNameSet } from "../../__test_support__/fake_variables";
import { fakeRegimen, fakeSequence } from "../../__test_support__/fake_state/resources";
describe("mapStateToProps()", () => {
it("returns props: no regimen selected", () => {
@ -42,4 +44,17 @@ describe("mapStateToProps()", () => {
props.current ? expect(props.current.uuid).toEqual(uuid) : fail;
expect(props.calendar[0].items[0].item.time_offset).toEqual(1000);
});
it("returns variableData", () => {
const reg = fakeRegimen();
const seq = fakeSequence();
reg.body.regimen_items = [{ sequence_id: seq.body.id || 0, time_offset: 1000 }];
const state = fakeState();
state.resources = buildResourceIndex([reg, seq]);
state.resources.consumers.regimens.currentRegimen = reg.uuid;
const varData = fakeVariableNameSet();
state.resources.index.sequenceMetas[seq.uuid] = varData;
const props = mapStateToProps(state);
expect(props.variableData).toEqual(varData);
});
});

View File

@ -22,7 +22,8 @@ describe("commitBulkEditor()", () => {
"color": "gray",
"regimen_items": [
{ regimen_id, sequence_id, time_offset: 1000 }
]
],
body: [],
};
const reg = newTaggedResource("Regimen", regBody)[0];
const seqBody: TaggedSequence["body"] = {

View File

@ -29,7 +29,7 @@ describe("<BulkScheduler />", () => {
weeks,
sequences: [fakeSequence(), fakeSequence()],
resources: buildResourceIndex([]).index,
dispatch: jest.fn()
dispatch: jest.fn(),
};
}
@ -53,7 +53,8 @@ describe("<BulkScheduler />", () => {
it("changes time", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = shallow(<BulkScheduler {...p} />);
const panel = shallow<BulkScheduler>(<BulkScheduler {...p} />);
const wrapper = shallow(panel.instance().TimeSelection());
const timeInput = wrapper.find("BlurableInput").first();
expect(timeInput.props().value).toEqual("01:00");
timeInput.simulate("commit", { currentTarget: { value: "02:00" } });
@ -66,7 +67,8 @@ describe("<BulkScheduler />", () => {
it("sets current time", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = shallow(<BulkScheduler {...p} />);
const panel = shallow<BulkScheduler>(<BulkScheduler {...p} />);
const wrapper = shallow(panel.instance().TimeSelection());
const currentTimeBtn = wrapper.find(".fa-clock-o").first();
currentTimeBtn.simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
@ -78,7 +80,8 @@ describe("<BulkScheduler />", () => {
it("changes sequence", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = shallow(<BulkScheduler {...p} />);
const panel = shallow<BulkScheduler>(<BulkScheduler {...p} />);
const wrapper = shallow(panel.instance().SequenceSelectBox());
const sequenceInput = wrapper.find("FBSelect").first();
sequenceInput.simulate("change", { value: "Sequence" });
expect(p.dispatch).toHaveBeenCalledWith({
@ -90,7 +93,8 @@ describe("<BulkScheduler />", () => {
it("doesn't change sequence", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = shallow(<BulkScheduler {...p} />);
const panel = shallow<BulkScheduler>(<BulkScheduler {...p} />);
const wrapper = shallow(panel.instance().SequenceSelectBox());
const sequenceInput = wrapper.find("FBSelect").first();
const change = () => sequenceInput.simulate("change", { value: 4 });
expect(change).toThrowError("WARNING: Not a sequence UUID.");

View File

@ -1,19 +1,4 @@
import { msToTime, maybeWarnAboutParameters } from "../utils";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { error } from "farmbot-toastr";
import { Content } from "../../../constants";
describe("maybeWarnAboutParameters", () => {
it("calls `error()` if the sequence uses params", () => {
const s = fakeSequence();
s.body.args.locals.body = [{
kind: "parameter_declaration",
args: { label: "parent", data_type: "point" }
}];
maybeWarnAboutParameters(s);
expect(error).toHaveBeenCalledWith(Content.NO_PARAMETERS);
});
});
import { msToTime } from "../utils";
describe("msToTime", () => {
it("handles bad inputs", () => {

View File

@ -9,6 +9,7 @@ import { defensiveClone } from "../../util";
import { overwrite } from "../../api/crud";
import { Actions } from "../../constants";
import { assertUuid } from "../../resources/util";
import { mergeVariableDeclarations } from "../../sequences/locals_list/declaration_support";
export function pushWeek() {
return {
@ -87,6 +88,8 @@ export function commitBulkEditor(): Thunk {
const regimen = findRegimen(resources.index, currentRegimen);
const clonedRegimen = defensiveClone(regimen).body;
clonedRegimen.regimen_items = clonedRegimen.regimen_items.concat(groupedItems);
const varData = resources.index.sequenceMetas[selectedSequenceUUID];
clonedRegimen.body = mergeVariableDeclarations(varData, regimen.body.body);
dispatch(overwrite(regimen, clonedRegimen));
} else {
return error(t("No day(s) selected."));

View File

@ -8,16 +8,18 @@ import {
} from "../../ui/index";
import * as moment from "moment";
import { t } from "i18next";
import * as _ from "lodash";
import { isString } from "lodash";
import { betterCompact, bail } from "../../util";
import { maybeWarnAboutParameters, msToTime, timeToMs } from "./utils";
import { msToTime, timeToMs } from "./utils";
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) ? { label: s.body.name, value: s.uuid } : NULL_CHOICE;
return (s && s.body.id)
? { label: s.body.name, value: s.uuid }
: NULL_CHOICE;
};
all = (): DropDownItem[] => {
@ -29,52 +31,50 @@ export class BulkScheduler extends React.Component<BulkEditorProps, {}> {
};
commitChange = (uuid: string) => {
const s = this.props.sequences.filter(x => x.uuid == uuid)[0];
maybeWarnAboutParameters(s);
this.props.dispatch(setSequence(uuid));
}
onChange = (event: DropDownItem) => {
const uuid = event.value;
_.isString(uuid) ? this.commitChange(uuid) : bail(BAD_UUID);
isString(uuid) ? this.commitChange(uuid) : bail(BAD_UUID);
}
SequenceSelectBox = () =>
<Col xs={6}>
<div>
<label>{t("Sequence")}</label>
<FBSelect onChange={this.onChange}
selectedItem={this.selected()}
list={this.all()}
placeholder="Pick a sequence (or save a new one)" />
</div>
</Col>
TimeSelection = () =>
<Col xs={6}>
<div>
<label>{t("Time")}</label>
<i className="fa fa-clock-o" onClick={() =>
this.props.dispatch(setTimeOffset(timeToMs(
moment().add(3, "minutes").format("HH:mm"))))} />
<BlurableInput type="time"
value={msToTime(this.props.dailyOffsetMs)}
onCommit={({ currentTarget }) => {
this.props.dispatch(setTimeOffset(timeToMs(currentTarget.value)));
}} />
</div>
</Col>
render() {
const {
dispatch,
dailyOffsetMs,
weeks,
sequences
} = this.props;
const { dispatch, weeks, sequences } = this.props;
const active = !!(sequences && sequences.length);
return <div>
<AddButton
active={active}
click={() => { dispatch(commitBulkEditor()); }} />
click={() => dispatch(commitBulkEditor())} />
<Row>
<Col xs={6}>
<div>
<label>{t("Sequence")}</label>
<FBSelect onChange={this.onChange}
selectedItem={this.selected()}
list={this.all()}
placeholder="Pick a sequence (or save a new one)" />
</div>
</Col>
<Col xs={6}>
<div>
<label>{t("Time")}</label>
<i className="fa fa-clock-o" onClick={() =>
this.props.dispatch(setTimeOffset(timeToMs(
moment().add(3, "minutes").format("HH:mm"))))
} />
<BlurableInput type="time"
value={msToTime(dailyOffsetMs)}
onCommit={({ currentTarget }) => {
dispatch(setTimeOffset(timeToMs(currentTarget.value)));
}} />
</div>
</Col>
<this.SequenceSelectBox />
<this.TimeSelection />
</Row>
<WeekGrid weeks={weeks} dispatch={dispatch} />
</div>;

View File

@ -1,14 +1,5 @@
import { TaggedSequence } from "farmbot";
import { isParameterized } from "../../sequences/locals_list/is_parameterized";
import { error } from "farmbot-toastr";
import { t } from "i18next";
import * as moment from "moment";
import * as _ from "lodash";
import { Content } from "../../constants";
export function maybeWarnAboutParameters(s: undefined | TaggedSequence) {
s && isParameterized(s.body) && error(t(Content.NO_PARAMETERS));
}
export function msToTime(ms: number) {
if (_.isNumber(ms)) {

View File

@ -1,13 +1,20 @@
jest.mock("../../../api/crud", () => ({
overwrite: jest.fn(),
}));
import * as React from "react";
import { mount } from "enzyme";
import { ActiveEditor } from "../active_editor";
import { ActiveEditor, editRegimenDeclarations } from "../active_editor";
import { fakeRegimen } from "../../../__test_support__/fake_state/resources";
import { ActiveEditorProps } from "../interfaces";
import { Actions } from "../../../constants";
import { SpecialStatus } from "farmbot";
import { VariableDeclaration } from "farmbot";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { overwrite } from "../../../api/crud";
describe("<ActiveEditor />", () => {
const props: ActiveEditorProps = {
const fakeProps = (): ActiveEditorProps => ({
dispatch: jest.fn(),
regimen: fakeRegimen(),
calendar: [{
@ -24,25 +31,44 @@ describe("<ActiveEditor />", () => {
sequence_id: 0, time_offset: 1000
}
}]
}]
};
}],
resources: buildResourceIndex([]).index,
shouldDisplay: () => false,
variableData: {},
});
it("renders", () => {
const wrapper = mount(<ActiveEditor {...props} />);
const wrapper = mount(<ActiveEditor {...fakeProps()} />);
["Day", "Item 0", "10:00"].map(string =>
expect(wrapper.text()).toContain(string));
});
it("removes regimen item", () => {
const wrapper = mount(<ActiveEditor {...props} />);
const keptItem = { sequence_id: 1, time_offset: 1000 };
const p = fakeProps();
p.calendar[0].items[0].regimen.body.regimen_items =
[p.calendar[0].items[0].item, keptItem];
const wrapper = mount(<ActiveEditor {...p} />);
wrapper.find("i").simulate("click");
expect(props.dispatch).toHaveBeenCalledWith({
payload: {
update: expect.objectContaining({ regimen_items: [] }),
uuid: expect.stringContaining("Regimen"),
specialStatus: SpecialStatus.DIRTY
},
type: Actions.OVERWRITE_RESOURCE
});
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({ regimen_items: [keptItem] }));
});
});
describe("editRegimenDeclarations()", () => {
const declaration: VariableDeclaration = {
kind: "variable_declaration",
args: {
label: "label", data_value: {
kind: "identifier", args: { label: "new_var" }
}
}
};
it("updates declarations", () => {
const regimen = fakeRegimen();
editRegimenDeclarations({ dispatch: jest.fn(), regimen })([])(declaration);
expect(overwrite).toHaveBeenCalledWith(regimen,
expect.objectContaining({ body: [declaration] }));
});
});

View File

@ -34,7 +34,7 @@ describe("<CopyButton />", () => {
wrapper.simulate("click");
expect(p.dispatch).toHaveBeenCalled();
expect(init).toHaveBeenCalledWith("Regimen", {
color: "red", name: "Foo copy 1", regimen_items
color: "red", name: "Foo copy 1", regimen_items, body: []
});
expect(push).toHaveBeenCalledWith("/app/regimens/foo_copy_1");
expect(setActiveRegimenByName).toHaveBeenCalled();

View File

@ -14,6 +14,9 @@ import { RegimenEditorProps } from "../interfaces";
import { destroy, save } from "../../../api/crud";
import { clickButton } from "../../../__test_support__/helpers";
import { SpecialStatus } from "farmbot";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
describe("<RegimenEditor />", () => {
function fakeProps(): RegimenEditorProps {
@ -36,7 +39,10 @@ describe("<RegimenEditor />", () => {
sequence_id: 0, time_offset: 1000
}
}]
}]
}],
resources: buildResourceIndex([]).index,
shouldDisplay: () => false,
variableData: {},
};
}

View File

@ -1,23 +1,27 @@
jest.mock("../../actions", () => ({ editRegimen: jest.fn() }));
import { write } from "../regimen_name_input";
import * as React from "react";
import { shallow } from "enzyme";
import { write, RegimenNameInput } from "../regimen_name_input";
import { fakeRegimen } from "../../../__test_support__/fake_state/resources";
import { editRegimen } from "../../actions";
import { inputEvent } from "../../../__test_support__/fake_input_event";
const fakeProps = () => ({ regimen: fakeRegimen(), dispatch: jest.fn() });
describe("write()", () => {
it("crashes without a regimen", () => {
const input = { regimen: undefined, dispatch: jest.fn() };
expect(() => write(input)).toThrowError();
});
it("calls dispatch", () => {
const input = { regimen: fakeRegimen(), dispatch: jest.fn() };
const callback = write(input);
expect(callback).toBeInstanceOf(Function);
const value = "FOO";
// tslint:disable-next-line:no-any
callback({ currentTarget: { value } } as any);
expect(input.dispatch).toHaveBeenCalled();
expect(editRegimen).toHaveBeenCalled();
const p = fakeProps();
write(p)(inputEvent("foo"));
expect(editRegimen).toHaveBeenCalledWith(p.regimen, { name: "foo" });
});
});
describe("<RegimenNameInput />", () => {
it("changes color", () => {
const p = fakeProps();
const wrapper = shallow(<RegimenNameInput {...p} />);
wrapper.find("ColorPicker").simulate("change", "red");
expect(editRegimen).toHaveBeenCalledWith(p.regimen, { color: "red" });
});
});

View File

@ -3,58 +3,97 @@ import { RegimenNameInput } from "./regimen_name_input";
import { ActiveEditorProps } from "./interfaces";
import { t } from "i18next";
import { push } from "../../history";
import { RegimenItem } from "../interfaces";
import { TaggedRegimen } from "farmbot";
import {
RegimenItem, CalendarRow, RegimenItemCalendarRow, RegimenProps
} from "../interfaces";
import { TaggedRegimen, VariableDeclaration } from "farmbot";
import { defensiveClone } from "../../util";
import { overwrite, save, destroy } from "../../api/crud";
import { SaveBtn } from "../../ui";
import { CopyButton } from "./copy_button";
import { LocalsList } from "../../sequences/locals_list/locals_list";
import {
addOrEditVarDeclaration
} from "../../sequences/locals_list/declaration_support";
import {
AllowedDeclaration
} from "../../sequences/locals_list/locals_list_support";
/**
* The bottom half of the regimen editor panel (when there's something to
* actually edit).
*/
export function ActiveEditor(props: ActiveEditorProps) {
const regimenProps = { regimen: props.regimen, dispatch: props.dispatch };
return <div className="regimen-editor-content">
<div className="regimen-editor-tools">
<div className="button-group">
<SaveBtn
status={props.regimen.specialStatus}
onClick={() => props.dispatch(save(props.regimen.uuid))} />
<CopyButton regimen={props.regimen} dispatch={props.dispatch} />
<button className="fb-button red"
onClick={() => {
props.dispatch(destroy(props.regimen.uuid)).then(
() => push("/app/regimens/"));
}}>
{t("Delete")}
</button>
</div>
<RegimenNameInput regimen={props.regimen} dispatch={props.dispatch} />
<RegimenButtonGroup {...regimenProps} />
<RegimenNameInput {...regimenProps} />
<LocalsList
locationDropdownKey={JSON.stringify(props.regimen)}
declarations={props.regimen.body.body}
variableData={props.variableData}
sequenceUuid={props.regimen.uuid}
resources={props.resources}
onChange={editRegimenDeclarations(regimenProps)(props.regimen.body.body)}
allowedDeclarations={AllowedDeclaration.variable}
shouldDisplay={props.shouldDisplay} />
<hr />
</div>
<div className="regimen">
{props.calendar.map(function (group, index1) {
return <div className="regimen-day" key={index1}>
<label> {t("Day {{day}}", { day: group.day })} </label>
{group.items.map(function (row, index2) {
const { item, regimen } = row;
const click = () => props.dispatch(removeRegimenItem(item, regimen));
const klass = `${row.color} regimen-event`;
return <div className={klass} key={`${index1}.${index2}`}>
<span className="regimen-event-title">{row.name}</span>
<span className="regimen-event-time">{row.hhmm}</span>
<i className="fa fa-trash regimen-control" onClick={click} />
</div>;
})}
</div>;
})}
</div>
<RegimenRows calendar={props.calendar} dispatch={props.dispatch} />
</div>;
}
function removeRegimenItem(item: RegimenItem, r: TaggedRegimen) {
export const editRegimenDeclarations = (props: RegimenProps) =>
(declarations: VariableDeclaration[]) =>
(declaration: VariableDeclaration) => {
const copy = defensiveClone(props.regimen);
copy.body.body = addOrEditVarDeclaration(declarations, declaration);
props.dispatch(overwrite(props.regimen, copy.body));
};
const RegimenButtonGroup = (props: RegimenProps) =>
<div className="button-group">
<SaveBtn
status={props.regimen.specialStatus}
onClick={() => props.dispatch(save(props.regimen.uuid))} />
<CopyButton regimen={props.regimen} dispatch={props.dispatch} />
<button className="fb-button red"
onClick={() => props.dispatch(destroy(props.regimen.uuid))
.then(() => push("/app/regimens/"))}>
{t("Delete")}
</button>
</div>;
interface RegimenRowsProps {
calendar: CalendarRow[];
dispatch: Function;
}
const RegimenRows = (props: RegimenRowsProps) =>
<div className="regimen">
{props.calendar.map(regimenDay(props.dispatch))}
</div>;
const regimenDay = (dispatch: Function) =>
(group: CalendarRow, dayIndex: number) =>
<div className="regimen-day" key={dayIndex}>
<label> {t("Day {{day}}", { day: group.day })} </label>
{group.items.map(regimenItemRow(dispatch, dayIndex))}
</div>;
const regimenItemRow = (dispatch: Function, dayIndex: number) =>
(row: RegimenItemCalendarRow, itemIndex: number) =>
<div className={`${row.color} regimen-event`}
key={`${dayIndex}.${itemIndex}`}>
<span className="regimen-event-title">{row.name}</span>
<span className="regimen-event-time">{row.hhmm}</span>
<i className="fa fa-trash regimen-control" onClick={() =>
dispatch(removeRegimenItem(row.item, row.regimen))} />
</div>;
const removeRegimenItem = (item: RegimenItem, r: TaggedRegimen) => {
const copy = defensiveClone(r);
copy.body.regimen_items = r.body.regimen_items.filter(x => x !== item);
return overwrite(r, copy.body);
}
};

View File

@ -19,6 +19,9 @@ export function RegimenEditor(props: RegimenEditorProps) {
{regimen && <ActiveEditor
dispatch={dispatch}
regimen={regimen}
calendar={calendar} />}
calendar={calendar}
resources={props.resources}
variableData={props.variableData}
shouldDisplay={props.shouldDisplay} />}
</EmptyStateWrapper>;
}

View File

@ -5,11 +5,16 @@ import {
} from "../interfaces";
import { TaggedRegimen } from "farmbot";
import { Actions } from "../../constants";
import { ResourceIndex, VariableNameSet } from "../../resources/interfaces";
import { ShouldDisplay } from "../../devices/interfaces";
export interface ActiveEditorProps {
regimen: TaggedRegimen;
calendar: CalendarRow[];
dispatch: Function;
resources: ResourceIndex;
shouldDisplay: ShouldDisplay;
variableData: VariableNameSet;
}
export interface RegimenItemListProps {
@ -31,6 +36,9 @@ export interface RegimenEditorProps {
current: TaggedRegimen | undefined;
dispatch: Function;
calendar: CalendarRow[];
resources: ResourceIndex;
shouldDisplay: ShouldDisplay;
variableData: VariableNameSet;
}
export interface CopyButtonProps {

View File

@ -4,19 +4,13 @@ import { t } from "i18next";
import { Row, Col, ColorPicker } from "../../ui/index";
import { editRegimen } from "../actions";
export function write({ dispatch, regimen }: RegimenProps):
React.EventHandler<React.FormEvent<{}>> {
if (regimen) {
return (event: React.FormEvent<HTMLInputElement>) => {
dispatch(editRegimen(regimen, { name: event.currentTarget.value }));
};
} else {
throw new Error("Regimen is required");
}
}
export const write = ({ dispatch, regimen }: RegimenProps):
React.EventHandler<React.FormEvent<{}>> =>
(event: React.FormEvent<HTMLInputElement>) =>
dispatch(editRegimen(regimen, { name: event.currentTarget.value }));
export function RegimenNameInput({ regimen, dispatch }: RegimenProps) {
const value = (regimen && regimen.body.name) || "";
const value = regimen.body.name || "";
return <Row>
<Col xs={11}>
<input
@ -27,8 +21,8 @@ export function RegimenNameInput({ regimen, dispatch }: RegimenProps) {
</Col>
<Col xs={1} className="color-picker-col">
<ColorPicker
current={(regimen && regimen.body.color) || "gray"}
onChange={(color) => dispatch(editRegimen(regimen, { color }))} />
current={regimen.body.color || "gray"}
onChange={color => dispatch(editRegimen(regimen, { color }))} />
</Col>
</Row>;
}

View File

@ -40,7 +40,10 @@ export class Regimens extends React.Component<Props, {}> {
<RegimenEditor
dispatch={this.props.dispatch}
calendar={this.props.calendar}
current={this.props.current} />
current={this.props.current}
resources={this.props.resources}
variableData={this.props.variableData}
shouldDisplay={this.props.shouldDisplay} />
</CenterPanel>
<RightPanel
className="bulk-scheduler"

View File

@ -1,9 +1,9 @@
import { Color } from "../interfaces";
import { Week } from "./bulk_scheduler/interfaces";
import { AuthState } from "../auth/interfaces";
import { BotState } from "../devices/interfaces";
import { BotState, ShouldDisplay } from "../devices/interfaces";
import { TaggedRegimen, TaggedSequence } from "farmbot";
import { ResourceIndex, UUID } from "../resources/interfaces";
import { ResourceIndex, UUID, VariableNameSet } from "../resources/interfaces";
export interface CalendarRow {
day: string;
@ -13,6 +13,7 @@ export interface CalendarRow {
export interface Props {
dispatch: Function;
sequences: TaggedSequence[];
variableData: VariableNameSet;
auth: AuthState | undefined;
bot: BotState;
current: TaggedRegimen | undefined;
@ -22,7 +23,8 @@ export interface Props {
dailyOffsetMs: number;
weeks: Week[];
calendar: CalendarRow[];
regimenUsageStats: Record<UUID, boolean | undefined>
regimenUsageStats: Record<UUID, boolean | undefined>;
shouldDisplay: ShouldDisplay;
}
export interface RegimenItemCalendarRow {
@ -39,7 +41,7 @@ export interface RegimenItemCalendarRow {
/** Used by UI widgets that modify a regimen */
export interface RegimenProps {
regimen?: TaggedRegimen;
regimen: TaggedRegimen;
dispatch: Function;
}

View File

@ -1,3 +1,8 @@
let mockPath = "/app/regimens";
jest.mock("../../../history", () => ({
history: { getCurrentLocation: () => ({ pathname: mockPath }) },
}));
import * as React from "react";
import { RegimenListItemProps } from "../../interfaces";
import { RegimenListItem } from "../regimen_list_item";
@ -5,6 +10,7 @@ import { render, shallow } from "enzyme";
import { fakeRegimen } from "../../../__test_support__/fake_state/resources";
import { SpecialStatus } from "farmbot";
import { Actions } from "../../../constants";
import { urlFriendly } from "../../../util";
describe("<RegimenListItem/>", () => {
const fakeProps = (): RegimenListItemProps => {
@ -54,4 +60,18 @@ describe("<RegimenListItem/>", () => {
payload: props.regimen.uuid
});
});
it("doesn't set regimen as active", () => {
const p = fakeProps();
mockPath = "/app/regimens";
const wrapper = render(<RegimenListItem {...p} />);
expect(wrapper.find(".active").length).toEqual(0);
});
it("sets active regimen", () => {
const p = fakeProps();
mockPath = "/app/regimens/" + urlFriendly(p.regimen.body.name);
const wrapper = render(<RegimenListItem {...p} />);
expect(wrapper.find(".active").length).toEqual(1);
});
});

View File

@ -10,7 +10,8 @@ import { urlFriendly } from "../../util";
const emptyRegimenBody = (length: number): TaggedRegimen["body"] => ({
name: (t("New regimen ") + (length++)),
color: "gray",
regimen_items: []
regimen_items: [],
body: [],
});
export function AddRegimen(props: AddRegimenProps) {

View File

@ -8,14 +8,14 @@ import { Link } from "../../link";
export function RegimenListItem({ regimen, dispatch, inUse }: RegimenListItemProps) {
const name = (regimen.body.name || "") + (regimen.specialStatus ? " *" : "");
const color = (regimen.body.color) || "gray";
const style = [`block`, `full-width`, `fb-button`, `${color}`];
lastUrlChunk() === urlFriendly(regimen.body.name) && style.push("active");
const color = regimen.body.color || "gray";
const classNames = [`block`, `full-width`, `fb-button`, `${color}`];
lastUrlChunk() === urlFriendly(regimen.body.name) && classNames.push("active");
return <Link
to={`/app/regimens/${urlFriendly(regimen.body.name)}`}
key={regimen.uuid}>
<button
className={style.join(" ")}
className={classNames.join(" ")}
onClick={() => dispatch(selectRegimen(regimen.uuid))}>
<label>{name}</label>
{inUse && <i className="in-use fa fa-hdd-o" title={t(Content.IN_USE)} />}

View File

@ -1,18 +1,25 @@
import { Everything } from "../interfaces";
import { Props, RegimenItem, RegimenItemCalendarRow, CalendarRow } from "./interfaces";
import {
Props, RegimenItem, RegimenItemCalendarRow, CalendarRow
} from "./interfaces";
import {
selectAllSequences,
selectAllRegimens,
maybeGetSequence,
maybeGetRegimen,
findId,
findSequence
findSequence,
maybeGetDevice,
findSequenceById
} from "../resources/selectors";
import { TaggedRegimen } from "farmbot";
import { duration } from "moment";
import * as moment from "moment";
import { ResourceIndex } from "../resources/interfaces";
import { randomColor } from "../util";
import { ResourceIndex, UUID, VariableNameSet } from "../resources/interfaces";
import {
randomColor, determineInstalledOsVersion,
shouldDisplay as shouldDisplayFunc
} from "../util";
import * as _ from "lodash";
import { resourceUsageList } from "../resources/in_use";
@ -25,9 +32,29 @@ export function mapStateToProps(props: Everything): Props {
const calendar = current ?
generateCalendar(current, index, dispatch) : [];
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const shouldDisplay = shouldDisplayFunc(
installedOsVersion, props.bot.minOsFeatureData);
const calledSequences = (): UUID[] => {
if (current) {
const sequenceIds = current.body.regimen_items.map(x => x.sequence_id);
return Array.from(new Set(sequenceIds)).map(x =>
findSequenceById(props.resources.index, x).uuid);
}
return [];
};
const variableData: VariableNameSet = {};
calledSequences().map(uuid =>
Object.entries(props.resources.index.sequenceMetas[uuid] || {})
.map(([key, value]) => !variableData[key] && (variableData[key] = value)));
return {
dispatch: props.dispatch,
sequences: selectAllSequences(index),
variableData,
resources: index,
auth: props.auth,
current,
@ -37,7 +64,8 @@ export function mapStateToProps(props: Everything): Props {
weeks,
bot,
calendar,
regimenUsageStats: resourceUsageList(props.resources.index.inUse)
regimenUsageStats: resourceUsageList(props.resources.index.inUse),
shouldDisplay,
};
}

View File

@ -109,7 +109,8 @@ describe("in_use tracking at reducer level", () => {
color: "red",
regimen_items: [
{ sequence_id, time_offset: 12 }
]
],
body: [],
})[0];
const ri = buildResourceIndex([regimen, sequence]);
expect(resourceUsageList(ri.index.inUse)[sequence.uuid]).toBe(true);

View File

@ -1,4 +1,7 @@
import { declarationList } from "../declaration_support";
import { declarationList, mergeVariableDeclarations } from "../declaration_support";
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
import { VariableDeclaration, ParameterDeclaration } from "farmbot";
import { cloneDeep } from "lodash";
describe("declarationList()", () => {
it("returns undefined", () => {
@ -38,3 +41,57 @@ describe("declarationList()", () => {
}]);
});
});
describe("mergeVariableDeclarations()", () => {
const declarations: VariableDeclaration[] = [
{
kind: "variable_declaration", args: {
label: "parent1", data_value: {
kind: "coordinate", args: { x: 1, y: 2, z: 3 }
}
}
},
{
kind: "variable_declaration", args: {
label: "parent2", data_value: {
kind: "coordinate", args: { x: 1, y: 2, z: 3 }
}
}
},
];
it("doesn't overwrite declarations", () => {
const varData = fakeVariableNameSet("parent1");
const result = mergeVariableDeclarations(varData, declarations);
expect(result).toEqual(declarations);
});
it("adds new declarations", () => {
// "parent2" will not be added: already exists
const varData = fakeVariableNameSet("parent2");
// "parent3" will not be added: already defined
const notAdded = "parent3";
varData[notAdded] = fakeVariableNameSet(notAdded)[notAdded];
// "parent4" will be added to the existing declarations
const label = "parent4";
const add = fakeVariableNameSet(label)[label];
const addedNewDecl: ParameterDeclaration = {
kind: "parameter_declaration", args: { label, data_type: "point" }
};
add && (add.celeryNode = addedNewDecl);
varData[label] = add;
const expected = cloneDeep(declarations);
expected.push({
kind: "variable_declaration", args: {
label, data_value: {
kind: "coordinate", args: { x: 0, y: 0, z: 0 }
}
}
});
const result = mergeVariableDeclarations(varData, declarations);
expect(result).toEqual(expected);
});
});

View File

@ -40,3 +40,19 @@ const reduceVarDeclarations = (declarations: VariableDeclaration[]):
declarations.map(d => items[d.args.label] = d);
return items;
};
/** Add new variable declarations if they don't already exist. */
export const mergeVariableDeclarations = (
varData: VariableNameSet | undefined,
declarations: VariableDeclaration[]
): VariableDeclaration[] => {
/** New variables required by the chosen sequence. */
const newVars = reduceVarDeclarations(declarationList(varData) || []);
const regimenVars = reduceVarDeclarations(declarations);
Object.entries(newVars)
/** Filter out variables already in the Regimen. */
.filter(([k, _]) => !Object.keys(regimenVars).includes(k))
/** Add the remaining new variables to the Regimen body. */
.map(([k, v]) => regimenVars[k] = v);
return Object.values(regimenVars);
};