Merge pull request #1749 from FarmBot/staging

v9.2.5 - Jolly Juniper
image_updates
Rick Carlino 2020-04-08 14:00:45 -05:00 committed by GitHub
commit eb8cfd3c91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
105 changed files with 2663 additions and 681 deletions

View File

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

View File

@ -1,7 +1,8 @@
import { DesignerState } from "../farm_designer/interfaces"; import { DesignerState } from "../farm_designer/interfaces";
export const fakeDesignerState = (): DesignerState => ({ export const fakeDesignerState = (): DesignerState => ({
selectedPlants: undefined, selectedPoints: undefined,
selectionPointType: undefined,
hoveredPlant: { hoveredPlant: {
plantUUID: undefined, plantUUID: undefined,
icon: "" icon: ""
@ -13,7 +14,8 @@ export const fakeDesignerState = (): DesignerState => ({
cropSearchResults: [], cropSearchResults: [],
cropSearchInProgress: false, cropSearchInProgress: false,
chosenLocation: { x: undefined, y: undefined, z: undefined }, chosenLocation: { x: undefined, y: undefined, z: undefined },
currentPoint: undefined, drawnPoint: undefined,
drawnWeed: undefined,
openedSavedGarden: undefined, openedSavedGarden: undefined,
tryGroupSortType: undefined, tryGroupSortType: undefined,
editGroupAreaInMap: false, editGroupAreaInMap: false,

View File

@ -26,6 +26,7 @@ import {
TaggedAlert, TaggedAlert,
TaggedPointGroup, TaggedPointGroup,
TaggedFolder, TaggedFolder,
TaggedWeedPointer,
} from "farmbot"; } from "farmbot";
import { fakeResource } from "../fake_resource"; import { fakeResource } from "../fake_resource";
import { import {
@ -171,6 +172,19 @@ export function fakePoint(): TaggedGenericPointer {
}); });
} }
export function fakeWeed(): TaggedWeedPointer {
return fakeResource("Point", {
id: idCounter++,
name: "Weed 1",
pointer_type: "Weed",
x: 200,
y: 400,
z: 0,
radius: 100,
meta: { created_by: "plant-detection", color: "red" }
});
}
export function fakeSavedGarden(): TaggedSavedGarden { export function fakeSavedGarden(): TaggedSavedGarden {
return fakeResource("SavedGarden", { return fakeResource("SavedGarden", {
id: idCounter++, id: idCounter++,
@ -289,6 +303,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
show_sensor_readings: false, show_sensor_readings: false,
show_plants: true, show_plants: true,
show_points: true, show_points: true,
show_weeds: true,
x_axis_inverted: false, x_axis_inverted: false,
y_axis_inverted: false, y_axis_inverted: false,
z_axis_inverted: true, z_axis_inverted: true,

View File

@ -316,6 +316,27 @@ const tr15: TaggedResource = {
"uuid": "Tool.15.50" "uuid": "Tool.15.50"
}; };
const tr16: TaggedPoint = {
specialStatus: SpecialStatus.SAVED,
kind: "Point",
body: {
id: 1395,
created_at: "2017-05-24T20:41:19.889Z",
updated_at: "2017-05-24T20:41:19.889Z",
meta: {
color: "gray",
created_by: "plant-detection"
},
name: "untitled",
pointer_type: "Weed",
radius: 10,
x: 490,
y: 421,
z: 5
},
uuid: "Point.1397.11"
};
const log: TaggedLog = { const log: TaggedLog = {
kind: "Log", kind: "Log",
specialStatus: SpecialStatus.SAVED, specialStatus: SpecialStatus.SAVED,
@ -345,6 +366,7 @@ export const FAKE_RESOURCES: TaggedResource[] = [
tr0, tr0,
tr14, tr14,
tr15, tr15,
tr16,
log, log,
]; ];
const KIND: keyof TaggedResource = "kind"; // Safety first, kids. const KIND: keyof TaggedResource = "kind"; // Safety first, kids.

View File

@ -334,6 +334,7 @@ const MUST_CONFIRM_LIST: ResourceName[] = [
"Regimen", "Regimen",
"Image", "Image",
"SavedGarden", "SavedGarden",
"PointGroup",
]; ];
const confirmationChecker = (resourceName: ResourceName, force = false) => const confirmationChecker = (resourceName: ResourceName, force = false) =>

View File

@ -773,7 +773,7 @@ export namespace Content {
trim(`Click and drag or use the inputs to draw a weed.`); trim(`Click and drag or use the inputs to draw a weed.`);
export const BOX_SELECT_DESCRIPTION = export const BOX_SELECT_DESCRIPTION =
trim(`Drag a box around the plants you would like to select. trim(`Drag a box around the items you would like to select.
Press the back arrow to exit.`); Press the back arrow to exit.`);
export const SAVED_GARDENS = export const SAVED_GARDENS =
@ -1139,7 +1139,8 @@ export enum Actions {
// Designer // Designer
SEARCH_QUERY_CHANGE = "SEARCH_QUERY_CHANGE", SEARCH_QUERY_CHANGE = "SEARCH_QUERY_CHANGE",
SELECT_PLANT = "SELECT_PLANT", SELECT_POINT = "SELECT_POINT",
SET_SELECTION_POINT_TYPE = "SET_SELECTION_POINT_TYPE",
TOGGLE_HOVERED_PLANT = "TOGGLE_HOVERED_PLANT", TOGGLE_HOVERED_PLANT = "TOGGLE_HOVERED_PLANT",
TOGGLE_HOVERED_POINT = "TOGGLE_HOVERED_POINT", TOGGLE_HOVERED_POINT = "TOGGLE_HOVERED_POINT",
HOVER_PLANT_LIST_ITEM = "HOVER_PLANT_LIST_ITEM", HOVER_PLANT_LIST_ITEM = "HOVER_PLANT_LIST_ITEM",
@ -1148,7 +1149,8 @@ export enum Actions {
OF_SEARCH_RESULTS_OK = "OF_SEARCH_RESULTS_OK", OF_SEARCH_RESULTS_OK = "OF_SEARCH_RESULTS_OK",
OF_SEARCH_RESULTS_NO = "OF_SEARCH_RESULTS_NO", OF_SEARCH_RESULTS_NO = "OF_SEARCH_RESULTS_NO",
CHOOSE_LOCATION = "CHOOSE_LOCATION", CHOOSE_LOCATION = "CHOOSE_LOCATION",
SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA", SET_DRAWN_POINT_DATA = "SET_DRAWN_POINT_DATA",
SET_DRAWN_WEED_DATA = "SET_DRAWN_WEED_DATA",
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN", CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
TRY_SORT_TYPE = "TRY_SORT_TYPE", TRY_SORT_TYPE = "TRY_SORT_TYPE",
EDIT_GROUP_AREA_IN_MAP = "EDIT_GROUP_AREA_IN_MAP", EDIT_GROUP_AREA_IN_MAP = "EDIT_GROUP_AREA_IN_MAP",

View File

@ -185,23 +185,33 @@
} }
} }
%panel-item-base {
text-align: right;
font-size: 1rem;
padding-right: 1rem;
line-height: 3rem;
float: right;
}
.plant-search-item, .plant-search-item,
.group-search-item { .group-search-item {
cursor: pointer; cursor: pointer;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
img { img {
margin: 0 1rem 0 0; margin-right: 0.5rem;
height: 4rem; height: 3rem;
width: 4rem; width: 3rem;
}
&.quick-del {
&:hover {
background: lighten($red, 10%) !important;
&:after {
content: "x";
margin-left: 1rem;
color: $darkest_red;
font-weight: bold;
}
}
} }
}
%panel-item-base {
text-align: right;
font-size: 1rem;
padding-top: 1.4rem;
padding-right: 1rem;
float: right;
} }
.plant-search-item-age { .plant-search-item-age {
@extend %panel-item-base; @extend %panel-item-base;
@ -209,6 +219,7 @@
.group-item-count { .group-item-count {
@extend %panel-item-base; @extend %panel-item-base;
padding-top: 0.6rem; padding-top: 0.6rem;
line-height: 1rem;
} }
.plant-search-item-name { .plant-search-item-name {
display: inline-block; display: inline-block;
@ -219,24 +230,27 @@
text-overflow: ellipsis; text-overflow: ellipsis;
margin-left: 1rem; margin-left: 1rem;
} }
.weed-search-item,
.point-search-item { .point-search-item {
cursor: pointer; cursor: pointer;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
.saucer { .saucer {
display: inline-block; display: inline-block;
margin: 0 1rem 0 0; height: 3rem;
height: 2rem; width: 3rem;
width: 2rem;
vertical-align: middle; vertical-align: middle;
margin-right: 0.25rem;
} }
} }
.weed-search-item-info,
.point-search-item-info { .point-search-item-info {
text-align: right; text-align: right;
font-size: 1rem; font-size: 1rem;
padding-top: 0.6rem;
padding-right: 1rem; padding-right: 1rem;
line-height: 3rem;
float: right; float: right;
} }
.weed-search-item-name,
.point-search-item-name { .point-search-item-name {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
@ -244,7 +258,34 @@
width: 40%; width: 40%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-left: 1rem; margin-left: 1.25rem;
}
.tool-search-item,
.tool-slot-search-item {
line-height: 4rem;
cursor: pointer;
.row {
margin-left: 0;
margin-right: 0;
}
.tool-slot-search-item-name {
margin-left: -1rem;
}
p {
font-size: 1rem;
line-height: 4rem;
&.tool-status,
&.tool-slot-position {
float: right;
}
}
svg {
vertical-align: middle;
}
.tool-slot-position-info {
padding: 0;
padding-right: 1.75rem;
}
} }
} }
@ -284,11 +325,17 @@
} }
.map-point { .map-point {
cursor: pointer !important;
stroke-width: 2; stroke-width: 2;
stroke-opacity: 0.3; stroke-opacity: 0.3;
fill-opacity: 0.1; fill-opacity: 0.1;
} }
.map-weed {
cursor: pointer !important;
}
.weed-image,
.plant-image { .plant-image {
transform-origin: bottom; transform-origin: bottom;
transform-box: fill-box; transform-box: fill-box;
@ -337,6 +384,9 @@
fill: $white; fill: $white;
stroke: $white; stroke: $white;
} }
&:hover {
opacity: 0.15;
}
} }
} }

View File

@ -291,6 +291,7 @@
.panel-action-buttons { .panel-action-buttons {
position: absolute; position: absolute;
z-index: 9; z-index: 9;
height: 19rem;
width: 100%; width: 100%;
background: $panel_medium_light_gray; background: $panel_medium_light_gray;
padding: 0.5rem; padding: 0.5rem;
@ -307,6 +308,9 @@
float: left; float: left;
width: 100%; width: 100%;
} }
.filter-search {
padding-right: 1rem;
}
.plant-status-bulk-update { .plant-status-bulk-update {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
@ -321,15 +325,13 @@
} }
} }
.panel-content { .panel-content {
padding-top: 15rem; padding-top: 19rem;
padding-right: 0; padding-right: 0;
padding-left: 0; padding-left: 0;
padding-bottom: 5rem; padding-bottom: 5rem;
max-height: calc(100vh - 13rem); max-height: calc(100vh - 13rem);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
.plant-search-item,
.group-search-item { pointer-events: none; }
} }
} }
@ -377,9 +379,13 @@
.weed-info-panel-content, .weed-info-panel-content,
.point-info-panel-content { .point-info-panel-content {
.saucer { .point-color-input {
margin: 1rem; div[class*=col-] {
margin-left: 2rem; padding-left: 0.5rem;
}
.saucer {
margin-top: 4.5rem;
}
} }
.fb-button & .red { .fb-button & .red {
display: block; display: block;
@ -557,22 +563,8 @@
overflow-x: hidden; overflow-x: hidden;
.tool-search-item, .tool-search-item,
.tool-slot-search-item { .tool-slot-search-item {
line-height: 4rem;
cursor: pointer;
margin-left: -15px; margin-left: -15px;
margin-right: -15px; margin-right: -15px;
.row {
margin-left: 0;
margin-right: 0;
}
p {
font-size: 1.2rem;
line-height: 4rem;
&.tool-status,
&.tool-slot-position {
float: right;
}
}
.filter-search { .filter-search {
.bp3-button { .bp3-button {
min-height: 2.5rem; min-height: 2.5rem;
@ -585,13 +577,6 @@
line-height: 2rem; line-height: 2rem;
} }
} }
svg {
vertical-align: middle;
}
.tool-slot-position-info {
padding: 0;
padding-right: 1rem;
}
} }
.mounted-tool-header { .mounted-tool-header {
display: flex; display: flex;
@ -650,6 +635,7 @@
margin-top: 1rem; margin-top: 1rem;
&.red { &.red {
float: left; float: left;
margin-bottom: 1rem;
} }
} }
svg { svg {
@ -659,6 +645,19 @@
height: 10rem; height: 10rem;
margin-top: 2rem; margin-top: 2rem;
} }
.edit-tool,
.add-new-tool {
margin-bottom: 3rem;
.name-error {
margin-top: 1.2rem;
margin-right: 1rem;
color: $dark_red;
float: right;
}
.save-btn {
float: right;
}
}
.add-stock-tools { .add-stock-tools {
.filter-search { .filter-search {
margin-bottom: 1rem; margin-bottom: 1rem;
@ -807,6 +806,19 @@
} }
} }
.weed-item-icon,
.group-item-icon {
display: inline-block;
position: relative;
.weed-icon {
position: absolute;
top: 13%;
left: 12%;
width: 70%;
height: 70%;
}
}
.weeds-inventory-panel, .weeds-inventory-panel,
.zones-inventory-panel, .zones-inventory-panel,
.groups-panel { .groups-panel {

View File

@ -15,7 +15,6 @@ import * as React from "react";
import { RawFarmDesigner as FarmDesigner } from "../index"; import { RawFarmDesigner as FarmDesigner } from "../index";
import { mount } from "enzyme"; import { mount } from "enzyme";
import { Props } from "../interfaces"; import { Props } from "../interfaces";
import { GardenMapLegendProps } from "../map/interfaces";
import { bot } from "../../__test_support__/fake_state/bot"; import { bot } from "../../__test_support__/fake_state/bot";
import { import {
fakeImage, fakeWebAppConfig, fakeImage, fakeWebAppConfig,
@ -28,6 +27,8 @@ import {
import { fakeState } from "../../__test_support__/fake_state"; import { fakeState } from "../../__test_support__/fake_state";
import { edit } from "../../api/crud"; import { edit } from "../../api/crud";
import { BooleanSetting } from "../../session_keys"; import { BooleanSetting } from "../../session_keys";
import { GardenMapLegend } from "../map/legend/garden_map_legend";
import { GardenMap } from "../map/garden_map";
describe("<FarmDesigner/>", () => { describe("<FarmDesigner/>", () => {
const fakeProps = (): Props => ({ const fakeProps = (): Props => ({
@ -36,6 +37,7 @@ describe("<FarmDesigner/>", () => {
designer: fakeDesignerState(), designer: fakeDesignerState(),
hoveredPlant: undefined, hoveredPlant: undefined,
genericPoints: [], genericPoints: [],
weeds: [],
allPoints: [], allPoints: [],
plants: [], plants: [],
toolSlots: [], toolSlots: [],
@ -67,8 +69,7 @@ describe("<FarmDesigner/>", () => {
it("loads default map settings", () => { it("loads default map settings", () => {
const wrapper = mount(<FarmDesigner {...fakeProps()} />); const wrapper = mount(<FarmDesigner {...fakeProps()} />);
const legendProps = const legendProps = wrapper.find(GardenMapLegend).props();
wrapper.find("GardenMapLegend").props() as GardenMapLegendProps;
expect(legendProps.legendMenuOpen).toBeFalsy(); expect(legendProps.legendMenuOpen).toBeFalsy();
expect(legendProps.showPlants).toBeTruthy(); expect(legendProps.showPlants).toBeTruthy();
expect(legendProps.showPoints).toBeTruthy(); expect(legendProps.showPoints).toBeTruthy();
@ -76,8 +77,7 @@ describe("<FarmDesigner/>", () => {
expect(legendProps.showFarmbot).toBeTruthy(); expect(legendProps.showFarmbot).toBeTruthy();
expect(legendProps.showImages).toBeFalsy(); expect(legendProps.showImages).toBeFalsy();
expect(legendProps.imageAgeInfo).toEqual({ newestDate: "", toOldest: 1 }); expect(legendProps.imageAgeInfo).toEqual({ newestDate: "", toOldest: 1 });
// tslint:disable-next-line:no-any const gardenMapProps = wrapper.find(GardenMap).props();
const gardenMapProps = wrapper.find("GardenMap").props() as any;
expect(gardenMapProps.gridSize.x).toEqual(2900); expect(gardenMapProps.gridSize.x).toEqual(2900);
expect(gardenMapProps.gridSize.y).toEqual(1400); expect(gardenMapProps.gridSize.y).toEqual(1400);
}); });
@ -90,8 +90,7 @@ describe("<FarmDesigner/>", () => {
image2.body.created_at = "2001-01-01T00:00:00.000Z"; image2.body.created_at = "2001-01-01T00:00:00.000Z";
p.latestImages = [image1, image2]; p.latestImages = [image1, image2];
const wrapper = mount(<FarmDesigner {...p} />); const wrapper = mount(<FarmDesigner {...p} />);
const legendProps = const legendProps = wrapper.find(GardenMapLegend).props();
wrapper.find("GardenMapLegend").props() as GardenMapLegendProps;
expect(legendProps.imageAgeInfo) expect(legendProps.imageAgeInfo)
.toEqual({ newestDate: "2001-01-03T00:00:00.000Z", toOldest: 2 }); .toEqual({ newestDate: "2001-01-03T00:00:00.000Z", toOldest: 2 });
}); });
@ -137,4 +136,18 @@ describe("<FarmDesigner/>", () => {
bot_origin_quadrant: 2 bot_origin_quadrant: 2
}); });
}); });
it("initializes setting", () => {
const p = fakeProps();
p.getConfigValue = () => false;
const i = new FarmDesigner(p);
expect(i.initializeSetting(BooleanSetting.show_farmbot, true)).toBeFalsy();
});
it("gets bot origin quadrant", () => {
const p = fakeProps();
p.getConfigValue = () => 1;
const i = new FarmDesigner(p);
expect(i.getBotOriginQuadrant()).toEqual(1);
});
}); });

View File

@ -2,7 +2,7 @@ import { designer } from "../reducer";
import { Actions } from "../../constants"; import { Actions } from "../../constants";
import { ReduxAction } from "../../redux/interfaces"; import { ReduxAction } from "../../redux/interfaces";
import { import {
HoveredPlantPayl, CurrentPointPayl, CropLiveSearchResult, HoveredPlantPayl, DrawnPointPayl, CropLiveSearchResult, DrawnWeedPayl,
} from "../interfaces"; } from "../interfaces";
import { BotPosition } from "../../devices/interfaces"; import { BotPosition } from "../../devices/interfaces";
import { import {
@ -10,6 +10,7 @@ import {
} from "../../__test_support__/fake_crop_search_result"; } from "../../__test_support__/fake_crop_search_result";
import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; import { fakeDesignerState } from "../../__test_support__/fake_designer_state";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { PointType } from "farmbot";
describe("designer reducer", () => { describe("designer reducer", () => {
const oldState = fakeDesignerState; const oldState = fakeDesignerState;
@ -24,13 +25,22 @@ describe("designer reducer", () => {
expect(newState.cropSearchInProgress).toEqual(true); expect(newState.cropSearchInProgress).toEqual(true);
}); });
it("selects plants", () => { it("selects points", () => {
const action: ReduxAction<string[]> = { const action: ReduxAction<string[]> = {
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: ["plantUuid"] payload: ["pointUuid"]
}; };
const newState = designer(oldState(), action); const newState = designer(oldState(), action);
expect(newState.selectedPlants).toEqual(["plantUuid"]); expect(newState.selectedPoints).toEqual(["pointUuid"]);
});
it("sets selection point type", () => {
const action: ReduxAction<PointType[] | undefined> = {
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant"],
};
const newState = designer(oldState(), action);
expect(newState.selectionPointType).toEqual(["Plant"]);
}); });
it("sets hovered plant", () => { it("sets hovered plant", () => {
@ -84,25 +94,49 @@ describe("designer reducer", () => {
}); });
it("sets current point data", () => { it("sets current point data", () => {
const action: ReduxAction<CurrentPointPayl> = { const action: ReduxAction<DrawnPointPayl> = {
type: Actions.SET_CURRENT_POINT_DATA, type: Actions.SET_DRAWN_POINT_DATA,
payload: { cx: 10, cy: 20, r: 30, color: "red" } payload: { cx: 10, cy: 20, r: 30, color: "red" }
}; };
const newState = designer(oldState(), action); const newState = designer(oldState(), action);
expect(newState.currentPoint).toEqual({ expect(newState.drawnPoint).toEqual({
cx: 10, cy: 20, r: 30, color: "red" cx: 10, cy: 20, r: 30, color: "red"
}); });
}); });
it("uses current point color", () => { it("uses current point color", () => {
const action: ReduxAction<CurrentPointPayl> = { const action: ReduxAction<DrawnPointPayl> = {
type: Actions.SET_CURRENT_POINT_DATA, type: Actions.SET_DRAWN_POINT_DATA,
payload: { cx: 10, cy: 20, r: 30 } payload: { cx: 10, cy: 20, r: 30 }
}; };
const state = oldState(); const state = oldState();
state.currentPoint = { cx: 0, cy: 0, r: 0, color: "red" }; state.drawnPoint = { cx: 0, cy: 0, r: 0, color: "red" };
const newState = designer(state, action); const newState = designer(state, action);
expect(newState.currentPoint).toEqual({ expect(newState.drawnPoint).toEqual({
cx: 10, cy: 20, r: 30, color: "red"
});
});
it("sets current weed data", () => {
const action: ReduxAction<DrawnWeedPayl> = {
type: Actions.SET_DRAWN_WEED_DATA,
payload: { cx: 10, cy: 20, r: 30, color: "red" }
};
const newState = designer(oldState(), action);
expect(newState.drawnWeed).toEqual({
cx: 10, cy: 20, r: 30, color: "red"
});
});
it("uses current weed color", () => {
const action: ReduxAction<DrawnWeedPayl> = {
type: Actions.SET_DRAWN_WEED_DATA,
payload: { cx: 10, cy: 20, r: 30 }
};
const state = oldState();
state.drawnWeed = { cx: 0, cy: 0, r: 0, color: "red" };
const newState = designer(state, action);
expect(newState.drawnWeed).toEqual({
cx: 10, cy: 20, r: 30, color: "red" cx: 10, cy: 20, r: 30, color: "red"
}); });
}); });
@ -156,4 +190,14 @@ describe("designer reducer", () => {
const newState = designer(state, action); const newState = designer(state, action);
expect(newState.tryGroupSortType).toEqual("random"); expect(newState.tryGroupSortType).toEqual("random");
}); });
it("enables edit group area in map mode", () => {
const state = oldState();
state.editGroupAreaInMap = false;
const action: ReduxAction<boolean> = {
type: Actions.EDIT_GROUP_AREA_IN_MAP, payload: true
};
const newState = designer(state, action);
expect(newState.editGroupAreaInMap).toEqual(true);
});
}); });

View File

@ -51,7 +51,7 @@ describe("mapStateToProps()", () => {
const state = fakeState(); const state = fakeState();
state.resources = buildResourceIndex([fakePlant(), fakeDevice()]); state.resources = buildResourceIndex([fakePlant(), fakeDevice()]);
const plantUuid = Object.keys(state.resources.index.byKind["Point"])[0]; const plantUuid = Object.keys(state.resources.index.byKind["Point"])[0];
state.resources.consumers.farm_designer.selectedPlants = [plantUuid]; state.resources.consumers.farm_designer.selectedPoints = [plantUuid];
expect(mapStateToProps(state).selectedPlant).toEqual( expect(mapStateToProps(state).selectedPlant).toEqual(
expect.objectContaining({ uuid: plantUuid })); expect.objectContaining({ uuid: plantUuid }));
}); });

View File

@ -70,6 +70,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
legend_menu_open: init(BooleanSetting.legend_menu_open, false), legend_menu_open: init(BooleanSetting.legend_menu_open, false),
show_plants: init(BooleanSetting.show_plants, true), show_plants: init(BooleanSetting.show_plants, true),
show_points: init(BooleanSetting.show_points, true), show_points: init(BooleanSetting.show_points, true),
show_weeds: init(BooleanSetting.show_weeds, true),
show_spread: init(BooleanSetting.show_spread, false), show_spread: init(BooleanSetting.show_spread, false),
show_farmbot: init(BooleanSetting.show_farmbot, true), show_farmbot: init(BooleanSetting.show_farmbot, true),
show_images: init(BooleanSetting.show_images, false), show_images: init(BooleanSetting.show_images, false),
@ -116,6 +117,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
legend_menu_open, legend_menu_open,
show_plants, show_plants,
show_points, show_points,
show_weeds,
show_spread, show_spread,
show_farmbot, show_farmbot,
show_images, show_images,
@ -155,6 +157,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
legendMenuOpen={legend_menu_open} legendMenuOpen={legend_menu_open}
showPlants={show_plants} showPlants={show_plants}
showPoints={show_points} showPoints={show_points}
showWeeds={show_weeds}
showSpread={show_spread} showSpread={show_spread}
showFarmbot={show_farmbot} showFarmbot={show_farmbot}
showImages={show_images} showImages={show_images}
@ -181,6 +184,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
<GardenMap <GardenMap
showPoints={show_points} showPoints={show_points}
showPlants={show_plants} showPlants={show_plants}
showWeeds={show_weeds}
showSpread={show_spread} showSpread={show_spread}
showFarmbot={show_farmbot} showFarmbot={show_farmbot}
showImages={show_images} showImages={show_images}
@ -192,6 +196,7 @@ export class RawFarmDesigner extends React.Component<Props, Partial<State>> {
designer={this.props.designer} designer={this.props.designer}
plants={this.props.plants} plants={this.props.plants}
genericPoints={this.props.genericPoints} genericPoints={this.props.genericPoints}
weeds={this.props.weeds}
allPoints={this.props.allPoints} allPoints={this.props.allPoints}
toolSlots={this.props.toolSlots} toolSlots={this.props.toolSlots}
botLocationData={this.props.botLocationData} botLocationData={this.props.botLocationData}

View File

@ -11,8 +11,10 @@ import {
TaggedSensor, TaggedSensor,
TaggedPoint, TaggedPoint,
TaggedPointGroup, TaggedPointGroup,
TaggedWeedPointer,
PointType,
} from "farmbot"; } from "farmbot";
import { SlotWithTool, ResourceIndex } from "../resources/interfaces"; import { SlotWithTool, ResourceIndex, UUID } from "../resources/interfaces";
import { import {
BotPosition, StepsPerMmXY, BotLocationData, ShouldDisplay, BotPosition, StepsPerMmXY, BotLocationData, ShouldDisplay,
} from "../devices/interfaces"; } from "../devices/interfaces";
@ -48,6 +50,7 @@ export interface State extends TypeCheckerHint {
legend_menu_open: boolean; legend_menu_open: boolean;
show_plants: boolean; show_plants: boolean;
show_points: boolean; show_points: boolean;
show_weeds: boolean;
show_spread: boolean; show_spread: boolean;
show_farmbot: boolean; show_farmbot: boolean;
show_images: boolean; show_images: boolean;
@ -63,6 +66,7 @@ export interface Props {
designer: DesignerState; designer: DesignerState;
hoveredPlant: TaggedPlant | undefined; hoveredPlant: TaggedPlant | undefined;
genericPoints: TaggedGenericPointer[]; genericPoints: TaggedGenericPointer[];
weeds: TaggedWeedPointer[];
allPoints: TaggedPoint[]; allPoints: TaggedPoint[];
plants: TaggedPlant[]; plants: TaggedPlant[];
toolSlots: SlotWithTool[]; toolSlots: SlotWithTool[];
@ -106,7 +110,8 @@ export interface Crop {
} }
export interface DesignerState { export interface DesignerState {
selectedPlants: string[] | undefined; selectedPoints: UUID[] | undefined;
selectionPointType: PointType[] | undefined;
hoveredPlant: HoveredPlantPayl; hoveredPlant: HoveredPlantPayl;
hoveredPoint: string | undefined; hoveredPoint: string | undefined;
hoveredPlantListItem: string | undefined; hoveredPlantListItem: string | undefined;
@ -115,7 +120,8 @@ export interface DesignerState {
cropSearchResults: CropLiveSearchResult[]; cropSearchResults: CropLiveSearchResult[];
cropSearchInProgress: boolean; cropSearchInProgress: boolean;
chosenLocation: BotPosition; chosenLocation: BotPosition;
currentPoint: CurrentPointPayl | undefined; drawnPoint: DrawnPointPayl | undefined;
drawnWeed: DrawnWeedPayl | undefined;
openedSavedGarden: string | undefined; openedSavedGarden: string | undefined;
tryGroupSortType: PointGroupSortType | "nn" | undefined; tryGroupSortType: PointGroupSortType | "nn" | undefined;
editGroupAreaInMap: boolean; editGroupAreaInMap: boolean;
@ -181,6 +187,7 @@ export interface FarmEventState {
export interface GardenMapProps { export interface GardenMapProps {
showPlants: boolean | undefined; showPlants: boolean | undefined;
showPoints: boolean | undefined; showPoints: boolean | undefined;
showWeeds: boolean | undefined;
showSpread: boolean | undefined; showSpread: boolean | undefined;
showFarmbot: boolean | undefined; showFarmbot: boolean | undefined;
showImages: boolean | undefined; showImages: boolean | undefined;
@ -189,6 +196,7 @@ export interface GardenMapProps {
dispatch: Function; dispatch: Function;
designer: DesignerState; designer: DesignerState;
genericPoints: TaggedGenericPointer[]; genericPoints: TaggedGenericPointer[];
weeds: TaggedWeedPointer[];
allPoints: TaggedPoint[]; allPoints: TaggedPoint[];
plants: TaggedPlant[]; plants: TaggedPlant[];
toolSlots: SlotWithTool[]; toolSlots: SlotWithTool[];
@ -279,7 +287,15 @@ export interface CameraCalibrationData {
calibrationZ: string | undefined; calibrationZ: string | undefined;
} }
export interface CurrentPointPayl { export interface DrawnPointPayl {
name?: string;
cx: number;
cy: number;
r: number;
color?: string;
}
export interface DrawnWeedPayl {
name?: string; name?: string;
cx: number; cx: number;
cy: number; cy: number;

View File

@ -16,8 +16,9 @@ jest.mock("../../point_groups/group_detail", () => ({
})); }));
import { import {
movePlant, closePlantInfo, setDragIcon, clickMapPlant, selectPlant, movePlant, closePlantInfo, setDragIcon, clickMapPlant, selectPoint,
setHoveredPlant, setHoveredPlant,
mapPointClickAction,
} from "../actions"; } from "../actions";
import { MovePlantProps } from "../../interfaces"; import { MovePlantProps } from "../../interfaces";
import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { fakePlant } from "../../../__test_support__/fake_state/resources";
@ -74,7 +75,7 @@ describe("closePlantInfo()", () => {
closePlantInfo(dispatch)(); closePlantInfo(dispatch)();
expect(history.push).toHaveBeenCalledWith("/app/designer/plants"); expect(history.push).toHaveBeenCalledWith("/app/designer/plants");
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
payload: undefined, type: Actions.SELECT_PLANT payload: undefined, type: Actions.SELECT_POINT
}); });
}); });
@ -84,7 +85,7 @@ describe("closePlantInfo()", () => {
closePlantInfo(dispatch)(); closePlantInfo(dispatch)();
expect(history.push).toHaveBeenCalledWith("/app/designer/plants"); expect(history.push).toHaveBeenCalledWith("/app/designer/plants");
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
payload: undefined, type: Actions.SELECT_PLANT payload: undefined, type: Actions.SELECT_POINT
}); });
}); });
}); });
@ -115,7 +116,7 @@ describe("clickMapPlant", () => {
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState: GetState = jest.fn(() => state); const getState: GetState = jest.fn(() => state);
clickMapPlant("fakeUuid", "fakeIcon")(dispatch, getState); clickMapPlant("fakeUuid", "fakeIcon")(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith(selectPlant(["fakeUuid"])); expect(dispatch).toHaveBeenCalledWith(selectPoint(["fakeUuid"]));
expect(dispatch).toHaveBeenCalledWith(setHoveredPlant("fakeUuid", "fakeIcon")); expect(dispatch).toHaveBeenCalledWith(setHoveredPlant("fakeUuid", "fakeIcon"));
expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledTimes(2);
}); });
@ -136,6 +137,18 @@ describe("clickMapPlant", () => {
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1);
}); });
it("doesn't add a point to current group", () => {
mockPath = "/app/designer/groups/1";
mockGroup.body.point_ids = [1];
const state = fakeState();
state.resources = buildResourceIndex([]);
const dispatch = jest.fn();
const getState: GetState = jest.fn(() => state);
clickMapPlant("missing plant uuid", "fakeIcon")(dispatch, getState);
expect(overwrite).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("removes a point from the current group if group editor is active", () => { it("removes a point from the current group if group editor is active", () => {
mockPath = "/app/designer/groups/1"; mockPath = "/app/designer/groups/1";
mockGroup.body.point_ids = [1, 2]; mockGroup.body.point_ids = [1, 2];
@ -162,7 +175,7 @@ describe("clickMapPlant", () => {
const getState: GetState = jest.fn(() => state); const getState: GetState = jest.fn(() => state);
clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState); clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, payload: [plant.uuid] type: Actions.SELECT_POINT, payload: [plant.uuid]
}); });
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1);
}); });
@ -173,13 +186,39 @@ describe("clickMapPlant", () => {
const plant = fakePlant(); const plant = fakePlant();
plant.uuid = "fakePlantUuid"; plant.uuid = "fakePlantUuid";
state.resources = buildResourceIndex([plant]); state.resources = buildResourceIndex([plant]);
state.resources.consumers.farm_designer.selectedPlants = [plant.uuid]; state.resources.consumers.farm_designer.selectedPoints = [plant.uuid];
const dispatch = jest.fn(); const dispatch = jest.fn();
const getState: GetState = jest.fn(() => state); const getState: GetState = jest.fn(() => state);
clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState); clickMapPlant(plant.uuid, "fakeIcon")(dispatch, getState);
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, payload: [] type: Actions.SELECT_POINT, payload: []
}); });
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(1);
}); });
}); });
describe("mapPointClickAction()", () => {
it("navigates", () => {
mockPath = "/app/designer/plants";
const dispatch = jest.fn();
mapPointClickAction(dispatch, "uuid", "fake path")();
expect(history.push).toHaveBeenCalledWith("fake path");
expect(dispatch).not.toHaveBeenCalled();
});
it("doesn't navigate: box select", () => {
mockPath = "/app/designer/plants/select";
const dispatch = jest.fn();
mapPointClickAction(dispatch, "uuid", "fake path")();
expect(history.push).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalled();
});
it("doesn't navigate: group edit", () => {
mockPath = "/app/designer/groups/edit/1";
const dispatch = jest.fn();
mapPointClickAction(dispatch, "uuid", "fake path")();
expect(history.push).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalled();
});
});

