Merge branch 'staging' of github.com:FarmBot/Farmbot-Web-App into new_steps

pull/1648/head
Rick Carlino 2019-12-30 16:52:24 -06:00
commit 6aff7445f1
14 changed files with 282 additions and 99 deletions

View File

@ -5,7 +5,7 @@ class LogService < AbstractServiceRunner
THROTTLE_POLICY = ThrottlePolicy.new(name, min: 250, hour: 5_000, day: 25_000)
LOG_TPL = Rails.env.test? ?
"\e[32m.\e[0m" : "FBOS LOG (device_%s): %s\n"
"\e[32m.\e[0m" : "FBOS LOG (device_%s) [v%s]: %s\n"
ERR_TPL = "MALFORMED LOG CAPTURE: %s"
# Clean up excess logs in a non-deterministic manner.
# Performs the slow DB query every nth request.
@ -41,7 +41,9 @@ class LogService < AbstractServiceRunner
dev, log = [data.device, data.payload]
dev.maybe_unthrottle
Log.deliver(Logs::Create.run!(log, device: dev).id)
print LOG_TPL % [data.device_id, data.payload["message"] || "??"]
print LOG_TPL % [data.device_id,
dev.fbos_version || "?",
data.payload["message"] || "??"]
rescue => x
Rollbar.error(x)
end

View File

