groups updates

pull/1686/head
gabrielburnworth 2020-02-07 15:05:16 -08:00
parent 837cbe8a85
commit 464b730cd8
126 changed files with 3646 additions and 754 deletions

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

@ -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

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

@ -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

@ -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

@ -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

@ -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

@ -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"),
};
}

View File

@ -24,7 +24,7 @@ interface State {
searchTerm: string;
}
function mapStateToProps(props: Everything): PlantInventoryProps {
export function mapStateToProps(props: Everything): PlantInventoryProps {
const { hoveredPlantListItem } = props.resources.consumers.farm_designer;
return {
plants: getPlants(props.resources),
@ -62,7 +62,7 @@ export class RawPlants extends React.Component<PlantInventoryProps, State> {
.includes(this.state.searchTerm.toLowerCase()))
.map(p => <PlantInventoryItem
key={p.uuid}
tpp={p}
plant={p}
hovered={this.props.hoveredPlantListItem === p.uuid}
dispatch={this.props.dispatch} />)}
</EmptyStateWrapper>

View File

@ -1,18 +1,15 @@
import * as React from "react";
import moment from "moment";
import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons";
import { DEFAULT_ICON } from "../../open_farm/icons";
import { push } from "../../history";
import { TaggedPlant } from "../map/interfaces";
import { get } from "lodash";
import { unpackUUID } from "../../util";
import { t } from "../../i18next_wrapper";
import { cachedCrop } from "../../open_farm/cached_crop";
import { maybeGetCachedPlantIcon } from "../../open_farm/cached_crop";
import { selectPlant, setHoveredPlant } from "../map/actions";
type IMGEvent = React.SyntheticEvent<HTMLImageElement>;
import { plantAge } from "./map_state_to_props";
export interface PlantInventoryItemProps {
tpp: TaggedPlant;
plant: TaggedPlant;
dispatch: Function;
hovered: boolean;
}
@ -28,49 +25,32 @@ export class PlantInventoryItem extends
state: PlantInventoryItemState = { icon: "" };
render() {
const plant = this.props.tpp.body;
const { tpp, dispatch } = this.props;
const plantId = (plant.id || "ERR_NO_PLANT_ID").toString();
const { plant, dispatch } = this.props;
const plantId = (plant.body.id || "ERR_NO_PLANT_ID").toString();
const toggle = (action: "enter" | "leave") => {
const isEnter = action === "enter";
const plantUUID = isEnter ? tpp.uuid : undefined;
const plantUUID = isEnter ? plant.uuid : undefined;
const icon = isEnter ? this.state.icon : "";
dispatch(setHoveredPlant(plantUUID, icon));
};
const click = () => {
const plantCategory =
unpackUUID(this.props.tpp.uuid).kind === "PlantTemplate"
unpackUUID(plant.uuid).kind === "PlantTemplate"
? "gardens/templates"
: "plants";
push(`/app/designer/${plantCategory}/${plantId}`);
dispatch(selectPlant([tpp.uuid]));
dispatch(selectPlant([plant.uuid]));
};
// See `cachedIcon` for more details on this.
const maybeGetCachedIcon = (e: IMGEvent) => {
const OFS = tpp.body.openfarm_slug;
const img = e.currentTarget;
OFS && cachedCrop(OFS)
.then((crop) => {
const i = svgToUrl(crop.svg_icon);
i !== img.getAttribute("src") && img.setAttribute("src", i);
this.setState({ icon: i });
});
};
const updateStateIcon = (i: string) => this.setState({ icon: i });
const onLoad = (e: React.SyntheticEvent<HTMLImageElement>) =>
maybeGetCachedPlantIcon(slug, e.currentTarget, updateStateIcon);
// Name given from OpenFarm's API.
const label = plant.name || "Unknown plant";
// Original planted date vs time now to determine age.
const getPlantedAt = get(plant, "planted_at", moment());
const createdAt = get(plant, "created_at", moment());
const plantedAt = getPlantedAt
? moment(getPlantedAt)
: moment(createdAt);
const currentDay = moment();
const daysOld = currentDay.diff(plantedAt, "days") + 1;
const label = plant.body.name || "Unknown plant";
const slug = plant.body.openfarm_slug;
return <div
className={`plant-search-item ${this.props.hovered ? "hovered" : ""}`}
@ -81,12 +61,12 @@ export class PlantInventoryItem extends
<img
className="plant-search-item-image"
src={DEFAULT_ICON}
onLoad={maybeGetCachedIcon} />
onLoad={onLoad} />
<span className="plant-search-item-name">
{label}
</span>
<i className="plant-search-item-age">
{daysOld} {t("days old")}
{plantAge(plant)} {t("days old")}
</i>
</div>;
}

View File

@ -77,7 +77,7 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
</button>
<button className="fb-button dark-blue"
onClick={() => !this.props.gardenOpen
? this.props.dispatch(createGroup({ points: this.selected }))
? this.props.dispatch(createGroup({ pointUuids: this.selected }))
: error(t(Content.ERROR_PLANT_TEMPLATE_GROUP))}>
{t("Create group")}
</button>
@ -111,7 +111,7 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
selectedPlantData.map(p =>
<PlantInventoryItem
key={p.uuid}
tpp={p}
plant={p}
hovered={false}
dispatch={dispatch} />)}
</DesignerPanelContent>

View File

@ -1,15 +1,9 @@
jest.mock("../../../api/crud", () => {
return {
init: jest.fn(() => ({ payload: { uuid: "???" } })),
save: jest.fn()
};
});
jest.mock("../../../api/crud", () => ({
init: jest.fn(() => ({ payload: { uuid: "???" } })),
save: jest.fn()
}));
jest.mock("../../../history", () => {
return {
history: { push: jest.fn() }
};
});
jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
jest.mock("../../../resources/selectors", () => ({
findPointGroup: jest.fn(() => ({ body: { id: 323232332 } })),
@ -19,32 +13,31 @@ jest.mock("../../../resources/selectors", () => ({
import { createGroup } from "../actions";
import { init, save } from "../../../api/crud";
import { history } from "../../../history";
import { buildResourceIndex } from "../../../__test_support__/resource_index_builder";
import { fakePoint, fakePlant, fakeToolSlot } from "../../../__test_support__/fake_state/resources";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import {
fakePoint, fakePlant, fakeToolSlot
} from "../../../__test_support__/fake_state/resources";
import { DeepPartial } from "redux";
import { Everything } from "../../../interfaces";
import { DEFAULT_CRITERIA } from "../criteria/interfaces";
describe("group action creators and thunks", () => {
it("creates groups", async () => {
const fakePoints = [fakePoint(), fakePlant(), fakeToolSlot()];
const resources = buildResourceIndex(fakePoints);
const points = fakePoints.map(x => x.uuid);
const pointUuids = fakePoints.map(x => x.uuid);
const fakeS: DeepPartial<Everything> = { resources };
const dispatch = jest.fn(() => Promise.resolve());
const thunk = createGroup({ points, name: "Name123" });
const thunk = createGroup({ pointUuids, groupName: "Name123" });
await thunk(dispatch, () => fakeS as Everything);
expect(init).toHaveBeenCalledWith("PointGroup", expect.objectContaining({
name: "Name123",
point_ids: [1, 2],
sort_type: "xy_ascending",
criteria: {
day: { days: 0, op: ">" },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
},
criteria: DEFAULT_CRITERIA,
}));
expect(save).toHaveBeenCalledWith("???");
expect(history.push)

View File

@ -1,26 +0,0 @@
const mockId = 123;
jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => ["groups", mockId])
}));
import { fetchGroupFromUrl } from "../group_detail";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
describe("fetchGroupFromUrl", () => {
it("fetches a group from URL", () => {
const group = fakePointGroup();
group.body.id = mockId;
const result = fetchGroupFromUrl(buildResourceIndex([group]).index);
expect(result).toEqual(group);
});
it("fetches a group from URL", () => {
const group = fakePointGroup();
group.body.id = 0; // intentionally wrong.
const result = fetchGroupFromUrl(buildResourceIndex([group]).index);
expect(result).toEqual(undefined);
});
});

View File

@ -14,39 +14,55 @@ jest.mock("../../../account/dev/dev_support", () => ({
}));
import React from "react";
import { GroupDetailActive } from "../group_detail_active";
import {
GroupDetailActive, GroupDetailActiveProps
} from "../group_detail_active";
import { mount, shallow } from "enzyme";
import {
fakePointGroup, fakePlant
} from "../../../__test_support__/fake_state/resources";
import { save, edit } from "../../../api/crud";
import { SpecialStatus } from "farmbot";
import { DEFAULT_CRITERIA } from "../criteria/interfaces";
describe("<GroupDetailActive/>", () => {
function fakeProps() {
const fakeProps = (): GroupDetailActiveProps => {
const plant = fakePlant();
plant.body.id = 1;
const plants = [plant];
const group = fakePointGroup();
group.specialStatus = SpecialStatus.DIRTY;
group.body.name = "XYZ";
group.body.point_ids = [plant.body.id];
return { dispatch: jest.fn(), group, plants };
}
return {
dispatch: jest.fn(),
group,
allPoints: [],
shouldDisplay: () => true,
slugs: [],
};
};
it("saves", () => {
const p = fakeProps();
const { dispatch } = p;
const el = new GroupDetailActive(p);
el.saveGroup();
expect(dispatch).toHaveBeenCalled();
expect(p.dispatch).toHaveBeenCalled();
expect(save).toHaveBeenCalledWith(p.group.uuid);
});
it("renders", () => {
const props = fakeProps();
const el = mount(<GroupDetailActive {...props} />);
expect(el.find("input").prop("defaultValue")).toContain("XYZ");
const p = fakeProps();
p.group.specialStatus = SpecialStatus.SAVED;
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.find("input").first().prop("defaultValue")).toContain("XYZ");
expect(wrapper.text()).not.toContain("saving");
});
it("shows saving indicator", () => {
const p = fakeProps();
p.group.specialStatus = SpecialStatus.DIRTY;
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text()).toContain("saving");
});
it("changes group name", () => {
@ -69,13 +85,7 @@ describe("<GroupDetailActive/>", () => {
name: "XYZ",
point_ids: [1],
sort_type: "xy_ascending",
criteria: {
day: { days: 0, op: ">" },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
}
criteria: DEFAULT_CRITERIA
},
kind: "PointGroup",
specialStatus: "DIRTY",
@ -97,7 +107,6 @@ describe("<GroupDetailActive/>", () => {
it("shows paths", () => {
mockDev = true;
const p = fakeProps();
p.plants = [fakePlant(), fakePlant()];
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text().toLowerCase()).toContain("optimized");
});

View File

@ -11,19 +11,18 @@ mockGroup.body.name = "one";
mockGroup.body.id = GOOD_ID;
mockGroup.body.point_ids = [23];
let mockId = GOOD_ID;
jest.mock("../../../history", () => {
return {
getPathArray: jest.fn(() => ["groups", mockId]),
push: jest.fn()
};
});
const mockId = GOOD_ID;
let mockPath = `/app/designer/groups/${mockId}`;
jest.mock("../../../history", () => ({
getPathArray: jest.fn(() => mockPath.split("/")),
push: jest.fn()
}));
import React from "react";
import { Provider } from "react-redux";
import { mount } from "enzyme";
import { GroupDetailActive } from "../group_detail_active";
import { GroupDetail } from "../group_detail";
import { GroupDetail, findGroupFromUrl } from "../group_detail";
import { fakeState } from "../../../__test_support__/fake_state";
import { createStore } from "redux";
import {
@ -42,7 +41,7 @@ describe("<GroupDetail />", () => {
};
it("redirects when group is not found", () => {
mockId = -23;
mockPath = "/app/designer/groups/-23";
const store = fakeStore();
const el = mount(<Provider store={store}>
<GroupDetail />
@ -53,7 +52,7 @@ describe("<GroupDetail />", () => {
});
it("loads <GroupDetailActive/>", () => {
mockId = GOOD_ID;
mockPath = `/app/designer/groups/${mockId}`;
const store = fakeStore();
const el = mount(<Provider store={store}>
<GroupDetail />
@ -62,3 +61,35 @@ describe("<GroupDetail />", () => {
expect(result.length).toEqual(1);
});
});
describe("findGroupFromUrl()", () => {
it("finds group from URL", () => {
mockPath = `/app/designer/groups/${mockId}`;
const group = fakePointGroup();
group.body.id = mockId;
const otherGroup = fakePointGroup();
otherGroup.body.id = mockId + 1;
const result = findGroupFromUrl([group]);
expect(result).toEqual(group);
});
it("fails to find group from URL", () => {
mockPath = `/app/designer/groups/${mockId}`;
const result = findGroupFromUrl([]);
expect(result).toEqual(undefined);
});
it("fails to find group from URL: undefined array item", () => {
mockPath = "/app/designer/groups/";
const result = findGroupFromUrl([]);
expect(result).toEqual(undefined);
});
it("doesn't try to find a group when at a different URL", () => {
mockPath = `/app/designer/plants/${mockId}`;
const group = fakePointGroup();
group.body.id = mockId;
const result = findGroupFromUrl([group]);
expect(result).toEqual(undefined);
});
});

View File

@ -1,21 +1,33 @@
import React from "react";
import { GroupInventoryItem } from "../group_inventory_item";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import {
GroupInventoryItem, GroupInventoryItemProps
} from "../group_inventory_item";
import {
fakePointGroup, fakePlant
} from "../../../__test_support__/fake_state/resources";
import { mount } from "enzyme";
describe("<GroupInventoryItem/>", () => {
it("renders information about the current group", () => {
const dispatch = jest.fn();
const group = fakePointGroup();
const onClick = jest.fn();
group.body.point_ids = [1, 2, 3];
group.body.name = "woosh";
describe("<GroupInventoryItem />", () => {
const fakeProps = (): GroupInventoryItemProps => ({
group: fakePointGroup(),
allPoints: [],
dispatch: jest.fn(),
onClick: jest.fn(),
hovered: true,
});
const x = mount(<GroupInventoryItem
group={group}
hovered={true}
dispatch={dispatch}
onClick={onClick} />);
it("renders information about the current group", () => {
const p = fakeProps();
p.group.body.point_ids = [1, 2, 3];
p.group.body.name = "woosh";
const point1 = fakePlant();
point1.body.id = 1;
const point2 = fakePlant();
point2.body.id = 2;
const point3 = fakePlant();
point3.body.id = 3;
p.allPoints = [point1, point2, point3];
const x = mount(<GroupInventoryItem {...p} />);
expect(x.text()).toContain("3 items");
expect(x.text()).toContain("woosh");
expect(x.find(".hovered").length).toBe(1);

View File

@ -8,7 +8,9 @@ import { mount, shallow } from "enzyme";
import {
RawGroupListPanel as GroupListPanel, GroupListPanelProps, mapStateToProps
} from "../group_list_panel";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import {
fakePointGroup, fakePlant
} from "../../../__test_support__/fake_state/resources";
import { history } from "../../../history";
import { fakeState } from "../../../__test_support__/fake_state";
import {
@ -17,15 +19,23 @@ import {
describe("<GroupListPanel />", () => {
const fakeProps = (): GroupListPanelProps => {
const fake1 = fakePointGroup();
fake1.body.name = "one";
fake1.body.id = 9;
fake1.body.point_ids = [1, 2, 3];
const fake2 = fakePointGroup();
fake2.body.name = "two";
return { dispatch: jest.fn(), groups: [fake1, fake2] };
const group1 = fakePointGroup();
group1.body.name = "one";
group1.body.id = 9;
group1.body.point_ids = [1, 2, 3];
const group2 = fakePointGroup();
group2.body.name = "two";
const point1 = fakePlant();
point1.body.id = 1;
const point2 = fakePlant();
point2.body.id = 2;
const point3 = fakePlant();
point3.body.id = 3;
return {
dispatch: jest.fn(),
groups: [group1, group2],
allPoints: [point1, point2, point3],
};
};
it("changes search term", () => {

View File

@ -1,14 +1,17 @@
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
const mockGroup = fakePointGroup();
mockGroup.body.point_ids = [1, 2, 3];
jest.mock("../group_detail", () => ({ fetchGroupFromUrl: () => mockGroup }));
import { fakeState } from "../../../__test_support__/fake_state";
const mockState = fakeState();
jest.mock("../../../redux/store", () => ({
store: { getState: () => mockState },
}));
import * as React from "react";
import { GroupOrder, GroupOrderProps } from "../group_order_visual";
import {
fakeMapTransformProps
} from "../../../__test_support__/map_transform_props";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import {
fakePlant, fakePointGroup
} from "../../../__test_support__/fake_state/resources";
import { svgMount } from "../../../__test_support__/svg_mount";
describe("<GroupOrder />", () => {
@ -23,9 +26,12 @@ describe("<GroupOrder />", () => {
plant4.body.id = undefined;
const plant5 = fakePlant();
plant5.body.id = 5;
const group = fakePointGroup();
group.body.point_ids = [1, 2, 3];
return {
mapTransformProps: fakeMapTransformProps(),
plants: [plant1, plant2, plant3],
groupPoints: [plant1, plant2, plant3],
group,
};
};
@ -33,4 +39,11 @@ describe("<GroupOrder />", () => {
const wrapper = svgMount(<GroupOrder {...fakeProps()} />);
expect(wrapper.find("line").length).toEqual(3);
});
it("renders optimized group order", () => {
const p = fakeProps();
mockState.resources.consumers.farm_designer.tryGroupSortType = "nn";
const wrapper = svgMount(<GroupOrder {...p} />);
expect(wrapper.find("line").length).toEqual(3);
});
});

View File

@ -1,10 +1,12 @@
jest.mock("../../../api/crud", () => ({ edit: jest.fn() }));
import * as React from "react";
import { shallow } from "enzyme";
import { PathInfoBar, nn, NNPath, PathInfoBarProps } from "../paths";
import { shallow, mount } from "enzyme";
import {
fakePlant, fakePointGroup
PathInfoBar, nn, NNPath, PathInfoBarProps, Paths, PathsProps
} from "../paths";
import {
fakePointGroup, fakePoint
} from "../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps
@ -13,6 +15,48 @@ import { Actions } from "../../../constants";
import { edit } from "../../../api/crud";
import { error } from "../../../toast/toast";
import { svgMount } from "../../../__test_support__/svg_mount";
import { SORT_OPTIONS } from "../point_group_sort_selector";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
/**
* p1 -- p2 --
* -- -- -- --
* -- -- -- --
* p3 -- -- p4
*/
const pathTestCases = () => {
const p1 = fakePoint();
p1.body.x = 0;
p1.body.y = 0;
const p2 = fakePoint();
p2.body.x = 2;
p2.body.y = 0;
const p3 = fakePoint();
p3.body.x = 0;
p3.body.y = 3;
const p4 = fakePoint();
p4.body.x = 3;
p4.body.y = 3;
return {
points: { p1, p2, p3, p4 },
order: {
xy_ascending: [p1, p3, p2, p4],
xy_descending: [p4, p2, p3, p1],
yx_ascending: [p1, p2, p3, p4],
yx_descending: [p4, p3, p2, p1],
random: expect.arrayContaining([p1, p2, p3, p4]),
nn: [p1, p2, p4, p3],
},
distance: {
xy_ascending: 10,
xy_descending: 10,
yx_ascending: 9,
yx_descending: 9,
random: expect.any(Number),
nn: 8,
}
};
};
describe("<PathInfoBar />", () => {
const fakeProps = (): PathInfoBarProps => ({
@ -59,26 +103,16 @@ describe("<PathInfoBar />", () => {
describe("nearest neighbor algorithm", () => {
it("returns optimized array", () => {
const p1 = fakePlant();
p1.body.x = 100;
p1.body.y = 100;
const p2 = fakePlant();
p2.body.x = 200;
p2.body.y = 200;
const p3 = fakePlant();
p3.body.x = 175;
p3.body.y = 1000;
const p4 = fakePlant();
p4.body.x = 1000;
p4.body.y = 150;
const points = nn([p4, p2, p3, p1, p1]);
expect(points).toEqual([p1, p2, p3, p4]);
const cases = pathTestCases();
const { p1, p2, p3, p4 } = cases.points;
const pathPoints = nn([p4, p2, p3, p1, p1]);
expect(pathPoints).toEqual(cases.order.nn);
});
});
describe("<NNPath />", () => {
const fakeProps = () => ({
plants: [],
pathPoints: [],
mapTransformProps: fakeMapTransformProps(),
});
@ -93,3 +127,31 @@ describe("<NNPath />", () => {
expect(wrapper.html()).not.toEqual("<svg><g></g></svg>");
});
});
describe("<Paths />", () => {
const fakeProps = (): PathsProps => ({
pathPoints: [],
dispatch: jest.fn(),
group: fakePointGroup(),
});
it("generates path data", () => {
const p = fakeProps();
const cases = pathTestCases();
p.pathPoints = cases.order.xy_ascending;
const wrapper = mount<Paths>(<Paths {...p} />);
expect(wrapper.state().pathData).toEqual(cases.distance);
});
it.each<[PointGroupSortType]>([
["xy_ascending"],
["xy_descending"],
["yx_ascending"],
["yx_descending"],
["random"],
])("checks sort order: %s", (sortType) => {
const cases = pathTestCases();
expect(SORT_OPTIONS[sortType](cases.order.xy_ascending))
.toEqual(cases.order[sortType]);
});
});

View File

@ -1,63 +1,94 @@
jest.mock("../../../open_farm/cached_crop", () => ({
maybeGetCachedPlantIcon: jest.fn(),
setImgSrc: jest.fn(),
}));
jest.mock("../../map/actions", () => ({ setHoveredPlant: jest.fn() }));
jest.mock("../../../api/crud", () => ({ overwrite: jest.fn() }));
import React from "react";
import { PointGroupItem } from "../point_group_item";
import {
PointGroupItem, PointGroupItemProps, genericPointIcon, OTHER_POINT_ICON
} from "../point_group_item";
import { shallow } from "enzyme";
import {
fakePlant, fakePointGroup
fakePlant, fakePointGroup, fakePoint, fakeToolSlot
} from "../../../__test_support__/fake_state/resources";
import { DeepPartial } from "redux";
import { cachedCrop } from "../../../open_farm/cached_crop";
import {
maybeGetCachedPlantIcon, setImgSrc
} from "../../../open_farm/cached_crop";
import { setHoveredPlant } from "../../map/actions";
import { overwrite } from "../../../api/crud";
import { cloneDeep } from "lodash";
import { imgEvent } from "../../../__test_support__/fake_html_events";
import { error } from "../../../toast/toast";
import { svgToUrl } from "../../../open_farm/icons";
describe("<PointGroupItem/>", () => {
const newProps = (): PointGroupItem["props"] => ({
const fakeProps = (): PointGroupItemProps => ({
dispatch: jest.fn(),
plant: fakePlant(),
point: fakePlant(),
group: fakePointGroup(),
hovered: true
});
it("renders", () => {
const props = newProps();
const el = shallow<HTMLSpanElement>(<PointGroupItem {...props} />);
const p = fakeProps();
const el = shallow<PointGroupItem>(<PointGroupItem {...p} />);
const i = el.instance() as PointGroupItem;
expect(el.first().prop("onMouseEnter")).toEqual(i.enter);
expect(el.first().prop("onMouseLeave")).toEqual(i.leave);
expect(el.first().prop("onClick")).toEqual(i.click);
});
it("handles hovering", async () => {
const i = new PointGroupItem(newProps());
it("fetches plant icon", async () => {
const p = fakeProps();
p.point = fakePlant();
const i = new PointGroupItem(p);
i.setState = jest.fn();
type E = React.SyntheticEvent<HTMLImageElement, Event>;
const partialE: DeepPartial<E> = {
currentTarget: {
getAttribute: jest.fn(),
setAttribute: jest.fn(),
}
};
const e = partialE as E;
await i.maybeGetCachedIcon(e as E);
const slug = i.props.plant.body.openfarm_slug;
expect(cachedCrop).toHaveBeenCalledWith(slug);
const icon = "data:image/svg+xml;utf8,icon";
expect(i.setState).toHaveBeenCalledWith({ icon });
expect(e.currentTarget.setAttribute).toHaveBeenCalledWith("src", icon);
const fakeImgEvent = imgEvent();
await i.maybeGetCachedIcon(fakeImgEvent);
const slug = i.props.point.body.pointer_type === "Plant" ?
i.props.point.body.openfarm_slug : "slug";
expect(maybeGetCachedPlantIcon)
.toHaveBeenCalledWith(slug, expect.any(Object), expect.any(Function));
expect(setImgSrc).not.toHaveBeenCalled();
});
it("fetches point icon", () => {
const p = fakeProps();
p.point = fakePoint();
const i = new PointGroupItem(p);
i.setState = jest.fn();
const fakeImgEvent = imgEvent();
i.maybeGetCachedIcon(fakeImgEvent);
expect(maybeGetCachedPlantIcon).not.toHaveBeenCalled();
expect(setImgSrc).toHaveBeenCalledWith(expect.any(Object),
svgToUrl(genericPointIcon(undefined)));
});
it("fetches other icon", () => {
const p = fakeProps();
p.point = fakeToolSlot();
const i = new PointGroupItem(p);
i.setState = jest.fn();
const fakeImgEvent = imgEvent();
i.maybeGetCachedIcon(fakeImgEvent);
expect(maybeGetCachedPlantIcon).not.toHaveBeenCalled();
expect(setImgSrc).toHaveBeenCalledWith(expect.any(Object),
svgToUrl(OTHER_POINT_ICON));
});
it("handles mouse enter", () => {
const i = new PointGroupItem(newProps());
const i = new PointGroupItem(fakeProps());
i.state.icon = "X";
i.enter();
expect(i.props.dispatch).toHaveBeenCalledTimes(1);
expect(setHoveredPlant).toHaveBeenCalledWith(i.props.plant.uuid, "X");
expect(setHoveredPlant).toHaveBeenCalledWith(i.props.point.uuid, "X");
});
it("handles mouse exit", () => {
const i = new PointGroupItem(newProps());
const i = new PointGroupItem(fakeProps());
i.state.icon = "X";
i.leave();
expect(i.props.dispatch).toHaveBeenCalledTimes(1);
@ -65,37 +96,28 @@ describe("<PointGroupItem/>", () => {
});
it("handles clicks", () => {
const i = new PointGroupItem(newProps());
const p = fakeProps();
p.point.body.id = 1;
p.group.body.point_ids = [1];
const i = new PointGroupItem(p);
i.click();
expect(i.props.dispatch).toHaveBeenCalledTimes(2);
expect(overwrite).toHaveBeenCalledWith({
body: {
name: "Fake",
point_ids: [],
sort_type: "xy_ascending",
criteria: {
day: { days: 0, op: ">" },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
}
},
kind: "PointGroup",
specialStatus: "",
uuid: expect.any(String),
}, {
name: "Fake",
point_ids: [],
sort_type: "xy_ascending",
criteria: {
day: { days: 0, op: ">" },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
}
});
const expectedGroupBody = cloneDeep(p.group.body);
expectedGroupBody.point_ids = [];
expect(overwrite).toHaveBeenCalledWith(p.group, expectedGroupBody);
expect(setHoveredPlant).toHaveBeenCalledWith(undefined);
});
it("errors on click", () => {
const p = fakeProps();
p.point.body.id = 1;
p.group.body.point_ids = [];
const i = new PointGroupItem(p);
i.click();
expect(i.props.dispatch).not.toHaveBeenCalled();
expect(overwrite).not.toHaveBeenCalled();
expect(setHoveredPlant).not.toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
"Cannot remove points selected by criteria.");
});
});

View File

@ -1,8 +1,14 @@
import { isSortType, sortTypeChange, SORT_OPTIONS } from "../point_group_sort_selector";
import * as React from "react";
import {
isSortType, sortTypeChange, SORT_OPTIONS, PointGroupSortSelector,
PointGroupSortSelectorProps
} from "../point_group_sort_selector";
import { DropDownItem } from "../../../ui";
import { DeepPartial } from "redux";
import { TaggedPlant } from "../../map/interfaces";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { TaggedPoint } from "farmbot";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { mount } from "enzyme";
import { Content } from "../../../constants";
const tests: [string, boolean][] = [
["", false],
@ -38,11 +44,15 @@ describe("sortTypeChange", () => {
});
});
describe("", () => {
const phony = (name: string, x: number, y: number): DeepPartial<TaggedPlant> => {
return { body: { name, x, y } };
describe("sort()", () => {
const phony = (name: string, x: number, y: number): TaggedPoint => {
const plant = fakePlant();
plant.body.name = name;
plant.body.x = x;
plant.body.y = y;
return plant;
};
const plants = [
const fakePoints = [
phony("A", 0, 0),
phony("B", 1, 0),
phony("C", 1, 1),
@ -50,13 +60,13 @@ describe("", () => {
];
const sort = (sortType: PointGroupSortType): string[] => {
const array = SORT_OPTIONS[sortType](plants as TaggedPlant[]);
const array = SORT_OPTIONS[sortType](fakePoints);
return array.map(x => x?.body?.name || "NA");
};
it("sorts randomly", () => {
const results = sort("random");
expect(results.length).toEqual(plants.length);
expect(results.length).toEqual(fakePoints.length);
});
it("sorts by xy_ascending", () => {
@ -79,3 +89,15 @@ describe("", () => {
expect(results).toEqual(["C", "D", "B", "A"]);
});
});
describe("<PointGroupSortSelector />", () => {
const fakeProps = (): PointGroupSortSelectorProps => ({
onChange: jest.fn(),
value: "random",
});
it("shows random warning text", () => {
const wrapper = mount(<PointGroupSortSelector {...fakeProps()} />);
expect(wrapper.text()).toContain(Content.SORT_DESCRIPTION);
});
});

View File

@ -5,42 +5,33 @@ import { history } from "../../history";
import { GetState } from "../../redux/interfaces";
import { findPointGroup } from "../../resources/selectors";
import { t } from "../../i18next_wrapper";
const UNTITLED = () => t("Untitled Group");
import { UUID } from "../../resources/interfaces";
import { DEFAULT_CRITERIA } from "./criteria/interfaces";
interface CreateGroupProps {
/** TaggedPoint UUIDs */
points: string[];
name?: string;
pointUuids: UUID[];
groupName?: string;
}
export const createGroup = ({ points, name }: CreateGroupProps) => {
return function (dispatch: Function, getState: GetState) {
if (points.length > 0) {
const { references } = getState().resources.index;
const possiblyNil = points
.map(x => references[x])
.map(x => x ? x.body.id : undefined);
const point_ids = betterCompact(possiblyNil);
const group: PointGroup = {
name: name || UNTITLED(),
point_ids,
sort_type: "xy_ascending",
criteria: {
day: { op: ">", days: 0 },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {}
}
};
const action = init("PointGroup", group);
dispatch(action);
return dispatch(save(action.payload.uuid)).then(() => {
export const createGroup = ({ pointUuids, groupName }: CreateGroupProps) =>
(dispatch: Function, getState: GetState) => {
const { references } = getState().resources.index;
const possiblyNil = pointUuids
.map(x => references[x])
.map(x => x ? x.body.id : undefined);
const point_ids = betterCompact(possiblyNil);
const group: PointGroup = {
name: groupName || t("Untitled Group"),
point_ids,
sort_type: "xy_ascending",
criteria: DEFAULT_CRITERIA
};
const action = init("PointGroup", group);
dispatch(action);
dispatch(save(action.payload.uuid))
.then(() => {
const pg = findPointGroup(getState().resources.index, action.payload.uuid);
const { id } = pg.body;
history.push("/app/designer/groups/" + (id ? id : ""));
});
}
};
};

View File

@ -0,0 +1,252 @@
jest.mock("../edit", () => ({
editCriteria: jest.fn(),
toggleStringCriteria: jest.fn(),
}));
import React from "react";
import { mount, shallow } from "enzyme";
import {
AddEqCriteria, AddNumberCriteria, editCriteria, AddStringCriteria,
toggleStringCriteria,
POINTER_TYPE_LIST,
PLANT_STAGE_LIST
} from "..";
import {
AddEqCriteriaProps, NumberCriteriaProps, DEFAULT_CRITERIA,
AddStringCriteriaProps,
} from "../interfaces";
import {
fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<AddEqCriteria<string> />", () => {
const fakeProps = (): AddEqCriteriaProps<string> => ({
dispatch: jest.fn(),
group: fakePointGroup(),
type: "string",
criteriaField: undefined,
criteriaKey: "string_eq",
});
it("renders string_eq add", () => {
const wrapper = mount(<AddEqCriteria<string> {...fakeProps()} />);
expect(wrapper.text()).toContain("=");
});
it("changes field", () => {
const wrapper = shallow<AddEqCriteria<string>>(
<AddEqCriteria<string> {...fakeProps()} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { value: "openfarm_slug" }
});
expect(wrapper.state().key).toEqual("openfarm_slug");
});
it("changes value", () => {
const wrapper = shallow<AddEqCriteria<string>>(
<AddEqCriteria<string> {...fakeProps()} />);
wrapper.find("input").last().simulate("change", {
currentTarget: { value: "slug" }
});
expect(wrapper.state().value).toEqual("slug");
});
it("updates criteria", () => {
const p = fakeProps();
const wrapper = mount(<AddEqCriteria<string> {...p} />);
wrapper.setState({ key: "openfarm_slug", value: "slug" });
wrapper.find("button").last().simulate("click");
expect(editCriteria).toHaveBeenCalledWith(p.group, {
string_eq: { openfarm_slug: ["slug"] }
});
});
});
describe("<AddEqCriteria<number> />", () => {
const fakeProps = (): AddEqCriteriaProps<number> => ({
dispatch: jest.fn(),
group: fakePointGroup(),
type: "number",
criteriaField: {},
criteriaKey: "number_eq",
});
it("renders number_eq add", () => {
const wrapper = mount(<AddEqCriteria<number> {...fakeProps()} />);
expect(wrapper.text()).toContain("=");
});
it("changes field", () => {
const wrapper = shallow<AddEqCriteria<number>>(
<AddEqCriteria<number> {...fakeProps()} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { value: "x" }
});
expect(wrapper.state().key).toEqual("x");
});
it("changes value", () => {
const wrapper = shallow<AddEqCriteria<number>>(
<AddEqCriteria<number> {...fakeProps()} />);
wrapper.find("input").last().simulate("change", {
currentTarget: { value: "1" }
});
expect(wrapper.state().value).toEqual("1");
});
it("updates criteria", () => {
const p = fakeProps();
const wrapper = mount(<AddEqCriteria<number> {...p} />);
wrapper.setState({ key: "x", value: 1 });
wrapper.find("button").last().simulate("click");
expect(editCriteria).toHaveBeenCalledWith(p.group, {
number_eq: { x: [1] }
});
});
});
describe("<AddStringCriteria />", () => {
const fakeProps = (): AddStringCriteriaProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
slugs: ["apple", "orange"],
});
it("renders", () => {
const wrapper = mount(<AddStringCriteria {...fakeProps()} />);
expect(wrapper.text()).toContain("None");
});
it("changes key", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.find("FBSelect").first().simulate("change", {
value: "openfarm_slug", label: ""
});
expect(wrapper.state().key).toEqual("openfarm_slug");
});
it("changes value", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "openfarm_slug" });
wrapper.find("FBSelect").last().simulate("change", {
label: "", value: "slug"
});
expect(wrapper.state().value).toEqual("slug");
});
it("renders slug list", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "openfarm_slug", value: "pear" });
expect(wrapper.find("FBSelect").last().props().list).toEqual([
{ label: "Apple", value: "apple" },
{ label: "Orange", value: "orange" },
]);
expect(wrapper.instance().selected).toEqual({
label: "Pear", value: "pear"
});
});
it("returns selected point type", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "pointer_type", value: "" });
expect(wrapper.instance().selected).toEqual(undefined);
});
it("renders point type list", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "pointer_type", value: "Plant" });
expect(wrapper.find("FBSelect").last().props().list)
.toEqual(POINTER_TYPE_LIST());
expect(wrapper.instance().selected).toEqual({
label: "Plants", value: "Plant"
});
});
it("returns selected plant stage", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "plant_stage", value: "" });
expect(wrapper.instance().selected).toEqual(undefined);
});
it("renders plant stage list", () => {
const wrapper = shallow<AddStringCriteria>(
<AddStringCriteria {...fakeProps()} />);
wrapper.setState({ key: "plant_stage", value: "planted" });
expect(wrapper.find("FBSelect").last().props().list)
.toEqual(PLANT_STAGE_LIST());
expect(wrapper.instance().selected).toEqual({
label: "Planted", value: "planted"
});
});
it("updates criteria", () => {
const p = fakeProps();
const wrapper = mount(<AddStringCriteria {...p} />);
wrapper.setState({ key: "openfarm_slug", value: "slug" });
wrapper.find("button").last().simulate("click");
expect(toggleStringCriteria).toHaveBeenCalledWith(
p.group, "openfarm_slug", "slug");
});
it("doesn't update criteria", () => {
const p = fakeProps();
const wrapper = mount(<AddStringCriteria {...p} />);
wrapper.setState({ key: "openfarm_slug", value: "" });
wrapper.find("button").last().simulate("click");
expect(toggleStringCriteria).not.toHaveBeenCalled();
});
});
describe("<AddNumberCriteria />", () => {
const fakeProps = (): NumberCriteriaProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
criteriaKey: "number_lt",
criteria: DEFAULT_CRITERIA,
});
it("renders", () => {
const wrapper = mount(<AddNumberCriteria {...fakeProps()} />);
expect(wrapper.text()).toContain("<");
});
it("changes field", () => {
const wrapper = shallow<AddNumberCriteria>(
<AddNumberCriteria {...fakeProps()} />);
wrapper.find("input").first().simulate("change", {
currentTarget: { value: "x" }
});
expect(wrapper.state().key).toEqual("x");
});
it("changes value", () => {
const wrapper = shallow<AddNumberCriteria>(
<AddNumberCriteria {...fakeProps()} />);
wrapper.find("input").last().simulate("change", {
currentTarget: { value: "1" }
});
expect(wrapper.state().value).toEqual(1);
});
it("updates criteria", () => {
const p = fakeProps();
const wrapper = mount(<AddNumberCriteria {...p} />);
wrapper.setState({ key: "x", value: 1 });
wrapper.find("button").last().simulate("click");
expect(editCriteria).toHaveBeenCalledWith(p.group, { number_lt: { x: 1 } });
});
it("handles missing criteria", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<AddNumberCriteria {...p} />);
expect(wrapper.text()).toContain("<");
});
});

View File

@ -0,0 +1,116 @@
import { selectPointsByCriteria, pointsSelectedByGroup } from "..";
import {
fakePoint, fakePlant, fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import moment from "moment";
import { DEFAULT_CRITERIA } from "../interfaces";
import { cloneDeep } from "lodash";
describe("selectPointsByCriteria()", () => {
const fakeCriteria = (): PointGroup["criteria"] =>
cloneDeep(DEFAULT_CRITERIA);
it("matches color", () => {
const criteria = fakeCriteria();
criteria.number_eq = { x: [], y: undefined, z: [] };
criteria.string_eq = { "meta.color": ["red", "blue"] };
const matchingPoint = fakePoint();
matchingPoint.body.meta.color = "red";
const otherPoint = fakePoint();
otherPoint.body.meta = {};
const allPoints = [matchingPoint, otherPoint];
const result = selectPointsByCriteria(criteria, allPoints);
expect(result).toEqual([matchingPoint]);
});
it("matches positions: equal", () => {
const criteria = fakeCriteria();
criteria.string_eq = { "meta.color": [] };
criteria.number_eq = { x: [0, 1], y: [100] };
const matchingPoint1 = fakePoint();
matchingPoint1.body.x = 0;
matchingPoint1.body.y = 100;
const matchingPoint2 = fakePoint();
matchingPoint2.body.x = 1;
matchingPoint2.body.y = 100;
const otherPoint = fakePoint();
otherPoint.body.x = 2;
otherPoint.body.y = 100;
const allPoints = [matchingPoint1, matchingPoint2, otherPoint];
const result = selectPointsByCriteria(criteria, allPoints);
expect(result).toEqual([matchingPoint1, matchingPoint2]);
});
it("matches positions: gt/lt", () => {
const criteria = fakeCriteria();
criteria.number_gt = { x: 100 };
criteria.number_lt = { x: 500 };
const matchingPoint = fakePoint();
matchingPoint.body.x = 200;
const otherPoint = fakePoint();
otherPoint.body.x = 0;
const allPoints = [matchingPoint, otherPoint];
const result = selectPointsByCriteria(criteria, allPoints);
expect(result).toEqual([matchingPoint]);
});
it("matches age greater than 1 day old", () => {
const criteria = fakeCriteria();
criteria.day = { days: 1, op: ">" };
const matchingPoint = fakePoint();
matchingPoint.body.created_at = "2020-01-20T20:00:00.000Z";
const otherPoint = fakePoint();
otherPoint.body.created_at = "2020-02-20T20:00:00.000Z";
const allPoints = [matchingPoint, otherPoint];
const now = moment("2020-02-20T20:00:00.000Z");
const result = selectPointsByCriteria(criteria, allPoints, now);
expect(result).toEqual([matchingPoint]);
});
it("matches age less than 1 day old", () => {
const criteria = fakeCriteria();
criteria.day = { days: 1, op: "<" };
const matchingPoint = fakePoint();
matchingPoint.body.created_at = "2020-02-20T20:00:00.000Z";
const otherPoint = fakePoint();
otherPoint.body.created_at = "2020-01-20T20:00:00.000Z";
const allPoints = [matchingPoint, otherPoint];
const now = moment("2020-02-20T20:00:00.000Z");
const result = selectPointsByCriteria(criteria, allPoints, now);
expect(result).toEqual([matchingPoint]);
});
it("matches planted date less than 1 day old", () => {
const criteria = fakeCriteria();
criteria.day = { days: 1, op: "<" };
const matchingPoint = fakePlant();
matchingPoint.body.planted_at = "2020-02-20T20:00:00.000Z";
matchingPoint.body.created_at = "2020-01-20T20:00:00.000Z";
const otherPoint = fakePlant();
otherPoint.body.planted_at = "2020-01-20T20:00:00.000Z";
otherPoint.body.created_at = "2020-01-20T20:00:00.000Z";
const allPoints = [matchingPoint, otherPoint];
const now = moment("2020-02-20T20:00:00.000Z");
const result = selectPointsByCriteria(criteria, allPoints, now);
expect(result).toEqual([matchingPoint]);
});
});
describe("pointsSelectedByGroup()", () => {
it("returns group points", () => {
const group = fakePointGroup();
group.body.point_ids = [1];
group.body.criteria.number_eq = { x: [123] };
const selectedPoint1 = fakePoint();
selectedPoint1.body.id = 1;
const selectedPoint2 = fakePoint();
selectedPoint2.body.id = 2;
selectedPoint2.body.x = 123;
const otherPoint = fakePoint();
otherPoint.body.id = 0;
const allPoints = [selectedPoint1, selectedPoint2, otherPoint];
const result = pointsSelectedByGroup(group, allPoints);
expect(result).toEqual([selectedPoint1, selectedPoint2]);
});
});

View File

@ -0,0 +1,64 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
import React from "react";
import { mount } from "enzyme";
import { GroupCriteria, GroupPointCountBreakdown } from "..";
import {
GroupCriteriaProps, GroupPointCountBreakdownProps, DEFAULT_CRITERIA
} from "../interfaces";
import {
fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { cloneDeep } from "lodash";
import { overwrite, save } from "../../../../api/crud";
import { ExpandableHeader } from "../../../../ui";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<GroupCriteria />", () => {
const fakeProps = (): GroupCriteriaProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
slugs: [],
});
it("renders", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<GroupCriteria {...p} />);
["criteria", "age selection"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
it("clears criteria", () => {
const p = fakeProps();
const wrapper = mount(<GroupCriteria {...p} />);
wrapper.find("button").first().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(save).toHaveBeenCalledWith(p.group.uuid);
});
it("expands section", () => {
const wrapper = mount(<GroupCriteria {...fakeProps()} />);
expect(wrapper.text()).not.toContain("number criteria");
wrapper.find(ExpandableHeader).simulate("click");
expect(wrapper.text()).toContain("number criteria");
});
});
describe("<GroupPointCountBreakdown />", () => {
const fakeProps = (): GroupPointCountBreakdownProps => ({
manualCount: 1,
totalCount: 3,
});
it("renders", () => {
const wrapper = mount(<GroupPointCountBreakdown {...fakeProps()} />);
["1manually selected", "2selected by criteria"].map(string =>
expect(wrapper.text()).toContain(string));
});
});

View File

@ -0,0 +1,133 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
import {
editCriteria, toggleEqCriteria,
togglePointSelection, toggleStringCriteria, editGtLtCriteria
} from "..";
import {
fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { overwrite, save } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { DEFAULT_CRITERIA } from "../interfaces";
describe("editCriteria()", () => {
it("edits criteria: all empty", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
editCriteria(group, {})(jest.fn());
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("edits criteria: empty update", () => {
const group = fakePointGroup();
editCriteria(group, {})(jest.fn());
expect(overwrite).toHaveBeenCalledWith(group, group.body);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("edits criteria: full update", () => {
const group = fakePointGroup();
const criteria: PointGroup["criteria"] = {
day: { days: 1, op: "<" },
string_eq: { openfarm_slug: ["slug"] },
number_eq: { x: [0] },
number_gt: { x: 0 },
number_lt: { x: 10 },
};
editCriteria(group, criteria)(jest.fn());
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = criteria;
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("toggleEqCriteria()", () => {
it("adds criteria", () => {
const result = toggleEqCriteria({})("openfarm_slug", "slug");
expect(result).toEqual({ openfarm_slug: ["slug"] });
});
it("removes criteria", () => {
const result = toggleEqCriteria({ openfarm_slug: ["slug"] })(
"openfarm_slug", "slug");
expect(result).toEqual({});
});
});
const dispatch = jest.fn(x => x(jest.fn()));
describe("togglePointSelection()", () => {
it("adds criteria", () => {
const group = fakePointGroup();
togglePointSelection(group)({ openfarm_slug: "slug" })(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("toggleStringCriteria()", () => {
it("adds criteria", () => {
const group = fakePointGroup();
toggleStringCriteria(group, "openfarm_slug", "slug")(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("handles missing criteria", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
toggleStringCriteria(group, "openfarm_slug", "slug")(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = cloneDeep(DEFAULT_CRITERIA);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("editGtLtCriteria()", () => {
it("adds criteria", () => {
const group = fakePointGroup();
const box = { x0: 0, y0: 2, x1: 3, y1: 4 };
editGtLtCriteria(group, box)(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_gt = { x: 0, y: 2 };
expectedBody.criteria.number_lt = { x: 3, y: 4 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("doesn't edit criteria", () => {
const group = fakePointGroup();
const box = { x0: undefined, y0: 2, x1: 3, y1: 4 };
editGtLtCriteria(group, box)(dispatch);
expect(overwrite).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("handles missing criteria", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
const box = { x0: 1, y0: 2, x1: 3, y1: 4 };
editGtLtCriteria(group, box)(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = cloneDeep(DEFAULT_CRITERIA);
expectedBody.criteria.number_gt = { x: 1, y: 2 };
expectedBody.criteria.number_lt = { x: 3, y: 4 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});

View File

@ -0,0 +1,56 @@
const mockToggle = jest.fn();
jest.mock("../edit", () => ({
togglePointSelection: jest.fn(() => mockToggle),
toggleStringCriteria: jest.fn(),
}));
import React from "react";
import { mount } from "enzyme";
import {
CheckboxSelections, togglePointSelection, criteriaSelected,
} from "..";
import { CheckboxSelectionsProps } from "../interfaces";
import {
fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<CheckboxSelections />", () => {
const fakeProps = (): CheckboxSelectionsProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
});
it("renders", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<CheckboxSelections {...p} />);
["planted plants", "detected weeds", "created points", "created weeds"
].map(string =>
expect(wrapper.text()).toContain(string));
});
it("changes criteria", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxSelections {...p} />);
wrapper.find("input").first().simulate("change");
expect(togglePointSelection).toHaveBeenCalledWith(p.group);
expect(mockToggle).toHaveBeenCalledWith({
plant_stage: "planted", pointer_type: "Plant"
});
});
});
describe("criteriaSelected()", () => {
it("returns selection state: false", () => {
const result = criteriaSelected(undefined)({ pointer_type: "Plant" });
expect(result).toEqual(false);
});
it("returns selection state: true", () => {
const result = criteriaSelected({
pointer_type: ["Plant"]
})({ pointer_type: "Plant" });
expect(result).toEqual(true);
});
});

View File

@ -0,0 +1,199 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
import React from "react";
import { mount, shallow } from "enzyme";
import {
EqCriteriaSelection,
NumberCriteriaSelection, DaySelection, LocationSelection, AddCriteria,
} from "..";
import {
EqCriteriaSelectionProps,
NumberCriteriaProps,
CriteriaSelectionProps,
DEFAULT_CRITERIA,
LocationSelectionProps,
GroupCriteriaProps
} from "../interfaces";
import {
fakePointGroup
} from "../../../../__test_support__/fake_state/resources";
import { overwrite } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { FBSelect } from "../../../../ui";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<EqCriteriaSelection<string> />", () => {
const fakeProps = (): EqCriteriaSelectionProps<string> => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
type: "string",
criteriaField: {},
criteriaKey: "string_eq",
});
it("renders", () => {
const p = fakeProps();
const wrapper = mount(<EqCriteriaSelection<string> {...p} />);
expect(wrapper.text()).toContain("=");
});
it("removes criteria", () => {
const p = fakeProps();
p.criteriaField = { openfarm_slug: ["slug"] };
const wrapper = mount(<EqCriteriaSelection<string> {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.string_eq = {};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
});
describe("<NumberCriteriaSelection />", () => {
const fakeProps = (): NumberCriteriaProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
criteriaKey: "number_lt",
});
it("renders", () => {
const p = fakeProps();
const wrapper = mount(<NumberCriteriaSelection {...p} />);
expect(wrapper.text()).toContain("<");
});
it("removes criteria", () => {
const p = fakeProps();
p.criteria.number_lt = { x: 1 };
const wrapper = mount(<NumberCriteriaSelection {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_lt = {};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
});
describe("<DaySelection />", () => {
const fakeProps = (): CriteriaSelectionProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
});
it("changes operator", () => {
const p = fakeProps();
const wrapper = shallow(<DaySelection {...p} />);
wrapper.find(FBSelect).simulate("change", { label: "", value: "<" });
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.day.op = "<";
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("changes day value", () => {
const p = fakeProps();
const wrapper = shallow(<DaySelection {...p} />);
wrapper.find("input").last().simulate("change", {
currentTarget: { value: "1" }
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.day.days = 1;
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("handles missing criteria", () => {
const p = fakeProps();
p.criteria = {} as PointGroup["criteria"];
const wrapper = shallow(<DaySelection {...p} />);
expect(wrapper.find("input").last().props().value).toEqual(0);
});
});
describe("<LocationSelection />", () => {
const fakeProps = (): LocationSelectionProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
});
it("changes number_gt", () => {
const p = fakeProps();
const wrapper = shallow(<LocationSelection {...p} />);
wrapper.find("input").first().simulate("blur", {
currentTarget: { value: "1" }
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_gt = { x: 1 };
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("changes number_lt", () => {
const p = fakeProps();
const wrapper = shallow(<LocationSelection {...p} />);
wrapper.find("input").last().simulate("blur", {
currentTarget: { value: "1" }
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_lt = { x: 1, y: 1 };
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("handles missing criteria", () => {
const p = fakeProps();
p.criteria = {} as PointGroup["criteria"];
const wrapper = shallow(<LocationSelection {...p} />);
expect(wrapper.find("input").first().props().defaultValue).toEqual(undefined);
expect(wrapper.find("input").last().props().defaultValue).toEqual(undefined);
});
});
describe("<AddCriteria />", () => {
const fakeProps = (): GroupCriteriaProps => ({
slugs: [],
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn(y => y(jest.fn())))),
});
it("renders", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
plant_stage: ["planted"],
};
const wrapper = mount(<AddCriteria {...p} />);
expect(wrapper.find("input").at(0).props().value).toEqual("Plant Type");
expect(wrapper.find("input").at(1).props().value).toEqual("Slug");
expect(wrapper.find("input").at(2).props().value).toEqual("Point Type");
expect(wrapper.find("input").at(3).props().value).toEqual("Plants");
expect(wrapper.find("input").at(4).props().value).toEqual("Plant Status");
expect(wrapper.find("input").at(5).props().value).toEqual("Planted");
});
it("removes criteria", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
plant_stage: ["planted"],
};
const wrapper = mount(<AddCriteria {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("handles missing criteria", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<AddCriteria {...p} />);
expect(wrapper.text()).toEqual("SelectNone");
});
});

View File

@ -0,0 +1,215 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { cloneDeep, capitalize } from "lodash";
import { Row, Col, FBSelect, DropDownItem } from "../../../ui";
import { editCriteria, toggleStringCriteria } from ".";
import {
AddEqCriteriaProps,
AddEqCriteriaState,
NumberCriteriaProps,
AddNumberCriteriaState,
AddStringCriteriaProps,
} from "./interfaces";
export class AddEqCriteria<T extends string | number>
extends React.Component<AddEqCriteriaProps<T>, AddEqCriteriaState> {
state: AddEqCriteriaState = { key: "", value: "" };
commit = () => {
const { dispatch, group, criteriaKey, criteriaField } = this.props;
const tempEqCriteria = cloneDeep(criteriaField || {});
const tempValues = tempEqCriteria[this.state.key] || [];
const value = this.props.type == "number"
? parseInt(this.state.value)
: this.state.value;
this.state.value && tempValues.push(value as T);
tempEqCriteria[this.state.key] = tempValues;
dispatch(editCriteria(group, { [criteriaKey]: tempEqCriteria }));
this.setState({ key: "", value: "" });
}
render() {
return <div className={`add-${this.props.type}-eq-criteria`}>
<Row>
<Col xs={4}>
<input type="string"
placeholder={t("field")}
value={this.state.key}
onChange={e => this.setState({ key: e.currentTarget.value })} />
</Col>
<Col xs={1}>
{"="}
</Col>
<Col xs={4}>
<input type={this.props.type}
placeholder={t("value")}
value={this.state.value}
onChange={e => this.setState({ value: e.currentTarget.value })} />
</Col>
<Col xs={2}>
<button className="fb-button green" onClick={this.commit}>
<i className="fa fa-plus" />
</button>
</Col>
</Row>
</div>;
}
}
export const CRITERIA_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
pointer_type: { label: t("Point Type"), value: "pointer_type" },
plant_stage: { label: t("Plant Status"), value: "plant_stage" },
openfarm_slug: { label: t("Plant Type"), value: "openfarm_slug" },
});
export const CRITERIA_TYPE_LIST = () => [
CRITERIA_TYPE_DDI_LOOKUP().pointer_type,
CRITERIA_TYPE_DDI_LOOKUP().plant_stage,
CRITERIA_TYPE_DDI_LOOKUP().openfarm_slug,
];
export const POINTER_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
Plant: { label: t("Plants"), value: "Plant" },
GenericPointer: { label: t("Points"), value: "GenericPointer" },
ToolSlot: { label: t("Tool Slots"), value: "ToolSlot" },
});
export const POINTER_TYPE_LIST = () => [
POINTER_TYPE_DDI_LOOKUP().Plant,
POINTER_TYPE_DDI_LOOKUP().GenericPointer,
POINTER_TYPE_DDI_LOOKUP().ToolSlot,
];
export const PLANT_STAGE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
planned: { label: t("Planned"), value: "planned" },
planted: { label: t("Planted"), value: "planted" },
sprouted: { label: t("Sprouted"), value: "sprouted" },
harvested: { label: t("Harvested"), value: "harvested" },
});
export const PLANT_STAGE_LIST = () => [
PLANT_STAGE_DDI_LOOKUP().planned,
PLANT_STAGE_DDI_LOOKUP().planted,
PLANT_STAGE_DDI_LOOKUP().sprouted,
PLANT_STAGE_DDI_LOOKUP().harvested,
];
export class AddStringCriteria
extends React.Component<AddStringCriteriaProps, AddEqCriteriaState> {
state: AddEqCriteriaState = { key: "", value: "" };
commit = () => {
if (this.state.key && this.state.value) {
this.props.dispatch(toggleStringCriteria(this.props.group,
this.state.key, this.state.value));
this.setState({ key: "", value: "" });
}
}
get key() { return JSON.stringify(this.props.group.body.criteria || {}); }
change = (ddi: DropDownItem) => this.setState({ value: "" + ddi.value });
get selected() {
switch (this.state.key) {
case "openfarm_slug":
return this.state.value
? { label: t(capitalize(this.state.value)), value: this.state.value }
: undefined;
case "pointer_type":
return this.state.value
? POINTER_TYPE_DDI_LOOKUP()[this.state.value]
: undefined;
case "plant_stage":
return this.state.value
? PLANT_STAGE_DDI_LOOKUP()[this.state.value]
: undefined;
default:
return undefined;
}
}
get options() {
switch (this.state.key) {
case "openfarm_slug":
return this.props.slugs.map(slug =>
({ label: t(capitalize(slug)), value: slug }));
case "pointer_type":
return POINTER_TYPE_LIST();
case "plant_stage":
return PLANT_STAGE_LIST();
default:
return [];
}
}
render() {
const noKey = this.options.length < 1;
return <div className={"add-string-criteria"}>
<Row>
<Col xs={5}>
<FBSelect key={this.key}
customNullLabel={t("Select")}
list={CRITERIA_TYPE_LIST()}
selectedItem={CRITERIA_TYPE_DDI_LOOKUP()[this.state.key]}
onChange={ddi => this.setState({ key: "" + ddi.value })} />
</Col>
<Col xs={5}>
<FBSelect key={this.key}
extraClass={noKey ? "disabled" : ""}
list={this.options}
selectedItem={this.selected}
onChange={this.change} />
</Col>
<Col xs={2}>
<button className="fb-button green" onClick={this.commit}>
<i className="fa fa-plus" />
</button>
</Col>
</Row>
</div>;
}
}
export class AddNumberCriteria
extends React.Component<NumberCriteriaProps, AddNumberCriteriaState> {
state: AddNumberCriteriaState = { key: "", value: 0 };
commit = () => {
const { dispatch, group, criteriaKey } = this.props;
const tempNumberCriteria =
cloneDeep(group.body.criteria?.[criteriaKey] || {});
tempNumberCriteria[this.state.key] = this.state.value;
dispatch(editCriteria(group, { [criteriaKey]: tempNumberCriteria }));
this.setState({ key: "", value: 0 });
}
changeKey = (e: React.FormEvent<HTMLInputElement>) =>
this.setState({ key: e.currentTarget.value })
changeValue = (e: React.FormEvent<HTMLInputElement>) =>
this.setState({ value: parseInt(e.currentTarget.value) })
render() {
return <div className="add-number-criteria">
<Row>
<Col xs={4}>
<input type="string"
placeholder={t("field")}
value={this.state.key}
onChange={this.changeKey} />
</Col>
<Col xs={1}>
{this.props.criteriaKey == "number_gt" ? ">" : "<"}
</Col>
<Col xs={4}>
<input type="number"
value={this.state.value}
onChange={this.changeValue} />
</Col>
<Col xs={2}>
<button className="fb-button green" onClick={this.commit}>
<i className="fa fa-plus" />
</button>
</Col>
</Row>
</div>;
}
}

View File

@ -0,0 +1,57 @@
import { every, get, isEqual, uniq, gt, lt, isNumber } from "lodash";
import { TaggedPoint, TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import moment from "moment";
import { DEFAULT_CRITERIA } from "./interfaces";
const eqCriteriaEmpty =
(eqCriteria: Record<string, (string | number)[] | undefined>) =>
every(Object.values(eqCriteria).map(values => !values?.length));
const checkCriteria =
(criteria: PointGroup["criteria"], now: moment.Moment) =>
(point: TaggedPoint, criteriaKey: keyof PointGroup["criteria"]) => {
switch (criteriaKey) {
case "string_eq":
case "number_eq":
return every(Object.entries(criteria[criteriaKey])
.map(([k, values]: [string, (string | number)[]]) =>
values?.includes(get(point.body, k))))
|| eqCriteriaEmpty(criteria[criteriaKey]);
case "number_gt":
case "number_lt":
const compare = { number_gt: gt, number_lt: lt };
return every(Object.entries(criteria[criteriaKey])
.map(([key, value]) => isNumber(value) &&
compare[criteriaKey](get(point.body, key), value)));
case "day":
const pointDate = moment(point.body.pointer_type == "Plant"
&& point.body.planted_at
? point.body.planted_at
: point.body.created_at);
const compareDate = moment(now)
.subtract(criteria[criteriaKey].days, "days");
const matchesDays = criteria[criteriaKey].op == "<"
? pointDate.isAfter(compareDate)
: pointDate.isBefore(compareDate);
return matchesDays || !criteria[criteriaKey].days;
}
};
export const selectPointsByCriteria = (
criteria: PointGroup["criteria"] | undefined,
allPoints: TaggedPoint[],
now = moment(),
): TaggedPoint[] => {
if (!criteria || isEqual(criteria, DEFAULT_CRITERIA)) { return []; }
const check = checkCriteria(criteria, now);
return allPoints.filter(point =>
every(Object.keys(criteria).map((key: keyof PointGroup["criteria"]) =>
check(point, key))));
};
export const pointsSelectedByGroup =
(group: TaggedPointGroup, allPoints: TaggedPoint[]) =>
uniq(allPoints
.filter(p => group.body.point_ids.includes(p.body.id || 0))
.concat(selectPointsByCriteria(group.body.criteria, allPoints)));

View File

@ -0,0 +1,74 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { overwrite, save } from "../../../api/crud";
import {
CheckboxSelections, DaySelection, EqCriteriaSelection,
NumberCriteriaSelection, LocationSelection, AddCriteria,
} from ".";
import {
GroupCriteriaProps, GroupPointCountBreakdownProps, GroupCriteriaState,
DEFAULT_CRITERIA,
} from "./interfaces";
import { ExpandableHeader } from "../../../ui";
import { Collapse } from "@blueprintjs/core";
export class GroupCriteria extends
React.Component<GroupCriteriaProps, GroupCriteriaState> {
state: GroupCriteriaState = { advanced: false, clearCount: 0 };
render() {
const { group, dispatch, slugs } = this.props;
const criteria = group.body.criteria || {};
const commonProps = { group, criteria, dispatch };
return <div className="group-criteria">
<label className="criteria-heading">{t("criteria")}</label>
<button className="fb-button red" onClick={() => {
dispatch(overwrite(group, {
...group.body, criteria: DEFAULT_CRITERIA
}));
dispatch(save(group.uuid));
}}>
{t("clear all criteria")}
</button>
<div className="group-criteria-presets">
<label>{t("presets")}</label>
<CheckboxSelections group={group} dispatch={dispatch} />
</div>
<DaySelection {...commonProps} />
<LocationSelection {...commonProps} />
<label>{t("additional criteria")}</label>
<AddCriteria group={group} dispatch={dispatch} slugs={slugs} />
<ExpandableHeader
expanded={this.state.advanced}
title={t("Advanced")}
onClick={() => this.setState({ advanced: !this.state.advanced })} />
<Collapse isOpen={this.state.advanced}>
<label>{t("string criteria")}</label>
<EqCriteriaSelection<string> {...commonProps}
type={"string"} criteriaField={criteria.string_eq}
criteriaKey={"string_eq"} />
<label>{t("number criteria")}</label>
<EqCriteriaSelection<number> {...commonProps}
type={"number"} criteriaField={criteria.number_eq}
criteriaKey={"number_eq"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_lt"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_gt"} />
</Collapse>
</div>;
}
}
export const GroupPointCountBreakdown = (props: GroupPointCountBreakdownProps) =>
<div className={"criteria-point-count-breakdown"}>
<div className={"manual-group-member-count"}>
<div>
{props.manualCount}
</div>
<p>{t("manually selected")}</p>
</div>
<div className={"criteria-group-member-count"}>
<div>
{props.totalCount - props.manualCount}
</div>
<p>{t("selected by criteria")}</p>
</div>
</div>;

View File

@ -0,0 +1,70 @@
import { overwrite, save } from "../../../api/crud";
import { TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { cloneDeep, isNumber } from "lodash";
import { SelectionBoxData } from "../../map/background";
import { DEFAULT_CRITERIA } from "./interfaces";
export const editCriteria =
(group: TaggedPointGroup, update: Partial<PointGroup["criteria"]>) =>
(dispatch: Function) => {
const criteria = {
string_eq: update.string_eq || group.body.criteria?.string_eq || {},
day: update.day || group.body.criteria?.day || DEFAULT_CRITERIA.day,
number_eq: update.number_eq || group.body.criteria?.number_eq || {},
number_gt: update.number_gt || group.body.criteria?.number_gt || {},
number_lt: update.number_lt || group.body.criteria?.number_lt || {},
};
dispatch(overwrite(group, { ...group.body, criteria }));
dispatch(save(group.uuid));
};
export const toggleEqCriteria = <T extends string | number>(
eqCriteria: Record<string, T[] | undefined>
) =>
(key: string, value: T): Record<string, T[] | undefined> => {
const values: T[] = eqCriteria[key] || [];
if (values.includes(value)) {
const newValues = values.filter(s => s != value);
eqCriteria[key] = newValues;
!newValues.length && delete eqCriteria[key];
} else {
values.push(value);
eqCriteria[key] = values;
}
return eqCriteria;
};
export const togglePointSelection =
(group: TaggedPointGroup) => (toggleCriteria: Record<string, string>) =>
(dispatch: Function) => {
const stringCriteria = {};
const toggle = toggleEqCriteria<string>(stringCriteria);
Object.entries(toggleCriteria).map(([key, value]) => toggle(key, value));
dispatch(editCriteria(group, { string_eq: stringCriteria }));
};
export const toggleStringCriteria =
(group: TaggedPointGroup, key: string, value: string) =>
(dispatch: Function) => {
const tempStringCriteria = cloneDeep(group.body.criteria?.string_eq || {});
toggleEqCriteria<string>(tempStringCriteria)(key, value);
dispatch(editCriteria(group, { string_eq: tempStringCriteria }));
};
export const editGtLtCriteria =
(group: TaggedPointGroup, box: SelectionBoxData) =>
(dispatch: Function) => {
if (!(isNumber(box.x0) && isNumber(box.y0)
&& isNumber(box.x1) && isNumber(box.y1))) { return; }
const tempGtCriteria = cloneDeep(group.body.criteria?.number_gt || {});
const tempLtCriteria = cloneDeep(group.body.criteria?.number_lt || {});
tempGtCriteria.x = Math.min(box.x0, box.x1);
tempGtCriteria.y = Math.min(box.y0, box.y1);
tempLtCriteria.x = Math.max(box.x0, box.x1);
tempLtCriteria.y = Math.max(box.y0, box.y1);
dispatch(editCriteria(group, {
number_gt: tempGtCriteria,
number_lt: tempLtCriteria,
}));
};

View File

@ -0,0 +1,6 @@
export * from "./add";
export * from "./apply";
export * from "./component";
export * from "./edit";
export * from "./presets";
export * from "./show";

View File

@ -0,0 +1,81 @@
import { TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
export const DEFAULT_CRITERIA: Readonly<PointGroup["criteria"]> = {
day: { op: "<", days: 0 },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
};
export type EqCriteria = Record<string, (string | number)[] | undefined> | undefined;
export type StringEqCriteria = PointGroup["criteria"]["string_eq"] | undefined;
export interface GroupCriteriaProps {
dispatch: Function;
group: TaggedPointGroup;
slugs: string[];
}
export interface GroupCriteriaState {
advanced: boolean;
clearCount: number;
}
export interface GroupPointCountBreakdownProps {
manualCount: number;
totalCount: number;
}
export interface CriteriaSelectionProps {
criteria: PointGroup["criteria"];
group: TaggedPointGroup;
dispatch: Function;
}
export interface LocationSelectionProps extends CriteriaSelectionProps {
}
export interface EqCriteriaSelectionProps<T> extends CriteriaSelectionProps {
type: "string" | "number";
criteriaField: Record<string, T[] | undefined> | undefined;
criteriaKey: keyof PointGroup["criteria"];
}
export interface NumberCriteriaProps extends CriteriaSelectionProps {
criteriaKey: "number_lt" | "number_gt";
}
export interface AddEqCriteriaProps<T> {
dispatch: Function;
group: TaggedPointGroup;
type: "string" | "number";
criteriaField: Record<string, T[] | undefined> | undefined;
criteriaKey: keyof PointGroup["criteria"];
}
export interface AddEqCriteriaState {
key: string;
value: string;
}
export interface AddCriteriaState {
key: string;
value: string;
}
export interface AddStringCriteriaProps {
group: TaggedPointGroup;
dispatch: Function;
slugs: string[];
}
export interface AddNumberCriteriaState {
key: string;
value: number;
}
export interface CheckboxSelectionsProps {
dispatch: Function;
group: TaggedPointGroup;
}

View File

@ -0,0 +1,58 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { every, } from "lodash";
import { togglePointSelection } from ".";
import { CheckboxSelectionsProps, StringEqCriteria } from "./interfaces";
const CRITERIA_PRESETS = (): {
description: string, criteria: Record<string, string>
}[] => [
{
description: t("planted plants"),
criteria: {
"pointer_type": "Plant",
"plant_stage": "planted",
}
},
{
description: t("detected weeds"),
criteria: {
"meta.created_by": "plant-detection",
"meta.color": "red",
}
},
{
description: t("created points"),
criteria: {
"meta.created_by": "farm-designer",
"meta.type": "point",
}
},
{
description: t("created weeds"),
criteria: {
"meta.created_by": "farm-designer",
"meta.type": "weed",
}
},
];
export const CheckboxSelections = (props: CheckboxSelectionsProps) => {
const toggle = togglePointSelection(props.group);
const stringCriteria = props.group.body.criteria?.string_eq;
const selected = criteriaSelected(stringCriteria);
return <div className={"criteria-checkbox-presets"}>
{CRITERIA_PRESETS().map((selector, index) =>
<div className="criteria-preset-checkbox" key={index}>
<input type="radio"
onChange={() => props.dispatch(toggle(selector.criteria))}
checked={selected(selector.criteria)} />
<p>{selector.description}</p>
</div>)}
</div>;
};
export const criteriaSelected = (stringCriteria: StringEqCriteria) =>
(selectionCriteria: Record<string, string>) =>
every(Object.entries(selectionCriteria).map(([key, value]) =>
stringCriteria?.[key]?.includes(value)));

View File

@ -0,0 +1,212 @@
import * as React from "react";
import { cloneDeep, capitalize } from "lodash";
import { Row, Col, FBSelect, DropDownItem } from "../../../ui";
import {
AddEqCriteria, toggleEqCriteria, editCriteria, AddNumberCriteria,
POINTER_TYPE_DDI_LOOKUP, PLANT_STAGE_DDI_LOOKUP, AddStringCriteria,
CRITERIA_TYPE_DDI_LOOKUP, toggleStringCriteria
} from ".";
import {
EqCriteriaSelectionProps, NumberCriteriaProps,
CriteriaSelectionProps, LocationSelectionProps, GroupCriteriaProps,
AddCriteriaState,
DEFAULT_CRITERIA
} from "./interfaces";
import { t } from "../../../i18next_wrapper";
import { PointGroup } from "farmbot/dist/resources/api_resources";
export class EqCriteriaSelection<T extends string | number>
extends React.Component<EqCriteriaSelectionProps<T>> {
render() {
const { criteriaField, criteriaKey, group, dispatch } = this.props;
return <div className={`${this.props.type}-eq-criteria`}>
<AddEqCriteria<T> group={group} dispatch={dispatch}
type={this.props.type} criteriaField={criteriaField}
criteriaKey={criteriaKey} />
{criteriaField && Object.entries(criteriaField)
.map(([key, values]: [string, T[]], keyIndex) =>
values && values.length > 0 &&
<div key={keyIndex}>
<label>{key}</label>
{values.map((value, valueIndex) =>
<Row key={"" + keyIndex + valueIndex}>
<Col xs={9}>
<input
disabled={true}
value={value} />
</Col>
<Col xs={2}>
<button className="fb-button red" onClick={() => {
const tempCriteriaField = cloneDeep(criteriaField);
toggleEqCriteria<T>(tempCriteriaField)(key, value);
dispatch(editCriteria(group, {
[criteriaKey]: tempCriteriaField
}));
}}>
<i className="fa fa-minus" />
</button>
</Col>
</Row>)}
</div>)}
</div>;
}
}
export const NumberCriteriaSelection = (props: NumberCriteriaProps) => {
const criteriaField = props.criteria[props.criteriaKey];
return <div className={"number-gt-lt-criteria"}>
<AddNumberCriteria {...props} />
{criteriaField && Object.entries(criteriaField)
.map(([key, value], keyIndex) =>
<div key={keyIndex}>
<Row>
<Col xs={4}>
<p>{key}</p>
</Col>
<Col xs={1}>
{props.criteriaKey == "number_gt" ? ">" : "<"}
</Col>
<Col xs={4}>
<input key={"" + keyIndex}
disabled={true}
value={value} />
</Col>
<Col xs={2}>
<button className="fb-button red" onClick={() => {
const tempNumberCriteria = cloneDeep(criteriaField);
delete tempNumberCriteria[key];
props.dispatch(editCriteria(props.group, {
[props.criteriaKey]: tempNumberCriteria
}));
}}>
<i className="fa fa-minus" />
</button>
</Col>
</Row>
</div>)}
</div>;
};
const DAY_OPERATOR_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
["<"]: { label: t("less than"), value: "<" },
[">"]: { label: t("greater than"), value: ">" },
});
export const DaySelection = (props: CriteriaSelectionProps) => {
const { group, criteria, dispatch } = props;
const dayCriteria = criteria.day || cloneDeep(DEFAULT_CRITERIA.day);
return <div className="day-criteria">
<label>{t("Age selection")}</label>
<Row>
<Col xs={5}>
<FBSelect key={JSON.stringify(criteria)}
list={[DAY_OPERATOR_DDI_LOOKUP()["<"],
DAY_OPERATOR_DDI_LOOKUP()[">"]]}
selectedItem={DAY_OPERATOR_DDI_LOOKUP()[dayCriteria.op]}
onChange={ddi => dispatch(editCriteria(group, {
day: {
days: dayCriteria.days,
op: ddi.value as PointGroup["criteria"]["day"]["op"]
}
}))} />
</Col>
<Col xs={3}>
<input type="number" value={dayCriteria.days} onChange={e => {
const { op } = dayCriteria;
const days = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { day: { days, op } }));
}} />
</Col>
<Col xs={4}>
<p>{t("days old")}</p>
</Col>
</Row>
</div>;
};
export const LocationSelection = (props: LocationSelectionProps) => {
const { group, criteria, dispatch } = props;
const gtCriteria = criteria.number_gt || {};
const ltCriteria = criteria.number_lt || {};
return <div className="location-criteria">
<label>{t("Location selection")}</label>
{["x", "y"].map(axis =>
<Row key={axis}>
<Col xs={4}>
<input key={JSON.stringify(gtCriteria)}
type="number"
defaultValue={gtCriteria[axis]}
onBlur={e => {
const tempGtCriteria = cloneDeep(gtCriteria);
tempGtCriteria[axis] = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { number_gt: tempGtCriteria }));
}} />
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={1}>
<label>{axis}</label>
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={4}>
<input key={JSON.stringify(ltCriteria)}
type="number"
defaultValue={ltCriteria[axis]}
onBlur={e => {
const tempLtCriteria = cloneDeep(ltCriteria);
tempLtCriteria[axis] = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { number_lt: tempLtCriteria }));
}} />
</Col>
</Row>)}
</div>;
};
export class AddCriteria
extends React.Component<GroupCriteriaProps, AddCriteriaState> {
labelLookup = (key: string, value: string) => {
switch (key) {
case "openfarm_slug":
return capitalize(value);
case "pointer_type":
return POINTER_TYPE_DDI_LOOKUP()[value].label;
case "plant_stage":
return PLANT_STAGE_DDI_LOOKUP()[value].label;
}
}
render() {
const { props } = this;
const stringCriteria = this.props.group.body.criteria?.string_eq || {};
const displayedCriteria = Object.entries(stringCriteria)
.filter(([key, _values]) =>
["openfarm_slug", "pointer_type", "plant_stage"].includes(key));
return <div className={"add-criteria"}>
<AddStringCriteria
group={props.group} dispatch={props.dispatch} slugs={props.slugs} />
{displayedCriteria.map(([key, values]) =>
values && values.map((value, index) =>
<div key={key + index} className={"criteria-string"}>
<Row>
<Col xs={5}>
<input value={CRITERIA_TYPE_DDI_LOOKUP()[key].label}
disabled={true} />
</Col>
<Col xs={5}>
<input value={this.labelLookup(key, value)} disabled={true} />
</Col>
<Col xs={2}>
<button className="fb-button red" onClick={() => props.dispatch(
toggleStringCriteria(props.group, key, value))}>
<i className="fa fa-minus" />
</button>
</Col>
</Row>
</div>))}
</div>;
}
}

View File

@ -2,55 +2,40 @@ import * as React from "react";
import { connect } from "react-redux";
import { Everything } from "../../interfaces";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { findByKindAndId } from "../../resources/selectors";
import { betterCompact } from "../../util/util";
import {
selectAllActivePoints, selectAllPlantPointers, selectAllPointGroups
} from "../../resources/selectors";
import { push, getPathArray } from "../../history";
import { ResourceIndex } from "../../resources/interfaces";
import { GroupDetailActive } from "./group_detail_active";
import { TaggedPlant } from "../map/interfaces";
import { ShouldDisplay } from "../../devices/interfaces";
import { getShouldDisplayFn } from "../../farmware/state_to_props";
import { uniq } from "lodash";
interface GroupDetailProps {
dispatch: Function;
group: TaggedPointGroup | undefined;
plants: TaggedPlant[];
allPoints: TaggedPoint[];
shouldDisplay: ShouldDisplay;
slugs: string[];
}
export function fetchGroupFromUrl(index: ResourceIndex) {
if (!getPathArray().includes("groups")) { return; }
/** TODO: Write better selectors. */
/** Find a group from a URL-provided ID. */
export const findGroupFromUrl = (groups: TaggedPointGroup[]) => {
const urlIncludes = (string: string) => getPathArray().includes(string);
if (!urlIncludes("groups") && !urlIncludes("zones")) { return; }
const groupId = parseInt(getPathArray().pop() || "?", 10);
let group: TaggedPointGroup | undefined;
try {
group = findByKindAndId<TaggedPointGroup>(index, "PointGroup", groupId);
} catch (error) {
group = undefined;
}
return group;
}
return groups.filter(group => group.body.id === groupId)[0];
};
function mapStateToProps(props: Everything): GroupDetailProps {
const plants: TaggedPlant[] = [];
const group = fetchGroupFromUrl(props.resources.index);
if (group) {
betterCompact(group
.body
.point_ids
.map((id) => {
return props.resources.index.byKindAndId[`Point.${id}`];
})).map(uuid => {
const p =
props.resources.index.references[uuid] as TaggedPoint | undefined;
if (p) {
if (p.kind === "Point") {
if (p.body.pointer_type == "Plant") {
plants.push(p as TaggedPlant); // Sorry.
}
}
}
});
}
return { plants, group, dispatch: props.dispatch };
return {
allPoints: selectAllActivePoints(props.resources.index),
group: findGroupFromUrl(selectAllPointGroups(props.resources.index)),
dispatch: props.dispatch,
shouldDisplay: getShouldDisplayFn(props.resources.index, props.bot),
slugs: uniq(selectAllPlantPointers(props.resources.index)
.map(p => p.body.openfarm_slug)),
};
}
export class RawGroupDetail extends React.Component<GroupDetailProps, {}> {

View File

@ -4,20 +4,27 @@ import { t } from "../../i18next_wrapper";
import {
DesignerPanel, DesignerPanelContent, DesignerPanelHeader
} from "../designer_panel";
import { TaggedPointGroup } from "farmbot";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { DeleteButton } from "../../ui/delete_button";
import { save, edit } from "../../api/crud";
import { TaggedPlant } from "../map/interfaces";
import { PointGroupSortSelector, sortGroupBy } from "./point_group_sort_selector";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { PointGroupItem } from "./point_group_item";
import { Paths } from "./paths";
import { DevSettings } from "../../account/dev/dev_support";
import { Feature, ShouldDisplay } from "../../devices/interfaces";
import { ErrorBoundary } from "../../error_boundary";
import {
GroupCriteria, GroupPointCountBreakdown, pointsSelectedByGroup
} from "./criteria";
import { Content } from "../../constants";
interface GroupDetailActiveProps {
export interface GroupDetailActiveProps {
dispatch: Function;
group: TaggedPointGroup;
plants: TaggedPlant[];
allPoints: TaggedPoint[];
shouldDisplay: ShouldDisplay;
slugs: string[];
}
type State = { timerId?: ReturnType<typeof setInterval> };
@ -30,16 +37,19 @@ export class GroupDetailActive
this.props.dispatch(edit(this.props.group, { name: currentTarget.value }));
};
get icons() {
const plants = sortGroupBy(this.props.group.body.sort_type,
this.props.plants);
get pointsSelectedByGroup() {
return pointsSelectedByGroup(this.props.group, this.props.allPoints);
}
return plants.map(point => {
get icons() {
const sortedPoints =
sortGroupBy(this.props.group.body.sort_type, this.pointsSelectedByGroup);
return sortedPoints.map(point => {
return <PointGroupItem
key={point.uuid}
hovered={false}
group={this.props.group}
plant={point}
point={point}
dispatch={this.props.dispatch} />;
});
}
@ -70,6 +80,7 @@ export class GroupDetailActive
}
render() {
const { group, dispatch } = this.props;
return <DesignerPanel panelName={"group-detail"} panel={Panel.Groups}>
<DesignerPanelHeader
onBack={this.saveGroup}
@ -79,35 +90,46 @@ export class GroupDetailActive
backTo={"/app/designer/groups"} />
<DesignerPanelContent
panelName={"groups"}>
<label>{t("GROUP NAME")}{this.saved ? "" : "*"}</label>
<input
defaultValue={this.props.group.body.name}
onChange={this.update}
onBlur={this.saveGroup} />
<PointGroupSortSelector
value={this.props.group.body.sort_type}
onChange={this.changeSortType} />
<label>
{t("GROUP MEMBERS ({{count}})", { count: this.icons.length })}
</label>
<p>
{t("Click plants in map to add or remove.")}
</p>
<div className="groups-list-wrapper">
{this.icons}
</div>
{DevSettings.futureFeaturesEnabled() &&
<Paths
points={this.props.plants}
dispatch={this.props.dispatch}
group={this.props.group} />}
<DeleteButton
className="groups-delete-btn"
dispatch={this.props.dispatch}
uuid={this.props.group.uuid}
onDestroy={history.back}>
{t("DELETE GROUP")}
</DeleteButton>
<ErrorBoundary>
<label>{t("GROUP NAME")}</label>
<i style={{ float: "right" }}>{this.saved ? "" : " saving..."}</i>
<input
defaultValue={group.body.name}
onChange={this.update}
onBlur={this.saveGroup} />
<PointGroupSortSelector
value={group.body.sort_type}
onChange={this.changeSortType} />
<label>
{t("GROUP MEMBERS ({{count}})", { count: this.icons.length })}
</label>
{this.props.shouldDisplay(Feature.criteria_groups) &&
<GroupPointCountBreakdown
manualCount={group.body.point_ids.length}
totalCount={this.pointsSelectedByGroup.length} />}
<p>{t("Click plants in map to add or remove.")}</p>
{this.props.shouldDisplay(Feature.criteria_groups) &&
this.pointsSelectedByGroup.length != group.body.point_ids.length &&
<p>{t(Content.CRITERIA_SELECTION_COUNT)}</p>}
<div className="groups-list-wrapper">
{this.icons}
</div>
{this.props.shouldDisplay(Feature.criteria_groups) &&
<GroupCriteria dispatch={dispatch}
group={group} slugs={this.props.slugs} />}
{DevSettings.futureFeaturesEnabled() &&
<Paths
pathPoints={this.pointsSelectedByGroup}
dispatch={dispatch}
group={group} />}
<DeleteButton
className="group-delete-btn"
dispatch={dispatch}
uuid={group.uuid}
onDestroy={history.back}>
{t("DELETE GROUP")}
</DeleteButton>
</ErrorBoundary>
</DesignerPanelContent>
</DesignerPanel>;
}

View File

@ -1,15 +1,18 @@
import React from "react";
import { TaggedPointGroup } from "farmbot";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { t } from "../../i18next_wrapper";
import { pointsSelectedByGroup } from "./criteria";
interface GroupInventoryItemProps {
export interface GroupInventoryItemProps {
group: TaggedPointGroup;
allPoints: TaggedPoint[];
hovered: boolean;
dispatch: Function;
onClick(): void;
}
export function GroupInventoryItem(props: GroupInventoryItemProps) {
const count = pointsSelectedByGroup(props.group, props.allPoints).length;
return <div
onClick={props.onClick}
className={`group-search-item ${props.hovered ? "hovered" : ""}`}>
@ -17,7 +20,7 @@ export function GroupInventoryItem(props: GroupInventoryItemProps) {
{props.group.body.name}
</span>
<i className="group-item-count">
{t("{{count}} items", { count: props.group.body.point_ids.length })}
{t("{{count}} items", { count })}
</i>
</div>;
}

View File

@ -7,15 +7,17 @@ import {
DesignerPanel, DesignerPanelTop, DesignerPanelContent
} from "../designer_panel";
import { findAll } from "../../resources/find_all";
import { TaggedPointGroup } from "farmbot";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { history } from "../../history";
import { GroupInventoryItem } from "./group_inventory_item";
import { EmptyStateWrapper, EmptyStateGraphic } from "../../ui/empty_state_wrapper";
import { Content } from "../../constants";
import { selectAllActivePoints } from "../../resources/selectors";
export interface GroupListPanelProps {
dispatch: Function;
groups: TaggedPointGroup[];
allPoints: TaggedPoint[];
}
interface State {
@ -23,9 +25,11 @@ interface State {
}
export function mapStateToProps(props: Everything): GroupListPanelProps {
const groups =
findAll<TaggedPointGroup>(props.resources.index, "PointGroup");
return { groups, dispatch: props.dispatch };
return {
groups: findAll<TaggedPointGroup>(props.resources.index, "PointGroup"),
dispatch: props.dispatch,
allPoints: selectAllActivePoints(props.resources.index)
};
}
export class RawGroupListPanel extends React.Component<GroupListPanelProps, State> {
@ -62,6 +66,7 @@ export class RawGroupListPanel extends React.Component<GroupListPanelProps, Stat
.map(group => <GroupInventoryItem
key={group.uuid}
group={group}
allPoints={this.props.allPoints}
hovered={false}
dispatch={this.props.dispatch}
onClick={() => this.navigate(group.body.id || 0)}

View File

@ -1,32 +1,31 @@
import * as React from "react";
import { store } from "../../redux/store";
import { MapTransformProps, TaggedPlant } from "../map/interfaces";
import { fetchGroupFromUrl } from "./group_detail";
import { MapTransformProps } from "../map/interfaces";
import { isUndefined } from "lodash";
import { sortGroupBy } from "./point_group_sort_selector";
import { Color } from "../../ui";
import { transformXY } from "../map/util";
import { nn } from "./paths";
import { TaggedPoint, TaggedPointGroup } from "farmbot";
export interface GroupOrderProps {
plants: TaggedPlant[];
group: TaggedPointGroup | undefined;
groupPoints: TaggedPoint[];
mapTransformProps: MapTransformProps;
}
const sortedPointCoordinates =
(plants: TaggedPlant[]): { x: number, y: number }[] => {
const { resources } = store.getState();
const group = fetchGroupFromUrl(resources.index);
if (isUndefined(group)) { return []; }
const groupPlants = plants
.filter(p => group.body.point_ids.includes(p.body.id || 0));
const groupSortType = resources.consumers.farm_designer.tryGroupSortType
|| group.body.sort_type;
const sorted = groupSortType == "nn"
? nn(groupPlants)
: sortGroupBy(groupSortType, groupPlants);
return sorted.map(p => ({ x: p.body.x, y: p.body.y }));
};
const sortedPointCoordinates = (
group: TaggedPointGroup | undefined, groupPoints: TaggedPoint[]
): { x: number, y: number }[] => {
if (isUndefined(group)) { return []; }
const { resources } = store.getState();
const groupSortType = resources.consumers.farm_designer.tryGroupSortType
|| group.body.sort_type;
const sorted = groupSortType == "nn"
? nn(groupPoints)
: sortGroupBy(groupSortType, groupPoints);
return sorted.map(p => ({ x: p.body.x, y: p.body.y }));
};
export interface PointsPathLineProps {
orderedPoints: { x: number, y: number }[];
@ -51,5 +50,5 @@ export const PointsPathLine = (props: PointsPathLineProps) =>
export const GroupOrder = (props: GroupOrderProps) =>
<PointsPathLine
orderedPoints={sortedPointCoordinates(props.plants)}
orderedPoints={sortedPointCoordinates(props.group, props.groupPoints)}
mapTransformProps={props.mapTransformProps} />;

View File

@ -1,5 +1,5 @@
import * as React from "react";
import { TaggedPlant, MapTransformProps } from "../map/interfaces";
import { MapTransformProps } from "../map/interfaces";
import { sortGroupBy, sortOptionsTable } from "./point_group_sort_selector";
import { sortBy } from "lodash";
import { PointsPathLine } from "./group_order_visual";
@ -8,18 +8,18 @@ import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { t } from "../../i18next_wrapper";
import { Actions } from "../../constants";
import { edit } from "../../api/crud";
import { TaggedPointGroup } from "farmbot";
import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { error } from "../../toast/toast";
const xy = (point: TaggedPlant) => ({ x: point.body.x, y: point.body.y });
const xy = (point: TaggedPoint) => ({ x: point.body.x, y: point.body.y });
const distance = (p1: { x: number, y: number }, p2: { x: number, y: number }) =>
Math.pow(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2), 0.5);
const pathDistance = (points: TaggedPlant[]) => {
const pathDistance = (pathPoints: TaggedPoint[]) => {
let total = 0;
let prev: { x: number, y: number } | undefined = undefined;
points.map(xy)
pathPoints.map(xy)
.map(p => {
prev ? total += distance(p, prev) : 0;
prev = p;
@ -28,18 +28,18 @@ const pathDistance = (points: TaggedPlant[]) => {
};
const findNearest =
(from: { x: number, y: number }, available: TaggedPlant[]) => {
(from: { x: number, y: number }, available: TaggedPoint[]) => {
const distances = available.map(p => ({
point: p, distance: distance(xy(p), from)
}));
return sortBy(distances, "distance")[0].point;
};
export const nn = (points: TaggedPlant[]) => {
let available = points.slice(0);
const ordered: TaggedPlant[] = [];
export const nn = (pathPoints: TaggedPoint[]) => {
let available = pathPoints.slice(0);
const ordered: TaggedPoint[] = [];
let from = { x: 0, y: 0 };
points.map(() => {
pathPoints.map(() => {
if (available.length < 1) { return; }
const nearest = findNearest(from, available);
ordered.push(nearest);
@ -80,8 +80,8 @@ export const PathInfoBar = (props: PathInfoBarProps) => {
</div>;
};
interface PathsProps {
points: TaggedPlant[];
export interface PathsProps {
pathPoints: TaggedPoint[];
dispatch: Function;
group: TaggedPointGroup;
}
@ -93,15 +93,15 @@ interface PathsState {
export class Paths extends React.Component<PathsProps, PathsState> {
state: PathsState = { pathData: {} };
generatePathData = (points: TaggedPlant[]) => {
generatePathData = (pathPoints: TaggedPoint[]) => {
SORT_TYPES.map((sortType: PointGroupSortType) =>
this.state.pathData[sortType] =
pathDistance(sortGroupBy(sortType, points)));
this.state.pathData.nn = pathDistance(nn(points));
pathDistance(sortGroupBy(sortType, pathPoints)));
this.state.pathData.nn = pathDistance(nn(pathPoints));
};
render() {
if (!this.state.pathData.nn) { this.generatePathData(this.props.points); }
if (!this.state.pathData.nn) { this.generatePathData(this.props.pathPoints); }
return <div>
<label>{t("Path lengths by sort type")}</label>
{SORT_TYPES.concat("nn").map(st =>
@ -115,7 +115,7 @@ export class Paths extends React.Component<PathsProps, PathsState> {
}
interface NNPathProps {
plants: TaggedPlant[];
pathPoints: TaggedPoint[];
mapTransformProps: MapTransformProps;
}
@ -125,6 +125,6 @@ export const NNPath = (props: NNPathProps) =>
color={Color.blue}
strokeWidth={2}
dash={1}
orderedPoints={nn(props.plants).map(xy)}
orderedPoints={nn(props.pathPoints).map(xy)}
mapTransformProps={props.mapTransformProps} />
: <g />;

View File

@ -1,15 +1,14 @@
import * as React from "react";
import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons";
import { TaggedPlant } from "../map/interfaces";
import { cachedCrop } from "../../open_farm/cached_crop";
import { setImgSrc, maybeGetCachedPlantIcon } from "../../open_farm/cached_crop";
import { setHoveredPlant } from "../map/actions";
import { TaggedPointGroup, uuid } from "farmbot";
import { TaggedPointGroup, uuid, TaggedPoint } from "farmbot";
import { overwrite } from "../../api/crud";
type IMGEvent = React.SyntheticEvent<HTMLImageElement>;
import { error } from "../../toast/toast";
import { t } from "../../i18next_wrapper";
export interface PointGroupItemProps {
plant: TaggedPlant;
point: TaggedPoint;
group: TaggedPointGroup;
dispatch: Function;
hovered: boolean;
@ -24,6 +23,18 @@ const removePoint = (group: TaggedPointGroup, pointId: number) => {
return overwrite(group, nextGroup);
};
export const genericPointIcon = (color: string | undefined) =>
`<svg xmlns='http://www.w3.org/2000/svg'
fill='none' stroke-width='1.5' stroke='${color || "gray"}'>
<circle cx='15' cy='15' r='12' />
<circle cx='15' cy='15' r='2' />
</svg>`;
export const OTHER_POINT_ICON =
`<svg xmlns='http://www.w3.org/2000/svg' fill='gray'>
<circle cx='15' cy='15' r='12' />
</svg>`;
// The individual plants in the point group detail page.
export class PointGroupItem
extends React.Component<PointGroupItemProps, PointGroupItemState> {
@ -33,24 +44,41 @@ export class PointGroupItem
key = uuid();
enter = () => this.props.dispatch(
setHoveredPlant(this.props.plant.uuid, this.state.icon));
setHoveredPlant(this.props.point.uuid, this.state.icon));
leave = () => this.props.dispatch(setHoveredPlant(undefined));
click = () => {
if (this.criteriaIcon) {
return error(t("Cannot remove points selected by criteria."));
}
this.props.dispatch(
removePoint(this.props.group, this.props.plant.body.id || 0));
removePoint(this.props.group, this.props.point.body.id || 0));
this.leave();
}
maybeGetCachedIcon = ({ currentTarget }: IMGEvent) => {
return cachedCrop(this.props.plant.body.openfarm_slug).then((crop) => {
const i = svgToUrl(crop.svg_icon);
if (i !== currentTarget.getAttribute("src")) {
currentTarget.setAttribute("src", i);
}
this.setState({ icon: i });
});
get criteriaIcon() {
return !this.props.group.body.point_ids
.includes(this.props.point.body.id || 0);
}
maybeGetCachedIcon = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
switch (this.props.point.body.pointer_type) {
case "Plant":
const slug = this.props.point.body.openfarm_slug;
maybeGetCachedPlantIcon(slug, img, icon => this.setState({ icon }));
break;
case "GenericPointer":
const { color } = this.props.point.body.meta;
const pointIcon = svgToUrl(genericPointIcon(color));
setImgSrc(img, pointIcon);
break;
default:
const otherIcon = svgToUrl(OTHER_POINT_ICON);
setImgSrc(img, otherIcon);
break;
}
};
render() {
@ -60,6 +88,10 @@ export class PointGroupItem
onMouseLeave={this.leave}
onClick={this.click}>
<img
style={{
border: this.criteriaIcon ? "1px solid gray" : "none",
borderRadius: "5px",
}}
src={DEFAULT_ICON}
onLoad={this.maybeGetCachedIcon}
width={32}

View File

@ -2,11 +2,11 @@ import * as React from "react";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { FBSelect, DropDownItem } from "../../ui";
import { t } from "../../i18next_wrapper";
import { TaggedPlant } from "../map/interfaces";
import { shuffle, sortBy } from "lodash";
import { Content } from "../../constants";
import { TaggedPoint } from "farmbot";
interface Props {
export interface PointGroupSortSelectorProps {
onChange(value: PointGroupSortType): void;
value: PointGroupSortType;
}
@ -41,7 +41,7 @@ export const sortTypeChange = (cb: Function) => (ddi: DropDownItem) => {
isSortType(value) && cb(value);
};
export function PointGroupSortSelector(p: Props) {
export function PointGroupSortSelector(p: PointGroupSortSelectorProps) {
return <div>
<div className="default-value-tooltip">
@ -60,25 +60,25 @@ export function PointGroupSortSelector(p: Props) {
</div>;
}
type Sorter = (p: TaggedPlant[]) => TaggedPlant[];
type Sorter = (p: TaggedPoint[]) => TaggedPoint[];
type SortDictionary = Record<PointGroupSortType, Sorter>;
export const SORT_OPTIONS: SortDictionary = {
random(plants) {
return shuffle(plants);
random(points) {
return shuffle(points);
},
xy_ascending(plants) {
return sortBy(plants, ["body.x", "body.y"]);
xy_ascending(points) {
return sortBy(points, ["body.x", "body.y"]);
},
xy_descending(plants) {
return sortBy(plants, ["body.x", "body.y"]).reverse();
xy_descending(points) {
return sortBy(points, ["body.x", "body.y"]).reverse();
},
yx_ascending(plants) {
return sortBy(plants, ["body.y", "body.x"]);
yx_ascending(points) {
return sortBy(points, ["body.y", "body.x"]);
},
yx_descending(plants) {
return sortBy(plants, ["body.y", "body.x"]).reverse();
yx_descending(points) {
return sortBy(points, ["body.y", "body.x"]).reverse();
}
};
export const sortGroupBy =
(st: PointGroupSortType, p: TaggedPlant[]) => SORT_OPTIONS[st](p);
(st: PointGroupSortType, p: TaggedPoint[]) => SORT_OPTIONS[st](p);

View File

@ -23,7 +23,7 @@ import { Actions } from "../../../constants";
import { clickButton } from "../../../__test_support__/helpers";
import { fakeState } from "../../../__test_support__/fake_state";
import { CurrentPointPayl } from "../../interfaces";
import { inputEvent } from "../../../__test_support__/fake_input_event";
import { inputEvent } from "../../../__test_support__/fake_html_events";
import { cloneDeep } from "lodash";
const FAKE_POINT: CurrentPointPayl =

View File

@ -16,7 +16,7 @@ import { mapStateToProps } from "../point_inventory";
describe("<Points> />", () => {
const fakeProps = (): PointsProps => ({
points: [],
genericPoints: [],
dispatch: jest.fn(),
hoveredPoint: undefined,
});
@ -28,15 +28,15 @@ describe("<Points> />", () => {
it("renders points", () => {
const p = fakeProps();
p.points = [fakePoint()];
p.genericPoints = [fakePoint()];
const wrapper = mount(<Points {...p} />);
expect(wrapper.text()).toContain("Point 1");
});
it("navigates to point info", () => {
const p = fakeProps();
p.points = [fakePoint()];
p.points[0].body.id = 1;
p.genericPoints = [fakePoint()];
p.genericPoints[0].body.id = 1;
const wrapper = mount(<Points {...p} />);
wrapper.find(".point-search-item").first().simulate("click");
expect(push).toHaveBeenCalledWith("/app/designer/points/1");
@ -44,9 +44,9 @@ describe("<Points> />", () => {
it("changes search term", () => {
const p = fakeProps();
p.points = [fakePoint(), fakePoint()];
p.points[0].body.name = "point 0";
p.points[1].body.name = "point 1";
p.genericPoints = [fakePoint(), fakePoint()];
p.genericPoints[0].body.name = "point 0";
p.genericPoints[1].body.name = "point 1";
const wrapper = shallow<Points>(<Points {...p} />);
wrapper.find("input").first().simulate("change",
{ currentTarget: { value: "0" } });
@ -55,9 +55,9 @@ describe("<Points> />", () => {
it("filters points", () => {
const p = fakeProps();
p.points = [fakePoint(), fakePoint()];
p.points[0].body.name = "point 0";
p.points[1].body.name = "point 1";
p.genericPoints = [fakePoint(), fakePoint()];
p.genericPoints[0].body.name = "point 0";
p.genericPoints[1].body.name = "point 1";
const wrapper = mount(<Points {...p} />);
wrapper.setState({ searchTerm: "0" });
expect(wrapper.text()).not.toContain("point 1");
@ -72,6 +72,6 @@ describe("mapStateToProps()", () => {
discarded.body.discarded_at = "2016-05-22T05:00:00.000Z";
state.resources = buildResourceIndex([point, discarded]);
const props = mapStateToProps(state);
expect(props.points).toEqual([point]);
expect(props.genericPoints).toEqual([point]);
});
});

View File

@ -8,7 +8,7 @@ import { fakePoint } from "../../../__test_support__/fake_state/resources";
describe("<Weeds> />", () => {
const fakeProps = (): WeedsProps => ({
points: [],
genericPoints: [],
dispatch: jest.fn(),
hoveredPoint: undefined,
});
@ -27,9 +27,9 @@ describe("<Weeds> />", () => {
it("filters points", () => {
const p = fakeProps();
p.points = [fakePoint(), fakePoint()];
p.points[0].body.name = "weed 0";
p.points[1].body.name = "weed 1";
p.genericPoints = [fakePoint(), fakePoint()];
p.genericPoints[0].body.name = "weed 0";
p.genericPoints[1].body.name = "weed 1";
const wrapper = mount(<Weeds {...p} />);
wrapper.setState({ searchTerm: "0" });
expect(wrapper.text()).toContain("weed 0");

View File

@ -8,7 +8,7 @@ import { history, getPathArray } from "../../history";
import { Panel } from "../panel_header";
import { Everything } from "../../interfaces";
import { TaggedGenericPointer } from "farmbot";
import { maybeFindPointById } from "../../resources/selectors";
import { maybeFindGenericPointerById } from "../../resources/selectors";
import { Actions } from "../../constants";
import {
EditPointProperties, updatePoint, PointActions
@ -21,7 +21,7 @@ export interface EditPointProps {
export const mapStateToProps = (props: Everything): EditPointProps => ({
dispatch: props.dispatch,
findPoint: id => maybeFindPointById(props.resources.index, id),
findPoint: id => maybeFindGenericPointerById(props.resources.index, id),
});
export class RawEditPoint extends React.Component<EditPointProps, {}> {

View File

@ -16,7 +16,7 @@ import { t } from "../../i18next_wrapper";
import { isAWeed } from "./weeds_inventory";
export interface PointsProps {
points: TaggedGenericPointer[];
genericPoints: TaggedGenericPointer[];
dispatch: Function;
hoveredPoint: string | undefined;
}
@ -28,7 +28,7 @@ interface PointsState {
export function mapStateToProps(props: Everything): PointsProps {
const { hoveredPoint } = props.resources.consumers.farm_designer;
return {
points: selectAllGenericPointers(props.resources.index)
genericPoints: selectAllGenericPointers(props.resources.index)
.filter(x => !x.body.discarded_at)
.filter(x => !isAWeed(x.body.name, x.body.meta.type)),
dispatch: props.dispatch,
@ -55,12 +55,12 @@ export class RawPoints extends React.Component<PointsProps, PointsState> {
</DesignerPanelTop>
<DesignerPanelContent panelName={"points"}>
<EmptyStateWrapper
notEmpty={this.props.points.length > 0}
notEmpty={this.props.genericPoints.length > 0}
graphic={EmptyStateGraphic.points}
title={t("No points yet.")}
text={Content.NO_POINTS}
colorScheme={"points"}>
{this.props.points
{this.props.genericPoints
.filter(p => p.body.name.toLowerCase()
.includes(this.state.searchTerm.toLowerCase()))
.map(p => <PointInventoryItem

View File

@ -7,7 +7,7 @@ import { t } from "../../i18next_wrapper";
import { history, getPathArray } from "../../history";
import { Everything } from "../../interfaces";
import { TaggedGenericPointer } from "farmbot";
import { maybeFindPointById } from "../../resources/selectors";
import { maybeFindGenericPointerById } from "../../resources/selectors";
import { Panel } from "../panel_header";
import {
EditPointProperties, PointActions, updatePoint
@ -21,7 +21,7 @@ export interface EditWeedProps {
export const mapStateToProps = (props: Everything): EditWeedProps => ({
dispatch: props.dispatch,
findPoint: id => maybeFindPointById(props.resources.index, id),
findPoint: id => maybeFindGenericPointerById(props.resources.index, id),
});
export class RawEditWeed extends React.Component<EditWeedProps, {}> {

View File

@ -15,7 +15,7 @@ import { selectAllGenericPointers } from "../../resources/selectors";
import { PointInventoryItem } from "./point_inventory_item";
export interface WeedsProps {
points: TaggedGenericPointer[];
genericPoints: TaggedGenericPointer[];
dispatch: Function;
hoveredPoint: string | undefined;
}
@ -28,7 +28,7 @@ export const isAWeed = (pointName: string, type?: string) =>
type == "weed" || pointName.toLowerCase().includes("weed");
export const mapStateToProps = (props: Everything): WeedsProps => ({
points: selectAllGenericPointers(props.resources.index)
genericPoints: selectAllGenericPointers(props.resources.index)
.filter(x => !x.body.discarded_at)
.filter(x => isAWeed(x.body.name, x.body.meta.type)),
dispatch: props.dispatch,
@ -54,12 +54,12 @@ export class RawWeeds extends React.Component<WeedsProps, WeedsState> {
</DesignerPanelTop>
<DesignerPanelContent panelName={"weeds-inventory"}>
<EmptyStateWrapper
notEmpty={this.props.points.length > 0}
notEmpty={this.props.genericPoints.length > 0}
graphic={EmptyStateGraphic.weeds}
title={t("No weeds yet.")}
text={Content.NO_WEEDS}
colorScheme={"weeds"}>
{this.props.points
{this.props.genericPoints
.filter(p => p.body.name.toLowerCase()
.includes(this.state.searchTerm.toLowerCase()))
.map(p => <PointInventoryItem

View File

@ -60,13 +60,14 @@ export const designer = generateReducer<DesignerState>(initialState)
s.hoveredToolSlot = payload;
return s;
})
.add<CurrentPointPayl | undefined>(Actions.SET_CURRENT_POINT_DATA, (s, { payload }) => {
const { color } =
(!payload || !payload.color) ? (s.currentPoint || { color: "green" }) : payload;
s.currentPoint = payload;
s.currentPoint && (s.currentPoint.color = color);
return s;
})
.add<CurrentPointPayl | undefined>(
Actions.SET_CURRENT_POINT_DATA, (s, { payload }) => {
const { color } = (!payload || !payload.color) ?
(s.currentPoint || { color: "green" }) : payload;
s.currentPoint = payload;
s.currentPoint && (s.currentPoint.color = color);
return s;
})
.add<CropLiveSearchResult[]>(Actions.OF_SEARCH_RESULTS_OK, (s, a) => {
s.cropSearchResults = a.payload;
s.cropSearchInProgress = false;

View File

@ -9,7 +9,9 @@ import {
selectAllPlantTemplates,
selectAllSensorReadings,
selectAllSensors,
maybeGetTimeSettings
maybeGetTimeSettings,
selectAllPoints,
selectAllPointGroups
} from "../resources/selectors";
import { validBotLocationData, validFwConfig, unpackUUID } from "../util";
import { getWebAppConfigValue } from "../config_storage/actions";
@ -47,10 +49,10 @@ export function mapStateToProps(props: Everything): Props {
const hoveredPlant = findPlant(plantUUID);
const getConfigValue = getWebAppConfigValue(() => props);
const allPoints = selectAllGenericPointers(props.resources.index);
const points = getConfigValue(BooleanSetting.show_historic_points)
? allPoints
: allPoints.filter(x => !x.body.discarded_at);
const allGenericPoints = selectAllGenericPointers(props.resources.index);
const genericPoints = getConfigValue(BooleanSetting.show_historic_points)
? allGenericPoints
: allGenericPoints.filter(x => !x.body.discarded_at);
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const { mcu_params } = props.bot.hardware;
@ -103,7 +105,8 @@ export function mapStateToProps(props: Everything): Props {
dispatch: props.dispatch,
selectedPlant,
designer: props.resources.consumers.farm_designer,
points,
genericPoints,
allPoints: selectAllPoints(props.resources.index),
toolSlots: joinToolsAndSlot(props.resources.index),
hoveredPlant,
plants,
@ -118,5 +121,7 @@ export function mapStateToProps(props: Everything): Props {
getConfigValue,
sensorReadings,
sensors: selectAllSensors(props.resources.index),
groups: selectAllPointGroups(props.resources.index),
shouldDisplay,
};
}

View File

@ -4,12 +4,22 @@ jest.mock("../../../history", () => ({
history: { push: jest.fn() }
}));
jest.mock("../../../api/crud", () => ({
edit: jest.fn(),
save: jest.fn(),
}));
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import {
RawEditZone as EditZone, EditZoneProps, mapStateToProps
} from "../edit_zone";
import { fakeState } from "../../../__test_support__/fake_state";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import {
buildResourceIndex
} from "../../../__test_support__/resource_index_builder";
import { save, edit } from "../../../api/crud";
describe("<EditZone />", () => {
const fakeProps = (): EditZoneProps => ({
@ -26,15 +36,29 @@ describe("<EditZone />", () => {
it("renders", () => {
mockPath = "/app/designer/zones/1";
const p = fakeProps();
p.findZone = () => "stub zone";
p.findZone = () => fakePointGroup();
const wrapper = mount(<EditZone {...p} />);
expect(wrapper.text().toLowerCase()).toContain("edit");
});
it("changes name", () => {
mockPath = "/app/designer/zones/1";
const p = fakeProps();
const group = fakePointGroup();
p.findZone = () => group;
const wrapper = shallow(<EditZone {...p} />);
wrapper.find("input").first().simulate("blur", {
currentTarget: { value: "new name" }
});
expect(edit).toHaveBeenCalledWith(group, { name: "new name" });
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("mapStateToProps()", () => {
it("returns props", () => {
const state = fakeState();
state.resources = buildResourceIndex([fakePointGroup()]);
const props = mapStateToProps(state);
expect(props.findZone(1)).toEqual(undefined);
});

View File

@ -1,13 +1,26 @@
jest.mock("../../../history", () => ({
history: { push: jest.fn() },
getPathArray: () => [],
}));
jest.mock("../../../api/crud", () => ({ initSaveGetId: jest.fn() }));
import * as React from "react";
import { mount, shallow } from "enzyme";
import {
RawZones as Zones, ZonesProps, mapStateToProps
} from "../zones_inventory";
import { fakeState } from "../../../__test_support__/fake_state";
import { fakePointGroup } from "../../../__test_support__/fake_state/resources";
import { history } from "../../../history";
import { initSaveGetId } from "../../../api/crud";
import { DesignerPanelTop } from "../../designer_panel";
describe("<Zones> />", () => {
const fakeProps = (): ZonesProps => ({
dispatch: jest.fn(),
zones: [],
allPoints: [],
});
it("renders no zones", () => {
@ -21,6 +34,56 @@ describe("<Zones> />", () => {
{ currentTarget: { value: "0" } });
expect(wrapper.state().searchTerm).toEqual("0");
});
it("navigates to zone info", () => {
const p = fakeProps();
p.zones = [fakePointGroup()];
p.zones[0].body.id = 1;
const wrapper = mount(<Zones {...p} />);
wrapper.find(".group-search-item").first().simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/zones/1");
});
it("navigates to unsaved zone", () => {
const p = fakeProps();
p.zones = [fakePointGroup()];
p.zones[0].body.id = 0;
const wrapper = mount(<Zones {...p} />);
wrapper.find(".group-search-item").first().simulate("click");
expect(history.push).toHaveBeenCalledWith("/app/designer/zones/0");
});
it("filters points", () => {
const p = fakeProps();
p.zones = [fakePointGroup(), fakePointGroup()];
p.zones[0].body.name = "zone 0";
p.zones[1].body.name = "zone 1";
const wrapper = mount(<Zones {...p} />);
wrapper.setState({ searchTerm: "0" });
expect(wrapper.text()).not.toContain("zone 1");
});
it("creates new zone", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.resolve(1));
const wrapper = shallow(<Zones {...p} />);
await wrapper.find(DesignerPanelTop).simulate("click");
expect(initSaveGetId).toHaveBeenCalledWith("PointGroup", {
name: "Untitled Zone", point_ids: []
});
expect(history.push).toHaveBeenCalledWith("/app/designer/zones/1");
});
it("handles zone creation error", async () => {
const p = fakeProps();
p.dispatch = jest.fn(() => Promise.reject());
const wrapper = shallow(<Zones {...p} />);
await wrapper.find(DesignerPanelTop).simulate("click");
expect(initSaveGetId).toHaveBeenCalledWith("PointGroup", {
name: "Untitled Zone", point_ids: []
});
expect(history.push).not.toHaveBeenCalled();
});
});
describe("mapStateToProps()", () => {

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