input box updates

pull/1094/head
gabrielburnworth 2019-01-13 15:39:26 -08:00
parent 5e7c9cdd1c
commit ed56ef7fb5
24 changed files with 298 additions and 102 deletions

View File

@ -9,6 +9,7 @@ import {
} from "./interfaces";
import * as _ from "lodash";
/** Coordinate input and GO button for Move widget. */
export class AxisInputBoxGroup extends
React.Component<AxisInputBoxGroupProps, Partial<AxisInputBoxGroupState>> {
constructor(props: AxisInputBoxGroupProps) {

View File

@ -6,6 +6,7 @@ import { Xyz } from "../../devices/interfaces";
import { AxisInputBox } from "../axis_input_box";
import { isNumber } from "lodash";
import { LocationSelectionProps } from "./interfaces";
import { parseIntInput } from "../../util";
/** Select a location filter for sensor readings. */
export const LocationSelection =
@ -33,8 +34,9 @@ export const LocationSelection =
}} />)}
<Col xs={3}>
<BlurableInput
type="number"
value={deviation}
onCommit={e => setDeviation(parseInt(e.currentTarget.value))} />
onCommit={e => setDeviation(parseIntInput(e.currentTarget.value))} />
</Col>
</Row>
</div>;

View File

@ -31,6 +31,27 @@ input:not([role="combobox"]) {
&.dim {
background: darken($white, 2%) !important;
}
&.error {
border: 2px solid $red;
background-color: $white !important;
color: $red;
}
}
.input-error-wrapper {
height: 0;
}
.input-error {
position: absolute;
top: 0;
right: 0;
width: 15px;
height: 15px;
color: $white;
background: $red;
padding: 2px;
font-size: 12px;
}
.day-selector-wrapper {

View File

@ -19,7 +19,7 @@ describe("McuInputBox", () => {
};
};
it("clamps numbers", () => {
it("clamps negative numbers", () => {
const mib = new McuInputBox(fakeProps());
const result = mib.clampInputAndWarn("-1", "short");
expect(result).toEqual(0);
@ -27,7 +27,15 @@ describe("McuInputBox", () => {
.toHaveBeenCalledWith("Must be a positive number. Rounding up to 0.");
});
it("clamps numbers", () => {
it("clamps large numbers", () => {
const mib = new McuInputBox(fakeProps());
const result = mib.clampInputAndWarn("100000", "short");
expect(result).toEqual(32000);
expect(warning)
.toHaveBeenCalledWith("Maximum input is 32,000. Rounding down.");
});
it("handles bad input", () => {
const mib = new McuInputBox(fakeProps());
expect(() => mib.clampInputAndWarn("QQQ", "short"))
.toThrowError("Bad input in mcu_input_box. Impossible?");

View File

@ -4,6 +4,7 @@ import { BlurableInput } from "../../ui/index";
import { SourceFbosConfig } from "../interfaces";
import { ConfigurationName } from "farmbot/dist";
import { updateConfig } from "../actions";
import { parseIntInput } from "../../util";
export interface BotConfigInputBoxProps {
setting: ConfigurationName;
@ -12,6 +13,9 @@ export interface BotConfigInputBoxProps {
sourceFbosConfig: SourceFbosConfig;
}
/**
* Currently only used for `network_not_found_timer` and `steps_per_mm_?`.
*/
export class BotConfigInputBox
extends React.Component<BotConfigInputBoxProps, {}> {
@ -21,7 +25,7 @@ export class BotConfigInputBox
change = (key: ConfigurationName, dispatch: Function) => {
return (event: React.FormEvent<HTMLInputElement>) => {
const next = parseInt(event.currentTarget.value, 10);
const next = parseIntInput(event.currentTarget.value);
const current = this.config.value;
if (!_.isNaN(next) && (next !== current)) {
dispatch(updateConfig({ [key]: next }));

View File

@ -10,11 +10,11 @@ import { updateMCU } from "../../../actions";
import {
fakeFirmwareConfig
} from "../../../../__test_support__/fake_state/resources";
import { warning } from "farmbot-toastr";
import { warning, error } from "farmbot-toastr";
describe("<HomingAndCalibration />", () => {
function testAxisLengthInput(
fw: string, provided: string, expected: string) {
fw: string, provided: string, expected: string | undefined) {
const dispatch = jest.fn();
bot.controlPanelState.homing_and_calibration = true;
bot.hardware.informational_settings.firmware_version = fw;
@ -31,23 +31,26 @@ describe("<HomingAndCalibration />", () => {
const input = result.find("input").first().props();
input.onChange && input.onChange(e);
input.onSubmit && input.onSubmit(e);
expect(updateMCU)
.toHaveBeenCalledWith("movement_axis_nr_steps_x", expected);
expected
? expect(updateMCU)
.toHaveBeenCalledWith("movement_axis_nr_steps_x", expected)
: expect(updateMCU).not.toHaveBeenCalled();
}
it("short int", () => {
testAxisLengthInput("5.0.0", "100000", "32000");
expect(warning)
.toHaveBeenCalledWith("Maximum input is 32,000. Rounding down.");
testAxisLengthInput("5.0.0", "100000", undefined);
expect(error)
.toHaveBeenCalledWith("Value must be less than or equal to 32000.");
});
it("long int: too long", () => {
testAxisLengthInput("6.0.0", "10000000000", "2000000000");
expect(warning)
.toHaveBeenCalledWith("Maximum input is 2,000,000,000. Rounding down.");
testAxisLengthInput("6.0.0", "10000000000", undefined);
expect(error)
.toHaveBeenCalledWith("Value must be less than or equal to 2000000000.");
});
it("long int: ok", () => {
testAxisLengthInput("6.0.0", "100000", "100000");
expect(warning).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
});

View File

@ -4,7 +4,7 @@ import { warning } from "farmbot-toastr";
import { McuInputBoxProps } from "../interfaces";
import { updateMCU } from "../actions";
import { BlurableInput } from "../../ui/index";
import { clampUnsignedInteger, IntegerSize } from "../../util";
import { clampUnsignedInteger, IntegerSize, getMaxInputFromIntSize } from "../../util";
import { t } from "i18next";
export class McuInputBox extends React.Component<McuInputBoxProps, {}> {
@ -63,6 +63,8 @@ export class McuInputBox extends React.Component<McuInputBoxProps, {}> {
type="number"
className={this.className}
value={this.value}
onCommit={this.commit} />;
onCommit={this.commit}
min={0}
max={getMaxInputFromIntSize(this.props.intSize)} />;
}
}

View File

@ -1,14 +1,16 @@
jest.mock("../../../history", () => ({
history: {
push: jest.fn()
}
jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
overwrite: jest.fn(),
save: jest.fn(),
}));
import * as React from "react";
import {
fakeFarmEvent, fakeSequence, fakeRegimen
} from "../../../__test_support__/fake_state/resources";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import {
EditFEForm,
EditFEProps,
@ -17,7 +19,7 @@ import {
destructureFarmEvent,
offsetTime
} from "../edit_fe_form";
import { isString } from "lodash";
import { isString, isFunction } from "lodash";
import { repeatOptions } from "../map_state_to_props_add_edit";
import { SpecialStatus, VariableDeclaration } from "farmbot";
import { success, error } from "farmbot-toastr";
@ -28,6 +30,8 @@ import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { fakeVariableNameSet } from "../../../__test_support__/fake_variables";
import { clickButton } from "../../../__test_support__/helpers";
import { destroy } from "../../../api/crud";
const mockSequence = fakeSequence();
@ -261,11 +265,7 @@ describe("<FarmEventForm/>", () => {
const p = props();
const state = fakeState();
state.resources.index.references = { [p.farmEvent.uuid]: p.farmEvent };
p.dispatch = jest.fn((x) => {
x(() => { }, () => state);
return Promise.resolve();
})
.mockImplementationOnce(() => Promise.resolve());
p.dispatch = jest.fn(x => { isFunction(x) && x(); return Promise.resolve(); });
p.farmEvent.body.executable_type = "Regimen";
p.farmEvent.body.start_time = "2017-05-22T05:00:00.000Z";
p.farmEvent.body.end_time = "2017-05-22T06:00:00.000Z";
@ -478,6 +478,46 @@ describe("<FarmEventForm/>", () => {
const inst = instance(p);
expect(inst.updatedFarmEvent.body).toEqual([oldDeclaration]);
});
it("deletes a farmEvent", async () => {
const p = props();
p.dispatch = jest.fn(() => Promise.resolve());
const inst = instance(p);
const wrapper = shallow(<inst.FarmEventDeleteButton />);
clickButton(wrapper, 0, "delete");
await expect(destroy).toHaveBeenCalledWith(p.farmEvent.uuid);
expect(history.push).toHaveBeenCalledWith("/app/designer/farm_events");
expect(success).toHaveBeenCalledWith("Deleted farm event.", "Deleted");
});
it("sets repeat", () => {
const p = props();
p.dispatch = jest.fn(() => Promise.resolve());
const e = {
currentTarget: { checked: true }
} as React.ChangeEvent<HTMLInputElement>;
const inst = instance(p);
inst.toggleRepeat(e);
expect(inst.state).toEqual({
fe: { timeUnit: "daily" },
specialStatusLocal: SpecialStatus.DIRTY
});
});
it("sets repeat: regimen", () => {
const p = props();
p.farmEvent.body.executable_type = "Regimen";
p.dispatch = jest.fn(() => Promise.resolve());
const e = {
currentTarget: { checked: true }
} as React.ChangeEvent<HTMLInputElement>;
const inst = instance(p);
inst.toggleRepeat(e);
expect(inst.state).toEqual({
fe: { timeUnit: "never" },
specialStatusLocal: SpecialStatus.DIRTY
});
});
});
describe("destructureFarmEvent", () => {

View File

@ -18,7 +18,7 @@ import {
import { destroy, save, overwrite } from "../../api/crud";
import { history } from "../../history";
// TIL: https://stackoverflow.com/a/24900248/1064917
import { betterMerge } from "../../util";
import { betterMerge, parseIntInput } from "../../util";
import { maybeWarnAboutMissedTasks } from "./util";
import { FarmEventRepeatForm } from "./farm_event_repeat_form";
import { scheduleForFarmEvent } from "./calendar/scheduler";
@ -109,7 +109,7 @@ export function recombine(vm: FarmEventViewModel,
end_time: offsetTime(vm.endDate, vm.endTime, vm.timeOffset),
repeat: parseInt(vm.repeat, 10) || 1,
time_unit: (isReg ? "never" : vm.timeUnit) as TimeUnit,
executable_id: parseInt(vm.executable_id, 10),
executable_id: parseIntInput(vm.executable_id),
executable_type: vm.executable_type as ("Sequence" | "Regimen"),
body: vm.body,
};
@ -418,6 +418,26 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
&nbsp;{t("Repeats?")}
</label> : <div />
dateCheck = (): string | undefined => {
const startDate = this.fieldGet("startDate");
const endDate = this.fieldGet("endDate");
if (!moment(endDate).isSameOrAfter(moment(startDate))) {
return t("End date must not be before start date.");
}
}
timeCheck = (): string | undefined => {
const startDate = this.fieldGet("startDate");
const startTime = this.fieldGet("startTime");
const endDate = this.fieldGet("endDate");
const endTime = this.fieldGet("endTime");
const start = offsetTime(startDate, startTime, this.props.timeOffset);
const end = offsetTime(endDate, endTime, this.props.timeOffset);
if (moment(start).isSameOrAfter(moment(end))) {
return t("End time must be after start time.");
}
}
RepeatForm = () => {
const allowRepeat = !this.isReg && this.repeats;
return <div>
@ -430,7 +450,9 @@ export class EditFEForm extends React.Component<EditFEProps, State> {
timeUnit={this.fieldGet("timeUnit") as TimeUnit}
repeat={this.fieldGet("repeat")}
endDate={this.fieldGet("endDate")}
endTime={this.fieldGet("endTime")} />
endTime={this.fieldGet("endTime")}
dateError={this.dateCheck()}
timeError={this.timeCheck()} />
</div>;
}

View File

@ -11,6 +11,7 @@ interface Props {
hidden?: boolean;
name: string;
className: string;
error?: string;
}
export function EventTimePicker(props: Props) {
@ -22,5 +23,6 @@ export function EventTimePicker(props: Props) {
type="time"
className="add-event-start-time"
value={value}
onCommit={onCommit} />;
onCommit={onCommit}
error={props.error} />;
}

View File

@ -23,6 +23,8 @@ export interface RepeatFormProps {
endDate: string;
endTime: string;
tzOffset: number;
dateError?: string;
timeError?: string;
}
const indexKey: keyof DropDownItem = "value";
@ -45,7 +47,8 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
className="add-event-repeat-frequency"
name="repeat"
value={repeat}
onCommit={changeHandler("repeat")} />
onCommit={changeHandler("repeat")}
min={1} />
</Col>
<Col xs={8}>
<FBSelect
@ -65,7 +68,8 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
className="add-event-end-date"
name="endDate"
value={endDate}
onCommit={changeHandler("endDate")} />
onCommit={changeHandler("endDate")}
error={props.dateError} />
</Col>
<Col xs={6}>
<EventTimePicker
@ -74,7 +78,8 @@ export function FarmEventRepeatForm(props: RepeatFormProps) {
name="endTime"
tzOffset={props.tzOffset}
value={endTime}
onCommit={changeHandler("endTime")} />
onCommit={changeHandler("endTime")}
error={props.timeError} />
</Col>
</Row>
</div>;

View File

@ -14,6 +14,7 @@ import { GenericPointer } from "farmbot/dist/resources/api_resources";
import {
DesignerPanel, DesignerPanelHeader, DesignerPanelContent
} from "./designer_panel";
import { parseIntInput } from "../../util";
export function mapStateToProps(props: Everything) {
return {
@ -77,7 +78,7 @@ export class CreatePoints
update = (key: keyof CreatePointsState) => {
return (e: React.SyntheticEvent<HTMLInputElement>) => {
const value = parseInt(e.currentTarget.value);
const value = parseIntInput(e.currentTarget.value);
this.setState({ [key]: value });
if (this.props.currentPoint) {
const point = clone(this.props.currentPoint);
@ -142,7 +143,8 @@ export class CreatePoints
name="r"
type="number"
onCommit={this.update("r")}
value={r || 0} />
value={r || 0}
min={0} />
</Col>
<Col xs={3}>
<label>{t("color")}</label>

View File

@ -7,6 +7,7 @@ import { WeedDetectorSlider } from "./slider";
import { TaggedImage } from "farmbot";
import { t } from "i18next";
import { PhotoFooter } from "../images/photos";
import { parseIntInput } from "../../util";
const RANGES = {
H: { LOWEST: 0, HIGHEST: 179 },
@ -53,7 +54,7 @@ export class ImageWorkspace extends React.Component<ImageWorkspaceProps, {}> {
/** Generates a function to handle changes to blur/morph/iteration. */
numericChange = (key: NumericKeyName) =>
(e: React.SyntheticEvent<HTMLInputElement>) => {
this.props.onChange(key, parseInt(e.currentTarget.value, 10) || 0);
this.props.onChange(key, parseIntInput(e.currentTarget.value) || 0);
};
maybeProcessPhoto = () => {

View File

@ -8,13 +8,13 @@ import { shallow, mount } from "enzyme";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { FBSelect } from "../../../ui/index";
import { FBSelect, BlurableInput } from "../../../ui/index";
import {
LocationFormProps, LocalsListProps, PARENT, AllowedDeclaration
} from "../locals_list_support";
import { difference } from "lodash";
import { VariableNameSet } from "../../../resources/interfaces";
import { InputBox, generateList } from "../../step_tiles/tile_move_absolute/index";
import { generateList } from "../../step_tiles/tile_move_absolute/generate_list";
import { convertDDItoDeclaration } from "../handle_select";
describe("<LocationForm/>", () => {
@ -38,7 +38,7 @@ describe("<LocationForm/>", () => {
const p = fakeProps();
const el = shallow(<LocationForm {...p} />);
const selects = el.find(FBSelect);
const inputs = el.find(InputBox);
const inputs = el.find(BlurableInput);
expect(selects.length).toBe(1);
const select = selects.first().props();

View File

@ -1,9 +1,7 @@
import * as React from "react";
import { Row, Col, FBSelect } from "../../ui";
import { Row, Col, FBSelect, BlurableInput } from "../../ui";
import { t } from "i18next";
import {
generateList, InputBox
} from "../step_tiles/tile_move_absolute/index";
import { generateList } from "../step_tiles/tile_move_absolute/generate_list";
import {
convertDDItoDeclaration, addOrEditDeclaration
} from "../locals_list/handle_select";
@ -112,13 +110,14 @@ export const LocationForm =
<Row>
{["x", "y", "z"].map((axis: Xyz) =>
<Col xs={props.width || 4} key={axis}>
<InputBox
onCommit={manuallyEditAxis({ ...axisPartialProps, axis })}
disabled={isDisabled}
name={`location-${axis}`}
value={"" + vector[axis]}>
<label>
{t("{{axis}} (mm)", { axis })}
</InputBox>
</label>
<BlurableInput type="number"
disabled={isDisabled}
onCommit={manuallyEditAxis({ ...axisPartialProps, axis })}
name={`location-${axis}`}
value={"" + vector[axis]} />
</Col>)}
</Row>}
</div>;

View File

@ -15,7 +15,7 @@ import {
ScopeDeclarationBodyItem,
VariableDeclaration
} from "farmbot";
import { Row, Col } from "../../ui/index";
import { Row, Col, BlurableInput } from "../../ui/index";
import {
isTaggedSequence,
} from "../../resources/tagged_resources";
@ -27,7 +27,6 @@ import {
import { defensiveClone, betterMerge } from "../../util";
import { overwrite } from "../../api/crud";
import { Xyz } from "../../devices/interfaces";
import { InputBox } from "./tile_move_absolute/index";
import { ToolTips } from "../../constants";
import {
StepWrapper,
@ -201,12 +200,13 @@ export class TileMoveAbsolute extends Component<StepParams, MoveAbsState> {
<Row>
{["x", "y", "z"].map((axis: Xyz) =>
<Col xs={3} key={axis}>
<InputBox
<label>
{t("{{axis}}-Offset", { axis })}
</label>
<BlurableInput type="number"
onCommit={this.updateInputValue(axis, "offset")}
name={`offset-${axis}`}
value={this.getOffsetValue(axis)}>
{t("{{axis}}-Offset", { axis })}
</InputBox>
value={this.getOffsetValue(axis)} />
</Col>)}
<this.SpeedForm />
</Row>

View File

@ -1,4 +0,0 @@
export * from "./generate_list";
export * from "./interfaces";
export * from "./variables_support";
export * from "./input_box";

View File

@ -1,17 +0,0 @@
import * as React from "react";
import { BlurableInput } from "../../../ui/index";
import { InputBoxProps } from "./interfaces";
export function InputBox(p: InputBoxProps) {
return <div>
<label>
{p.children}
</label>
<BlurableInput
disabled={!!p.disabled}
onCommit={p.onCommit}
type="number"
name={p.name}
value={p.value} />
</div>;
}

View File

@ -45,4 +45,39 @@ describe("<BlurableInput />", () => {
expect(error).toHaveBeenCalledWith(
"Value must be less than or equal to 100.");
});
it("checks for non-number input", () => {
const p = fakeProps();
p.type = "number";
const wrapper = shallow<BlurableInput>(<BlurableInput {...p} />);
wrapper.find("input").simulate("change", { currentTarget: { value: "" } });
expect(wrapper.instance().state.buffer).toEqual("");
expect(wrapper.instance().state.error).toEqual("Please enter a number.");
wrapper.find("input").simulate("submit");
expect(p.onCommit).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("checks for non-number input", () => {
const p = fakeProps();
p.type = "number";
p.allowEmpty = true;
const wrapper = shallow<BlurableInput>(<BlurableInput {...p} />);
wrapper.find("input").simulate("change", { currentTarget: { value: "" } });
expect(wrapper.instance().state.buffer).toEqual("");
expect(wrapper.instance().state.error).toEqual(undefined);
wrapper.find("input").simulate("submit");
expect(p.onCommit).toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("parses number", () => {
const p = fakeProps();
p.type = "number";
const wrapper = shallow<BlurableInput>(<BlurableInput {...p} />);
const e = { currentTarget: { value: "-1.1e+2" } };
wrapper.setState({ buffer: e.currentTarget.value });
wrapper.find("input").simulate("change", e);
expect(wrapper.instance().state.buffer).toEqual(e.currentTarget.value);
});
});

View File

@ -1,8 +1,9 @@
import * as React from "react";
import { equals } from "../util";
import { equals, parseIntInput } from "../util";
import { isNumber } from "lodash";
import { error } from "farmbot-toastr";
import { t } from "i18next";
import { InputError } from "./input_error";
export interface BIProps {
value: string | number;
@ -25,49 +26,69 @@ export interface BIProps {
className?: string;
placeholder?: string;
hidden?: boolean;
error?: string;
title?: string;
}
interface BIState {
buffer: string;
isEditing: boolean;
error: string | undefined;
}
export class BlurableInput extends React.Component<BIProps, Partial<BIState>> {
state: BIState = { buffer: "", isEditing: false };
state: BIState = { buffer: "", isEditing: false, error: undefined };
get error() { return this.props.error || this.state.error; }
withinLimits = (options?: { toasts?: boolean }): boolean => {
const onError = (msg: string) => {
this.setState({ error: msg });
options && options.toasts && error(msg);
};
withinLimits = (): boolean => {
if (this.props.type === "number") {
const value = parseInt(this.state.buffer);
const value = parseIntInput(this.state.buffer);
if (isNumber(this.props.min) && value < this.props.min) {
error(t("Value must be greater than or equal to {{min}}.",
onError(t("Value must be greater than or equal to {{min}}.",
{ min: this.props.min }));
return false;
}
if (isNumber(this.props.max) && value > this.props.max) {
error(t("Value must be less than or equal to {{max}}.",
onError(t("Value must be less than or equal to {{max}}.",
{ max: this.props.max }));
return false;
}
/** `e.currentTarget.value` is "" for any invalid number input. */
if ((this.state.buffer === "") && !this.props.allowEmpty) {
onError(t("Please enter a number."));
return false;
}
}
this.setState({ error: undefined });
return true;
}
/** Called on blur. */
maybeCommit = (e: React.SyntheticEvent<HTMLInputElement>) => {
const bufferOk = this.state.buffer || this.props.allowEmpty;
const shouldPassToParent = bufferOk && this.withinLimits();
const shouldPassToParent = bufferOk && this.withinLimits({ toasts: true });
shouldPassToParent && this.props.onCommit(e);
this.setState({ isEditing: false, buffer: "" });
this.setState({ isEditing: false, buffer: "", error: undefined });
}
focus = () => {
const { value } = this.props;
this.setState({ isEditing: true, buffer: "" + (value || "") });
this.setState({
isEditing: true,
buffer: "" + (value || ""),
error: undefined
});
}
updateBuffer = (e: React.SyntheticEvent<HTMLInputElement>) => {
this.setState({ buffer: e.currentTarget.value });
this.setState({ buffer: e.currentTarget.value }, this.withinLimits);
}
usualProps = () => {
@ -86,7 +107,8 @@ export class BlurableInput extends React.Component<BIProps, Partial<BIState>> {
max: this.props.max,
type: this.props.type || "text",
disabled: this.props.disabled,
className: this.props.className,
className: (this.props.className || "") + (this.error ? " error" : ""),
title: this.props.title || "",
placeholder: this.props.placeholder,
};
}
@ -96,6 +118,9 @@ export class BlurableInput extends React.Component<BIProps, Partial<BIState>> {
}
render() {
return <input {...this.usualProps()} />;
return <div>
<InputError error={this.error} />
<input {...this.usualProps()} />
</div>;
}
}

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { Popover, PopoverInteractionKind, Position } from "@blueprintjs/core";
interface InputErrorProps {
error?: string;
}
export const InputError = (props: InputErrorProps) =>
props.error
? <Popover
minimal={true}
usePortal={false}
position={Position.TOP_LEFT}
modifiers={{ offset: { offset: "0, 20" } }}
interactionKind={PopoverInteractionKind.HOVER}
className="input-error-wrapper">
<i className="fa fa-exclamation-circle input-error" />
<p>{props.error}</p>
</Popover>
: <div />;

View File

@ -1,7 +1,7 @@
import * as Util from "../util";
import { times } from "lodash";
import { validBotLocationData } from "../index";
import { parseClassNames } from "../../ui/util";
describe("util", () => {
describe("safeStringFetch", () => {
const data = {
@ -127,7 +127,7 @@ describe("util", () => {
describe("validBotLocationData()", () => {
it("returns valid location_data object", () => {
const result = validBotLocationData(undefined);
const result = Util.validBotLocationData(undefined);
expect(result).toEqual({
position: { x: undefined, y: undefined, z: undefined },
scaled_encoders: { x: undefined, y: undefined, z: undefined },
@ -176,3 +176,16 @@ describe("parseClassNames", () => {
].map(string => expect(results).toContain(string));
});
});
describe("parseIntInput()", () => {
it("parses int from number input", () => {
expect(Util.parseIntInput("-1.1e+2")).toEqual(-110);
expect(Util.parseIntInput("-1.1e-1")).toEqual(0);
expect(Util.parseIntInput("1.1E1")).toEqual(11);
expect(Util.parseIntInput("+123")).toEqual(123);
expect(Util.parseIntInput("1.5")).toEqual(1);
expect(Util.parseIntInput("e")).toEqual(NaN);
expect(Util.parseIntInput("")).toEqual(NaN);
});
});

View File

@ -1,4 +1,5 @@
import * as _ from "lodash";
import { parseIntInput } from "./util";
/** The firmware will have an integer overflow if you don't check this one. */
const MAX_SHORT_INPUT = 32000;
@ -13,24 +14,26 @@ type ClampResult = High | Low | Malformed | Ok;
export type IntegerSize = "short" | "long" | undefined;
export const getMaxInputFromIntSize = (size: IntegerSize) => {
switch (size) {
case "long":
return MAX_LONG_INPUT;
case "short":
default:
return MAX_SHORT_INPUT;
}
};
/** Handle all the possible ways a user could give us bad data or cause an
* integer overflow in the firmware. */
export function clampUnsignedInteger(
input: string, size: IntegerSize): ClampResult {
const result = Math.round(parseInt(input, 10));
const maxInput = () => {
switch (size) {
case "long":
return MAX_LONG_INPUT;
case "short":
default:
return MAX_SHORT_INPUT;
}
};
const result = parseIntInput(input);
// Clamp to prevent overflow.
if (_.isNaN(result)) { return { outcome: "malformed", result: undefined }; }
if (result > maxInput()) { return { outcome: "high", result: maxInput() }; }
const max = getMaxInputFromIntSize(size);
if (result > max) { return { outcome: "high", result: max }; }
if (result < MIN_INPUT) { return { outcome: "low", result: MIN_INPUT }; }
return { outcome: "ok", result };

View File

@ -217,3 +217,12 @@ export function unpackUUID(uuid: string): BetterUUID {
remoteId: id > 0 ? id : undefined
};
}
/**
* Integer parsed from float
* since number type inputs allow floating point notation.
*/
export const parseIntInput = (input: string): number => {
const int = parseInt("" + parseFloat(input).toFixed(1), 10);
return int === 0 ? 0 : int;
};