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

pull/1240/head
Rick Carlino 2019-06-15 14:29:08 -05:00
commit b9607c09c8
64 changed files with 781 additions and 737 deletions

View File

@ -3,17 +3,18 @@
# this part last
module CeleryScript
UNBOUND_VAR = "Unbound variable: %s"
class TypeCheckError < StandardError; end
class Checker
MISSING_ARG = "Expected node '%s' to have a '%s', but got: %s."
EXTRA_ARGS = "'%s' has unexpected arguments: %s. Allowed arguments: %s"
BAD_LEAF = "Expected leaf '%{kind}' within '%{parent_kind}'"\
" to be one of: %{allowed} but got %{actual}"
MALFORMED = "Expected '%s' to be a node or leaf, but it was neither"
BAD_BODY = "Body of '%s' node contains '%s' node. "\
"Expected one of: %s"
T_MISMATCH = "Type mismatch. %s must be one of: %s. Got: %s"
EXTRA_ARGS = "'%s' has unexpected arguments: %s. Allowed arguments: %s"
BAD_LEAF = "Expected leaf '%{kind}' within '%{parent_kind}'" \
" to be one of: %{allowed} but got %{actual}"
MALFORMED = "Expected '%s' to be a node or leaf, but it was neither"
BAD_BODY = "Body of '%s' node contains '%s' node. " \
"Expected one of: %s"
T_MISMATCH = "Type mismatch. %s must be one of: %s. Got: %s"
# Certain CeleryScript pairing errors are more than just a syntax error.
# For instance, A `nothing` node in a `parameter_declaration` is often an
@ -22,6 +23,7 @@ module CeleryScript
# BAD_LEAF template.
FRIENDLY_ERRORS = {
nothing: {
write_pin: "You must select a Peripheral in the Control Peripheral step.",
variable_declaration: "You must provide a value for all parameters",
parameter_declaration: "You must provide a value for all parameters",
},
@ -89,7 +91,7 @@ module CeleryScript
unless has_key
msgs = node.args.keys.join(", ")
msgs = "nothing" if msgs.length < 1
msg = MISSING_ARG % [node.kind, arg, msgs]
msg = MISSING_ARG % [node.kind, arg, msgs]
raise TypeCheckError, msg
end
end

View File

@ -1,12 +1,12 @@
# Support class for Fragment. Please see fragment.rb for documentation.
class ArgName < ApplicationRecord
EXPIRY = Rails.env.test? ? 1.second : 2.hours
KEY = "arg_names/%s"
KEY = "arg_names:%s"
validates_uniqueness_of :value
has_many :primitive_pairs, autosave: true
has_many :standard_pairs, autosave: true
has_many :standard_pairs, autosave: true
def self.cached_by_value(v)
key = KEY % v

View File

@ -10,7 +10,7 @@ class Device < ApplicationRecord
"Suspending log storage and display until %s."
THROTTLE_OFF = "Cooldown period has ended. " \
"Resuming log storage."
CACHE_KEY = "devices.%s"
CACHE_KEY = "devices:%s"
PLURAL_RESOURCES = %i(alerts farmware_envs farm_events farmware_installations
images logs peripherals pin_bindings plant_templates

View File

@ -1,7 +1,7 @@
# Support class for Fragment. Please see fragment.rb for documentation.
class Kind < ApplicationRecord
EXPIRY = Rails.env.test? ? 1.second : 2.hours
KEY = "kinds/%s"
KEY = "kinds:%s"
has_many :nodes
def self.cached_by_value(v)

View File

@ -30,7 +30,6 @@ module Devices
:settings_device_name,
:settings_enable_encoders,
:settings_firmware,
:settings_map_xl,
:settings_hide_sensors,
# TOOLS ==================================
@ -78,10 +77,6 @@ module Devices
@device = device
end
def settings_map_xl
device.web_app_config.update_attributes!(map_xl: false)
end
def settings_hide_sensors
device.web_app_config.update_attributes!(hide_sensors: false)
end

View File

@ -18,10 +18,6 @@ module Devices
def settings_default_map_size_y
device.web_app_config.update_attributes!(map_size_y: 2_900)
end
def settings_map_xl
device.web_app_config.update_attributes!(map_xl: true)
end
end
end
end

View File

@ -21,7 +21,6 @@ module Devices
def settings_default_map_size_x; end
def settings_default_map_size_y; end
def settings_firmware; end
def settings_map_xl; end
def settings_hide_sensors; end
def tool_slots_slot_1; end
def tool_slots_slot_2; end

View File

@ -132,7 +132,6 @@
encoder_figure: false,
hide_webcam_widget: false,
legend_menu_open: true,
map_xl: false,
raw_encoders: false,
scaled_encoders: false,
show_spread: false,

View File

@ -0,0 +1,6 @@
class RemoveMapXlFromWebAppConfigs < ActiveRecord::Migration[5.2]
safety_assured
def change
remove_column :web_app_configs, :map_xl, :boolean, default: false
end
end

View File

@ -0,0 +1,9 @@
class AddConfirmPlantDeletionToWebAppConfigs < ActiveRecord::Migration[5.2]
safety_assured
def change
add_column :web_app_configs,
:confirm_plant_deletion,
:boolean,
default: true
end
end

View File

@ -1521,7 +1521,6 @@ CREATE TABLE public.web_app_configs (
encoder_figure boolean DEFAULT false,
hide_webcam_widget boolean DEFAULT false,
legend_menu_open boolean DEFAULT false,
map_xl boolean DEFAULT false,
raw_encoders boolean DEFAULT false,
scaled_encoders boolean DEFAULT false,
show_spread boolean DEFAULT true,
@ -1560,7 +1559,8 @@ CREATE TABLE public.web_app_configs (
map_size_x integer DEFAULT 2900,
map_size_y integer DEFAULT 1400,
expand_step_options boolean DEFAULT false,
hide_sensors boolean DEFAULT false
hide_sensors boolean DEFAULT false,
confirm_plant_deletion boolean DEFAULT true
);
@ -2759,14 +2759,6 @@ ALTER TABLE ONLY public.points
ADD CONSTRAINT fk_rails_a62cbb8aca FOREIGN KEY (tool_id) REFERENCES public.tools(id);
--
-- Name: farmware_envs fk_rails_ab55c3a1d1; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.farmware_envs
ADD CONSTRAINT fk_rails_ab55c3a1d1 FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: primary_nodes fk_rails_bca7fee3b9; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -2775,6 +2767,14 @@ ALTER TABLE ONLY public.primary_nodes
ADD CONSTRAINT fk_rails_bca7fee3b9 FOREIGN KEY (sequence_id) REFERENCES public.sequences(id);
--
-- Name: farmware_envs fk_rails_bdadc396eb; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.farmware_envs
ADD CONSTRAINT fk_rails_bdadc396eb FOREIGN KEY (device_id) REFERENCES public.devices(id);
--
-- Name: alerts fk_rails_c0132c78be; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -2979,6 +2979,8 @@ INSERT INTO "schema_migrations" (version) VALUES
('20190515205442'),
('20190603233157'),
('20190605185311'),
('20190607192429');
('20190607192429'),
('20190613190531'),
('20190613215319');

View File

@ -270,6 +270,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
device_id: idCounter++,
created_at: "2018-01-11T20:20:38.362Z",
updated_at: "2018-01-22T15:32:41.970Z",
confirm_plant_deletion: true,
confirm_step_deletion: false,
disable_animations: false,
disable_i18n: false,
@ -278,7 +279,6 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
encoder_figure: false,
hide_webcam_widget: false,
legend_menu_open: false,
map_xl: false,
raw_encoders: true,
scaled_encoders: true,
show_spread: false,

View File

@ -1,25 +0,0 @@
jest.mock("../../../config_storage/actions", () => ({
setWebAppConfigValue: jest.fn()
}));
import * as React from "react";
import { MapSizeSetting, MapSizeSettingProps } from "../map_size_setting";
import { mount } from "enzyme";
import { setWebAppConfigValue } from "../../../config_storage/actions";
import { NumericSetting } from "../../../session_keys";
describe("<MapSizeSetting />", () => {
const fakeProps = (): MapSizeSettingProps => ({
getConfigValue: () => 100,
dispatch: jest.fn(),
});
it("changes value", () => {
const wrapper = mount(<MapSizeSetting {...fakeProps()} />);
wrapper.find("input").last().simulate("change"), {
currentTarget: { value: 100 }
};
expect(setWebAppConfigValue).toHaveBeenCalledWith(
NumericSetting.map_size_y, "100");
});
});

View File

@ -474,16 +474,12 @@ export namespace Content {
widget from the Controls page.`);
export const DYNAMIC_MAP_SIZE =
trim(`Change the Farm Designer map size based on axis length.
trim(`Change the garden map size based on axis length.
A value must be input in AXIS LENGTH and STOP AT MAX must be enabled in
the HARDWARE widget. Overrides MAP SIZE values.`);
export const DOUBLE_MAP_DIMENSIONS =
trim(`Double the default dimensions of the Farm Designer map
for a map with four times the area. Overriden by MAP SIZE values.`);
export const PLANT_ANIMATIONS =
trim(`Enable plant animations in the Farm Designer.`);
trim(`Enable plant animations in the garden map.`);
export const BROWSER_SPEAK_LOGS =
trim(`Have the browser also read aloud log messages on the
@ -498,7 +494,7 @@ export namespace Content {
will be discarded when refreshing or closing the page. Are you sure?`);
export const VIRTUAL_TRAIL =
trim(`Display a virtual trail for FarmBot in the Farm Designer map to show
trim(`Display a virtual trail for FarmBot in the garden map to show
movement and watering history while the map is open. Toggling this setting
will clear data for the current trail.`);
@ -523,9 +519,21 @@ export namespace Content {
Are you sure you want to disable this feature?`);
export const MAP_SIZE =
trim(`Specify custom Farm Designer Garden Map dimensions (in millimeters).
These values set the size of the garden map displayed in the designer
unless DYNAMIC MAP SIZE is enabled.`);
trim(`Specify custom map dimensions (in millimeters).
These values set the size of the garden map unless
DYNAMIC MAP SIZE is enabled.`);
export const MAP_SWAP_XY =
trim(`Swap map X and Y axes, making the Y axis horizontal and X axis
vertical. This setting will also swap the X and Y jog control buttons
in the Move widget.`);
export const MAP_ORIGIN =
trim(`Select a map origin by clicking on one of the four quadrants to
adjust the garden map to your viewing angle.`);
export const CONFIRM_PLANT_DELETION =
trim(`Show a confirmation dialog when deleting a plant.`);
// Device
export const NOT_HTTPS =

View File

@ -5,7 +5,7 @@ import { mount } from "enzyme";
import { Controls } from "../controls";
import { bot } from "../../__test_support__/fake_state/bot";
import {
fakePeripheral, fakeWebcamFeed, fakeSensor, fakeSensorReading
fakePeripheral, fakeWebcamFeed, fakeSensor
} from "../../__test_support__/fake_state/resources";
import { Dictionary } from "farmbot";
import { Props } from "../interfaces";
@ -67,7 +67,7 @@ describe("<Controls />", () => {
it("doesn't show sensor readings widget", () => {
const p = fakeProps();
p.sensorReadings = [];
mockConfig.hide_sensors = true;
const wrapper = mount(<Controls {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).not.toContain("sensor history");
@ -75,7 +75,7 @@ describe("<Controls />", () => {
it("shows sensor readings widget", () => {
const p = fakeProps();
p.sensorReadings = [fakeSensorReading()];
mockConfig.hide_sensors = false;
const wrapper = mount(<Controls {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("sensor history");

View File

@ -25,7 +25,7 @@ export class Controls extends React.Component<Props, {}> {
}
get hideSensors() {
return this.props.getWebAppConfigVal("hide_sensors");
return this.props.getWebAppConfigVal(BooleanSetting.hide_sensors);
}
move = () => <Move
@ -47,19 +47,19 @@ export class Controls extends React.Component<Props, {}> {
dispatch={this.props.dispatch} />
sensors = () => this.hideSensors
? <div />
? <div id="hidden-sensors-widget" />
: <Sensors
bot={this.props.bot}
sensors={this.props.sensors}
dispatch={this.props.dispatch}
disabled={this.arduinoBusy || !this.botOnline} />
sensorReadings = () => this.props.sensorReadings.length > 0
? <SensorReadings
sensorReadings = () => this.hideSensors
? <div id="hidden-sensor-history-widget" />
: <SensorReadings
sensorReadings={this.props.sensorReadings}
sensors={this.props.sensors}
timeSettings={this.props.timeSettings} />
: <div id="hidden-sensor-history-widget" />
render() {
const showWebcamWidget =

View File

@ -73,6 +73,7 @@
overflow-y: auto;
overflow-x: hidden;
margin-right: -10px;
padding-bottom: 5rem;
.plant-catalog-tile {
position: relative;
cursor: pointer;
@ -449,86 +450,6 @@
font-size: medium;
cursor: pointer;
}
.farmbot-origin {
.quadrants {
display: flex;
flex-wrap: wrap;
border: 1px solid $dark_gray;
}
.quadrant {
display: inline-block;
position: relative;
background-image: linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(rgba(0, 0, 0, 0.05) 2px, transparent 2px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 2px, transparent 2px);
background-size: 4px 4px, 4px 4px, 100px 100px, 100px 100px;
cursor: pointer;
border: 1px solid $dark_gray;
width: 50%;
height: 24px;
transition: all 0.2s ease-in-out;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&.selected {
box-shadow: inset 0 0 8px $dark_gray;
} // Quadrant 1
&:nth-child(2) {
&:before {
top: 0;
right: 0;
}
&:after {
top: 8px;
right: 16px;
}
} // Quadrant 2
&:nth-child(1) {
&:before {
top: 0;
left: 0;
}
&:after {
top: 8px;
left: 16px;
}
} // Quadrant 3
&:nth-child(3) {
&:before {
left: 0;
bottom: 0;
}
&:after {
left: 16px;
bottom: 8px;
}
} // Quadrant 4
&:nth-child(4) {
&:before {
bottom: 0;
right: 0;
}
&:after {
bottom: 8px;
right: 16px;
}
}
&:before {
content: "";
position: absolute;
background: $black;
width: 8px;
height: 8px;
border-radius: 50%;
}
&:after {
content: "";
position: absolute;
background: $green;
width: 8px;
height: 8px;
border-radius: 50%;
}
}
}
.more-bugs,
.move-to-mode {
button {
@ -580,6 +501,89 @@
}
}
.farmbot-origin {
margin: auto;
width: 120px;
.quadrants {
display: flex;
flex-wrap: wrap;
border: 1px solid $dark_gray;
}
.quadrant {
display: inline-block;
position: relative;
background-image: linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px), linear-gradient(rgba(0, 0, 0, 0.05) 2px, transparent 2px), linear-gradient(90deg, rgba(0, 0, 0, 0.05) 2px, transparent 2px);
background-size: 4px 4px, 4px 4px, 100px 100px, 100px 100px;
cursor: pointer;
border: 1px solid $dark_gray;
width: 50%;
height: 24px;
transition: all 0.2s ease-in-out;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&.selected {
box-shadow: inset 0 0 8px $dark_gray;
} // Quadrant 1
&:nth-child(2) {
&:before {
top: 0;
right: 0;
}
&:after {
top: 8px;
right: 16px;
}
} // Quadrant 2
&:nth-child(1) {
&:before {
top: 0;
left: 0;
}
&:after {
top: 8px;
left: 16px;
}
} // Quadrant 3
&:nth-child(3) {
&:before {
left: 0;
bottom: 0;
}
&:after {
left: 16px;
bottom: 8px;
}
} // Quadrant 4
&:nth-child(4) {
&:before {
bottom: 0;
right: 0;
}
&:after {
bottom: 8px;
right: 16px;
}
}
&:before {
content: "";
position: absolute;
background: $black;
width: 8px;
height: 8px;
border-radius: 50%;
}
&:after {
content: "";
position: absolute;
background: $green;
width: 8px;
height: 8px;
border-radius: 50%;
}
}
}
.map-points-submenu {
display: flex;
flex-direction: column;

View File

@ -193,6 +193,7 @@
max-height: calc(100vh - 19rem);
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 5rem;
}
}
@ -211,6 +212,7 @@
padding-top: 5rem;
padding-right: 0;
padding-left: 0;
padding-bottom: 5rem;
max-height: calc(100vh - 10rem);
overflow-y: auto;
overflow-x: hidden;
@ -329,6 +331,7 @@
overflow-y: auto;
overflow-x: hidden;
padding: 2rem 1rem 6rem;
padding-bottom: 10rem;
li {
margin-bottom: 1rem;
p {
@ -382,13 +385,18 @@
}
.settings-panel-content {
max-height: calc(100vh - 15rem);
overflow-y: auto;
overflow-x: hidden;
margin-top: 5rem;
padding-bottom: 5rem;
button {
margin-top: 1.75rem;
}
p {
padding: 1rem;
padding: 0.5rem;
margin-left: 1rem;
margin-right: 1rem;
}
.map-size-inputs {
.row {

View File

@ -15,6 +15,7 @@
overflow-x: hidden;
margin-top: 1.8rem;
margin-right: -10px;
padding-bottom: 5rem;
}
.farm-event {

View File

@ -872,11 +872,15 @@ ul {
.farmware-button,
.farmware-settings-menu {
position: absolute !important;
top: 1rem;
top: 3rem;
right: 3rem;
float: right !important;
}
.farmware-settings-menu {
top: 1rem;
}
.farmware-settings-menu-contents {
label {
margin-top: 0.5rem;

View File

@ -29,7 +29,11 @@
margin-right: 15px;
}
h3 {
max-width: 60%;
margin-left: 0;
margin-right: 0;
margin-bottom: 2.5rem;
padding-left: 0 !important;
}
.button-group {
margin-right: 15px;
@ -326,7 +330,7 @@
&.open {
display: block;
margin: 4rem;
margin-top: 0;
margin-top: 1rem;
margin-left: 2rem;
}
&.farmware-info-open {

View File

@ -25,6 +25,7 @@ import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
import { buildResourceIndex } from "../../__test_support__/resource_index_builder";
import { fakeState } from "../../__test_support__/fake_state";
import { edit } from "../../api/crud";
import { BooleanSetting } from "../../session_keys";
describe("<FarmDesigner/>", () => {
function fakeProps(): Props {
@ -71,7 +72,6 @@ describe("<FarmDesigner/>", () => {
expect(legendProps.showSpread).toBeFalsy();
expect(legendProps.showFarmbot).toBeTruthy();
expect(legendProps.showImages).toBeFalsy();
expect(legendProps.botOriginQuadrant).toEqual(2);
expect(legendProps.imageAgeInfo).toEqual({ newestDate: "", toOldest: 1 });
// tslint:disable-next-line:no-any
const gardenMapProps = wrapper.find("GardenMap").props() as any;
@ -127,7 +127,7 @@ describe("<FarmDesigner/>", () => {
state.resources = buildResourceIndex([fakeWebAppConfig()]);
p.dispatch = jest.fn(x => x(dispatch, () => state));
const wrapper = mount<FarmDesigner>(<FarmDesigner {...p} />);
wrapper.instance().toggle("show_plants")();
wrapper.instance().toggle(BooleanSetting.show_plants)();
expect(edit).toHaveBeenCalledWith(expect.any(Object), { bot_origin_quadrant: 2 });
});
});

View File

@ -0,0 +1,25 @@
jest.mock("../../config_storage/actions", () => ({
setWebAppConfigValue: jest.fn()
}));
import * as React from "react";
import { MapSizeInputs, MapSizeInputsProps } from "../map_size_setting";
import { mount } from "enzyme";
import { setWebAppConfigValue } from "../../config_storage/actions";
import { NumericSetting } from "../../session_keys";
describe("<MapSizeInputs />", () => {
const fakeProps = (): MapSizeInputsProps => ({
getConfigValue: () => 100,
dispatch: jest.fn(),
});
it("changes value", () => {
const wrapper = mount(<MapSizeInputs {...fakeProps()} />);
wrapper.find("input").last().simulate("change"), {
currentTarget: { value: 100 }
};
expect(setWebAppConfigValue).toHaveBeenCalledWith(
NumericSetting.map_size_y, "100");
});
});

View File

@ -1,19 +1,27 @@
jest.mock("react-redux", () => ({ connect: jest.fn() }));
jest.mock("../../config_storage/actions", () => ({
getWebAppConfigValue: jest.fn(() => jest.fn(() => true)),
getWebAppConfigValue: jest.fn(x => { x(); return jest.fn(() => true); }),
setWebAppConfigValue: jest.fn(),
}));
import * as React from "react";
import { mount } from "enzyme";
import { mount, ReactWrapper } from "enzyme";
import {
DesignerSettings, DesignerSettingsProps, mapStateToProps
} from "../settings";
import { fakeState } from "../../__test_support__/fake_state";
import { BooleanSetting } from "../../session_keys";
import { BooleanSetting, NumericSetting } from "../../session_keys";
import { setWebAppConfigValue } from "../../config_storage/actions";
const getSetting =
(wrapper: ReactWrapper, position: number, containsString: string) => {
const setting = wrapper.find(".designer-setting").at(position);
expect(setting.text().toLowerCase())
.toContain(containsString.toLowerCase());
return setting;
};
describe("<DesignerSettings />", () => {
const fakeProps = (): DesignerSettingsProps => ({
dispatch: jest.fn(),
@ -23,14 +31,35 @@ describe("<DesignerSettings />", () => {
it("renders settings", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
expect(wrapper.text()).toContain("size");
const settings = wrapper.find(".designer-setting");
expect(settings.length).toEqual(7);
});
it("renders defaultOn setting", () => {
const p = fakeProps();
p.getConfigValue = () => undefined;
const wrapper = mount(<DesignerSettings {...p} />);
const confirmDeletion = getSetting(wrapper, 6, "confirm plant");
expect(confirmDeletion.find("button").text()).toEqual("on");
});
it("toggles setting", () => {
const wrapper = mount(<DesignerSettings {...fakeProps()} />);
wrapper.find("button").at(1).simulate("click");
const trailSetting = getSetting(wrapper, 1, "trail");
trailSetting.find("button").simulate("click");
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(BooleanSetting.display_trail, true);
});
it("changes origin", () => {
const p = fakeProps();
p.getConfigValue = () => 2;
const wrapper = mount(<DesignerSettings {...p} />);
const originSetting = getSetting(wrapper, 5, "origin");
originSetting.find("div").last().simulate("click");
expect(setWebAppConfigValue).toHaveBeenCalledWith(
NumericSetting.bot_origin_quadrant, 4);
});
});
describe("mapStateToProps()", () => {

View File

@ -1,7 +1,9 @@
import * as React from "react";
import { connect } from "react-redux";
import { GardenMap } from "./map/garden_map";
import { Props, State, BotOriginQuadrant, isBotOriginQuadrant } from "./interfaces";
import {
Props, State, BotOriginQuadrant, isBotOriginQuadrant
} from "./interfaces";
import { mapStateToProps } from "./state_to_props";
import { Plants } from "./plants/plant_inventory";
import { GardenMapLegend } from "./map/legend/garden_map_legend";
@ -11,7 +13,9 @@ import { AxisNumberProperty, BotSize } from "./map/interfaces";
import {
getBotSize, round, getPanelStatus, MapPanelStatus, mapPanelClassName
} from "./map/util";
import { calcZoomLevel, getZoomLevelIndex, saveZoomLevelIndex } from "./map/zoom";
import {
calcZoomLevel, getZoomLevelIndex, saveZoomLevelIndex
} from "./map/zoom";
import moment from "moment";
import { DesignerNavTabs } from "./panel_header";
import {
@ -111,7 +115,6 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
show_farmbot,
show_images,
show_sensor_readings,
bot_origin_quadrant,
zoom_level
} = this.state;
@ -140,8 +143,6 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
className={this.mapPanelClassName}
zoom={this.updateZoomLevel}
toggle={this.toggle}
updateBotOriginQuadrant={this.updateBotOriginQuadrant}
botOriginQuadrant={bot_origin_quadrant}
legendMenuOpen={legend_menu_open}
showPlants={show_plants}
showPoints={show_points}
@ -182,7 +183,7 @@ export class FarmDesigner extends React.Component<Props, Partial<State>> {
stopAtHome={stopAtHome}
hoveredPlant={this.props.hoveredPlant}
zoomLvl={zoom_level}
botOriginQuadrant={bot_origin_quadrant}
botOriginQuadrant={this.getBotOriginQuadrant()}
gridSize={getGridSize(this.props.getConfigValue, botSize)}
gridOffset={gridOffset}
peripherals={this.props.peripherals}

View File

@ -217,6 +217,7 @@ export interface EditPlantInfoProps {
findPlant(stringyID: string | undefined): TaggedPlant | undefined;
openedSavedGarden: string | undefined;
timeSettings: TimeSettings;
getConfigValue: GetWebAppConfigValue;
}
export interface DraggableEvent {

View File

@ -4,6 +4,7 @@ jest.mock("../../../config_storage/actions", () => ({
import * as ZoomUtils from "../zoom";
import { setWebAppConfigValue } from "../../../config_storage/actions";
import { NumericSetting } from "../../../session_keys";
describe("zoom utilities", () => {
it("getZoomLevelIndex()", () => {
@ -12,7 +13,8 @@ describe("zoom utilities", () => {
it("saveZoomLevelIndex()", () => {
ZoomUtils.saveZoomLevelIndex(jest.fn(), 9);
expect(setWebAppConfigValue).toHaveBeenCalledWith("zoom_level", 1);
expect(setWebAppConfigValue)
.toHaveBeenCalledWith(NumericSetting.zoom_level, 1);
});
it("calcZoomLevel()", () => {

View File

@ -31,8 +31,6 @@ export interface CropSpreadDict {
export interface GardenMapLegendProps {
zoom: (value: number) => () => void;
toggle: (property: keyof State) => () => void;
updateBotOriginQuadrant: (quadrant: number) => () => void;
botOriginQuadrant: number;
legendMenuOpen: boolean;
showPlants: boolean;
showPoints: boolean;

View File

@ -5,12 +5,10 @@ jest.mock("../../../../history", () => ({
let mockAtMax = false;
let mockAtMin = false;
jest.mock("../../zoom", () => {
return {
atMaxZoom: () => mockAtMax,
atMinZoom: () => mockAtMin,
};
});
jest.mock("../../zoom", () => ({
atMaxZoom: () => mockAtMax,
atMinZoom: () => mockAtMin,
}));
let mockDev = false;
jest.mock("../../../../account/dev/dev_support", () => ({
@ -22,20 +20,20 @@ jest.mock("../../../../account/dev/dev_support", () => ({
import * as React from "react";
import { shallow, mount } from "enzyme";
import {
GardenMapLegend, ZoomControls, PointsSubMenu, RotationSelector
GardenMapLegend, ZoomControls, PointsSubMenu
} from "../garden_map_legend";
import { GardenMapLegendProps } from "../../interfaces";
import { clickButton } from "../../../../__test_support__/helpers";
import { history } from "../../../../history";
import { BooleanSetting } from "../../../../session_keys";
import { fakeTimeSettings } from "../../../../__test_support__/fake_time_settings";
import {
fakeTimeSettings
} from "../../../../__test_support__/fake_time_settings";
describe("<GardenMapLegend />", () => {
const fakeProps = (): GardenMapLegendProps => ({
zoom: () => () => undefined,
toggle: () => () => undefined,
updateBotOriginQuadrant: () => () => undefined,
botOriginQuadrant: 2,
legendMenuOpen: true,
showPlants: false,
showPoints: false,
@ -52,7 +50,7 @@ describe("<GardenMapLegend />", () => {
it("renders", () => {
const wrapper = mount(<GardenMapLegend {...fakeProps()} />);
["plants", "origin", "move"].map(string =>
["plants", "move"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
expect(wrapper.html()).toContain("filter");
expect(wrapper.html()).not.toContain("extras");
@ -117,19 +115,3 @@ describe("<PointsSubMenu />", () => {
expect(toggle).toHaveBeenCalledWith(BooleanSetting.show_historic_points);
});
});
describe("<RotationSelector />", () => {
it("swaps map x&y", () => {
const dispatch = jest.fn();
const wrapper = mount(<RotationSelector
dispatch={dispatch} value={false} />);
wrapper.find("button").simulate("click");
expect(dispatch).toHaveBeenCalled();
});
it("shows correct status", () => {
const wrapper = mount(<RotationSelector
dispatch={jest.fn()} value={true} />);
expect(wrapper.find("button").hasClass("green")).toBeTruthy();
});
});

View File

@ -5,43 +5,14 @@ import { history } from "../../../history";
import { atMaxZoom, atMinZoom } from "../zoom";
import { ImageFilterMenu } from "../layers/images/image_filter_menu";
import { BugsControls } from "../easter_eggs/bugs";
import { BotOriginQuadrant, State } from "../../interfaces";
import { State } from "../../interfaces";
import { MoveModeLink } from "../../move_to";
import { SavedGardensLink } from "../../saved_gardens/saved_gardens";
import {
GetWebAppConfigValue, setWebAppConfigValue
} from "../../../config_storage/actions";
import { GetWebAppConfigValue } from "../../../config_storage/actions";
import { BooleanSetting } from "../../../session_keys";
import { DevSettings } from "../../../account/dev/dev_support";
import { t } from "../../../i18next_wrapper";
const OriginSelector = ({ quadrant, update }: {
quadrant: BotOriginQuadrant,
update: (quadrant: number) => () => void
}) =>
<div className="farmbot-origin">
<label>
{t("Origin")}
</label>
<div className="quadrants">
{[2, 1, 3, 4].map(q =>
<div key={"quadrant_" + q}
className={"quadrant " + (quadrant === q && "selected")}
onClick={update(q)} />
)}
</div>
</div>;
export const RotationSelector = ({ dispatch, value }:
{ dispatch: Function, value: boolean }) => {
const classNames = `fb-button fb-toggle-button ${value ? "green" : "red"}`;
return <div className={"map-rotate-button"}>
<label>{t("rotate")}</label>
<button className={classNames} onClick={() =>
dispatch(setWebAppConfigValue(BooleanSetting.xy_swap, !value))} />
</div>;
};
export const ZoomControls = ({ zoom, getConfigValue }: {
zoom: (value: number) => () => void,
getConfigValue: GetWebAppConfigValue
@ -85,11 +56,11 @@ const LayerToggles = (props: GardenMapLegendProps) => {
<LayerToggle
value={props.showPlants}
label={t("Plants?")}
onClick={toggle("show_plants")} />
onClick={toggle(BooleanSetting.show_plants)} />
<LayerToggle
value={props.showPoints}
label={t("Points?")}
onClick={toggle("show_points")}
onClick={toggle(BooleanSetting.show_points)}
submenuTitle={t("extras")}
popover={DevSettings.futureFeaturesEnabled()
? <PointsSubMenu toggle={toggle} getConfigValue={getConfigValue} />
@ -97,15 +68,15 @@ const LayerToggles = (props: GardenMapLegendProps) => {
<LayerToggle
value={props.showSpread}
label={t("Spread?")}
onClick={toggle("show_spread")} />
onClick={toggle(BooleanSetting.show_spread)} />
<LayerToggle
value={props.showFarmbot}
label={t("FarmBot?")}
onClick={toggle("show_farmbot")} />
onClick={toggle(BooleanSetting.show_farmbot)} />
<LayerToggle
value={props.showImages}
label={t("Photos?")}
onClick={toggle("show_images")}
onClick={toggle(BooleanSetting.show_images)}
submenuTitle={t("filter")}
popover={<ImageFilterMenu
timeSettings={props.timeSettings}
@ -116,7 +87,7 @@ const LayerToggles = (props: GardenMapLegendProps) => {
<LayerToggle
value={props.showSensorReadings}
label={t("Readings?")}
onClick={toggle("show_sensor_readings")} />}
onClick={toggle(BooleanSetting.show_sensor_readings)} />}
</div>;
};
@ -127,7 +98,7 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
style={{ zoom: 1 }}>
<div
className={"menu-pullout " + menuClass}
onClick={props.toggle("legend_menu_open")}>
onClick={props.toggle(BooleanSetting.legend_menu_open)}>
<span>
{t("Menu")}
</span>
@ -136,11 +107,6 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
<div className="content">
<ZoomControls zoom={props.zoom} getConfigValue={props.getConfigValue} />
<LayerToggles {...props} />
<OriginSelector
quadrant={props.botOriginQuadrant}
update={props.updateBotOriginQuadrant} />
<RotationSelector dispatch={props.dispatch}
value={!!props.getConfigValue(BooleanSetting.xy_swap)} />
<MoveModeLink />
<SavedGardensLink />
<BugsControls />

View File

@ -1,20 +1,14 @@
import * as React from "react";
import {
GetWebAppConfigValue, setWebAppConfigValue
} from "../../config_storage/actions";
import { t } from "../../i18next_wrapper";
import { Row, Col } from "../../ui";
import { NumericSetting } from "../../session_keys";
import { Content } from "../../constants";
} from "../config_storage/actions";
import { t } from "../i18next_wrapper";
import { Row, Col } from "../ui";
import { NumericSetting } from "../session_keys";
import {
NumberConfigKey as WebAppNumberConfigKey
} from "farmbot/dist/resources/configs/web_app";
export interface MapSizeSettingProps {
getConfigValue: GetWebAppConfigValue;
dispatch: Function;
}
interface LengthInputProps {
value: number;
label: string;
@ -24,10 +18,10 @@ interface LengthInputProps {
const LengthInput = (props: LengthInputProps) =>
<Row>
<Col xs={5}>
<Col xs={4}>
<label style={{ float: "right" }}>{t(props.label)}</label>
</Col>
<Col xs={7}>
<Col xs={5}>
<input
type="number"
value={"" + props.value}
@ -36,25 +30,7 @@ const LengthInput = (props: LengthInputProps) =>
</Col>
</Row>;
export const MapSizeSetting =
({ dispatch, getConfigValue }: MapSizeSettingProps) =>
<div className={"map-size-setting"}>
<Row>
<Col xs={4}>
<label>{t("garden map size")}</label>
</Col>
<Col xs={4}>
<p>{t(Content.MAP_SIZE)}</p>
</Col>
<Col xs={4}>
<MapSizeInputs
getConfigValue={getConfigValue}
dispatch={dispatch} />
</Col>
</Row>
</div>;
interface MapSizeInputsProps {
export interface MapSizeInputsProps {
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}

View File

@ -1,43 +0,0 @@
jest.mock("react-redux", () => ({ connect: jest.fn() }));
jest.mock("../../../history", () => ({
history: { push: jest.fn() },
getPathArray: () => []
}));
import * as React from "react";
import { EditPlantInfo } from "../edit_plant_info";
import { mount } from "enzyme";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { EditPlantInfoProps } from "../../interfaces";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
describe("<EditPlantInfo />", () => {
const fakeProps = (): EditPlantInfoProps => ({
push: jest.fn(),
dispatch: jest.fn(),
findPlant: fakePlant,
openedSavedGarden: undefined,
timeSettings: fakeTimeSettings(),
});
it("renders", async () => {
const wrapper = mount(<EditPlantInfo {...fakeProps()} />);
["Strawberry Plant 1", "Plant Type", "Strawberry"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
const buttons = wrapper.find("button");
expect(buttons.at(1).text()).toEqual("Move FarmBot to this plant");
expect(buttons.at(1).props().hidden).toBeFalsy();
});
it("deletes plant", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => { return Promise.resolve(); });
const wrapper = mount(<EditPlantInfo {...p} />);
const deleteButton = wrapper.find("button").at(2);
expect(deleteButton.text()).toEqual("Delete");
expect(deleteButton.props().hidden).toBeFalsy();
deleteButton.simulate("click");
expect(p.dispatch).toHaveBeenCalled();
});
});

View File

@ -1,10 +1,17 @@
jest.mock("react-redux", () => ({ connect: jest.fn() }));
let mockPath = "/app/designer/plants/1";
jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => { return []; }),
getPathArray: jest.fn(() => mockPath.split("/")),
history: { push: jest.fn() }
}));
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
save: jest.fn(),
edit: jest.fn(),
}));
import * as React from "react";
import { PlantInfo } from "../plant_info";
import { mount } from "enzyme";
@ -12,6 +19,7 @@ import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { EditPlantInfoProps } from "../../interfaces";
import { history } from "../../../history";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { edit, save, destroy } from "../../../api/crud";
describe("<PlantInfo />", () => {
const fakeProps = (): EditPlantInfoProps => ({
@ -20,6 +28,7 @@ describe("<PlantInfo />", () => {
dispatch: jest.fn(),
openedSavedGarden: undefined,
timeSettings: fakeTimeSettings(),
getConfigValue: jest.fn(),
});
it("renders", () => {
@ -27,8 +36,8 @@ describe("<PlantInfo />", () => {
["Strawberry Plant 1", "Plant Type", "Strawberry"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
const buttons = wrapper.find("button");
expect(buttons.at(1).text()).toEqual("Move FarmBot to this plant");
expect(buttons.at(1).props().hidden).toBeFalsy();
expect(buttons.at(0).text()).toEqual("Move FarmBot to this plant");
expect(buttons.at(1).text()).toEqual("Planned");
});
it("renders: no plant", () => {
@ -46,4 +55,57 @@ describe("<PlantInfo />", () => {
expect(wrapper.find("Link").first().props().to)
.toContain("/app/designer/plants");
});
it("gets plant id", () => {
mockPath = "/app/designer/plants/1";
const p = fakeProps();
p.openedSavedGarden = undefined;
const wrapper = mount<PlantInfo>(<PlantInfo {...p} />);
expect(wrapper.instance().stringyID).toEqual("1");
});
it("gets template id", () => {
mockPath = "/app/designer/saved_gardens/templates/2";
const p = fakeProps();
p.openedSavedGarden = "uuid";
const wrapper = mount<PlantInfo>(<PlantInfo {...p} />);
expect(wrapper.instance().stringyID).toEqual("2");
});
it("handles missing plant id", () => {
mockPath = "/app/designer/plants";
const p = fakeProps();
p.openedSavedGarden = undefined;
const wrapper = mount<PlantInfo>(<PlantInfo {...p} />);
expect(wrapper.instance().stringyID).toEqual("");
});
it("updates plant", () => {
const wrapper = mount<PlantInfo>(<PlantInfo {...fakeProps()} />);
wrapper.instance().updatePlant("uuid", {});
expect(edit).toHaveBeenCalled();
expect(save).toHaveBeenCalledWith("uuid");
});
it("handles missing plant", () => {
const p = fakeProps();
p.findPlant = jest.fn();
const wrapper = mount<PlantInfo>(<PlantInfo {...p} />);
wrapper.instance().updatePlant("uuid", {});
expect(edit).not.toHaveBeenCalled();
});
it("destroys plant", () => {
const wrapper = mount<PlantInfo>(<PlantInfo {...fakeProps()} />);
wrapper.instance().destroy("uuid");
expect(destroy).toHaveBeenCalledWith("uuid", false);
});
it("force destroys plant", () => {
const p = fakeProps();
p.getConfigValue = jest.fn(() => false);
const wrapper = mount<PlantInfo>(<PlantInfo {...p} />);
wrapper.instance().destroy("uuid");
expect(destroy).toHaveBeenCalledWith("uuid", true);
});
});

View File

@ -43,8 +43,8 @@ describe("<PlantPanel/>", () => {
expect(txt).toContain("1 days old");
const x = wrapper.find("input").at(1).props().value;
const y = wrapper.find("input").at(2).props().value;
expect(x).toEqual(10);
expect(y).toEqual(30);
expect(x).toEqual(12);
expect(y).toEqual(34);
});
it("calls destroy", () => {
@ -56,20 +56,26 @@ describe("<PlantPanel/>", () => {
it("renders", () => {
const p = fakeProps();
p.onDestroy = undefined;
p.updatePlant = undefined;
const wrapper = mount(<PlantPanel {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("1 days old");
expect(txt).toContain("(12, 34)");
expect(txt).not.toContain("june");
expect(wrapper.find("button").length).toEqual(4);
});
it("renders in saved garden", () => {
const p = fakeProps();
p.inSavedGarden = true;
const wrapper = mount(<PlantPanel {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("june");
expect(wrapper.find("button").length).toEqual(3);
});
it("enters select mode", () => {
const p = fakeProps();
p.onDestroy = undefined;
p.updatePlant = undefined;
const wrapper = mount(<PlantPanel {...p} />);
clickButton(wrapper, 2, "Delete multiple");
clickButton(wrapper, 3, "Delete multiple");
expect(history.push).toHaveBeenCalledWith("/app/designer/plants/select");
});
@ -77,8 +83,6 @@ describe("<PlantPanel/>", () => {
const p = fakeProps();
const innerDispatch = jest.fn();
p.dispatch = jest.fn(x => x(innerDispatch));
p.onDestroy = undefined;
p.updatePlant = undefined;
const wrapper = mount(<PlantPanel {...p} />);
await clickButton(wrapper, 0, "Move FarmBot to this plant");
expect(history.push).toHaveBeenCalledWith("/app/designer/move_to");
@ -90,13 +94,11 @@ describe("<PlantPanel/>", () => {
});
describe("<EditPlantStatus />", () => {
const fakeProps = (): EditPlantStatusProps => {
return {
uuid: "Plant.0.0",
plantStatus: "planned",
updatePlant: jest.fn(),
};
};
const fakeProps = (): EditPlantStatusProps => ({
uuid: "Plant.0.0",
plantStatus: "planned",
updatePlant: jest.fn(),
});
it("changes stage to planted", () => {
const p = fakeProps();

View File

@ -1,34 +0,0 @@
import * as React from "react";
import { connect } from "react-redux";
import { mapStateToProps, formatPlantInfo } from "./map_state_to_props";
import { PlantInfoBase } from "./plant_info_base";
import { PlantPanel } from "./plant_panel";
import { TaggedPlant } from "../map/interfaces";
import { DesignerPanel, DesignerPanelHeader } from "./designer_panel";
import { t } from "../../i18next_wrapper";
@connect(mapStateToProps)
export class EditPlantInfo extends PlantInfoBase {
default = (plant_info: TaggedPlant) => {
const info = formatPlantInfo(plant_info);
return <DesignerPanel panelName={"plant"} panelColor={"green"}>
<DesignerPanelHeader
panelName={"plant"}
title={`${t("Edit")} ${info.name}`}
panelColor={"green"} />
<PlantPanel
info={info}
onDestroy={this.destroy}
updatePlant={this.updatePlant}
dispatch={this.props.dispatch}
timeSettings={this.props.timeSettings}
inSavedGarden={!!this.props.openedSavedGarden} />
</DesignerPanel>;
}
render() {
const plant_info = this.plant;
return plant_info ? this.default(plant_info) : this.fallback();
}
}

View File

@ -8,6 +8,7 @@ import { history } from "../../history";
import { PlantStage } from "farmbot";
import { TaggedPlant } from "../map/interfaces";
import { isNumber, get } from "lodash";
import { getWebAppConfigValue } from "../../config_storage/actions";
export function mapStateToProps(props: Everything): EditPlantInfoProps {
const openedSavedGarden =
@ -28,6 +29,7 @@ export function mapStateToProps(props: Everything): EditPlantInfoProps {
push: history.push,
dispatch: props.dispatch,
timeSettings: maybeGetTimeSettings(props.resources.index),
getConfigValue: getWebAppConfigValue(() => props),
};
}

View File

@ -1,15 +1,43 @@
import * as React from "react";
import { connect } from "react-redux";
import { mapStateToProps, formatPlantInfo } from "./map_state_to_props";
import { PlantInfoBase } from "./plant_info_base";
import { PlantPanel } from "./plant_panel";
import { unselectPlant } from "../actions";
import { TaggedPlant } from "../map/interfaces";
import { DesignerPanel, DesignerPanelHeader } from "./designer_panel";
import { t } from "../../i18next_wrapper";
import { EditPlantInfoProps, PlantOptions } from "../interfaces";
import { isString, isUndefined } from "lodash";
import { history, getPathArray } from "../../history";
import { destroy, edit, save } from "../../api/crud";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
@connect(mapStateToProps)
export class PlantInfo extends PlantInfoBase {
export class PlantInfo extends React.Component<EditPlantInfoProps, {}> {
get templates() { return isString(this.props.openedSavedGarden); }
get stringyID() { return getPathArray()[this.templates ? 5 : 4] || ""; }
get plant() { return this.props.findPlant(this.stringyID); }
get confirmDelete() {
const confirmSetting = this.props.getConfigValue(
"confirm_plant_deletion" as BooleanConfigKey);
return isUndefined(confirmSetting) ? true : confirmSetting;
}
destroy = (plantUUID: string) => {
this.props.dispatch(destroy(plantUUID, !this.confirmDelete));
}
updatePlant = (plantUUID: string, update: PlantOptions) => {
if (this.plant) {
this.props.dispatch(edit(this.plant, update));
this.props.dispatch(save(plantUUID));
}
}
fallback = () => {
history.push("/app/designer/plants");
return <span>{t("Redirecting...")}</span>;
}
default = (plant_info: TaggedPlant) => {
const info = formatPlantInfo(plant_info);

View File

@ -1,36 +0,0 @@
import * as React from "react";
import { EditPlantInfoProps, PlantOptions } from "../interfaces";
import { history, getPathArray } from "../../history";
import { destroy, edit, save } from "../../api/crud";
import { isString } from "lodash";
import { t } from "../../i18next_wrapper";
export abstract class PlantInfoBase extends
React.Component<EditPlantInfoProps, {}> {
get templates() { return isString(this.props.openedSavedGarden); }
get plantCategory() {
return this.templates ? "saved_gardens/templates" : "plants";
}
get stringyID() { return getPathArray()[this.templates ? 5 : 4] || ""; }
get plant() { return this.props.findPlant(this.stringyID); }
destroy = (plantUUID: string) => {
this.props.dispatch(destroy(plantUUID));
}
updatePlant = (plantUUID: string, update: PlantOptions) => {
if (this.plant) {
this.props.dispatch(edit(this.plant, update));
this.props.dispatch(save(plantUUID));
}
}
fallback = () => {
history.push("/app/designer/plants");
return <span>{t("Redirecting...")}</span>;
}
}

View File

@ -17,8 +17,8 @@ import { TimeSettings } from "../../interfaces";
export interface PlantPanelProps {
info: FormattedPlantInfo;
onDestroy?(uuid: string): void;
updatePlant?(uuid: string, update: PlantOptions): void;
onDestroy(uuid: string): void;
updatePlant(uuid: string, update: PlantOptions): void;
inSavedGarden: boolean;
dispatch: Function;
timeSettings?: TimeSettings;
@ -128,39 +128,35 @@ interface MoveToPlantProps {
x: number;
y: number;
dispatch: Function;
hidden: boolean;
}
const MoveToPlant = (props: MoveToPlantProps) =>
<button className="fb-button gray"
hidden={props.hidden}
<button className="fb-button gray no-float"
style={{ marginTop: "1rem" }}
onClick={() => props.dispatch(chooseLocation({ x: props.x, y: props.y }))
.then(() => history.push("/app/designer/move_to"))}>
{t("Move FarmBot to this plant")}
</button>;
interface DeleteButtonsProps {
hidden: boolean;
destroy(): void;
}
const DeleteButtons = (props: DeleteButtonsProps) =>
<div>
<div>
<label hidden={props.hidden}>
<label>
{t("Delete this plant")}
</label>
</div>
<button
className="fb-button red"
hidden={props.hidden}
className="fb-button red no-float"
onClick={props.destroy}>
{t("Delete")}
</button>
<button
className="fb-button gray"
className="fb-button gray no-float"
style={{ marginRight: "10px" }}
hidden={props.hidden}
onClick={() => history.push("/app/designer/plants/select")} >
{t("Delete multiple")}
</button>
@ -186,10 +182,8 @@ export function PlantPanel(props: PlantPanelProps) {
info, onDestroy, updatePlant, dispatch, inSavedGarden, timeSettings
} = props;
const { slug, plantedAt, daysOld, uuid, plantStatus } = info;
let { x, y } = info;
const isEditing = !!onDestroy;
if (isEditing) { x = round(x); y = round(y); }
const destroy = () => onDestroy && onDestroy(uuid);
const { x, y } = info;
const destroy = () => onDestroy(uuid);
return <DesignerPanelContent panelName={"plants"}>
<label>
{t("Plant Info")}
@ -203,7 +197,7 @@ export function PlantPanel(props: PlantPanelProps) {
</Link>
</ListItem>
<ListItem name={t("Started")}>
{(updatePlant && timeSettings && !inSavedGarden)
{(timeSettings && !inSavedGarden)
? <EditDatePlanted
uuid={uuid}
datePlanted={plantedAt}
@ -215,14 +209,13 @@ export function PlantPanel(props: PlantPanelProps) {
{`${daysOld} ${t("days old")}`}
</ListItem>
<ListItem name={t("Location")}>
{updatePlant
? <EditPlantLocation uuid={uuid}
location={{ x, y }}
updatePlant={updatePlant} />
: `(${x}, ${y})`}
<EditPlantLocation uuid={uuid}
location={{ x, y }}
updatePlant={updatePlant} />
</ListItem>
<MoveToPlant x={x} y={y} dispatch={dispatch} />
<ListItem name={t("Status")}>
{(updatePlant && !inSavedGarden)
{(!inSavedGarden)
? <EditPlantStatus
uuid={uuid}
plantStatus={plantStatus}
@ -230,7 +223,6 @@ export function PlantPanel(props: PlantPanelProps) {
: t(startCase(plantStatus))}
</ListItem>
</ul>
<MoveToPlant x={x} y={y} dispatch={dispatch} hidden={false} />
<DeleteButtons destroy={destroy} hidden={!isEditing} />
<DeleteButtons destroy={destroy} />
</DesignerPanelContent>;
}

View File

@ -10,10 +10,11 @@ import {
import { Row, Col } from "../ui";
import { ToggleButton } from "../controls/toggle_button";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
import { BooleanSetting } from "../session_keys";
import { BooleanSetting, NumericSetting } from "../session_keys";
import { resetVirtualTrail } from "./map/layers/farmbot/bot_trail";
import { MapSizeInputs } from "../account/components/map_size_setting";
import { MapSizeInputs } from "./map_size_setting";
import { DesignerNavTabs } from "./panel_header";
import { isUndefined } from "lodash";
export const mapStateToProps = (props: Everything): DesignerSettingsProps => ({
dispatch: props.dispatch,
@ -30,21 +31,13 @@ export class DesignerSettings
extends React.Component<DesignerSettingsProps, {}> {
render() {
const { getConfigValue, dispatch } = this.props;
const settingsProps = { getConfigValue, dispatch };
return <DesignerPanel panelName={"settings"} panelColor={"gray"}>
<DesignerNavTabs />
<DesignerPanelContent panelName={"settings"}>
{DESIGNER_SETTINGS.map(setting =>
<Setting key={setting.title}
dispatch={this.props.dispatch}
getConfigValue={this.props.getConfigValue}
setting={setting.setting}
title={setting.title}
description={setting.description}
invert={setting.invert}
callback={setting.callback} />)}
<MapSizeInputs
getConfigValue={this.props.getConfigValue}
dispatch={this.props.dispatch} />
{DESIGNER_SETTINGS(settingsProps).map(setting =>
<Setting key={setting.title} {...setting} {...settingsProps} />)}
</DesignerPanelContent>
</DesignerPanel>;
}
@ -56,14 +49,17 @@ interface SettingDescriptionProps {
description: string;
invert?: boolean;
callback?: () => void;
children?: React.ReactChild;
defaultOn?: boolean;
}
interface SettingProps
extends DesignerSettingsProps, SettingDescriptionProps { }
const Setting = (props: SettingProps) => {
const { title, setting, callback } = props;
const value = setting ? !!props.getConfigValue(setting) : undefined;
const { title, setting, callback, defaultOn } = props;
const raw_value = setting ? props.getConfigValue(setting) : undefined;
const value = (defaultOn && isUndefined(raw_value)) ? true : !!raw_value;
return <div className="designer-setting">
<Row>
<Col xs={9}>
@ -83,29 +79,63 @@ const Setting = (props: SettingProps) => {
<Row>
<p>{t(props.description)}</p>
</Row>
{props.children}
</div>;
};
const DESIGNER_SETTINGS: SettingDescriptionProps[] = [
{
title: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
setting: BooleanSetting.disable_animations,
invert: true
},
{
title: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
setting: BooleanSetting.display_trail,
callback: resetVirtualTrail,
},
{
title: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
setting: BooleanSetting.dynamic_map,
},
{
title: t("Map size"),
description: t(Content.MAP_SIZE),
},
];
const DESIGNER_SETTINGS =
(settingsProps: DesignerSettingsProps): SettingDescriptionProps[] => ([
{
title: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
setting: BooleanSetting.disable_animations,
invert: true
},
{
title: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
setting: BooleanSetting.display_trail,
callback: resetVirtualTrail,
},
{
title: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
setting: BooleanSetting.dynamic_map,
},
{
title: t("Map size"),
description: t(Content.MAP_SIZE),
children: <MapSizeInputs {...settingsProps} />
},
{
title: t("Rotate map"),
description: t(Content.MAP_SWAP_XY),
setting: BooleanSetting.xy_swap,
},
{
title: t("Map origin"),
description: t(Content.MAP_ORIGIN),
children: <OriginSelector {...settingsProps} />
},
{
title: t("Confirm plant deletion"),
description: t(Content.CONFIRM_PLANT_DELETION),
setting: "confirm_plant_deletion" as BooleanConfigKey,
defaultOn: true,
},
]);
const OriginSelector = (props: DesignerSettingsProps) => {
const quadrant = props.getConfigValue(NumericSetting.bot_origin_quadrant);
const update = (value: number) => () => props.dispatch(setWebAppConfigValue(
NumericSetting.bot_origin_quadrant, value));
return <div className="farmbot-origin">
<div className="quadrants">
{[2, 1, 3, 4].map(q =>
<div key={"quadrant_" + q}
className={`quadrant ${quadrant === q ? "selected" : ""}`}
onClick={update(q)} />
)}
</div>
</div>;
};

View File

@ -25,6 +25,7 @@ import { getDevice } from "../../device";
import { toggleWebAppBool } from "../../config_storage/actions";
import { destroyAll } from "../../api/crud";
import { success, error } from "farmbot-toastr";
import { BooleanSetting } from "../../session_keys";
describe("<FarmwareConfigMenu />", () => {
const fakeProps = (): FarmwareConfigMenuProps => ({
@ -57,7 +58,8 @@ describe("<FarmwareConfigMenu />", () => {
expect(button.hasClass("green")).toBeTruthy();
expect(button.hasClass("fb-toggle-button")).toBeTruthy();
button.simulate("click");
expect(toggleWebAppBool).toHaveBeenCalledWith("show_first_party_farmware");
expect(toggleWebAppBool).toHaveBeenCalledWith(
BooleanSetting.show_first_party_farmware);
});
it("1st party farmware display is disabled", () => {

View File

@ -7,33 +7,33 @@ import * as React from "react";
import { mount } from "enzyme";
import { FarmwarePage, BasicFarmwarePage } from "../index";
import { FarmwareProps } from "../../devices/interfaces";
import { fakeFarmware, fakeFarmwares } from "../../__test_support__/fake_farmwares";
import {
fakeFarmware, fakeFarmwares
} from "../../__test_support__/fake_farmwares";
import { clickButton } from "../../__test_support__/helpers";
import { Actions } from "../../constants";
import { fakeTimeSettings } from "../../__test_support__/fake_time_settings";
describe("<FarmwarePage />", () => {
const fakeProps = (): FarmwareProps => {
return {
farmwares: fakeFarmwares(),
botToMqttStatus: "up",
env: {},
user_env: {},
dispatch: jest.fn(),
currentImage: undefined,
images: [],
timeSettings: fakeTimeSettings(),
syncStatus: "synced",
getConfigValue: jest.fn(),
firstPartyFarmwareNames: [],
currentFarmware: undefined,
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
taggedFarmwareInstallations: [],
imageJobs: [],
infoOpen: false,
};
};
const fakeProps = (): FarmwareProps => ({
farmwares: fakeFarmwares(),
botToMqttStatus: "up",
env: {},
user_env: {},
dispatch: jest.fn(),
currentImage: undefined,
images: [],
timeSettings: fakeTimeSettings(),
syncStatus: "synced",
getConfigValue: jest.fn(),
firstPartyFarmwareNames: [],
currentFarmware: undefined,
shouldDisplay: () => false,
saveFarmwareEnv: jest.fn(),
taggedFarmwareInstallations: [],
imageJobs: [],
infoOpen: false,
});
it("renders panels", () => {
const wrapper = mount(<FarmwarePage {...fakeProps()} />);
@ -42,8 +42,24 @@ describe("<FarmwarePage />", () => {
});
it("renders photos page by default", () => {
const wrapper = mount(<FarmwarePage {...fakeProps()} />);
const p = fakeProps();
const wrapper = mount(<FarmwarePage {...p} />);
expect(wrapper.text()).toContain("Take Photo");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_FARMWARE,
payload: "Photos"
});
});
it("doesn't render photos page by default", () => {
Object.defineProperty(window, "innerWidth", {
value: 400,
configurable: true
});
const p = fakeProps();
const wrapper = mount(<FarmwarePage {...p} />);
wrapper.mount();
expect(p.dispatch).not.toHaveBeenCalled();
});
it("renders photos page by default without farmware data", () => {

View File

@ -36,7 +36,7 @@ export class CameraCalibration extends
lockOpen={process.env.NODE_ENV !== "production"}>
<button
onClick={this.props.dispatch(calibrate)}
className="fb-button green farmware-button" >
className="fb-button green" >
{t("Calibrate")}
</button>
</MustBeOnline>

View File

@ -1,5 +1,4 @@
import * as React from "react";
import { getDevice } from "../device";
import { FarmwareConfigMenuProps } from "./interfaces";
import { commandErr } from "../devices/actions";
@ -8,6 +7,7 @@ import { destroyAll } from "../api/crud";
import { success, error } from "farmbot-toastr";
import { Feature } from "../devices/interfaces";
import { t } from "../i18next_wrapper";
import { BooleanSetting } from "../session_keys";
/** First-party Farmware settings. */
export function FarmwareConfigMenu(props: FarmwareConfigMenuProps) {
@ -34,8 +34,8 @@ export function FarmwareConfigMenu(props: FarmwareConfigMenuProps) {
</label>
<button
className={"fb-button fb-toggle-button " + listBtnColor}
onClick={() =>
props.dispatch(toggleWebAppBool("show_first_party_farmware"))} />
onClick={() => props.dispatch(
toggleWebAppBool(BooleanSetting.show_first_party_farmware))} />
</fieldset>
{props.shouldDisplay(Feature.api_farmware_env) &&
<fieldset>

View File

@ -52,10 +52,10 @@ const getDocLinkByFarmware =
if (farmwareName) {
switch (urlFriendly(farmwareName).replace("-", "_")) {
case "camera_calibration":
return "farmware#section-camera-calibration";
return "camera-calibration";
case "plant_detection":
case "weed_detector":
return "farmware#section-weed-detector";
return "weed-detection";
}
}
};
@ -124,10 +124,12 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
}
componentWillMount() {
this.props.dispatch({
type: Actions.SELECT_FARMWARE,
payload: "Photos"
});
if (window.innerWidth > 450) {
this.props.dispatch({
type: Actions.SELECT_FARMWARE,
payload: "Photos"
});
}
const farmwareNames = Object.values(this.props.farmwares).map(x => x.name)
.concat(Object.keys(FARMWARE_NAMES_1ST_PARTY));
setActiveFarmwareByName(farmwareNames);

View File

@ -1,4 +1,9 @@
jest.mock("../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../history", () => ({
history: {
push: jest.fn(),
getCurrentLocation: () => ({ pathname: "/app/messages" }),
}
}));
import * as React from "react";
import { tourNames, TOUR_STEPS } from "../tours";
@ -37,7 +42,9 @@ describe("<Tour />", () => {
const steps = [TOUR_STEPS()[tourNames()[0].name][0]];
const wrapper = shallow<Tour>(<Tour steps={steps} />);
wrapper.instance().callback(fakeCallbackData({ type: "tour:end" }));
expect(wrapper.state()).toEqual({ run: false, index: 0 });
expect(wrapper.state()).toEqual({
run: false, index: 0, returnPath: "/app/messages"
});
expect(history.push).toHaveBeenCalledWith("/app/messages");
});
@ -46,7 +53,9 @@ describe("<Tour />", () => {
const wrapper = shallow<Tour>(<Tour steps={steps} />);
wrapper.instance().callback(
fakeCallbackData({ action: "next", type: "step:after" }));
expect(wrapper.state()).toEqual({ run: true, index: 1 });
expect(wrapper.state()).toEqual({
run: true, index: 1, returnPath: "/app/messages"
});
expect(history.push).toHaveBeenCalledWith("/app/tools");
});
@ -55,7 +64,9 @@ describe("<Tour />", () => {
const wrapper = shallow<Tour>(<Tour steps={steps} />);
wrapper.instance().callback(
fakeCallbackData({ action: "prev", index: 9, type: "step:after" }));
expect(wrapper.state()).toEqual({ run: true, index: 8 });
expect(wrapper.state()).toEqual({
run: true, index: 8, returnPath: "/app/messages"
});
expect(history.push).not.toHaveBeenCalled();
});
});

View File

@ -27,10 +27,11 @@ interface TourProps {
interface TourState {
run: boolean;
index: number;
returnPath: string;
}
export class Tour extends React.Component<TourProps, TourState> {
state: TourState = { run: false, index: 0, };
state: TourState = { run: false, index: 0, returnPath: "", };
callback = ({ action, index, step, type }: CallBackProps) => {
console.log("Tour debug:", step.target, type, action);
@ -45,13 +46,17 @@ export class Tour extends React.Component<TourProps, TourState> {
}
if (type === "tour:end") {
this.setState({ run: false });
history.push("/app/messages");
history.push(this.state.returnPath);
store.dispatch({ type: Actions.START_TOUR, payload: undefined });
}
};
componentDidMount() {
this.setState({ run: true, index: 0 });
this.setState({
run: true,
index: 0,
returnPath: history.getCurrentLocation().pathname,
});
}
render() {

View File

@ -45,7 +45,6 @@ export class ActiveEditor
collapsible={true}
collapsed={this.state.variablesCollapsed}
toggleVarShow={this.toggleVarShow}
listVarLabel={t("Defined outside of regimen")}
allowedVariableNodes={AllowedVariableNodes.parameter}
shouldDisplay={this.props.shouldDisplay} />;
}

View File

@ -32,8 +32,8 @@ describe("determineDropdown", () => {
}
}
}, buildResourceIndex([]).index);
expect(r.label).toBe("Defined outside of sequence");
expect(r.value).toBe("parameter_declaration");
expect(r.label).toBe("Externally defined");
expect(r.value).toBe("?");
});
it("Returns a label for `coordinate`", () => {
@ -209,7 +209,9 @@ describe("createSequenceMeta", () => {
describe("determineVarDDILabel()", () => {
it("returns 'add new' variable label", () => {
const ri = buildResourceIndex().index;
const label = determineVarDDILabel("variable", ri, undefined);
const label = determineVarDDILabel({
label: "variable", resources: ri, uuid: undefined
});
expect(label).toEqual("Location Variable - Add new");
});
@ -219,7 +221,9 @@ describe("determineVarDDILabel()", () => {
data && (data.celeryNode = NOTHING_SELECTED);
const ri = buildResourceIndex().index;
ri.sequenceMetas = { "sequence uuid": varData };
const label = determineVarDDILabel("variable", ri, "sequence uuid");
const label = determineVarDDILabel({
label: "variable", resources: ri, uuid: "sequence uuid"
});
expect(label).toEqual("Location Variable - Select a location");
});
@ -236,7 +240,9 @@ describe("determineVarDDILabel()", () => {
});
const ri = buildResourceIndex().index;
ri.sequenceMetas = { "sequence uuid": varData };
const label = determineVarDDILabel("variable", ri, "sequence uuid");
const label = determineVarDDILabel({
label: "variable", resources: ri, uuid: "sequence uuid"
});
expect(label).toEqual("Location Variable - Externally defined");
});
@ -246,7 +252,9 @@ describe("determineVarDDILabel()", () => {
data && (data.celeryNode.kind = "variable_declaration");
const ri = buildResourceIndex().index;
ri.sequenceMetas = { "sequence uuid": varData };
const label = determineVarDDILabel("variable", ri, "sequence uuid");
const label = determineVarDDILabel({
label: "variable", resources: ri, uuid: "sequence uuid"
});
expect(label).toEqual("Location Variable - variable");
});
});

View File

@ -30,6 +30,7 @@ import { joinKindAndId } from "./reducer_support";
import { chain } from "lodash";
import { getWebAppConfig } from "./getters";
import { TimeSettings } from "../interfaces";
import { BooleanSetting } from "../session_keys";
export * from "./selectors_by_id";
export * from "./selectors_by_kind";
@ -195,7 +196,7 @@ export function maybeGetTimeOffset(index: ResourceIndex): number {
/** Return 12/24hr time format preference if possible. If not, use 12hr. */
export function maybeGet24HourTimeSetting(index: ResourceIndex): boolean {
const conf = getWebAppConfig(index);
return conf ? conf.body["time_format_24_hour"] : false;
return conf ? conf.body[BooleanSetting.time_format_24_hour] : false;
}
export function maybeGetTimeSettings(index: ResourceIndex): TimeSettings {

View File

@ -57,8 +57,17 @@ const maybeFindVariable = (
const withPrefix = (label: string) => `${t("Location Variable")} - ${label}`;
interface DetermineVarDDILabelProps {
label: string;
resources: ResourceIndex;
uuid?: UUID;
forceExternal?: boolean;
}
export const determineVarDDILabel =
(label: string, resources: ResourceIndex, uuid?: UUID): string => {
({ label, resources, uuid, forceExternal }: DetermineVarDDILabelProps):
string => {
if (forceExternal) { return t("Externally defined"); }
const variable = maybeFindVariable(label, resources, uuid);
if (variable) {
if (variable.celeryNode.kind === "parameter_declaration") {
@ -78,8 +87,8 @@ export const determineDropdown =
(node: VariableNode, resources: ResourceIndex, uuid?: UUID): DropDownItem => {
if (node.kind === "parameter_declaration") {
return {
label: t("Defined outside of sequence"),
value: "parameter_declaration"
label: t("Externally defined"),
value: "?"
};
}
@ -90,7 +99,7 @@ export const determineDropdown =
return { label: `Coordinate (${x}, ${y}, ${z})`, value: "?" };
case "identifier":
const { label } = data_value.args;
const varName = determineVarDDILabel(label, resources, uuid);
const varName = determineVarDDILabel({ label, resources, uuid });
return { label: varName, value: "?" };
// tslint:disable-next-line:no-any
case "every_point" as any:

View File

@ -248,14 +248,6 @@ export const UNBOUND_ROUTES = [
getChild: () => import("./farm_designer/plants/crop_info"),
childKey: "CropInfo"
}),
route({
children: true,
$: "/designer/plants/:plant_id/edit",
getModule,
key,
getChild: () => import("./farm_designer/plants/edit_plant_info"),
childKey: "EditPlantInfo"
}),
route({
children: true,
$: "/designer/plants/:plant_id",
@ -280,14 +272,6 @@ export const UNBOUND_ROUTES = [
getChild: () => import("./farm_designer/plants/plant_inventory"),
childKey: "Plants"
}),
route({
children: true,
$: "/designer/saved_gardens/templates/:plant_template_id/edit",
getModule,
key,
getChild: () => import("./farm_designer/plants/edit_plant_info"),
childKey: "EditPlantInfo"
}),
route({
children: true,
$: "/designer/saved_gardens/templates/:plant_template_id",

View File

@ -76,6 +76,7 @@ describe("<LocationForm/>", () => {
it("shows parent in dropdown", () => {
const p = fakeProps();
p.allowedVariableNodes = AllowedVariableNodes.identifier;
p.shouldDisplay = () => true;
const wrapper = shallow(<LocationForm {...p} />);
expect(wrapper.find(FBSelect).first().props().list)
@ -92,19 +93,18 @@ describe("<LocationForm/>", () => {
it("shows correct variable label", () => {
const p = fakeProps();
p.shouldDisplay = () => true;
p.variable.dropdown.label = "not shown";
p.variable.dropdown.value = "parameter_declaration";
p.listVarLabel = "Variable Label";
p.variable.dropdown.label = "Externally defined";
const wrapper = shallow(<LocationForm {...p} />);
expect(wrapper.find(FBSelect).props().selectedItem).toEqual({
label: "Variable Label", value: "parameter_declaration"
label: "Externally defined", value: 0
});
expect(wrapper.find(FBSelect).first().props().list)
.toEqual(expect.arrayContaining([PARENT(p.listVarLabel)]));
.toEqual(expect.arrayContaining([PARENT("Externally defined")]));
});
it("shows add new variable option", () => {
const p = fakeProps();
p.allowedVariableNodes = AllowedVariableNodes.identifier;
p.shouldDisplay = () => true;
p.variable.dropdown.isNull = true;
const wrapper = shallow(<LocationForm {...p} />);

View File

@ -113,8 +113,8 @@ describe("getRegimenVariableData()", () => {
parent1: {
celeryNode: paramDeclaration,
dropdown: {
label: "Defined outside of sequence",
value: "parameter_declaration"
label: "Externally defined",
value: "?"
},
vector: undefined
},

View File

@ -60,7 +60,6 @@ export const LocalsList = (props: LocalsListProps) => {
collapsible={props.collapsible}
collapsed={props.collapsed}
toggleVarShow={props.toggleVarShow}
listVarLabel={props.listVarLabel}
onChange={props.onChange} />)}
</div>;
};

View File

@ -55,8 +55,6 @@ interface CommonProps {
collapsible?: boolean;
collapsed?: boolean;
toggleVarShow?: () => void;
/** Label to display for variable option in dropdown. */
listVarLabel?: string;
}
export interface LocalsListProps extends CommonProps {

View File

@ -1,5 +1,5 @@
import * as React from "react";
import { Row, Col, FBSelect, DropDownItem } from "../../ui";
import { Row, Col, FBSelect } from "../../ui";
import { locationFormList, NO_VALUE_SELECTED_DDI } from "./location_form_list";
import { convertDDItoVariable } from "../locals_list/handle_select";
import {
@ -38,14 +38,6 @@ const maybeUseStepData = ({ resources, bodyVariables, variable, uuid }: {
return variable;
};
/** Determine DropdownItem for the current LocationForm selection. */
const selectedLabelDDI = (ddi: DropDownItem, override?: string) => {
const newDDI = Object.assign({}, ddi);
newDDI.label = (ddi.value === "parameter_declaration" && override)
? override : newDDI.label;
return newDDI;
};
/**
* Form with an "import from" dropdown and coordinate input boxes.
* Can be used to set a specific value, import a value, or declare a variable.
@ -59,8 +51,10 @@ export const LocationForm =
});
const displayVariables = props.shouldDisplay(Feature.variables) &&
allowedVariableNodes !== AllowedVariableNodes.variable;
const variableListItems = displayVariables ? [PARENT(props.listVarLabel ||
determineVarDDILabel("parent", resources, sequenceUuid))] : [];
const headerForm = allowedVariableNodes === AllowedVariableNodes.parameter;
const variableListItems = displayVariables ? [PARENT(determineVarDDILabel({
label: "parent", resources, uuid: sequenceUuid, forceExternal: headerForm
}))] : [];
const displayGroups = props.shouldDisplay(Feature.loops) && !disallowGroups;
const list = locationFormList(resources, variableListItems, displayGroups);
/** Variable name. */
@ -82,7 +76,7 @@ export const LocationForm =
<FBSelect
key={props.locationDropdownKey}
list={list}
selectedItem={selectedLabelDDI(dropdown, props.listVarLabel)}
selectedItem={dropdown}
customNullLabel={NO_VALUE_SELECTED_DDI().label}
onChange={ddi => props.onChange(convertDDItoVariable({
label, allowedVariableNodes

View File

@ -90,7 +90,7 @@ export const SequenceSettingsMenu =
label={t("Show pins")}
description={Content.SHOW_PINS} />
<Setting {...commonProps}
setting={"expand_step_options"}
setting={BooleanSetting.expand_step_options}
label={t("Open options by default")}
description={Content.EXPAND_STEP_OPTIONS} />
</div>;
@ -184,7 +184,6 @@ const SequenceHeader = (props: SequenceHeaderProps) => {
collapsible={true}
collapsed={props.variablesCollapsed}
toggleVarShow={props.toggleVarShow}
listVarLabel={t("Defined outside of sequence")}
shouldDisplay={props.shouldDisplay} />
</div>;
};
@ -222,7 +221,7 @@ export class SequenceEditorMiddleActive extends
shouldDisplay: this.props.shouldDisplay,
confirmStepDeletion: !!getConfig(BooleanSetting.confirm_step_deletion),
showPins: !!getConfig(BooleanSetting.show_pins),
expandStepOptions: !!getConfig("expand_step_options"),
expandStepOptions: !!getConfig(BooleanSetting.expand_step_options),
};
}

View File

@ -4,57 +4,69 @@ import {
} from "farmbot/dist/resources/configs/web_app";
export const BooleanSetting: Record<BooleanConfigKey, BooleanConfigKey> = {
/** Move settings */
x_axis_inverted: "x_axis_inverted",
y_axis_inverted: "y_axis_inverted",
z_axis_inverted: "z_axis_inverted",
raw_encoders: "raw_encoders",
scaled_encoders: "scaled_encoders",
raw_encoders: "raw_encoders",
home_button_homing: "home_button_homing",
show_motor_plot: "show_motor_plot",
/** Designer settings */
legend_menu_open: "legend_menu_open",
show_plants: "show_plants",
show_points: "show_points",
show_historic_points: "show_historic_points",
show_spread: "show_spread",
show_farmbot: "show_farmbot",
show_images: "show_images",
show_sensor_readings: "show_sensor_readings",
xy_swap: "xy_swap",
home_button_homing: "home_button_homing",
show_motor_plot: "show_motor_plot",
show_historic_points: "show_historic_points",
time_format_24_hour: "time_format_24_hour",
show_pins: "show_pins",
disable_emergency_unlock_confirmation: "disable_emergency_unlock_confirmation",
expand_step_options: "expand_step_options",
/** "Labs" feature names. (App preferences) */
stub_config: "stub_config",
disable_i18n: "disable_i18n",
confirm_step_deletion: "confirm_step_deletion",
hide_webcam_widget: "hide_webcam_widget",
hide_sensors: "hide_sensors",
dynamic_map: "dynamic_map",
map_xl: "map_xl",
disable_animations: "disable_animations",
display_trail: "display_trail",
encoder_figure: "encoder_figure",
dynamic_map: "dynamic_map",
xy_swap: "xy_swap",
confirm_plant_deletion: "confirm_plant_deletion",
/** Sequence settings */
confirm_step_deletion: "confirm_step_deletion",
show_pins: "show_pins",
expand_step_options: "expand_step_options",
/** App settings */
disable_i18n: "disable_i18n",
hide_webcam_widget: "hide_webcam_widget",
hide_sensors: "hide_sensors",
enable_browser_speak: "enable_browser_speak",
discard_unsaved: "discard_unsaved",
time_format_24_hour: "time_format_24_hour",
disable_emergency_unlock_confirmation: "disable_emergency_unlock_confirmation",
/** Farmware Settings Panel */
/** Farmware settings */
show_first_party_farmware: "show_first_party_farmware",
/** Other */
stub_config: "stub_config",
};
export const NumericSetting: Record<NumberConfigKey, NumberConfigKey> = {
bot_origin_quadrant: "bot_origin_quadrant",
busy_log: "busy_log",
debug_log: "debug_log",
device_id: "device_id",
error_log: "error_log",
fun_log: "fun_log",
id: "id",
info_log: "info_log",
/** Logs settings */
success_log: "success_log",
busy_log: "busy_log",
warn_log: "warn_log",
error_log: "error_log",
info_log: "info_log",
fun_log: "fun_log",
debug_log: "debug_log",
/** Designer settings */
zoom_level: "zoom_level",
map_size_x: "map_size_x",
map_size_y: "map_size_y",
bot_origin_quadrant: "bot_origin_quadrant",
/** Other */
id: "id",
device_id: "device_id",
};

View File

@ -3,8 +3,8 @@ export const BASE_URL = "https://software.farm.bot/docs/";
/** A centralized list of all documentation slugs in the app makes it easier to
* rename / move links in the future. */
export const DOC_SLUGS = {
"farmware#section-weed-detector": "Weed Detector",
"farmware#section-camera-calibration": "Camera Calibration",
"weed-detection": "Weed Detector",
"camera-calibration": "Camera Calibration",
"the-farmbot-web-app": "Web App",
"farmware": "Farmware",
};

View File

@ -43,7 +43,7 @@
"coveralls": "3.0.4",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.14.0",
"farmbot": "8.0.1-rc5",
"farmbot": "8.0.1-rc7",
"farmbot-toastr": "1.0.3",
"i18next": "17.0.3",
"jest": "24.8.0",

View File

@ -21,7 +21,6 @@ describe Api::WebAppConfigsController do
encoder_figure: false,
hide_webcam_widget: false,
legend_menu_open: false,
map_xl: false,
raw_encoders: false,
scaled_encoders: false,
show_spread: true,

View File

@ -61,10 +61,6 @@ describe Api::DevicesController do
device.name
end
def settings_map_xl?(device)
device.web_app_config.map_xl
end
def settings_hide_sensors?(device)
device.web_app_config.hide_sensors
end
@ -227,7 +223,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Genesis")
expect(settings_enable_encoders?(device)).to be(true)
expect(settings_firmware?(device)).to eq("arduino")
expect(settings_map_xl?(device)).to be(false)
expect(settings_hide_sensors?(device)).to be(false)
expect(tool_slots_slot_1?(device).name).to eq("Seeder")
expect(tool_slots_slot_2?(device).name).to eq("Seed Bin")
@ -278,7 +273,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Genesis")
expect(settings_enable_encoders?(device)).to be(true)
expect(settings_firmware?(device)).to eq("farmduino")
expect(settings_map_xl?(device)).to be(false)
expect(settings_hide_sensors?(device)).to be(false)
expect(tool_slots_slot_1?(device).name).to eq("Seeder")
expect(tool_slots_slot_2?(device).name).to eq("Seed Bin")
@ -330,7 +324,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Genesis")
expect(settings_enable_encoders?(device)).to be(true)
expect(settings_firmware?(device)).to eq("farmduino_k14")
expect(settings_map_xl?(device)).to be(false)
expect(settings_hide_sensors?(device)).to be(false)
expect(tool_slots_slot_1?(device).name).to eq("Seeder")
expect(tool_slots_slot_2?(device).name).to eq("Seed Bin")
@ -375,7 +368,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Genesis XL")
expect(settings_enable_encoders?(device)).to be(true)
expect(settings_firmware?(device)).to eq("farmduino_k14")
expect(settings_map_xl?(device)).to be(true)
expect(settings_hide_sensors?(device)).to be(false)
expect(tool_slots_slot_1?(device).name).to eq("Seeder")
expect(tool_slots_slot_2?(device).name).to eq("Seed Bin")
@ -428,7 +420,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Express")
expect(settings_enable_encoders?(device)).to be(false)
expect(settings_firmware?(device)).to eq("express_k10")
expect(settings_map_xl?(device)).to be(false)
expect(settings_hide_sensors?(device)).to be(true)
expect(tool_slots_slot_1?(device).name).to eq("Seed Trough 1")
expect(tool_slots_slot_2?(device).name).to eq("Seed Trough 2")
@ -476,7 +467,6 @@ describe Api::DevicesController do
expect(settings_device_name?(device)).to eq("FarmBot Express XL")
expect(settings_enable_encoders?(device)).to be(false)
expect(settings_firmware?(device)).to eq("express_k10")
expect(settings_map_xl?(device)).to be(false)
expect(settings_hide_sensors?(device)).to be(true)
expect(tool_slots_slot_1?(device).name).to eq("Seed Trough 1")
expect(tool_slots_slot_2?(device).name).to eq("Seed Trough 2")

View File

@ -1,51 +1,72 @@
require 'spec_helper'
require "spec_helper"
HAS_POINTS = JSON.parse(File.read("spec/lib/celery_script/ast_has_points.json"))
describe Api::SequencesController do
before :each do
request.headers["accept"] = 'application/json'
request.headers["accept"] = "application/json"
end
include Devise::Test::ControllerHelpers
describe '#create' do
describe "#create" do
let(:user) { FactoryBot.create(:user) }
let(:nodes) { sequence_body_for(user) }
it 'handles a well formed AST in the body attribute' do
it "provides human readable errors for empty write_pin nodes" do
sign_in user
body = [
{
kind: "write_pin",
args: {
pin_number: { kind: "nothing", args: {} },
pin_value: 0,
pin_mode: 0,
},
},
]
input = { name: "Scare Birds", body: body }
sequence_body_for(user)
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
err = json.fetch(:body)
expected = "You must select a Peripheral in the Control Peripheral step."
expect(err).to eq(expected)
end
it "handles a well formed AST in the body attribute" do
sign_in user
input = { name: "Scare Birds",
body: nodes }
sequence_body_for(user)
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(200)
expect(json[:args]).to be_kind_of(Hash)
expect(json[:body]).to be_kind_of(Array)
expect(json[:body].length).to eq(nodes.length)
end
it 'disregards extra attrs (like `uuid`) on sequence body nodes' do
it "disregards extra attrs (like `uuid`) on sequence body nodes" do
sign_in user
input = { name: "Scare Birds",
body: nodes }
input[:body].first[:uuid] = SecureRandom.uuid
input[:body].first["uuid"] = SecureRandom.uuid
sequence_body_for(user)
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(200)
expect(json[:args]).to be_kind_of(Hash)
expect(json[:body]).to be_kind_of(Array)
expect(json[:body].length).to eq(nodes.length)
end
it 'creates a new sequences for a user' do
it "creates a new sequences for a user" do
sign_in user
input = { name: "Scare Birds", body: [] }
post :create, body: input.to_json, format: :json
expect(response.status).to eq(200)
end
it 'handles invalid params' do
it "handles invalid params" do
# Needed to test the `else` branch of mutate() somewhere
sign_in user
input = {}
@ -55,32 +76,31 @@ describe Api::SequencesController do
expect(json[:name]).to eq("Name is required")
end
it 'doesnt allow nonsense in `sequence.args.locals`' do
it "doesnt allow nonsense in `sequence.args.locals`" do
PinBinding.destroy_all
Sequence.destroy_all
input = { name: "Scare Birds",
body: [],
# Intentional nonsense to check validation logic.
args: { locals: { kind: "wait", args: { milliseconds: 5000 } } }
}
args: { locals: { kind: "wait", args: { milliseconds: 5000 } } } }
sign_in user
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(Sequence.last).to_not be
xpectd = "Expected leaf 'wait' within 'sequence' to be one of: "\
xpectd = "Expected leaf 'wait' within 'sequence' to be one of: " \
"[\"scope_declaration\"] but got wait"
expect(json.fetch(:body)).to eq(xpectd)
end
it 'strips excess `args`' do
it "strips excess `args`" do
input = { name: "Scare Birds",
body: [],
# Intentional nonsense to check validation logic.
args: { foo: "BAR" } }
sign_in user
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(200)
expect(json[:args][:foo]).to eq(nil)
generated_result = CeleryScript::FetchCelery
@ -89,7 +109,7 @@ describe Api::SequencesController do
expect(generated_result.dig(:args, :foo)).to eq(nil)
end
it 'disallows bad default_values' do
it "disallows bad default_values" do
input = {
name: "Scare Birds",
body: [],
@ -105,24 +125,24 @@ describe Api::SequencesController do
label: "parent",
default_value: {
kind: "wait",
args: { milliseconds: 12 }
}
}
}
]
}
}
args: { milliseconds: 12 },
},
},
},
],
},
},
}
sign_in user
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json.fetch(:body)).to include('"tool"')
expect(json[:body]).to include("Expected leaf 'wait' within "\
expect(json[:body]).to include("Expected leaf 'wait' within " \
"'parameter_declaration' to be one of: [")
end
it 'disallows erroneous `locals` declaration' do
it "disallows erroneous `locals` declaration" do
input = {
name: "Scare Birds",
body: [],
@ -132,21 +152,21 @@ describe Api::SequencesController do
kind: "scope_declaration",
args: {},
body: [
{ kind: "wait", args: { milliseconds: 5000 } }
]
}
}
{ kind: "wait", args: { milliseconds: 5000 } },
],
},
},
}
sign_in user
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expctd =
"Expected one of: [:variable_declaration, :parameter_declaration]"
expect(json[:body]).to include(expctd)
end
it 'allows declaration of a variable named `parent`' do
it "allows declaration of a variable named `parent`" do
input = {
name: "Scare Birds",
args: {
@ -163,10 +183,10 @@ describe Api::SequencesController do
args: {
x: 0,
y: 0,
z: 0
}
}
}
z: 0,
},
},
},
},
{
kind: "variable_declaration",
@ -174,12 +194,12 @@ describe Api::SequencesController do
label: "parent2",
data_value: {
kind: "coordinate",
args: { x: 9, y: 9, z: 9, }
}
}
}
]
}
args: { x: 9, y: 9, z: 9 },
},
},
},
],
},
},
body: [
{
@ -187,30 +207,34 @@ describe Api::SequencesController do
args: {
location: {
kind: "identifier",
args: { label: "parent" } },
offset: {
args: { label: "parent" },
},
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 } },
speed: 100,
}
args: { x: 0, y: 0, z: 0 },
},
speed: 100,
},
},
{
kind: "move_absolute",
args: {
location: {
kind: "identifier",
args: { label: "parent2" } },
offset: {
args: { label: "parent2" },
},
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 } },
speed: 100,
}
}
]
args: { x: 0, y: 0, z: 0 },
},
speed: 100,
},
},
],
}
sign_in user
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(200)
dig_path = [:args, :locals, :body, 0, :args, :label]
generated_result = CeleryScript::FetchCelery
@ -220,7 +244,7 @@ describe Api::SequencesController do
expect(json.dig(*dig_path)).to eq("parent")
end
it 'tracks Points' do
it "tracks Points" do
point = FactoryBot.create(:generic_pointer, device: user.device)
PinBinding.destroy_all
Sequence.destroy_all
@ -231,14 +255,14 @@ describe Api::SequencesController do
sign_in user
input = { name: "Scare Birds", body: HAS_POINTS["body"] }
sequence_body_for(user)
before = EdgeNode.where(kind: "pointer_id").count
post :create, body: input.to_json, params: {format: :json}
before = EdgeNode.where(kind: "pointer_id").count
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(200)
now = EdgeNode.where(kind: "pointer_id").count
expect(now).to be > before
end
it 'prevents unbound variables' do
it "prevents unbound variables" do
sign_in user
input = {
name: "Unbound Variable Exception",
@ -249,23 +273,23 @@ describe Api::SequencesController do
args: {
location: {
kind: "identifier",
args: { label: "parent" }
args: { label: "parent" },
},
offset: {
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 }
args: { x: 0, y: 0, z: 0 },
},
speed: 100,
}
}
]
speed: 100,
},
},
],
}
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json[:body]).to eq("Unbound variable: parent")
end
it 'does not let you use other peoples point resources' do
it "does not let you use other peoples point resources" do
sign_in user
not_yours = FactoryBot.create(:plant)
expect(not_yours.device_id).to_not eq(user.device_id)
@ -277,52 +301,50 @@ describe Api::SequencesController do
kind: "move_absolute",
args: {
location: {
kind: "point",
args: { pointer_type: "Plant", pointer_id: not_yours.id }
},
speed: 100,
offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }
}
}
kind: "point",
args: { pointer_type: "Plant", pointer_id: not_yours.id },
},
speed: 100,
offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } },
},
},
],
}
post :create, body: input.to_json, params: {format: :json}
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json[:body]).to include("Bad point ID")
end
it 'prevents type errors from bad identifier / binding combos' do
it "prevents type errors from bad identifier / binding combos" do
sign_in user
input = { name: "type mismatch",
args: {
locals: {
kind: "scope_declaration",
args: {},
body: [
{
kind: "parameter_declaration",
args: {
label: "parent",
default_value: { kind: "sync", args: {} }
}
}
]
}
},
body: [
{ kind: "move_absolute",
args: {
location: { kind: "identifier", args: { label: "parent" } },
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 }
},
speed: 100,
}
}
]
}
post :create, body: input.to_json, params: {format: :json}
args: {
locals: {
kind: "scope_declaration",
args: {},
body: [
{
kind: "parameter_declaration",
args: {
label: "parent",
default_value: { kind: "sync", args: {} },
},
},
],
},
},
body: [
{ kind: "move_absolute",
args: {
location: { kind: "identifier", args: { label: "parent" } },
offset: {
kind: "coordinate",
args: { x: 0, y: 0, z: 0 },
},
speed: 100,
} },
] }
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json.fetch(:body)).to include('"point"')
expect(json[:body]).to include("but got sync")
@ -331,27 +353,26 @@ describe Api::SequencesController do
it 'provides human readable errors for "nothing" mismatches' do
sign_in user
input = { name: "type mismatch",
args: {
locals: {
kind: "scope_declaration",
args: { },
body: [
{
kind: "parameter_declaration",
args: {
label: "x",
default_value: {
kind: "nothing",
args: {}
}
}
}
]
}
args: {
locals: {
kind: "scope_declaration",
args: {},
body: [
{
kind: "parameter_declaration",
args: {
label: "x",
default_value: {
kind: "nothing",
args: {},
},
body: [ ]
}
post :create, body: input.to_json, params: {format: :json}
},
},
],
},
},
body: [] }
post :create, body: input.to_json, params: { format: :json }
expect(response.status).to eq(422)
expect(json[:body]).to include("must provide a value for all parameters")
end