Merge conflicts

This commit is contained in:
Rick Carlino 2018-09-06 09:07:11 -05:00
commit 94edf72fb5
81 changed files with 688 additions and 453 deletions

View file

@ -32,6 +32,7 @@ before_script:
- bundle exec rake db:create db:migrate
script:
- bundle exec rspec --fail-fast=3
- npm run tslint
- npm run typecheck
- npm run test-slow
- npm run coverage

View file

@ -0,0 +1,9 @@
class AddShowMotorPlotToWebAppConfigs < ActiveRecord::Migration[5.2]
safety_assured
def change
add_column :web_app_configs,
:show_motor_plot,
:boolean,
default: false
end
end

View file

@ -30,6 +30,7 @@ const fakeProps = (): AppProps => {
firmwareConfig: undefined,
xySwap: false,
animate: false,
getConfigValue: jest.fn(),
};
};

View file

@ -8,14 +8,7 @@ jest.mock("axios", () => ({
}
}));
jest.mock("../session", () => {
return {
Session: {
clear: jest.fn(),
deprecatedGetBool: jest.fn(),
}
};
});
jest.mock("../session", () => ({ Session: { clear: jest.fn(), } }));
import { maybeRefreshToken } from "../refresh_token";
import { API } from "../api/index";

View file

@ -6,8 +6,6 @@ let mockAuth: AuthState | undefined = undefined;
jest.mock("../session", () => ({
Session: {
fetchStoredToken: jest.fn(() => mockAuth),
deprecatedGetNum: () => undefined,
deprecatedGetBool: () => undefined,
getAll: () => undefined,
clear: jest.fn()
}

View file

@ -1,24 +1,3 @@
import { fakeWebAppConfig } from "../__test_support__/fake_state/resources";
import { fakeState } from "../__test_support__/fake_state";
const mockConfig = fakeWebAppConfig();
jest.mock("../resources/selectors_by_kind", () => ({
getWebAppConfig: () => mockConfig
}));
jest.mock("../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
const mockState = fakeState();
jest.mock("../redux/store", () => ({
store: {
dispatch: jest.fn(),
getState: () => mockState,
}
}));
import {
isNumericSetting,
isBooleanSetting,
@ -27,7 +6,6 @@ import {
Session,
} from "../session";
import { auth } from "../__test_support__/fake_state/token";
import { edit, save } from "../api/crud";
describe("fetchStoredToken", () => {
it("can't fetch token", () => {
@ -61,41 +39,6 @@ describe("safeBooleanSetting", () => {
});
});
describe("deprecatedGetNum", () => {
it("gets number", () => {
const result = Session.deprecatedGetNum("success_log");
expect(result).toEqual(3);
});
});
describe("deprecatedSetNum", () => {
it("sets number", () => {
Session.deprecatedSetNum("success_log", 0);
expect(edit).toHaveBeenCalledWith(expect.any(Object), { success_log: 0 });
expect(save).toHaveBeenCalledWith(mockConfig.uuid);
});
});
describe("setBool", () => {
it("sets bool", () => {
Session.setBool("x_axis_inverted", false);
expect(edit).toHaveBeenCalledWith(expect.any(Object), {
x_axis_inverted: false
});
expect(save).toHaveBeenCalledWith(mockConfig.uuid);
});
});
describe("invertBool", () => {
it("inverts bool", () => {
Session.invertBool("x_axis_inverted");
expect(edit).toHaveBeenCalledWith(expect.any(Object), {
x_axis_inverted: true
});
expect(save).toHaveBeenCalledWith(mockConfig.uuid);
});
});
describe("safeNumericSetting", () => {
it("safely returns num", () => {
expect(() => safeNumericSetting("no")).toThrow();

View file

@ -79,7 +79,9 @@ export class Account extends React.Component<Props, State> {
<ChangePassword />
</Row>
<Row>
<LabsFeatures />
<LabsFeatures
dispatch={this.props.dispatch}
getConfigValue={this.props.getConfigValue} />
</Row>
<Row>
<DeleteAccount onClick={deleteAcct} />

View file

@ -1,9 +1,11 @@
import { User } from "../auth/interfaces";
import { TaggedUser } from "farmbot";
import { GetWebAppConfigValue } from "../config_storage/actions";
export interface Props {
user: TaggedUser;
dispatch: Function;
getConfigValue: GetWebAppConfigValue;
}
/** JSON form that gets POSTed to the API when user updates their info. */

View file

@ -3,7 +3,7 @@ import { fetchLabFeatures } from "../labs_features_list_data";
describe("fetchLabFeatures", () => {
window.location.reload = jest.fn();
it("basically just initializes stuff", () => {
const val = fetchLabFeatures();
const val = fetchLabFeatures(jest.fn());
expect(val.length).toBe(9);
expect(val[0].value).toBeFalsy();
const { callback } = val[0];

View file

@ -1,20 +1,5 @@
const mockStorj: Dictionary<boolean> = {};
jest.mock("../../../session", () => {
return {
Session: {
deprecatedGetBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
},
invertBool: (k: string) => {
mockStorj[k] = !mockStorj[k];
return mockStorj[k];
}
}
};
});
import { Dictionary } from "farmbot";
import { maybeToggleFeature, LabsFeature } from "../labs_features_list_data";
import { BooleanSetting } from "../../../session_keys";
@ -29,7 +14,7 @@ describe("maybeToggleFeature()", () => {
storageKey: BooleanSetting.stub_config,
confirmationMessage: "are you sure?"
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
expect(data.value).toBeFalsy();
expect(out).toBeUndefined();
expect(window.confirm).toHaveBeenCalledWith(data.confirmationMessage);
@ -44,7 +29,7 @@ describe("maybeToggleFeature()", () => {
storageKey: BooleanSetting.stub_config,
confirmationMessage: "are you sure?"
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
out ?
expect(out.value).toBeTruthy() : fail("out === undefined. Thats bad");
expect(out).toBeTruthy();
@ -52,7 +37,7 @@ describe("maybeToggleFeature()", () => {
it("Does not require consent when going from true to false", () => {
window.confirm = jest.fn(() => true);
const output = maybeToggleFeature({
const output = maybeToggleFeature(x => mockStorj[x], jest.fn())({
name: "Example",
value: (mockStorj[BooleanSetting.stub_config] = true),
description: "I stub this.",
@ -71,7 +56,7 @@ describe("maybeToggleFeature()", () => {
description: "I stub this.",
storageKey: BooleanSetting.stub_config
};
const out = maybeToggleFeature(data);
const out = maybeToggleFeature(x => mockStorj[x], jest.fn())(data);
out ?
expect(out.value).toBeTruthy() : fail("out === undefined. Thats bad");
expect(out).toBeTruthy();

View file

@ -9,7 +9,7 @@ const mockFeatures = [
];
const mocks = {
"maybeToggleFeature": jest.fn(),
"maybeToggleFeature": jest.fn(() => jest.fn()),
"fetchLabFeatures": jest.fn(() => mockFeatures)
};
@ -21,7 +21,9 @@ import { LabsFeatures } from "../labs_features";
describe("<LabsFeatures/>", () => {
it("triggers the correct callback on click", () => {
const el = mount(<LabsFeatures />);
const el = mount(<LabsFeatures
dispatch={jest.fn()}
getConfigValue={jest.fn()} />);
expect(mocks.fetchLabFeatures.mock.calls.length).toBeGreaterThan(0);
el.find("button").simulate("click");
expect(mockFeatures[0].callback).toHaveBeenCalled();

View file

@ -4,20 +4,29 @@ import { LabsFeaturesList } from "./labs_features_list_ui";
import { maybeToggleFeature } from "./labs_features_list_data";
import { t } from "i18next";
import { ToolTips } from "../../constants";
import { GetWebAppConfigValue } from "../../config_storage/actions";
export class LabsFeatures extends React.Component<{}, {}> {
interface LabsFeaturesProps {
getConfigValue: GetWebAppConfigValue;
dispatch: Function;
}
export class LabsFeatures extends React.Component<LabsFeaturesProps, {}> {
state = {};
render() {
const { getConfigValue, dispatch } = this.props;
return <Widget className="peripherals-widget">
<WidgetHeader title={t("App Settings")}
helpText={ToolTips.LABS}>
</WidgetHeader>
<WidgetBody>
<LabsFeaturesList onToggle={(x) => {
maybeToggleFeature(x);
this.forceUpdate();
}} />
<LabsFeaturesList
getConfigValue={getConfigValue}
onToggle={x => {
maybeToggleFeature(getConfigValue, dispatch)(x);
this.forceUpdate();
}} />
</WidgetBody>
</Widget>;
}

View file

@ -1,8 +1,9 @@
import { Session } from "../../session";
import { t } from "i18next";
import { BooleanConfigKey } from "../../config_storage/web_app_configs";
import { BooleanSetting } from "../../session_keys";
import { Content } from "../../constants";
import { VirtualTrail } from "../../farm_designer/map/virtual_farmbot/bot_trail";
import { GetWebAppConfigValue, setWebAppConfigValue } from "../../config_storage/actions";
export interface LabsFeature {
/** Toggle label. */
@ -21,88 +22,91 @@ export interface LabsFeature {
callback?(): void;
}
export const fetchLabFeatures = (): LabsFeature[] => ([
{
name: t("Internationalize Web App"),
description: t("Turn off to set Web App to English."),
storageKey: BooleanSetting.disable_i18n,
value: false,
displayInvert: true,
callback: () => window.location.reload()
},
{
name: t("Confirm Sequence step deletion"),
description: t(Content.CONFIRM_STEP_DELETION),
storageKey: BooleanSetting.confirm_step_deletion,
value: false
},
{
name: t("Hide Webcam widget"),
description: t(Content.HIDE_WEBCAM_WIDGET),
storageKey: BooleanSetting.hide_webcam_widget,
value: false
},
{
name: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
storageKey: BooleanSetting.dynamic_map,
value: false
},
{
name: t("Double default map dimensions"),
description: t(Content.DOUBLE_MAP_DIMENSIONS),
storageKey: BooleanSetting.map_xl,
value: false
},
{
name: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
storageKey: BooleanSetting.disable_animations,
value: false,
displayInvert: true
},
{
name: t("Read speak logs in browser"),
description: t(Content.BROWSER_SPEAK_LOGS),
storageKey: BooleanSetting.enable_browser_speak,
value: false
},
{
name: t("Discard Unsaved Changes"),
description: t(Content.DISCARD_UNSAVED_CHANGES),
storageKey: BooleanSetting.discard_unsaved,
value: false,
confirmationMessage: t(Content.DISCARD_UNSAVED_CHANGES_CONFIRM)
},
{
name: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
storageKey: BooleanSetting.display_trail,
value: false,
callback: () => sessionStorage.setItem("virtualTrailRecords", "[]")
},
].map(fetchRealValue));
export const fetchLabFeatures =
(getConfigValue: GetWebAppConfigValue): LabsFeature[] => ([
{
name: t("Internationalize Web App"),
description: t("Turn off to set Web App to English."),
storageKey: BooleanSetting.disable_i18n,
value: false,
displayInvert: true,
callback: () => window.location.reload()
},
{
name: t("Confirm Sequence step deletion"),
description: t(Content.CONFIRM_STEP_DELETION),
storageKey: BooleanSetting.confirm_step_deletion,
value: false
},
{
name: t("Hide Webcam widget"),
description: t(Content.HIDE_WEBCAM_WIDGET),
storageKey: BooleanSetting.hide_webcam_widget,
value: false
},
{
name: t("Dynamic map size"),
description: t(Content.DYNAMIC_MAP_SIZE),
storageKey: BooleanSetting.dynamic_map,
value: false
},
{
name: t("Double default map dimensions"),
description: t(Content.DOUBLE_MAP_DIMENSIONS),
storageKey: BooleanSetting.map_xl,
value: false
},
{
name: t("Display plant animations"),
description: t(Content.PLANT_ANIMATIONS),
storageKey: BooleanSetting.disable_animations,
value: false,
displayInvert: true
},
{
name: t("Read speak logs in browser"),
description: t(Content.BROWSER_SPEAK_LOGS),
storageKey: BooleanSetting.enable_browser_speak,
value: false
},
{
name: t("Discard Unsaved Changes"),
description: t(Content.DISCARD_UNSAVED_CHANGES),
storageKey: BooleanSetting.discard_unsaved,
value: false,
confirmationMessage: t(Content.DISCARD_UNSAVED_CHANGES_CONFIRM)
},
{
name: t("Display virtual FarmBot trail"),
description: t(Content.VIRTUAL_TRAIL),
storageKey: BooleanSetting.display_trail,
value: false,
callback: () => sessionStorage.setItem(VirtualTrail.records, "[]")
},
].map(fetchSettingValue(getConfigValue)));
/** Always allow toggling from true => false (deactivate).
* Require a disclaimer when going from false => true (activate). */
export const maybeToggleFeature =
(x: LabsFeature): LabsFeature | undefined => {
return (x.value
|| !x.confirmationMessage
|| window.confirm(x.confirmationMessage)) ?
toggleFeatureValue(x) : undefined;
};
/** Stub this when testing if need be. */
const fetchVal = (k: BooleanConfigKey) => !!Session.deprecatedGetBool(k);
(getConfigValue: GetWebAppConfigValue, dispatch: Function) =>
(x: LabsFeature): LabsFeature | undefined =>
(x.value
|| !x.confirmationMessage
|| window.confirm(x.confirmationMessage)) ?
toggleFeatureValue(getConfigValue, dispatch)(x) : undefined;
/** Takes a `LabFeature` (probably one with an uninitialized fallback / default
* value) and sets it to the _real_ value that's in the API. */
const fetchRealValue = (x: LabsFeature): LabsFeature => {
return { ...x, value: fetchVal(x.storageKey) };
};
const fetchSettingValue = (getConfigValue: GetWebAppConfigValue) =>
(x: LabsFeature): LabsFeature => {
return { ...x, value: !!getConfigValue(x.storageKey) };
};
/** Toggle the `.value` of a `LabsToggle` object */
const toggleFeatureValue = (x: LabsFeature) => {
return { ...x, value: Session.invertBool(x.storageKey) };
};
const toggleFeatureValue =
(getConfigValue: GetWebAppConfigValue, dispatch: Function) =>
(x: LabsFeature) => {
const value = !getConfigValue(x.storageKey);
dispatch(setWebAppConfigValue(x.storageKey, value));
return { ...x, value };
};

View file

@ -1,14 +1,16 @@
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";
interface LabsFeaturesListProps {
onToggle(feature: LabsFeature): void;
getConfigValue: GetWebAppConfigValue;
}
export function LabsFeaturesList(props: LabsFeaturesListProps) {
return <div>
{fetchLabFeatures().map((p, i) => {
{fetchLabFeatures(props.getConfigValue).map((p, i) => {
const displayValue = p.displayInvert ? !p.value : p.value;
return <KeyValShowRow key={i}
label={p.name}

View file

@ -1,12 +1,14 @@
import { Everything } from "../interfaces";
import { Props } from "./interfaces";
import { getUserAccountSettings } from "../resources/selectors";
import { getWebAppConfigValue } from "../config_storage/actions";
export function mapStateToProps(props: Everything): Props {
const user = getUserAccountSettings(props.resources.index);
return {
user,
dispatch: () => { throw new Error("NEVER SHOULD HAPPEN"); }
dispatch: () => { throw new Error("NEVER SHOULD HAPPEN"); },
getConfigValue: getWebAppConfigValue(() => props),
};
}

View file

@ -22,7 +22,7 @@ import { validBotLocationData, validFwConfig } from "./util";
import { BooleanSetting } from "./session_keys";
import { getPathArray } from "./history";
import { FirmwareConfig } from "./config_storage/firmware_configs";
import { getWebAppConfigValue } from "./config_storage/actions";
import { getWebAppConfigValue, GetWebAppConfigValue } from "./config_storage/actions";
import { takeSortedLogs } from "./logs/state_to_props";
/** Remove 300ms delay on touch devices - https://github.com/ftlabs/fastclick */
@ -44,6 +44,7 @@ export interface AppProps {
xySwap: boolean;
firmwareConfig: FirmwareConfig | undefined;
animate: boolean;
getConfigValue: GetWebAppConfigValue;
}
function mapStateToProps(props: Everything): AppProps {
@ -64,6 +65,7 @@ function mapStateToProps(props: Everything): AppProps {
xySwap: !!webAppConfigValue(BooleanSetting.xy_swap),
firmwareConfig: validFwConfig(getFirmwareConfig(props.resources.index)),
animate: !webAppConfigValue(BooleanSetting.disable_animations),
getConfigValue: webAppConfigValue,
};
}
/** Time at which the app gives up and asks the user to refresh */
@ -111,7 +113,8 @@ export class App extends React.Component<AppProps, {}> {
user={this.props.user}
bot={this.props.bot}
dispatch={this.props.dispatch}
logs={this.props.logs} />
logs={this.props.logs}
getConfigValue={this.props.getConfigValue} />
{!syncLoaded && <LoadingPlant animate={this.props.animate} />}
{syncLoaded && this.props.children}
{!(["controls", "account", "regimens"].includes(currentPage)) &&

View file

@ -19,8 +19,6 @@ jest.mock("axios", () => ({
jest.mock("../../session", () => ({
Session: {
fetchStoredToken: jest.fn(),
deprecatedGetNum: () => undefined,
deprecatedGetBool: () => undefined,
getAll: () => undefined,
clear: jest.fn()
}

View file

@ -1,52 +0,0 @@
import { store } from "../redux/store";
import { BooleanConfigKey, NumberConfigKey } from "../config_storage/web_app_configs";
import { edit, save } from "../api/crud";
import { getWebAppConfig } from "../resources/selectors_by_kind";
/**
* HISTORICAL CONTEXT: We once stored user settings (like map zoom level) in
* localStorage and would retrieve values via `Session.getBool("zoom_level")`
*
* PROBLEM: localStorage is no longer used. Many parts of the app were accessing
* values in places that did not have access to the Redux store.
*
* SOLUTION: Create a temporary shim that will "cheat" and directly call Redux
* store without a lot of boilerplate props passing.
*
* WHY NOT JUST INLINE THESE FUNCTIONS?: It's easier to stub out calls in tests
* that already exist.
*/
/** Avoid using this function in new places. Pass props instead. */
export function getBoolViaRedux(key: BooleanConfigKey): boolean | undefined {
const conf = getWebAppConfig(store.getState().resources.index);
return conf && conf.body[key];
}
/** Avoid using this function in new places. Pass props instead. */
export function setBoolViaRedux(key: BooleanConfigKey, val: boolean) {
const conf = getWebAppConfig(store.getState().resources.index);
if (conf) {
store.dispatch(edit(conf, { [key]: val }));
// tslint:disable-next-line:no-any
store.dispatch(save(conf.uuid) as any);
}
return val;
}
/** Avoid using this function in new places. Pass props instead. */
export function getNumViaRedux(key: NumberConfigKey): number | undefined {
const conf = getWebAppConfig(store.getState().resources.index);
return conf && conf.body[key];
}
/** Avoid using this function in new places. Pass props instead. */
export function setNumViaRedux(key: NumberConfigKey, val: number): number {
const conf = getWebAppConfig(store.getState().resources.index);
if (conf) {
store.dispatch(edit(conf, { [key]: val }));
// tslint:disable-next-line:no-any
store.dispatch(save(conf.uuid) as any);
}
return val;
}

View file

@ -1,7 +1,8 @@
import {
BooleanConfigKey as BooleanWebAppConfigKey,
NumberConfigKey as NumberWebAppConfigKey,
StringConfigKey as StringWebAppConfigKey
StringConfigKey as StringWebAppConfigKey,
WebAppConfig
} from "./web_app_configs";
import { GetState } from "../redux/interfaces";
import { edit, save } from "../api/crud";
@ -12,7 +13,7 @@ export function toggleWebAppBool(key: BooleanWebAppConfigKey) {
return (dispatch: Function, getState: GetState) => {
const conf = getWebAppConfig(getState().resources.index);
if (conf) {
const val = !conf.body[key];
const val = !(conf.body as WebAppConfig)[key];
dispatch(edit(conf, { [key]: val }));
dispatch(save(conf.uuid));
} else {
@ -33,7 +34,7 @@ export type GetWebAppConfigValue = (k: WebAppConfigKey) => WebAppConfigValue;
export function getWebAppConfigValue(getState: GetState) {
return (key: WebAppConfigKey): WebAppConfigValue => {
const conf = getWebAppConfig(getState().resources.index);
return conf && conf.body[key];
return conf && (conf.body as WebAppConfig)[key];
};
}

View file

@ -46,6 +46,7 @@ export interface WebAppConfig {
discard_unsaved: boolean;
xy_swap: boolean;
home_button_homing: boolean;
show_motor_plot: boolean;
}
export type NumberConfigKey = "id"
@ -89,4 +90,5 @@ export type BooleanConfigKey = "confirm_step_deletion"
|"show_images"
|"discard_unsaved"
|"xy_swap"
|"home_button_homing";
|"home_button_homing"
|"show_motor_plot";

View file

@ -0,0 +1,51 @@
jest.mock("moment", () => () => ({ unix: () => 1020 }));
import * as React from "react";
import { mount } from "enzyme";
import { MotorPositionPlot, MotorPositionHistory } from "../motor_position_plot";
import { BotLocationData, BotPosition } from "../../../devices/interfaces";
describe("<MotorPositionPlot />", () => {
const fakePosition = (): BotPosition => ({ x: 0, y: 0, z: 0 });
const fakeLocationData = (): BotLocationData => ({
position: fakePosition(),
scaled_encoders: fakePosition(),
raw_encoders: fakePosition()
});
const fakeProps = () => ({
locationData: fakeLocationData(),
});
it("renders", () => {
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
["x", "y", "z", "position", "seconds ago", "120", "100"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
it("renders motor position", () => {
const location1 = fakeLocationData();
const location2 = fakeLocationData();
location2.position.x = 100;
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify([
{ timestamp: 1000, locationData: location1 },
{ timestamp: 1010, locationData: location2 },
]));
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,-12.5 L 100,0");
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,0 L 100,0");
});
it("handles undefined data", () => {
const location1 = fakeLocationData();
const location2 = fakeLocationData();
location2.position.x = undefined;
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify([
{ timestamp: 1000, locationData: location1 },
{ timestamp: 1010, locationData: location2 },
]));
const wrapper = mount(<MotorPositionPlot {...fakeProps()} />);
expect(wrapper.html()).not.toContain("M 120,0 L 120,0 L 110,-12.5 L 100,0");
expect(wrapper.html()).toContain("M 120,0 L 120,0 L 110,0 L 100,0");
});
});

View file

@ -13,7 +13,7 @@ jest.mock("../../../config_storage/actions", () => {
});
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { Move } from "../move";
import { bot } from "../../../__test_support__/fake_state/bot";
import { MoveProps } from "../interfaces";
@ -81,4 +81,10 @@ describe("<Move />", () => {
payload: 1
});
});
it("displays motor position plot", () => {
mockConfig.show_motor_plot = true;
const wrapper = shallow(<Move {...fakeProps()} />);
expect(wrapper.html()).toContain("motor-position-plot");
});
});

View file

@ -1,7 +1,7 @@
import * as React from "react";
import { mount } from "enzyme";
import { BooleanSetting } from "../../../session_keys";
import { moveWidgetSetting } from "../settings_menu";
import { moveWidgetSetting, MoveWidgetSettingsMenu } from "../settings_menu";
describe("moveWidgetSetting()", () => {
it("renders setting", () => {
@ -13,3 +13,18 @@ describe("moveWidgetSetting()", () => {
expect(wrapper.text().toLowerCase()).toContain(string));
});
});
describe("<MoveWidgetSettingsMenu />", () => {
const fakeProps = () => ({
toggle: jest.fn(),
getValue: jest.fn(),
});
it("displays motor plot toggle", () => {
const noToggle = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
expect(noToggle.text()).not.toContain("Motor position plot");
localStorage.setItem("FUTURE_FEATURES", "true");
const wrapper = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
expect(wrapper.text()).toContain("Motor position plot");
});
});

View file

@ -0,0 +1,186 @@
import * as React from "react";
import * as _ from "lodash";
import { Xyz, LocationName, Dictionary } from "farmbot";
import * as moment from "moment";
import { BotLocationData, BotPosition } from "../../devices/interfaces";
import { trim } from "../../util";
import { t } from "i18next";
const HEIGHT = 50;
const HISTORY_LENGTH_SECONDS = 120;
const BORDER_WIDTH = 15;
const BORDERS = BORDER_WIDTH * 2;
const MAX_X = HISTORY_LENGTH_SECONDS;
const DEFAULT_Y_MAX = 100;
const COLOR_LOOKUP: Dictionary<string> = {
x: "red", y: "green", z: "blue"
};
const LINEWIDTH_LOOKUP: Dictionary<number> = {
position: 0.5, scaled_encoders: 0.25
};
export enum MotorPositionHistory {
array = "motorPositionHistoryArray",
}
type Entry = {
timestamp: number,
locationData: Record<LocationName, BotPosition>
};
type Paths = Record<LocationName, Record<Xyz, string>>;
const getArray = (): Entry[] =>
JSON.parse(_.get(sessionStorage, MotorPositionHistory.array, "[]"));
const getReversedArray = (): Entry[] => _.cloneDeep(getArray()).reverse();
const getLastEntry = (): Entry | undefined => {
const array = getArray();
return array[array.length - 1];
};
const findYLimit = (): number => {
const array = getArray();
const arrayAbsMax = _.max(array.map(entry =>
_.max(["position", "scaled_encoders"].map((name: LocationName) =>
_.max(["x", "y", "z"].map((axis: Xyz) =>
Math.abs(entry.locationData[name][axis] || 0) + 1))))));
return Math.max(_.ceil(arrayAbsMax || 0, -2), DEFAULT_Y_MAX);
};
const updateArray = (update: Entry): Entry[] => {
const arr = getArray();
const last = getLastEntry();
if (update && _.isNumber(update.locationData.position.x) &&
(!last || !_.isEqual(last.timestamp, update.timestamp))) {
arr.push(update);
}
const newArray = _.takeRight(arr, 100)
.filter(x => {
const entryAge = (last ? last.timestamp : moment().unix()) - x.timestamp;
return entryAge <= HISTORY_LENGTH_SECONDS;
});
sessionStorage.setItem(MotorPositionHistory.array, JSON.stringify(newArray));
return newArray;
};
const newPaths = (): Paths => ({
position: { x: "", y: "", z: "" },
scaled_encoders: { x: "", y: "", z: "" },
raw_encoders: { x: "", y: "", z: "" }
});
const getPaths = (): Paths => {
const last = getLastEntry();
const maxY = findYLimit();
const paths = newPaths();
if (last) {
getReversedArray().map(entry => {
["position", "scaled_encoders"].map((name: LocationName) => {
["x", "y", "z"].map((axis: Xyz) => {
const lastPos = last.locationData[name][axis];
const pos = entry.locationData[name][axis];
if (_.isNumber(lastPos) && _.isFinite(lastPos)
&& _.isNumber(maxY) && _.isNumber(pos)) {
if (!paths[name][axis].startsWith("M")) {
const yStart = -lastPos / maxY * HEIGHT / 2;
paths[name][axis] = `M ${MAX_X},${yStart} `;
}
const x = MAX_X - (last.timestamp - entry.timestamp);
const y = -pos / maxY * HEIGHT / 2;
paths[name][axis] += `L ${x},${y} `;
}
});
});
});
}
return paths;
};
const TitleLegend = () => {
const titleY = -(HEIGHT + BORDER_WIDTH) / 2;
const legendX = HISTORY_LENGTH_SECONDS / 4;
return <g id="title_with_legend">
<text fill={COLOR_LOOKUP.x} fontWeight={"bold"}
x={legendX - 10} y={titleY}>{"X"}</text>
<text fill={COLOR_LOOKUP.y} fontWeight={"bold"}
x={legendX} y={titleY}>{"Y"}</text>
<text fill={COLOR_LOOKUP.z} fontWeight={"bold"}
x={legendX + 10} y={titleY}>{"Z"}</text>
<text fontWeight={"bold"}
x={HISTORY_LENGTH_SECONDS / 2} y={titleY}>{t("Position (mm)")}</text>
</g>;
};
const YAxisLabels = () => {
const maxY = findYLimit();
return <g id="y_axis_labels">
{[maxY, maxY / 2, 0, -maxY / 2, -maxY].map(yPosition =>
<g key={"y_axis_label_" + yPosition}>
<text x={MAX_X + BORDER_WIDTH / 2} y={-yPosition / maxY * HEIGHT / 2}>
{yPosition}
</text>
<text x={-BORDER_WIDTH / 2} y={-yPosition / maxY * HEIGHT / 2}>
{yPosition}
</text>
</g>)}
</g>;
};
const XAxisLabels = () =>
<g id="x_axis_labels">
<text x={HISTORY_LENGTH_SECONDS / 2} y={HEIGHT / 2 + BORDER_WIDTH / 1.25}
fontStyle={"italic"}>
{t("seconds ago")}
</text>
{_.range(0, HISTORY_LENGTH_SECONDS + 1, 20).map(secondsAgo =>
<text key={"x_axis_label_" + secondsAgo}
x={MAX_X - secondsAgo} y={HEIGHT / 2 + BORDER_WIDTH / 3}>
{secondsAgo}
</text>)}
</g>;
const PlotBackground = () =>
<g id="plot_background">
<rect fill="white" x={0} y={-HEIGHT / 2} width={"100%"} height={"100%"} />
<line x1={0} y1={0} x2={MAX_X} y2={0} strokeWidth={0.25} stroke={"grey"} />
</g>;
const PlotLines = ({ locationData }: { locationData: BotLocationData }) => {
updateArray({ timestamp: moment().unix(), locationData });
const paths = getPaths();
return <g id="plot_lines">
{["position", "scaled_encoders"].map((name: LocationName) =>
["x", "y", "z"].map((axis: Xyz) =>
<path key={name + axis} fill={"none"}
stroke={COLOR_LOOKUP[axis]} strokeWidth={LINEWIDTH_LOOKUP[name]}
strokeLinecap={"round"} strokeLinejoin={"round"}
d={paths[name][axis]} />))}
</g>;
};
export const MotorPositionPlot = (props: { locationData: BotLocationData }) => {
return <svg
className="motor-position-plot-border"
style={{ marginTop: "2rem" }}
width="100%"
height="100%"
viewBox={trim(`${-BORDER_WIDTH} ${-HEIGHT / 2 - BORDER_WIDTH}
${HISTORY_LENGTH_SECONDS + BORDERS} ${HEIGHT + BORDERS}`)}>
<TitleLegend />
<YAxisLabels />
<XAxisLabels />
<svg
className="motor-position-plot"
width={HISTORY_LENGTH_SECONDS}
height={HEIGHT}
x={0}
y={-HEIGHT / 2}
viewBox={`0 ${-HEIGHT / 2} ${HISTORY_LENGTH_SECONDS} ${HEIGHT}`}>
<PlotBackground />
<PlotLines locationData={props.locationData} />
</svg>
</svg>;
};

View file

@ -14,6 +14,8 @@ import { MoveProps } from "./interfaces";
import { MoveWidgetSettingsMenu } from "./settings_menu";
import { JogControlsGroup } from "./jog_controls_group";
import { BotPositionRows } from "./bot_position_rows";
import { MotorPositionPlot } from "./motor_position_plot";
import { Popover, Position } from "@blueprintjs/core";
export class Move extends React.Component<MoveProps, {}> {
@ -30,9 +32,12 @@ export class Move extends React.Component<MoveProps, {}> {
<WidgetHeader
title={t("Move")}
helpText={ToolTips.MOVE}>
<MoveWidgetSettingsMenu
toggle={this.toggle}
getValue={this.getValue} />
<Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<MoveWidgetSettingsMenu
toggle={this.toggle}
getValue={this.getValue} />
</Popover>
<EStopButton
bot={this.props.bot}
user={this.props.user} />
@ -55,6 +60,8 @@ export class Move extends React.Component<MoveProps, {}> {
arduinoBusy={this.props.arduinoBusy}
firmware_version={informational_settings.firmware_version} />
</MustBeOnline>
{this.props.getWebAppConfigVal(BooleanSetting.show_motor_plot) &&
<MotorPositionPlot locationData={locationData} />}
</WidgetBody>
</Widget>;
}

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { t } from "i18next";
import { Popover, Position } from "@blueprintjs/core";
import {
BooleanConfigKey as BooleanWebAppConfigKey
} from "../../config_storage/web_app_configs";
@ -24,29 +23,34 @@ export const MoveWidgetSettingsMenu = ({ toggle, getValue }: {
getValue: GetWebAppBool
}) => {
const Setting = moveWidgetSetting(toggle, getValue);
return <Popover position={Position.BOTTOM_RIGHT}>
<i className="fa fa-gear" />
<div className="move-settings-menu">
<p>{t("Invert Jog Buttons")}</p>
<Setting label={t("X Axis")} setting={BooleanSetting.x_axis_inverted} />
<Setting label={t("Y Axis")} setting={BooleanSetting.y_axis_inverted} />
<Setting label={t("Z Axis")} setting={BooleanSetting.z_axis_inverted} />
return <div className="move-settings-menu">
<p>{t("Invert Jog Buttons")}</p>
<Setting label={t("X Axis")} setting={BooleanSetting.x_axis_inverted} />
<Setting label={t("Y Axis")} setting={BooleanSetting.y_axis_inverted} />
<Setting label={t("Z Axis")} setting={BooleanSetting.z_axis_inverted} />
<p>{t("Display Encoder Data")}</p>
<Setting
label={t("Scaled encoder position")}
setting={BooleanSetting.scaled_encoders} />
<Setting
label={t("Raw encoder position")}
setting={BooleanSetting.raw_encoders} />
<p>{t("Display Encoder Data")}</p>
<Setting
label={t("Scaled encoder position")}
setting={BooleanSetting.scaled_encoders} />
<Setting
label={t("Raw encoder position")}
setting={BooleanSetting.raw_encoders} />
<p>{t("Swap jog buttons (and rotate map)")}</p>
<Setting label={t("x and y axis")} setting={BooleanSetting.xy_swap} />
<p>{t("Swap jog buttons (and rotate map)")}</p>
<Setting label={t("x and y axis")} setting={BooleanSetting.xy_swap} />
<p>{t("Home button behavior")}</p>
<Setting
label={t("perform homing (find home)")}
setting={BooleanSetting.home_button_homing} />
</div>
</Popover>;
<p>{t("Home button behavior")}</p>
<Setting
label={t("perform homing (find home)")}
setting={BooleanSetting.home_button_homing} />
{localStorage.getItem("FUTURE_FEATURES") &&
<div>
<p>{t("Motor position plot")}</p>
<Setting
label={t("show")}
setting={BooleanSetting.show_motor_plot} />
</div>}
</div>;
};

View file

@ -610,6 +610,14 @@ ul {
}
}
.motor-position-plot-border {
text {
font-size: 0.4rem;
text-anchor: middle;
dominant-baseline: middle;
}
}
.controls-popup,
.controls-popup-menu-outer {
position: fixed;

View file

@ -113,7 +113,7 @@ export function PlantPanel(props: PlantPanelProps) {
</ListItem>
</ul>
<button className="fb-button gray"
hidden={true}
hidden={!localStorage.getItem("FUTURE_FEATURES")}
onClick={() => {
dispatch({
type: Actions.CHOOSE_LOCATION,

View file

@ -9,12 +9,6 @@ jest.mock("../../history", () => ({
},
}));
jest.mock("../../session", () => ({
Session: {
deprecatedGetBool: () => true // Simulate opt-in to beta features.
}
}));
const mockDevice = {
execScript: jest.fn(() => Promise.resolve({})),
};

View file

@ -4,30 +4,26 @@ import { shallow } from "enzyme";
import { NavBar } from "../index";
import { bot } from "../../__test_support__/fake_state/bot";
import { taggedUser } from "../../__test_support__/user";
import { NavBarProps } from "../interfaces";
describe("NavBar", () => {
const fakeProps = (): NavBarProps => ({
timeOffset: 0,
consistent: true,
logs: [],
bot,
user: taggedUser,
dispatch: jest.fn(),
getConfigValue: jest.fn(),
});
it("has correct parent classname", () => {
const wrapper = shallow(
<NavBar
timeOffset={0}
consistent={true}
logs={[]}
bot={bot}
user={taggedUser}
dispatch={jest.fn()} />
);
const wrapper = shallow(<NavBar {...fakeProps()} />);
expect(wrapper.find("div").first().hasClass("nav-wrapper")).toBeTruthy();
});
it("closes nav menu", () => {
const wrapper = shallow<NavBar>(<NavBar
timeOffset={0}
consistent={true}
logs={[]}
bot={bot}
user={taggedUser}
dispatch={jest.fn()} />);
const wrapper = shallow<NavBar>(<NavBar {...fakeProps()} />);
const link = wrapper.find("Link").first();
link.simulate("click");
expect(wrapper.instance().state.mobileMenuOpen).toBeFalsy();

View file

@ -1,25 +1,5 @@
const mockStorj: Dictionary<number | boolean> = {};
jest.mock("../../session", () => {
return {
Session: {
deprecatedGetNum: (k: string) => {
return mockStorj[k];
},
deprecatedSetNum: (k: string, v: number) => {
mockStorj[k] = v;
},
deprecatedGetBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
}
},
// tslint:disable-next-line:no-any
safeNumericSetting: (x: any) => x
};
});
import * as React from "react";
import { mount } from "enzyme";
import { TickerList } from "../ticker_list";
@ -41,6 +21,7 @@ describe("<TickerList />", () => {
logs: [fakeTaggedLog(), fakeTaggedLog()],
tickerListOpen: false,
toggle: jest.fn(),
getConfigValue: x => mockStorj[x],
};
};

View file

@ -55,15 +55,17 @@ export class NavBar extends React.Component<NavBarProps, Partial<NavBarState>> {
const { toggle, close } = this;
const { mobileMenuOpen, tickerListOpen, accountMenuOpen } = this.state;
const { logs, timeOffset } = this.props;
const { logs, timeOffset, getConfigValue } = this.props;
const tickerListProps = {
logs, tickerListOpen, toggle, timeOffset, getConfigValue
};
return <ErrorBoundary>
<div className="nav-wrapper">
<nav role="navigation">
<Row>
<Col xs={12}>
<div>
<TickerList {...{ logs, tickerListOpen, toggle, timeOffset }} />
<TickerList {...tickerListProps} />
<div className="nav-group">
<div className="nav-left">
<i

View file

@ -1,5 +1,6 @@
import { BotState } from "../devices/interfaces";
import { TaggedUser, TaggedLog } from "farmbot";
import { GetWebAppConfigValue } from "../config_storage/actions";
export interface NavButtonProps {
user: TaggedUser | undefined;
@ -16,6 +17,7 @@ export interface NavBarProps {
user: TaggedUser | undefined;
dispatch: Function;
timeOffset: number;
getConfigValue: GetWebAppConfigValue;
}
export interface NavBarState {
@ -36,6 +38,7 @@ export interface TickerListProps {
logs: TaggedLog[]
tickerListOpen: boolean;
timeOffset: number;
getConfigValue: GetWebAppConfigValue;
}
export interface NavLinksProps {

View file

@ -5,18 +5,20 @@ import { TickerListProps } from "./interfaces";
import { Link } from "react-router";
import { t } from "i18next";
import { formatLogTime } from "../logs/index";
import { Session, safeNumericSetting } from "../session";
import { safeNumericSetting } from "../session";
import { ErrorBoundary } from "../error_boundary";
import { ALLOWED_MESSAGE_TYPES, TaggedLog, SpecialStatus } from "farmbot";
import { filterByVerbosity } from "../logs/components/logs_table";
import { isNumber } from "lodash";
import { GetWebAppConfigValue } from "../config_storage/actions";
/** Get current verbosity filter level for a message type from WebAppConfig. */
const getFilterLevel = (type: ALLOWED_MESSAGE_TYPES): number => {
const filterLevel =
Session.deprecatedGetNum(safeNumericSetting(type + "_log"));
return isNumber(filterLevel) ? filterLevel : 1;
};
const getFilterLevel = (getConfigValue: GetWebAppConfigValue) =>
(type: ALLOWED_MESSAGE_TYPES): number => {
const filterLevel =
getConfigValue(safeNumericSetting(type + "_log"));
return isNumber(filterLevel) ? filterLevel : 1;
};
/** Generate a fallback TaggedLog to display in the first line of the ticker. */
const generateFallbackLog = (uuid: string, message: string): TaggedLog => {
@ -34,19 +36,21 @@ const generateFallbackLog = (uuid: string, message: string): TaggedLog => {
};
/** Choose the log to display in the first line of the ticker. */
const getfirstTickerLog = (logs: TaggedLog[]): TaggedLog => {
if (logs.length == 0) {
return generateFallbackLog("no_logs_yet", t("No logs yet."));
} else {
const filteredLogs = filterByVerbosity(getFilterLevel, logs);
if (filteredLogs.length > 0) {
return filteredLogs[0];
const getfirstTickerLog = (getConfigValue: GetWebAppConfigValue) =>
(logs: TaggedLog[]): TaggedLog => {
if (logs.length == 0) {
return generateFallbackLog("no_logs_yet", t("No logs yet."));
} else {
return generateFallbackLog("no_logs_to_display",
t("No logs to display. Visit Logs page to view filters."));
const filteredLogs =
filterByVerbosity(getFilterLevel(getConfigValue), logs);
if (filteredLogs.length > 0) {
return filteredLogs[0];
} else {
return generateFallbackLog("no_logs_to_display",
t("No logs to display. Visit Logs page to view filters."));
}
}
}
};
};
/** Format a single log for display in the ticker. */
const Ticker = (log: TaggedLog, timeOffset: number) => {
@ -70,10 +74,11 @@ export let TickerList = (props: TickerListProps) => {
return <ErrorBoundary>
<div className="ticker-list" onClick={props.toggle("tickerListOpen")} >
<div className="first-ticker">
{Ticker(getfirstTickerLog(props.logs), props.timeOffset)}
{Ticker(getfirstTickerLog(props.getConfigValue)(props.logs),
props.timeOffset)}
</div>
<Collapse isOpen={props.tickerListOpen}>
{filterByVerbosity(getFilterLevel, props.logs)
{filterByVerbosity(getFilterLevel(props.getConfigValue), props.logs)
// Don't use first log again since it's already displayed in first row
.filter((_, index) => index !== 0)
.map((log: TaggedLog) => Ticker(log, props.timeOffset))}

View file

@ -61,7 +61,8 @@ describe("<AllSteps/>", () => {
sequence={TEST_CASE}
onDrop={() => { }}
dispatch={jest.fn()}
resources={buildResourceIndex([]).index} />);
resources={buildResourceIndex([]).index}
confirmStepDeletion={false} />);
[TileMoveRelative, TileReadPin, TileWritePin]
.map(q => {
expect(el.find(q).length).toEqual(1);

View file

@ -55,6 +55,7 @@ describe("<SequenceEditorMiddleActive/>", () => {
farmwareConfigs: {},
},
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
};
}

View file

@ -23,6 +23,7 @@ describe("<SequenceEditorMiddle/>", () => {
farmwareConfigs: {},
},
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
};
}

View file

@ -29,6 +29,7 @@ describe("<Sequences/>", () => {
farmwareConfigs: {},
},
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
};
}

View file

@ -17,6 +17,7 @@ describe("<StepIconGroup />", () => {
step: { kind: "wait", args: { milliseconds: 100 } },
sequence: fakeSequence(),
helpText: "helpful text",
confirmStepDeletion: false,
});
it("renders", () => {

View file

@ -17,6 +17,7 @@ interface AllStepsProps {
hardwareFlags?: HardwareFlags;
farmwareInfo?: FarmwareInfo;
shouldDisplay?: ShouldDisplay;
confirmStepDeletion: boolean;
}
export class AllSteps extends React.Component<AllStepsProps, {}> {
@ -50,6 +51,7 @@ export class AllSteps extends React.Component<AllStepsProps, {}> {
hardwareFlags,
farmwareInfo,
shouldDisplay,
confirmStepDeletion: this.props.confirmStepDeletion,
})}
</div>
</StepDragger>

View file

@ -30,6 +30,7 @@ export interface Props {
hardwareFlags: HardwareFlags;
farmwareInfo: FarmwareInfo;
shouldDisplay: ShouldDisplay;
confirmStepDeletion: boolean;
}
export interface SequenceEditorMiddleProps {
@ -40,11 +41,11 @@ export interface SequenceEditorMiddleProps {
hardwareFlags: HardwareFlags;
farmwareInfo: FarmwareInfo;
shouldDisplay: ShouldDisplay;
confirmStepDeletion: boolean;
}
export interface ActiveMiddleProps extends SequenceEditorMiddleProps {
sequence: TaggedSequence;
syncStatus: SyncStatus;
}
export type ChannelName = ALLOWED_CHANNEL_NAMES;
@ -156,4 +157,5 @@ export interface StepParams {
hardwareFlags?: HardwareFlags;
farmwareInfo?: FarmwareInfo;
shouldDisplay?: ShouldDisplay;
confirmStepDeletion: boolean;
}

View file

@ -7,24 +7,17 @@ import { SequenceEditorMiddleActive } from "./sequence_editor_middle_active";
export class SequenceEditorMiddle
extends React.Component<SequenceEditorMiddleProps, {}> {
render() {
const {
dispatch,
sequence,
resources,
syncStatus,
hardwareFlags,
farmwareInfo,
shouldDisplay,
} = this.props;
const { sequence } = this.props;
if (sequence && isTaggedSequence(sequence)) {
return <SequenceEditorMiddleActive
dispatch={dispatch}
dispatch={this.props.dispatch}
sequence={sequence}
resources={resources}
syncStatus={syncStatus}
hardwareFlags={hardwareFlags}
farmwareInfo={farmwareInfo}
shouldDisplay={shouldDisplay} />;
resources={this.props.resources}
syncStatus={this.props.syncStatus}
hardwareFlags={this.props.hardwareFlags}
farmwareInfo={this.props.farmwareInfo}
shouldDisplay={this.props.shouldDisplay}
confirmStepDeletion={this.props.confirmStepDeletion} />;
} else {
return <SequenceEditorMiddleInactive />;
}

View file

@ -43,7 +43,8 @@ export class Sequences extends React.Component<Props, {}> {
resources={this.props.resources}
hardwareFlags={this.props.hardwareFlags}
farmwareInfo={this.props.farmwareInfo}
shouldDisplay={this.props.shouldDisplay} />
shouldDisplay={this.props.shouldDisplay}
confirmStepDeletion={this.props.confirmStepDeletion} />
</CenterPanel>
<RightPanel
className="step-button-cluster-panel"

View file

@ -9,6 +9,8 @@ import {
betterCompact, shouldDisplay, determineInstalledOsVersion, validFwConfig
} from "../util";
import { getWebAppConfig } from "../resources/selectors";
import { BooleanSetting } from "../session_keys";
import { getWebAppConfigValue } from "../config_storage/actions";
export function mapStateToProps(props: Everything): Props {
const uuid = props.resources.consumers.sequences.current;
@ -63,6 +65,9 @@ export function mapStateToProps(props: Everything): Props {
const installedOsVersion = determineInstalledOsVersion(
props.bot, maybeGetDevice(props.resources.index));
const confirmStepDeletion =
!!getWebAppConfigValue(() => props)(BooleanSetting.confirm_step_deletion);
return {
dispatch: props.dispatch,
sequences: selectAllSequences(props.resources.index),
@ -81,5 +86,6 @@ export function mapStateToProps(props: Everything): Props {
farmwareConfigs,
},
shouldDisplay: shouldDisplay(installedOsVersion, props.bot.minOsFeatureData),
confirmStepDeletion,
};
}

View file

@ -10,6 +10,7 @@ export interface StepIconBarProps {
step: SequenceBodyItem;
sequence: TaggedSequence;
helpText: string;
confirmStepDeletion: boolean;
}
export function StepUpDownButtonPopover(
@ -24,10 +25,13 @@ export function StepUpDownButtonPopover(
}
export function StepIconGroup(props: StepIconBarProps) {
const { index, dispatch, step, sequence, helpText } = props;
const {
index, dispatch, step, sequence, helpText, confirmStepDeletion
} = props;
const onClone = () => dispatch(splice({ step, index, sequence }));
const onTrash = () => remove({ dispatch, index, sequence });
const onTrash = () =>
remove({ dispatch, index, sequence, confirmStepDeletion });
const onMove = (delta: number) => () => {
const to = Math.max(index + delta, 0);
dispatch(move({ step, sequence, from: index, to }));

View file

@ -1,39 +1,76 @@
const mockStorj: Dictionary<boolean> = {};
jest.mock("../../../api/crud", () => ({
overwrite: jest.fn(),
}));
jest.mock("../../../session", () => {
return {
Session: {
deprecatedGetBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
}
}
};
});
import { Dictionary } from "farmbot";
import { remove } from "../index";
import { remove, move, splice } from "../index";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { BooleanSetting } from "../../../session_keys";
import { overwrite } from "../../../api/crud";
import { Wait } from "farmbot";
describe("remove()", () => {
const fakeProps = () => ({
index: 0,
dispatch: jest.fn(),
sequence: fakeSequence(),
confirmStepDeletion: false,
});
it("deletes step without confirmation", () => {
const dispatch = jest.fn();
mockStorj[BooleanSetting.confirm_step_deletion] = false;
remove({ index: 0, dispatch, sequence: fakeSequence() });
expect(dispatch).toHaveBeenCalled();
const p = fakeProps();
remove(p);
expect(p.dispatch).toHaveBeenCalled();
});
it("deletes step with confirmation", () => {
const dispatch = jest.fn();
mockStorj[BooleanSetting.confirm_step_deletion] = true;
const p = fakeProps();
p.confirmStepDeletion = true;
window.confirm = jest.fn();
remove({ index: 0, dispatch, sequence: fakeSequence() });
remove(p);
expect(window.confirm).toHaveBeenCalledWith(
expect.stringContaining("delete this step?"));
expect(dispatch).not.toHaveBeenCalled();
expect(p.dispatch).not.toHaveBeenCalled();
window.confirm = jest.fn(() => true);
remove({ index: 0, dispatch, sequence: fakeSequence() });
expect(dispatch).toHaveBeenCalled();
remove(p);
expect(p.dispatch).toHaveBeenCalled();
});
});
describe("move()", () => {
const sequence = fakeSequence();
const step1: Wait = { kind: "wait", args: { milliseconds: 100 } };
const step2: Wait = { kind: "wait", args: { milliseconds: 200 } };
sequence.body.body = [step1, step2];
const fakeProps = () => ({ step: step2, sequence, to: 0, from: 1, });
it("moves step backward", () => {
const p = fakeProps();
p.from = 1;
p.to = 0;
move(p);
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({ body: [step2, step1] }));
});
it("moves step forward", () => {
const p = fakeProps();
p.sequence.body.body = [step2, step1];
p.from = 0;
p.to = 2;
move(p);
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({ body: [step1, step2] }));
});
});
describe("splice()", () => {
const sequence = fakeSequence();
const step: Wait = { kind: "wait", args: { milliseconds: 100 } };
const fakeProps = () => ({ step, sequence, index: 1, });
it("adds step", () => {
const p = fakeProps();
splice(p);
expect(overwrite).toHaveBeenCalledWith(expect.any(Object),
expect.objectContaining({ body: [step] }));
});
});

View file

@ -289,7 +289,8 @@ describe("Pin and Peripheral support files", () => {
dispatch,
currentSequence,
currentStep,
index
index,
confirmStepDeletion: false,
};
const callback = setArgsDotPinNumber(stepParams);
const ddi: DropDownItem = { label: "hmm", value: 0 };

View file

@ -26,7 +26,8 @@ describe("<TileExecuteScript/>", () => {
firstPartyFarmwareNames: ["one"],
showFirstPartyFarmware: false,
farmwareConfigs: { "farmware-to-execute": [] },
}
},
confirmStepDeletion: false,
};
};

View file

@ -20,11 +20,12 @@ function fakeProps(): ExecBlockParams {
};
return {
currentSequence: fakeSequence(),
currentStep: currentStep,
currentStep,
dispatch: jest.fn(),
index: 0,
resources: emptyState().index,
shouldDisplay: () => false,
confirmStepDeletion: false,
};
}

View file

@ -1,5 +1,5 @@
import * as React from "react";
import { TileFindHome } from "../tile_find_home";
import { TileFindHome, FindHomeParams } from "../tile_find_home";
import { mount } from "enzyme";
import { fakeSequence } from "../../../__test_support__/fake_state/resources";
import { FindHome } from "farmbot/dist";
@ -7,9 +7,10 @@ import { emptyState } from "../../../resources/reducer";
import {
fakeHardwareFlags
} from "../../../__test_support__/sequence_hardware_settings";
import { HardwareFlags } from "../../interfaces";
describe("<TileFindHome/>", () => {
const fakeProps = () => {
const fakeProps = (): FindHomeParams => {
const currentStep: FindHome = {
kind: "find_home",
args: {
@ -23,7 +24,8 @@ describe("<TileFindHome/>", () => {
dispatch: jest.fn(),
index: 0,
resources: emptyState().index,
hardwareFlags: fakeHardwareFlags()
hardwareFlags: fakeHardwareFlags(),
confirmStepDeletion: false,
};
};
@ -49,7 +51,7 @@ describe("<TileFindHome/>", () => {
it("doesn't render warning", () => {
const p = fakeProps();
p.currentStep.args.axis = "x";
p.hardwareFlags.findHomeEnabled.x = true;
(p.hardwareFlags as HardwareFlags).findHomeEnabled.x = true;
const wrapper = mount(<TileFindHome {...p} />);
expect(wrapper.text()).not.toContain(CONFLICT_TEXT_BASE);
});
@ -57,7 +59,7 @@ describe("<TileFindHome/>", () => {
it("renders warning: all axes", () => {
const p = fakeProps();
p.currentStep.args.axis = "all";
p.hardwareFlags.findHomeEnabled.x = false;
(p.hardwareFlags as HardwareFlags).findHomeEnabled.x = false;
const wrapper = mount(<TileFindHome {...p} />);
expect(wrapper.text()).toContain(CONFLICT_TEXT_BASE + ": x");
});
@ -65,7 +67,7 @@ describe("<TileFindHome/>", () => {
it("renders warning: one axis", () => {
const p = fakeProps();
p.currentStep.args.axis = "x";
p.hardwareFlags.findHomeEnabled.x = false;
(p.hardwareFlags as HardwareFlags).findHomeEnabled.x = false;
const wrapper = mount(<TileFindHome {...p} />);
expect(wrapper.text()).toContain(CONFLICT_TEXT_BASE + ": x");
});

View file

@ -23,7 +23,8 @@ describe("<TileIf/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={emptyState().index} />)
resources={emptyState().index}
confirmStepDeletion={false} />)
};
}

View file

@ -38,7 +38,8 @@ describe("<TileMoveAbsolute/>", () => {
dispatch: jest.fn(),
index: 0,
resources: emptyState().index,
hardwareFlags: fakeHardwareFlags()
hardwareFlags: fakeHardwareFlags(),
confirmStepDeletion: false,
};
};
@ -100,7 +101,8 @@ describe("<TileMoveAbsolute/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={index} />).instance() as TileMoveAbsolute;
resources={index}
confirmStepDeletion={false} />).instance() as TileMoveAbsolute;
expect(component.tool).toEqual(tool);
});

View file

@ -22,7 +22,8 @@ describe("<TileMoveRelative/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={emptyState().index} />)
resources={emptyState().index}
confirmStepDeletion={false} />)
};
}

View file

@ -34,7 +34,8 @@ describe("Pin tile support functions", () => {
currentStep,
dispatch,
index,
resources
resources,
confirmStepDeletion: false,
};
}

View file

@ -21,7 +21,8 @@ describe("<TileReadPin/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={emptyState().index} />)
resources={emptyState().index}
confirmStepDeletion={false} />)
};
}

View file

@ -28,6 +28,7 @@ describe("<TileSendMessage/>", () => {
dispatch: jest.fn(),
index: 0,
resources: emptyState().index,
confirmStepDeletion: false,
};
}

View file

@ -17,7 +17,8 @@ describe("<TileTakePhoto/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={emptyState().index} />)
resources={emptyState().index}
confirmStepDeletion={false} />)
};
}

View file

@ -19,7 +19,8 @@ describe("<TileWait/>", () => {
currentStep={currentStep}
dispatch={jest.fn()}
index={0}
resources={emptyState().index} />)
resources={emptyState().index}
confirmStepDeletion={false} />)
};
}

View file

@ -20,7 +20,8 @@ describe("<TileWritePin/>", () => {
currentStep: currentStep,
dispatch: jest.fn(),
index: 0,
resources: emptyState().index
resources: emptyState().index,
confirmStepDeletion: false,
};
}

View file

@ -20,9 +20,6 @@ import * as _ from "lodash";
import { overwrite } from "../../api/crud";
import { TileFindHome } from "./tile_find_home";
import { t } from "i18next";
import { Session } from "../../session";
import { BooleanSetting } from "../../session_keys";
import { MarkAs } from "./mark_as";
interface MoveParams {
step: Step;
@ -65,10 +62,12 @@ interface RemoveParams {
index: number;
dispatch: Function;
sequence: TaggedSequence;
confirmStepDeletion: boolean;
}
export function remove({ dispatch, index, sequence }: RemoveParams) {
if (!Session.deprecatedGetBool(BooleanSetting.confirm_step_deletion) ||
export function remove(props: RemoveParams) {
const { dispatch, index, sequence, confirmStepDeletion } = props;
if (!confirmStepDeletion ||
confirm(t("Are you sure you want to delete this step?"))) {
const original = sequence;
const update = defensiveClone(original);

View file

@ -21,7 +21,8 @@ export function ExecuteBlock(p: StepParams) {
index={p.index}
dispatch={p.dispatch}
resources={p.resources}
shouldDisplay={p.shouldDisplay || (() => false)} />;
shouldDisplay={p.shouldDisplay || (() => false)}
confirmStepDeletion={p.confirmStepDeletion} />;
} else {
throw new Error("Thats not an execute block!");
}
@ -34,6 +35,7 @@ export interface ExecBlockParams {
index: number;
resources: ResourceIndex;
shouldDisplay: ShouldDisplay;
confirmStepDeletion: boolean;
}
export class RefactoredExecuteBlock extends React.Component<ExecBlockParams, {}> {
changeSelection = (input: DropDownItem) => {
@ -99,7 +101,8 @@ export class RefactoredExecuteBlock extends React.Component<ExecBlockParams, {}>
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={this.props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={12}>

View file

@ -9,8 +9,8 @@ import { editStep } from "../../api/crud";
import { ExecuteScript, FarmwareConfig } from "farmbot";
import { FarmwareInputs, farmwareList } from "./tile_execute_script_support";
export function TileExecuteScript({
dispatch, currentStep, index, currentSequence, farmwareInfo }: StepParams) {
export function TileExecuteScript(props: StepParams) {
const { dispatch, currentStep, index, currentSequence, farmwareInfo } = props;
if (currentStep.kind === "execute_script") {
const farmwareName = currentStep.args.label;
@ -63,7 +63,8 @@ export function TileExecuteScript({
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={12}>

View file

@ -20,18 +20,20 @@ export function TileFindHome(props: StepParams) {
dispatch={props.dispatch}
index={props.index}
resources={props.resources}
hardwareFlags={props.hardwareFlags} />;
hardwareFlags={props.hardwareFlags}
confirmStepDeletion={props.confirmStepDeletion} />;
} else {
throw new Error("TileFindHome expects find_home");
}
}
interface FindHomeParams {
export interface FindHomeParams {
currentStep: FindHome;
currentSequence: TaggedSequence;
dispatch: Function;
index: number;
resources: ResourceIndex;
hardwareFlags: HardwareFlags | undefined;
confirmStepDeletion: boolean;
}
const AXIS_CHOICES: ALLOWED_AXIS[] = ["x", "y", "z", "all"];
@ -87,7 +89,8 @@ class InnerFindHome extends React.Component<FindHomeParams, {}> {
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index}>
index={index}
confirmStepDeletion={this.props.confirmStepDeletion}>
{some(this.settingConflicts) &&
<StepWarning
warning={this.settingConflictWarning}

View file

@ -10,7 +10,8 @@ export function TileIf(props: StepParams) {
dispatch={props.dispatch}
index={props.index}
resources={props.resources}
shouldDisplay={props.shouldDisplay} />;
shouldDisplay={props.shouldDisplay}
confirmStepDeletion={props.confirmStepDeletion} />;
} else {
return <p> Expected "_if" node</p>;
}

View file

@ -23,7 +23,8 @@ describe("<Else/>", () => {
currentStep,
dispatch: jest.fn(),
index: 0,
resources: emptyState().index
resources: emptyState().index,
confirmStepDeletion: false,
};
}

View file

@ -25,6 +25,7 @@ describe("<If_/>", () => {
index: 0,
resources: emptyState().index,
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
};
}

View file

@ -46,6 +46,7 @@ function fakeProps(): IfParams {
index: 0,
resources: fakeResourceIndex,
shouldDisplay: jest.fn(),
confirmStepDeletion: false,
};
}

View file

@ -23,7 +23,8 @@ describe("<Then/>", () => {
currentStep,
dispatch: jest.fn(),
index: 0,
resources: emptyState().index
resources: emptyState().index,
confirmStepDeletion: false,
};
}

View file

@ -26,6 +26,7 @@ export interface IfParams {
index: number;
resources: ResourceIndex;
shouldDisplay?: ShouldDisplay;
confirmStepDeletion: boolean;
}
export type Operator = "lhs"
@ -88,7 +89,8 @@ export function InnerIf(props: IfParams) {
index,
dispatch,
currentStep,
currentSequence
currentSequence,
confirmStepDeletion,
} = props;
const recursive = isRecursive(currentStep, currentSequence);
const className = "if-step";
@ -99,7 +101,8 @@ export function InnerIf(props: IfParams) {
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index}>
index={index}
confirmStepDeletion={confirmStepDeletion}>
{recursive && (
<span>
<i className="fa fa-exclamation-triangle"></i>

View file

@ -169,7 +169,8 @@ export class TileMoveAbsolute extends Component<StepParams, MoveAbsState> {
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index}>
index={index}
confirmStepDeletion={this.props.confirmStepDeletion}>
{_.some(this.settingConflicts) &&
<StepWarning
warning={this.settingConflictWarning}

View file

@ -6,8 +6,8 @@ import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { Row, Col } from "../../ui/index";
export function TileMoveRelative({
dispatch, currentStep, index, currentSequence }: StepParams) {
export function TileMoveRelative(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
const className = "move-relative-step";
return <StepWrapper>
<StepHeader
@ -16,7 +16,8 @@ export function TileMoveRelative({
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={6} md={3}>

View file

@ -35,7 +35,8 @@ export function TileReadPin(props: StepParams) {
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={6} md={6}>

View file

@ -23,7 +23,8 @@ export function TileSendMessage(props: StepParams) {
currentSequence={props.currentSequence}
dispatch={props.dispatch}
index={props.index}
resources={props.resources} />;
resources={props.resources}
confirmStepDeletion={props.confirmStepDeletion} />;
} else {
throw new Error("TileSendMessage expects send_message");
}
@ -35,6 +36,7 @@ interface SendMessageParams {
dispatch: Function;
index: number;
resources: ResourceIndex;
confirmStepDeletion: boolean;
}
export class RefactoredSendMessage
@ -102,7 +104,8 @@ export class RefactoredSendMessage
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={this.props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={12}>

View file

@ -6,8 +6,8 @@ import { StepWrapper, StepHeader, StepContent } from "../step_ui";
import { Col, Row } from "../../ui/index";
import { t } from "i18next";
export function TileTakePhoto({
dispatch, currentStep, index, currentSequence }: StepParams) {
export function TileTakePhoto(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
const className = "take-photo-step";
return <StepWrapper>
<StepHeader
@ -16,7 +16,8 @@ export function TileTakePhoto({
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={12}>

View file

@ -6,8 +6,8 @@ import { ToolTips } from "../../constants";
import { StepWrapper, StepHeader, StepContent } from "../step_ui/index";
import { Row, Col } from "../../ui/index";
export function TileWait({
dispatch, currentStep, index, currentSequence }: StepParams) {
export function TileWait(props: StepParams) {
const { dispatch, currentStep, index, currentSequence } = props;
const className = "wait-step";
return <StepWrapper>
<StepHeader
@ -16,7 +16,8 @@ export function TileWait({
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={6} md={3}>

View file

@ -46,7 +46,8 @@ export function TileWritePin(props: StepParams) {
currentSequence={currentSequence}
currentStep={currentStep}
dispatch={dispatch}
index={index} />
index={index}
confirmStepDeletion={props.confirmStepDeletion} />
<StepContent className={className}>
<Row>
<Col xs={6} md={6}>

View file

@ -30,7 +30,8 @@ describe("<StepHeader />", () => {
currentStep: { kind: "take_photo", args: {} },
dispatch: jest.fn(),
index: 0,
children: "child"
children: "child",
confirmStepDeletion: false,
};
it("renders", () => {

View file

@ -13,6 +13,7 @@ export interface StepHeaderProps {
currentStep: SequenceBodyItem;
dispatch: Function;
index: number;
confirmStepDeletion: boolean;
}
export function StepHeader(props: StepHeaderProps) {
@ -22,7 +23,8 @@ export function StepHeader(props: StepHeaderProps) {
currentSequence,
currentStep,
dispatch,
index
index,
confirmStepDeletion,
} = props;
return <Row>
<Col sm={12}>
@ -37,7 +39,8 @@ export function StepHeader(props: StepHeaderProps) {
dispatch={dispatch}
step={currentStep}
sequence={currentSequence}
helpText={t(helpText)} />
helpText={t(helpText)}
confirmStepDeletion={confirmStepDeletion} />
{props.children}
</div>
</Col>

View file

@ -2,7 +2,6 @@ import { AuthState } from "./auth/interfaces";
import { box } from "boxed_value";
import { BooleanConfigKey, NumberConfigKey } from "./config_storage/web_app_configs";
import { BooleanSetting, NumericSetting } from "./session_keys";
import * as LegacyShim from "./config/legacy_shims";
/** The `Session` namespace is a wrapper for `localStorage`.
* Use this to avoid direct access of `localStorage` where possible.
@ -43,33 +42,6 @@ export namespace Session {
window.location.assign(window.location.origin || "/");
return undefined as never;
}
/** @deprecated Don't use this anymore. This is a legacy articfact of when we
* used localStorage to store API settings. */
export function deprecatedGetBool(key: BooleanConfigKey): boolean | undefined {
return LegacyShim.getBoolViaRedux(key);
}
/** @deprecated Store a boolean value in `localStorage` */
export function setBool(key: BooleanConfigKey, val: boolean): boolean {
return LegacyShim.setBoolViaRedux(key, val);
}
/** @deprecated */
export function invertBool(key: BooleanConfigKey): boolean {
return Session.setBool(key, !Session.deprecatedGetBool(key));
}
/** @deprecated Extract numeric settings from `localStorage`. Returns `undefined` when
* none are found. */
export function deprecatedGetNum(key: NumberConfigKey): number | undefined {
return LegacyShim.getNumViaRedux(key);
}
/** @deprecated Set a numeric value in `localStorage`. */
export function deprecatedSetNum(key: NumberConfigKey, val: number): void {
LegacyShim.setNumViaRedux(key, val);
}
}
export const isBooleanSetting =

View file

@ -14,6 +14,7 @@ export const BooleanSetting: Record<BooleanConfigKey, BooleanConfigKey> = {
show_images: "show_images",
xy_swap: "xy_swap",
home_button_homing: "home_button_homing",
show_motor_plot: "show_motor_plot",
/** "Labs" feature names. (App preferences) */
stub_config: "stub_config",

View file

@ -24,6 +24,13 @@ describe("<FBSelect />", () => {
expect(wrapper.text()).toEqual("Item");
});
it("renders custom null label", () => {
const p = fakeProps();
p.customNullLabel = "Other";
const wrapper = mount(<FBSelect {...p} />);
expect(wrapper.text()).toEqual("Other");
});
it("allows empty", () => {
const p = fakeProps();
p.allowEmpty = true;