@ -1,7 +1,9 @@
import * as React from "react";
import { fetchLabFeatures, LabsFeature } from "./labs_features_list_data";
import { KeyValShowRow } from "../../controls/key_val_show_row";
import { GetWebAppConfigValue } from "../../config_storage/actions";
import { Row, Col } from "../../ui";
import { ToggleButton } from "../../controls/toggle_button";
import { t } from "../../i18next_wrapper";
interface LabsFeaturesListProps {
onToggle(feature: LabsFeature): Promise<void>;
@ -10,19 +12,23 @@ interface LabsFeaturesListProps {
export function LabsFeaturesList(props: LabsFeaturesListProps) {
return <div>
{fetchLabFeatures(props.getConfigValue).map((p, i) => {
const displayValue = p.displayInvert ? !p.value : p.value;
return <KeyValShowRow key={i}
label={p.name}
labelPlaceholder=""
value={p.description}
toggleValue={displayValue ? 1 : 0}
valuePlaceholder=""
onClick={() => {
props.onToggle(p)
.then(() => p.callback && p.callback());
}}
disabled={false} />;
{fetchLabFeatures(props.getConfigValue).map((feature, i) => {
const displayValue = feature.displayInvert ? !feature.value : feature.value;
return <Row key={i}>
<Col xs={4}>
<label>{feature.name}</label>
</Col>
<Col xs={6}>
<p>{feature.description}</p>
</Col>
<Col xs={2}>
<ToggleButton
toggleValue={displayValue ? 1 : 0}
toggleAction={() => props.onToggle(feature)
.then(() => feature.callback && feature.callback())}
customText={{ textFalse: t("off"), textTrue: t("on") }} />
</Col>
</Row>;
})}
</div>;
}

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { mount } from "enzyme";
import { KeyValShowRow, KeyValRowProps } from "../key_val_show_row";
describe("<KeyValShowRow />", () => {
const fakeProps = (): KeyValRowProps => ({
label: "label",
labelPlaceholder: "",
value: "value",
valuePlaceholder: "",
onClick: jest.fn(),
disabled: false,
});
it("renders", () => {
const wrapper = mount(<KeyValShowRow {...fakeProps()} />);
expect(wrapper.text()).toContain("label");
expect(wrapper.text()).toContain("value");
});
});

View File

@ -2,19 +2,31 @@ import * as React from "react";
import { pinToggle } from "../../devices/actions";
import { PeripheralListProps } from "./interfaces";
import { sortResourcesById } from "../../util";
import { KeyValShowRow } from "../key_val_show_row";
import { Row, Col } from "../../ui";
import { ToggleButton } from "../toggle_button";
import { t } from "../../i18next_wrapper";
export const PeripheralList = (props: PeripheralListProps) =>
<div className="peripheral-list">
{sortResourcesById(props.peripherals).map(p =>
<KeyValShowRow key={p.uuid}
label={p.body.label}
labelPlaceholder=""
value={"" + p.body.pin}
toggleValue={(props.pins[p.body.pin || -1] || { value: undefined }).value}
valuePlaceholder=""
title={t(`Toggle ${p.body.label}`)}
onClick={() => p.body.pin && pinToggle(p.body.pin)}
disabled={!!props.disabled} />)}
{sortResourcesById(props.peripherals).map(peripheral => {
const toggleValue =
(props.pins[peripheral.body.pin || -1] || { value: undefined }).value;
return <Row key={peripheral.uuid}>
<Col xs={6}>
<label>{peripheral.body.label}</label>
</Col>
<Col xs={4}>
<p>{"" + peripheral.body.pin}</p>
</Col>
<Col xs={2}>
<ToggleButton
toggleValue={toggleValue}
toggleAction={() =>
peripheral.body.pin && pinToggle(peripheral.body.pin)}
title={t(`Toggle ${peripheral.body.label}`)}
customText={{ textFalse: t("off"), textTrue: t("on") }}
disabled={!!props.disabled} />
</Col>
</Row>;
})}
</div>;

View File

@ -6,6 +6,7 @@ import { Edit } from "../edit";
import { SpecialStatus } from "farmbot";
import { clickButton } from "../../../__test_support__/helpers";
import { WebcamPanelProps } from "../interfaces";
import { KeyValEditRow } from "../../key_val_edit_row";
describe("<Edit/>", () => {
const fakeProps = (): WebcamPanelProps => {
@ -48,4 +49,22 @@ describe("<Edit/>", () => {
wrapper.find("WidgetBody").find("KeyValEditRow").first().simulate("click");
expect(p.destroy).toHaveBeenCalledWith(p.feeds[0]);
});
it("changes name", () => {
const p = fakeProps();
const wrapper = shallow(<Edit {...p} />);
wrapper.find(KeyValEditRow).first().simulate("labelChange", {
currentTarget: { value: "new_name" }
});
expect(p.edit).toHaveBeenCalledWith(p.feeds[0], { name: "new_name" });
});
it("changes url", () => {
const p = fakeProps();
const wrapper = shallow(<Edit {...p} />);
wrapper.find(KeyValEditRow).first().simulate("valueChange", {
currentTarget: { value: "new_url" }
});
expect(p.edit).toHaveBeenCalledWith(p.feeds[0], { url: "new_url" });
});
});

View File

@ -303,12 +303,25 @@
margin-bottom: 0px;
margin-left: .5rem;
}
.buttonrow {
float:left;
.button-row {
float: left;
width: 100%;
}
.plant-status-bulk-update {
display: inline-flex;
width: 100%;
margin-left: 1rem;
.filter-search {
margin-left: 0.5rem;
}
p {
font-size: 1.2rem;
line-height: 4.1rem;
}
}
}
.panel-content {
padding-top: 10rem;
padding-top: 15rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 5rem;
@ -528,6 +541,9 @@
}
p {
line-height: 3rem;
&.tool-slot-position {
float: right;
}
}
}
.mounted-tool-header {

View File

@ -37,7 +37,7 @@ type HOUR =
| 23;
type TimeTable = Record<HOUR, DropDownItem>;
type EveryTimeTable = Record<PreferredHourFormat, TimeTable>;
const TIME_TABLE_12H: TimeTable = {
const TIME_TABLE_12H = (): TimeTable => ({
0: { label: t("Midnight"), value: 0 },
1: { label: "1:00 AM", value: 1 },
2: { label: "2:00 AM", value: 2 },
@ -63,8 +63,8 @@ const TIME_TABLE_12H: TimeTable = {
22: { label: "10:00 PM", value: 22 },
23: { label: "11:00 PM", value: 23 },
[IMMEDIATELY]: { label: t("as soon as possible"), value: IMMEDIATELY },
};
const TIME_TABLE_24H: TimeTable = {
});
const TIME_TABLE_24H = (): TimeTable => ({
0: { label: "00:00", value: 0 },
1: { label: "01:00", value: 1 },
2: { label: "02:00", value: 2 },
@ -90,13 +90,13 @@ const TIME_TABLE_24H: TimeTable = {
22: { label: "22:00", value: 22 },
23: { label: "23:00", value: 23 },
[IMMEDIATELY]: { label: t("as soon as possible"), value: IMMEDIATELY },
};
});
const DEFAULT_HOUR: keyof TimeTable = IMMEDIATELY;
const TIME_FORMATS: EveryTimeTable = {
"12h": TIME_TABLE_12H,
"24h": TIME_TABLE_24H
};
const TIME_FORMATS = (): EveryTimeTable => ({
"12h": TIME_TABLE_12H(),
"24h": TIME_TABLE_24H()
});
interface OtaTimeSelectorProps {
disabled: boolean;
@ -136,7 +136,7 @@ export const OtaTimeSelector = (props: OtaTimeSelectorProps): JSX.Element => {
}
};
const theTimeTable = TIME_FORMATS[props.timeFormat];
const theTimeTable = TIME_FORMATS()[props.timeFormat];
const list = Object
.values(theTimeTable)
.map(x => ({ ...x, label: t(x.label) }))

View File

@ -1,7 +1,9 @@
import { isNumber } from "lodash";
import { BotPosition } from "../../../../devices/interfaces";
export function botPositionLabel(position: BotPosition) {
const show = (n: number | undefined) => isNumber(n) ? n : "---";
return `(${show(position.x)}, ${show(position.y)}, ${show(position.z)})`;
}
export const botPositionLabel =
(position: BotPosition, gantryMounted?: boolean) => {
const show = (n: number | undefined) => isNumber(n) ? n : "---";
const x = gantryMounted ? "gantry" : show(position.x);
return `(${x}, ${show(position.y)}, ${show(position.z)})`;
};

View File

@ -1,10 +1,15 @@
jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
import * as React from "react";
import {
PlantPanel, PlantPanelProps, EditPlantStatus, EditPlantStatusProps,
PlantPanel, PlantPanelProps, EditPlantStatusProps,
EditDatePlantedProps, EditDatePlanted, EditPlantLocationProps,
EditPlantLocation
EditPlantLocation,
} from "../plant_panel";
import { shallow, mount } from "enzyme";
import { FormattedPlantInfo } from "../map_state_to_props";
@ -13,6 +18,11 @@ import { clickButton } from "../../../__test_support__/helpers";
import { history } from "../../../history";
import moment from "moment";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { edit } from "../../../api/crud";
import {
EditPlantStatus, PlantStatusBulkUpdateProps, PlantStatusBulkUpdate
} from "../edit_plant_status";
describe("<PlantPanel/>", () => {
const info: FormattedPlantInfo = {
@ -120,6 +130,42 @@ describe("<EditPlantStatus />", () => {
});
});
describe("<PlantStatusBulkUpdate />", () => {
const fakeProps = (): PlantStatusBulkUpdateProps => ({
plants: [],
selected: [],
dispatch: jest.fn(),
});
it("doesn't update plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
p.plants = [plant1, plant2];
p.selected = [plant1.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => false);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalled();
expect(edit).not.toHaveBeenCalled();
});
it("updates plant statuses", () => {
const p = fakeProps();
const plant1 = fakePlant();
const plant2 = fakePlant();
const plant3 = fakePlant();
p.plants = [plant1, plant2, plant3];
p.selected = [plant1.uuid, plant2.uuid];
const wrapper = shallow(<PlantStatusBulkUpdate {...p} />);
window.confirm = jest.fn(() => true);
wrapper.find("FBSelect").simulate("change", { label: "", value: "planted" });
expect(window.confirm).toHaveBeenCalledWith(
"Change the plant status to 'planted' for 2 plants?");
expect(edit).toHaveBeenCalledTimes(2);
});
});
describe("<EditDatePlanted />", () => {
const fakeProps = (): EditDatePlantedProps => ({
uuid: "Plant.0.0",

View File

@ -0,0 +1,91 @@
import * as React from "react";
import { FBSelect, DropDownItem } from "../../ui";
import { PlantOptions } from "../interfaces";
import { PlantStage } from "farmbot";
import moment from "moment";
import { t } from "../../i18next_wrapper";
import { TaggedPlant } from "../map/interfaces";
import { UUID } from "../../resources/interfaces";
import { edit, save } from "../../api/crud";
import { EditPlantStatusProps } from "./plant_panel";
const PLANT_STAGES: DropDownItem[] = [
{ value: "planned", label: t("Planned") },
{ value: "planted", label: t("Planted") },
{ value: "sprouted", label: t("Sprouted") },
{ value: "harvested", label: t("Harvested") },
];
const PLANT_STAGES_DDI = {
[PLANT_STAGES[0].value]: {
label: PLANT_STAGES[0].label,
value: PLANT_STAGES[0].value
},
[PLANT_STAGES[1].value]: {
label: PLANT_STAGES[1].label,
value: PLANT_STAGES[1].value
},
[PLANT_STAGES[2].value]: {
label: PLANT_STAGES[2].label,
value: PLANT_STAGES[2].value
},
[PLANT_STAGES[3].value]: {
label: PLANT_STAGES[3].label,
value: PLANT_STAGES[3].value
},
};
/** Change `planted_at` value based on `plant_stage` update. */
const getUpdateByPlantStage = (plant_stage: PlantStage): PlantOptions => {
const update: PlantOptions = { plant_stage };
switch (plant_stage) {
case "planned":
update.planted_at = undefined;
break;
case "planted":
update.planted_at = moment().toISOString();
}
return update;
};
/** Select a `plant_stage` for a plant. */
export function EditPlantStatus(props: EditPlantStatusProps) {
const { plantStatus, updatePlant, uuid } = props;
return <FBSelect
list={PLANT_STAGES}
selectedItem={PLANT_STAGES_DDI[plantStatus]}
onChange={ddi =>
updatePlant(uuid, getUpdateByPlantStage(ddi.value as PlantStage))} />;
}
export interface PlantStatusBulkUpdateProps {
plants: TaggedPlant[];
selected: UUID[];
dispatch: Function;
}
/** Update `plant_stage` for multiple plants at once. */
export const PlantStatusBulkUpdate = (props: PlantStatusBulkUpdateProps) =>
<div className="plant-status-bulk-update">
<p>{t("update plant status to")}</p>
<FBSelect
key={JSON.stringify(props.selected)}
list={PLANT_STAGES}
selectedItem={undefined}
customNullLabel={t("Select a status")}
onChange={ddi => {
const plant_stage = ddi.value as PlantStage;
const update = getUpdateByPlantStage(plant_stage);
const plants = props.plants.filter(plant =>
props.selected.includes(plant.uuid)
&& plant.kind === "Point"
&& plant.body.plant_stage != plant_stage);
plants.length > 0 && confirm(
t("Change the plant status to '{{ status }}' for {{ num }} plants?",
{ status: plant_stage, num: plants.length }))
&& plants.map(plant => {
props.dispatch(edit(plant, update));
props.dispatch(save(plant.uuid));
});
}} />
</div>;

View File

@ -2,7 +2,7 @@ import * as React from "react";
import { FormattedPlantInfo } from "./map_state_to_props";
import { round } from "../map/util";
import { history } from "../../history";
import { FBSelect, DropDownItem, BlurableInput, Row, Col } from "../../ui";
import { BlurableInput, Row, Col } from "../../ui";
import { PlantOptions } from "../interfaces";
import { PlantStage } from "farmbot";
import { Moment } from "moment";
@ -14,6 +14,7 @@ import { parseIntInput } from "../../util";
import { startCase } from "lodash";
import { t } from "../../i18next_wrapper";
import { TimeSettings } from "../../interfaces";
import { EditPlantStatus } from "./edit_plant_status";
export interface PlantPanelProps {
info: FormattedPlantInfo;
@ -24,32 +25,6 @@ export interface PlantPanelProps {
timeSettings?: TimeSettings;
}
export const PLANT_STAGES: DropDownItem[] = [
{ value: "planned", label: t("Planned") },
{ value: "planted", label: t("Planted") },
{ value: "sprouted", label: t("Sprouted") },
{ value: "harvested", label: t("Harvested") },
];
export const PLANT_STAGES_DDI = {
[PLANT_STAGES[0].value]: {
label: PLANT_STAGES[0].label,
value: PLANT_STAGES[0].value
},
[PLANT_STAGES[1].value]: {
label: PLANT_STAGES[1].label,
value: PLANT_STAGES[1].value
},
[PLANT_STAGES[2].value]: {
label: PLANT_STAGES[2].label,
value: PLANT_STAGES[2].value
},
[PLANT_STAGES[3].value]: {
label: PLANT_STAGES[3].label,
value: PLANT_STAGES[3].value
},
};
interface EditPlantProperty {
uuid: string;
updatePlant(uuid: string, update: PlantOptions): void;
@ -59,25 +34,6 @@ export interface EditPlantStatusProps extends EditPlantProperty {
plantStatus: PlantStage;
}
export function EditPlantStatus(props: EditPlantStatusProps) {
const { plantStatus, updatePlant, uuid } = props;
return <FBSelect
list={PLANT_STAGES}
selectedItem={PLANT_STAGES_DDI[plantStatus]}
onChange={e => {
const plant_stage = e.value as PlantStage;
const update: PlantOptions = { plant_stage };
switch (plant_stage) {
case "planned":
update.planted_at = undefined;
break;
case "planted":
update.planted_at = moment().toISOString();
}
updatePlant(uuid, update);
}} />;
}
export interface EditDatePlantedProps extends EditPlantProperty {
datePlanted: Moment;
timeSettings: TimeSettings;

View File

@ -15,6 +15,7 @@ import { t } from "../../i18next_wrapper";
import { createGroup } from "../point_groups/actions";
import { PanelColor } from "../panel_header";
import { error } from "../../toast/toast";
import { PlantStatusBulkUpdate } from "./edit_plant_status";
export const mapStateToProps = (props: Everything): SelectPlantsProps => ({
selected: props.resources.consumers.farm_designer.selectedPlants,
@ -57,7 +58,7 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
ActionButtons = () =>
<div className="panel-action-buttons">
<div className="buttonrow">
<div className="button-row">
<button className="fb-button gray"
onClick={() => this.props.dispatch(selectPlant(undefined))}>
{t("Select none")}
@ -69,7 +70,7 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
</button>
</div>
<label>{t("SELECTION ACTIONS")}</label>
<div className="buttonrow">
<div className="button-row">
<button className="fb-button red"
onClick={() => this.destroySelected(this.props.selected)}>
{t("Delete")}
@ -80,6 +81,10 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
: error(t(Content.ERROR_PLANT_TEMPLATE_GROUP))}>
{t("Create group")}
</button>
<PlantStatusBulkUpdate
plants={this.props.plants}
selected={this.selected}
dispatch={this.props.dispatch} />
</div>
</div>;

View File

@ -48,18 +48,24 @@ describe("<Tools />", () => {
it("renders with tools", () => {
const p = fakeProps();
p.tools = [fakeTool(), fakeTool()];
p.tools = [fakeTool(), fakeTool(), fakeTool()];
p.tools[0].body.id = 1;
p.tools[0].body.status = "inactive";
p.tools[0].body.name = undefined;
p.tools[1].body.id = 2;
p.tools[1].body.name = "my tool";
p.toolSlots = [fakeToolSlot()];
p.tools[2].body.id = 3;
p.tools[2].body.name = "my tool";
p.toolSlots = [fakeToolSlot(), fakeToolSlot()];
p.toolSlots[0].body.tool_id = 2;
p.toolSlots[0].body.x = 1;
p.toolSlots[1].body.tool_id = 3;
p.toolSlots[1].body.gantry_mounted = true;
p.toolSlots[1].body.y = 2;
const wrapper = mount(<Tools {...p} />);
["foo", "my tool", "unnamed tool", "(1, 0, 0)", "unknown"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
[
"foo", "my tool", "unnamed tool", "(1, 0, 0)", "unknown", "(gantry, 2, 0)"
].map(string => expect(wrapper.text().toLowerCase()).toContain(string));
});
it("navigates to tool", () => {

View File

@ -208,7 +208,7 @@ interface ToolSlotInventoryItemProps {
}
const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => {
const { x, y, z, id, tool_id } = props.toolSlot.body;
const { x, y, z, id, tool_id, gantry_mounted } = props.toolSlot.body;
return <div
className={`tool-slot-search-item ${props.hovered ? "hovered" : ""}`}
onClick={() => history.push(`/app/designer/tool-slots/${id}`)}
@ -219,7 +219,9 @@ const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => {
<p>{props.getToolName(tool_id) || t("No tool")}</p>
</Col>
<Col xs={5}>
<p style={{ float: "right" }}>{botPositionLabel({ x, y, z })}</p>
<p className="tool-slot-position">
{botPositionLabel({ x, y, z }, gantry_mounted)}
</p>
</Col>
</Row>
</div>;