View File

@ -6,15 +6,17 @@ jest.mock("../actions", () => ({
import { Mode } from "../interfaces"; import { Mode } from "../interfaces";
let mockMode = Mode.none; let mockMode = Mode.none;
let mockAtPlant = true; let mockAtPlant = true;
let mockInteractionAllow = true;
jest.mock("../util", () => ({ jest.mock("../util", () => ({
getMode: () => mockMode, getMode: () => mockMode,
getMapSize: () => ({ h: 100, w: 100 }), getMapSize: () => ({ h: 100, w: 100 }),
getGardenCoordinates: jest.fn(), getGardenCoordinates: jest.fn(),
transformXY: jest.fn(() => ({ qx: 0, qy: 0 })), transformXY: jest.fn(() => ({ qx: 0, qy: 0 })),
transformForQuadrant: jest.fn(), transformForQuadrant: jest.fn(),
maybeNoPointer: jest.fn(),
round: jest.fn(), round: jest.fn(),
cursorAtPlant: () => mockAtPlant, cursorAtPlant: () => mockAtPlant,
allowInteraction: () => mockInteractionAllow,
allowGroupAreaInteraction: jest.fn(),
})); }));
jest.mock("../layers/plants/plant_actions", () => ({ jest.mock("../layers/plants/plant_actions", () => ({
@ -81,6 +83,7 @@ const DEFAULT_EVENT = { preventDefault: jest.fn(), pageX: NaN, pageY: NaN };
const fakeProps = (): GardenMapProps => ({ const fakeProps = (): GardenMapProps => ({
showPoints: true, showPoints: true,
showPlants: true, showPlants: true,
showWeeds: true,
showSpread: false, showSpread: false,
showFarmbot: false, showFarmbot: false,
showImages: false, showImages: false,
@ -92,6 +95,7 @@ const fakeProps = (): GardenMapProps => ({
designer: fakeDesignerState(), designer: fakeDesignerState(),
plants: [], plants: [],
genericPoints: [], genericPoints: [],
weeds: [],
allPoints: [], allPoints: [],
toolSlots: [], toolSlots: [],
botLocationData: { botLocationData: {
@ -286,7 +290,22 @@ describe("<GardenMap/>", () => {
wrapper.find(".drop-area-svg").simulate("mouseDown", { wrapper.find(".drop-area-svg").simulate("mouseDown", {
pageX: 1, pageY: 2 pageX: 1, pageY: 2
}); });
expect(startNewPoint).toHaveBeenCalled(); expect(startNewPoint).toHaveBeenCalledWith(expect.objectContaining({
type: "point"
}));
expect(getGardenCoordinates).toHaveBeenCalledWith(
expect.objectContaining({ pageX: 1, pageY: 2 }));
});
it("starts drawing weed", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
mockMode = Mode.createWeed;
wrapper.find(".drop-area-svg").simulate("mouseDown", {
pageX: 1, pageY: 2
});
expect(startNewPoint).toHaveBeenCalledWith(expect.objectContaining({
type: "weed"
}));
expect(getGardenCoordinates).toHaveBeenCalledWith( expect(getGardenCoordinates).toHaveBeenCalledWith(
expect.objectContaining({ pageX: 1, pageY: 2 })); expect.objectContaining({ pageX: 1, pageY: 2 }));
}); });
@ -297,7 +316,20 @@ describe("<GardenMap/>", () => {
wrapper.find(".drop-area-svg").simulate("mouseMove", { wrapper.find(".drop-area-svg").simulate("mouseMove", {
pageX: 10, pageY: 20 pageX: 10, pageY: 20
}); });
expect(resizePoint).toHaveBeenCalled(); expect(resizePoint).toHaveBeenCalledWith(expect.objectContaining({
type: "point"
}));
});
it("sets drawn weed radius", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
mockMode = Mode.createWeed;
wrapper.find(".drop-area-svg").simulate("mouseMove", {
pageX: 10, pageY: 20
});
expect(resizePoint).toHaveBeenCalledWith(expect.objectContaining({
type: "weed"
}));
}); });
it("lays eggs", () => { it("lays eggs", () => {
@ -350,7 +382,7 @@ describe("<GardenMap/>", () => {
it("closes panel", () => { it("closes panel", () => {
mockMode = Mode.boxSelect; mockMode = Mode.boxSelect;
const p = fakeProps(); const p = fakeProps();
p.designer.selectedPlants = undefined; p.designer.selectedPoints = undefined;
const wrapper = mount<GardenMap>(<GardenMap {...p} />); const wrapper = mount<GardenMap>(<GardenMap {...p} />);
wrapper.instance().closePanel()(); wrapper.instance().closePanel()();
expect(closePlantInfo).toHaveBeenCalled(); expect(closePlantInfo).toHaveBeenCalled();
@ -366,7 +398,7 @@ describe("<GardenMap/>", () => {
it("doesn't close panel: box select", () => { it("doesn't close panel: box select", () => {
mockMode = Mode.boxSelect; mockMode = Mode.boxSelect;
const p = fakeProps(); const p = fakeProps();
p.designer.selectedPlants = [fakePlant().uuid]; p.designer.selectedPoints = [fakePlant().uuid];
const wrapper = mount<GardenMap>(<GardenMap {...p} />); const wrapper = mount<GardenMap>(<GardenMap {...p} />);
wrapper.instance().closePanel()(); wrapper.instance().closePanel()();
expect(closePlantInfo).not.toHaveBeenCalled(); expect(closePlantInfo).not.toHaveBeenCalled();
@ -375,7 +407,7 @@ describe("<GardenMap/>", () => {
it("doesn't close panel: move mode", () => { it("doesn't close panel: move mode", () => {
mockMode = Mode.moveTo; mockMode = Mode.moveTo;
const p = fakeProps(); const p = fakeProps();
p.designer.selectedPlants = [fakePlant().uuid]; p.designer.selectedPoints = [fakePlant().uuid];
const wrapper = mount<GardenMap>(<GardenMap {...p} />); const wrapper = mount<GardenMap>(<GardenMap {...p} />);
wrapper.instance().closePanel()(); wrapper.instance().closePanel()();
expect(closePlantInfo).not.toHaveBeenCalled(); expect(closePlantInfo).not.toHaveBeenCalled();
@ -404,6 +436,46 @@ describe("<GardenMap/>", () => {
expect(wrapper.instance().state.isDragging).toBe(true); expect(wrapper.instance().state.isDragging).toBe(true);
}); });
it("allows interactions: default", () => {
mockMode = Mode.none;
mockInteractionAllow = true;
const p = fakeProps();
p.designer.selectionPointType = undefined;
const wrapper = mount<GardenMap>(<GardenMap {...p} />);
const allowed = wrapper.instance().interactions("Plant");
expect(allowed).toBeTruthy();
});
it("allows interactions: box select", () => {
mockMode = Mode.boxSelect;
mockInteractionAllow = true;
const p = fakeProps();
p.designer.selectionPointType = undefined;
const wrapper = mount<GardenMap>(<GardenMap {...p} />);
const allowed = wrapper.instance().interactions("Plant");
expect(allowed).toBeTruthy();
});
it("disallows interactions: default", () => {
mockMode = Mode.none;
mockInteractionAllow = false;
const p = fakeProps();
p.designer.selectionPointType = undefined;
const wrapper = mount<GardenMap>(<GardenMap {...p} />);
const allowed = wrapper.instance().interactions("Plant");
expect(allowed).toBeFalsy();
});
it("disallows interactions: box select", () => {
mockMode = Mode.boxSelect;
mockInteractionAllow = true;
const p = fakeProps();
p.designer.selectionPointType = ["Plant"];
const wrapper = mount<GardenMap>(<GardenMap {...p} />);
const allowed = wrapper.instance().interactions("Weed");
expect(allowed).toBeFalsy();
});
it("unswapped height and width", () => { it("unswapped height and width", () => {
const p = fakeProps(); const p = fakeProps();
p.getConfigValue = () => false; p.getConfigValue = () => false;

View File

@ -21,6 +21,8 @@ import {
mapPanelClassName, mapPanelClassName,
getMode, getMode,
cursorAtPlant, cursorAtPlant,
allowInteraction,
allowGroupAreaInteraction,
} from "../util"; } from "../util";
import { McuParams } from "farmbot"; import { McuParams } from "farmbot";
import { import {
@ -32,13 +34,37 @@ import {
} from "../../../__test_support__/map_transform_props"; } from "../../../__test_support__/map_transform_props";
import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { fakePlant } from "../../../__test_support__/fake_state/resources";
describe("Utils", () => { describe("round()", () => {
it("rounds a number", () => { it("rounds a number", () => {
expect(round(44)).toEqual(40); expect(round(44)).toEqual(40);
expect(round(98)).toEqual(100); expect(round(98)).toEqual(100);
}); });
}); });
describe("mapPanelClassName()", () => {
it("returns correct panel status: short panel", () => {
Object.defineProperty(window, "innerWidth", {
value: 400,
configurable: true
});
mockPath = "/app/designer/move_to";
expect(mapPanelClassName()).toEqual("short-panel");
mockPath = "/app/designer/plants/crop_search/mint/add";
expect(mapPanelClassName()).toEqual("short-panel");
});
it("returns correct panel status: panel open", () => {
Object.defineProperty(window, "innerWidth", {
value: 500,
configurable: true
});
mockPath = "/app/designer/move_to";
expect(mapPanelClassName()).toEqual("panel-open");
mockPath = "/app/designer/plants/crop_search/mint/add";
expect(mapPanelClassName()).toEqual("panel-open");
});
});
describe("translateScreenToGarden()", () => { describe("translateScreenToGarden()", () => {
it("translates screen coords to garden coords: zoomLvl = 1", () => { it("translates screen coords to garden coords: zoomLvl = 1", () => {
const result = translateScreenToGarden({ const result = translateScreenToGarden({
@ -344,6 +370,10 @@ describe("getMode()", () => {
expect(getMode()).toEqual(Mode.points); expect(getMode()).toEqual(Mode.points);
mockPath = "/app/designer/points/add"; mockPath = "/app/designer/points/add";
expect(getMode()).toEqual(Mode.createPoint); expect(getMode()).toEqual(Mode.createPoint);
mockPath = "/app/designer/weeds";
expect(getMode()).toEqual(Mode.weeds);
mockPath = "/app/designer/weeds/add";
expect(getMode()).toEqual(Mode.createWeed);
mockPath = "/app/designer/gardens"; mockPath = "/app/designer/gardens";
mockGardenOpen = true; mockGardenOpen = true;
expect(getMode()).toEqual(Mode.templateView); expect(getMode()).toEqual(Mode.templateView);
@ -396,27 +426,37 @@ describe("getGardenCoordinates()", () => {
}); });
}); });
describe("mapPanelClassName()", () => { describe("allowInteraction()", () => {
it("returns correct panel status: short panel", () => { it("allows interaction", () => {
Object.defineProperty(window, "innerWidth", { mockPath = "/app/designer/plants";
value: 400, expect(allowInteraction()).toBeTruthy();
configurable: true
});
mockPath = "/app/designer/move_to";
expect(mapPanelClassName()).toEqual("short-panel");
mockPath = "/app/designer/plants/crop_search/mint/add";
expect(mapPanelClassName()).toEqual("short-panel");
}); });
it("returns correct panel status: panel open", () => { it("disallows interaction", () => {
Object.defineProperty(window, "innerWidth", {
value: 500,
configurable: true
});
mockPath = "/app/designer/move_to";
expect(mapPanelClassName()).toEqual("panel-open");
mockPath = "/app/designer/plants/crop_search/mint/add"; mockPath = "/app/designer/plants/crop_search/mint/add";
expect(mapPanelClassName()).toEqual("panel-open"); expect(allowInteraction()).toBeFalsy();
mockPath = "/app/designer/move_to";
expect(allowInteraction()).toBeFalsy();
mockPath = "/app/designer/points/add";
expect(allowInteraction()).toBeFalsy();
mockPath = "/app/designer/weeds/add";
expect(allowInteraction()).toBeFalsy();
});
});
describe("allowGroupAreaInteraction()", () => {
it("allows interaction", () => {
mockPath = "/app/designer/plants";
expect(allowGroupAreaInteraction()).toBeTruthy();
});
it("disallows interaction", () => {
mockPath = "/app/designer/plants/select";
expect(allowGroupAreaInteraction()).toBeFalsy();
mockPath = "/app/designer/move_to";
expect(allowGroupAreaInteraction()).toBeFalsy();
mockPath = "/app/designer/groups/1";
expect(allowGroupAreaInteraction()).toBeFalsy();
}); });
}); });

View File

@ -23,8 +23,8 @@ export function movePlant(payload: MovePlantProps) {
return edit(tr, update); return edit(tr, update);
} }
export const selectPlant = (payload: string[] | undefined) => { export const selectPoint = (payload: string[] | undefined) => {
return { type: Actions.SELECT_PLANT, payload }; return { type: Actions.SELECT_POINT, payload };
}; };
export const setHoveredPlant = (plantUUID: string | undefined, icon = "") => ({ export const setHoveredPlant = (plantUUID: string | undefined, icon = "") => ({
@ -52,16 +52,16 @@ const addOrRemoveFromGroup =
}; };
const addOrRemoveFromSelection = const addOrRemoveFromSelection =
(clickedPlantUuid: UUID, selectedPlants: UUID[] | undefined) => { (clickedPointUuid: UUID, selectedPoints: UUID[] | undefined) => {
const nextSelected = const nextSelected =
(selectedPlants || []).filter(uuid => uuid !== clickedPlantUuid); (selectedPoints || []).filter(uuid => uuid !== clickedPointUuid);
if (!(selectedPlants?.includes(clickedPlantUuid))) { if (!(selectedPoints?.includes(clickedPointUuid))) {
nextSelected.push(clickedPlantUuid); nextSelected.push(clickedPointUuid);
} }
return selectPlant(nextSelected); return selectPoint(nextSelected);
}; };
export const clickMapPlant = (clickedPlantUuid: string, icon: string) => { export const clickMapPlant = (clickedPlantUuid: UUID, icon: string) => {
return (dispatch: Function, getState: GetState) => { return (dispatch: Function, getState: GetState) => {
switch (getMode()) { switch (getMode()) {
case Mode.editGroup: case Mode.editGroup:
@ -69,11 +69,11 @@ export const clickMapPlant = (clickedPlantUuid: string, icon: string) => {
dispatch(addOrRemoveFromGroup(clickedPlantUuid, resources.index)); dispatch(addOrRemoveFromGroup(clickedPlantUuid, resources.index));
break; break;
case Mode.boxSelect: case Mode.boxSelect:
const { selectedPlants } = getState().resources.consumers.farm_designer; const { selectedPoints } = getState().resources.consumers.farm_designer;
dispatch(addOrRemoveFromSelection(clickedPlantUuid, selectedPlants)); dispatch(addOrRemoveFromSelection(clickedPlantUuid, selectedPoints));
break; break;
default: default:
dispatch(selectPlant([clickedPlantUuid])); dispatch(selectPoint([clickedPlantUuid]));
dispatch(setHoveredPlant(clickedPlantUuid, icon)); dispatch(setHoveredPlant(clickedPlantUuid, icon));
break; break;
} }
@ -81,7 +81,7 @@ export const clickMapPlant = (clickedPlantUuid: string, icon: string) => {
}; };
export const unselectPlant = (dispatch: Function) => () => { export const unselectPlant = (dispatch: Function) => () => {
dispatch(selectPlant(undefined)); dispatch(selectPoint(undefined));
dispatch(setHoveredPlant(undefined)); dispatch(setHoveredPlant(undefined));
dispatch({ type: Actions.HOVER_PLANT_LIST_ITEM, payload: undefined }); dispatch({ type: Actions.HOVER_PLANT_LIST_ITEM, payload: undefined });
}; };
@ -104,3 +104,14 @@ export const setDragIcon =
e.dataTransfer.setDragImage e.dataTransfer.setDragImage
&& e.dataTransfer.setDragImage(dragImg, width / 2, height / 2); && e.dataTransfer.setDragImage(dragImg, width / 2, height / 2);
}; };
export const mapPointClickAction =
(dispatch: Function, uuid: UUID, path?: string) => () => {
switch (getMode()) {
case Mode.editGroup:
case Mode.boxSelect:
return dispatch(clickMapPlant(uuid, ""));
default:
return path && history.push(path);
}
};

View File

@ -55,6 +55,9 @@ describe("resizeBox", () => {
const fakeProps = (): ResizeSelectionBoxProps => ({ const fakeProps = (): ResizeSelectionBoxProps => ({
selectionBox: { x0: 0, y0: 0, x1: undefined, y1: undefined }, selectionBox: { x0: 0, y0: 0, x1: undefined, y1: undefined },
plants: [], plants: [],
allPoints: [],
selectionPointType: undefined,
getConfigValue: () => true,
gardenCoords: { x: 100, y: 200 }, gardenCoords: { x: 100, y: 200 },
setMapState: jest.fn(), setMapState: jest.fn(),
dispatch: jest.fn(), dispatch: jest.fn(),
@ -68,7 +71,7 @@ describe("resizeBox", () => {
selectionBox: { x0: 0, y0: 0, x1: 100, y1: 200 } selectionBox: { x0: 0, y0: 0, x1: 100, y1: 200 }
}); });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: undefined payload: undefined
}); });
}); });
@ -113,7 +116,7 @@ describe("resizeBox", () => {
selectionBox: { x0: 0, y0: 0, x1: 100, y1: 200 } selectionBox: { x0: 0, y0: 0, x1: 100, y1: 200 }
}); });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: [plant.uuid] payload: [plant.uuid]
}); });
expect(history.push).toHaveBeenCalledWith("/app/designer/plants/select"); expect(history.push).toHaveBeenCalledWith("/app/designer/plants/select");
@ -135,7 +138,7 @@ describe("startNewSelectionBox", () => {
selectionBox: { x0: 100, y0: 200, x1: undefined, y1: undefined } selectionBox: { x0: 100, y0: 200, x1: undefined, y1: undefined }
}); });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: undefined payload: undefined
}); });
}); });
@ -157,7 +160,7 @@ describe("startNewSelectionBox", () => {
startNewSelectionBox(p); startNewSelectionBox(p);
expect(p.setMapState).not.toHaveBeenCalled(); expect(p.setMapState).not.toHaveBeenCalled();
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: undefined payload: undefined
}); });
}); });

View File

@ -3,18 +3,20 @@ import { TaggedPlant, AxisNumberProperty, Mode } from "../interfaces";
import { SelectionBoxData } from "./selection_box"; import { SelectionBoxData } from "./selection_box";
import { GardenMapState } from "../../interfaces"; import { GardenMapState } from "../../interfaces";
import { history } from "../../../history"; import { history } from "../../../history";
import { selectPlant } from "../actions"; import { selectPoint } from "../actions";
import { getMode } from "../util"; import { getMode } from "../util";
import { editGtLtCriteria } from "../../point_groups/criteria"; import { editGtLtCriteria } from "../../point_groups/criteria";
import { TaggedPointGroup } from "farmbot"; import { TaggedPointGroup, TaggedPoint, PointType } from "farmbot";
import { ShouldDisplay, Feature } from "../../../devices/interfaces"; import { ShouldDisplay, Feature } from "../../../devices/interfaces";
import { overwrite } from "../../../api/crud"; import { overwrite } from "../../../api/crud";
import { unpackUUID } from "../../../util"; import { unpackUUID } from "../../../util";
import { UUID } from "../../../resources/interfaces"; import { UUID } from "../../../resources/interfaces";
import { getFilteredPoints } from "../../plants/select_plants";
import { GetWebAppConfigValue } from "../../../config_storage/actions";
/** Return all plants within the selection box. */ /** Return all plants within the selection box. */
export const getSelected = ( export const getSelected = (
plants: TaggedPlant[], plants: (TaggedPlant | TaggedPoint)[],
box: SelectionBoxData | undefined, box: SelectionBoxData | undefined,
): string[] | undefined => { ): string[] | undefined => {
const arraySelected = plants.filter(p => { const arraySelected = plants.filter(p => {
@ -35,6 +37,9 @@ export const getSelected = (
export interface ResizeSelectionBoxProps { export interface ResizeSelectionBoxProps {
selectionBox: SelectionBoxData | undefined; selectionBox: SelectionBoxData | undefined;
plants: TaggedPlant[]; plants: TaggedPlant[];
allPoints: TaggedPoint[];
selectionPointType: PointType[] | undefined;
getConfigValue: GetWebAppConfigValue;
gardenCoords: AxisNumberProperty | undefined; gardenCoords: AxisNumberProperty | undefined;
setMapState: (x: Partial<GardenMapState>) => void; setMapState: (x: Partial<GardenMapState>) => void;
dispatch: Function; dispatch: Function;
@ -54,11 +59,16 @@ export const resizeBox = (props: ResizeSelectionBoxProps) => {
props.setMapState({ selectionBox: newSelectionBox }); props.setMapState({ selectionBox: newSelectionBox });
if (props.plantActions) { if (props.plantActions) {
// Select all plants within the updated selection box // Select all plants within the updated selection box
const payload = getSelected(props.plants, newSelectionBox); const { plants, allPoints, selectionPointType, getConfigValue } = props;
const points =
getFilteredPoints({
plants, allPoints, selectionPointType, getConfigValue
});
const payload = getSelected(points, newSelectionBox);
if (payload && getMode() === Mode.none) { if (payload && getMode() === Mode.none) {
history.push("/app/designer/plants/select"); history.push("/app/designer/plants/select");
} }
props.dispatch(selectPlant(payload)); props.dispatch(selectPoint(payload));
} }
} }
} }
@ -84,7 +94,7 @@ export const startNewSelectionBox = (props: StartNewSelectionBoxProps) => {
} }
if (props.plantActions) { if (props.plantActions) {
// Clear the previous plant selection when starting a new selection box // Clear the previous plant selection when starting a new selection box
props.dispatch(selectPlant(undefined)); props.dispatch(selectPoint(undefined));
} }
}; };
@ -112,7 +122,7 @@ export const maybeUpdateGroup =
nextGroupBody.point_ids = uniq(nextGroupBody.point_ids); nextGroupBody.point_ids = uniq(nextGroupBody.point_ids);
if (!isEqual(props.group.body.point_ids, nextGroupBody.point_ids)) { if (!isEqual(props.group.body.point_ids, nextGroupBody.point_ids)) {
props.dispatch(overwrite(props.group, nextGroupBody)); props.dispatch(overwrite(props.group, nextGroupBody));
props.dispatch(selectPlant(undefined)); props.dispatch(selectPoint(undefined));
} }
} }
} }

View File

@ -1,11 +1,14 @@
import { startNewPoint, resizePoint } from "../drawn_point_actions"; import {
startNewPoint, resizePoint, StartNewPointProps, ResizePointProps,
} from "../drawn_point_actions";
import { Actions } from "../../../../constants"; import { Actions } from "../../../../constants";
describe("startNewPoint", () => { describe("startNewPoint", () => {
const fakeProps = () => ({ const fakeProps = (): StartNewPointProps => ({
gardenCoords: { x: 100, y: 200 }, gardenCoords: { x: 100, y: 200 },
dispatch: jest.fn(), dispatch: jest.fn(),
setMapState: jest.fn(), setMapState: jest.fn(),
type: "point",
}); });
it("starts point", () => { it("starts point", () => {
@ -13,15 +16,25 @@ describe("startNewPoint", () => {
startNewPoint(p); startNewPoint(p);
expect(p.setMapState).toHaveBeenCalledWith({ isDragging: true }); expect(p.setMapState).toHaveBeenCalledWith({ isDragging: true });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_CURRENT_POINT_DATA, type: Actions.SET_DRAWN_POINT_DATA,
payload: { cx: 100, cy: 200, r: 0 }
});
});
it("starts weed", () => {
const p = fakeProps();
p.type = "weed";
startNewPoint(p);
expect(p.setMapState).toHaveBeenCalledWith({ isDragging: true });
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_DRAWN_WEED_DATA,
payload: { cx: 100, cy: 200, r: 0 } payload: { cx: 100, cy: 200, r: 0 }
}); });
}); });
it("doesn't start point", () => { it("doesn't start point", () => {
const p = fakeProps(); const p = fakeProps();
// tslint:disable-next-line:no-any p.gardenCoords = undefined;
p.gardenCoords = undefined as any;
startNewPoint(p); startNewPoint(p);
expect(p.setMapState).toHaveBeenCalledWith({ isDragging: true }); expect(p.setMapState).toHaveBeenCalledWith({ isDragging: true });
expect(p.dispatch).not.toHaveBeenCalled(); expect(p.dispatch).not.toHaveBeenCalled();
@ -29,18 +42,29 @@ describe("startNewPoint", () => {
}); });
describe("resizePoint", () => { describe("resizePoint", () => {
const fakeProps = () => ({ const fakeProps = (): ResizePointProps => ({
gardenCoords: { x: 100, y: 200 }, gardenCoords: { x: 100, y: 200 },
currentPoint: { cx: 100, cy: 200, r: 0 }, drawnPoint: { cx: 100, cy: 200, r: 0 },
dispatch: jest.fn(), dispatch: jest.fn(),
isDragging: true, isDragging: true,
type: "point",
}); });
it("resizes point", () => { it("resizes point", () => {
const p = fakeProps(); const p = fakeProps();
resizePoint(p); resizePoint(p);
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_CURRENT_POINT_DATA, type: Actions.SET_DRAWN_POINT_DATA,
payload: { cx: 100, cy: 200, r: 0 }
});
});
it("resizes weed", () => {
const p = fakeProps();
p.type = "weed";
resizePoint(p);
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_DRAWN_WEED_DATA,
payload: { cx: 100, cy: 200, r: 0 } payload: { cx: 100, cy: 200, r: 0 }
}); });
}); });

View File

@ -12,13 +12,12 @@ describe("<DrawnPoint/>", () => {
cx: 10, cx: 10,
cy: 20, cy: 20,
r: 30, r: 30,
color: "red"
} }
}); });
it("renders point", () => { it("renders point", () => {
const wrapper = svgMount(<DrawnPoint {...fakeProps()} />); const wrapper = svgMount(<DrawnPoint {...fakeProps()} />);
expect(wrapper.find("g").props().stroke).toEqual("red"); expect(wrapper.find("g").props().stroke).toEqual("green");
expect(wrapper.find("circle").first().props()).toEqual({ expect(wrapper.find("circle").first().props()).toEqual({
id: "point-radius", strokeDasharray: "4 5", id: "point-radius", strokeDasharray: "4 5",
cx: 10, cy: 20, r: 30, cx: 10, cy: 20, r: 30,
@ -28,4 +27,11 @@ describe("<DrawnPoint/>", () => {
cx: 10, cy: 20, r: 2, cx: 10, cy: 20, r: 2,
}); });
}); });
it("renders point with chosen color", () => {
const p = fakeProps();
p.data = { cx: 0, cy: 0, r: 1, color: "red" };
const wrapper = svgMount(<DrawnPoint {...p} />);
expect(wrapper.find("g").props().stroke).toEqual("red");
});
}); });

View File

@ -0,0 +1,36 @@
import * as React from "react";
import { DrawnWeed, DrawnWeedProps } from "../drawn_weed";
import {
fakeMapTransformProps,
} from "../../../../__test_support__/map_transform_props";
import { svgMount } from "../../../../__test_support__/svg_mount";
describe("<DrawnWeed />", () => {
const fakeProps = (): DrawnWeedProps => ({
mapTransformProps: fakeMapTransformProps(),
data: {
cx: 10,
cy: 20,
r: 30,
}
});
it("renders weed", () => {
const wrapper = svgMount(<DrawnWeed {...fakeProps()} />);
const stop = wrapper.find("stop").first().props();
expect(stop.stopColor).toEqual("red");
expect(stop.stopOpacity).toEqual(0.25);
expect(wrapper.find("circle").first().props()).toEqual({
id: "weed-radius", cx: 10, cy: 20, r: 30, fill: "url(#DrawnWeedGradient)",
});
});
it("renders point with chosen color", () => {
const p = fakeProps();
p.data = { cx: 0, cy: 0, r: 1, color: "orange" };
const wrapper = svgMount(<DrawnWeed {...p} />);
const stop = wrapper.find("stop").first().props();
expect(stop.stopColor).toEqual("orange");
expect(stop.stopOpacity).toEqual(0.5);
});
});

View File

@ -1,11 +1,11 @@
import * as React from "react"; import * as React from "react";
import { MapTransformProps } from "../interfaces"; import { MapTransformProps } from "../interfaces";
import { transformXY } from "../util"; import { transformXY } from "../util";
import { CurrentPointPayl } from "../../interfaces"; import { DrawnPointPayl } from "../../interfaces";
export interface DrawnPointProps { export interface DrawnPointProps {
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
data: CurrentPointPayl | undefined; data: DrawnPointPayl | undefined;
} }
export function DrawnPoint(props: DrawnPointProps) { export function DrawnPoint(props: DrawnPointProps) {

View File

@ -1,37 +1,47 @@
import { Actions } from "../../../constants"; import { Actions } from "../../../constants";
import { AxisNumberProperty } from "../interfaces"; import { AxisNumberProperty } from "../interfaces";
import { CurrentPointPayl } from "../../interfaces"; import { DrawnPointPayl } from "../../interfaces";
export interface StartNewPointProps {
gardenCoords: AxisNumberProperty | undefined;
dispatch: Function;
setMapState: Function;
type: "point" | "weed";
}
/** Create a new point. */ /** Create a new point. */
export const startNewPoint = (props: { export const startNewPoint = (props: StartNewPointProps) => {
gardenCoords: AxisNumberProperty | undefined,
dispatch: Function,
setMapState: Function,
}) => {
props.setMapState({ isDragging: true }); props.setMapState({ isDragging: true });
const center = props.gardenCoords; const center = props.gardenCoords;
if (center) { if (center) {
// Set the center of a new point // Set the center of a new point
props.dispatch({ props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: props.type == "weed"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: { cx: center.x, cy: center.y, r: 0 } payload: { cx: center.x, cy: center.y, r: 0 }
}); });
} }
}; };
export interface ResizePointProps {
gardenCoords: AxisNumberProperty | undefined;
drawnPoint: DrawnPointPayl | undefined;
dispatch: Function;
isDragging: boolean | undefined;
type: "point" | "weed";
}
/** Resize a point. */ /** Resize a point. */
export const resizePoint = (props: { export const resizePoint = (props: ResizePointProps) => {
gardenCoords: AxisNumberProperty | undefined,
currentPoint: CurrentPointPayl | undefined,
dispatch: Function,
isDragging: boolean | undefined,
}) => {
const edge = props.gardenCoords; const edge = props.gardenCoords;
if (edge && props.currentPoint && !!props.isDragging) { if (edge && props.drawnPoint && !!props.isDragging) {
const { cx, cy } = props.currentPoint; const { cx, cy } = props.drawnPoint;
// Adjust the radius of the point being created // Adjust the radius of the point being created
props.dispatch({ props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: props.type == "weed"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: { payload: {
cx, cy, // Center was set by click, radius is adjusted by drag cx, cy, // Center was set by click, radius is adjusted by drag
r: Math.round(Math.sqrt( r: Math.round(Math.sqrt(

View File

@ -0,0 +1,34 @@
import * as React from "react";
import { MapTransformProps } from "../interfaces";
import { transformXY } from "../util";
import { DrawnWeedPayl } from "../../interfaces";
export interface DrawnWeedProps {
mapTransformProps: MapTransformProps;
data: DrawnWeedPayl | undefined;
}
export function DrawnWeed(props: DrawnWeedProps) {
const ID = "current-weed";
const { data, mapTransformProps } = props;
if (!data) { return <g id={ID} />; }
const { cx, cy, r } = data;
const color = data.color || "red";
const { qx, qy } = transformXY(cx, cy, mapTransformProps);
const stopOpacity = ["gray", "pink", "orange"].includes(color) ? 0.5 : 0.25;
return <g id={ID}>
<defs>
<radialGradient id={"DrawnWeedGradient"}>
<stop offset="90%" stopColor={color} stopOpacity={stopOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</radialGradient>
</defs>
<circle
id={"weed-radius"}
cx={qx}
cy={qy}
r={r}
fill={"url(#DrawnWeedGradient)"} />
</g>;
}

View File

@ -6,7 +6,7 @@ import {
} from "./interfaces"; } from "./interfaces";
import { GardenMapProps, GardenMapState } from "../interfaces"; import { GardenMapProps, GardenMapState } from "../interfaces";
import { import {
getMapSize, getGardenCoordinates, getMode, cursorAtPlant, getMapSize, getGardenCoordinates, getMode, cursorAtPlant, allowInteraction,
} from "./util"; } from "./util";
import { import {
Grid, MapBackground, Grid, MapBackground,
@ -17,6 +17,7 @@ import {
PlantLayer, PlantLayer,
SpreadLayer, SpreadLayer,
PointLayer, PointLayer,
WeedLayer,
ToolSlotLayer, ToolSlotLayer,
FarmBotLayer, FarmBotLayer,
ImageLayer, ImageLayer,
@ -34,9 +35,11 @@ import { NNPath } from "../point_groups/paths";
import { history } from "../../history"; import { history } from "../../history";
import { ZonesLayer } from "./layers/zones/zones_layer"; import { ZonesLayer } from "./layers/zones/zones_layer";
import { ErrorBoundary } from "../../error_boundary"; import { ErrorBoundary } from "../../error_boundary";
import { TaggedPoint, TaggedPointGroup } from "farmbot"; import { TaggedPoint, TaggedPointGroup, PointType } from "farmbot";
import { findGroupFromUrl } from "../point_groups/group_detail"; import { findGroupFromUrl } from "../point_groups/group_detail";
import { pointsSelectedByGroup } from "../point_groups/criteria"; import { pointsSelectedByGroup } from "../point_groups/criteria";
import { DrawnWeed } from "./drawn_point/drawn_weed";
import { UUID } from "../../resources/interfaces";
export class GardenMap extends export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> { React.Component<GardenMapProps, Partial<GardenMapState>> {
@ -81,6 +84,10 @@ export class GardenMap extends
pointsSelectedByGroup(this.group, this.props.allPoints) : []; pointsSelectedByGroup(this.group, this.props.allPoints) : [];
} }
get groupSelected(): UUID[] {
return this.pointsSelectedByGroup.map(point => point.uuid);
}
/** Save the current plant (if needed) and reset drag state. */ /** Save the current plant (if needed) and reset drag state. */
endDrag = () => { endDrag = () => {
maybeSavePlantLocation({ maybeSavePlantLocation({
@ -94,7 +101,7 @@ export class GardenMap extends
dispatch: this.props.dispatch, dispatch: this.props.dispatch,
shouldDisplay: this.props.shouldDisplay, shouldDisplay: this.props.shouldDisplay,
editGroupAreaInMap: this.props.designer.editGroupAreaInMap, editGroupAreaInMap: this.props.designer.editGroupAreaInMap,
boxSelected: this.props.designer.selectedPlants, boxSelected: this.props.designer.selectedPoints,
}); });
this.setState({ this.setState({
isDragging: false, qPageX: 0, qPageY: 0, isDragging: false, qPageX: 0, qPageY: 0,
@ -152,6 +159,15 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e), gardenCoords: this.getGardenCoordinates(e),
dispatch: this.props.dispatch, dispatch: this.props.dispatch,
setMapState: this.setMapState, setMapState: this.setMapState,
type: "point",
});
break;
case Mode.createWeed:
startNewPoint({
gardenCoords: this.getGardenCoordinates(e),
dispatch: this.props.dispatch,
setMapState: this.setMapState,
type: "weed",
}); });
break; break;
case Mode.clickToAdd: case Mode.clickToAdd:
@ -163,8 +179,8 @@ export class GardenMap extends
startDragOnBackground = (e: React.MouseEvent<SVGElement>): void => { startDragOnBackground = (e: React.MouseEvent<SVGElement>): void => {
switch (getMode()) { switch (getMode()) {
case Mode.moveTo: case Mode.moveTo:
break;
case Mode.createPoint: case Mode.createPoint:
case Mode.createWeed:
case Mode.clickToAdd: case Mode.clickToAdd:
case Mode.editPlant: case Mode.editPlant:
break; break;
@ -196,17 +212,26 @@ export class GardenMap extends
} }
} }
interactions = (pointerType: PointType): boolean => {
if (allowInteraction()) {
switch (getMode()) {
case Mode.boxSelect:
return (this.props.designer.selectionPointType || ["Plant"])
.includes(pointerType);
}
}
return allowInteraction();
};
/** Return the selected plant, mode-allowing. */ /** Return the selected plant, mode-allowing. */
getPlant = (): TaggedPlant | undefined => { getPlant = (): TaggedPlant | undefined => {
switch (getMode()) { return allowInteraction()
case Mode.boxSelect: ? this.props.selectedPlant
case Mode.moveTo: : undefined;
case Mode.points: }
case Mode.createPoint:
return undefined; // For modes without plant interaction get currentPoint(): UUID | undefined {
default: return this.props.designer.selectedPoints?.[0];
return this.props.selectedPlant;
}
} }
handleDragOver = (e: React.DragEvent<HTMLElement>) => { handleDragOver = (e: React.DragEvent<HTMLElement>) => {
@ -273,15 +298,28 @@ export class GardenMap extends
case Mode.createPoint: case Mode.createPoint:
resizePoint({ resizePoint({
gardenCoords: this.getGardenCoordinates(e), gardenCoords: this.getGardenCoordinates(e),
currentPoint: this.props.designer.currentPoint, drawnPoint: this.props.designer.drawnPoint,
dispatch: this.props.dispatch, dispatch: this.props.dispatch,
isDragging: this.state.isDragging, isDragging: this.state.isDragging,
type: "point",
});
break;
case Mode.createWeed:
resizePoint({
gardenCoords: this.getGardenCoordinates(e),
drawnPoint: this.props.designer.drawnWeed,
dispatch: this.props.dispatch,
isDragging: this.state.isDragging,
type: "weed",
}); });
break; break;
case Mode.editGroup: case Mode.editGroup:
resizeBox({ resizeBox({
selectionBox: this.state.selectionBox, selectionBox: this.state.selectionBox,
plants: this.props.plants, plants: this.props.plants,
allPoints: this.props.allPoints,
selectionPointType: this.props.designer.selectionPointType,
getConfigValue: this.props.getConfigValue,
gardenCoords: this.getGardenCoordinates(e), gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState, setMapState: this.setMapState,
dispatch: this.props.dispatch, dispatch: this.props.dispatch,
@ -293,6 +331,9 @@ export class GardenMap extends
resizeBox({ resizeBox({
selectionBox: this.state.selectionBox, selectionBox: this.state.selectionBox,
plants: this.props.plants, plants: this.props.plants,
allPoints: this.props.allPoints,
selectionPointType: this.props.designer.selectionPointType,
getConfigValue: this.props.getConfigValue,
gardenCoords: this.getGardenCoordinates(e), gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState, setMapState: this.setMapState,
dispatch: this.props.dispatch, dispatch: this.props.dispatch,
@ -308,7 +349,7 @@ export class GardenMap extends
case Mode.moveTo: case Mode.moveTo:
return () => { }; return () => { };
case Mode.boxSelect: case Mode.boxSelect:
return this.props.designer.selectedPlants return this.props.designer.selectedPoints
? () => { } ? () => { }
: closePlantInfo(this.props.dispatch); : closePlantInfo(this.props.dispatch);
default: default:
@ -362,6 +403,7 @@ export class GardenMap extends
botSize={this.props.botSize} botSize={this.props.botSize}
mapTransformProps={this.mapTransformProps} mapTransformProps={this.mapTransformProps}
groups={this.props.groups} groups={this.props.groups}
startDrag={this.startDragOnBackground}
currentGroup={this.group?.uuid} /> currentGroup={this.group?.uuid} />
SensorReadingsLayer = () => <SensorReadingsLayer SensorReadingsLayer = () => <SensorReadingsLayer
visible={!!this.props.showSensorReadings} visible={!!this.props.showSensorReadings}
@ -385,7 +427,20 @@ export class GardenMap extends
dispatch={this.props.dispatch} dispatch={this.props.dispatch}
hoveredPoint={this.props.designer.hoveredPoint} hoveredPoint={this.props.designer.hoveredPoint}
visible={!!this.props.showPoints} visible={!!this.props.showPoints}
interactions={this.interactions("GenericPointer")}
genericPoints={this.props.genericPoints} /> genericPoints={this.props.genericPoints} />
WeedLayer = () => <WeedLayer
mapTransformProps={this.mapTransformProps}
dispatch={this.props.dispatch}
hoveredPoint={this.props.designer.hoveredPoint}
visible={!!this.props.showWeeds}
spreadVisible={!!this.props.showSpread}
currentPoint={this.currentPoint}
boxSelected={this.props.designer.selectedPoints}
groupSelected={this.groupSelected}
interactions={this.interactions("Weed")}
weeds={this.props.weeds}
animate={this.animate} />
PlantLayer = () => <PlantLayer PlantLayer = () => <PlantLayer
mapTransformProps={this.mapTransformProps} mapTransformProps={this.mapTransformProps}
dispatch={this.props.dispatch} dispatch={this.props.dispatch}
@ -395,10 +450,11 @@ export class GardenMap extends
hoveredPlant={this.props.hoveredPlant} hoveredPlant={this.props.hoveredPlant}
dragging={!!this.state.isDragging} dragging={!!this.state.isDragging}
editing={this.isEditing} editing={this.isEditing}
boxSelected={this.props.designer.selectedPlants} boxSelected={this.props.designer.selectedPoints}
groupSelected={this.pointsSelectedByGroup.map(point => point.uuid)} groupSelected={this.groupSelected}
zoomLvl={this.props.zoomLvl} zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY} activeDragXY={this.state.activeDragXY}
interactions={this.interactions("Plant")}
animate={this.animate} /> animate={this.animate} />
ToolSlotLayer = () => <ToolSlotLayer ToolSlotLayer = () => <ToolSlotLayer
mapTransformProps={this.mapTransformProps} mapTransformProps={this.mapTransformProps}
@ -406,6 +462,7 @@ export class GardenMap extends
dispatch={this.props.dispatch} dispatch={this.props.dispatch}
hoveredToolSlot={this.props.designer.hoveredToolSlot} hoveredToolSlot={this.props.designer.hoveredToolSlot}
botPositionX={this.props.botLocationData.position.x} botPositionX={this.props.botLocationData.position.x}
interactions={this.interactions("ToolSlot")}
slots={this.props.toolSlots} /> slots={this.props.toolSlots} />
FarmBotLayer = () => <FarmBotLayer FarmBotLayer = () => <FarmBotLayer
mapTransformProps={this.mapTransformProps} mapTransformProps={this.mapTransformProps}
@ -443,8 +500,10 @@ export class GardenMap extends
chosenLocation={this.props.designer.chosenLocation} chosenLocation={this.props.designer.chosenLocation}
mapTransformProps={this.mapTransformProps} /> mapTransformProps={this.mapTransformProps} />
DrawnPoint = () => <DrawnPoint DrawnPoint = () => <DrawnPoint
data={this.props.designer.currentPoint} data={this.props.designer.drawnPoint}
key={"currentPoint"} mapTransformProps={this.mapTransformProps} />
DrawnWeed = () => <DrawnWeed
data={this.props.designer.drawnWeed}
mapTransformProps={this.mapTransformProps} /> mapTransformProps={this.mapTransformProps} />
GroupOrder = () => <GroupOrder GroupOrder = () => <GroupOrder
group={this.group} group={this.group}
@ -468,6 +527,7 @@ export class GardenMap extends
<this.SensorReadingsLayer /> <this.SensorReadingsLayer />
<this.SpreadLayer /> <this.SpreadLayer />
<this.PointLayer /> <this.PointLayer />
<this.WeedLayer />
<this.PlantLayer /> <this.PlantLayer />
<this.ToolSlotLayer /> <this.ToolSlotLayer />
<this.FarmBotLayer /> <this.FarmBotLayer />
@ -476,6 +536,7 @@ export class GardenMap extends
<this.SelectionBox /> <this.SelectionBox />
<this.TargetCoordinate /> <this.TargetCoordinate />
<this.DrawnPoint /> <this.DrawnPoint />
<this.DrawnWeed />
<this.GroupOrder /> <this.GroupOrder />
<this.NNPath /> <this.NNPath />
<this.Bugs /> <this.Bugs />

View File

@ -2,6 +2,7 @@ import {
TaggedPlantPointer, TaggedPlantPointer,
TaggedGenericPointer, TaggedGenericPointer,
TaggedPlantTemplate, TaggedPlantTemplate,
TaggedWeedPointer,
} from "farmbot"; } from "farmbot";
import { State, BotOriginQuadrant } from "../interfaces"; import { State, BotOriginQuadrant } from "../interfaces";
import { BotPosition, BotLocationData } from "../../devices/interfaces"; import { BotPosition, BotLocationData } from "../../devices/interfaces";
@ -22,9 +23,10 @@ export interface PlantLayerProps {
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
zoomLvl: number; zoomLvl: number;
activeDragXY: BotPosition | undefined; activeDragXY: BotPosition | undefined;
boxSelected: string[] | undefined; boxSelected: UUID[] | undefined;
groupSelected: UUID[]; groupSelected: UUID[];
animate: boolean; animate: boolean;
interactions: boolean;
} }
export interface GardenMapLegendProps { export interface GardenMapLegendProps {
@ -33,6 +35,7 @@ export interface GardenMapLegendProps {
legendMenuOpen: boolean; legendMenuOpen: boolean;
showPlants: boolean; showPlants: boolean;
showPoints: boolean; showPoints: boolean;
showWeeds: boolean;
showSpread: boolean; showSpread: boolean;
showFarmbot: boolean; showFarmbot: boolean;
showImages: boolean; showImages: boolean;
@ -80,6 +83,17 @@ export interface GardenPointProps {
dispatch: Function; dispatch: Function;
} }
export interface GardenWeedProps {
mapTransformProps: MapTransformProps;
weed: TaggedWeedPointer;
hovered: boolean;
current: boolean;
selected: boolean;
animate: boolean;
spreadVisible: boolean;
dispatch: Function;
}
interface DragHelpersBaseProps { interface DragHelpersBaseProps {
dragging: boolean; dragging: boolean;
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
@ -152,7 +166,9 @@ export enum Mode {
addPlant = "addPlant", addPlant = "addPlant",
moveTo = "moveTo", moveTo = "moveTo",
points = "points", points = "points",
weeds = "weeds",
createPoint = "createPoint", createPoint = "createPoint",
createWeed = "createWeed",
templateView = "templateView", templateView = "templateView",
editGroup = "editGroup", editGroup = "editGroup",
} }

View File

@ -1,6 +1,7 @@
export * from "./farmbot/farmbot_layer"; export * from "./farmbot/farmbot_layer";
export * from "./plants/plant_layer"; export * from "./plants/plant_layer";
export * from "./points/point_layer"; export * from "./points/point_layer";
export * from "./weeds/weed_layer";
export * from "./spread/spread_layer"; export * from "./spread/spread_layer";
export * from "./tool_slots/tool_slot_layer"; export * from "./tool_slots/tool_slot_layer";
export * from "./images/image_layer"; export * from "./images/image_layer";

View File

@ -1,6 +1,6 @@
let mockPath = "/app/designer/plants"; let mockPath = "/app/designer/plants";
jest.mock("../../../../../history", () => ({ jest.mock("../../../../../history", () => ({
getPathArray: jest.fn(() => { return mockPath.split("/"); }) getPathArray: jest.fn(() => mockPath.split("/"))
})); }));
import * as React from "react"; import * as React from "react";
@ -31,6 +31,7 @@ describe("<PlantLayer/>", () => {
activeDragXY: { x: undefined, y: undefined, z: undefined }, activeDragXY: { x: undefined, y: undefined, z: undefined },
animate: true, animate: true,
hoveredPlant: undefined, hoveredPlant: undefined,
interactions: true,
}); });
it("shows plants", () => { it("shows plants", () => {
@ -59,14 +60,19 @@ describe("<PlantLayer/>", () => {
it("is in clickable mode", () => { it("is in clickable mode", () => {
mockPath = "/app/designer/plants"; mockPath = "/app/designer/plants";
const p = fakeProps(); const p = fakeProps();
p.interactions = true;
p.plants[0].body.id = 1;
const wrapper = svgMount(<PlantLayer {...p} />); const wrapper = svgMount(<PlantLayer {...p} />);
expect(wrapper.find("Link").props().style).toEqual({}); expect(wrapper.find("Link").props().style).toEqual({
cursor: "pointer"
});
}); });
it("is in non-clickable mode", () => { it("is in non-clickable mode", () => {
mockPath = "/app/designer/plants/crop_search/mint/add"; mockPath = "/app/designer/plants/crop_search/mint/add";
const p = fakeProps(); const p = fakeProps();
p.interactions = false;
p.plants[0].body.id = 1;
const wrapper = svgMount(<PlantLayer {...p} />); const wrapper = svgMount(<PlantLayer {...p} />);
expect(wrapper.find("Link").props().style) expect(wrapper.find("Link").props().style)
.toEqual({ pointerEvents: "none" }); .toEqual({ pointerEvents: "none" });
@ -111,22 +117,12 @@ describe("<PlantLayer/>", () => {
expect(wrapper.find("GardenPlant").props().selected).toEqual(true); expect(wrapper.find("GardenPlant").props().selected).toEqual(true);
}); });
it("allows clicking of unsaved plants", () => {
const p = fakeProps();
const plant = fakePlant();
plant.body.id = 1;
p.plants = [plant];
const wrapper = svgMount(<PlantLayer {...p} />);
expect((wrapper.find("Link").props()).style).toEqual({});
});
it("doesn't allow clicking of unsaved plants", () => { it("doesn't allow clicking of unsaved plants", () => {
const p = fakeProps(); const p = fakeProps();
const plant = fakePlant(); p.interactions = false;
plant.body.id = 0; p.plants[0].body.id = 0;
p.plants = [plant];
const wrapper = svgMount(<PlantLayer {...p} />); const wrapper = svgMount(<PlantLayer {...p} />);
expect((wrapper.find("Link").props()).style) expect(wrapper.find("Link").props().style)
.toEqual({ pointerEvents: "none" }); .toEqual({ pointerEvents: "none" });
}); });

View File

@ -2,7 +2,7 @@ import * as React from "react";
import { GardenPlant } from "./garden_plant"; import { GardenPlant } from "./garden_plant";
import { PlantLayerProps, Mode } from "../../interfaces"; import { PlantLayerProps, Mode } from "../../interfaces";
import { unpackUUID } from "../../../../util"; import { unpackUUID } from "../../../../util";
import { maybeNoPointer, getMode } from "../../util"; import { getMode } from "../../util";
import { Link } from "../../../../link"; import { Link } from "../../../../link";
export function PlantLayer(props: PlantLayerProps) { export function PlantLayer(props: PlantLayerProps) {
@ -44,9 +44,12 @@ export function PlantLayer(props: PlantLayerProps) {
activeDragXY={activeDragXY} activeDragXY={activeDragXY}
hovered={hovered} hovered={hovered}
animate={animate} />; animate={animate} />;
const style: React.SVGProps<SVGGElement>["style"] =
(props.interactions && p.body.id)
? { cursor: "pointer" } : { pointerEvents: "none" };
const wrapperProps = { const wrapperProps = {
className: "plant-link-wrapper", className: "plant-link-wrapper",
style: maybeNoPointer(p.body.id ? {} : { pointerEvents: "none" }), style,
key: p.uuid, key: p.uuid,
}; };
return (getMode() === Mode.editGroup || getMode() === Mode.boxSelect) return (getMode() === Mode.editGroup || getMode() === Mode.boxSelect)

View File

@ -1,4 +1,7 @@
jest.mock("../../../../../history", () => ({ history: { push: jest.fn() } })); jest.mock("../../../../../history", () => ({
history: { push: jest.fn() },
getPathArray: jest.fn(),
}));
import * as React from "react"; import * as React from "react";
import { GardenPoint } from "../garden_point"; import { GardenPoint } from "../garden_point";
@ -55,10 +58,9 @@ describe("<GardenPoint/>", () => {
it("opens point info", () => { it("opens point info", () => {
const p = fakeProps(); const p = fakeProps();
p.point.body.name = "weed";
const wrapper = svgMount(<GardenPoint {...p} />); const wrapper = svgMount(<GardenPoint {...p} />);
wrapper.find("g").simulate("click"); wrapper.find("g").simulate("click");
expect(history.push).toHaveBeenCalledWith( expect(history.push).toHaveBeenCalledWith(
`/app/designer/weeds/${p.point.body.id}`); `/app/designer/points/${p.point.body.id}`);
}); });
}); });

View File

@ -19,10 +19,12 @@ describe("<PointLayer/>", () => {
mapTransformProps: fakeMapTransformProps(), mapTransformProps: fakeMapTransformProps(),
hoveredPoint: undefined, hoveredPoint: undefined,
dispatch: jest.fn(), dispatch: jest.fn(),
interactions: true,
}); });
it("shows points", () => { it("shows points", () => {
const p = fakeProps(); const p = fakeProps();
p.interactions = false;
const wrapper = svgMount(<PointLayer {...p} />); const wrapper = svgMount(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer"); const layer = wrapper.find("#point-layer");
expect(layer.find(GardenPoint).html()).toContain("r=\"100\""); expect(layer.find(GardenPoint).html()).toContain("r=\"100\"");
@ -40,6 +42,7 @@ describe("<PointLayer/>", () => {
it("allows point mode interaction", () => { it("allows point mode interaction", () => {
mockPath = "/app/designer/points"; mockPath = "/app/designer/points";
const p = fakeProps(); const p = fakeProps();
p.interactions = true;
const wrapper = svgMount(<PointLayer {...p} />); const wrapper = svgMount(<PointLayer {...p} />);
const layer = wrapper.find("#point-layer"); const layer = wrapper.find("#point-layer");
expect(layer.props().style).toEqual({}); expect(layer.props().style).toEqual({});

View File

@ -2,8 +2,7 @@ import * as React from "react";
import { GardenPointProps } from "../../interfaces"; import { GardenPointProps } from "../../interfaces";
import { transformXY } from "../../util"; import { transformXY } from "../../util";
import { Actions } from "../../../../constants"; import { Actions } from "../../../../constants";
import { history } from "../../../../history"; import { mapPointClickAction } from "../../actions";
import { isAWeed } from "../../../points/weeds_inventory";
export const GardenPoint = (props: GardenPointProps) => { export const GardenPoint = (props: GardenPointProps) => {
@ -19,11 +18,11 @@ export const GardenPoint = (props: GardenPointProps) => {
const { id, x, y, meta } = point.body; const { id, x, y, meta } = point.body;
const { qx, qy } = transformXY(x, y, mapTransformProps); const { qx, qy } = transformXY(x, y, mapTransformProps);
const color = meta.color || "green"; const color = meta.color || "green";
const panel = isAWeed(point.body.name, meta.type) ? "weeds" : "points"; return <g id={`point-${id}`} className={"map-point"} stroke={color}
return <g id={"point-" + id} className={"map-point"} stroke={color}
onMouseEnter={iconHover("start")} onMouseEnter={iconHover("start")}
onMouseLeave={iconHover("end")} onMouseLeave={iconHover("end")}
onClick={() => history.push(`/app/designer/${panel}/${id}`)}> onClick={mapPointClickAction(props.dispatch, point.uuid,
`/app/designer/points/${id}`)}>
<circle id="point-radius" cx={qx} cy={qy} r={point.body.radius} <circle id="point-radius" cx={qx} cy={qy} r={point.body.radius}
fill={hovered ? color : "transparent"} /> fill={hovered ? color : "transparent"} />
<circle id="point-center" cx={qx} cy={qy} r={2} /> <circle id="point-center" cx={qx} cy={qy} r={2} />

View File

@ -1,8 +1,7 @@
import * as React from "react"; import * as React from "react";
import { TaggedGenericPointer } from "farmbot"; import { TaggedGenericPointer } from "farmbot";
import { GardenPoint } from "./garden_point"; import { GardenPoint } from "./garden_point";
import { MapTransformProps, Mode } from "../../interfaces"; import { MapTransformProps } from "../../interfaces";
import { getMode } from "../../util";
export interface PointLayerProps { export interface PointLayerProps {
visible: boolean; visible: boolean;
@ -10,13 +9,14 @@ export interface PointLayerProps {
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
hoveredPoint: string | undefined; hoveredPoint: string | undefined;
dispatch: Function; dispatch: Function;
interactions: boolean;
} }
export function PointLayer(props: PointLayerProps) { export function PointLayer(props: PointLayerProps) {
const { visible, genericPoints, mapTransformProps, hoveredPoint } = props; const { visible, genericPoints, mapTransformProps, hoveredPoint } = props;
const style: React.CSSProperties = const style: React.CSSProperties =
getMode() === Mode.points ? {} : { pointerEvents: "none" }; props.interactions ? {} : { pointerEvents: "none" };
return <g id="point-layer" style={style}> return <g id={"point-layer"} style={style}>
{visible && {visible &&
genericPoints.map(p => genericPoints.map(p =>
<GardenPoint <GardenPoint

View File

@ -38,6 +38,7 @@ describe("<ToolSlotLayer/>", () => {
mapTransformProps: fakeMapTransformProps(), mapTransformProps: fakeMapTransformProps(),
dispatch: jest.fn(), dispatch: jest.fn(),
hoveredToolSlot: undefined, hoveredToolSlot: undefined,
interactions: true,
}; };
} }
it("toggles visibility off", () => { it("toggles visibility off", () => {
@ -61,9 +62,19 @@ describe("<ToolSlotLayer/>", () => {
expect(history.push).not.toHaveBeenCalled(); expect(history.push).not.toHaveBeenCalled();
}); });
it("is in clickable mode", () => {
mockPath = "/app/designer/plants/crop_search/mint/add";
const p = fakeProps();
p.interactions = true;
const wrapper = shallow(<ToolSlotLayer {...p} />);
expect(wrapper.find("g").props().style)
.toEqual({ cursor: "pointer" });
});
it("is in non-clickable mode", () => { it("is in non-clickable mode", () => {
mockPath = "/app/designer/plants/crop_search/mint/add"; mockPath = "/app/designer/plants/crop_search/mint/add";
const p = fakeProps(); const p = fakeProps();
p.interactions = false;
const wrapper = shallow(<ToolSlotLayer {...p} />); const wrapper = shallow(<ToolSlotLayer {...p} />);
expect(wrapper.find("g").props().style) expect(wrapper.find("g").props().style)
.toEqual({ pointerEvents: "none" }); .toEqual({ pointerEvents: "none" });

View File

@ -1,4 +1,7 @@
jest.mock("../../../../../history", () => ({ history: { push: jest.fn() } })); jest.mock("../../../../../history", () => ({
history: { push: jest.fn() },
getPathArray: jest.fn(),
}));
import * as React from "react"; import * as React from "react";
import { ToolSlotPoint, TSPProps } from "../tool_slot_point"; import { ToolSlotPoint, TSPProps } from "../tool_slot_point";

View File

@ -2,7 +2,6 @@ import * as React from "react";
import { SlotWithTool, UUID } from "../../../../resources/interfaces"; import { SlotWithTool, UUID } from "../../../../resources/interfaces";
import { ToolSlotPoint } from "./tool_slot_point"; import { ToolSlotPoint } from "./tool_slot_point";
import { MapTransformProps } from "../../interfaces"; import { MapTransformProps } from "../../interfaces";
import { maybeNoPointer } from "../../util";
export interface ToolSlotLayerProps { export interface ToolSlotLayerProps {
visible: boolean; visible: boolean;
@ -11,6 +10,7 @@ export interface ToolSlotLayerProps {
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
dispatch: Function; dispatch: Function;
hoveredToolSlot: UUID | undefined; hoveredToolSlot: UUID | undefined;
interactions: boolean;
} }
export function ToolSlotLayer(props: ToolSlotLayerProps) { export function ToolSlotLayer(props: ToolSlotLayerProps) {
@ -18,7 +18,9 @@ export function ToolSlotLayer(props: ToolSlotLayerProps) {
return <g return <g
id="toolslot-layer" id="toolslot-layer"
style={maybeNoPointer({ cursor: "pointer" })}> style={props.interactions
? { cursor: "pointer" }
: { pointerEvents: "none" }}>
{visible && {visible &&
slots.map(slot => slots.map(slot =>
<ToolSlotPoint <ToolSlotPoint

View File

@ -5,8 +5,8 @@ import { MapTransformProps } from "../../interfaces";
import { ToolbaySlot, ToolNames, Tool, GantryToolSlot } from "./tool_graphics"; import { ToolbaySlot, ToolNames, Tool, GantryToolSlot } from "./tool_graphics";
import { ToolLabel } from "./tool_label"; import { ToolLabel } from "./tool_label";
import { includes } from "lodash"; import { includes } from "lodash";
import { history } from "../../../../history";
import { t } from "../../../../i18next_wrapper"; import { t } from "../../../../i18next_wrapper";
import { mapPointClickAction } from "../../actions";
export interface TSPProps { export interface TSPProps {
slot: SlotWithTool; slot: SlotWithTool;
@ -30,25 +30,27 @@ export const reduceToolName = (raw: string | undefined) => {
}; };
export const ToolSlotPoint = (props: TSPProps) => { export const ToolSlotPoint = (props: TSPProps) => {
const { tool, toolSlot } = props.slot;
const { const {
id, x, y, pullout_direction, gantry_mounted id, x, y, pullout_direction, gantry_mounted
} = props.slot.toolSlot.body; } = toolSlot.body;
const { mapTransformProps, botPositionX } = props; const { mapTransformProps, botPositionX } = props;
const { quadrant, xySwap } = mapTransformProps; const { quadrant, xySwap } = mapTransformProps;
const xPosition = gantry_mounted ? (botPositionX || 0) : x; const xPosition = gantry_mounted ? (botPositionX || 0) : x;
const { qx, qy } = transformXY(xPosition, y, props.mapTransformProps); const { qx, qy } = transformXY(xPosition, y, props.mapTransformProps);
const toolName = props.slot.tool ? props.slot.tool.body.name : t("Empty"); const toolName = tool ? tool.body.name : t("Empty");
const hovered = props.slot.toolSlot.uuid === props.hoveredToolSlot; const hovered = toolSlot.uuid === props.hoveredToolSlot;
const toolProps = { const toolProps = {
x: qx, x: qx,
y: qy, y: qy,
hovered, hovered,
dispatch: props.dispatch, dispatch: props.dispatch,
uuid: props.slot.toolSlot.uuid, uuid: toolSlot.uuid,
xySwap, xySwap,
}; };
return <g id={"toolslot-" + id} return <g id={"toolslot-" + id}
onClick={() => history.push(`/app/designer/tool-slots/${id}`)}> onClick={mapPointClickAction(props.dispatch, toolSlot.uuid,
`/app/designer/tool-slots/${id}`)}>
{pullout_direction && !gantry_mounted && {pullout_direction && !gantry_mounted &&
<ToolbaySlot <ToolbaySlot
id={id} id={id}

View File

@ -0,0 +1,89 @@
jest.mock("../../../../../history", () => ({
history: { push: jest.fn() },
getPathArray: jest.fn(),
}));
import * as React from "react";
import { GardenWeed } from "../garden_weed";
import { GardenWeedProps } from "../../../interfaces";
import { fakeWeed } from "../../../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { Actions } from "../../../../../constants";
import { history } from "../../../../../history";
import { svgMount } from "../../../../../__test_support__/svg_mount";
describe("<GardenWeed />", () => {
const fakeProps = (): GardenWeedProps => ({
mapTransformProps: fakeMapTransformProps(),
weed: fakeWeed(),
hovered: false,
dispatch: jest.fn(),
current: false,
selected: false,
animate: false,
spreadVisible: true,
});
it("renders weed", () => {
const p = fakeProps();
p.weed.body.meta.color = undefined;
const wrapper = svgMount(<GardenWeed {...p} />);
expect(wrapper.find("#weed-radius").props().r).toEqual(100);
expect(wrapper.find("#weed-radius").props().opacity).toEqual(0.5);
expect(wrapper.find("stop").first().props().stopColor).toEqual("red");
});
it("renders weed color", () => {
const p = fakeProps();
p.weed.body.meta.color = "orange";
const wrapper = svgMount(<GardenWeed {...p} />);
expect(wrapper.find("#weed-radius").props().r).toEqual(100);
expect(wrapper.find("#weed-radius").props().opacity).toEqual(0.5);
expect(wrapper.find("stop").first().props().stopColor).toEqual("orange");
});
it("animates", () => {
const p = fakeProps();
p.animate = true;
const wrapper = svgMount(<GardenWeed {...p} />);
expect(wrapper.find(".soil-cloud").length).toEqual(1);
expect(wrapper.find("image").hasClass("animate")).toBeTruthy();
});
it("hovers weed", () => {
const p = fakeProps();
const wrapper = svgMount(<GardenWeed {...p} />);
wrapper.find("g").first().simulate("mouseEnter");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: p.weed.uuid
});
});
it("is hovered", () => {
const p = fakeProps();
p.hovered = true;
const wrapper = svgMount(<GardenWeed {...p} />);
expect(wrapper.find("#weed-radius").props().opacity).toEqual(1);
});
it("un-hovers weed", () => {
const p = fakeProps();
const wrapper = svgMount(<GardenWeed {...p} />);
wrapper.find("g").first().simulate("mouseLeave");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: undefined
});
});
it("opens weed info", () => {
const p = fakeProps();
const wrapper = svgMount(<GardenWeed {...p} />);
wrapper.find("g").first().simulate("click");
expect(history.push).toHaveBeenCalledWith(
`/app/designer/weeds/${p.weed.body.id}`);
});
});

View File

@ -0,0 +1,66 @@
let mockPath = "/app/designer/plants";
jest.mock("../../../../../history", () => ({
getPathArray: jest.fn(() => mockPath.split("/")),
}));
import * as React from "react";
import { WeedLayer, WeedLayerProps } from "../weed_layer";
import { fakeWeed } from "../../../../../__test_support__/fake_state/resources";
import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { GardenWeed } from "../garden_weed";
import { svgMount } from "../../../../../__test_support__/svg_mount";
describe("<WeedLayer/>", () => {
const fakeProps = (): WeedLayerProps => ({
visible: true,
spreadVisible: true,
weeds: [fakeWeed()],
mapTransformProps: fakeMapTransformProps(),
hoveredPoint: undefined,
dispatch: jest.fn(),
currentPoint: undefined,
boxSelected: undefined,
groupSelected: [],
animate: false,
interactions: true,
});
it("shows weeds", () => {
const p = fakeProps();
p.interactions = false;
const wrapper = svgMount(<WeedLayer {...p} />);
const layer = wrapper.find("#weeds-layer");
expect(layer.find(GardenWeed).html()).toContain("r=\"100\"");
expect(layer.props().style).toEqual({ pointerEvents: "none" });
});
it("toggles visibility off", () => {
const p = fakeProps();
p.visible = false;
const wrapper = svgMount(<WeedLayer {...p} />);
const layer = wrapper.find("#weeds-layer");
expect(layer.find(GardenWeed).length).toEqual(0);
});
it("allows weed mode interaction", () => {
mockPath = "/app/designer/weeds";
const p = fakeProps();
p.interactions = true;
const wrapper = svgMount(<WeedLayer {...p} />);
const layer = wrapper.find("#weeds-layer");
expect(layer.props().style).toEqual({ cursor: "pointer" });
});
it("is selected", () => {
mockPath = "/app/designer/weeds";
const p = fakeProps();
const weed = fakeWeed();
p.weeds = [weed];
p.boxSelected = [weed.uuid];
const wrapper = svgMount(<WeedLayer {...p} />);
const layer = wrapper.find("#weeds-layer");
expect(layer.find(GardenWeed).props().selected).toBeTruthy();
});
});

View File

@ -0,0 +1,69 @@
import * as React from "react";
import { GardenWeedProps } from "../../interfaces";
import { transformXY } from "../../util";
import { Actions } from "../../../../constants";
import { Color } from "../../../../ui";
import { mapPointClickAction } from "../../actions";
export const DEFAULT_WEED_ICON = "/app-resources/img/generic-weed.svg";
export const GardenWeed = (props: GardenWeedProps) => {
const iconHover = (action: "start" | "end") => () => {
const hover = action === "start";
props.dispatch({
type: Actions.TOGGLE_HOVERED_POINT,
payload: hover ? props.weed.uuid : undefined
});
};
const { weed, mapTransformProps, hovered, current, selected, animate } = props;
const { id, x, y, meta, radius } = weed.body;
const { qx, qy } = transformXY(x, y, mapTransformProps);
const color = meta.color || "red";
const stopOpacity = ["gray", "pink", "orange"].includes(color) ? 0.5 : 0.25;
const className = [
"weed-image", `is-chosen-${current || selected}`, animate ? "animate" : "",
].join(" ");
const iconRadius = hovered ? radius * 0.88 : radius * 0.8;
return <g id={`weed-${id}`} className={`map-weed ${color}`}
onMouseEnter={iconHover("start")}
onMouseLeave={iconHover("end")}
onClick={mapPointClickAction(props.dispatch, weed.uuid,
`/app/designer/weeds/${id}`)}>
<defs>
<radialGradient id={`Weed${id}Gradient`}>
<stop offset="90%" stopColor={color} stopOpacity={stopOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</radialGradient>
</defs>
{animate &&
<circle
className="soil-cloud"
cx={qx}
cy={qy}
r={radius}
fill={Color.soilCloud}
fillOpacity={0} />}
{props.spreadVisible &&
<circle
id={"weed-radius"}
cx={qx}
cy={qy}
r={radius}
fill={`url(#Weed${id}Gradient)`}
opacity={hovered ? 1 : 0.5} />}
<g id="weed-icon">
<image
className={className}
xlinkHref={DEFAULT_WEED_ICON}
height={iconRadius * 2}
width={iconRadius * 2}
x={qx - iconRadius}
y={qy - iconRadius} />
</g>
</g>;
};

View File

@ -0,0 +1,43 @@
import * as React from "react";
import { TaggedWeedPointer } from "farmbot";
import { GardenWeed } from "./garden_weed";
import { MapTransformProps } from "../../interfaces";
import { UUID } from "../../../../resources/interfaces";
export interface WeedLayerProps {
visible: boolean;
spreadVisible: boolean;
weeds: TaggedWeedPointer[];
mapTransformProps: MapTransformProps;
hoveredPoint: UUID | undefined;
currentPoint: UUID | undefined;
boxSelected: UUID[] | undefined;
groupSelected: UUID[];
dispatch: Function;
animate: boolean;
interactions: boolean;
}
export function WeedLayer(props: WeedLayerProps) {
const { visible, weeds, mapTransformProps } = props;
return <g id={"weeds-layer"} style={props.interactions
? { cursor: "pointer" } : { pointerEvents: "none" }}>
{visible &&
weeds.map(p => {
const current = p.uuid === props.currentPoint;
const hovered = p.uuid === props.hoveredPoint;
const selectedByBox = !!props.boxSelected?.includes(p.uuid);
const selectedByGroup = props.groupSelected.includes(p.uuid);
return <GardenWeed
weed={p}
key={p.uuid}
hovered={hovered}
current={current}
selected={selectedByBox || selectedByGroup}
animate={props.animate}
spreadVisible={props.spreadVisible}
dispatch={props.dispatch}
mapTransformProps={mapTransformProps} />;
})}
</g>;
}

View File

@ -18,6 +18,7 @@ describe("<ZonesLayer />", () => {
y: { value: 1500, isDefault: true } y: { value: 1500, isDefault: true }
}, },
mapTransformProps: fakeMapTransformProps(), mapTransformProps: fakeMapTransformProps(),
startDrag: jest.fn(),
}); });
it("renders", () => { it("renders", () => {
@ -70,15 +71,15 @@ describe("<ZonesLayer />", () => {
p.groups[0].body.id = 1; p.groups[0].body.id = 1;
p.currentGroup = p.groups[0].uuid; p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />); const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html()) expect(wrapper.html()).toEqual(
.toEqual("<svg><g class=\"zones-layer\"></g></svg>"); "<svg><g class=\"zones-layer\" style=\"cursor: pointer;\"></g></svg>");
}); });
it("doesn't render current group's zones", () => { it("doesn't render current group's zones", () => {
const p = fakeProps(); const p = fakeProps();
p.visible = false; p.visible = false;
const wrapper = svgMount(<ZonesLayer {...p} />); const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html()) expect(wrapper.html()).toEqual(
.toEqual("<svg><g class=\"zones-layer\"></g></svg>"); "<svg><g class=\"zones-layer\" style=\"cursor: pointer;\"></g></svg>");
}); });
}); });

View File

@ -4,6 +4,7 @@ import { MapTransformProps, BotSize } from "../../interfaces";
import { transformXY } from "../../util"; import { transformXY } from "../../util";
import { isUndefined } from "lodash"; import { isUndefined } from "lodash";
import { UUID } from "../../../../resources/interfaces"; import { UUID } from "../../../../resources/interfaces";
import { history } from "../../../../history";
export interface ZonesProps { export interface ZonesProps {
currentGroup: UUID | undefined; currentGroup: UUID | undefined;
@ -43,6 +44,9 @@ export const getZoneType = (group: TaggedPointGroup): ZoneType => {
return ZoneType.none; return ZoneType.none;
}; };
const openGroup = (id: number | undefined) =>
() => history.push(`/app/designer/groups/${id}`);
/** Bounds for area selected by criteria or bot extents. */ /** Bounds for area selected by criteria or bot extents. */
const getBoundary = (props: GetBoundaryProps): Boundary => { const getBoundary = (props: GetBoundaryProps): Boundary => {
const { criteria } = props.group.body; const { criteria } = props.group.body;
@ -85,7 +89,8 @@ const zone0D = (props: ZonesProps) =>
/** Coordinates selected by both x and y number equal values. */ /** Coordinates selected by both x and y number equal values. */
export const Zones0D = (props: ZonesProps) => { export const Zones0D = (props: ZonesProps) => {
const current = props.group.uuid == props.currentGroup; const current = props.group.uuid == props.currentGroup;
return <g id={`zones-0D-${props.group.body.id}`} const { id } = props.group.body;
return <g id={`zones-0D-${id}`} onClick={openGroup(id)}
className={current ? "current" : ""}> className={current ? "current" : ""}>
{zone0D(props).map((point, i) => {zone0D(props).map((point, i) =>
<circle key={i} cx={point.x} cy={point.y} r={5} />)} <circle key={i} cx={point.x} cy={point.y} r={5} />)}
@ -126,7 +131,8 @@ const zone1D = (props: ZonesProps) => {
/** Lines selected by an x or y number equal value. */ /** Lines selected by an x or y number equal value. */
export const Zones1D = (props: ZonesProps) => { export const Zones1D = (props: ZonesProps) => {
const current = props.group.uuid == props.currentGroup; const current = props.group.uuid == props.currentGroup;
return <g id={`zones-1D-${props.group.body.id}`} const { id } = props.group.body;
return <g id={`zones-1D-${id}`} onClick={openGroup(id)}
className={current ? "current" : ""}> className={current ? "current" : ""}>
{zone1D(props).map((line, i) => {zone1D(props).map((line, i) =>
<line key={i} x1={line.x1} y1={line.y1} <line key={i} x1={line.x1} y1={line.y1}
@ -153,7 +159,8 @@ const zone2D = (boundary: Boundary, mapTransformProps: MapTransformProps) => {
export const Zones2D = (props: ZonesProps) => { export const Zones2D = (props: ZonesProps) => {
const zone = zone2D(getBoundary(props), props.mapTransformProps); const zone = zone2D(getBoundary(props), props.mapTransformProps);
const current = props.group.uuid == props.currentGroup; const current = props.group.uuid == props.currentGroup;
return <g id={`zones-2D-${props.group.body.id}`} const { id } = props.group.body;
return <g id={`zones-2D-${id}`} onClick={openGroup(id)}
className={current ? "current" : ""}> className={current ? "current" : ""}>
{!zone.selectsAll && {!zone.selectsAll &&
<rect x={zone.x} y={zone.y} width={zone.width} height={zone.height} />} <rect x={zone.x} y={zone.y} width={zone.width} height={zone.height} />}

View File

@ -3,6 +3,7 @@ import { TaggedPointGroup } from "farmbot";
import { MapTransformProps, BotSize } from "../../interfaces"; import { MapTransformProps, BotSize } from "../../interfaces";
import { Zones0D, Zones1D, Zones2D, getZoneType, ZoneType } from "./zones"; import { Zones0D, Zones1D, Zones2D, getZoneType, ZoneType } from "./zones";
import { UUID } from "../../../../resources/interfaces"; import { UUID } from "../../../../resources/interfaces";
import { allowGroupAreaInteraction } from "../../util";
export interface ZonesLayerProps { export interface ZonesLayerProps {
visible: boolean; visible: boolean;
@ -10,6 +11,7 @@ export interface ZonesLayerProps {
groups: TaggedPointGroup[]; groups: TaggedPointGroup[];
botSize: BotSize; botSize: BotSize;
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
startDrag(e: React.MouseEvent<SVGElement>): void;
} }
export function ZonesLayer(props: ZonesLayerProps) { export function ZonesLayer(props: ZonesLayerProps) {
@ -17,7 +19,9 @@ export function ZonesLayer(props: ZonesLayerProps) {
const commonProps = { botSize, mapTransformProps, currentGroup }; const commonProps = { botSize, mapTransformProps, currentGroup };
const visible = (group: TaggedPointGroup) => const visible = (group: TaggedPointGroup) =>
props.visible || (group.uuid == currentGroup); props.visible || (group.uuid == currentGroup);
return <g className="zones-layer"> return <g className="zones-layer" style={allowGroupAreaInteraction()
? { cursor: "pointer" }
: { pointerEvents: "none" }} onMouseDown={props.startDrag}>
{groups.map(group => visible(group) && {groups.map(group => visible(group) &&
getZoneType(group) === ZoneType.area && getZoneType(group) === ZoneType.area &&
<Zones2D {...commonProps} key={group.uuid} group={group} />)} <Zones2D {...commonProps} key={group.uuid} group={group} />)}

View File

@ -35,6 +35,7 @@ describe("<GardenMapLegend />", () => {
legendMenuOpen: true, legendMenuOpen: true,
showPlants: false, showPlants: false,
showPoints: false, showPoints: false,
showWeeds: false,
showSpread: false, showSpread: false,
showFarmbot: false, showFarmbot: false,
showImages: false, showImages: false,

View File

@ -59,6 +59,10 @@ const LayerToggles = (props: GardenMapLegendProps) => {
popover={DevSettings.futureFeaturesEnabled() popover={DevSettings.futureFeaturesEnabled()
? <PointsSubMenu toggle={toggle} getConfigValue={getConfigValue} /> ? <PointsSubMenu toggle={toggle} getConfigValue={getConfigValue} />
: undefined} /> : undefined} />
<LayerToggle
value={props.showWeeds}
label={t("Weeds?")}
onClick={toggle(BooleanSetting.show_weeds)} />
<LayerToggle <LayerToggle
value={props.showSpread} value={props.showSpread}
label={t("Spread?")} label={t("Spread?")}

View File

@ -299,10 +299,14 @@ export const getMode = (): Mode => {
if (pathArray[4] === "select") { return Mode.boxSelect; } if (pathArray[4] === "select") { return Mode.boxSelect; }
if (pathArray[4] === "crop_search") { return Mode.addPlant; } if (pathArray[4] === "crop_search") { return Mode.addPlant; }
if (pathArray[3] === "move_to") { return Mode.moveTo; } if (pathArray[3] === "move_to") { return Mode.moveTo; }
if (pathArray[3] === "points" || pathArray[3] === "weeds") { if (pathArray[3] === "points") {
if (pathArray[4] === "add") { return Mode.createPoint; } if (pathArray[4] === "add") { return Mode.createPoint; }
return Mode.points; return Mode.points;
} }
if (pathArray[3] === "weeds") {
if (pathArray[4] === "add") { return Mode.createWeed; }
return Mode.weeds;
}
if (savedGardenOpen(pathArray)) { return Mode.templateView; } if (savedGardenOpen(pathArray)) { return Mode.templateView; }
} }
return Mode.none; return Mode.none;
@ -337,18 +341,28 @@ export const getGardenCoordinates = (props: {
} }
}; };
export const maybeNoPointer = export const allowInteraction = () => {
(defaultStyle: React.CSSProperties): React.SVGProps<SVGGElement>["style"] => { switch (getMode()) {
switch (getMode()) { case Mode.clickToAdd:
case Mode.clickToAdd: case Mode.moveTo:
case Mode.moveTo: case Mode.createPoint:
case Mode.points: case Mode.createWeed:
case Mode.createPoint: return false;
return { pointerEvents: "none" }; default:
default: return true;
return defaultStyle; }
} };
};
export const allowGroupAreaInteraction = () => {
if (!allowInteraction()) { return false; }
switch (getMode()) {
case Mode.boxSelect:
case Mode.editGroup:
return false;
default:
return true;
}
};
/** Check if the cursor is within the selected plant indicator area. */ /** Check if the cursor is within the selected plant indicator area. */
export const cursorAtPlant = export const cursorAtPlant =

View File

@ -2,7 +2,17 @@ jest.mock("../../../open_farm/cached_crop", () => ({
maybeGetCachedPlantIcon: jest.fn(), maybeGetCachedPlantIcon: jest.fn(),
})); }));
jest.mock("../../../history", () => ({ push: jest.fn() })); let mockPath = "/app/designer/plants";
jest.mock("../../../history", () => ({
push: jest.fn(),
getPathArray: () => mockPath.split("/"),
}));
jest.mock("../../map/actions", () => ({
mapPointClickAction: jest.fn(() => jest.fn()),
setHoveredPlant: jest.fn(),
selectPoint: jest.fn(),
}));
import * as React from "react"; import * as React from "react";
import { import {
@ -12,9 +22,11 @@ import { shallow, mount } from "enzyme";
import { import {
fakePlant, fakePlantTemplate, fakePlant, fakePlantTemplate,
} from "../../../__test_support__/fake_state/resources"; } from "../../../__test_support__/fake_state/resources";
import { Actions } from "../../../constants";
import { push } from "../../../history"; import { push } from "../../../history";
import { maybeGetCachedPlantIcon } from "../../../open_farm/cached_crop"; import { maybeGetCachedPlantIcon } from "../../../open_farm/cached_crop";
import {
mapPointClickAction, setHoveredPlant, selectPoint,
} from "../../map/actions";
describe("<PlantInventoryItem />", () => { describe("<PlantInventoryItem />", () => {
const fakeProps = (): PlantInventoryItemProps => ({ const fakeProps = (): PlantInventoryItemProps => ({
@ -40,48 +52,43 @@ describe("<PlantInventoryItem />", () => {
const p = fakeProps(); const p = fakeProps();
const wrapper = shallow(<PlantInventoryItem {...p} />); const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("mouseEnter"); wrapper.simulate("mouseEnter");
expect(p.dispatch).toBeCalledWith({ expect(setHoveredPlant).toBeCalledWith(p.plant.uuid, "");
payload: {
icon: "",
plantUUID: p.plant.uuid
},
type: Actions.TOGGLE_HOVERED_PLANT
});
}); });
it("hover end", () => { it("hover end", () => {
const p = fakeProps(); const wrapper = shallow(<PlantInventoryItem {...fakeProps()} />);
const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("mouseLeave"); wrapper.simulate("mouseLeave");
expect(p.dispatch).toBeCalledWith({ expect(setHoveredPlant).toBeCalledWith(undefined, "");
payload: {
icon: "",
plantUUID: undefined
},
type: Actions.TOGGLE_HOVERED_PLANT
});
}); });
it("selects plant", () => { it("selects plant", () => {
mockPath = "/app/designer/plants";
const p = fakeProps(); const p = fakeProps();
const wrapper = shallow(<PlantInventoryItem {...p} />); const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("click"); wrapper.simulate("click");
expect(p.dispatch).toBeCalledWith({ expect(mapPointClickAction).not.toHaveBeenCalled();
payload: [p.plant.uuid], expect(selectPoint).toBeCalledWith([p.plant.uuid]);
type: Actions.SELECT_PLANT
});
expect(push).toHaveBeenCalledWith("/app/designer/plants/" + p.plant.body.id); expect(push).toHaveBeenCalledWith("/app/designer/plants/" + p.plant.body.id);
}); });
it("removes item in box select mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("click");
expect(mapPointClickAction).toHaveBeenCalledWith(expect.any(Function),
p.plant.uuid);
expect(push).not.toHaveBeenCalled();
expect(setHoveredPlant).toHaveBeenCalledWith(undefined, "");
});
it("selects plant template", () => { it("selects plant template", () => {
mockPath = "/app/designer/plants";
const p = fakeProps(); const p = fakeProps();
p.plant = fakePlantTemplate(); p.plant = fakePlantTemplate();
const wrapper = shallow(<PlantInventoryItem {...p} />); const wrapper = shallow(<PlantInventoryItem {...p} />);
wrapper.simulate("click"); wrapper.simulate("click");
expect(p.dispatch).toBeCalledWith({ expect(selectPoint).toBeCalledWith([p.plant.uuid]);
payload: [p.plant.uuid],
type: Actions.SELECT_PLANT
});
expect(push).toHaveBeenCalledWith( expect(push).toHaveBeenCalledWith(
"/app/designer/gardens/templates/" + p.plant.body.id); "/app/designer/gardens/templates/" + p.plant.body.id);
}); });
@ -94,4 +101,12 @@ describe("<PlantInventoryItem />", () => {
expect(maybeGetCachedPlantIcon).toHaveBeenCalledWith("strawberry", expect(maybeGetCachedPlantIcon).toHaveBeenCalledWith("strawberry",
img.instance(), expect.any(Function)); img.instance(), expect.any(Function));
}); });
it("sets icon", () => {
const wrapper =
mount<PlantInventoryItem>(<PlantInventoryItem {...fakeProps()} />);
expect(wrapper.state().icon).toEqual("");
wrapper.instance().updateStateIcon("fake icon");
expect(wrapper.state().icon).toEqual("fake icon");
});
}); });

View File

@ -15,11 +15,15 @@ jest.mock("../../../account/dev/dev_support", () => ({
jest.mock("../../point_groups/actions", () => ({ createGroup: jest.fn() })); jest.mock("../../point_groups/actions", () => ({ createGroup: jest.fn() }));
import * as React from "react"; import * as React from "react";
import { mount } from "enzyme"; import { mount, shallow } from "enzyme";
import { import {
RawSelectPlants as SelectPlants, SelectPlantsProps, mapStateToProps, RawSelectPlants as SelectPlants, SelectPlantsProps, mapStateToProps,
getFilteredPoints, GetFilteredPointsProps,
} from "../select_plants"; } from "../select_plants";
import { fakePlant } from "../../../__test_support__/fake_state/resources"; import {
fakePlant, fakePoint, fakeWeed, fakeToolSlot, fakeTool,
fakePlantTemplate,
} from "../../../__test_support__/fake_state/resources";
import { Actions, Content } from "../../../constants"; import { Actions, Content } from "../../../constants";
import { clickButton } from "../../../__test_support__/helpers"; import { clickButton } from "../../../__test_support__/helpers";
import { destroy } from "../../../api/crud"; import { destroy } from "../../../api/crud";
@ -41,9 +45,16 @@ describe("<SelectPlants />", () => {
plant2.body.name = "Blueberry"; plant2.body.name = "Blueberry";
return { return {
selected: ["plant.1"], selected: ["plant.1"],
selectionPointType: undefined,
getConfigValue: () => true,
plants: [plant1, plant2], plants: [plant1, plant2],
dispatch: jest.fn(x => x), dispatch: jest.fn(x => x),
gardenOpen: undefined, gardenOpen: undefined,
allPoints: [],
xySwap: false,
quadrant: 2,
isActive: () => false,
tools: [],
}; };
} }
@ -52,6 +63,53 @@ describe("<SelectPlants />", () => {
expect(wrapper.text()).toContain("Strawberry"); expect(wrapper.text()).toContain("Strawberry");
}); });
it("displays selected point", () => {
const p = fakeProps();
const point = fakePoint();
point.body.name = "fake point";
p.allPoints = [point];
p.selected = [point.uuid];
p.selectionPointType = ["GenericPointer"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain(point.body.name);
});
it("displays selected weed", () => {
const p = fakeProps();
const weed = fakeWeed();
weed.body.name = "fake weed";
p.allPoints = [weed];
p.selected = [weed.uuid];
p.selectionPointType = ["Weed"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain(weed.body.name);
});
it("displays selected slot", () => {
const p = fakeProps();
const tool = fakeTool();
tool.body.id = 1;
tool.body.name = "fake tool slot";
p.tools = [tool];
const slot = fakeToolSlot();
slot.body.tool_id = 1;
p.allPoints = [slot];
p.selected = [slot.uuid];
p.selectionPointType = ["ToolSlot"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain(tool.body.name);
});
it("clears point section type", () => {
const p = fakeProps();
const wrapper = mount(<SelectPlants {...p} />);
wrapper.unmount();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: undefined,
});
});
it("displays multiple selected plants", () => { it("displays multiple selected plants", () => {
const p = fakeProps(); const p = fakeProps();
p.selected = ["plant.1", "plant.2"]; p.selected = ["plant.1", "plant.2"];
@ -88,31 +146,42 @@ describe("<SelectPlants />", () => {
expect(wrapper.text()).not.toContain("Strawberry Plant"); expect(wrapper.text()).not.toContain("Strawberry Plant");
}); });
it("changes selection type", () => {
const p = fakeProps();
const wrapper = mount<SelectPlants>(<SelectPlants {...p} />);
const actionsWrapper = shallow(wrapper.instance().ActionButtons());
actionsWrapper.find("FBSelect").first().simulate("change",
{ label: "", value: "All" });
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ["Plant", "GenericPointer", "ToolSlot", "Weed"],
});
});
it("selects all", () => { it("selects all", () => {
const p = fakeProps(); const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
clickButton(wrapper, 1, "select all"); clickButton(wrapper, 2, "select all");
expect(p.dispatch).toHaveBeenCalledWith( expect(p.dispatch).toHaveBeenCalledWith(
{ payload: ["plant.1", "plant.2"], type: Actions.SELECT_PLANT }); { payload: ["plant.1", "plant.2"], type: Actions.SELECT_POINT });
}); });
it("selects none", () => { it("selects none", () => {
const p = fakeProps(); const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
clickButton(wrapper, 0, "select none"); clickButton(wrapper, 1, "select none");
expect(p.dispatch).toHaveBeenCalledWith( expect(p.dispatch).toHaveBeenCalledWith(
{ payload: undefined, type: Actions.SELECT_PLANT }); { payload: undefined, type: Actions.SELECT_POINT });
}); });
const DELETE_BTN_INDEX = 3;
it("confirms deletion of selected plants", () => { it("confirms deletion of selected plants", () => {
const p = fakeProps(); const p = fakeProps();
p.selected = ["plant.1", "plant.2"]; p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");
window.confirm = jest.fn(); window.confirm = jest.fn();
wrapper.find("button").at(2).simulate("click"); clickButton(wrapper, DELETE_BTN_INDEX, "Delete");
expect(window.confirm).toHaveBeenCalledWith( expect(window.confirm).toHaveBeenCalledWith(
"Are you sure you want to delete 2 plants?"); "Are you sure you want to delete 2 plants?");
}); });
@ -122,9 +191,8 @@ describe("<SelectPlants />", () => {
mockDestroy = jest.fn(() => Promise.resolve()); mockDestroy = jest.fn(() => Promise.resolve());
p.selected = ["plant.1", "plant.2"]; p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");
window.confirm = () => true; window.confirm = () => true;
wrapper.find("button").at(2).simulate("click"); clickButton(wrapper, DELETE_BTN_INDEX, "Delete");
expect(destroy).toHaveBeenCalledWith("plant.1", true); expect(destroy).toHaveBeenCalledWith("plant.1", true);
expect(destroy).toHaveBeenCalledWith("plant.2", true); expect(destroy).toHaveBeenCalledWith("plant.2", true);
}); });
@ -134,19 +202,17 @@ describe("<SelectPlants />", () => {
mockDestroy = jest.fn(() => Promise.resolve()); mockDestroy = jest.fn(() => Promise.resolve());
p.selected = undefined; p.selected = undefined;
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete"); clickButton(wrapper, DELETE_BTN_INDEX, "Delete");
wrapper.find("button").at(2).simulate("click");
expect(destroy).not.toHaveBeenCalled(); expect(destroy).not.toHaveBeenCalled();
}); });
it("errors when deleting selected plants", () => { it("errors when deleting selected plants", async () => {
const p = fakeProps(); const p = fakeProps();
mockDestroy = jest.fn(() => Promise.reject()); mockDestroy = jest.fn(() => Promise.reject());
p.selected = ["plant.1", "plant.2"]; p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />); const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Delete");
window.confirm = () => true; window.confirm = () => true;
wrapper.find("button").at(2).simulate("click"); await clickButton(wrapper, DELETE_BTN_INDEX, "Delete");
expect(destroy).toHaveBeenCalledWith("plant.1", true); expect(destroy).toHaveBeenCalledWith("plant.1", true);
expect(destroy).toHaveBeenCalledWith("plant.2", true); expect(destroy).toHaveBeenCalledWith("plant.2", true);
}); });
@ -183,3 +249,43 @@ describe("mapStateToProps", () => {
expect(result.dispatch).toBe(state.dispatch); expect(result.dispatch).toBe(state.dispatch);
}); });
}); });
describe("getFilteredPoints()", () => {
const plant = fakePlant();
const point = fakePoint();
const weed = fakeWeed();
const slot = fakeToolSlot();
const fakeProps = (): GetFilteredPointsProps => ({
selectionPointType: undefined,
getConfigValue: () => true,
plants: [plant],
allPoints: [plant, point, weed, slot],
});
it("returns filtered points: all", () => {
const p = fakeProps();
p.selectionPointType = ["Plant", "GenericPointer", "Weed", "ToolSlot"];
expect(getFilteredPoints(p)).toEqual([plant, point, weed, slot]);
});
it("returns filtered points: none", () => {
const p = fakeProps();
p.selectionPointType = ["Plant", "GenericPointer", "Weed", "ToolSlot"];
p.getConfigValue = () => false;
expect(getFilteredPoints(p)).toEqual([]);
});
it("returns filtered points: plants", () => {
const p = fakeProps();
const plantTemplate = fakePlantTemplate();
p.plants = [plantTemplate];
expect(getFilteredPoints(p)).toEqual([plantTemplate]);
});
it("returns filtered points: tool slots", () => {
const p = fakeProps();
p.selectionPointType = ["ToolSlot"];
expect(getFilteredPoints(p)).toEqual([slot]);
});
});

View File

@ -1,12 +1,13 @@
import * as React from "react"; import * as React from "react";
import { DEFAULT_ICON } from "../../open_farm/icons"; import { DEFAULT_ICON } from "../../open_farm/icons";
import { push } from "../../history"; import { push } from "../../history";
import { TaggedPlant } from "../map/interfaces"; import { TaggedPlant, Mode } from "../map/interfaces";
import { unpackUUID } from "../../util"; import { unpackUUID } from "../../util";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { maybeGetCachedPlantIcon } from "../../open_farm/cached_crop"; import { maybeGetCachedPlantIcon } from "../../open_farm/cached_crop";
import { selectPlant, setHoveredPlant } from "../map/actions"; import { selectPoint, setHoveredPlant, mapPointClickAction } from "../map/actions";
import { plantAge } from "./map_state_to_props"; import { plantAge } from "./map_state_to_props";
import { getMode } from "../map/util";
export interface PlantInventoryItemProps { export interface PlantInventoryItemProps {
plant: TaggedPlant; plant: TaggedPlant;
@ -21,9 +22,10 @@ interface PlantInventoryItemState {
// The individual plants that show up in the farm designer sub nav. // The individual plants that show up in the farm designer sub nav.
export class PlantInventoryItem extends export class PlantInventoryItem extends
React.Component<PlantInventoryItemProps, PlantInventoryItemState> { React.Component<PlantInventoryItemProps, PlantInventoryItemState> {
state: PlantInventoryItemState = { icon: "" }; state: PlantInventoryItemState = { icon: "" };
updateStateIcon = (i: string) => this.setState({ icon: i });
render() { render() {
const { plant, dispatch } = this.props; const { plant, dispatch } = this.props;
const plantId = (plant.body.id || "ERR_NO_PLANT_ID").toString(); const plantId = (plant.body.id || "ERR_NO_PLANT_ID").toString();
@ -36,17 +38,21 @@ export class PlantInventoryItem extends
}; };
const click = () => { const click = () => {
const plantCategory = if (getMode() == Mode.boxSelect) {
unpackUUID(plant.uuid).kind === "PlantTemplate" mapPointClickAction(dispatch, plant.uuid)();
? "gardens/templates" toggle("leave");
: "plants"; } else {
push(`/app/designer/${plantCategory}/${plantId}`); const plantCategory =
dispatch(selectPlant([plant.uuid])); unpackUUID(plant.uuid).kind === "PlantTemplate"
? "gardens/templates"
: "plants";
push(`/app/designer/${plantCategory}/${plantId}`);
dispatch(selectPoint([plant.uuid]));
}
}; };
const updateStateIcon = (i: string) => this.setState({ icon: i });
const onLoad = (e: React.SyntheticEvent<HTMLImageElement>) => const onLoad = (e: React.SyntheticEvent<HTMLImageElement>) =>
maybeGetCachedPlantIcon(slug, e.currentTarget, updateStateIcon); maybeGetCachedPlantIcon(slug, e.currentTarget, this.updateStateIcon);
// Name given from OpenFarm's API. // Name given from OpenFarm's API.
const label = plant.body.name || "Unknown plant"; const label = plant.body.name || "Unknown plant";

View File

@ -4,7 +4,7 @@ import { connect } from "react-redux";
import { Everything } from "../../interfaces"; import { Everything } from "../../interfaces";
import { PlantInventoryItem } from "./plant_inventory_item"; import { PlantInventoryItem } from "./plant_inventory_item";
import { destroy } from "../../api/crud"; import { destroy } from "../../api/crud";
import { unselectPlant, selectPlant, setHoveredPlant } from "../map/actions"; import { unselectPlant, selectPoint, setHoveredPlant } from "../map/actions";
import { Actions, Content } from "../../constants"; import { Actions, Content } from "../../constants";
import { TaggedPlant } from "../map/interfaces"; import { TaggedPlant } from "../map/interfaces";
import { getPlants } from "../state_to_props"; import { getPlants } from "../state_to_props";
@ -16,19 +16,73 @@ import { createGroup } from "../point_groups/actions";
import { PanelColor } from "../panel_header"; import { PanelColor } from "../panel_header";
import { error } from "../../toast/toast"; import { error } from "../../toast/toast";
import { PlantStatusBulkUpdate } from "./edit_plant_status"; import { PlantStatusBulkUpdate } from "./edit_plant_status";
import { FBSelect, DropDownItem } from "../../ui";
import {
PointType, TaggedPoint, TaggedGenericPointer, TaggedToolSlotPointer,
TaggedTool,
TaggedWeedPointer,
} from "farmbot";
import { UUID } from "../../resources/interfaces";
import {
selectAllActivePoints, selectAllToolSlotPointers, selectAllTools,
} from "../../resources/selectors";
import { PointInventoryItem } from "../points/point_inventory_item";
import { ToolSlotInventoryItem } from "../tools";
import { getWebAppConfigValue, GetWebAppConfigValue } from "../../config_storage/actions";
import { BooleanSetting, NumericSetting } from "../../session_keys";
import { isBotOriginQuadrant, BotOriginQuadrant } from "../interfaces";
import { isActive } from "../tools/edit_tool";
import { uniq } from "lodash";
import { POINTER_TYPES } from "../point_groups/criteria/interfaces";
import { WeedInventoryItem } from "../weeds/weed_inventory_item";
export const mapStateToProps = (props: Everything): SelectPlantsProps => ({ export const POINTER_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
selected: props.resources.consumers.farm_designer.selectedPlants, Plant: { label: t("Plants"), value: "Plant" },
plants: getPlants(props.resources), GenericPointer: { label: t("Points"), value: "GenericPointer" },
dispatch: props.dispatch, Weed: { label: t("Weeds"), value: "Weed" },
gardenOpen: props.resources.consumers.farm_designer.openedSavedGarden, ToolSlot: { label: t("Slots"), value: "ToolSlot" },
All: { label: t("All"), value: "All" },
}); });
export const POINTER_TYPE_LIST = () => [
POINTER_TYPE_DDI_LOOKUP().Plant,
POINTER_TYPE_DDI_LOOKUP().GenericPointer,
POINTER_TYPE_DDI_LOOKUP().Weed,
POINTER_TYPE_DDI_LOOKUP().ToolSlot,
POINTER_TYPE_DDI_LOOKUP().All,
];
export const mapStateToProps = (props: Everything): SelectPlantsProps => {
const getWebAppConfig = getWebAppConfigValue(() => props);
const xySwap = !!getWebAppConfig(BooleanSetting.xy_swap);
const rawQuadrant = getWebAppConfig(NumericSetting.bot_origin_quadrant);
const quadrant = isBotOriginQuadrant(rawQuadrant) ? rawQuadrant : 2;
return {
selected: props.resources.consumers.farm_designer.selectedPoints,
selectionPointType: props.resources.consumers.farm_designer.selectionPointType,
getConfigValue: getWebAppConfig,
plants: getPlants(props.resources),
allPoints: selectAllActivePoints(props.resources.index),
dispatch: props.dispatch,
gardenOpen: props.resources.consumers.farm_designer.openedSavedGarden,
tools: selectAllTools(props.resources.index),
isActive: isActive(selectAllToolSlotPointers(props.resources.index)),
xySwap,
quadrant,
};
};
export interface SelectPlantsProps { export interface SelectPlantsProps {
plants: TaggedPlant[]; plants: TaggedPlant[];
allPoints: TaggedPoint[];
dispatch: Function; dispatch: Function;
selected: string[] | undefined; selected: UUID[] | undefined;
selectionPointType: PointType[] | undefined;
getConfigValue: GetWebAppConfigValue;
gardenOpen: string | undefined; gardenOpen: string | undefined;
xySwap: boolean;
quadrant: BotOriginQuadrant;
isActive(id: number | undefined): boolean;
tools: TaggedTool[];
} }
export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> { export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
@ -42,6 +96,11 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
} }
} }
componentWillUnmount = () => this.props.dispatch({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: undefined,
});
get selected() { return this.props.selected || []; } get selected() { return this.props.selected || []; }
destroySelected = (plantUUIDs: string[] | undefined) => { destroySelected = (plantUUIDs: string[] | undefined) => {
@ -56,18 +115,32 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
} }
} }
get selectionPointType() {
const selectionPointTypes = this.props.selectionPointType || ["Plant"];
return selectionPointTypes.length > 1 ? "All" : selectionPointTypes[0];
}
ActionButtons = () => ActionButtons = () =>
<div className="panel-action-buttons"> <div className="panel-action-buttons">
<FBSelect
list={POINTER_TYPE_LIST()}
selectedItem={POINTER_TYPE_DDI_LOOKUP()[this.selectionPointType]}
onChange={ddi => {
this.props.dispatch(selectPoint(undefined));
this.props.dispatch({
type: Actions.SET_SELECTION_POINT_TYPE,
payload: ddi.value == "All" ? POINTER_TYPES : [ddi.value],
});
}} />
<div className="button-row"> <div className="button-row">
<button className="fb-button gray" <button className="fb-button gray"
title={t("Select none")} title={t("Select none")}
onClick={() => this.props.dispatch(selectPlant(undefined))}> onClick={() => this.props.dispatch(selectPoint(undefined))}>
{t("Select none")} {t("Select none")}
</button> </button>
<button className="fb-button gray" <button className="fb-button gray"
title={t("Select all")} title={t("Select all")}
onClick={() => this.props onClick={() => this.props.dispatch(selectPoint(this.allPointUuids))}>
.dispatch(selectPlant(this.props.plants.map(p => p.uuid)))}>
{t("Select all")} {t("Select all")}
</button> </button>
</div> </div>
@ -85,42 +158,152 @@ export class RawSelectPlants extends React.Component<SelectPlantsProps, {}> {
: error(t(Content.ERROR_PLANT_TEMPLATE_GROUP))}> : error(t(Content.ERROR_PLANT_TEMPLATE_GROUP))}>
{t("Create group")} {t("Create group")}
</button> </button>
<PlantStatusBulkUpdate {this.selectionPointType == "Plant" &&
plants={this.props.plants} <PlantStatusBulkUpdate
selected={this.selected} plants={this.props.plants}
dispatch={this.props.dispatch} /> selected={this.selected}
dispatch={this.props.dispatch} />}
</div> </div>
</div>; </div>;
render() { get filteredPoints() {
const { plants, dispatch } = this.props; const { plants, allPoints, selectionPointType, getConfigValue } = this.props;
const selectedPlantData = return getFilteredPoints({
this.selected.map(uuid => plants.filter(p => p.uuid == uuid)[0]); plants, allPoints, selectionPointType, getConfigValue
});
}
get selectedPointData() {
const { plants, allPoints, selectionPointType } = this.props;
return getSelectedPoints({
plants, allPoints, selectionPointType,
selected: this.selected,
});
}
get allPointUuids() {
return this.filteredPoints.map(p => p.uuid);
}
get itemName() {
const { value } = POINTER_TYPE_DDI_LOOKUP()[this.selectionPointType];
const ITEM_NAME_LOOKUP:
Record<string, Record<"singular" | "plural", string>> = {
"Plant": { singular: t("plant"), plural: t("plants") },
"GenericPointer": { singular: t("point"), plural: t("points") },
"Weed": { singular: t("weed"), plural: t("weeds") },
"ToolSlot": { singular: t("slot"), plural: t("slots") },
"All": { singular: t("item"), plural: t("items") },
};
return ITEM_NAME_LOOKUP["" + value][
this.selected.length == 1 ? "singular" : "plural"];
}
render() {
const { dispatch } = this.props;
return <DesignerPanel panelName={"plant-selection"} return <DesignerPanel panelName={"plant-selection"}
panelColor={PanelColor.lightGray}> panelColor={PanelColor.lightGray}>
<DesignerPanelHeader <DesignerPanelHeader
panelName={"plant-selection"} panelName={"plant-selection"}
panelColor={PanelColor.lightGray} panelColor={PanelColor.lightGray}
blackText={true} blackText={true}
title={t("{{length}} plants selected", title={t("{{length}} {{name}} selected",
{ length: this.selected.length })} { length: this.selected.length, name: this.itemName })}
backTo={"/app/designer/plants"} backTo={"/app/designer/plants"}
onBack={unselectPlant(dispatch)} onBack={unselectPlant(dispatch)}
description={Content.BOX_SELECT_DESCRIPTION} /> description={Content.BOX_SELECT_DESCRIPTION} />
<this.ActionButtons /> <this.ActionButtons />
<DesignerPanelContent panelName={"plant-selection"}> <DesignerPanelContent panelName={"plant-selection"}>
{selectedPlantData && selectedPlantData[0] && {this.selectedPointData.map(p => {
selectedPlantData.map(p => if (p.kind == "PlantTemplate" || p.body.pointer_type == "Plant") {
<PlantInventoryItem return <PlantInventoryItem
key={p.uuid} key={p.uuid}
plant={p} plant={p as TaggedPlant}
hovered={false} hovered={false}
dispatch={dispatch} />)} dispatch={dispatch} />;
} else {
switch (p.body.pointer_type) {
case "GenericPointer":
return <PointInventoryItem
key={p.uuid}
tpp={p as TaggedGenericPointer}
hovered={false}
dispatch={this.props.dispatch} />;
case "Weed":
return <WeedInventoryItem
key={p.uuid}
tpp={p as TaggedWeedPointer}
hovered={false}
dispatch={this.props.dispatch} />;
case "ToolSlot":
return <ToolSlotInventoryItem
key={p.uuid}
hovered={false}
dispatch={this.props.dispatch}
toolSlot={p as TaggedToolSlotPointer}
isActive={this.props.isActive}
tools={this.props.tools}
xySwap={this.props.xySwap}
quadrant={this.props.quadrant}
hideDropdown={true} />;
}
}
})}
</DesignerPanelContent> </DesignerPanelContent>
</DesignerPanel>; </DesignerPanel>;
} }
} }
export const SelectPlants = connect(mapStateToProps)(RawSelectPlants); export const SelectPlants = connect(mapStateToProps)(RawSelectPlants);
export interface GetFilteredPointsProps {
selectionPointType: PointType[] | undefined;
plants: TaggedPlant[];
allPoints: TaggedPoint[];
getConfigValue?: GetWebAppConfigValue;
}
export const getFilteredPoints = (props: GetFilteredPointsProps) => {
const selectionPointType = (props.selectionPointType || ["Plant"])
.filter(x => !props.getConfigValue ||
getVisibleLayers(props.getConfigValue).includes(x));
const filterPoints = (p: TaggedPoint) =>
selectionPointType.includes(p.body.pointer_type);
const plants = selectionPointType.includes("Plant") ? props.plants : [];
const otherPoints =
props.allPoints
.filter(p => p.body.pointer_type != "Plant")
.filter(filterPoints);
const plantsAndOtherPoints: (TaggedPlant | TaggedPoint)[] = [];
return uniq(plantsAndOtherPoints.concat(plants).concat(otherPoints));
};
interface GetSelectedPointsProps extends GetFilteredPointsProps {
selected: UUID[];
}
export const getSelectedPoints = (props: GetSelectedPointsProps) =>
props.selected
.map(uuid => getFilteredPoints(props).filter(p => p.uuid == uuid)[0])
.filter(p => p);
enum PointerType {
Plant = "Plant",
GenericPointer = "GenericPointer",
Weed = "Weed",
ToolSlot = "ToolSlot",
}
const getVisibleLayers = (getConfigValue: GetWebAppConfigValue): PointType[] => {
const showPlants = getConfigValue(BooleanSetting.show_plants);
const showPoints = getConfigValue(BooleanSetting.show_points);
const showWeeds = getConfigValue(BooleanSetting.show_weeds);
const showFarmbot = getConfigValue(BooleanSetting.show_farmbot);
return [
...(showPlants ? [PointerType.Plant] : []),
...(showPoints ? [PointerType.GenericPointer] : []),
...(showWeeds ? [PointerType.Weed] : []),
...(showFarmbot ? [PointerType.ToolSlot] : []),
];
};

View File

@ -1,3 +1,14 @@
jest.mock("../../../api/crud", () => ({
destroy: jest.fn(),
}));
let mockDelMode = false;
jest.mock("../../../account/dev/dev_support", () => ({
DevSettings: {
quickDeleteEnabled: () => mockDelMode,
}
}));
import React from "react"; import React from "react";
import { import {
GroupInventoryItem, GroupInventoryItemProps, GroupInventoryItem, GroupInventoryItemProps,
@ -6,6 +17,7 @@ import {
fakePointGroup, fakePlant, fakePointGroup, fakePlant,
} from "../../../__test_support__/fake_state/resources"; } from "../../../__test_support__/fake_state/resources";
import { mount } from "enzyme"; import { mount } from "enzyme";
import { destroy } from "../../../api/crud";
describe("<GroupInventoryItem />", () => { describe("<GroupInventoryItem />", () => {
const fakeProps = (): GroupInventoryItemProps => ({ const fakeProps = (): GroupInventoryItemProps => ({
@ -32,4 +44,21 @@ describe("<GroupInventoryItem />", () => {
expect(x.text()).toContain("woosh"); expect(x.text()).toContain("woosh");
expect(x.find(".hovered").length).toBe(1); expect(x.find(".hovered").length).toBe(1);
}); });
it("opens group", () => {
const p = fakeProps();
const wrapper = mount(<GroupInventoryItem {...p} />);
wrapper.find("div").first().simulate("click");
expect(p.onClick).toHaveBeenCalled();
expect(destroy).not.toHaveBeenCalledWith(p.group.uuid);
});
it("deletes group", () => {
mockDelMode = true;
const p = fakeProps();
const wrapper = mount(<GroupInventoryItem {...p} />);
wrapper.find("div").first().simulate("click");
expect(p.onClick).not.toHaveBeenCalled();
expect(destroy).toHaveBeenCalledWith(p.group.uuid);
});
}); });

View File

@ -9,10 +9,11 @@ jest.mock("../../../api/crud", () => ({ overwrite: jest.fn() }));
import React from "react"; import React from "react";
import { import {
PointGroupItem, PointGroupItemProps, genericPointIcon, OTHER_POINT_ICON, PointGroupItem, PointGroupItemProps, genericPointIcon, OTHER_POINT_ICON,
genericWeedIcon,
} from "../point_group_item"; } from "../point_group_item";
import { shallow } from "enzyme"; import { shallow, mount } from "enzyme";
import { import {
fakePlant, fakePointGroup, fakePoint, fakeToolSlot, fakePlant, fakePointGroup, fakePoint, fakeToolSlot, fakeWeed,
} from "../../../__test_support__/fake_state/resources"; } from "../../../__test_support__/fake_state/resources";
import { import {
maybeGetCachedPlantIcon, setImgSrc, maybeGetCachedPlantIcon, setImgSrc,
@ -22,7 +23,8 @@ import { overwrite } from "../../../api/crud";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { imgEvent } from "../../../__test_support__/fake_html_events"; import { imgEvent } from "../../../__test_support__/fake_html_events";
import { error } from "../../../toast/toast"; import { error } from "../../../toast/toast";
import { svgToUrl } from "../../../open_farm/icons"; import { svgToUrl, DEFAULT_ICON } from "../../../open_farm/icons";
import { DEFAULT_WEED_ICON } from "../../map/layers/weeds/garden_weed";
describe("<PointGroupItem/>", () => { describe("<PointGroupItem/>", () => {
const fakeProps = (): PointGroupItemProps => ({ const fakeProps = (): PointGroupItemProps => ({
@ -61,25 +63,36 @@ describe("<PointGroupItem/>", () => {
expect(i.setState).toHaveBeenCalledWith({ icon: "fake icon" }); expect(i.setState).toHaveBeenCalledWith({ icon: "fake icon" });
}); });
it("fetches point icon", () => { it("displays default plant icon", () => {
const p = fakeProps();
p.point = fakePlant();
const wrapper = mount<PointGroupItem>(<PointGroupItem {...p} />);
expect(wrapper.find("img").props().src).toEqual(DEFAULT_ICON);
});
it("displays point icon", () => {
const p = fakeProps(); const p = fakeProps();
p.point = fakePoint(); p.point = fakePoint();
const i = new PointGroupItem(p); const wrapper = mount<PointGroupItem>(<PointGroupItem {...p} />);
const fakeImgEvent = imgEvent(); expect(wrapper.find("img").props().src).toEqual(
i.maybeGetCachedIcon(fakeImgEvent);
expect(maybeGetCachedPlantIcon).not.toHaveBeenCalled();
expect(setImgSrc).toHaveBeenCalledWith(expect.any(Object),
svgToUrl(genericPointIcon(undefined))); svgToUrl(genericPointIcon(undefined)));
}); });
it("fetches other icon", () => { it("displays weed icon", () => {
const p = fakeProps();
p.point = fakeWeed();
p.point.body.meta.color = undefined;
const wrapper = mount<PointGroupItem>(<PointGroupItem {...p} />);
expect(wrapper.find("img").first().props().src).toEqual(DEFAULT_WEED_ICON);
expect(wrapper.find("img").last().props().src).toEqual(
svgToUrl(genericWeedIcon(undefined)));
});
it("displays other icon", () => {
const p = fakeProps(); const p = fakeProps();
p.point = fakeToolSlot(); p.point = fakeToolSlot();
const i = new PointGroupItem(p); const wrapper = mount<PointGroupItem>(<PointGroupItem {...p} />);
const fakeImgEvent = imgEvent(); expect(wrapper.find("img").props().src).toEqual(
i.maybeGetCachedIcon(fakeImgEvent);
expect(maybeGetCachedPlantIcon).not.toHaveBeenCalled();
expect(setImgSrc).toHaveBeenCalledWith(expect.any(Object),
svgToUrl(OTHER_POINT_ICON)); svgToUrl(OTHER_POINT_ICON));
}); });

View File

@ -1,14 +1,14 @@
import { TaggedPointGroup } from "farmbot"; import { TaggedPointGroup, PointType } from "farmbot";
import { PointGroup, Point } from "farmbot/dist/resources/api_resources"; import { PointGroup } from "farmbot/dist/resources/api_resources";
export type PointGroupCriteria = PointGroup["criteria"]; export type PointGroupCriteria = PointGroup["criteria"];
export type StringEqCriteria = PointGroupCriteria["string_eq"]; export type StringEqCriteria = PointGroupCriteria["string_eq"];
export type PointerType = Point["pointer_type"]; export type PointerType = PointType;
export type StrAndNumCriteriaKeys = (keyof Omit<PointGroupCriteria, "day">)[]; export type StrAndNumCriteriaKeys = (keyof Omit<PointGroupCriteria, "day">)[];
export type EqCriteria<T> = Record<string, T[] | undefined>; export type EqCriteria<T> = Record<string, T[] | undefined>;
export const POINTER_TYPES: PointerType[] = export const POINTER_TYPES: PointerType[] =
["Plant", "GenericPointer", "ToolSlot"]; ["Plant", "GenericPointer", "ToolSlot", "Weed"];
export const DEFAULT_CRITERIA: Readonly<PointGroupCriteria> = { export const DEFAULT_CRITERIA: Readonly<PointGroupCriteria> = {
day: { op: "<", days_ago: 0 }, day: { op: "<", days_ago: 0 },

View File

@ -2,6 +2,9 @@ import React from "react";
import { TaggedPointGroup, TaggedPoint } from "farmbot"; import { TaggedPointGroup, TaggedPoint } from "farmbot";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { pointsSelectedByGroup } from "./criteria"; import { pointsSelectedByGroup } from "./criteria";
import { ErrorBoundary } from "../../error_boundary";
import { DevSettings } from "../../account/dev/dev_support";
import { destroy } from "../../api/crud";
export interface GroupInventoryItemProps { export interface GroupInventoryItemProps {
group: TaggedPointGroup; group: TaggedPointGroup;
@ -12,15 +15,30 @@ export interface GroupInventoryItemProps {
} }
export function GroupInventoryItem(props: GroupInventoryItemProps) { export function GroupInventoryItem(props: GroupInventoryItemProps) {
const count = pointsSelectedByGroup(props.group, props.allPoints).length; const { group } = props;
const delMode = DevSettings.quickDeleteEnabled();
return <div return <div
onClick={props.onClick} onClick={delMode ? () => props.dispatch(destroy(group.uuid)) : props.onClick}
className={`group-search-item ${props.hovered ? "hovered" : ""}`}> className={["group-search-item",
props.hovered ? "hovered" : "",
delMode ? "quick-del" : ""].join(" ")}>
<span className="group-search-item-name"> <span className="group-search-item-name">
{props.group.body.name} {group.body.name}
</span> </span>
<i className="group-item-count"> <ErrorBoundary fallback={<i className="group-item-count">{t("? items")}</i>}>
{t("{{count}} items", { count })} <GroupItemCount group={group} allPoints={props.allPoints} />
</i> </ErrorBoundary>
</div>; </div>;
} }
interface GroupItemCountProps {
group: TaggedPointGroup;
allPoints: TaggedPoint[];
}
const GroupItemCount = (props: GroupItemCountProps) => {
const count = pointsSelectedByGroup(props.group, props.allPoints).length;
return <i className="group-item-count">
{t("{{count}} items", { count })}
</i>;
};

View File

@ -1,11 +1,12 @@
import * as React from "react"; import * as React from "react";
import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons"; import { DEFAULT_ICON, svgToUrl } from "../../open_farm/icons";
import { setImgSrc, maybeGetCachedPlantIcon } from "../../open_farm/cached_crop"; import { maybeGetCachedPlantIcon } from "../../open_farm/cached_crop";
import { setHoveredPlant } from "../map/actions"; import { setHoveredPlant } from "../map/actions";
import { TaggedPointGroup, uuid, TaggedPoint } from "farmbot"; import { TaggedPointGroup, uuid, TaggedPoint } from "farmbot";
import { overwrite } from "../../api/crud"; import { overwrite } from "../../api/crud";
import { error } from "../../toast/toast"; import { error } from "../../toast/toast";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { DEFAULT_WEED_ICON } from "../map/layers/weeds/garden_weed";
export interface PointGroupItemProps { export interface PointGroupItemProps {
point: TaggedPoint; point: TaggedPoint;
@ -25,9 +26,23 @@ const removePoint = (group: TaggedPointGroup, pointId: number) => {
export const genericPointIcon = (color: string | undefined) => export const genericPointIcon = (color: string | undefined) =>
`<svg xmlns='http://www.w3.org/2000/svg' `<svg xmlns='http://www.w3.org/2000/svg'
fill='none' stroke-width='1.5' stroke='${color || "gray"}'> fill='none' stroke-width='1.5' stroke='${color || "green"}'>
<circle cx='15' cy='15' r='12' /> <circle cx='15' cy='15' r='12' />
<circle cx='15' cy='15' r='2' /> <circle cx='15' cy='15' r='2' />
</svg>`;
export const genericWeedIcon = (color: string | undefined) =>
`<svg xmlns='http://www.w3.org/2000/svg'>
<defs>
<radialGradient id='WeedGradient'>
<stop offset='90%' stop-color='${color || "red"}'
stop-opacity='0.25'></stop>
<stop offset='100%' stop-color='${color || "red"}'
stop-opacity='0'></stop>
</radialGradient>
</defs>
<circle id='weed-radius' cx='15' cy='15' r='14'
fill='url(#WeedGradient)' opacity='0.5'></circle>
</svg>`; </svg>`;
export const OTHER_POINT_ICON = export const OTHER_POINT_ICON =
@ -66,36 +81,46 @@ export class PointGroupItem
maybeGetCachedIcon = (e: React.SyntheticEvent<HTMLImageElement>) => { maybeGetCachedIcon = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget; const img = e.currentTarget;
switch (this.props.point.body.pointer_type) { if (this.props.point.body.pointer_type == "Plant") {
case "Plant": const slug = this.props.point.body.openfarm_slug;
const slug = this.props.point.body.openfarm_slug; maybeGetCachedPlantIcon(slug, img, this.setIconState);
maybeGetCachedPlantIcon(slug, img, this.setIconState);
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;
} }
}; };
get initIcon() {
switch (this.props.point.body.pointer_type) {
case "Plant":
return DEFAULT_ICON;
case "GenericPointer":
const { color } = this.props.point.body.meta;
return svgToUrl(genericPointIcon(color));
case "Weed":
const weedColor = this.props.point.body.meta.color;
return svgToUrl(genericWeedIcon(weedColor));
default:
return svgToUrl(OTHER_POINT_ICON);
}
}
render() { render() {
return <span return <span
key={this.key} key={this.key}
className={"group-item-icon"}
onMouseEnter={this.enter} onMouseEnter={this.enter}
onMouseLeave={this.leave} onMouseLeave={this.leave}
onClick={this.click}> onClick={this.click}>
{this.props.point.body.pointer_type == "Weed" &&
<img className={"weed-icon"}
src={DEFAULT_WEED_ICON}
width={32}
height={32} />}
<img <img
style={{ style={{
border: this.criteriaIcon ? "1px solid gray" : "none", border: this.criteriaIcon ? "1px solid gray" : "none",
borderRadius: "5px", borderRadius: "5px",
background: this.props.hovered ? "lightgray" : "none", background: this.props.hovered ? "lightgray" : "none",
}} }}
src={DEFAULT_ICON} src={this.initIcon}
onLoad={this.maybeGetCachedIcon} onLoad={this.maybeGetCachedIcon}
width={32} width={32}
height={32} /> height={32} />

View File

@ -23,30 +23,29 @@ import { deletePoints } from "../../../farmware/weed_detector/actions";
import { Actions } from "../../../constants"; import { Actions } from "../../../constants";
import { clickButton } from "../../../__test_support__/helpers"; import { clickButton } from "../../../__test_support__/helpers";
import { fakeState } from "../../../__test_support__/fake_state"; import { fakeState } from "../../../__test_support__/fake_state";
import { CurrentPointPayl } from "../../interfaces"; import { DrawnPointPayl } from "../../interfaces";
import { inputEvent } from "../../../__test_support__/fake_html_events"; import { inputEvent } from "../../../__test_support__/fake_html_events";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
const FAKE_POINT: CurrentPointPayl = const FAKE_POINT: DrawnPointPayl =
({ name: "My Point", cx: 13, cy: 22, r: 345, color: "red" }); ({ name: "My Point", cx: 13, cy: 22, r: 345, color: "red" });
describe("mapStateToProps", () => { describe("mapStateToProps", () => {
it("maps state to props", () => { it("maps state to props: drawn point", () => {
const state = fakeState(); const state = fakeState();
state state.resources.consumers.farm_designer.drawnPoint = FAKE_POINT;
.resources const props = mapStateToProps(state);
.consumers expect(props.drawnPoint?.cx).toEqual(13);
.farm_designer expect(props.drawnPoint?.cy).toEqual(22);
.currentPoint = FAKE_POINT; });
const result = mapStateToProps(state);
const { currentPoint } = result; it("maps state to props: drawn weed", () => {
expect(currentPoint).toBeTruthy(); const state = fakeState();
if (currentPoint) { state.resources.consumers.farm_designer.drawnPoint = undefined;
expect(currentPoint.cx).toEqual(13); state.resources.consumers.farm_designer.drawnWeed = FAKE_POINT;
expect(currentPoint.cy).toEqual(22); const props = mapStateToProps(state);
} else { expect(props.drawnPoint?.cx).toEqual(13);
fail("Nope"); expect(props.drawnPoint?.cy).toEqual(22);
}
}); });
}); });
@ -57,17 +56,11 @@ describe("<CreatePoints />", () => {
const fakeProps = (): CreatePointsProps => ({ const fakeProps = (): CreatePointsProps => ({
dispatch: jest.fn(), dispatch: jest.fn(),
currentPoint: undefined, drawnPoint: undefined,
deviceY: 1.23, deviceY: 1.23,
deviceX: 3.21 deviceX: 3.21
}); });
const fakeInstance = () => {
const props = fakeProps();
props.currentPoint = FAKE_POINT;
return new CreatePoints(props);
};
it("renders for points", () => { it("renders for points", () => {
mockPath = "/app/designer"; mockPath = "/app/designer";
const wrapper = mount(<CreatePoints {...fakeProps()} />); const wrapper = mount(<CreatePoints {...fakeProps()} />);
@ -83,13 +76,15 @@ describe("<CreatePoints />", () => {
}); });
it("updates specific fields", () => { it("updates specific fields", () => {
const i = fakeInstance(); const p = fakeProps();
p.drawnPoint = FAKE_POINT;
const i = new CreatePoints(p);
i.updateValue("color")(inputEvent("cheerful hue")); i.updateValue("color")(inputEvent("cheerful hue"));
expect(i.props.currentPoint).toBeTruthy(); expect(i.props.drawnPoint).toBeTruthy();
const expected = cloneDeep(FAKE_POINT); const expected = cloneDeep(FAKE_POINT);
expected.color = "cheerful hue"; expected.color = "cheerful hue";
expect(i.props.dispatch).toHaveBeenCalledWith({ expect(i.props.dispatch).toHaveBeenCalledWith({
type: "SET_CURRENT_POINT_DATA", type: "SET_DRAWN_POINT_DATA",
payload: expected, payload: expected,
}); });
}); });
@ -103,25 +98,27 @@ describe("<CreatePoints />", () => {
}); });
it("loads default point data", () => { it("loads default point data", () => {
const i = fakeInstance(); const p = fakeProps();
p.drawnPoint = FAKE_POINT;
const i = new CreatePoints(p);
i.loadDefaultPoint(); i.loadDefaultPoint();
expect(i.props.dispatch).toHaveBeenCalledWith({ expect(i.props.dispatch).toHaveBeenCalledWith({
type: "SET_CURRENT_POINT_DATA", type: "SET_DRAWN_POINT_DATA",
payload: { name: "Created Point", color: "green", cx: 1, cy: 1, r: 15 }, payload: { name: "Created Point", color: "green", cx: 1, cy: 1, r: 15 },
}); });
}); });
it("updates point name", () => { it("updates weed name", () => {
mockPath = "/app/designer/weeds/add"; mockPath = "/app/designer/weeds/add";
const p = fakeProps(); const p = fakeProps();
p.currentPoint = { cx: 0, cy: 0, r: 100 }; p.drawnPoint = { cx: 0, cy: 0, r: 100 };
const panel = mount<CreatePoints>(<CreatePoints {...p} />); const panel = mount<CreatePoints>(<CreatePoints {...p} />);
const wrapper = shallow(panel.instance().PointProperties()); const wrapper = shallow(panel.instance().PointProperties());
wrapper.find("BlurableInput").first().simulate("commit", { wrapper.find("BlurableInput").first().simulate("commit", {
currentTarget: { value: "new name" } currentTarget: { value: "new name" }
}); });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_CURRENT_POINT_DATA, payload: { type: Actions.SET_DRAWN_WEED_DATA, payload: {
cx: 0, cy: 0, r: 100, name: "new name", color: "red", cx: 0, cy: 0, r: 100, name: "new name", color: "red",
} }
}); });
@ -148,7 +145,7 @@ describe("<CreatePoints />", () => {
expect(initSave).toHaveBeenCalledWith("Point", { expect(initSave).toHaveBeenCalledWith("Point", {
meta: { color: "red", created_by: "farm-designer", type: "weed" }, meta: { color: "red", created_by: "farm-designer", type: "weed" },
name: "Created Weed", name: "Created Weed",
pointer_type: "GenericPointer", pointer_type: "Weed",
radius: 30, x: 10, y: 20, z: 0, radius: 30, x: 10, y: 20, z: 0,
}); });
}); });
@ -167,7 +164,8 @@ describe("<CreatePoints />", () => {
p.dispatch = jest.fn(x => x()); p.dispatch = jest.fn(x => x());
button.simulate("click"); button.simulate("click");
expect(deletePoints).toHaveBeenCalledWith("points", { expect(deletePoints).toHaveBeenCalledWith("points", {
created_by: "farm-designer", type: "point" pointer_type: "GenericPointer",
meta: { created_by: "farm-designer" }
}); });
}); });
@ -186,26 +184,41 @@ describe("<CreatePoints />", () => {
p.dispatch = jest.fn(x => x()); p.dispatch = jest.fn(x => x());
button.simulate("click"); button.simulate("click");
expect(deletePoints).toHaveBeenCalledWith("points", { expect(deletePoints).toHaveBeenCalledWith("points", {
created_by: "farm-designer", type: "weed" pointer_type: "Weed",
meta: { created_by: "farm-designer" }
}); });
}); });
it("changes color", () => { it("changes point color", () => {
const p = fakeProps(); const p = fakeProps();
p.currentPoint = { cx: 0, cy: 0, r: 0 }; p.drawnPoint = { cx: 0, cy: 0, r: 0 };
const wrapper = mount<CreatePoints>(<CreatePoints {...p} />); const wrapper = mount<CreatePoints>(<CreatePoints {...p} />);
const PP = wrapper.instance().PointProperties; const PP = wrapper.instance().PointProperties;
const component = shallow(<PP />); const component = shallow(<PP />);
component.find("ColorPicker").simulate("change", "red"); component.find("ColorPicker").simulate("change", "red");
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
payload: { color: "red", cx: 0, cy: 0, r: 0 }, payload: { color: "red", cx: 0, cy: 0, r: 0 },
type: Actions.SET_CURRENT_POINT_DATA type: Actions.SET_DRAWN_POINT_DATA
});
});
it("changes weed color", () => {
mockPath = "/app/designer/weeds/add";
const p = fakeProps();
p.drawnPoint = { cx: 0, cy: 0, r: 0 };
const wrapper = mount<CreatePoints>(<CreatePoints {...p} />);
const PP = wrapper.instance().PointProperties;
const component = shallow(<PP />);
component.find("ColorPicker").simulate("change", "red");
expect(p.dispatch).toHaveBeenCalledWith({
payload: { color: "red", cx: 0, cy: 0, r: 0 },
type: Actions.SET_DRAWN_WEED_DATA
}); });
}); });
it("updates value", () => { it("updates value", () => {
const p = fakeProps(); const p = fakeProps();
p.currentPoint = { cx: 0, cy: 0, r: 0 }; p.drawnPoint = { cx: 0, cy: 0, r: 0 };
const wrapper = shallow<CreatePoints>(<CreatePoints {...p} />); const wrapper = shallow<CreatePoints>(<CreatePoints {...p} />);
const PP = wrapper.instance().PointProperties; const PP = wrapper.instance().PointProperties;
const component = shallow(<PP />); const component = shallow(<PP />);
@ -214,13 +227,13 @@ describe("<CreatePoints />", () => {
}); });
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
payload: { cx: 10, cy: 0, r: 0, color: "green" }, payload: { cx: 10, cy: 0, r: 0, color: "green" },
type: Actions.SET_CURRENT_POINT_DATA type: Actions.SET_DRAWN_POINT_DATA
}); });
}); });
it("fills the state with point data", () => { it("fills the state with point data", () => {
const p = fakeProps(); const p = fakeProps();
p.currentPoint = { cx: 1, cy: 2, r: 3, color: "blue" }; p.drawnPoint = { cx: 1, cy: 2, r: 3, color: "blue" };
const wrapper = shallow<CreatePoints>(<CreatePoints {...p} />); const wrapper = shallow<CreatePoints>(<CreatePoints {...p} />);
const i = wrapper.instance(); const i = wrapper.instance();
expect(i.state).toEqual({}); expect(i.state).toEqual({});
@ -239,7 +252,7 @@ describe("<CreatePoints />", () => {
jest.clearAllMocks(); jest.clearAllMocks();
wrapper.unmount(); wrapper.unmount();
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.SET_CURRENT_POINT_DATA, type: Actions.SET_DRAWN_POINT_DATA,
payload: undefined payload: undefined
}); });
}); });

View File

@ -1,4 +1,12 @@
jest.mock("../../../history", () => ({ push: jest.fn() })); let mockPath = "/app/designer/points";
jest.mock("../../../history", () => ({
push: jest.fn(),
getPathArray: () => mockPath.split("/"),
}));
jest.mock("../../map/actions", () => ({
mapPointClickAction: jest.fn(() => jest.fn()),
}));
import * as React from "react"; import * as React from "react";
import { shallow } from "enzyme"; import { shallow } from "enzyme";
@ -8,21 +16,41 @@ import {
import { fakePoint } from "../../../__test_support__/fake_state/resources"; import { fakePoint } from "../../../__test_support__/fake_state/resources";
import { push } from "../../../history"; import { push } from "../../../history";
import { Actions } from "../../../constants"; import { Actions } from "../../../constants";
import { mapPointClickAction } from "../../map/actions";
describe("<PointInventoryItem> />", () => { describe("<PointInventoryItem> />", () => {
const fakeProps = (): PointInventoryItemProps => ({ const fakeProps = (): PointInventoryItemProps => ({
tpp: fakePoint(), tpp: fakePoint(),
dispatch: jest.fn(), dispatch: jest.fn(),
hovered: false, hovered: false,
navName: "points",
}); });
it("navigates to point", () => { it("navigates to point", () => {
mockPath = "/app/designer/points";
const p = fakeProps(); const p = fakeProps();
p.tpp.body.id = 1; p.tpp.body.id = 1;
const wrapper = shallow(<PointInventoryItem {...p} />); const wrapper = shallow(<PointInventoryItem {...p} />);
wrapper.simulate("click"); wrapper.simulate("click");
expect(mapPointClickAction).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/app/designer/points/1"); expect(push).toHaveBeenCalledWith("/app/designer/points/1");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: [p.tpp.uuid],
});
});
it("removes item in box select mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
const wrapper = shallow(<PointInventoryItem {...p} />);
wrapper.simulate("click");
expect(mapPointClickAction).toHaveBeenCalledWith(expect.any(Function),
p.tpp.uuid);
expect(push).not.toHaveBeenCalled();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: undefined,
});
}); });
it("hovers point", () => { it("hovers point", () => {

View File

@ -11,10 +11,10 @@ import {
BlurableInput, BlurableInput,
ColorPicker, ColorPicker,
} from "../../ui/index"; } from "../../ui/index";
import { CurrentPointPayl } from "../interfaces"; import { DrawnPointPayl } from "../interfaces";
import { Actions, Content } from "../../constants"; import { Actions, Content } from "../../constants";
import { deletePoints } from "../../farmware/weed_detector/actions"; import { deletePoints } from "../../farmware/weed_detector/actions";
import { GenericPointer } from "farmbot/dist/resources/api_resources"; import { GenericPointer, WeedPointer } from "farmbot/dist/resources/api_resources";
import { import {
DesignerPanel, DesignerPanel,
DesignerPanelHeader, DesignerPanelHeader,
@ -29,9 +29,10 @@ import { success } from "../../toast/toast";
export function mapStateToProps(props: Everything): CreatePointsProps { export function mapStateToProps(props: Everything): CreatePointsProps {
const { position } = props.bot.hardware.location_data; const { position } = props.bot.hardware.location_data;
const { drawnPoint, drawnWeed } = props.resources.consumers.farm_designer;
return { return {
dispatch: props.dispatch, dispatch: props.dispatch,
currentPoint: props.resources.consumers.farm_designer.currentPoint, drawnPoint: drawnPoint || drawnWeed,
deviceX: position.x || 0, deviceX: position.x || 0,
deviceY: position.y || 0, deviceY: position.y || 0,
}; };
@ -39,14 +40,14 @@ export function mapStateToProps(props: Everything): CreatePointsProps {
export interface CreatePointsProps { export interface CreatePointsProps {
dispatch: Function; dispatch: Function;
currentPoint: CurrentPointPayl | undefined; drawnPoint: DrawnPointPayl | undefined;
deviceX: number; deviceX: number;
deviceY: number; deviceY: number;
} }
type CreatePointsState = Partial<CurrentPointPayl>; type CreatePointsState = Partial<DrawnPointPayl>;
const DEFAULTS: CurrentPointPayl = { const DEFAULTS: DrawnPointPayl = {
name: undefined, name: undefined,
cx: 1, cx: 1,
cy: 1, cy: 1,
@ -61,10 +62,10 @@ export class RawCreatePoints
this.state = {}; this.state = {};
} }
attr = <T extends (keyof CurrentPointPayl & keyof CreatePointsState)>(key: T, attr = <T extends (keyof DrawnPointPayl & keyof CreatePointsState)>(key: T,
fallback = DEFAULTS[key]): CurrentPointPayl[T] => { fallback = DEFAULTS[key]): DrawnPointPayl[T] => {
const p = this.props.currentPoint; const p = this.props.drawnPoint;
const userValue = this.state[key] as CurrentPointPayl[T] | undefined; const userValue = this.state[key] as DrawnPointPayl[T] | undefined;
const propValue = p ? p[key] : fallback; const propValue = p ? p[key] : fallback;
if (typeof userValue === "undefined") { if (typeof userValue === "undefined") {
return propValue; return propValue;
@ -81,7 +82,7 @@ export class RawCreatePoints
get defaultColor() { return this.panel == "weeds" ? "red" : "green"; } get defaultColor() { return this.panel == "weeds" ? "red" : "green"; }
getPointData = (): CurrentPointPayl => { getPointData = (): DrawnPointPayl => {
return { return {
name: this.attr("name"), name: this.attr("name"),
cx: this.attr("cx"), cx: this.attr("cx"),
@ -93,7 +94,9 @@ export class RawCreatePoints
cancel = () => { cancel = () => {
this.props.dispatch({ this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: this.panel == "weeds"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: undefined payload: undefined
}); });
this.setState({ this.setState({
@ -106,14 +109,16 @@ export class RawCreatePoints
loadDefaultPoint = () => { loadDefaultPoint = () => {
this.props.dispatch({ this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: this.panel == "weeds"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: { payload: {
name: this.defaultName, name: this.defaultName,
cx: DEFAULTS.cx, cx: DEFAULTS.cx,
cy: DEFAULTS.cy, cy: DEFAULTS.cy,
r: DEFAULTS.r, r: DEFAULTS.r,
color: this.defaultColor, color: this.defaultColor,
} as CurrentPointPayl } as DrawnPointPayl
}); });
} }
@ -129,7 +134,7 @@ export class RawCreatePoints
updateValue = (key: keyof CreatePointsState) => { updateValue = (key: keyof CreatePointsState) => {
return (e: React.SyntheticEvent<HTMLInputElement>) => { return (e: React.SyntheticEvent<HTMLInputElement>) => {
const { value } = e.currentTarget; const { value } = e.currentTarget;
if (this.props.currentPoint) { if (this.props.drawnPoint) {
const point = this.getPointData(); const point = this.getPointData();
switch (key) { switch (key) {
case "name": case "name":
@ -143,7 +148,9 @@ export class RawCreatePoints
point[key] = intValue; point[key] = intValue;
} }
this.props.dispatch({ this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: this.panel == "weeds"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: point payload: point
}); });
} }
@ -155,7 +162,9 @@ export class RawCreatePoints
const point = this.getPointData(); const point = this.getPointData();
point.color = color; point.color = color;
this.props.dispatch({ this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA, type: this.panel == "weeds"
? Actions.SET_DRAWN_WEED_DATA
: Actions.SET_DRAWN_POINT_DATA,
payload: point payload: point
}); });
} }
@ -163,8 +172,8 @@ export class RawCreatePoints
get panel() { return getPathArray()[3] || "points"; } get panel() { return getPathArray()[3] || "points"; }
createPoint = () => { createPoint = () => {
const body: GenericPointer = { const body: GenericPointer | WeedPointer = {
pointer_type: "GenericPointer", pointer_type: this.panel == "weeds" ? "Weed" : "GenericPointer",
name: this.attr("name") || this.defaultName, name: this.attr("name") || this.defaultName,
meta: { meta: {
color: this.attr("color") || this.defaultColor, color: this.attr("color") || this.defaultColor,
@ -247,8 +256,9 @@ export class RawCreatePoints
</button> </button>
</Row> </Row>
DeleteAllPoints = (type: "point" | "weed") => DeleteAllPoints = (type: "point" | "weed") => {
<Row> const meta = { created_by: "farm-designer" };
return <Row>
<div className="delete-row"> <div className="delete-row">
<label>{t("delete")}</label> <label>{t("delete")}</label>
<p>{type === "weed" <p>{type === "weed"
@ -261,7 +271,8 @@ export class RawCreatePoints
? t("Delete all the weeds you have created?") ? t("Delete all the weeds you have created?")
: t("Delete all the points you have created?"))) { : t("Delete all the points you have created?"))) {
this.props.dispatch(deletePoints("points", { this.props.dispatch(deletePoints("points", {
created_by: "farm-designer", type, pointer_type: type === "weed" ? "Weed" : "GenericPointer",
meta,
})); }));
this.cancel(); this.cancel();
} }
@ -271,7 +282,8 @@ export class RawCreatePoints
: t("Delete all created points")} : t("Delete all created points")}
</button> </button>
</div> </div>
</Row> </Row>;
};
render() { render() {
const panelType = this.panel == "weeds" ? Panel.Weeds : Panel.Points; const panelType = this.panel == "weeds" ? Panel.Weeds : Panel.Points;

View File

@ -3,16 +3,20 @@ import { t } from "../../i18next_wrapper";
import { getDevice } from "../../device"; import { getDevice } from "../../device";
import { destroy, edit, save } from "../../api/crud"; import { destroy, edit, save } from "../../api/crud";
import { ResourceColor } from "../../interfaces"; import { ResourceColor } from "../../interfaces";
import { TaggedGenericPointer } from "farmbot"; import { TaggedGenericPointer, TaggedWeedPointer } from "farmbot";
import { ListItem } from "../plants/plant_panel"; import { ListItem } from "../plants/plant_panel";
import { round } from "lodash"; import { round } from "lodash";
import { Row, Col, BlurableInput, ColorPicker } from "../../ui"; import { Row, Col, BlurableInput, ColorPicker } from "../../ui";
import { parseIntInput } from "../../util"; import { parseIntInput } from "../../util";
import { UUID } from "../../resources/interfaces"; import { UUID } from "../../resources/interfaces";
type PointUpdate =
Partial<TaggedGenericPointer["body"] | TaggedWeedPointer["body"]>;
export const updatePoint = export const updatePoint =
(point: TaggedGenericPointer | undefined, dispatch: Function) => (point: TaggedGenericPointer | TaggedWeedPointer | undefined,
(update: Partial<TaggedGenericPointer["body"]>) => { dispatch: Function) =>
(update: PointUpdate) => {
if (point) { if (point) {
dispatch(edit(point, update)); dispatch(edit(point, update));
dispatch(save(point.uuid)); dispatch(save(point.uuid));
@ -20,18 +24,21 @@ export const updatePoint =
}; };
export interface EditPointPropertiesProps { export interface EditPointPropertiesProps {
point: TaggedGenericPointer; point: TaggedGenericPointer | TaggedWeedPointer;
updatePoint(update: Partial<TaggedGenericPointer["body"]>): void; updatePoint(update: PointUpdate): void;
} }
export const EditPointProperties = (props: EditPointPropertiesProps) => export const EditPointProperties = (props: EditPointPropertiesProps) =>
<ul> <ul>
<li> <li>
<div className={"point-name-input"}> <Row>
<EditPointName <EditPointName
name={props.point.body.name} name={props.point.body.name}
updatePoint={props.updatePoint} /> updatePoint={props.updatePoint} />
</div> <EditPointColor
color={props.point.body.meta.color}
updatePoint={props.updatePoint} />
</Row>
</li> </li>
<ListItem name={t("Location")}> <ListItem name={t("Location")}>
<EditPointLocation <EditPointLocation
@ -43,11 +50,6 @@ export const EditPointProperties = (props: EditPointPropertiesProps) =>
radius={props.point.body.radius} radius={props.point.body.radius}
updatePoint={props.updatePoint} /> updatePoint={props.updatePoint} />
</ListItem> </ListItem>
<ListItem name={t("Color")}>
<EditPointColor
color={props.point.body.meta.color}
updatePoint={props.updatePoint} />
</ListItem>
</ul>; </ul>;
export interface PointActionsProps { export interface PointActionsProps {
@ -76,13 +78,13 @@ export const PointActions = ({ x, y, z, uuid, dispatch }: PointActionsProps) =>
</div>; </div>;
export interface EditPointNameProps { export interface EditPointNameProps {
updatePoint(update: Partial<TaggedGenericPointer["body"]>): void; updatePoint(update: PointUpdate): void;
name: string; name: string;
} }
export const EditPointName = (props: EditPointNameProps) => export const EditPointName = (props: EditPointNameProps) =>
<Row> <div className={"point-name-input"}>
<Col xs={12}> <Col xs={10}>
<label>{t("Name")}</label> <label>{t("Name")}</label>
<BlurableInput <BlurableInput
type="text" type="text"
@ -90,10 +92,10 @@ export const EditPointName = (props: EditPointNameProps) =>
value={props.name} value={props.name}
onCommit={e => props.updatePoint({ name: e.currentTarget.value })} /> onCommit={e => props.updatePoint({ name: e.currentTarget.value })} />
</Col> </Col>
</Row>; </div>;
export interface EditPointLocationProps { export interface EditPointLocationProps {
updatePoint(update: Partial<TaggedGenericPointer["body"]>): void; updatePoint(update: PointUpdate): void;
xyLocation: Record<"x" | "y", number>; xyLocation: Record<"x" | "y", number>;
} }
@ -114,7 +116,7 @@ export const EditPointLocation = (props: EditPointLocationProps) =>
</Row>; </Row>;
export interface EditPointRadiusProps { export interface EditPointRadiusProps {
updatePoint(update: Partial<TaggedGenericPointer["body"]>): void; updatePoint(update: PointUpdate): void;
radius: number; radius: number;
} }
@ -134,13 +136,15 @@ export const EditPointRadius = (props: EditPointRadiusProps) =>
</Row>; </Row>;
export interface EditPointColorProps { export interface EditPointColorProps {
updatePoint(update: Partial<TaggedGenericPointer["body"]>): void; updatePoint(update: PointUpdate): void;
color: string | undefined; color: string | undefined;
} }
export const EditPointColor = (props: EditPointColorProps) => export const EditPointColor = (props: EditPointColorProps) =>
<Row> <div className={"point-color-input"}>
<ColorPicker <Col xs={2}>
current={(props.color || "green") as ResourceColor} <ColorPicker
onChange={color => props.updatePoint({ meta: { color } })} /> current={(props.color || "green") as ResourceColor}
</Row>; onChange={color => props.updatePoint({ meta: { color } })} />
</Col>
</div>;

View File

@ -13,7 +13,6 @@ import {
import { selectAllGenericPointers } from "../../resources/selectors"; import { selectAllGenericPointers } from "../../resources/selectors";
import { TaggedGenericPointer } from "farmbot"; import { TaggedGenericPointer } from "farmbot";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { isAWeed } from "./weeds_inventory";
export interface PointsProps { export interface PointsProps {
genericPoints: TaggedGenericPointer[]; genericPoints: TaggedGenericPointer[];
@ -29,8 +28,7 @@ export function mapStateToProps(props: Everything): PointsProps {
const { hoveredPoint } = props.resources.consumers.farm_designer; const { hoveredPoint } = props.resources.consumers.farm_designer;
return { return {
genericPoints: selectAllGenericPointers(props.resources.index) genericPoints: selectAllGenericPointers(props.resources.index)
.filter(x => !x.body.discarded_at) .filter(x => !x.body.discarded_at),
.filter(x => !isAWeed(x.body.name, x.body.meta.type)),
dispatch: props.dispatch, dispatch: props.dispatch,
hoveredPoint, hoveredPoint,
}; };
@ -65,7 +63,6 @@ export class RawPoints extends React.Component<PointsProps, PointsState> {
.includes(this.state.searchTerm.toLowerCase())) .includes(this.state.searchTerm.toLowerCase()))
.map(p => <PointInventoryItem .map(p => <PointInventoryItem
key={p.uuid} key={p.uuid}
navName={"points"}
tpp={p} tpp={p}
hovered={this.props.hoveredPoint === p.uuid} hovered={this.props.hoveredPoint === p.uuid}
dispatch={this.props.dispatch} />)} dispatch={this.props.dispatch} />)}

View File

@ -3,12 +3,15 @@ import { TaggedGenericPointer } from "farmbot";
import { Saucer } from "../../ui"; import { Saucer } from "../../ui";
import { Actions } from "../../constants"; import { Actions } from "../../constants";
import { push } from "../../history"; import { push } from "../../history";
import { t } from "../../i18next_wrapper";
import { getMode } from "../map/util";
import { Mode } from "../map/interfaces";
import { mapPointClickAction } from "../map/actions";
export interface PointInventoryItemProps { export interface PointInventoryItemProps {
tpp: TaggedGenericPointer; tpp: TaggedGenericPointer;
dispatch: Function; dispatch: Function;
hovered: boolean; hovered: boolean;
navName: "points" | "weeds";
} }
// The individual points that show up in the farm designer sub nav. // The individual points that show up in the farm designer sub nav.
@ -29,13 +32,15 @@ export class PointInventoryItem extends
}; };
const click = () => { const click = () => {
push(`/app/designer/${this.props.navName}/${pointId}`); if (getMode() == Mode.boxSelect) {
dispatch({ type: Actions.TOGGLE_HOVERED_POINT, payload: [tpp.uuid] }); mapPointClickAction(dispatch, tpp.uuid)();
toggle("leave");
} else {
push(`/app/designer/points/${pointId}`);
dispatch({ type: Actions.TOGGLE_HOVERED_POINT, payload: [tpp.uuid] });
}
}; };
// Name given from OpenFarm's API.
const label = point.name || "Unknown plant";
return <div return <div
className={`point-search-item ${this.props.hovered ? "hovered" : ""}`} className={`point-search-item ${this.props.hovered ? "hovered" : ""}`}
key={pointId} key={pointId}
@ -44,7 +49,7 @@ export class PointInventoryItem extends
onClick={click}> onClick={click}>
<Saucer color={point.meta.color || "green"} /> <Saucer color={point.meta.color || "green"} />
<span className="point-search-item-name"> <span className="point-search-item-name">
{label} {point.name || t("Untitled point")}
</span> </span>
<p className="point-search-item-info"> <p className="point-search-item-info">
<i>{`(${point.x}, ${point.y}) ⌀${point.radius * 2}`}</i> <i>{`(${point.x}, ${point.y}) ⌀${point.radius * 2}`}</i>

View File

@ -1,14 +1,16 @@
import { CropLiveSearchResult, CurrentPointPayl } from "./interfaces"; import { CropLiveSearchResult, DrawnPointPayl, DrawnWeedPayl } from "./interfaces";
import { generateReducer } from "../redux/generate_reducer"; import { generateReducer } from "../redux/generate_reducer";
import { DesignerState, HoveredPlantPayl } from "./interfaces"; import { DesignerState, HoveredPlantPayl } from "./interfaces";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { TaggedResource } from "farmbot"; import { TaggedResource, PointType } from "farmbot";
import { Actions } from "../constants"; import { Actions } from "../constants";
import { BotPosition } from "../devices/interfaces"; import { BotPosition } from "../devices/interfaces";
import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
import { UUID } from "../resources/interfaces";
export const initialState: DesignerState = { export const initialState: DesignerState = {
selectedPlants: undefined, selectedPoints: undefined,
selectionPointType: undefined,
hoveredPlant: { hoveredPlant: {
plantUUID: undefined, plantUUID: undefined,
icon: "" icon: ""
@ -20,7 +22,8 @@ export const initialState: DesignerState = {
cropSearchResults: [], cropSearchResults: [],
cropSearchInProgress: false, cropSearchInProgress: false,
chosenLocation: { x: undefined, y: undefined, z: undefined }, chosenLocation: { x: undefined, y: undefined, z: undefined },
currentPoint: undefined, drawnPoint: undefined,
drawnWeed: undefined,
openedSavedGarden: undefined, openedSavedGarden: undefined,
tryGroupSortType: undefined, tryGroupSortType: undefined,
editGroupAreaInMap: false, editGroupAreaInMap: false,
@ -41,10 +44,15 @@ export const designer = generateReducer<DesignerState>(initialState)
s.cropSearchInProgress = false; s.cropSearchInProgress = false;
return s; return s;
}) })
.add<string[] | undefined>(Actions.SELECT_PLANT, (s, { payload }) => { .add<UUID[] | undefined>(Actions.SELECT_POINT, (s, { payload }) => {
s.selectedPlants = payload; s.selectedPoints = payload;
return s; return s;
}) })
.add<PointType[] | undefined>(
Actions.SET_SELECTION_POINT_TYPE, (s, { payload }) => {
s.selectionPointType = payload;
return s;
})
.add<HoveredPlantPayl>(Actions.TOGGLE_HOVERED_PLANT, (s, { payload }) => { .add<HoveredPlantPayl>(Actions.TOGGLE_HOVERED_PLANT, (s, { payload }) => {
s.hoveredPlant = payload; s.hoveredPlant = payload;
return s; return s;
@ -61,12 +69,20 @@ export const designer = generateReducer<DesignerState>(initialState)
s.hoveredToolSlot = payload; s.hoveredToolSlot = payload;
return s; return s;
}) })
.add<CurrentPointPayl | undefined>( .add<DrawnPointPayl | undefined>(
Actions.SET_CURRENT_POINT_DATA, (s, { payload }) => { Actions.SET_DRAWN_POINT_DATA, (s, { payload }) => {
const { color } = (!payload || !payload.color) ? const { color } = (!payload || !payload.color) ?
(s.currentPoint || { color: "green" }) : payload; (s.drawnPoint || { color: "green" }) : payload;
s.currentPoint = payload; s.drawnPoint = payload;
s.currentPoint && (s.currentPoint.color = color); s.drawnPoint && (s.drawnPoint.color = color);
return s;
})
.add<DrawnWeedPayl | undefined>(
Actions.SET_DRAWN_WEED_DATA, (s, { payload }) => {
const { color } = (!payload || !payload.color) ?
(s.drawnWeed || { color: "red" }) : payload;
s.drawnWeed = payload;
s.drawnWeed && (s.drawnWeed.color = color);
return s; return s;
}) })
.add<CropLiveSearchResult[]>(Actions.OF_SEARCH_RESULTS_OK, (s, a) => { .add<CropLiveSearchResult[]>(Actions.OF_SEARCH_RESULTS_OK, (s, a) => {
@ -75,7 +91,7 @@ export const designer = generateReducer<DesignerState>(initialState)
return s; return s;
}) })
.add<TaggedResource>(Actions.DESTROY_RESOURCE_OK, (s) => { .add<TaggedResource>(Actions.DESTROY_RESOURCE_OK, (s) => {
s.selectedPlants = undefined; s.selectedPoints = undefined;
s.hoveredPlant = { plantUUID: undefined, icon: "" }; s.hoveredPlant = { plantUUID: undefined, icon: "" };
return s; return s;
}) })

View File

@ -125,7 +125,7 @@ describe("<SavedGardenHUD />", () => {
clickButton(wrapper, 1, "edit"); clickButton(wrapper, 1, "edit");
expect(history.push).toHaveBeenCalledWith("/app/designer/plants"); expect(history.push).toHaveBeenCalledWith("/app/designer/plants");
expect(dispatch).toHaveBeenCalledWith({ expect(dispatch).toHaveBeenCalledWith({
type: Actions.SELECT_PLANT, type: Actions.SELECT_POINT,
payload: undefined payload: undefined
}); });
}); });

View File

@ -14,6 +14,7 @@ import {
selectAllPointGroups, selectAllPointGroups,
getDeviceAccountSettings, getDeviceAccountSettings,
maybeFindToolById, maybeFindToolById,
selectAllWeedPointers,
} from "../resources/selectors"; } from "../resources/selectors";
import { validBotLocationData, validFwConfig, unpackUUID } from "../util"; import { validBotLocationData, validFwConfig, unpackUUID } from "../util";
import { getWebAppConfigValue } from "../config_storage/actions"; import { getWebAppConfigValue } from "../config_storage/actions";
@ -44,10 +45,10 @@ export function mapStateToProps(props: Everything): Props {
const plants = getPlants(props.resources); const plants = getPlants(props.resources);
const findPlant = plantFinder(plants); const findPlant = plantFinder(plants);
const { selectedPlants } = props.resources.consumers.farm_designer; const { selectedPoints } = props.resources.consumers.farm_designer;
const selectedPlant = selectedPlants ? findPlant(selectedPlants[0]) : undefined; const selectedPlant = selectedPoints ? findPlant(selectedPoints[0]) : undefined;
const { plantUUID } = props.resources.consumers.farm_designer.hoveredPlant;
const { plantUUID } = props.resources.consumers.farm_designer.hoveredPlant;
const hoveredPlant = findPlant(plantUUID); const hoveredPlant = findPlant(plantUUID);
const getConfigValue = getWebAppConfigValue(() => props); const getConfigValue = getWebAppConfigValue(() => props);
@ -55,6 +56,7 @@ export function mapStateToProps(props: Everything): Props {
const genericPoints = getConfigValue(BooleanSetting.show_historic_points) const genericPoints = getConfigValue(BooleanSetting.show_historic_points)
? allGenericPoints ? allGenericPoints
: allGenericPoints.filter(x => !x.body.discarded_at); : allGenericPoints.filter(x => !x.body.discarded_at);
const weeds = selectAllWeedPointers(props.resources.index);
const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index)); const fwConfig = validFwConfig(getFirmwareConfig(props.resources.index));
const { mcu_params } = props.bot.hardware; const { mcu_params } = props.bot.hardware;
@ -113,6 +115,7 @@ export function mapStateToProps(props: Everything): Props {
selectedPlant, selectedPlant,
designer: props.resources.consumers.farm_designer, designer: props.resources.consumers.farm_designer,
genericPoints, genericPoints,
weeds,
allPoints: selectAllPoints(props.resources.index), allPoints: selectAllPoints(props.resources.index),
toolSlots: joinToolsAndSlot(props.resources.index), toolSlots: joinToolsAndSlot(props.resources.index),
hoveredPlant, hoveredPlant,

View File

@ -1,4 +1,10 @@
jest.mock("../../../api/crud", () => ({ initSave: jest.fn() })); let mockSave = () => Promise.resolve();
jest.mock("../../../api/crud", () => ({
initSave: jest.fn(),
init: jest.fn(() => ({ payload: { uuid: "fake uuid" } })),
save: jest.fn(() => mockSave),
destroy: jest.fn(),
}));
jest.mock("../../../history", () => ({ history: { push: jest.fn() } })); jest.mock("../../../history", () => ({ history: { push: jest.fn() } }));
@ -7,7 +13,7 @@ import { mount, shallow } from "enzyme";
import { RawAddTool as AddTool, mapStateToProps } from "../add_tool"; import { RawAddTool as AddTool, mapStateToProps } from "../add_tool";
import { fakeState } from "../../../__test_support__/fake_state"; import { fakeState } from "../../../__test_support__/fake_state";
import { SaveBtn } from "../../../ui"; import { SaveBtn } from "../../../ui";
import { initSave } from "../../../api/crud"; import { initSave, init, destroy } from "../../../api/crud";
import { history } from "../../../history"; import { history } from "../../../history";
import { FirmwareHardware } from "farmbot"; import { FirmwareHardware } from "farmbot";
import { AddToolProps } from "../interfaces"; import { AddToolProps } from "../interfaces";
@ -32,11 +38,47 @@ describe("<AddTool />", () => {
expect(wrapper.state().toolName).toEqual("new name"); expect(wrapper.state().toolName).toEqual("new name");
}); });
it("saves", () => { it("disables save until name in entered", () => {
const wrapper = shallow(<AddTool {...fakeProps()} />); const wrapper = shallow<AddTool>(<AddTool {...fakeProps()} />);
expect(wrapper.state().toolName).toEqual("");
expect(wrapper.find("SaveBtn").first().props().disabled).toBeTruthy();
wrapper.setState({ toolName: "fake tool name" });
expect(wrapper.find("SaveBtn").first().props().disabled).toBeFalsy();
});
it("shows name collision message", () => {
const p = fakeProps();
p.existingToolNames = ["tool"];
const wrapper = shallow<AddTool>(<AddTool {...p} />);
wrapper.setState({ toolName: "tool" });
expect(wrapper.find("p").first().text()).toEqual("Already added.");
expect(wrapper.find("SaveBtn").first().props().disabled).toBeTruthy();
});
it("saves", async () => {
mockSave = () => Promise.resolve();
const p = fakeProps();
p.dispatch = jest.fn(x => typeof x === "function" && x());
const wrapper = shallow<AddTool>(<AddTool {...p} />);
wrapper.setState({ toolName: "Foo" }); wrapper.setState({ toolName: "Foo" });
wrapper.find(SaveBtn).simulate("click"); await wrapper.find(SaveBtn).simulate("click");
expect(initSave).toHaveBeenCalledWith("Tool", { name: "Foo" }); expect(init).toHaveBeenCalledWith("Tool", { name: "Foo" });
expect(wrapper.state().uuid).toEqual(undefined);
expect(history.push).toHaveBeenCalledWith("/app/designer/tools");
});
it("removes unsaved tool on exit", async () => {
mockSave = () => Promise.reject();
const p = fakeProps();
p.dispatch = jest.fn(x => typeof x === "function" && x());
const wrapper = shallow<AddTool>(<AddTool {...p} />);
wrapper.setState({ toolName: "Foo" });
await wrapper.find(SaveBtn).simulate("click");
expect(init).toHaveBeenCalledWith("Tool", { name: "Foo" });
expect(wrapper.state().uuid).toEqual("fake uuid");
expect(history.push).not.toHaveBeenCalled();
wrapper.unmount();
expect(destroy).toHaveBeenCalledWith("fake uuid");
}); });
it.each<[FirmwareHardware, number]>([ it.each<[FirmwareHardware, number]>([

View File

@ -38,6 +38,7 @@ describe("<EditTool />", () => {
dispatch: jest.fn(), dispatch: jest.fn(),
mountedToolId: undefined, mountedToolId: undefined,
isActive: jest.fn(), isActive: jest.fn(),
existingToolNames: [],
}); });
it("renders", () => { it("renders", () => {
@ -71,6 +72,23 @@ describe("<EditTool />", () => {
expect(wrapper.state().toolName).toEqual("new name"); expect(wrapper.state().toolName).toEqual("new name");
}); });
it("disables save until name in entered", () => {
const wrapper = shallow<EditTool>(<EditTool {...fakeProps()} />);
wrapper.setState({ toolName: "" });
expect(wrapper.find("SaveBtn").first().props().disabled).toBeTruthy();
wrapper.setState({ toolName: "fake tool name" });
expect(wrapper.find("SaveBtn").first().props().disabled).toBeFalsy();
});
it("shows name collision message", () => {
const p = fakeProps();
p.existingToolNames = ["tool"];
const wrapper = shallow<EditTool>(<EditTool {...p} />);
wrapper.setState({ toolName: "tool" });
expect(wrapper.find("p").first().text()).toEqual("Name already taken.");
expect(wrapper.find("SaveBtn").first().props().disabled).toBeTruthy();
});
it("saves", () => { it("saves", () => {
const wrapper = shallow(<EditTool {...fakeProps()} />); const wrapper = shallow(<EditTool {...fakeProps()} />);
wrapper.find(SaveBtn).simulate("click"); wrapper.find(SaveBtn).simulate("click");

View File

@ -1,6 +1,7 @@
let mockPath = "/app/designer/tools";
jest.mock("../../../history", () => ({ jest.mock("../../../history", () => ({
history: { push: jest.fn() }, history: { push: jest.fn() },
getPathArray: () => "/app/designer/tools".split("/"), getPathArray: () => mockPath.split("/"),
})); }));
jest.mock("../../../api/crud", () => ({ jest.mock("../../../api/crud", () => ({
@ -8,6 +9,10 @@ jest.mock("../../../api/crud", () => ({
save: jest.fn(), save: jest.fn(),
})); }));
jest.mock("../../map/actions", () => ({
mapPointClickAction: jest.fn(() => jest.fn()),
}));
const mockDevice = { readPin: jest.fn(() => Promise.resolve()) }; const mockDevice = { readPin: jest.fn(() => Promise.resolve()) };
jest.mock("../../../device", () => ({ getDevice: () => mockDevice })); jest.mock("../../../device", () => ({ getDevice: () => mockDevice }));
@ -28,6 +33,7 @@ import { Content, Actions } from "../../../constants";
import { edit, save } from "../../../api/crud"; import { edit, save } from "../../../api/crud";
import { ToolSelection } from "../tool_slot_edit_components"; import { ToolSelection } from "../tool_slot_edit_components";
import { ToolsProps } from "../interfaces"; import { ToolsProps } from "../interfaces";
import { mapPointClickAction } from "../../map/actions";
describe("<Tools />", () => { describe("<Tools />", () => {
const fakeProps = (): ToolsProps => ({ const fakeProps = (): ToolsProps => ({
@ -188,7 +194,9 @@ describe("<Tools />", () => {
it("displays tool as active", () => { it("displays tool as active", () => {
const p = fakeProps(); const p = fakeProps();
p.tools = [fakeTool()]; const tool = fakeTool();
tool.body.id = 1;
p.tools = [tool];
p.isActive = () => true; p.isActive = () => true;
p.device.body.mounted_tool_id = undefined; p.device.body.mounted_tool_id = undefined;
const wrapper = mount(<Tools {...p} />); const wrapper = mount(<Tools {...p} />);
@ -243,4 +251,37 @@ describe("<ToolSlotInventoryItem />", () => {
wrapper.find(".tool-selection-wrapper").first().simulate("click", e); wrapper.find(".tool-selection-wrapper").first().simulate("click", e);
expect(e.stopPropagation).toHaveBeenCalled(); expect(e.stopPropagation).toHaveBeenCalled();
}); });
it("shows tool name", () => {
const p = fakeProps();
p.hideDropdown = true;
const wrapper = mount(<ToolSlotInventoryItem {...p} />);
expect(wrapper.text().toLowerCase()).toContain("empty");
});
it("opens tool slot", () => {
mockPath = "/app/designer/tool-slots";
const p = fakeProps();
p.toolSlot.body.id = 1;
const wrapper = shallow(<ToolSlotInventoryItem {...p} />);
wrapper.find("div").first().simulate("click");
expect(mapPointClickAction).not.toHaveBeenCalled();
expect(history.push).toHaveBeenCalledWith("/app/designer/tool-slots/1");
expect(p.dispatch).not.toHaveBeenCalled();
});
it("removes item in box select mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
p.toolSlot.body.id = 1;
const wrapper = shallow(<ToolSlotInventoryItem {...p} />);
wrapper.find("div").first().simulate("click");
expect(mapPointClickAction).toHaveBeenCalledWith(expect.any(Function),
p.toolSlot.uuid);
expect(history.push).not.toHaveBeenCalled();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.HOVER_TOOL_SLOT,
payload: undefined,
});
});
}); });

View File

@ -7,7 +7,7 @@ import { Everything } from "../../interfaces";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { SaveBtn } from "../../ui"; import { SaveBtn } from "../../ui";
import { SpecialStatus } from "farmbot"; import { SpecialStatus } from "farmbot";
import { initSave } from "../../api/crud"; import { initSave, destroy, init, save } from "../../api/crud";
import { Panel } from "../panel_header"; import { Panel } from "../panel_header";
import { history } from "../../history"; import { history } from "../../history";
import { selectAllTools } from "../../resources/selectors"; import { selectAllTools } from "../../resources/selectors";
@ -27,7 +27,7 @@ export const mapStateToProps = (props: Everything): AddToolProps => ({
}); });
export class RawAddTool extends React.Component<AddToolProps, AddToolState> { export class RawAddTool extends React.Component<AddToolProps, AddToolState> {
state: AddToolState = { toolName: "", toAdd: [] }; state: AddToolState = { toolName: "", toAdd: [], uuid: undefined };
filterExisting = (n: string) => !this.props.existingToolNames.includes(n); filterExisting = (n: string) => !this.props.existingToolNames.includes(n);
@ -41,15 +41,23 @@ export class RawAddTool extends React.Component<AddToolProps, AddToolState> {
toAdd: this.stockToolNames().filter(this.filterExisting) toAdd: this.stockToolNames().filter(this.filterExisting)
}); });
newTool = (name: string) => { newTool = (name: string) => this.props.dispatch(initSave("Tool", { name }));
this.props.dispatch(initSave("Tool", { name }));
};
save = () => { save = () => {
this.newTool(this.state.toolName); const initTool = init("Tool", { name: this.state.toolName });
history.push("/app/designer/tools"); this.props.dispatch(initTool);
const { uuid } = initTool.payload;
this.setState({ uuid });
this.props.dispatch(save(uuid))
.then(() => {
this.setState({ uuid: undefined });
history.push("/app/designer/tools");
}).catch(() => { });
} }
componentWillUnmount = () =>
this.state.uuid && this.props.dispatch(destroy(this.state.uuid));
stockToolNames = () => { stockToolNames = () => {
switch (this.props.firmwareHardware) { switch (this.props.firmwareHardware) {
case "arduino": case "arduino":
@ -123,6 +131,8 @@ export class RawAddTool extends React.Component<AddToolProps, AddToolState> {
} }
render() { render() {
const { toolName, uuid } = this.state;
const alreadyAdded = !uuid && !this.filterExisting(toolName);
const panelName = "add-tool"; const panelName = "add-tool";
return <DesignerPanel panelName={panelName} panel={Panel.Tools}> return <DesignerPanel panelName={panelName} panel={Panel.Tools}>
<DesignerPanelHeader <DesignerPanelHeader
@ -138,7 +148,13 @@ export class RawAddTool extends React.Component<AddToolProps, AddToolState> {
name="name" name="name"
onChange={e => onChange={e =>
this.setState({ toolName: e.currentTarget.value })} /> this.setState({ toolName: e.currentTarget.value })} />
<SaveBtn onClick={this.save} status={SpecialStatus.DIRTY} /> <SaveBtn
onClick={this.save}
disabled={!this.state.toolName || alreadyAdded}
status={SpecialStatus.DIRTY} />
<p className="name-error">
{alreadyAdded ? t("Already added.") : ""}
</p>
</div> </div>
<this.AddStockTools /> <this.AddStockTools />
</DesignerPanelContent> </DesignerPanelContent>

View File

@ -9,6 +9,7 @@ import { getPathArray } from "../../history";
import { TaggedTool, SpecialStatus, TaggedToolSlotPointer } from "farmbot"; import { TaggedTool, SpecialStatus, TaggedToolSlotPointer } from "farmbot";
import { import {
maybeFindToolById, getDeviceAccountSettings, selectAllToolSlotPointers, maybeFindToolById, getDeviceAccountSettings, selectAllToolSlotPointers,
selectAllTools,
} from "../../resources/selectors"; } from "../../resources/selectors";
import { SaveBtn } from "../../ui"; import { SaveBtn } from "../../ui";
import { edit, destroy } from "../../api/crud"; import { edit, destroy } from "../../api/crud";
@ -17,6 +18,7 @@ import { Panel } from "../panel_header";
import { ToolSVG } from "../map/layers/tool_slots/tool_graphics"; import { ToolSVG } from "../map/layers/tool_slots/tool_graphics";
import { error } from "../../toast/toast"; import { error } from "../../toast/toast";
import { EditToolProps, EditToolState } from "./interfaces"; import { EditToolProps, EditToolState } from "./interfaces";
import { betterCompact } from "../../util";
export const isActive = (toolSlots: TaggedToolSlotPointer[]) => export const isActive = (toolSlots: TaggedToolSlotPointer[]) =>
(toolId: number | undefined) => (toolId: number | undefined) =>
@ -29,6 +31,8 @@ export const mapStateToProps = (props: Everything): EditToolProps => ({
mountedToolId: getDeviceAccountSettings(props.resources.index) mountedToolId: getDeviceAccountSettings(props.resources.index)
.body.mounted_tool_id, .body.mounted_tool_id,
isActive: isActive(selectAllToolSlotPointers(props.resources.index)), isActive: isActive(selectAllToolSlotPointers(props.resources.index)),
existingToolNames: betterCompact(selectAllTools(props.resources.index)
.map(tool => tool.body.name)),
}); });
export class RawEditTool extends React.Component<EditToolProps, EditToolState> { export class RawEditTool extends React.Component<EditToolProps, EditToolState> {
@ -53,18 +57,26 @@ export class RawEditTool extends React.Component<EditToolProps, EditToolState> {
? t("Cannot delete while mounted.") ? t("Cannot delete while mounted.")
: t("Cannot delete while in a slot."); : t("Cannot delete while in a slot.");
const activeOrMounted = this.props.isActive(tool.body.id) || isMounted; const activeOrMounted = this.props.isActive(tool.body.id) || isMounted;
const nameTaken = this.props.existingToolNames
.filter(x => x != tool.body.name).includes(this.state.toolName);
return <this.PanelWrapper> return <this.PanelWrapper>
<ToolSVG toolName={this.state.toolName} /> <div className="edit-tool">
<label>{t("Name")}</label> <ToolSVG toolName={this.state.toolName} />
<input name="name" <label>{t("Name")}</label>
value={toolName} <input name="name"
onChange={e => this.setState({ toolName: e.currentTarget.value })} /> value={toolName}
<SaveBtn onChange={e => this.setState({ toolName: e.currentTarget.value })} />
onClick={() => { <SaveBtn
dispatch(edit(tool, { name: toolName })); onClick={() => {
history.push("/app/designer/tools"); this.props.dispatch(edit(tool, { name: toolName }));
}} history.push("/app/designer/tools");
status={SpecialStatus.DIRTY} /> }}
disabled={!this.state.toolName || nameTaken}
status={SpecialStatus.DIRTY} />
<p className="name-error">
{nameTaken ? t("Name already taken.") : ""}
</p>
</div>
<button <button
className={`fb-button red no-float ${activeOrMounted className={`fb-button red no-float ${activeOrMounted
? "pseudo-disabled" : ""}`} ? "pseudo-disabled" : ""}`}

View File

@ -26,6 +26,9 @@ import { hasUTM } from "../../devices/components/firmware_hardware_support";
import { ToolsProps, ToolsState } from "./interfaces"; import { ToolsProps, ToolsState } from "./interfaces";
import { mapStateToProps } from "./state_to_props"; import { mapStateToProps } from "./state_to_props";
import { BotOriginQuadrant } from "../interfaces"; import { BotOriginQuadrant } from "../interfaces";
import { mapPointClickAction } from "../map/actions";
import { getMode } from "../map/util";
import { Mode } from "../map/interfaces";
const toolStatus = (value: number | undefined): string => { const toolStatus = (value: number | undefined): string => {
switch (value) { switch (value) {
@ -143,6 +146,7 @@ export class RawTools extends React.Component<ToolsProps, ToolsState> {
.filter(tool => !tool.body.name || .filter(tool => !tool.body.name ||
tool.body.name && tool.body.name.toLowerCase() tool.body.name && tool.body.name.toLowerCase()
.includes(this.state.searchTerm.toLowerCase())) .includes(this.state.searchTerm.toLowerCase()))
.filter(tool => tool.body.id)
.map(tool => .map(tool =>
<ToolInventoryItem key={tool.uuid} <ToolInventoryItem key={tool.uuid}
toolId={tool.body.id} toolId={tool.body.id}
@ -206,6 +210,7 @@ export interface ToolSlotInventoryItemProps {
isActive(id: number | undefined): boolean; isActive(id: number | undefined): boolean;
xySwap: boolean; xySwap: boolean;
quadrant: BotOriginQuadrant; quadrant: BotOriginQuadrant;
hideDropdown?: boolean;
} }
export const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => { export const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => {
@ -214,7 +219,14 @@ export const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => {
.filter(tool => tool.body.id == tool_id)[0]?.body.name; .filter(tool => tool.body.id == tool_id)[0]?.body.name;
return <div return <div
className={`tool-slot-search-item ${props.hovered ? "hovered" : ""}`} className={`tool-slot-search-item ${props.hovered ? "hovered" : ""}`}
onClick={() => history.push(`/app/designer/tool-slots/${id}`)} onClick={() => {
if (getMode() == Mode.boxSelect) {
mapPointClickAction(props.dispatch, props.toolSlot.uuid)();
props.dispatch(setToolHover(undefined));
} else {
history.push(`/app/designer/tool-slots/${id}`);
}
}}
onMouseEnter={() => props.dispatch(setToolHover(props.toolSlot.uuid))} onMouseEnter={() => props.dispatch(setToolHover(props.toolSlot.uuid))}
onMouseLeave={() => props.dispatch(setToolHover(undefined))}> onMouseLeave={() => props.dispatch(setToolHover(undefined))}>
<Row> <Row>
@ -225,20 +237,24 @@ export const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => {
xySwap={props.xySwap} quadrant={props.quadrant} /> xySwap={props.xySwap} quadrant={props.quadrant} />
</Col> </Col>
<Col xs={6}> <Col xs={6}>
<div className={"tool-selection-wrapper"} {props.hideDropdown
onClick={e => e.stopPropagation()}> ? <span className={"tool-slot-search-item-name"}>
<ToolSelection {toolName || t("Empty")}
tools={props.tools} </span>
selectedTool={props.tools : <div className={"tool-selection-wrapper"}
.filter(tool => tool.body.id == tool_id)[0]} onClick={e => e.stopPropagation()}>
onChange={update => { <ToolSelection
props.dispatch(edit(props.toolSlot, update)); tools={props.tools}
props.dispatch(save(props.toolSlot.uuid)); selectedTool={props.tools
}} .filter(tool => tool.body.id == tool_id)[0]}
isActive={props.isActive} onChange={update => {
filterSelectedTool={false} props.dispatch(edit(props.toolSlot, update));
filterActiveTools={true} /> props.dispatch(save(props.toolSlot.uuid));
</div> }}
isActive={props.isActive}
filterSelectedTool={false}
filterActiveTools={true} />
</div>}
</Col> </Col>
<Col xs={4} className={"tool-slot-position-info"}> <Col xs={4} className={"tool-slot-position-info"}>
<p className="tool-slot-position"> <p className="tool-slot-position">

View File

@ -19,6 +19,7 @@ export interface AddToolProps {
export interface AddToolState { export interface AddToolState {
toolName: string; toolName: string;
toAdd: string[]; toAdd: string[];
uuid: UUID | undefined;
} }
export interface EditToolProps { export interface EditToolProps {
@ -26,6 +27,7 @@ export interface EditToolProps {
dispatch: Function; dispatch: Function;
mountedToolId: number | undefined; mountedToolId: number | undefined;
isActive(id: number | undefined): boolean; isActive(id: number | undefined): boolean;
existingToolNames: string[];
} }
export interface EditToolState { export interface EditToolState {

View File

@ -0,0 +1,81 @@
let mockPath = "/app/designer/weeds";
jest.mock("../../../history", () => ({
push: jest.fn(),
getPathArray: () => mockPath.split("/"),
}));
jest.mock("../../map/actions", () => ({
mapPointClickAction: jest.fn(() => jest.fn()),
}));
import * as React from "react";
import { shallow } from "enzyme";
import {
WeedInventoryItem, WeedInventoryItemProps,
} from "../weed_inventory_item";
import { fakeWeed } from "../../../__test_support__/fake_state/resources";
import { push } from "../../../history";
import { Actions } from "../../../constants";
import { mapPointClickAction } from "../../map/actions";
describe("<WeedInventoryItem /> />", () => {
const fakeProps = (): WeedInventoryItemProps => ({
tpp: fakeWeed(),
dispatch: jest.fn(),
hovered: false,
});
it("navigates to weed", () => {
const p = fakeProps();
p.tpp.body.id = 1;
const wrapper = shallow(<WeedInventoryItem {...p} />);
wrapper.simulate("click");
expect(mapPointClickAction).not.toHaveBeenCalled();
expect(push).toHaveBeenCalledWith("/app/designer/weeds/1");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: [p.tpp.uuid],
});
});
it("removes item in box select mode", () => {
mockPath = "/app/designer/plants/select";
const p = fakeProps();
const wrapper = shallow(<WeedInventoryItem {...p} />);
wrapper.simulate("click");
expect(mapPointClickAction).toHaveBeenCalledWith(expect.any(Function),
p.tpp.uuid);
expect(push).not.toHaveBeenCalled();
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT,
payload: undefined,
});
});
it("hovers weed", () => {
const p = fakeProps();
p.tpp.body.id = 1;
const wrapper = shallow(<WeedInventoryItem {...p} />);
wrapper.simulate("mouseEnter");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT, payload: p.tpp.uuid
});
});
it("shows hovered", () => {
const p = fakeProps();
p.hovered = true;
const wrapper = shallow(<WeedInventoryItem {...p} />);
expect(wrapper.hasClass("hovered")).toBeTruthy();
});
it("un-hovers weed", () => {
const p = fakeProps();
p.tpp.body.id = 1;
const wrapper = shallow(<WeedInventoryItem {...p} />);
wrapper.simulate("mouseLeave");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.TOGGLE_HOVERED_POINT, payload: undefined
});
});
});

View File

@ -9,7 +9,7 @@ import { mount, shallow } from "enzyme";
import { import {
RawEditWeed as EditWeed, EditWeedProps, mapStateToProps, RawEditWeed as EditWeed, EditWeedProps, mapStateToProps,
} from "../weeds_edit"; } from "../weeds_edit";
import { fakePoint } from "../../../__test_support__/fake_state/resources"; import { fakeWeed } from "../../../__test_support__/fake_state/resources";
import { fakeState } from "../../../__test_support__/fake_state"; import { fakeState } from "../../../__test_support__/fake_state";
import { import {
buildResourceIndex, buildResourceIndex,
@ -32,9 +32,9 @@ describe("<EditWeed />", () => {
it("renders", () => { it("renders", () => {
mockPath = "/app/designer/weeds/1"; mockPath = "/app/designer/weeds/1";
const p = fakeProps(); const p = fakeProps();
const point = fakePoint(); const weed = fakeWeed();
point.body.id = 1; weed.body.id = 1;
p.findPoint = () => point; p.findPoint = () => weed;
const wrapper = mount(<EditWeed {...p} />); const wrapper = mount(<EditWeed {...p} />);
expect(wrapper.text().toLowerCase()).toContain("edit"); expect(wrapper.text().toLowerCase()).toContain("edit");
}); });
@ -42,9 +42,9 @@ describe("<EditWeed />", () => {
it("goes back", () => { it("goes back", () => {
mockPath = "/app/designer/weeds/1"; mockPath = "/app/designer/weeds/1";
const p = fakeProps(); const p = fakeProps();
const point = fakePoint(); const weed = fakeWeed();
point.body.id = 1; weed.body.id = 1;
p.findPoint = () => point; p.findPoint = () => weed;
const wrapper = shallow(<EditWeed {...p} />); const wrapper = shallow(<EditWeed {...p} />);
wrapper.find(DesignerPanelHeader).simulate("back"); wrapper.find(DesignerPanelHeader).simulate("back");
expect(p.dispatch).toHaveBeenCalledWith({ expect(p.dispatch).toHaveBeenCalledWith({
@ -56,10 +56,10 @@ describe("<EditWeed />", () => {
describe("mapStateToProps()", () => { describe("mapStateToProps()", () => {
it("returns props", () => { it("returns props", () => {
const state = fakeState(); const state = fakeState();
const point = fakePoint(); const weed = fakeWeed();
point.body.id = 1; weed.body.id = 1;
state.resources = buildResourceIndex([point]); state.resources = buildResourceIndex([weed]);
const props = mapStateToProps(state); const props = mapStateToProps(state);
expect(props.findPoint(1)).toEqual(point); expect(props.findPoint(1)).toEqual(weed);
}); });
}); });

View File

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

View File

@ -0,0 +1,69 @@
import * as React from "react";
import { TaggedWeedPointer } from "farmbot";
import { Actions } from "../../constants";
import { push } from "../../history";
import { t } from "../../i18next_wrapper";
import { DEFAULT_WEED_ICON } from "../map/layers/weeds/garden_weed";
import { svgToUrl } from "../../open_farm/icons";
import { genericWeedIcon } from "../point_groups/point_group_item";
import { getMode } from "../map/util";
import { Mode } from "../map/interfaces";
import { mapPointClickAction } from "../map/actions";
export interface WeedInventoryItemProps {
tpp: TaggedWeedPointer;
dispatch: Function;
hovered: boolean;
}
export class WeedInventoryItem extends
React.Component<WeedInventoryItemProps, {}> {
render() {
const weed = this.props.tpp.body;
const { tpp, dispatch } = this.props;
const weedId = (weed.id || "ERR_NO_POINT_ID").toString();
const toggle = (action: "enter" | "leave") => {
const isEnter = action === "enter";
dispatch({
type: Actions.TOGGLE_HOVERED_POINT,
payload: isEnter ? tpp.uuid : undefined
});
};
const click = () => {
if (getMode() == Mode.boxSelect) {
mapPointClickAction(dispatch, tpp.uuid)();
toggle("leave");
} else {
push(`/app/designer/weeds/${weedId}`);
dispatch({ type: Actions.TOGGLE_HOVERED_POINT, payload: [tpp.uuid] });
}
};
return <div
className={`weed-search-item ${this.props.hovered ? "hovered" : ""}`}
key={weedId}
onMouseEnter={() => toggle("enter")}
onMouseLeave={() => toggle("leave")}
onClick={click}>
<span className={"weed-item-icon"}>
<img className={"weed-icon"}
src={DEFAULT_WEED_ICON}
width={32}
height={32} />
<img
src={svgToUrl(genericWeedIcon(weed.meta.color))}
width={32}
height={32} />
</span>
<span className="weed-search-item-name">
{weed.name || t("Untitled weed")}
</span>
<p className="weed-search-item-info">
<i>{`(${weed.x}, ${weed.y}) ⌀${weed.radius * 2}`}</i>
</p>
</div>;
}
}

View File

@ -6,22 +6,22 @@ import {
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { history, getPathArray } from "../../history"; import { history, getPathArray } from "../../history";
import { Everything } from "../../interfaces"; import { Everything } from "../../interfaces";
import { TaggedGenericPointer } from "farmbot"; import { TaggedWeedPointer } from "farmbot";
import { maybeFindGenericPointerById } from "../../resources/selectors"; import { maybeFindWeedPointerById } from "../../resources/selectors";
import { Panel } from "../panel_header"; import { Panel } from "../panel_header";
import { import {
EditPointProperties, PointActions, updatePoint, EditPointProperties, PointActions, updatePoint,
} from "./point_edit_actions"; } from "../points/point_edit_actions";
import { Actions } from "../../constants"; import { Actions } from "../../constants";
export interface EditWeedProps { export interface EditWeedProps {
dispatch: Function; dispatch: Function;
findPoint(id: number): TaggedGenericPointer | undefined; findPoint(id: number): TaggedWeedPointer | undefined;
} }
export const mapStateToProps = (props: Everything): EditWeedProps => ({ export const mapStateToProps = (props: Everything): EditWeedProps => ({
dispatch: props.dispatch, dispatch: props.dispatch,
findPoint: id => maybeFindGenericPointerById(props.resources.index, id), findPoint: id => maybeFindWeedPointerById(props.resources.index, id),
}); });
export class RawEditWeed extends React.Component<EditWeedProps, {}> { export class RawEditWeed extends React.Component<EditWeedProps, {}> {

View File

@ -10,12 +10,12 @@ import {
DesignerPanel, DesignerPanelContent, DesignerPanelTop, DesignerPanel, DesignerPanelContent, DesignerPanelTop,
} from "../designer_panel"; } from "../designer_panel";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
import { TaggedGenericPointer } from "farmbot"; import { TaggedWeedPointer } from "farmbot";
import { selectAllGenericPointers } from "../../resources/selectors"; import { selectAllWeedPointers } from "../../resources/selectors";
import { PointInventoryItem } from "./point_inventory_item"; import { WeedInventoryItem } from "./weed_inventory_item";
export interface WeedsProps { export interface WeedsProps {
genericPoints: TaggedGenericPointer[]; weeds: TaggedWeedPointer[];
dispatch: Function; dispatch: Function;
hoveredPoint: string | undefined; hoveredPoint: string | undefined;
} }
@ -24,13 +24,8 @@ interface WeedsState {
searchTerm: string; searchTerm: string;
} }
export const isAWeed = (pointName: string, type?: string) =>
type == "weed" || pointName.toLowerCase().includes("weed");
export const mapStateToProps = (props: Everything): WeedsProps => ({ export const mapStateToProps = (props: Everything): WeedsProps => ({
genericPoints: selectAllGenericPointers(props.resources.index) weeds: selectAllWeedPointers(props.resources.index),
.filter(x => !x.body.discarded_at)
.filter(x => isAWeed(x.body.name, x.body.meta.type)),
dispatch: props.dispatch, dispatch: props.dispatch,
hoveredPoint: props.resources.consumers.farm_designer.hoveredPoint, hoveredPoint: props.resources.consumers.farm_designer.hoveredPoint,
}); });
@ -54,17 +49,16 @@ export class RawWeeds extends React.Component<WeedsProps, WeedsState> {
</DesignerPanelTop> </DesignerPanelTop>
<DesignerPanelContent panelName={"weeds-inventory"}> <DesignerPanelContent panelName={"weeds-inventory"}>
<EmptyStateWrapper <EmptyStateWrapper
notEmpty={this.props.genericPoints.length > 0} notEmpty={this.props.weeds.length > 0}
graphic={EmptyStateGraphic.weeds} graphic={EmptyStateGraphic.weeds}
title={t("No weeds yet.")} title={t("No weeds yet.")}
text={Content.NO_WEEDS} text={Content.NO_WEEDS}
colorScheme={"weeds"}> colorScheme={"weeds"}>
{this.props.genericPoints {this.props.weeds
.filter(p => p.body.name.toLowerCase() .filter(p => p.body.name.toLowerCase()
.includes(this.state.searchTerm.toLowerCase())) .includes(this.state.searchTerm.toLowerCase()))
.map(p => <PointInventoryItem .map(p => <WeedInventoryItem
key={p.uuid} key={p.uuid}
navName={"weeds"}
tpp={p} tpp={p}
hovered={this.props.hoveredPoint === p.uuid} hovered={this.props.hoveredPoint === p.uuid}
dispatch={this.props.dispatch} />)} dispatch={this.props.dispatch} />)}

View File

@ -62,7 +62,8 @@ describe("deletePoints()", () => {
mockDelete = Promise.resolve(); mockDelete = Promise.resolve();
mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; mockData = [{ id: 1 }, { id: 2 }, { id: 3 }];
const dispatch = jest.fn(); const dispatch = jest.fn();
await deletePoints("weeds", { created_by: "plant-detection" })(dispatch, jest.fn()); const query = { meta: { created_by: "plant-detection" } };
await deletePoints("weeds", query)(dispatch, jest.fn());
expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search", expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search",
{ meta: { created_by: "plant-detection" } }); { meta: { created_by: "plant-detection" } });
await expect(axios.delete).toHaveBeenCalledWith("http://localhost/api/points/1,2,3"); await expect(axios.delete).toHaveBeenCalledWith("http://localhost/api/points/1,2,3");
@ -80,7 +81,8 @@ describe("deletePoints()", () => {
mockDelete = Promise.reject("error"); mockDelete = Promise.reject("error");
mockData = [{ id: 1 }, { id: 2 }, { id: 3 }]; mockData = [{ id: 1 }, { id: 2 }, { id: 3 }];
const dispatch = jest.fn(); const dispatch = jest.fn();
await deletePoints("weeds", { created_by: "plant-detection" })(dispatch, jest.fn()); const query = { meta: { created_by: "plant-detection" } };
await deletePoints("weeds", query)(dispatch, jest.fn());
expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search", expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search",
{ meta: { created_by: "plant-detection" } }); { meta: { created_by: "plant-detection" } });
await expect(axios.delete).toHaveBeenCalledWith("http://localhost/api/points/1,2,3"); await expect(axios.delete).toHaveBeenCalledWith("http://localhost/api/points/1,2,3");
@ -98,7 +100,8 @@ describe("deletePoints()", () => {
mockDelete = Promise.resolve(); mockDelete = Promise.resolve();
mockData = times(200, () => ({ id: 1 })); mockData = times(200, () => ({ id: 1 }));
const dispatch = jest.fn(); const dispatch = jest.fn();
await deletePoints("weeds", { created_by: "plant-detection" })(dispatch, jest.fn()); const query = { meta: { created_by: "plant-detection" } };
await deletePoints("weeds", query)(dispatch, jest.fn());
expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search", expect(axios.post).toHaveBeenCalledWith("http://localhost/api/points/search",
{ meta: { created_by: "plant-detection" } }); { meta: { created_by: "plant-detection" } });
await expect(axios.delete).toHaveBeenCalledWith( await expect(axios.delete).toHaveBeenCalledWith(

View File

@ -85,7 +85,7 @@ describe("<WeedDetector />", () => {
expect(wrapper.instance().state.deletionProgress).toBeUndefined(); expect(wrapper.instance().state.deletionProgress).toBeUndefined();
clickButton(wrapper, 1, "clear weeds"); clickButton(wrapper, 1, "clear weeds");
expect(deletePoints).toHaveBeenCalledWith( expect(deletePoints).toHaveBeenCalledWith(
"weeds", { created_by: "plant-detection" }, expect.any(Function)); "weeds", { meta: { created_by: "plant-detection" } }, expect.any(Function));
expect(wrapper.instance().state.deletionProgress).toEqual("Deleting..."); expect(wrapper.instance().state.deletionProgress).toEqual("Deleting...");
const fakeProgress = { completed: 50, total: 100, isDone: false }; const fakeProgress = { completed: 50, total: 100, isDone: false };
mockDeletePoints.mock.calls[0][2](fakeProgress); mockDeletePoints.mock.calls[0][2](fakeProgress);

View File

@ -5,20 +5,19 @@ import { API } from "../../api";
import { Progress, ProgressCallback, trim } from "../../util"; import { Progress, ProgressCallback, trim } from "../../util";
import { getDevice } from "../../device"; import { getDevice } from "../../device";
import { noop, chunk } from "lodash"; import { noop, chunk } from "lodash";
import { GenericPointer } from "farmbot/dist/resources/api_resources"; import { Point } from "farmbot/dist/resources/api_resources";
import { Actions } from "../../constants"; import { Actions } from "../../constants";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
export function deletePoints( export function deletePoints(
pointName: string, pointName: string,
metaQuery: { [key: string]: string }, query: Partial<Point>,
cb?: ProgressCallback): Thunk { cb?: ProgressCallback): Thunk {
// TODO: Generalize and add to api/crud.ts // TODO: Generalize and add to api/crud.ts
return async function (dispatch) { return async function (dispatch) {
const URL = API.current.pointSearchPath; const URL = API.current.pointSearchPath;
const QUERY = { meta: metaQuery };
try { try {
const resp = await axios.post<GenericPointer[]>(URL, QUERY); const resp = await axios.post<Point[]>(URL, query);
const ids = resp.data.map(x => x.id); const ids = resp.data.map(x => x.id);
// If you delete too many points, you will violate the URL length // If you delete too many points, you will violate the URL length
// limitation of 2,083. Chunking helps fix that. // limitation of 2,083. Chunking helps fix that.

View File

@ -39,7 +39,7 @@ export class WeedDetector
this.setState({ deletionProgress: p.isDone ? "" : percentage }); this.setState({ deletionProgress: p.isDone ? "" : percentage });
}; };
this.props.dispatch(deletePoints(t("weeds"), this.props.dispatch(deletePoints(t("weeds"),
{ created_by: "plant-detection" }, progress)); { meta: { created_by: "plant-detection" } }, progress));
this.setState({ deletionProgress: t("Deleting...") }); this.setState({ deletionProgress: t("Deleting...") });
} }

View File

@ -111,7 +111,7 @@ describe("isKind()", () => {
describe("groupPointsByType()", () => { describe("groupPointsByType()", () => {
it("returns points", () => { it("returns points", () => {
const points = Selector.groupPointsByType(fakeIndex); const points = Selector.groupPointsByType(fakeIndex);
const expectedKeys = ["Plant", "GenericPointer", "ToolSlot"]; const expectedKeys = ["Plant", "GenericPointer", "ToolSlot", "Weed"];
expect(expectedKeys.every(key => key in points)).toBeTruthy(); expect(expectedKeys.every(key => key in points)).toBeTruthy();
}); });
}); });

View File

@ -10,15 +10,16 @@ import {
TaggedToolSlotPointer, TaggedToolSlotPointer,
TaggedUser, TaggedUser,
TaggedDevice, TaggedDevice,
TaggedWeedPointer,
} from "farmbot"; } from "farmbot";
import { import {
isTaggedPlantPointer, isTaggedPlantPointer,
isTaggedGenericPointer, isTaggedGenericPointer,
isTaggedRegimen, isTaggedRegimen,
isTaggedSequence, isTaggedSequence,
isTaggedTool,
isTaggedToolSlotPointer, isTaggedToolSlotPointer,
sanityCheck, sanityCheck,
isTaggedWeedPointer,
} from "./tagged_resources"; } from "./tagged_resources";
import { betterCompact, bail } from "../util"; import { betterCompact, bail } from "../util";
import { findAllById } from "./selectors_by_id"; import { findAllById } from "./selectors_by_id";
@ -94,6 +95,13 @@ export function selectAllGenericPointers(index: ResourceIndex):
return betterCompact(genericPointers); return betterCompact(genericPointers);
} }
export function selectAllWeedPointers(index: ResourceIndex):
TaggedWeedPointer[] {
const weedPointers = selectAllPoints(index)
.map(p => isTaggedWeedPointer(p) ? p : undefined);
return betterCompact(weedPointers);
}
export function selectAllPlantPointers(index: ResourceIndex): TaggedPlantPointer[] { export function selectAllPlantPointers(index: ResourceIndex): TaggedPlantPointer[] {
const plantPointers = selectAllActivePoints(index) const plantPointers = selectAllActivePoints(index)
.map(p => isTaggedPlantPointer(p) ? p : undefined); .map(p => isTaggedPlantPointer(p) ? p : undefined);
@ -141,22 +149,6 @@ export function getSequenceByUUID(index: ResourceIndex,
} }
} }
/** GIVEN: a slot UUID.
* FINDS: Tool in that slot (if any) */
export const currentToolInSlot = (index: ResourceIndex) =>
(toolSlotUUID: string): TaggedTool | undefined => {
const currentSlot = selectCurrentToolSlot(index, toolSlotUUID);
if (currentSlot
&& currentSlot.kind === "Point") {
const toolUUID = index
.byKindAndId[joinKindAndId("Tool", currentSlot.body.tool_id)];
const tool = index.references[toolUUID || "NOPE!"];
if (tool && isTaggedTool(tool)) {
return tool;
}
}
};
/** FINDS: All tools that are in use. */ /** FINDS: All tools that are in use. */
export function toolsInUse(index: ResourceIndex): TaggedTool[] { export function toolsInUse(index: ResourceIndex): TaggedTool[] {
const ids = betterCompact(selectAllToolSlotPointers(index) const ids = betterCompact(selectAllToolSlotPointers(index)

View File

@ -11,6 +11,7 @@ import {
isTaggedGenericPointer, isTaggedGenericPointer,
isTaggedSavedGarden, isTaggedSavedGarden,
isTaggedFolder, isTaggedFolder,
isTaggedWeedPointer,
} from "./tagged_resources"; } from "./tagged_resources";
import { import {
ResourceName, ResourceName,
@ -125,6 +126,13 @@ export function maybeFindGenericPointerById(index: ResourceIndex, id: number) {
if (resource && isTaggedGenericPointer(resource)) { return resource; } if (resource && isTaggedGenericPointer(resource)) { return resource; }
} }
/** Unlike other findById methods, this one allows undefined (missed) values */
export function maybeFindWeedPointerById(index: ResourceIndex, id: number) {
const uuid = index.byKindAndId[joinKindAndId("Point", id)];
const resource = index.references[uuid || "nope"];
if (resource && isTaggedWeedPointer(resource)) { return resource; }
}
/** Unlike other findById methods, this one allows undefined (missed) values */ /** Unlike other findById methods, this one allows undefined (missed) values */
export function maybeFindSavedGardenById(index: ResourceIndex, id: number) { export function maybeFindSavedGardenById(index: ResourceIndex, id: number) {
const uuid = index.byKindAndId[joinKindAndId("SavedGarden", id)]; const uuid = index.byKindAndId[joinKindAndId("SavedGarden", id)];

View File

@ -16,6 +16,7 @@ import {
TaggedPlantTemplate, TaggedPlantTemplate,
TaggedSavedGarden, TaggedSavedGarden,
TaggedPointGroup, TaggedPointGroup,
TaggedWeedPointer,
} from "farmbot"; } from "farmbot";
export interface TaggedResourceBase { export interface TaggedResourceBase {
@ -102,6 +103,8 @@ export const isTaggedGenericPointer =
(x: object): x is TaggedGenericPointer => { (x: object): x is TaggedGenericPointer => {
return isTaggedPoint(x) && (x.body.pointer_type === "GenericPointer"); return isTaggedPoint(x) && (x.body.pointer_type === "GenericPointer");
}; };
export const isTaggedWeedPointer = (x: object): x is TaggedWeedPointer =>
isTaggedPoint(x) && (x.body.pointer_type === "Weed");
export const isTaggedSavedGarden = export const isTaggedSavedGarden =
(x: object): x is TaggedSavedGarden => is("SavedGarden")(x); (x: object): x is TaggedSavedGarden => is("SavedGarden")(x);
export const isTaggedPlantTemplate = export const isTaggedPlantTemplate =

View File

@ -376,7 +376,7 @@ export const UNBOUND_ROUTES = [
$: "/designer/weeds", $: "/designer/weeds",
getModule, getModule,
key, key,
getChild: () => import("./farm_designer/points/weeds_inventory"), getChild: () => import("./farm_designer/weeds/weeds_inventory"),
childKey: "Weeds" childKey: "Weeds"
}), }),
route({ route({
@ -392,7 +392,7 @@ export const UNBOUND_ROUTES = [
$: "/designer/weeds/:point_id", $: "/designer/weeds/:point_id",
getModule, getModule,
key, key,
getChild: () => import("./farm_designer/points/weeds_edit"), getChild: () => import("./farm_designer/weeds/weeds_edit"),
childKey: "EditWeed" childKey: "EditWeed"
}), }),
route({ route({

View File

@ -30,45 +30,58 @@ describe("locationFormList()", () => {
label: "Generic tool (100, 200, 300)", label: "Generic tool (100, 200, 300)",
value: "1", value: "1",
}); });
const plantHeading = items[3]; const groupHeading = items[3];
expect(plantHeading).toEqual({
headingId: "Plant",
label: "Plants",
value: 0,
heading: true,
});
const plant = items[4];
expect(plant).toEqual({
headingId: "Plant",
label: "Plant 1 (1, 2, 3)",
value: "1"
});
const pointHeading = items[6];
expect(pointHeading).toEqual({
headingId: "GenericPointer",
label: "Map Points",
value: 0,
heading: true,
});
const point = items[7];
expect(point).toEqual({
headingId: "GenericPointer",
label: "Point 1 (10, 20, 30)",
value: "2"
});
const groupHeading = items[8];
expect(groupHeading).toEqual({ expect(groupHeading).toEqual({
headingId: "PointGroup", headingId: "PointGroup",
label: "Groups", label: "Groups",
value: 0, value: 0,
heading: true, heading: true,
}); });
const group = items[9]; const group = items[4];
expect(group).toEqual({ expect(group).toEqual({
headingId: "PointGroup", headingId: "PointGroup",
label: "Fake", label: "Fake",
value: "1" value: "1"
}); });
const plantHeading = items[5];
expect(plantHeading).toEqual({
headingId: "Plant",
label: "Plants",
value: 0,
heading: true,
});
const plant = items[6];
expect(plant).toEqual({
headingId: "Plant",
label: "Plant 1 (1, 2, 3)",
value: "1"
});
const pointHeading = items[8];
expect(pointHeading).toEqual({
headingId: "GenericPointer",
label: "Map Points",
value: 0,
heading: true,
});
const point = items[9];
expect(point).toEqual({
headingId: "GenericPointer",
label: "Point 1 (10, 20, 30)",
value: "2"
});
const weedHeading = items[10];
expect(weedHeading).toEqual({
headingId: "Weed",
label: "Weeds",
value: 0,
heading: true,
});
const weed = items[11];
expect(weed).toEqual({
headingId: "Weed",
label: "Weed 1 (15, 25, 35)",
value: "5"
});
}); });
}); });

View File

@ -78,7 +78,7 @@ const toolVar = (value: string | number) =>
}); });
const pointVar = ( const pointVar = (
pointer_type: "Plant" | "GenericPointer", pointer_type: "Plant" | "GenericPointer" | "Weed",
value: string | number, value: string | number,
) => ({ identifierLabel: label, allowedVariableNodes }: NewVarProps): VariableWithAValue => ) => ({ identifierLabel: label, allowedVariableNodes }: NewVarProps): VariableWithAValue =>
createVariableNode(allowedVariableNodes)(label, { createVariableNode(allowedVariableNodes)(label, {
@ -123,7 +123,9 @@ const createNewVariable = (props: NewVarProps): VariableNode | undefined => {
if (ddi.isNull) { return nothingVar(props); } // Empty form. Nothing selected yet. if (ddi.isNull) { return nothingVar(props); } // Empty form. Nothing selected yet.
switch (ddi.headingId) { switch (ddi.headingId) {
case "Plant": case "Plant":
case "GenericPointer": return pointVar(ddi.headingId, ddi.value)(props); case "GenericPointer":
case "Weed":
return pointVar(ddi.headingId, ddi.value)(props);
case "Tool": return toolVar(ddi.value)(props); case "Tool": return toolVar(ddi.value)(props);
case "parameter": return newParameter(props); case "parameter": return newParameter(props);
case "Coordinate": return manualEntry(ddi.value)(props); case "Coordinate": return manualEntry(ddi.value)(props);

View File

@ -72,17 +72,20 @@ export function locationFormList(resources: ResourceIndex,
const allPoints = selectAllActivePoints(resources); const allPoints = selectAllActivePoints(resources);
const plantDDI = points2ddi(allPoints, "Plant"); const plantDDI = points2ddi(allPoints, "Plant");
const genericPointerDDI = points2ddi(allPoints, "GenericPointer"); const genericPointerDDI = points2ddi(allPoints, "GenericPointer");
const weedDDI = points2ddi(allPoints, "Weed");
const toolDDI = activeToolDDIs(resources); const toolDDI = activeToolDDIs(resources);
return [COORDINATE_DDI()] return [COORDINATE_DDI()]
.concat(additionalItems) .concat(additionalItems)
.concat(heading("Tool")) .concat(heading("Tool"))
.concat(toolDDI) .concat(toolDDI)
.concat(displayGroups ? heading("PointGroup") : [])
.concat(displayGroups ? groups2Ddi(selectAllPointGroups(resources)) : [])
.concat(heading("Plant")) .concat(heading("Plant"))
.concat(plantDDI) .concat(plantDDI)
.concat(heading("GenericPointer")) .concat(heading("GenericPointer"))
.concat(genericPointerDDI) .concat(genericPointerDDI)
.concat(displayGroups ? heading("PointGroup") : []) .concat(heading("Weed"))
.concat(displayGroups ? groups2Ddi(selectAllPointGroups(resources)) : []); .concat(weedDDI);
} }
/** Create drop down item with label; i.e., "Point/Plant (1, 2, 3)" */ /** Create drop down item with label; i.e., "Point/Plant (1, 2, 3)" */
@ -126,15 +129,6 @@ export function dropDownName(name: string, v?: Record<Xyz, number | undefined>,
return capitalize(label); return capitalize(label);
} }
export const ALL_POINT_LABELS = {
"Plant": "All plants",
"GenericPointer": "All map points",
"Tool": "All tools and seed containers",
"ToolSlot": "All slots",
};
export type EveryPointType = keyof typeof ALL_POINT_LABELS;
export const COORDINATE_DDI = (vector?: Vector3): DropDownItem => ({ export const COORDINATE_DDI = (vector?: Vector3): DropDownItem => ({
label: vector label: vector
? `${t("Coordinate")} (${vector.x}, ${vector.y}, ${vector.z})` ? `${t("Coordinate")} (${vector.x}, ${vector.y}, ${vector.z})`

View File

@ -52,6 +52,16 @@ export function fakeResourceIndex(extra: TaggedResource[] = []): ResourceIndex {
"y": 200, "y": 200,
"z": 300, "z": 300,
}), }),
...newTaggedResource("Point", {
id: 5,
meta: {},
name: "Weed 1",
pointer_type: "Weed",
radius: 15,
x: 15,
y: 25,
z: 35,
}),
...newTaggedResource("Tool", { ...newTaggedResource("Tool", {
"id": 1, "id": 1,
"name": "Generic Tool", "name": "Generic Tool",

View File

@ -11,7 +11,7 @@ import { commitStepChanges } from "./mark_as/commit_step_changes";
import { t } from "../../i18next_wrapper"; import { t } from "../../i18next_wrapper";
interface MarkAsState { nextResource: DropDownItem | undefined } interface MarkAsState { nextResource: DropDownItem | undefined }
const NONE: DropDownItem = { value: 0, label: "" }; const NONE = (): DropDownItem => ({ label: t("Select one"), value: 0 });
export class MarkAs extends React.Component<StepParams, MarkAsState> { export class MarkAs extends React.Component<StepParams, MarkAsState> {
state: MarkAsState = { nextResource: undefined }; state: MarkAsState = { nextResource: undefined };
@ -57,7 +57,7 @@ export class MarkAs extends React.Component<StepParams, MarkAsState> {
list={actionList(this.state.nextResource, step, this.props.resources)} list={actionList(this.state.nextResource, step, this.props.resources)}
onChange={this.commitSelection} onChange={this.commitSelection}
key={JSON.stringify(rightSide) + JSON.stringify(this.state)} key={JSON.stringify(rightSide) + JSON.stringify(this.state)}
selectedItem={this.state.nextResource ? NONE : rightSide} /> selectedItem={this.state.nextResource ? NONE() : rightSide} />
</Col> </Col>
</Row> </Row>
</StepContent> </StepContent>

View File

@ -10,7 +10,7 @@ describe("actionList()", () => {
const step = resourceUpdate({ resource_type: "Plant" }); const step = resourceUpdate({ resource_type: "Plant" });
const { index } = markAsResourceFixture(); const { index } = markAsResourceFixture();
const result = actionList(undefined, step, index); const result = actionList(undefined, step, index);
expect(result).toEqual(PLANT_OPTIONS); expect(result).toEqual(PLANT_OPTIONS());
}); });
it("provides a list of tool mount actions", () => { it("provides a list of tool mount actions", () => {
@ -35,6 +35,16 @@ describe("actionList()", () => {
expect(labels).toContain("Removed"); expect(labels).toContain("Removed");
}); });
it("provides a list of weed pointer actions", () => {
const ddi = { label: "test case", value: 1, headingId: "Weed" };
const step = resourceUpdate({});
const { index } = markAsResourceFixture();
const result = actionList(ddi, step, index);
expect(result.length).toBe(1);
const labels = result.map(x => x.label);
expect(labels).toContain("Removed");
});
it("returns an empty list for all other options", () => { it("returns an empty list for all other options", () => {
const ddi = { label: "test case", value: 1, headingId: "USB Cables" }; const ddi = { label: "test case", value: 1, headingId: "USB Cables" };
const step = resourceUpdate({}); const step = resourceUpdate({});

View File

@ -10,5 +10,9 @@ describe("resourceList()", () => {
expect(headings).toContain("Device"); expect(headings).toContain("Device");
expect(headings).toContain("Plants"); expect(headings).toContain("Plants");
expect(headings).toContain("Points"); expect(headings).toContain("Points");
expect(headings).toContain("Weeds");
const weeds = result.filter(x => x.headingId == "Weed");
expect(weeds.length).toEqual(2);
expect(weeds[1].label).toEqual("weed 1 (200, 400, 0)");
}); });
}); });

View File

@ -7,6 +7,10 @@ import {
selectAllGenericPointers, selectAllGenericPointers,
} from "../../../../resources/selectors"; } from "../../../../resources/selectors";
import { DropDownPair } from "../interfaces"; import { DropDownPair } from "../interfaces";
import { fakeTool } from "../../../../__test_support__/fake_state/resources";
import {
buildResourceIndex,
} from "../../../../__test_support__/resource_index_builder";
describe("unpackStep()", () => { describe("unpackStep()", () => {
function assertGoodness(result: DropDownPair, function assertGoodness(result: DropDownPair,
action_label: string, action_label: string,
@ -41,6 +45,23 @@ describe("unpackStep()", () => {
assertGoodness(result, actionLabel, "mounted", label, value); assertGoodness(result, actionLabel, "mounted", label, value);
}); });
it("unpacks valid tool_ids with missing names", () => {
const tool = fakeTool();
tool.body.id = 1;
tool.body.name = undefined;
const resourceIndex = buildResourceIndex([tool]).index;
const { body } = selectAllTools(resourceIndex)[0];
expect(body).toBeTruthy();
const result = unpackStep({
step: resourceUpdate({ label: "mounted_tool_id", value: body.id || NaN }),
resourceIndex
});
const actionLabel = "Mounted to: Untitled Tool";
const { label, value } = TOOL_MOUNT();
assertGoodness(result, actionLabel, "mounted", label, value);
});
it("unpacks invalid tool_ids (that may have been valid previously)", () => { it("unpacks invalid tool_ids (that may have been valid previously)", () => {
const result = unpackStep({ const result = unpackStep({
step: resourceUpdate({ label: "mounted_tool_id", value: Infinity }), step: resourceUpdate({ label: "mounted_tool_id", value: Infinity }),

View File

@ -16,7 +16,7 @@ const allToolsAsDDI = (i: ResourceIndex) => {
.filter(x => !!x.body.id) .filter(x => !!x.body.id)
.map(x => { .map(x => {
return { return {
label: `${MOUNTED_TO} ${x.body.name}`, label: `${MOUNTED_TO()} ${x.body.name}`,
value: x.body.id || 0 value: x.body.id || 0
}; };
}); });
@ -25,9 +25,10 @@ const allToolsAsDDI = (i: ResourceIndex) => {
const DEFAULT = "Default"; const DEFAULT = "Default";
const ACTION_LIST: Dictionary<ListBuilder> = { const ACTION_LIST: Dictionary<ListBuilder> = {
"Device": (i) => [DISMOUNT, ...allToolsAsDDI(i)], "Device": (i) => [DISMOUNT(), ...allToolsAsDDI(i)],
"Plant": () => PLANT_OPTIONS, "Plant": () => PLANT_OPTIONS(),
"GenericPointer": () => POINT_OPTIONS, "GenericPointer": () => POINT_OPTIONS,
"Weed": () => POINT_OPTIONS,
[DEFAULT]: () => [] [DEFAULT]: () => []
}; };

View File

@ -7,6 +7,7 @@ import {
fakePlant, fakePlant,
fakePoint, fakePoint,
fakeSequence, fakeSequence,
fakeWeed,
} from "../../../__test_support__/fake_state/resources"; } from "../../../__test_support__/fake_state/resources";
import { betterMerge } from "../../../util"; import { betterMerge } from "../../../util";
import { MarkAs } from "../mark_as"; import { MarkAs } from "../mark_as";
@ -30,6 +31,7 @@ export const markAsResourceFixture = () => buildResourceIndex([
fakePlant(), fakePlant(),
betterMerge(fakeTool(), { body: { name: "T2", id: 2 } }), betterMerge(fakeTool(), { body: { name: "T2", id: 2 } }),
betterMerge(fakePoint(), { body: { name: "my point", id: 7 } }), betterMerge(fakePoint(), { body: { name: "my point", id: 7 } }),
betterMerge(fakeWeed(), { body: { name: "weed 1", id: 8 } }),
betterMerge(fakeTool(), { body: { name: "T3", id: undefined } }), betterMerge(fakeTool(), { body: { name: "T3", id: undefined } }),
]); ]);

View File

@ -1,9 +1,11 @@
import { DropDownItem } from "../../../ui"; import { DropDownItem } from "../../../ui";
import { t } from "../../../i18next_wrapper"; import { t } from "../../../i18next_wrapper";
import { PLANT_STAGE_LIST } from "../../../farm_designer/plants/edit_plant_status";
export const MOUNTED_TO = t("Mounted to:"); export const MOUNTED_TO = () => t("Mounted to:");
export const DISMOUNT: DropDownItem = { label: t("Not Mounted"), value: 0 }; export const DISMOUNT = (): DropDownItem =>
({ label: t("Not Mounted"), value: 0 });
/** Legal "actions" for "Mark As.." block when marking Point resources */ /** Legal "actions" for "Mark As.." block when marking Point resources */
export const POINT_OPTIONS: DropDownItem[] = [ export const POINT_OPTIONS: DropDownItem[] = [
@ -12,12 +14,7 @@ export const POINT_OPTIONS: DropDownItem[] = [
/** Legal "actions" in the "Mark As.." block when operating on /** Legal "actions" in the "Mark As.." block when operating on
* a Plant resource. */ * a Plant resource. */
export const PLANT_OPTIONS: DropDownItem[] = [ export const PLANT_OPTIONS = PLANT_STAGE_LIST;
{ label: t("Planned"), value: "planned" },
{ label: t("Planted"), value: "planted" },
{ label: t("Sprouted"), value: "sprouted" },
{ label: t("Harvested"), value: "harvested" },
];
const value = 0; // Not used in headings. const value = 0; // Not used in headings.
@ -35,6 +32,13 @@ export const POINT_HEADER: DropDownItem = {
heading: true heading: true
}; };
export const WEED_HEADER: DropDownItem = {
headingId: "Weed",
label: t("Weeds"),
value,
heading: true
};
export const TOP_HALF = [ export const TOP_HALF = [
{ headingId: "Device", label: t("Device"), value, heading: true }, { headingId: "Device", label: t("Device"), value, heading: true },
{ headingId: "Device", label: t("Tool Mount"), value }, { headingId: "Device", label: t("Tool Mount"), value },

View File

@ -1,9 +1,9 @@
import { ResourceIndex } from "../../../resources/interfaces"; import { ResourceIndex } from "../../../resources/interfaces";
import { DropDownItem } from "../../../ui/fb_select"; import { DropDownItem } from "../../../ui/fb_select";
import { selectAllPoints } from "../../../resources/selectors"; import { selectAllPoints } from "../../../resources/selectors";
import { TaggedPoint, TaggedPlantPointer } from "farmbot"; import { TaggedPoint } from "farmbot";
import { GenericPointer } from "farmbot/dist/resources/api_resources"; import { Point } from "farmbot/dist/resources/api_resources";
import { POINT_HEADER, PLANT_HEADER, TOP_HALF } from "./constants"; import { POINT_HEADER, PLANT_HEADER, TOP_HALF, WEED_HEADER } from "./constants";
/** Filter function to remove resources we don't care about, /** Filter function to remove resources we don't care about,
* such as ToolSlots and unsaved (Plant|Point)'s */ * such as ToolSlots and unsaved (Plant|Point)'s */
@ -17,26 +17,14 @@ const isRelevant = (x: TaggedPoint) => {
const labelStr = const labelStr =
(n: string, x: number, y: number, z: number) => `${n} (${x}, ${y}, ${z})`; (n: string, x: number, y: number, z: number) => `${n} (${x}, ${y}, ${z})`;
/** Convert a GenericPointer to a DropDownItem that is formatted appropriately /** Convert a Point to a DropDownItem that is formatted appropriately
* for the "Mark As.." step. */ * for the "Mark As.." step. */
export const pointer2ddi = (i: GenericPointer): DropDownItem => { export const point2ddi = (i: Point): DropDownItem => {
const { x, y, z, name } = i; const { x, y, z, name, id, pointer_type } = i;
return { return {
value: i.id as number, value: id || 0,
label: labelStr(name, x, y, z), label: labelStr(name, x, y, z),
headingId: "GenericPointer" headingId: pointer_type,
};
};
/** Convert a PlantPointer to a DropDownItem appropriately formatted for the
* "Mark As.." step. */
export const plant2ddi = (i: TaggedPlantPointer["body"]): DropDownItem => {
const { x, y, z, name, id } = i;
return {
value: id as number,
label: labelStr(name, x, y, z),
headingId: "Plant"
}; };
}; };
@ -45,16 +33,18 @@ export const plant2ddi = (i: TaggedPlantPointer["body"]): DropDownItem => {
const pointList = const pointList =
(input: TaggedPoint[]): DropDownItem[] => { (input: TaggedPoint[]): DropDownItem[] => {
const genericPoints: DropDownItem[] = [POINT_HEADER]; const genericPoints: DropDownItem[] = [POINT_HEADER];
const weeds: DropDownItem[] = [WEED_HEADER];
const plants: DropDownItem[] = [PLANT_HEADER]; const plants: DropDownItem[] = [PLANT_HEADER];
input input
.map(x => x.body) .map(x => x.body)
.forEach(body => { .forEach(body => {
switch (body.pointer_type) { switch (body.pointer_type) {
case "GenericPointer": return genericPoints.push(pointer2ddi(body)); case "GenericPointer": return genericPoints.push(point2ddi(body));
case "Plant": return plants.push(plant2ddi(body)); case "Weed": return weeds.push(point2ddi(body));
case "Plant": return plants.push(point2ddi(body));
} }
}); });
return [...plants, ...genericPoints]; return [...plants, ...genericPoints, ...weeds];
}; };
/** Creates a formatted DropDownItem list for the "Resource" (left hand) side of /** Creates a formatted DropDownItem list for the "Resource" (left hand) side of

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