Merge pull request #1691 from FarmBot/staging

v9.1.1 - Jolly Juniper
tool_deleteion
Rick Carlino 2020-02-10 10:43:19 -06:00 committed by GitHub
commit e490aa83f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
180 changed files with 4046 additions and 972 deletions

View File

@ -24,7 +24,7 @@ gem "scenic"
gem "secure_headers"
gem "tzinfo" # For validation of user selected timezone names
gem "valid_url"
# gem "farady", "~> 1.0.0"
gem "kaminari"
group :development, :test do
gem "climate_control"

View File

@ -153,6 +153,18 @@ GEM
json (2.3.0)
jsonapi-renderer (0.2.2)
jwt (2.2.1)
kaminari (1.2.0)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.0)
kaminari-activerecord (= 1.2.0)
kaminari-core (= 1.2.0)
kaminari-actionview (1.2.0)
actionview
kaminari-core (= 1.2.0)
kaminari-activerecord (1.2.0)
activerecord
kaminari-core (= 1.2.0)
kaminari-core (1.2.0)
loofah (2.4.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@ -320,6 +332,7 @@ DEPENDENCIES
google-cloud-storage (~> 1.11)
hashdiff
jwt
kaminari
mutations
passenger
pg

View File

@ -80,6 +80,16 @@ module Api
{ root: false, user: current_user }
end
def maybe_paginate(collection)
page = params[:page]
per = params[:per]
if page && per
render json: collection.page(page).per(per)
else
render json: collection
end
end
private
def clean_expired_farm_events

View File

@ -1,7 +1,7 @@
module Api
class AlertsController < Api::AbstractController
def index
render json: current_device.alerts
maybe_paginate current_device.alerts
end
def destroy

View File

@ -3,7 +3,7 @@ module Api
before_action :clean_expired_farm_events, only: [:index]
def index
render json: current_device.farm_events
maybe_paginate current_device.farm_events
end
def show

View File

@ -10,7 +10,7 @@ module Api
end
def index
render json: farmware_envs
maybe_paginate farmware_envs
end
def show

View File

@ -1,7 +1,7 @@
module Api
class FarmwareInstallationsController < Api::AbstractController
def index
render json: farmware_installations
maybe_paginate farmware_installations
end
def show

View File

@ -1,7 +1,7 @@
module Api
class PeripheralsController < Api::AbstractController
def index
render json: current_device.peripherals
maybe_paginate current_device.peripherals
end
def show

View File

@ -1,7 +1,7 @@
module Api
class PinBindingsController < Api::AbstractController
def index
render json: pin_bindings
maybe_paginate pin_bindings
end
def show

View File

@ -1,7 +1,7 @@
module Api
class PlantTemplatesController < Api::AbstractController
def index
render json: current_device.plant_templates
maybe_paginate current_device.plant_templates
end
def create

View File

@ -3,7 +3,7 @@ module Api
before_action :clean_expired_farm_events, only: [:destroy]
def index
render json: your_point_groups
maybe_paginate your_point_groups
end
def show

View File

@ -20,7 +20,7 @@ module Api
.where("discarded_at < ?", Time.now - HARD_DELETE_AFTER)
.destroy_all
render json: points(params.fetch(:filter) { "kept" })
maybe_paginate points(params.fetch(:filter) { "kept" })
end
def show

View File

@ -3,7 +3,7 @@ module Api
before_action :clean_expired_farm_events, only: [:destroy]
def index
render json: your_regimens
maybe_paginate your_regimens
end
def show

View File

@ -1,7 +1,7 @@
module Api
class SavedGardensController < Api::AbstractController
def index
render json: current_device.saved_gardens
maybe_paginate current_device.saved_gardens
end
def create

View File

@ -5,7 +5,7 @@ module Api
end
def index
render json: readings
maybe_paginate(readings)
end
def show

View File

@ -1,7 +1,7 @@
module Api
class SensorsController < Api::AbstractController
def index
render json: current_device.sensors
maybe_paginate current_device.sensors
end
def show

View File

@ -2,7 +2,7 @@ module Api
class ToolsController < Api::AbstractController
def index
render json: tools
maybe_paginate tools
end
def show

View File

@ -7,7 +7,7 @@ module Api
end
def index
render json: webcams
maybe_paginate webcams
end
def show

View File

@ -4,4 +4,8 @@ class PointGroupSerializer < ApplicationSerializer
def point_ids
object.point_group_items.pluck(:point_id)
end
def criteria
object.criteria || PointGroup::DEFAULT_CRITERIA
end
end

View File

@ -0,0 +1,8 @@
class AddShowZonesToWebAppConfig < ActiveRecord::Migration[6.0]
def change
add_column :web_app_configs,
:show_zones,
:boolean,
default: false
end
end

View File

@ -1,3 +1,5 @@
import * as React from "react";
jest.mock("browser-speech", () => ({
talk: jest.fn(),
}));
@ -16,3 +18,8 @@ window.location = {
pathname: "", href: "", hash: "", search: "",
hostname: "", origin: "", port: "", protocol: "", host: "",
};
jest.mock("../error_boundary", () => ({
// tslint:disable-next-line:no-any
ErrorBoundary: (p: any) => <div>{p.children}</div>,
}));

View File

@ -0,0 +1,36 @@
import { DeepPartial } from "redux";
type DomEvent = React.SyntheticEvent<HTMLInputElement>;
export const inputEvent = (value: string, name?: string): DomEvent => {
const event: DeepPartial<DomEvent> = { currentTarget: { value, name } };
return event as DomEvent;
};
type ChangeEvent = React.ChangeEvent<HTMLInputElement>;
export const changeEvent = (value: string): ChangeEvent => {
const event: DeepPartial<ChangeEvent> = { currentTarget: { value } };
return event as ChangeEvent;
};
type IMGEvent = React.SyntheticEvent<HTMLImageElement, Event>;
export const imgEvent = (): IMGEvent => {
const event: DeepPartial<IMGEvent> = {
currentTarget: {
getAttribute: jest.fn(),
setAttribute: jest.fn(),
}
};
return event as IMGEvent;
};
type FormEvent = React.FormEvent<HTMLFormElement>;
export const formEvent = (): FormEvent => {
const event: Partial<FormEvent> = { preventDefault: jest.fn() };
return event as FormEvent;
};
type DragEvent = React.DragEvent<HTMLElement>;
export const dragEvent = (key: string): DragEvent => {
const event: DeepPartial<DragEvent> = { dataTransfer: { getData: () => key } };
return event as DragEvent;
};

View File

@ -1,7 +0,0 @@
import { DeepPartial } from "redux";
type DomEvent = React.SyntheticEvent<HTMLInputElement>;
export const inputEvent = (value: string): DomEvent => {
const event: DeepPartial<DomEvent> = { currentTarget: { value } };
return event as DomEvent;
};

View File

@ -316,6 +316,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
show_historic_points: false,
time_format_24_hour: false,
show_pins: false,
show_zones: false,
disable_emergency_unlock_confirmation: false,
map_size_x: 2900,
map_size_y: 1400,
@ -459,7 +460,7 @@ export function fakePointGroup(): TaggedPointGroup {
sort_type: "xy_ascending",
point_ids: [],
criteria: {
day: { op: ">", days: 0 },
day: { op: "<", days: 0 },
number_eq: {},
number_gt: {},
number_lt: {},

View File

@ -1,3 +1,5 @@
jest.unmock("../error_boundary");
jest.mock("../util/errors.ts", () => ({ catchErrors: jest.fn() }));
import * as React from "react";

View File

@ -4,8 +4,6 @@ jest.mock("../index", () => ({
dispatchQosStart: jest.fn(),
pingOK: jest.fn()
}));
const mockTimestamp = 0;
jest.mock("../../util", () => ({ timestamp: () => mockTimestamp }));
import {
readPing,

View File

@ -572,12 +572,6 @@ export namespace Content {
export const CONFIRM_PLANT_DELETION =
trim(`Show a confirmation dialog when deleting a plant.`);
export const SORT_DESCRIPTION =
trim(`When executing a sequence over a Group of locations, FarmBot will
travel to each group member in the order of the chosen sort method.
If the random option is chosen, FarmBot will travel in a random order
every time, so the ordering shown below will only be representative.`);
// Device
export const NOT_HTTPS =
trim(`WARNING: Sending passwords via HTTP:// is not secure.`);
@ -824,6 +818,20 @@ export namespace Content {
trim(`You haven't made any sequences or regimens yet. To add an event,
first create a sequence or regimen.`);
// Groups
export const SORT_DESCRIPTION =
trim(`When executing a sequence over a Group of locations, FarmBot will
travel to each group member in the order of the chosen sort method.
If the random option is chosen, FarmBot will travel in a random order
every time, so the ordering shown below will only be representative.`);
export const CRITERIA_SELECTION_COUNT =
trim(`Criteria additions can only be removed by changing criteria.
Click and drag in the map to modify zone selection criteria.
Criteria will be applied at the time of sequence execution. The final
selection at that time may differ from the selection currently
displayed.`);
// Farmware
export const NO_IMAGES_YET =
trim(`You haven't yet taken any photos with your FarmBot.
@ -912,12 +920,12 @@ export namespace DiagnosticMessages {
network, a firewall may be blocking port 5672. Ensure that the blue LED
communications light on the FarmBot electronics box is illuminated.`);
export const WIFI_OR_CONFIG = trim(`Your browser is connected correctly, but
we have no recent record of FarmBot connecting to the internet. This usually
happens because of poor WiFi connectivity in the garden, a bad password during
configuration, a very long power outage, or blocked ports on FarmBot's local
network. Please refer IT staff to
https://software.farm.bot/docs/for-it-security-professionals`);
export const WIFI_OR_CONFIG = trim(`Your browser is connected correctly,
but we have no recent record of FarmBot connecting to the internet.
This usually happens because of poor WiFi connectivity in the garden,
a bad password during configuration, a very long power outage, or
blocked ports on FarmBot's local network. Please refer IT staff to
https://software.farm.bot/docs/for-it-security-professionals`);
export const NO_WS_AVAILABLE = trim(`You are either offline, using a web
browser that does not support WebSockets, or are behind a firewall that

View File

@ -25,6 +25,7 @@ describe("<Controls />", () => {
sensorReadings: [],
timeSettings: fakeTimeSettings(),
env: {},
firmwareHardware: undefined,
});
it("shows webcam widget", () => {

View File

@ -34,6 +34,7 @@ export class RawControls extends React.Component<Props, {}> {
arduinoBusy={this.arduinoBusy}
botToMqttStatus={this.props.botToMqttStatus}
firmwareSettings={this.props.firmwareSettings}
firmwareHardware={this.props.firmwareHardware}
getWebAppConfigVal={this.props.getWebAppConfigVal} />
peripherals = () => <Peripherals

View File

@ -1,7 +1,7 @@
import {
BotState, Xyz, BotPosition, ShouldDisplay, UserEnv
} from "../devices/interfaces";
import { Vector3, McuParams } from "farmbot/dist";
import { Vector3, McuParams, FirmwareHardware } from "farmbot/dist";
import {
TaggedWebcamFeed,
TaggedPeripheral,
@ -25,6 +25,7 @@ export interface Props {
sensorReadings: TaggedSensorReading[];
timeSettings: TimeSettings;
env: UserEnv;
firmwareHardware: FirmwareHardware | undefined;
}
export interface AxisDisplayGroupProps {

View File

@ -1,17 +1,12 @@
const mockDevice = {
moveAbsolute: jest.fn(() => { return Promise.resolve(); }),
moveAbsolute: jest.fn(() => Promise.resolve()),
};
jest.mock("../../../device", () => ({ getDevice: () => mockDevice }));
jest.mock("../../../device", () => ({
getDevice: () => (mockDevice)
jest.mock("../../../config_storage/actions", () => ({
toggleWebAppBool: jest.fn()
}));
jest.mock("../../../config_storage/actions", () => {
return {
toggleWebAppBool: jest.fn()
};
});
import * as React from "react";
import { shallow, mount } from "enzyme";
import { BotPositionRows, BotPositionRowsProps } from "../bot_position_rows";
@ -22,14 +17,12 @@ import { BooleanSetting } from "../../../session_keys";
describe("<BotPositionRows />", () => {
const mockConfig: Dictionary<boolean> = {};
function fakeProps(): BotPositionRowsProps {
return {
getValue: jest.fn(key => mockConfig[key]),
locationData: bot.hardware.location_data,
arduinoBusy: false,
firmware_version: undefined,
};
}
const fakeProps = (): BotPositionRowsProps => ({
getValue: jest.fn(key => mockConfig[key]),
locationData: bot.hardware.location_data,
arduinoBusy: false,
firmwareHardware: undefined,
});
it("inputs axis destination", () => {
const wrapper = shallow(<BotPositionRows {...fakeProps()} />);
@ -38,19 +31,21 @@ describe("<BotPositionRows />", () => {
expect(mockDevice.moveAbsolute).toHaveBeenCalledWith("123");
});
it("shows encoder position in steps", () => {
it("shows encoder position", () => {
mockConfig[BooleanSetting.scaled_encoders] = true;
mockConfig[BooleanSetting.raw_encoders] = true;
const p = fakeProps();
p.firmware_version = undefined;
p.firmwareHardware = undefined;
const wrapper = mount(<BotPositionRows {...p} />);
expect(wrapper.text().toLowerCase()).toContain("scaled encoder (steps)");
expect(wrapper.text().toLowerCase()).toContain("encoder");
});
it("shows encoder position in mm", () => {
it("doesn't show encoder position", () => {
mockConfig[BooleanSetting.scaled_encoders] = true;
mockConfig[BooleanSetting.raw_encoders] = true;
const p = fakeProps();
p.firmware_version = "6.0.0";
p.firmwareHardware = "express_k10";
const wrapper = mount(<BotPositionRows {...p} />);
expect(wrapper.text().toLowerCase()).toContain("scaled encoder (mm)");
expect(wrapper.text().toLowerCase()).not.toContain("encoder");
});
});

View File

@ -33,6 +33,7 @@ describe("<Move />", () => {
firmwareSettings: bot.hardware.mcu_params,
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
env: {},
firmwareHardware: undefined,
});
it("has default elements", () => {

View File

@ -8,7 +8,9 @@ jest.mock("../../../account/dev/dev_support", () => ({
import * as React from "react";
import { mount } from "enzyme";
import { BooleanSetting } from "../../../session_keys";
import { moveWidgetSetting, MoveWidgetSettingsMenu } from "../settings_menu";
import {
moveWidgetSetting, MoveWidgetSettingsMenu, MoveWidgetSettingsMenuProps
} from "../settings_menu";
describe("moveWidgetSetting()", () => {
it("renders setting", () => {
@ -22,9 +24,10 @@ describe("moveWidgetSetting()", () => {
});
describe("<MoveWidgetSettingsMenu />", () => {
const fakeProps = () => ({
const fakeProps = (): MoveWidgetSettingsMenuProps => ({
toggle: jest.fn(),
getValue: jest.fn(),
firmwareHardware: undefined,
});
it("displays motor plot toggle", () => {
@ -35,4 +38,16 @@ describe("<MoveWidgetSettingsMenu />", () => {
expect(wrapper.text()).toContain("Motor position plot");
mockDev = false;
});
it("displays encoder toggles", () => {
const wrapper = mount(<MoveWidgetSettingsMenu {...fakeProps()} />);
expect(wrapper.text().toLowerCase()).toContain("encoder");
});
it("doesn't display encoder toggles", () => {
const p = fakeProps();
p.firmwareHardware = "express_k10";
const wrapper = mount(<MoveWidgetSettingsMenu {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("encoder");
});
});

View File

@ -2,26 +2,23 @@ import * as React from "react";
import { Row, Col } from "../../ui";
import { BotLocationData } from "../../devices/interfaces";
import { moveAbs } from "../../devices/actions";
import { minFwVersionCheck } from "../../util";
import { AxisDisplayGroup } from "../axis_display_group";
import { AxisInputBoxGroup } from "../axis_input_box_group";
import { GetWebAppBool } from "./interfaces";
import { BooleanSetting } from "../../session_keys";
import { t } from "../../i18next_wrapper";
import { isExpressBoard } from "../../devices/components/firmware_hardware_support";
import { FirmwareHardware } from "farmbot";
export interface BotPositionRowsProps {
locationData: BotLocationData;
getValue: GetWebAppBool;
arduinoBusy: boolean;
firmware_version: string | undefined;
firmwareHardware: FirmwareHardware | undefined;
}
export const BotPositionRows = (props: BotPositionRowsProps) => {
const { locationData, getValue, arduinoBusy, firmware_version } = props;
const scaled_encoder_label =
minFwVersionCheck(firmware_version, "5.0.5")
? t("Scaled Encoder (mm)")
: t("Scaled Encoder (steps)");
const { locationData, getValue, arduinoBusy } = props;
return <div>
<Row>
<Col xs={3}>
@ -37,11 +34,13 @@ export const BotPositionRows = (props: BotPositionRowsProps) => {
<AxisDisplayGroup
position={locationData.position}
label={t("Motor Coordinates (mm)")} />
{getValue(BooleanSetting.scaled_encoders) &&
{!isExpressBoard(props.firmwareHardware) &&
getValue(BooleanSetting.scaled_encoders) &&
<AxisDisplayGroup
position={locationData.scaled_encoders}
label={scaled_encoder_label} />}
{getValue(BooleanSetting.raw_encoders) &&
label={t("Scaled Encoder (mm)")} />}
{!isExpressBoard(props.firmwareHardware) &&
getValue(BooleanSetting.raw_encoders) &&
<AxisDisplayGroup
position={locationData.raw_encoders}
label={t("Raw Encoder data")} />}

View File

@ -1,5 +1,5 @@
import { BotPosition, BotState, UserEnv } from "../../devices/interfaces";
import { McuParams, Xyz } from "farmbot";
import { McuParams, Xyz, FirmwareHardware } from "farmbot";
import { NetworkState } from "../../connectivity/interfaces";
import { GetWebAppConfigValue } from "../../config_storage/actions";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
@ -15,6 +15,7 @@ export interface MoveProps {
firmwareSettings: McuParams;
getWebAppConfigVal: GetWebAppConfigValue;
env: UserEnv;
firmwareHardware: FirmwareHardware | undefined;
}
export interface DirectionButtonProps {

View File

@ -33,7 +33,8 @@ export class Move extends React.Component<MoveProps, {}> {
<i className="fa fa-gear" />
<MoveWidgetSettingsMenu
toggle={this.toggle}
getValue={this.getValue} />
getValue={this.getValue}
firmwareHardware={this.props.firmwareHardware} />
</Popover>
</WidgetHeader>
<WidgetBody>
@ -53,7 +54,7 @@ export class Move extends React.Component<MoveProps, {}> {
locationData={locationData}
getValue={this.getValue}
arduinoBusy={this.props.arduinoBusy}
firmware_version={informational_settings.firmware_version} />
firmwareHardware={this.props.firmwareHardware} />
</MustBeOnline>
{this.props.getWebAppConfigVal(BooleanSetting.show_motor_plot) &&
<MotorPositionPlot locationData={locationData} />}

View File

@ -5,6 +5,8 @@ import { ToggleWebAppBool, GetWebAppBool } from "./interfaces";
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
import { DevSettings } from "../../account/dev/dev_support";
import { t } from "../../i18next_wrapper";
import { FirmwareHardware } from "farmbot";
import { isExpressBoard } from "../../devices/components/firmware_hardware_support";
export const moveWidgetSetting =
(toggle: ToggleWebAppBool, getValue: GetWebAppBool) =>
@ -18,10 +20,15 @@ export const moveWidgetSetting =
toggleValue={getValue(setting)} />
</fieldset>;
export const MoveWidgetSettingsMenu = ({ toggle, getValue }: {
toggle: ToggleWebAppBool,
getValue: GetWebAppBool
}) => {
export interface MoveWidgetSettingsMenuProps {
toggle: ToggleWebAppBool;
getValue: GetWebAppBool;
firmwareHardware: FirmwareHardware | undefined;
}
export const MoveWidgetSettingsMenu = (
{ toggle, getValue, firmwareHardware }: MoveWidgetSettingsMenuProps
) => {
const Setting = moveWidgetSetting(toggle, getValue);
return <div className="move-settings-menu">
<p>{t("Invert Jog Buttons")}</p>
@ -29,13 +36,16 @@ export const MoveWidgetSettingsMenu = ({ toggle, getValue }: {
<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} />
{!isExpressBoard(firmwareHardware) &&
<div className="display-encoder-data">
<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} />
</div>}
<p>{t("Swap jog buttons (and rotate map)")}</p>
<Setting label={t("x and y axis")} setting={BooleanSetting.xy_swap} />

View File

@ -7,12 +7,14 @@ import {
maybeGetTimeSettings
} from "../resources/selectors";
import { Props } from "./interfaces";
import { validFwConfig } from "../util";
import { validFwConfig, validFbosConfig } from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";
import { getFirmwareConfig } from "../resources/getters";
import { getFirmwareConfig, getFbosConfig } from "../resources/getters";
import { uniq } from "lodash";
import { getStatus } from "../connectivity/reducer_support";
import { getEnv, getShouldDisplayFn } from "../farmware/state_to_props";
import { sourceFbosConfigValue } from "../devices/components/source_config_value";
import { isFwHardwareValue } from "../devices/components/firmware_hardware_support";
export function mapStateToProps(props: Everything): Props {
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
@ -21,6 +23,12 @@ export function mapStateToProps(props: Everything): Props {
const shouldDisplay = getShouldDisplayFn(props.resources.index, props.bot);
const env = getEnv(props.resources.index, shouldDisplay, props.bot);
const { configuration } = props.bot.hardware;
const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
const sourceFbosConfig = sourceFbosConfigValue(fbosConfig, configuration);
const { value } = sourceFbosConfig("firmware_hardware");
const firmwareHardware = isFwHardwareValue(value) ? value : undefined;
return {
feeds: selectAllWebcamFeeds(props.resources.index),
dispatch: props.dispatch,
@ -34,5 +42,6 @@ export function mapStateToProps(props: Everything): Props {
sensorReadings: selectAllSensorReadings(props.resources.index),
timeSettings: maybeGetTimeSettings(props.resources.index),
env,
firmwareHardware,
};
}

View File

@ -213,7 +213,7 @@
.groups-list-wrapper {
padding: 0.5em 0em;
}
.groups-delete-btn {
.group-delete-btn {
float: left;
margin-top: 1em;
}
@ -332,6 +332,21 @@
}
}
.zones-layer {
[id*="zones-1D-"] {
stroke: $black;
stroke-width: 5;
}
[id*="zones-"] {
opacity: 0.1;
&.current {
opacity: 0.25;
fill: $white;
stroke: $white;
}
}
}
.virtual-bot-trail,
.virtual-peripherals {
pointer-events: none;

View File

@ -738,6 +738,114 @@
}
}
.group-detail-panel {
.panel-content {
.group-criteria {
margin-top: 1rem;
.criteria-heading {
margin-top: 0;
}
.fb-button {
margin-top: 0.5rem;
}
.group-criteria-presets {
input[type="radio"] {
width: auto;
margin-right: 1rem;
}
p {
display: inline;
text-transform: uppercase;
}
}
.criteria-string,
.criteria-pointer-type,
.criteria-plant-status,
.criteria-slug {
margin-top: 1rem;
}
.location-criteria {
.row {
margin-top: 1rem;
p {
font-size: 1.4rem;
font-weight: bold;
}
label {
margin-top: 0;
}
}
}
.day-criteria {
p {
display: inline;
vertical-align: bottom;
}
}
.string-eq-criteria {
margin-top: 1rem;
.row {
margin-top: 1rem;
}
}
.number-eq-criteria,
.number-gt-lt-criteria {
margin-top: 1rem;
.row {
margin-top: 1rem;
}
p {
text-align: center;
margin-top: 0.5rem;
}
}
.expandable-header {
margin-top: 3rem;
}
}
.criteria-point-count-breakdown {
margin-bottom: 1rem;
.manual-group-member-count,
.criteria-group-member-count {
margin-left: 2rem;
div {
display: inline;
padding: 0.25rem;
font-size: 1.2rem;
border: 1px solid $panel_light_blue;
}
p {
display: inline;
margin-left: 1rem;
}
}
.criteria-group-member-count {
div {
border: 1px solid gray;
border-radius: 5px;
}
}
}
}
}
.zone-info-panel {
.panel-content {
.location-criteria {
.row {
margin-top: 1rem;
p {
font-size: 1.4rem;
font-weight: bold;
}
label {
margin-top: 0;
}
}
}
}
}
.weeds-inventory-panel,
.zones-inventory-panel,
.groups-panel {

View File

@ -1579,7 +1579,7 @@ textarea:focus {
}
}
table {
margin: 2rem;
margin: auto;
margin-top: 3rem;
width: 93%;
font-size: 1.2rem;
@ -1596,6 +1596,7 @@ textarea:focus {
padding-bottom: 1rem;
span {
display: block;
white-space: nowrap;
}
}
}

View File

@ -95,9 +95,9 @@ select {
}
}
&.disabled {
pointer-events: none;
button {
background: darken($white, 10%) !important;
pointer-events: none;
}
}
}

View File

@ -2,7 +2,7 @@ import { boardType } from "../firmware_hardware_support";
describe("boardType()", () => {
it("returns Farmduino", () => {
expect(boardType("5.0.3.F")).toEqual("farmduino");
expect(boardType("5.0.3.F.extra")).toEqual("farmduino");
});
it("returns Farmduino k1.4", () => {
@ -25,6 +25,7 @@ describe("boardType()", () => {
expect(boardType(undefined)).toEqual("unknown");
expect(boardType("Arduino Disconnected!")).toEqual("unknown");
expect(boardType("STUBFW")).toEqual("unknown");
expect(boardType("0.0.0.S.STUB")).toEqual("unknown");
});
it("returns None", () => {

View File

@ -1,7 +1,13 @@
import { sequence2ddi, mapStateToProps, RawBootSequenceSelector } from "../boot_sequence_selector";
import { fakeSequence, fakeFbosConfig } from "../../../../__test_support__/fake_state/resources";
import {
sequence2ddi, mapStateToProps, RawBootSequenceSelector
} from "../boot_sequence_selector";
import {
fakeSequence, fakeFbosConfig
} from "../../../../__test_support__/fake_state/resources";
import { fakeState } from "../../../../__test_support__/fake_state";
import { buildResourceIndex } from "../../../../__test_support__/resource_index_builder";
import {
buildResourceIndex
} from "../../../../__test_support__/resource_index_builder";
import React from "react";
import { mount } from "enzyme";
import { FBSelect } from "../../../../ui";

View File

@ -37,7 +37,7 @@ describe("<FbosDetails/>", () => {
p.botInfoSettings.commit = "fakeCommit";
p.botInfoSettings.target = "fakeTarget";
p.botInfoSettings.node_name = "fakeName";
p.botInfoSettings.firmware_version = "fakeFirmware";
p.botInfoSettings.firmware_version = "0.0.0.R.ramps";
p.botInfoSettings.firmware_commit = "fakeFwCommit";
p.botInfoSettings.soc_temp = 48.3;
p.botInfoSettings.wifi_level = -49;
@ -50,8 +50,9 @@ describe("<FbosDetails/>", () => {
"Commit", "fakeComm",
"Target", "fakeTarget",
"Node name", "fakeName",
"Firmware", "fakeFirmware",
"Firmware", "0.0.0 Arduino/RAMPS (Genesis v1.2)",
"Firmware commit", "fakeFwCo",
"Firmware code", "0.0.0.R.ramps",
"FAKETARGET CPU temperature", "48.3", "C",
"WiFi strength", "-49dBm",
"OS release channel",
@ -70,6 +71,20 @@ describe("<FbosDetails/>", () => {
expect(wrapper.text()).not.toContain("name@");
});
it("handles missing firmware version", () => {
const p = fakeProps();
p.botInfoSettings.firmware_version = undefined;
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text()).toContain("---");
});
it("handles unknown firmware version", () => {
const p = fakeProps();
p.botInfoSettings.firmware_version = "0.0.0.S.S";
const wrapper = mount(<FbosDetails {...p} />);
expect(wrapper.text()).toContain("0.0.0");
});
it("displays commit link", () => {
const p = fakeProps();
p.botInfoSettings.commit = "abcdefgh";

View File

@ -13,6 +13,7 @@ import moment from "moment";
import { timeFormatString } from "../../../util";
import { TimeSettings } from "../../../interfaces";
import { StringConfigKey } from "farmbot/dist/resources/configs/fbos";
import { boardType, FIRMWARE_CHOICES_DDI } from "../firmware_hardware_support";
/** Return an indicator color for the given temperature (C). */
export const colorFromTemp = (temp: number | undefined): string => {
@ -244,6 +245,13 @@ const reformatDatetime = (datetime: string, timeSettings: TimeSettings) =>
.utcOffset(timeSettings.utcOffset)
.format(`MMMM D, ${timeFormatString(timeSettings)}`);
const reformatFwVersion = (firmwareVersion: string | undefined): string => {
const version = firmwareVersion ?
firmwareVersion.split(".").slice(0, 3).join(".") : "none";
const board = FIRMWARE_CHOICES_DDI[boardType(firmwareVersion)]?.label || "";
return version == "none" ? "---" : `${version} ${board}`;
};
/** Current technical information about FarmBot OS running on the device. */
export function FbosDetails(props: FbosDetailsProps) {
const {
@ -265,9 +273,10 @@ export function FbosDetails(props: FbosDetailsProps) {
<p><b>{t("Node name")}: </b>{last((node_name || "").split("@"))}</p>
<p><b>{t("Device ID")}: </b>{props.deviceAccount.body.id}</p>
{isString(private_ip) && <p><b>{t("Local IP address")}: </b>{private_ip}</p>}
<p><b>{t("Firmware")}: </b>{firmware_version}</p>
<p><b>{t("Firmware")}: </b>{reformatFwVersion(firmware_version)}</p>
<CommitDisplay title={t("Firmware commit")}
repo={"farmbot-arduino-firmware"} commit={firmware_commit} />
<p><b>{t("Firmware code")}: </b>{firmware_version}</p>
{isNumber(uptime) && <UptimeDisplay uptime_sec={uptime} />}
{isNumber(memory_usage) &&
<p><b>{t("Memory usage")}: </b>{memory_usage}MB</p>}

View File

@ -11,9 +11,18 @@ export const isFwHardwareValue = (x?: unknown): x is FirmwareHardware => {
return !!values.includes(x as FirmwareHardware);
};
const TMC_BOARDS = ["express_k10", "farmduino_k15"];
const EXPRESS_BOARDS = ["express_k10"];
export const isTMCBoard = (firmwareHardware: FirmwareHardware | undefined) =>
!!(firmwareHardware && TMC_BOARDS.includes(firmwareHardware));
export const isExpressBoard = (firmwareHardware: FirmwareHardware | undefined) =>
!!(firmwareHardware && EXPRESS_BOARDS.includes(firmwareHardware));
export const getBoardIdentifier =
(firmwareVersion: string | undefined): string =>
firmwareVersion ? firmwareVersion.slice(-1) : "undefined";
firmwareVersion ? firmwareVersion.split(".")[3] : "undefined";
export const isKnownBoard = (firmwareVersion: string | undefined): boolean => {
const boardIdentifier = getBoardIdentifier(firmwareVersion);

View File

@ -25,7 +25,6 @@ export class HardwareSettings extends
botToMqttStatus, firmwareHardware, resources
} = this.props;
const { informational_settings } = this.props.bot.hardware;
const firmwareVersion = informational_settings.firmware_version;
const { sync_status } = informational_settings;
const botDisconnected = !isBotOnline(sync_status, botToMqttStatus);
return <Widget className="hardware-widget">
@ -68,16 +67,15 @@ export class HardwareSettings extends
botDisconnected={botDisconnected} />
<Motors
dispatch={dispatch}
firmwareVersion={firmwareVersion}
controlPanelState={controlPanelState}
sourceFwConfig={sourceFwConfig}
isValidFwConfig={!!firmwareConfig}
firmwareHardware={firmwareHardware} />
<EncodersAndEndStops
dispatch={dispatch}
shouldDisplay={this.props.shouldDisplay}
controlPanelState={controlPanelState}
sourceFwConfig={sourceFwConfig} />
sourceFwConfig={sourceFwConfig}
firmwareHardware={firmwareHardware} />
<PinGuard
dispatch={dispatch}
resources={resources}

View File

@ -14,12 +14,23 @@ describe("<EncodersAndEndStops />", () => {
sourceFwConfig: x =>
({ value: bot.hardware.mcu_params[x], consistent: true }),
shouldDisplay: jest.fn(key => mockFeatures[key]),
firmwareHardware: undefined,
});
it("shows new inversion param", () => {
mockFeatures.endstop_invert = true;
const wrapper = mount(<EncodersAndEndStops {...fakeProps()} />);
expect(wrapper.text().toLowerCase()).not.toContain("invert endstops");
it("shows encoder labels", () => {
const p = fakeProps();
p.firmwareHardware = undefined;
const wrapper = mount(<EncodersAndEndStops {...p} />);
expect(wrapper.text().toLowerCase()).toContain("encoder");
expect(wrapper.text().toLowerCase()).not.toContain("stall");
});
it("shows stall labels", () => {
const p = fakeProps();
p.firmwareHardware = "express_k10";
const wrapper = mount(<EncodersAndEndStops {...p} />);
expect(wrapper.text().toLowerCase()).not.toContain("encoder");
expect(wrapper.text().toLowerCase()).toContain("stall");
});
it.each<["short" | "long"]>([

View File

@ -1,6 +1,4 @@
jest.mock("../../../actions", () => ({
updateMCU: jest.fn()
}));
jest.mock("../../../actions", () => ({ updateMCU: jest.fn() }));
import * as React from "react";
import { mount } from "enzyme";
@ -11,23 +9,22 @@ import {
fakeFirmwareConfig
} from "../../../../__test_support__/fake_state/resources";
import { error, warning } from "../../../../toast/toast";
import { inputEvent } from "../../../../__test_support__/fake_html_events";
describe("<HomingAndCalibration />", () => {
function testAxisLengthInput(
fw: string, provided: string, expected: string | undefined) {
provided: string, expected: string | undefined) {
const dispatch = jest.fn();
bot.controlPanelState.homing_and_calibration = true;
bot.hardware.informational_settings.firmware_version = fw;
const result = mount(<HomingAndCalibration
dispatch={dispatch}
bot={bot}
firmwareConfig={fakeFirmwareConfig().body}
sourceFwConfig={(x) => {
return { value: bot.hardware.mcu_params[x], consistent: true };
}}
sourceFwConfig={x => ({
value: bot.hardware.mcu_params[x], consistent: true
})}
botDisconnected={false} />);
const e = { currentTarget: { value: provided } } as
React.SyntheticEvent<HTMLInputElement>;
const e = inputEvent(provided);
const input = result.find("input").first().props();
input.onChange && input.onChange(e);
input.onSubmit && input.onSubmit(e);
@ -36,20 +33,15 @@ describe("<HomingAndCalibration />", () => {
.toHaveBeenCalledWith("movement_axis_nr_steps_x", expected)
: expect(updateMCU).not.toHaveBeenCalled();
}
it("short int", () => {
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", undefined);
testAxisLengthInput("10000000000", undefined);
expect(error)
.toHaveBeenCalledWith("Value must be less than or equal to 2000000000.");
});
it("long int: ok", () => {
testAxisLengthInput("6.0.0", "100000", "100000");
testAxisLengthInput("100000", "100000");
expect(warning).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});

View File

@ -28,10 +28,8 @@ describe("<Motors/>", () => {
controlPanelState.motors = true;
return {
dispatch: jest.fn(x => x(jest.fn(), () => state)),
firmwareVersion: undefined,
controlPanelState,
sourceFwConfig: () => ({ value: 0, consistent: true }),
isValidFwConfig: true,
firmwareHardware: undefined,
};
};
@ -46,27 +44,20 @@ describe("<Motors/>", () => {
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
});
it("doesn't render homing speed", () => {
it("shows TMC parameters", () => {
const p = fakeProps();
p.firmwareVersion = "4.0.0R";
p.isValidFwConfig = false;
p.firmwareHardware = "express_k10";
const wrapper = render(<Motors {...p} />);
expect(wrapper.text()).not.toContain("Homing Speed");
expect(wrapper.text()).toContain("Stall");
expect(wrapper.text()).toContain("Current");
});
it("renders homing speed", () => {
it("doesn't show TMC parameters", () => {
const p = fakeProps();
p.firmwareVersion = "5.1.0R";
p.firmwareHardware = "farmduino";
const wrapper = render(<Motors {...p} />);
expect(wrapper.text()).toContain("Homing Speed");
});
it("renders homing speed while disconnected", () => {
const p = fakeProps();
p.firmwareVersion = undefined;
p.isValidFwConfig = true;
const wrapper = render(<Motors {...p} />);
expect(wrapper.text()).toContain("Homing Speed");
expect(wrapper.text()).not.toContain("Stall");
expect(wrapper.text()).not.toContain("Current");
});
const testParamToggle = (

View File

@ -7,11 +7,12 @@ import { Header } from "./header";
import { Collapse } from "@blueprintjs/core";
import { Feature } from "../../interfaces";
import { t } from "../../../i18next_wrapper";
import { isExpressBoard } from "../firmware_hardware_support";
export function EncodersAndEndStops(props: EncodersProps) {
const { encoders_and_endstops } = props.controlPanelState;
const { dispatch, sourceFwConfig, shouldDisplay } = props;
const { dispatch, sourceFwConfig, shouldDisplay, firmwareHardware } = props;
const encodersDisabled = {
x: !sourceFwConfig("encoder_enabled_x").value,
@ -22,36 +23,42 @@ export function EncodersAndEndStops(props: EncodersProps) {
return <section>
<Header
expanded={encoders_and_endstops}
title={t("Encoders and Endstops")}
title={isExpressBoard(firmwareHardware)
? t("Stall Detection and Endstops")
: t("Encoders and Endstops")}
name={"encoders_and_endstops"}
dispatch={dispatch} />
<Collapse isOpen={!!encoders_and_endstops}>
<BooleanMCUInputGroup
name={t("Enable Encoders")}
name={isExpressBoard(firmwareHardware)
? t("Enable Stall Detection")
: t("Enable Encoders")}
tooltip={ToolTips.ENABLE_ENCODERS}
x={"encoder_enabled_x"}
y={"encoder_enabled_y"}
z={"encoder_enabled_z"}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />
<BooleanMCUInputGroup
name={t("Use Encoders for Positioning")}
tooltip={ToolTips.ENCODER_POSITIONING}
x={"encoder_use_for_pos_x"}
y={"encoder_use_for_pos_y"}
z={"encoder_use_for_pos_z"}
grayscale={encodersDisabled}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />
<BooleanMCUInputGroup
name={t("Invert Encoders")}
tooltip={ToolTips.INVERT_ENCODERS}
x={"encoder_invert_x"}
y={"encoder_invert_y"}
z={"encoder_invert_z"}
grayscale={encodersDisabled}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />
{!isExpressBoard(firmwareHardware) &&
<BooleanMCUInputGroup
name={t("Use Encoders for Positioning")}
tooltip={ToolTips.ENCODER_POSITIONING}
x={"encoder_use_for_pos_x"}
y={"encoder_use_for_pos_y"}
z={"encoder_use_for_pos_z"}
grayscale={encodersDisabled}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />}
{!isExpressBoard(firmwareHardware) &&
<BooleanMCUInputGroup
name={t("Invert Encoders")}
tooltip={ToolTips.INVERT_ENCODERS}
x={"encoder_invert_x"}
y={"encoder_invert_y"}
z={"encoder_invert_z"}
grayscale={encodersDisabled}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />}
<NumericMCUInputGroup
name={t("Max Missed Steps")}
tooltip={ToolTips.MAX_MISSED_STEPS}
@ -62,7 +69,7 @@ export function EncodersAndEndStops(props: EncodersProps) {
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />
<NumericMCUInputGroup
name={t("Encoder Missed Step Decay")}
name={t("Missed Step Decay")}
tooltip={ToolTips.ENCODER_MISSED_STEP_DECAY}
x={"encoder_missed_steps_decay_x"}
y={"encoder_missed_steps_decay_y"}
@ -70,19 +77,20 @@ export function EncodersAndEndStops(props: EncodersProps) {
gray={encodersDisabled}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />
<NumericMCUInputGroup
name={t("Encoder Scaling")}
tooltip={ToolTips.ENCODER_SCALING}
x={"encoder_scaling_x"}
y={"encoder_scaling_y"}
z={"encoder_scaling_z"}
xScale={sourceFwConfig("movement_microsteps_x").value}
yScale={sourceFwConfig("movement_microsteps_y").value}
zScale={sourceFwConfig("movement_microsteps_z").value}
intSize={shouldDisplay(Feature.long_scaling_factor) ? "long" : "short"}
gray={encodersDisabled}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />
{!isExpressBoard(firmwareHardware) &&
<NumericMCUInputGroup
name={t("Encoder Scaling")}
tooltip={ToolTips.ENCODER_SCALING}
x={"encoder_scaling_x"}
y={"encoder_scaling_y"}
z={"encoder_scaling_z"}
xScale={sourceFwConfig("movement_microsteps_x").value}
yScale={sourceFwConfig("movement_microsteps_y").value}
zScale={sourceFwConfig("movement_microsteps_z").value}
intSize={shouldDisplay(Feature.long_scaling_factor) ? "long" : "short"}
gray={encodersDisabled}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />}
<BooleanMCUInputGroup
name={t("Enable Endstops")}
tooltip={ToolTips.ENABLE_ENDSTOPS}

View File

@ -9,7 +9,6 @@ import { disabledAxisMap } from "../axis_tracking_status";
import { HomingAndCalibrationProps } from "../interfaces";
import { Header } from "./header";
import { Collapse } from "@blueprintjs/core";
import { minFwVersionCheck } from "../../../util";
import { t } from "../../../i18next_wrapper";
import { calculateScale } from "./motors";
@ -18,14 +17,8 @@ export function HomingAndCalibration(props: HomingAndCalibrationProps) {
const { dispatch, bot, sourceFwConfig, firmwareConfig, botDisconnected
} = props;
const hardware = firmwareConfig ? firmwareConfig : bot.hardware.mcu_params;
const { firmware_version } = bot.hardware.informational_settings;
const { homing_and_calibration } = props.bot.controlPanelState;
const axisLengthIntSize =
minFwVersionCheck(firmware_version, "6.0.0")
? "long"
: "short";
/**
* Tells us if X/Y/Z have a means of checking their position.
* FARMBOT WILL CRASH INTO WALLS IF THIS IS WRONG! BE CAREFUL.
@ -94,7 +87,7 @@ export function HomingAndCalibration(props: HomingAndCalibrationProps) {
}}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch}
intSize={axisLengthIntSize} />
intSize={"long"} />
<NumericMCUInputGroup
name={t("Timeout after (seconds)")}
tooltip={ToolTips.TIMEOUT_AFTER}

View File

@ -9,11 +9,11 @@ import { Row, Col, Help } from "../../../ui/index";
import { Header } from "./header";
import { Collapse, Position } from "@blueprintjs/core";
import { McuInputBox } from "../mcu_input_box";
import { minFwVersionCheck } from "../../../util";
import { t } from "../../../i18next_wrapper";
import { Xyz, McuParamName } from "farmbot";
import { SourceFwConfig } from "../../interfaces";
import { calcMicrostepsPerMm } from "../../../controls/move/direction_axes_props";
import { isTMCBoard, isExpressBoard } from "../firmware_hardware_support";
const SingleSettingRow =
({ label, tooltip, settingType, children }: {
@ -25,7 +25,7 @@ const SingleSettingRow =
<Row>
<Col xs={6} className={"widget-body-tooltips"}>
<label>{label}</label>
<Help text={tooltip} requireClick={true} position={Position.RIGHT}/>
<Help text={tooltip} requireClick={true} position={Position.RIGHT} />
</Col>
{settingType === "button"
? <Col xs={2} className={"centered-button-div"}>{children}</Col>
@ -47,15 +47,17 @@ export const calculateScale =
export function Motors(props: MotorsProps) {
const {
dispatch, firmwareVersion, controlPanelState,
sourceFwConfig, isValidFwConfig, firmwareHardware
dispatch, controlPanelState, sourceFwConfig, firmwareHardware
} = props;
const enable2ndXMotor = sourceFwConfig("movement_secondary_motor_x");
const invert2ndXMotor = sourceFwConfig("movement_secondary_motor_invert_x");
const eStopOnMoveError = sourceFwConfig("param_e_stop_on_mov_err");
const scale = calculateScale(sourceFwConfig);
const isFarmduinoExpress = firmwareHardware &&
firmwareHardware.includes("express");
const encodersDisabled = {
x: !sourceFwConfig("encoder_enabled_x").value,
y: !sourceFwConfig("encoder_enabled_y").value,
z: !sourceFwConfig("encoder_enabled_z").value,
};
return <section>
<Header
expanded={controlPanelState.motors}
@ -91,18 +93,17 @@ export function Motors(props: MotorsProps) {
zScale={scale.z}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />
{(minFwVersionCheck(firmwareVersion, "5.0.5") || isValidFwConfig) &&
<NumericMCUInputGroup
name={t("Homing Speed (mm/s)")}
tooltip={ToolTips.HOME_SPEED}
x={"movement_home_spd_x"}
y={"movement_home_spd_y"}
z={"movement_home_spd_z"}
xScale={scale.x}
yScale={scale.y}
zScale={scale.z}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />}
<NumericMCUInputGroup
name={t("Homing Speed (mm/s)")}
tooltip={ToolTips.HOME_SPEED}
x={"movement_home_spd_x"}
y={"movement_home_spd_y"}
z={"movement_home_spd_z"}
xScale={scale.x}
yScale={scale.y}
zScale={scale.z}
sourceFwConfig={sourceFwConfig}
dispatch={dispatch} />
<NumericMCUInputGroup
name={t("Minimum Speed (mm/s)")}
tooltip={ToolTips.MIN_SPEED}
@ -161,7 +162,7 @@ export function Motors(props: MotorsProps) {
z={"movement_invert_motor_z"}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />
{isFarmduinoExpress &&
{isTMCBoard(firmwareHardware) &&
<NumericMCUInputGroup
name={t("Motor Current")}
tooltip={ToolTips.MOTOR_CURRENT}
@ -170,13 +171,14 @@ export function Motors(props: MotorsProps) {
z={"movement_motor_current_z"}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />}
{isFarmduinoExpress &&
{isExpressBoard(firmwareHardware) &&
<NumericMCUInputGroup
name={t("Stall Sensitivity")}
tooltip={ToolTips.STALL_SENSITIVITY}
x={"movement_stall_sensitivity_x"}
y={"movement_stall_sensitivity_y"}
z={"movement_stall_sensitivity_z"}
gray={encodersDisabled}
dispatch={dispatch}
sourceFwConfig={sourceFwConfig} />}
<SingleSettingRow settingType="button"

View File

@ -24,7 +24,8 @@ export function PinGuard(props: PinGuardProps) {
<label>
{t("Pin Number")}
</label>
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER} requireClick={true} position={Position.RIGHT}/>
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER} requireClick={true}
position={Position.RIGHT} />
</Col>
<Col xs={4}>
<label>

View File

@ -28,7 +28,8 @@ export function ZeroRow({ botDisconnected }: ZeroRowProps) {
<label>
{t("SET ZERO POSITION")}
</label>
<Help text={ToolTips.SET_ZERO_POSITION} requireClick={true} position={Position.RIGHT} />
<Help text={ToolTips.SET_ZERO_POSITION} requireClick={true}
position={Position.RIGHT} />
</Col>
{AXES.map((axis) => {
return <Col xs={2} key={axis} className={"centered-button-div"}>

View File

@ -78,10 +78,8 @@ export interface PinGuardProps {
export interface MotorsProps {
dispatch: Function;
firmwareVersion: string | undefined;
controlPanelState: ControlPanelState;
sourceFwConfig: SourceFwConfig;
isValidFwConfig: boolean;
firmwareHardware: FirmwareHardware | undefined;
}
@ -90,6 +88,7 @@ export interface EncodersProps {
shouldDisplay: ShouldDisplay;
controlPanelState: ControlPanelState;
sourceFwConfig: SourceFwConfig;
firmwareHardware: FirmwareHardware | undefined;
}
export interface DangerZoneProps {

View File

@ -14,7 +14,7 @@ export function NumericMCUInputGroup(props: NumericMCUInputGroupProps) {
<label>
{name}
</label>
<Help text={tooltip} requireClick={true} position={Position.RIGHT}/>
<Help text={tooltip} requireClick={true} position={Position.RIGHT} />
</Col>
<Col xs={2}>
<McuInputBox
@ -24,7 +24,7 @@ export function NumericMCUInputGroup(props: NumericMCUInputGroupProps) {
intSize={intSize}
float={float}
scale={props.xScale}
gray={gray && gray.x} />
gray={gray?.x} />
</Col>
<Col xs={2}>
<McuInputBox
@ -34,7 +34,7 @@ export function NumericMCUInputGroup(props: NumericMCUInputGroupProps) {
intSize={intSize}
float={float}
scale={props.yScale}
gray={gray && gray.y} />
gray={gray?.y} />
</Col>
<Col xs={2}>
<McuInputBox
@ -44,7 +44,7 @@ export function NumericMCUInputGroup(props: NumericMCUInputGroupProps) {
intSize={intSize}
float={float}
scale={props.zScale}
gray={gray && gray.z} />
gray={gray?.z} />
</Col>
</Row>;
}

View File

@ -73,6 +73,7 @@ export enum Feature {
backscheduled_regimens = "backscheduled_regimens",
boot_sequence = "boot_sequence",
change_ownership = "change_ownership",
criteria_groups = "criteria_groups",
endstop_invert = "endstop_invert",
express_k10 = "express_k10",
farmduino_k14 = "farmduino_k14",
@ -89,7 +90,7 @@ export enum Feature {
rpi_led_control = "rpi_led_control",
sensors = "sensors",
use_update_channel = "use_update_channel",
variables = "variables"
variables = "variables",
}
/** Object fetched from FEATURE_MIN_VERSIONS_URL. */

View File

@ -62,7 +62,8 @@ export const initialState = (): BotState => ({
target: "---",
env: "---",
node_name: "---",
firmware_commit: "---"
firmware_version: "---",
firmware_commit: "---",
},
user_env: {},
process_info: {

View File

@ -14,4 +14,11 @@ describe("<DesignerPanelHeader />", () => {
const wrapper = mount(<DesignerPanelHeader panelName={"test-panel"} />);
expect(wrapper.find("div").first().hasClass("gray-panel")).toBeTruthy();
});
it("goes back", () => {
const wrapper = mount(<DesignerPanelHeader panelName={"test-panel"} />);
history.back = jest.fn();
wrapper.find("i").first().simulate("click");
expect(history.back).toHaveBeenCalled();
});
});

View File

@ -35,7 +35,8 @@ describe("<FarmDesigner/>", () => {
selectedPlant: undefined,
designer: fakeDesignerState(),
hoveredPlant: undefined,
points: [],
genericPoints: [],
allPoints: [],
plants: [],
toolSlots: [],
crops: [],
@ -59,6 +60,8 @@ describe("<FarmDesigner/>", () => {
getConfigValue: jest.fn(),
sensorReadings: [],
sensors: [],
groups: [],
shouldDisplay: () => false,
});
it("loads default map settings", () => {

View File

@ -1,6 +1,6 @@
let mockPath = "/app/designer/plants";
jest.mock("../../history", () => ({
getPathArray: jest.fn(() => { return mockPath.split("/"); }),
getPathArray: jest.fn(() => mockPath.split("/")),
}));
let mockDev = false;

View File

@ -5,7 +5,9 @@ import {
HoveredPlantPayl, CurrentPointPayl, CropLiveSearchResult
} from "../interfaces";
import { BotPosition } from "../../devices/interfaces";
import { fakeCropLiveSearchResult } from "../../__test_support__/fake_crop_search_result";
import {
fakeCropLiveSearchResult
} from "../../__test_support__/fake_crop_search_result";
import { fakeDesignerState } from "../../__test_support__/fake_designer_state";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";

View File

@ -56,7 +56,7 @@ describe("mapStateToProps()", () => {
expect.objectContaining({ uuid: plantUuid }));
});
it("returns all points", () => {
it("returns all genericPoints", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
(webAppConfig.body as WebAppConfig).show_historic_points = true;
@ -67,10 +67,10 @@ describe("mapStateToProps()", () => {
const point3 = fakePoint();
point3.body.discarded_at = DISCARDED_AT;
state.resources = buildResourceIndex([webAppConfig, point1, point2, point3]);
expect(mapStateToProps(state).points.length).toEqual(3);
expect(mapStateToProps(state).genericPoints.length).toEqual(3);
});
it("returns active points", () => {
it("returns active genericPoints", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
(webAppConfig.body as WebAppConfig).show_historic_points = false;
@ -81,7 +81,7 @@ describe("mapStateToProps()", () => {
const point3 = fakePoint();
point3.body.discarded_at = DISCARDED_AT;
state.resources = buildResourceIndex([webAppConfig, point1, point2, point3]);
expect(mapStateToProps(state).points.length).toEqual(1);
expect(mapStateToProps(state).genericPoints.length).toEqual(1);
});
it("returns sensor readings", () => {

View File

@ -78,13 +78,15 @@ export const DesignerPanelHeader = (props: DesignerPanelHeaderProps) => {
interface DesignerPanelTopProps {
panel: Panel;
linkTo?: string;
onClick?(): void;
title?: string;
children?: React.ReactNode;
noIcon?: boolean;
}
export const DesignerPanelTop = (props: DesignerPanelTopProps) => {
return <div className={`panel-top ${props.linkTo ? "with-button" : ""}`}>
const withBtn = !!props.linkTo || !!props.onClick;
return <div className={`panel-top ${withBtn ? "with-button" : ""}`}>
<div className="thin-search-wrapper">
<div className="text-input-wrapper">
{!props.noIcon &&
@ -94,6 +96,13 @@ export const DesignerPanelTop = (props: DesignerPanelTopProps) => {
</ErrorBoundary>
</div>
</div>
{props.onClick &&
<a>
<div className={`fb-button panel-${TAB_COLOR[props.panel]}`}
onClick={props.onClick}>
<i className="fa fa-plus" title={props.title} />
</div>
</a>}
{props.linkTo &&
<Link to={props.linkTo}>
<div className={`fb-button panel-${TAB_COLOR[props.panel]}`}>

View File

@ -1,9 +1,7 @@
let mockPath = "";
jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => { return mockPath.split("/"); }),
history: {
push: jest.fn()
}
getPathArray: jest.fn(() => mockPath.split("/")),
history: { push: jest.fn() }
}));
import { mapStateToPropsAddEdit } from "../map_state_to_props_add_edit";
@ -15,6 +13,7 @@ import {
fakeSequence, fakeRegimen, fakeFarmEvent
} from "../../../__test_support__/fake_state/resources";
import { history } from "../../../history";
import { inputEvent } from "../../../__test_support__/fake_html_events";
describe("mapStateToPropsAddEdit()", () => {
@ -22,25 +21,19 @@ describe("mapStateToPropsAddEdit()", () => {
const { handleTime } = mapStateToPropsAddEdit(fakeState());
it("handles an element with name `start_time`", () => {
const e = {
currentTarget: { value: "10:54", name: "start_time" }
} as React.SyntheticEvent<HTMLInputElement>;
const e = inputEvent("10:54", "start_time");
const result = handleTime(e, "2017-05-21T22:00:00.000");
expect(result).toContain("54");
});
it("handles an element with name `end_time`", () => {
const e = {
currentTarget: { value: "10:53", name: "end_time" }
} as React.SyntheticEvent<HTMLInputElement>;
const e = inputEvent("10:53", "end_time");
const result = handleTime(e, "2017-05-21T22:00:00.000");
expect(result).toContain("53");
});
it("crashes on other names", () => {
const e = {
currentTarget: { value: "10:52", name: "other" }
} as React.SyntheticEvent<HTMLInputElement>;
const e = inputEvent("10:52", "other");
const boom = () => handleTime(e, "2017-05-21T22:00:00.000");
expect(boom).toThrowError("Expected a name attribute from time field.");
});

View File

@ -73,6 +73,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
show_spread: init(BooleanSetting.show_spread, false),
show_farmbot: init(BooleanSetting.show_farmbot, true),
show_images: init(BooleanSetting.show_images, false),
show_zones: init(BooleanSetting.show_zones, false),
show_sensor_readings: init(BooleanSetting.show_sensor_readings, false),
bot_origin_quadrant: this.getBotOriginQuadrant(),
zoom_level: calcZoomLevel(getZoomLevelIndex(this.props.getConfigValue)),
@ -118,6 +119,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
show_spread,
show_farmbot,
show_images,
show_zones,
show_sensor_readings,
zoom_level
} = this.state;
@ -156,6 +158,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
showSpread={show_spread}
showFarmbot={show_farmbot}
showImages={show_images}
showZones={show_zones}
showSensorReadings={show_sensor_readings}
hasSensorReadings={this.props.sensorReadings.length > 0}
dispatch={this.props.dispatch}
@ -181,13 +184,15 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
showSpread={show_spread}
showFarmbot={show_farmbot}
showImages={show_images}
showZones={show_zones}
showSensorReadings={show_sensor_readings}
selectedPlant={this.props.selectedPlant}
crops={this.props.crops}
dispatch={this.props.dispatch}
designer={this.props.designer}
plants={this.props.plants}
points={this.props.points}
genericPoints={this.props.genericPoints}
allPoints={this.props.allPoints}
toolSlots={this.props.toolSlots}
botLocationData={this.props.botLocationData}
botSize={botSize}
@ -204,7 +209,9 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
getConfigValue={this.props.getConfigValue}
sensorReadings={this.props.sensorReadings}
timeSettings={this.props.timeSettings}
sensors={this.props.sensors} />
sensors={this.props.sensors}
groups={this.props.groups}
shouldDisplay={this.props.shouldDisplay} />
</div>
{this.props.designer.openedSavedGarden &&

View File

@ -9,6 +9,8 @@ import {
TaggedImage,
TaggedSensorReading,
TaggedSensor,
TaggedPoint,
TaggedPointGroup,
} from "farmbot";
import { SlotWithTool, ResourceIndex } from "../resources/interfaces";
import {
@ -49,6 +51,7 @@ export interface State extends TypeCheckerHint {
show_spread: boolean;
show_farmbot: boolean;
show_images: boolean;
show_zones: boolean;
show_sensor_readings: boolean;
bot_origin_quadrant: BotOriginQuadrant;
zoom_level: number;
@ -59,7 +62,8 @@ export interface Props {
selectedPlant: TaggedPlant | undefined;
designer: DesignerState;
hoveredPlant: TaggedPlant | undefined;
points: TaggedGenericPointer[];
genericPoints: TaggedGenericPointer[];
allPoints: TaggedPoint[];
plants: TaggedPlant[];
toolSlots: SlotWithTool[];
crops: TaggedCrop[];
@ -74,6 +78,8 @@ export interface Props {
getConfigValue: GetWebAppConfigValue;
sensorReadings: TaggedSensorReading[];
sensors: TaggedSensor[];
groups: TaggedPointGroup[];
shouldDisplay: ShouldDisplay;
}
export interface MovePlantProps {
@ -176,10 +182,12 @@ export interface GardenMapProps {
showSpread: boolean | undefined;
showFarmbot: boolean | undefined;
showImages: boolean | undefined;
showZones: boolean | undefined;
showSensorReadings: boolean | undefined;
dispatch: Function;
designer: DesignerState;
points: TaggedGenericPointer[];
genericPoints: TaggedGenericPointer[];
allPoints: TaggedPoint[];
plants: TaggedPlant[];
toolSlots: SlotWithTool[];
selectedPlant: TaggedPlant | undefined;
@ -200,6 +208,8 @@ export interface GardenMapProps {
sensorReadings: TaggedSensorReading[];
sensors: TaggedSensor[];
timeSettings: TimeSettings;
groups: TaggedPointGroup[];
shouldDisplay: ShouldDisplay;
}
export interface GardenMapState {

View File

@ -12,7 +12,7 @@ jest.mock("../../../api/crud", () => ({
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
const mockGroup = fakePointGroup();
jest.mock("../../point_groups/group_detail", () => ({
fetchGroupFromUrl: jest.fn(() => mockGroup)
findGroupFromUrl: jest.fn(() => mockGroup)
}));
import {

View File

@ -32,6 +32,7 @@ jest.mock("../drawn_point/drawn_point_actions", () => ({
jest.mock("../background/selection_box_actions", () => ({
startNewSelectionBox: jest.fn(),
resizeBox: jest.fn(),
maybeUpdateGroupCriteria: jest.fn(),
}));
jest.mock("../../move_to", () => ({ chooseLocation: jest.fn() }));
@ -45,6 +46,11 @@ jest.mock("../../../history", () => ({
getPathArray: () => [],
}));
let mockGroup: TaggedPointGroup | undefined = undefined;
jest.mock("../../point_groups/group_detail", () => ({
findGroupFromUrl: () => mockGroup,
}));
import * as React from "react";
import { GardenMap } from "../garden_map";
import { shallow, mount } from "enzyme";
@ -55,7 +61,7 @@ import {
dropPlant, beginPlantDrag, maybeSavePlantLocation, dragPlant
} from "../layers/plants/plant_actions";
import {
startNewSelectionBox, resizeBox
startNewSelectionBox, resizeBox, maybeUpdateGroupCriteria
} from "../background/selection_box_actions";
import { getGardenCoordinates } from "../util";
import { chooseLocation } from "../../move_to";
@ -63,9 +69,12 @@ import { startNewPoint, resizePoint } from "../drawn_point/drawn_point_actions";
import {
fakeDesignerState
} from "../../../__test_support__/fake_designer_state";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import {
fakePlant, fakePointGroup, fakePoint
} from "../../../__test_support__/fake_state/resources";
import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings";
import { history } from "../../../history";
import { TaggedPointGroup } from "farmbot";
const DEFAULT_EVENT = { preventDefault: jest.fn(), pageX: NaN, pageY: NaN };
@ -75,13 +84,15 @@ const fakeProps = (): GardenMapProps => ({
showSpread: false,
showFarmbot: false,
showImages: false,
showZones: false,
showSensorReadings: false,
selectedPlant: undefined,
crops: [],
dispatch: jest.fn(),
designer: fakeDesignerState(),
plants: [],
points: [],
genericPoints: [],
allPoints: [],
toolSlots: [],
botLocationData: {
position: { x: 0, y: 0, z: 0 },
@ -111,6 +122,8 @@ const fakeProps = (): GardenMapProps => ({
sensorReadings: [],
sensors: [],
timeSettings: fakeTimeSettings(),
groups: [],
shouldDisplay: () => false,
});
describe("<GardenMap/>", () => {
@ -144,6 +157,7 @@ describe("<GardenMap/>", () => {
wrapper.setState({ isDragging: true });
wrapper.find(".drop-area-svg").simulate("mouseUp", DEFAULT_EVENT);
expect(maybeSavePlantLocation).toHaveBeenCalled();
expect(maybeUpdateGroupCriteria).toHaveBeenCalled();
expect(wrapper.instance().state.isDragging).toBeFalsy();
});
@ -198,6 +212,18 @@ describe("<GardenMap/>", () => {
expect.objectContaining(e));
});
it("starts drag on background: selecting zone", () => {
const wrapper = mount(<GardenMap {...fakeProps()} />);
mockMode = Mode.editGroup;
const e = { pageX: 1000, pageY: 2000 };
wrapper.find(".drop-area-background").simulate("mouseDown", e);
expect(startNewSelectionBox).toHaveBeenCalledWith(
expect.objectContaining({ plantActions: false }));
expect(history.push).not.toHaveBeenCalled();
expect(getGardenCoordinates).toHaveBeenCalledWith(
expect.objectContaining(e));
});
it("starts drag: click-to-add mode", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
mockMode = Mode.clickToAdd;
@ -217,6 +243,17 @@ describe("<GardenMap/>", () => {
expect.objectContaining(e));
});
it("drags: selecting zone", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
mockMode = Mode.editGroup;
const e = { pageX: 2000, pageY: 2000 };
wrapper.find(".drop-area-svg").simulate("mouseMove", e);
expect(resizeBox).toHaveBeenCalledWith(
expect.objectContaining({ plantActions: false }));
expect(getGardenCoordinates).toHaveBeenCalledWith(
expect.objectContaining(e));
});
it("selects location", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
mockMode = Mode.moveTo;
@ -361,4 +398,14 @@ describe("<GardenMap/>", () => {
expect(svg.props().height).toEqual(1000);
});
it("gets group points", () => {
mockGroup = fakePointGroup();
mockGroup.body.point_ids = [1];
const p = fakeProps();
const point = fakePoint();
point.body.id = 1;
p.allPoints = [point];
const wrapper = shallow<GardenMap>(<GardenMap {...p} />);
expect(wrapper.instance().pointsSelectedByGroup).toEqual([point]);
});
});

View File

@ -7,10 +7,11 @@ import { svgToUrl, DEFAULT_ICON } from "../../open_farm/icons";
import { Mode } from "../map/interfaces";
import { clamp, uniq } from "lodash";
import { GetState } from "../../redux/interfaces";
import { fetchGroupFromUrl } from "../point_groups/group_detail";
import { findGroupFromUrl } from "../point_groups/group_detail";
import { TaggedPoint } from "farmbot";
import { getMode } from "../map/util";
import { ResourceIndex, UUID } from "../../resources/interfaces";
import { selectAllPointGroups } from "../../resources/selectors";
export function movePlant(payload: MovePlantProps) {
const tr = payload.plant;
@ -33,7 +34,7 @@ export const setHoveredPlant = (plantUUID: string | undefined, icon = "") => ({
const addOrRemoveFromGroup =
(clickedPlantUuid: UUID, resources: ResourceIndex) => {
const group = fetchGroupFromUrl(resources);
const group = findGroupFromUrl(selectAllPointGroups(resources));
const point =
resources.references[clickedPlantUuid] as TaggedPoint | undefined;
if (group && point?.body.id) {

View File

@ -46,9 +46,9 @@ describe("<Grid/>", () => {
const minorGrid = wrapper.find("#minor_grid>path");
const majorGrid = wrapper.find("#major_grid>path");
const superiorGrid = wrapper.find("#superior_grid>path");
expect(minorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.15)");
expect(majorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.3)");
expect(superiorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.4)");
expect(minorGrid.props()).toHaveProperty("strokeWidth", "1");
expect(majorGrid.props()).toHaveProperty("strokeWidth", "2");
expect(superiorGrid.props()).toHaveProperty("strokeWidth", "4");
});
it("change patterns strokes on 0.5 zoom and below", () => {
@ -58,8 +58,48 @@ describe("<Grid/>", () => {
const minorGrid = wrapper.find("#minor_grid>path");
const majorGrid = wrapper.find("#major_grid>path");
const superiorGrid = wrapper.find("#superior_grid>path");
expect(minorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0)");
expect(majorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.6)");
expect(superiorGrid.props()).toHaveProperty("stroke", "rgba(0, 0, 0, 0.8)");
expect(minorGrid.props()).toHaveProperty("strokeWidth", "0");
expect(majorGrid.props()).toHaveProperty("strokeWidth", "3");
expect(superiorGrid.props()).toHaveProperty("strokeWidth", "6");
});
it("visualizes axis values every 100mm above 0.5 zoom", () => {
const p = fakeProps();
p.zoomLvl = 0.6;
const wrapper = shallow(<Grid {...p} />);
const axisValues = wrapper.find(".x-label").children();
expect(axisValues).toHaveLength(29);
});
it("visualizes axis values every 200mm between 0.5 and 0.2 excluded zoom", () => {
const p = fakeProps();
p.zoomLvl = 0.5;
const wrapper = shallow(<Grid {...p} />);
const axisValues = wrapper.find(".x-label").children();
expect(axisValues).toHaveLength(14);
});
it("visualizes axis values every 500mm on 0.2 zoom and below", () => {
const p = fakeProps();
p.zoomLvl = 0.2;
const wrapper = shallow(<Grid {...p} />);
const axisValues = wrapper.find(".x-label").children();
expect(axisValues).toHaveLength(5);
});
it("use transform scale 1 for zoom above 1", () => {
const p = fakeProps();
p.zoomLvl = 1.1;
const wrapper = shallow(<Grid {...p} />);
const textNode = wrapper.find(".x-label").first();
expect(textNode.prop("style")).toHaveProperty("transform", "scale(1)");
});
it("use transform scale 1.5 for zoom on 0.5", () => {
const p = fakeProps();
p.zoomLvl = 0.5;
const wrapper = shallow(<Grid {...p} />);
const textNode = wrapper.find(".x-label").first();
expect(textNode.prop("style")).toHaveProperty("transform", "scale(1.5)");
});
});

View File

@ -4,12 +4,22 @@ jest.mock("../../util", () => ({ getMode: () => mockMode }));
jest.mock("../../../../history", () => ({ history: { push: jest.fn() } }));
import { fakePlant } from "../../../../__test_support__/fake_state/resources";
jest.mock("../../../point_groups/criteria", () => ({
editGtLtCriteria: jest.fn(),
}));
import {
getSelected, resizeBox, startNewSelectionBox, ResizeSelectionBoxProps
fakePlant, fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import {
getSelected, resizeBox, startNewSelectionBox, ResizeSelectionBoxProps,
StartNewSelectionBoxProps,
maybeUpdateGroupCriteria,
MaybeUpdateGroupCriteriaProps,
} from "../selection_box_actions";
import { Actions } from "../../../../constants";
import { history } from "../../../../history";
import { editGtLtCriteria } from "../../../point_groups/criteria";
describe("getSelected", () => {
it("returns some", () => {
@ -41,6 +51,7 @@ describe("resizeBox", () => {
gardenCoords: { x: 100, y: 200 },
setMapState: jest.fn(),
dispatch: jest.fn(),
plantActions: true,
});
it("resizes selection box", () => {
@ -55,6 +66,16 @@ describe("resizeBox", () => {
});
});
it("resizes selection box without plant actions", () => {
const p = fakeProps();
p.plantActions = false;
resizeBox(p);
expect(p.setMapState).toHaveBeenCalledWith({
selectionBox: { x0: 0, y0: 0, x1: 100, y1: 200 }
});
expect(p.dispatch).not.toHaveBeenCalled();
});
it("doesn't resize box: no location", () => {
const p = fakeProps();
// tslint:disable-next-line:no-any
@ -93,10 +114,11 @@ describe("resizeBox", () => {
});
describe("startNewSelectionBox", () => {
const fakeProps = () => ({
const fakeProps = (): StartNewSelectionBoxProps => ({
gardenCoords: { x: 100, y: 200 },
setMapState: jest.fn(),
dispatch: jest.fn(),
plantActions: true,
});
it("starts selection box", () => {
@ -111,6 +133,16 @@ describe("startNewSelectionBox", () => {
});
});
it("starts selection box without plant actions", () => {
const p = fakeProps();
p.plantActions = false;
startNewSelectionBox(p);
expect(p.setMapState).toHaveBeenCalledWith({
selectionBox: { x0: 100, y0: 200, x1: undefined, y1: undefined }
});
expect(p.dispatch).not.toHaveBeenCalled();
});
it("doesn't start box", () => {
const p = fakeProps();
// tslint:disable-next-line:no-any
@ -123,3 +155,25 @@ describe("startNewSelectionBox", () => {
});
});
});
describe("maybeUpdateGroupCriteria()", () => {
const fakeProps = (): MaybeUpdateGroupCriteriaProps => ({
selectionBox: { x0: 0, y0: 0, x1: undefined, y1: undefined },
dispatch: jest.fn(),
group: fakePointGroup(),
shouldDisplay: () => true,
});
it("updates criteria", () => {
const p = fakeProps();
maybeUpdateGroupCriteria(p);
expect(editGtLtCriteria).toHaveBeenCalledWith(p.group, p.selectionBox);
});
it("doesn't update criteria", () => {
const p = fakeProps();
p.shouldDisplay = () => false;
maybeUpdateGroupCriteria(p);
expect(editGtLtCriteria).not.toHaveBeenCalled();
});
});

View File

@ -13,27 +13,43 @@ export function Grid(props: GridProps) {
const arrowEnd = transformXY(25, 25, mapTransformProps);
const xLabel = transformXY(15, -10, mapTransformProps);
const yLabel = transformXY(-11, 18, mapTransformProps);
const minorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0)" : "rgba(0, 0, 0, 0.15)";
const majorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0.6)" : "rgba(0, 0, 0, 0.3)";
const superiorGridStroke = zoomLvl <= 0.5 ? "rgba(0, 0, 0, 0.8)" : "rgba(0, 0, 0, 0.4)";
const minorStrokeWidth = zoomLvl <= 0.5 ? "0" : "1";
const majorStrokeWidth = zoomLvl <= 0.5 ? "3" : "2";
const superiorStrokeWidth = zoomLvl <= 0.5 ? "6" : "4";
// Start axis-values controls
// TODO: Create helper to regroup code and clean grid.tsx
// Text transform:scale value
const axisTransformValue = zoomLvl <= 1 ? 2 - zoomLvl : 1;
// Start and increment steps to visualize in grid
let axisStep;
if (zoomLvl <= 0.2) {
axisStep = 500;
} else if (zoomLvl <= 0.5) {
axisStep = 200;
} else {
axisStep = 100;
}
// End axis-values controls
return <g className="drop-area-background" onClick={props.onClick}
onMouseDown={props.onMouseDown}>
<defs>
<pattern id="minor_grid"
width={10} height={10} patternUnits="userSpaceOnUse">
<path d="M10,0 L0,0 L0,10" strokeWidth={1}
fill="none" stroke={minorGridStroke} />
<path d="M10,0 L0,0 L0,10" strokeWidth={minorStrokeWidth}
fill="none" stroke="rgba(0, 0, 0, 0.15)" />
</pattern>
<pattern id={"major_grid"}
width={100} height={100} patternUnits="userSpaceOnUse">
<path d="M100,0 L0,0 0,100" strokeWidth={2}
fill="none" stroke={majorGridStroke} />
<path d="M100,0 L0,0 0,100" strokeWidth={majorStrokeWidth}
fill="none" stroke="rgba(0, 0, 0, 0.3)" />
</pattern>
<pattern id="superior_grid" width={1000} height={1000} patternUnits="userSpaceOnUse">
<path d="M1000,0 L0,0 0,1000" strokeWidth={2}
fill="none" stroke={superiorGridStroke} />
<path d="M1000,0 L0,0 0,1000" strokeWidth={superiorStrokeWidth}
fill="none" stroke="rgba(0, 0, 0, 0.4)" />
</pattern>
<marker id="arrow"
@ -70,10 +86,21 @@ export function Grid(props: GridProps) {
<g id="axis-values" fontFamily="Arial" fontSize="10"
textAnchor="middle" dominantBaseline="central" fill="rgba(0, 0, 0, 0.3)">
{range(100, gridSize.x, 100).map((i) => {
{range(axisStep, gridSize.x, axisStep).map((i) => {
const location = transformXY(i, -10, mapTransformProps);
return <text key={"x-label-" + i}
x={location.qx} y={location.qy}>{i}</text>;
return (
<text key={"x-label-" + i}
fontSize="16"
fontWeight="bold"
className="x-label"
color="rgba(0, 0, 0, 0.4)"
x={location.qx}
y={location.qy}
style={{transformOrigin: "center", transformBox: "fill-box", transform: `scale(${axisTransformValue})`}}>
{i}
</text>
);
})}
{range(100, gridSize.y, 100).map((i) => {
const location = transformXY(-15, i, mapTransformProps);

View File

@ -5,6 +5,9 @@ import { GardenMapState } from "../../interfaces";
import { history } from "../../../history";
import { selectPlant } from "../actions";
import { getMode } from "../util";
import { editGtLtCriteria } from "../../point_groups/criteria";
import { TaggedPointGroup } from "farmbot";
import { ShouldDisplay, Feature } from "../../../devices/interfaces";
/** Return all plants within the selection box. */
export const getSelected = (
@ -32,6 +35,7 @@ export interface ResizeSelectionBoxProps {
gardenCoords: AxisNumberProperty | undefined;
setMapState: (x: Partial<GardenMapState>) => void;
dispatch: Function;
plantActions: boolean;
}
/** Resize a selection box. */
@ -45,24 +49,29 @@ export const resizeBox = (props: ResizeSelectionBoxProps) => {
x1: current.x, y1: current.y // Update box active corner
};
props.setMapState({ selectionBox: newSelectionBox });
// Select all plants within the updated selection box
const payload = getSelected(props.plants, newSelectionBox);
if (payload && getMode() === Mode.none) {
history.push("/app/designer/plants/select");
if (props.plantActions) {
// Select all plants within the updated selection box
const payload = getSelected(props.plants, newSelectionBox);
if (payload && getMode() === Mode.none) {
history.push("/app/designer/plants/select");
}
props.dispatch(selectPlant(payload));
}
props.dispatch(selectPlant(payload));
}
}
};
export interface StartNewSelectionBoxProps {
gardenCoords: AxisNumberProperty | undefined;
setMapState: (x: Partial<GardenMapState>) => void;
dispatch: Function;
plantActions: boolean;
}
/** Create a new selection box. */
export const startNewSelectionBox = (props: {
gardenCoords: AxisNumberProperty | undefined,
setMapState: (x: Partial<GardenMapState>) => void,
dispatch: Function,
}) => {
export const startNewSelectionBox = (props: StartNewSelectionBoxProps) => {
if (props.gardenCoords) {
// Set the starting point (initial corner) of a selection box
// Set the starting point (initial corner) of a selection box
props.setMapState({
selectionBox: {
x0: props.gardenCoords.x, y0: props.gardenCoords.y,
@ -70,6 +79,23 @@ export const startNewSelectionBox = (props: {
}
});
}
// Clear the previous plant selection when starting a new selection box
props.dispatch(selectPlant(undefined));
if (props.plantActions) {
// Clear the previous plant selection when starting a new selection box
props.dispatch(selectPlant(undefined));
}
};
export interface MaybeUpdateGroupCriteriaProps {
selectionBox: SelectionBoxData | undefined;
dispatch: Function;
group: TaggedPointGroup | undefined;
shouldDisplay: ShouldDisplay;
}
export const maybeUpdateGroupCriteria =
(props: MaybeUpdateGroupCriteriaProps) => {
if (props.selectionBox && props.group &&
props.shouldDisplay(Feature.criteria_groups)) {
props.dispatch(editGtLtCriteria(props.group, props.selectionBox));
}
};

View File

@ -11,7 +11,7 @@ import {
import {
Grid, MapBackground,
TargetCoordinate,
SelectionBox, resizeBox, startNewSelectionBox
SelectionBox, resizeBox, startNewSelectionBox, maybeUpdateGroupCriteria,
} from "./background";
import {
PlantLayer,
@ -32,6 +32,11 @@ import { chooseLocation } from "../move_to";
import { GroupOrder } from "../point_groups/group_order_visual";
import { NNPath } from "../point_groups/paths";
import { history } from "../../history";
import { ZonesLayer } from "./layers/zones/zones_layer";
import { ErrorBoundary } from "../../error_boundary";
import { TaggedPoint, TaggedPointGroup } from "farmbot";
import { findGroupFromUrl } from "../point_groups/group_detail";
import { pointsSelectedByGroup } from "../point_groups/criteria";
export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> {
@ -67,6 +72,14 @@ export class GardenMap extends
get animate(): boolean {
return !this.props.getConfigValue(BooleanSetting.disable_animations);
}
get group(): TaggedPointGroup | undefined {
return findGroupFromUrl(this.props.groups);
}
get pointsSelectedByGroup(): TaggedPoint[] {
return this.group ?
pointsSelectedByGroup(this.group, this.props.allPoints) : [];
}
/** Save the current plant (if needed) and reset drag state. */
endDrag = () => {
@ -75,6 +88,12 @@ export class GardenMap extends
isDragging: this.state.isDragging,
dispatch: this.props.dispatch,
});
maybeUpdateGroupCriteria({
selectionBox: this.state.selectionBox,
group: this.group,
dispatch: this.props.dispatch,
shouldDisplay: this.props.shouldDisplay,
});
this.setState({
isDragging: false, qPageX: 0, qPageY: 0,
activeDragXY: { x: undefined, y: undefined, z: undefined },
@ -114,9 +133,18 @@ export class GardenMap extends
gardenCoords,
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: true,
});
}
break;
case Mode.editGroup:
startNewSelectionBox({
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
});
break;
case Mode.createPoint:
startNewPoint({
gardenCoords: this.getGardenCoordinates(e),
@ -141,6 +169,15 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: true,
});
break;
case Mode.editGroup:
startNewSelectionBox({
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
});
break;
default:
@ -149,6 +186,7 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: true,
});
break;
}
@ -236,6 +274,16 @@ export class GardenMap extends
isDragging: this.state.isDragging,
});
break;
case Mode.editGroup:
resizeBox({
selectionBox: this.state.selectionBox,
plants: this.props.plants,
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
});
break;
case Mode.boxSelect:
default:
resizeBox({
@ -244,6 +292,7 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: true,
});
break;
}
@ -302,6 +351,12 @@ export class GardenMap extends
onMouseDown={this.startDragOnBackground}
mapTransformProps={this.mapTransformProps}
zoomLvl={this.props.zoomLvl} />
ZonesLayer = () => <ZonesLayer
visible={!!this.props.showZones}
botSize={this.props.botSize}
mapTransformProps={this.mapTransformProps}
groups={this.props.groups}
currentGroup={this.group?.uuid} />
SensorReadingsLayer = () => <SensorReadingsLayer
visible={!!this.props.showSensorReadings}
sensorReadings={this.props.sensorReadings}
@ -324,7 +379,7 @@ export class GardenMap extends
dispatch={this.props.dispatch}
hoveredPoint={this.props.designer.hoveredPoint}
visible={!!this.props.showPoints}
points={this.props.points} />
genericPoints={this.props.genericPoints} />
PlantLayer = () => <PlantLayer
mapTransformProps={this.mapTransformProps}
dispatch={this.props.dispatch}
@ -333,7 +388,8 @@ export class GardenMap extends
currentPlant={this.getPlant()}
dragging={!!this.state.isDragging}
editing={this.isEditing}
selectedForDel={this.props.designer.selectedPlants}
boxSelected={this.props.designer.selectedPlants}
groupSelected={this.pointsSelectedByGroup.map(point => point.uuid)}
zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY}
animate={this.animate} />
@ -383,9 +439,10 @@ export class GardenMap extends
key={"currentPoint"}
mapTransformProps={this.mapTransformProps} />
GroupOrder = () => <GroupOrder
plants={this.props.plants}
group={this.group}
groupPoints={this.pointsSelectedByGroup}
mapTransformProps={this.mapTransformProps} />
NNPath = () => <NNPath plants={this.props.plants}
NNPath = () => <NNPath pathPoints={this.props.allPoints}
mapTransformProps={this.mapTransformProps} />
Bugs = () => showBugs() ? <Bugs mapTransformProps={this.mapTransformProps}
botSize={this.props.botSize} /> : <g />
@ -393,27 +450,30 @@ export class GardenMap extends
/** Render layers in order from back to front. */
render() {
return <div className={"drop-area"} {...this.mapDropAreaProps()}>
<svg id={"map-background-svg"}>
<this.MapBackground />
<svg className={"drop-area-svg"} {...this.svgDropAreaProps()}>
<this.ImageLayer />
<this.Grid />
<this.SensorReadingsLayer />
<this.SpreadLayer />
<this.PointLayer />
<this.PlantLayer />
<this.ToolSlotLayer />
<this.FarmBotLayer />
<this.HoveredPlant />
<this.DragHelper />
<this.SelectionBox />
<this.TargetCoordinate />
<this.DrawnPoint />
<this.GroupOrder />
<this.NNPath />
<this.Bugs />
<ErrorBoundary>
<svg id={"map-background-svg"}>
<this.MapBackground />
<svg className={"drop-area-svg"} {...this.svgDropAreaProps()}>
<this.ImageLayer />
<this.Grid />
<this.ZonesLayer />
<this.SensorReadingsLayer />
<this.SpreadLayer />
<this.PointLayer />
<this.PlantLayer />
<this.ToolSlotLayer />
<this.FarmBotLayer />
<this.HoveredPlant />
<this.DragHelper />
<this.SelectionBox />
<this.TargetCoordinate />
<this.DrawnPoint />
<this.GroupOrder />
<this.NNPath />
<this.Bugs />
</svg>
</svg>
</svg>
</ErrorBoundary>
</div>;
}
}

View File

@ -1,12 +1,13 @@
import {
TaggedPlantPointer,
TaggedGenericPointer,
TaggedPlantTemplate
TaggedPlantTemplate,
} from "farmbot";
import { State, BotOriginQuadrant } from "../interfaces";
import { BotPosition, BotLocationData } from "../../devices/interfaces";
import { GetWebAppConfigValue } from "../../config_storage/actions";
import { TimeSettings } from "../../interfaces";
import { UUID } from "../../resources/interfaces";
export type TaggedPlant = TaggedPlantPointer | TaggedPlantTemplate;
@ -20,7 +21,8 @@ export interface PlantLayerProps {
mapTransformProps: MapTransformProps;
zoomLvl: number;
activeDragXY: BotPosition | undefined;
selectedForDel: string[] | undefined;
boxSelected: string[] | undefined;
groupSelected: UUID[];
animate: boolean;
}
@ -33,6 +35,7 @@ export interface GardenMapLegendProps {
showSpread: boolean;
showFarmbot: boolean;
showImages: boolean;
showZones: boolean;
showSensorReadings: boolean;
hasSensorReadings: boolean;
dispatch: Function;
@ -59,7 +62,6 @@ export interface GardenPlantProps {
zoomLvl: number;
activeDragXY: BotPosition | undefined;
uuid: string;
multiselected: boolean;
animate: boolean;
}

View File

@ -15,7 +15,6 @@ describe("<GardenPlant/>", () => {
plant: fakePlant(),
selected: false,
editing: false,
multiselected: false,
dragging: false,
dispatch: jest.fn(),
zoomLvl: 1.8,
@ -27,7 +26,7 @@ describe("<GardenPlant/>", () => {
it("renders plant", () => {
const p = fakeProps();
p.multiselected = true;
p.selected = true;
p.animate = false;
const wrapper = shallow(<GardenPlant {...p} />);
expect(wrapper.find("image").length).toEqual(1);
@ -42,7 +41,7 @@ describe("<GardenPlant/>", () => {
it("renders plant animations", () => {
const p = fakeProps();
p.animate = true;
p.multiselected = true;
p.selected = true;
const wrapper = shallow(<GardenPlant {...p} />);
expect(wrapper.find(".soil-cloud").length).toEqual(1);
expect(wrapper.find(".animate").length).toEqual(2);
@ -82,9 +81,9 @@ describe("<GardenPlant/>", () => {
expect(wrapper.find(".plant-indicator").length).toEqual(0);
});
it("indicator cirlce is there", () => {
it("indicator circle is rendered", () => {
const p = fakeProps();
p.multiselected = true;
p.selected = true;
const wrapper = shallow(<GardenPlant {...p} />);
expect(wrapper.find(".plant-indicator").length).toEqual(1);
expect(wrapper.find("Circle").length).toEqual(1);

View File

@ -22,7 +22,8 @@ describe("<PlantLayer/>", () => {
currentPlant: undefined,
dragging: false,
editing: false,
selectedForDel: undefined,
boxSelected: undefined,
groupSelected: [],
dispatch: jest.fn(),
zoomLvl: 1,
activeDragXY: { x: undefined, y: undefined, z: undefined },
@ -97,14 +98,14 @@ describe("<PlantLayer/>", () => {
expect(wrapper.find("GardenPlant").props().selected).toEqual(true);
});
it("has plant selected for deletion", () => {
it("has plant selected by selection box", () => {
mockPath = "/app/designer/plants";
const p = fakeProps();
const plant = fakePlant();
p.plants = [plant];
p.selectedForDel = [plant.uuid];
p.boxSelected = [plant.uuid];
const wrapper = svgMount(<PlantLayer {...p} />);
expect((wrapper.find("GardenPlant").props() as GardenPlantProps).multiselected)
expect((wrapper.find("GardenPlant").props() as GardenPlantProps).selected)
.toEqual(true);
});

View File

@ -43,7 +43,7 @@ export class GardenPlant extends
}
render() {
const { selected, dragging, plant, multiselected, mapTransformProps,
const { selected, dragging, plant, mapTransformProps,
activeDragXY, zoomLvl, animate, editing } = this.props;
const { id, radius, x, y } = plant.body;
const { icon } = this.state;
@ -65,7 +65,7 @@ export class GardenPlant extends
fill={Color.soilCloud}
fillOpacity={0} />}
{multiselected && !editing &&
{selected && !editing &&
<g id="selected-plant-indicator">
<Circle
className={`plant-indicator ${animate ? "animate" : ""}`}
@ -73,8 +73,7 @@ export class GardenPlant extends
y={qy}
r={radius}
selected={true} />
</g>
}
</g>}
<g id="plant-icon">
<image

View File

@ -16,14 +16,16 @@ export function PlantLayer(props: PlantLayerProps) {
mapTransformProps,
zoomLvl,
activeDragXY,
selectedForDel,
boxSelected,
groupSelected,
animate,
} = props;
return <g id="plant-layer">
{visible && plants.map(p => {
const selected = !!(currentPlant && (p.uuid === currentPlant.uuid));
const multiselected = !!(selectedForDel && (selectedForDel.includes(p.uuid)));
const selected = !!(p.uuid === currentPlant?.uuid);
const selectedByBox = !!boxSelected?.includes(p.uuid);
const selectedByGroup = groupSelected.includes(p.uuid);
const plantCategory = unpackUUID(p.uuid).kind === "PlantTemplate"
? "gardens/templates"
: "plants";
@ -31,9 +33,8 @@ export function PlantLayer(props: PlantLayerProps) {
uuid={p.uuid}
mapTransformProps={mapTransformProps}
plant={p}
selected={selected}
selected={selected || selectedByBox || selectedByGroup}
editing={editing}
multiselected={multiselected}
dragging={selected && dragging && editing}
dispatch={dispatch}
zoomLvl={zoomLvl}

View File

@ -1,3 +1,8 @@
let mockPath = "/app/designer/plants";
jest.mock("../../../../../history", () => ({
getPathArray: jest.fn(() => mockPath.split("/")),
}));
import * as React from "react";
import { PointLayer, PointLayerProps } from "../point_layer";
import { fakePoint } from "../../../../../__test_support__/fake_state/resources";
@ -8,21 +13,20 @@ import { GardenPoint } from "../garden_point";
import { svgMount } from "../../../../../__test_support__/svg_mount";
describe("<PointLayer/>", () => {
function fakeProps(): PointLayerProps {
return {
visible: true,
points: [fakePoint()],
mapTransformProps: fakeMapTransformProps(),
hoveredPoint: undefined,
dispatch: jest.fn(),
};
}
const fakeProps = (): PointLayerProps => ({
visible: true,
genericPoints: [fakePoint()],
mapTransformProps: fakeMapTransformProps(),
hoveredPoint: undefined,
dispatch: jest.fn(),
});
it("shows points", () => {
const p = fakeProps();
const wrapper = svgMount(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer");
expect(layer.find(GardenPoint).html()).toContain("r=\"100\"");
expect(layer.props().style).toEqual({ pointerEvents: "none" });
});
it("toggles visibility off", () => {
@ -32,4 +36,12 @@ describe("<PointLayer/>", () => {
const layer = wrapper.find("#point-layer");
expect(layer.find(GardenPoint).length).toEqual(0);
});
it("allows point mode interaction", () => {
mockPath = "/app/designer/points";
const p = fakeProps();
const wrapper = svgMount(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer");
expect(layer.props().style).toEqual({});
});
});

View File

@ -6,19 +6,19 @@ import { getMode } from "../../util";
export interface PointLayerProps {
visible: boolean;
points: TaggedGenericPointer[];
genericPoints: TaggedGenericPointer[];
mapTransformProps: MapTransformProps;
hoveredPoint: string | undefined;
dispatch: Function;
}
export function PointLayer(props: PointLayerProps) {
const { visible, points, mapTransformProps, hoveredPoint } = props;
const { visible, genericPoints, mapTransformProps, hoveredPoint } = props;
const style: React.CSSProperties =
getMode() === Mode.points ? {} : { pointerEvents: "none" };
return <g id="point-layer" style={style}>
{visible &&
points.map(p =>
genericPoints.map(p =>
<GardenPoint
point={p}
key={p.uuid}

View File

@ -1,26 +1,27 @@
import * as React from "react";
import { SpreadLayer, SpreadLayerProps } from "../spread_layer";
import {
SpreadLayer, SpreadLayerProps, SpreadCircle, SpreadCircleProps
} from "../spread_layer";
import { shallow } from "enzyme";
import { fakePlant } from "../../../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps
} from "../../../../../__test_support__/map_transform_props";
import { SpreadOverlapHelper } from "../spread_overlap_helper";
describe("<SpreadLayer/>", () => {
function fakeProps(): SpreadLayerProps {
return {
visible: true,
plants: [fakePlant()],
currentPlant: undefined,
mapTransformProps: fakeMapTransformProps(),
dragging: false,
zoomLvl: 1.8,
activeDragXY: { x: undefined, y: undefined, z: undefined },
activeDragSpread: undefined,
editing: false,
animate: false,
};
}
const fakeProps = (): SpreadLayerProps => ({
visible: true,
plants: [fakePlant()],
currentPlant: undefined,
mapTransformProps: fakeMapTransformProps(),
dragging: false,
zoomLvl: 1.8,
activeDragXY: { x: undefined, y: undefined, z: undefined },
activeDragSpread: undefined,
editing: false,
animate: false,
});
it("shows spread", () => {
const p = fakeProps();
@ -36,4 +37,30 @@ describe("<SpreadLayer/>", () => {
const layer = wrapper.find("#spread-layer");
expect(layer.find("SpreadCircle").length).toEqual(0);
});
it("is dragging", () => {
const p = fakeProps();
p.dragging = true;
p.editing = true;
p.currentPlant = p.plants[0];
const wrapper = shallow(<SpreadLayer {...p} />);
expect(wrapper.find(SpreadOverlapHelper).props().dragging).toEqual(true);
});
});
describe("<SpreadCircle />", () => {
const fakeProps = (): SpreadCircleProps => ({
plant: fakePlant(),
mapTransformProps: fakeMapTransformProps(),
visible: true,
animate: true,
});
it("uses spread value", () => {
const wrapper = shallow(<SpreadCircle {...fakeProps()} />);
wrapper.setState({ spread: 20 });
expect(wrapper.find("circle").props().r).toEqual(100);
expect(wrapper.find("circle").hasClass("animate")).toBeTruthy();
expect(wrapper.find("circle").props().fill).toEqual("url(#SpreadGradient)");
});
});

View File

@ -36,7 +36,7 @@ export function SpreadLayer(props: SpreadLayerProps) {
</defs>
{plants.map(p => {
const selected = !!(currentPlant && (p.uuid === currentPlant.uuid));
const selected = p.uuid === currentPlant?.uuid;
return <g id={"spread-components-" + p.body.id} key={p.uuid}>
{visible &&
<SpreadCircle
@ -58,7 +58,7 @@ export function SpreadLayer(props: SpreadLayerProps) {
</g>;
}
interface SpreadCircleProps {
export interface SpreadCircleProps {
plant: TaggedPlant;
mapTransformProps: MapTransformProps;
visible: boolean;

View File

@ -0,0 +1,86 @@
import * as React from "react";
import { svgMount } from "../../../../../__test_support__/svg_mount";
import { ZonesLayer, ZonesLayerProps } from "../zones_layer";
import {
fakePointGroup
} from "../../../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps
} from "../../../../../__test_support__/map_transform_props";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<ZonesLayer />", () => {
const fakeProps = (): ZonesLayerProps => ({
visible: true,
groups: [fakePointGroup(), fakePointGroup()],
currentGroup: undefined,
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true }
},
mapTransformProps: fakeMapTransformProps(),
});
it("renders", () => {
const wrapper = svgMount(<ZonesLayer {...fakeProps()} />);
expect(wrapper.find(".zones-layer").length).toEqual(1);
});
it("renders current group's zones: 2D", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria.number_gt = { x: 100 };
p.currentGroup = p.groups[0].uuid;
p.groups[1].body.id = 2;
p.groups[1].body.criteria.number_gt = { x: 200 };
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(0);
expect(wrapper.find("#zones-1D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("#zones-2D-2").length).toEqual(0);
});
it("renders current group's zones: 1D", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria.number_eq = { x: [100] };
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(0);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("#zones-2D-1").length).toEqual(0);
});
it("renders current group's zones: 0D", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria.number_eq = { x: [100], y: [100] };
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("#zones-1D-1").length).toEqual(0);
expect(wrapper.find("#zones-2D-1").length).toEqual(0);
});
it("renders current group's zones: none", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria = undefined as unknown as PointGroup["criteria"];
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html())
.toEqual("<svg><g class=\"zones-layer\"></g></svg>");
});
it("doesn't render current group's zones", () => {
const p = fakeProps();
p.visible = false;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html())
.toEqual("<svg><g class=\"zones-layer\"></g></svg>");
});
});

View File

@ -0,0 +1,165 @@
import * as React from "react";
import { svgMount } from "../../../../../__test_support__/svg_mount";
import {
Zones0D, ZonesProps, Zones1D, Zones2D, getZoneType, ZoneType
} from "../zones";
import {
fakePointGroup
} from "../../../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps
} from "../../../../../__test_support__/map_transform_props";
import { PointGroup } from "farmbot/dist/resources/api_resources";
const fakeProps = (): ZonesProps => ({
group: fakePointGroup(),
botSize: {
x: { value: 3000, isDefault: true },
y: { value: 1500, isDefault: true }
},
mapTransformProps: fakeMapTransformProps(),
currentGroup: undefined,
});
describe("<Zones0D />", () => {
it("renders none: no data", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = svgMount(<Zones0D {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(0);
});
it("renders none: some data", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100] };
const wrapper = svgMount(<Zones0D {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(0);
});
it("renders one", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100], y: [200] };
const wrapper = svgMount(<Zones0D {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(1);
});
it("renders some", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100], y: [200, 300] };
const wrapper = svgMount(<Zones0D {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(2);
});
});
describe("<Zones1D />", () => {
it("renders none: no data", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(0);
});
it("renders none: too constrained", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100], y: [100] };
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(0);
});
it("renders one: x", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [100] };
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(1);
});
it("renders one: y", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { y: [100] };
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(1);
});
it("renders some", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_eq = { x: [], y: [200, 300] };
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(2);
});
});
describe("<Zones2D />", () => {
it("renders none", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = svgMount(<Zones2D {...p} />);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("rect").length).toEqual(0);
});
it("renders one", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria.number_gt = { x: 100, y: 200 };
p.group.body.criteria.number_lt = { x: 300, y: 400 };
const wrapper = svgMount(<Zones2D {...p} />);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("rect").length).toEqual(1);
});
it("renders one: rotated", () => {
const p = fakeProps();
p.mapTransformProps.quadrant = 4;
p.mapTransformProps.xySwap = true;
p.group.body.id = 1;
p.group.body.criteria.number_gt = { x: 100, y: 200 };
p.group.body.criteria.number_lt = { x: 300, y: 400 };
const wrapper = svgMount(<Zones2D {...p} />);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("rect").length).toEqual(1);
});
});
describe("getZoneType()", () => {
it("returns none", () => {
const group = fakePointGroup();
expect(getZoneType(group)).toEqual(ZoneType.none);
});
it("returns area", () => {
const group = fakePointGroup();
group.body.criteria.number_gt = { x: 100 };
expect(getZoneType(group)).toEqual(ZoneType.area);
});
it("returns lines", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [100] };
expect(getZoneType(group)).toEqual(ZoneType.lines);
});
it("returns points", () => {
const group = fakePointGroup();
group.body.criteria.number_eq = { x: [100], y: [100] };
expect(getZoneType(group)).toEqual(ZoneType.points);
});
});

View File

@ -0,0 +1,161 @@
import * as React from "react";
import { TaggedPointGroup } from "farmbot";
import { MapTransformProps, BotSize } from "../../interfaces";
import { transformXY } from "../../util";
import { isUndefined } from "lodash";
import { UUID } from "../../../../resources/interfaces";
export interface ZonesProps {
currentGroup: UUID | undefined;
group: TaggedPointGroup;
mapTransformProps: MapTransformProps;
botSize: BotSize;
}
interface GetBoundaryProps {
group: TaggedPointGroup;
botSize: BotSize;
}
type Boundary = {
x1: number, y1: number, x2: number, y2: number,
selectsAll: boolean
};
type Line = { x?: number, y?: number };
type Point = { x: number, y: number };
export enum ZoneType { points, lines, area, none }
export const getZoneType = (group: TaggedPointGroup): ZoneType => {
const numEq = group.body.criteria?.number_eq || {};
const numGt = group.body.criteria?.number_gt || {};
const numLt = group.body.criteria?.number_lt || {};
const hasXEq = !!numEq.x?.length;
const hasYEq = !!numEq.y?.length;
if (hasXEq && hasYEq) {
return ZoneType.points;
}
if ((hasXEq && !hasYEq) || (!hasXEq && hasYEq)) {
return ZoneType.lines;
}
if (numGt.x || numGt.y || numLt.x || numLt.y) {
return ZoneType.area;
}
return ZoneType.none;
};
/** Bounds for area selected by criteria or bot extents. */
const getBoundary = (props: GetBoundaryProps): Boundary => {
const { criteria } = props.group.body;
const gt = criteria?.number_gt || {};
const lt = criteria?.number_lt || {};
const x1 = gt.x || 0;
const x2 = lt.x || props.botSize.x.value;
const y1 = gt.y || 0;
const y2 = lt.y || props.botSize.y.value;
const selectsAll = !(gt.x || lt.x || gt.y || lt.y);
return { x1, x2, y1, y2, selectsAll };
};
/** Apply bounds to zone data. */
const filter: <T extends Point | Line>(
boundary: Boundary, data: T[] | undefined) => T[] =
(boundary, data) =>
data?.filter(({ x, y }) =>
(isUndefined(x) || (x > boundary.x1 && x < boundary.x2)) &&
(isUndefined(y) || (y > boundary.y1 && y < boundary.y2))) || [];
/** Coordinates selected by both x and y number equal values. */
const getPoints =
(boundary: Boundary, group: TaggedPointGroup): Point[] => {
const xs = group.body.criteria?.number_eq.x;
const ys = group.body.criteria?.number_eq.y;
const points: Point[] = [];
xs?.map(x => ys?.map(y => points.push({ x, y })));
return filter<Point>(boundary, points);
};
/** Coordinates selected by both x and y number equal values. */
const zone0D = (props: ZonesProps) =>
getPoints(getBoundary(props), props.group)
.map(point => {
const { qx, qy } = transformXY(point.x, point.y, props.mapTransformProps);
return { x: qx, y: qy };
});
/** Coordinates selected by both x and y number equal values. */
export const Zones0D = (props: ZonesProps) => {
const current = props.group.uuid == props.currentGroup;
return <g id={`zones-0D-${props.group.body.id}`}
className={current ? "current" : ""}>
{zone0D(props).map((point, i) =>
<circle key={i} cx={point.x} cy={point.y} r={5} />)}
</g>;
};
/** Lines selected by an x or y number equal value. */
const getLines =
(boundary: Boundary, group: TaggedPointGroup): Line[] => {
const xs = group.body.criteria?.number_eq.x;
const ys = group.body.criteria?.number_eq.y;
const onlyXs = !!xs?.length && !ys?.length;
const onlyYs = !!ys?.length && !xs?.length;
const xLineData = onlyXs ? xs?.map(x => ({ x })) : undefined;
const yLineData = onlyYs ? ys?.map(y => ({ y })) : undefined;
return filter<Line>(boundary, xLineData || yLineData);
};
/** Lines selected by an x or y number equal value. */
const zone1D = (props: ZonesProps) => {
const boundary = getBoundary(props);
return getLines(boundary, props.group).map(line => {
const min = transformXY(
line.x || boundary.x1,
line.y || boundary.y1, props.mapTransformProps);
const max = transformXY(
line.x || boundary.x2,
line.y || boundary.y2, props.mapTransformProps);
return {
x1: min.qx,
y1: min.qy,
x2: max.qx,
y2: max.qy,
};
});
};
/** Lines selected by an x or y number equal value. */
export const Zones1D = (props: ZonesProps) => {
const current = props.group.uuid == props.currentGroup;
return <g id={`zones-1D-${props.group.body.id}`}
className={current ? "current" : ""}>
{zone1D(props).map((line, i) =>
<line key={i} x1={line.x1} y1={line.y1}
x2={line.x2} y2={line.y2} />)}
</g>;
};
/** Area selected by x and y number gt/lt values. */
const zone2D = (boundary: Boundary, mapTransformProps: MapTransformProps) => {
const position = transformXY(boundary.x1, boundary.y1, mapTransformProps);
const { xySwap, quadrant } = mapTransformProps;
const xLength = boundary.x2 - boundary.x1;
const yLength = boundary.y2 - boundary.y1;
return {
x: [1, 4].includes(quadrant) ? position.qx - xLength : position.qx,
y: [3, 4].includes(quadrant) ? position.qy - yLength : position.qy,
width: xySwap ? yLength : xLength,
height: xySwap ? xLength : yLength,
selectsAll: boundary.selectsAll,
};
};
/** Area selected by x and y number gt/lt values. */
export const Zones2D = (props: ZonesProps) => {
const zone = zone2D(getBoundary(props), props.mapTransformProps);
const current = props.group.uuid == props.currentGroup;
return <g id={`zones-2D-${props.group.body.id}`}
className={current ? "current" : ""}>
{!zone.selectsAll &&
<rect x={zone.x} y={zone.y} width={zone.width} height={zone.height} />}
</g>;
};

View File

@ -0,0 +1,31 @@
import * as React from "react";
import { TaggedPointGroup } from "farmbot";
import { MapTransformProps, BotSize } from "../../interfaces";
import { Zones0D, Zones1D, Zones2D, getZoneType, ZoneType } from "./zones";
import { UUID } from "../../../../resources/interfaces";
export interface ZonesLayerProps {
visible: boolean;
currentGroup: UUID | undefined;
groups: TaggedPointGroup[];
botSize: BotSize;
mapTransformProps: MapTransformProps;
}
export function ZonesLayer(props: ZonesLayerProps) {
const { groups, botSize, mapTransformProps, currentGroup } = props;
const commonProps = { botSize, mapTransformProps, currentGroup };
const visible = (group: TaggedPointGroup) =>
props.visible || (group.uuid == currentGroup);
return <g className="zones-layer">
{groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.area &&
<Zones2D {...commonProps} key={group.uuid} group={group} />)}
{groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.lines &&
<Zones1D {...commonProps} key={group.uuid} group={group} />)}
{groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.points &&
<Zones0D {...commonProps} key={group.uuid} group={group} />)}
</g>;
}

View File

@ -40,6 +40,7 @@ describe("<GardenMapLegend />", () => {
showSpread: false,
showFarmbot: false,
showImages: false,
showZones: false,
showSensorReadings: false,
hasSensorReadings: false,
dispatch: jest.fn(),

View File

@ -84,6 +84,11 @@ const LayerToggles = (props: GardenMapLegendProps) => {
dispatch={props.dispatch}
getConfigValue={getConfigValue}
imageAgeInfo={props.imageAgeInfo} />} />
{DevSettings.futureFeaturesEnabled() &&
<LayerToggle
value={props.showZones}
label={t("Zones?")}
onClick={toggle(BooleanSetting.show_zones)} />}
{DevSettings.futureFeaturesEnabled() && props.hasSensorReadings &&
<LayerToggle
value={props.showSensorReadings}

View File

@ -291,7 +291,8 @@ export const transformForQuadrant =
export const getMode = (): Mode => {
const pathArray = getPathArray();
if (pathArray) {
if ((pathArray[3] === "groups") && pathArray[4]) { return Mode.editGroup; }
if ((pathArray[3] === "groups" || pathArray[3] === "zones")
&& pathArray[4]) { return Mode.editGroup; }
if (pathArray[6] === "add") { return Mode.clickToAdd; }
if (!isNaN(parseInt(pathArray.slice(-1)[0]))) { return Mode.editPlant; }
if (pathArray[5] === "edit") { return Mode.editPlant; }

View File

@ -5,7 +5,7 @@ jest.mock("../../../history", () => ({
}));
import * as React from "react";
import { render } from "enzyme";
import { mount } from "enzyme";
import {
RawAddPlant as AddPlant, AddPlantProps, mapStateToProps
} from "../add_plant";
@ -14,7 +14,8 @@ import {
} from "../../../__test_support__/fake_crop_search_result";
import { svgToUrl } from "../../../open_farm/icons";
import { fakeState } from "../../../__test_support__/fake_state";
import { CropLiveSearchResult } from "../../interfaces";
import { buildResourceIndex } from "../../../__test_support__/resource_index_builder";
import { fakeWebAppConfig } from "../../../__test_support__/fake_state/resources";
describe("<AddPlant />", () => {
const fakeProps = (): AddPlantProps => {
@ -24,40 +25,40 @@ describe("<AddPlant />", () => {
cropSearchResults: [cropSearchResult],
dispatch: jest.fn(),
xy_swap: false,
openfarmSearch: jest.fn(),
openfarmSearch: jest.fn(() => jest.fn()),
};
};
it("renders", () => {
mockPath = "/app/designer/plants/crop_search/mint/add";
const wrapper = render(<AddPlant {...fakeProps()} />);
const p = fakeProps();
p.dispatch = jest.fn(x => x(jest.fn()));
const wrapper = mount(<AddPlant {...p} />);
expect(wrapper.text()).toContain("Mint");
expect(wrapper.text()).toContain("Preview");
const img = wrapper.find("img");
expect(img).toBeDefined();
expect(img.attr("src")).toEqual(svgToUrl("fake_mint_svg"));
expect(img.props().src).toEqual(svgToUrl("fake_mint_svg"));
expect(p.openfarmSearch).toHaveBeenCalledWith("mint");
});
});
describe("mapStateToProps", () => {
it("maps state to props", () => {
const state = fakeState();
const crop: CropLiveSearchResult = {
crop: {
name: "fake",
slug: "fake",
binomial_name: "fake",
common_names: ["fake"],
description: "",
sun_requirements: "",
sowing_method: "",
processing_pictures: 0
},
image: "X"
};
const crop = fakeCropLiveSearchResult();
state.resources.consumers.farm_designer.cropSearchResults = [crop];
const results = mapStateToProps(state);
expect(results.cropSearchResults).toEqual([crop]);
expect(results.xy_swap).toEqual(false);
});
it("returns xy_swap equals true", () => {
const state = fakeState();
const webAppConfig = fakeWebAppConfig();
webAppConfig.body.xy_swap = true;
state.resources = buildResourceIndex([webAppConfig]);
const results = mapStateToProps(state);
expect(results.xy_swap).toEqual(true);
});
});

View File

@ -1,4 +1,4 @@
import { mapStateToProps } from "../map_state_to_props";
import { mapStateToProps, plantAge } from "../map_state_to_props";
import { fakeState } from "../../../__test_support__/fake_state";
import {
buildResourceIndex
@ -32,3 +32,19 @@ describe("mapStateToProps()", () => {
expect.objectContaining({ uuid }));
});
});
describe("plantAge()", () => {
it("returns planted at date", () => {
const plant = fakePlant();
plant.body.planted_at = "2018-01-11T20:20:38.362Z";
plant.body.created_at = undefined;
expect(plantAge(plant)).toBeGreaterThan(100);
});
it("returns created at date", () => {
const plant = fakePlant();
plant.body.planted_at = undefined;
plant.body.created_at = "2018-01-11T20:20:38.362Z";
expect(plantAge(plant)).toBeGreaterThan(100);
});
});

View File

@ -1,5 +1,5 @@
jest.mock("../../../open_farm/cached_crop", () => ({
cachedCrop: jest.fn(() => Promise.resolve({ svg_icon: "icon" })),
maybeGetCachedPlantIcon: jest.fn(),
}));
jest.mock("../../../history", () => ({ push: jest.fn() }));
@ -8,22 +8,20 @@ import * as React from "react";
import {
PlantInventoryItem, PlantInventoryItemProps
} from "../plant_inventory_item";
import { shallow } from "enzyme";
import { shallow, mount } from "enzyme";
import {
fakePlant, fakePlantTemplate
} from "../../../__test_support__/fake_state/resources";
import { Actions } from "../../../constants";
import { push } from "../../../history";
import { svgToUrl } from "../../../open_farm/icons";
import { maybeGetCachedPlantIcon } from "../../../open_farm/cached_crop";
describe("<PlantInventoryItem />", () => {
const fakeProps = (): PlantInventoryItemProps => {
return {
tpp: fakePlant(),
dispatch: jest.fn(),
hovered: false,
};
};
const fakeProps = (): PlantInventoryItemProps => ({
plant: fakePlant(),
dispatch: jest.fn(),
hovered: false,
});
it("renders", () => {
const wrapper = shallow(<PlantInventoryItem {...fakeProps()} />);
@ -45,7 +43,7 @@ describe("<PlantInventoryItem />", () => {
expect(p.dispatch).toBeCalledWith({
payload: {
icon: "",
plantUUID: p.tpp.uuid
plantUUID: p.plant.uuid
},
type: Actions.TOGGLE_HOVERED_PLANT
});
@ -69,30 +67,31 @@ describe("<PlantInventoryItem />", () => {
const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("click");
expect(p.dispatch).toBeCalledWith({
payload: [p.tpp.uuid],
payload: [p.plant.uuid],
type: Actions.SELECT_PLANT
});
expect(push).toHaveBeenCalledWith("/app/designer/plants/" + p.tpp.body.id);
expect(push).toHaveBeenCalledWith("/app/designer/plants/" + p.plant.body.id);
});
it("selects plant template", () => {
const p = fakeProps();
p.tpp = fakePlantTemplate();
p.plant = fakePlantTemplate();
const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("click");
expect(p.dispatch).toBeCalledWith({
payload: [p.tpp.uuid],
payload: [p.plant.uuid],
type: Actions.SELECT_PLANT
});
expect(push).toHaveBeenCalledWith(
"/app/designer/gardens/templates/" + p.tpp.body.id);
"/app/designer/gardens/templates/" + p.plant.body.id);
});
it("gets cached icon", async () => {
it("gets cached icon", () => {
const wrapper =
shallow<PlantInventoryItem>(<PlantInventoryItem {...fakeProps()} />);
const img = new Image;
await wrapper.find("img").simulate("load", { currentTarget: img });
expect(wrapper.state().icon).toEqual(svgToUrl("icon"));
mount<PlantInventoryItem>(<PlantInventoryItem {...fakeProps()} />);
const img = wrapper.find("img");
img.simulate("load");
expect(maybeGetCachedPlantIcon).toHaveBeenCalledWith("strawberry",
img.instance(), expect.any(Function));
});
});

View File

@ -1,7 +1,14 @@
jest.mock("../../../open_farm/cached_crop", () => ({
maybeGetCachedPlantIcon: jest.fn(),
}));
import * as React from "react";
import { RawPlants as Plants, PlantInventoryProps } from "../plant_inventory";
import {
RawPlants as Plants, PlantInventoryProps, mapStateToProps
} from "../plant_inventory";
import { mount, shallow } from "enzyme";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { fakeState } from "../../../__test_support__/fake_state";
describe("<PlantInventory />", () => {
const fakeProps = (): PlantInventoryProps => ({
@ -32,3 +39,12 @@ describe("<PlantInventory />", () => {
expect(wrapper.state().searchTerm).toEqual("mint");
});
});
describe("mapStateToProps()", () => {
it("returns props", () => {
const state = fakeState();
state.resources.consumers.farm_designer.hoveredPlantListItem = "uuid";
const result = mapStateToProps(state);
expect(result.hoveredPlantListItem).toEqual("uuid");
});
});

View File

@ -3,7 +3,7 @@ import { testGrid } from "./generate_grid_test";
import { GridInput, InputCell, InputCellProps, createCB } from "../grid_input";
import { mount, shallow } from "enzyme";
import { BlurableInput } from "../../../../ui/blurable_input";
import { DeepPartial } from "redux";
import { changeEvent } from "../../../../__test_support__/fake_html_events";
describe("<GridInput/>", () => {
it("renders", () => {
@ -37,15 +37,8 @@ describe("<InputCell/>", () => {
describe("createCB", () => {
it("creates a callback", () => {
type E = React.ChangeEvent<HTMLInputElement>;
const e: DeepPartial<E> = {
currentTarget: {
value: "7"
}
};
const dispatch = jest.fn();
const cb = createCB("numPlantsH", dispatch);
cb(e as E);
createCB("numPlantsH", dispatch)(changeEvent("7"));
expect(dispatch).toHaveBeenCalledWith("numPlantsH", 7);
});
});

View File

@ -49,22 +49,30 @@ export interface FormattedPlantInfo {
plantStatus: PlantStage;
}
export function formatPlantInfo(rsrc: TaggedPlant): FormattedPlantInfo {
const p = rsrc.body;
const plantedAt = get(p, "planted_at", moment())
? moment(get(p, "planted_at", moment()))
: moment(get(p, "created_at", moment()));
const currentDay = moment();
const daysOld = currentDay.diff(plantedAt, "days") + 1;
/** Get date planted or fallback to creation date. */
const plantDate = (plant: TaggedPlant): moment.Moment => {
const plantedAt = get(plant, "body.planted_at");
const createdAt = get(plant, "body.created_at", moment());
return plantedAt ? moment(plantedAt) : moment(createdAt);
};
/** Compare planted or created date vs time now to determine age. */
export const plantAge = (plant: TaggedPlant): number => {
const currentDate = moment();
const daysOld = currentDate.diff(plantDate(plant), "days") + 1;
return daysOld;
};
export function formatPlantInfo(plant: TaggedPlant): FormattedPlantInfo {
return {
slug: p.openfarm_slug,
id: p.id,
name: p.name,
daysOld,
x: p.x,
y: p.y,
uuid: rsrc.uuid,
plantedAt,
plantStatus: get(p, "plant_stage", "planned"),
slug: plant.body.openfarm_slug,
id: plant.body.id,
name: plant.body.name,
daysOld: plantAge(plant),
x: plant.body.x,
y: plant.body.y,
uuid: plant.uuid,
plantedAt: plantDate(plant),
plantStatus: get(plant, "plant_stage", "planned"),
};
}

Some files were not shown because too many files have changed in this diff Show More