diff --git a/frontend/__tests__/link_test.tsx b/frontend/__tests__/link_test.tsx index 8e5e359dc..6e260ed91 100644 --- a/frontend/__tests__/link_test.tsx +++ b/frontend/__tests__/link_test.tsx @@ -40,4 +40,16 @@ describe("", () => { expect(el.html()).toContain("Hey!"); el.unmount(); }); + + it("navigates", () => { + const wrapper = shallow(); + wrapper.simulate("click", { preventDefault: jest.fn() }); + expect(navigate).toHaveBeenCalledWith("/tools"); + }); + + it("doesn't navigate when disabled", () => { + const wrapper = shallow(); + wrapper.simulate("click", { preventDefault: jest.fn() }); + expect(navigate).not.toHaveBeenCalledWith(); + }); }); diff --git a/frontend/constants.ts b/frontend/constants.ts index ac2ffe3e9..d1f764a48 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -747,6 +747,15 @@ export namespace Content { export const NO_PLANTS = trim(`Press "+" to add a plant to your garden.`); + export const NO_GARDENS = + trim(`Press "CREATE NEW GARDEN" to add a garden.`); + + export const NO_POINTS = + trim(`Press "+" to add a point to your garden.`); + + export const NO_GROUPS = + trim(`Press "+" to add a point group.`); + export const ENTER_CROP_SEARCH_TERM = trim(`Search for a crop to add to your garden.`); @@ -756,6 +765,9 @@ export namespace Content { export const CROP_NOT_FOUND_LINK = trim(`add this crop on OpenFarm?`); + export const NO_TOOLS = + trim(`Press "+" to add a new tool.`); + // Farm Events export const NOTHING_SCHEDULED = trim(`Press "+" to schedule an event.`); @@ -945,6 +957,7 @@ export enum Actions { SEARCH_QUERY_CHANGE = "SEARCH_QUERY_CHANGE", SELECT_PLANT = "SELECT_PLANT", TOGGLE_HOVERED_PLANT = "TOGGLE_HOVERED_PLANT", + TOGGLE_HOVERED_POINT = "TOGGLE_HOVERED_POINT", HOVER_PLANT_LIST_ITEM = "HOVER_PLANT_LIST_ITEM", OF_SEARCH_RESULTS_START = "OF_SEARCH_RESULTS_START", OF_SEARCH_RESULTS_OK = "OF_SEARCH_RESULTS_OK", diff --git a/frontend/css/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss index b12acfb1d..fb98bbcb6 100644 --- a/frontend/css/farm_designer/farm_designer.scss +++ b/frontend/css/farm_designer/farm_designer.scss @@ -211,6 +211,38 @@ text-overflow: ellipsis; margin-left: 1rem; } + .point-search-item { + cursor: pointer; + padding: 0.5rem 1rem; + &:hover, + &.hovered { + background: darken($light_brown, 10%); + transition: background 0.2s ease; + } + .saucer { + display: inline-block; + margin: 0 1rem 0 0; + height: 2rem; + width: 2rem; + vertical-align: middle; + } + } + .point-search-item-info { + text-align: right; + font-size: 1rem; + padding-top: 0.6rem; + padding-right: 1rem; + float: right; + } + .point-search-item-name { + display: inline-block; + vertical-align: middle; + white-space: nowrap; + width: 8em; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 1rem; + } } .thin-search { diff --git a/frontend/css/farm_designer/farm_designer_panels.scss b/frontend/css/farm_designer/farm_designer_panels.scss index b7766679e..8c116b566 100644 --- a/frontend/css/farm_designer/farm_designer_panels.scss +++ b/frontend/css/farm_designer/farm_designer_panels.scss @@ -187,6 +187,7 @@ } } +.point-inventory-panel, .plant-inventory-panel { .panel-content { padding: 0; @@ -197,6 +198,18 @@ } } +.points-panel-tabs { + i { + padding-left: 1rem; + padding-right: 1rem; + } + label { + padding-left: 10rem; + padding-right: 10rem; + margin-top: 0 !important; + } +} + .plant-selection-panel { .panel-action-buttons { position: absolute; @@ -393,6 +406,12 @@ } } +.tools-panel-content { + button { + margin-top: 1rem; + } +} + .settings-panel-content { max-height: calc(100vh - 15rem); overflow-y: auto; @@ -450,3 +469,9 @@ min-width: 7rem; } } + +.point-panel-content { + .point-search-item-name { + width: 40%; + } +} diff --git a/frontend/css/global.scss b/frontend/css/global.scss index 35f296c09..41ab009c9 100644 --- a/frontend/css/global.scss +++ b/frontend/css/global.scss @@ -829,6 +829,13 @@ ul { color: $panel_green; } } + &.gardens { + p, + h5, + a { + color: $panel_green; + } + } &.events { p, h5, @@ -839,6 +846,33 @@ ul { filter: sepia(1) contrast(1.2) saturate(1.2); } } + &.points { + p, + h5, + a { + color: $panel_gray; + } + .empty-state-graphic { + filter: saturate(0); + } + } + &.tools { + p, + h5, + a { + color: $panel_gray; + } + } + &.groups { + p, + h5, + a { + color: $panel_blue; + } + .empty-state-graphic { + filter: hue-rotate(60deg) saturate(0.6); + } + } } .farmware-selection-panel { diff --git a/frontend/farm_designer/__tests__/reducer_test.ts b/frontend/farm_designer/__tests__/reducer_test.ts index 6e9ffb9ff..90e03a1c3 100644 --- a/frontend/farm_designer/__tests__/reducer_test.ts +++ b/frontend/farm_designer/__tests__/reducer_test.ts @@ -2,28 +2,14 @@ import { designer } from "../reducer"; import { Actions } from "../../constants"; import { ReduxAction } from "../../redux/interfaces"; import { - DesignerState, HoveredPlantPayl, CurrentPointPayl, CropLiveSearchResult + HoveredPlantPayl, CurrentPointPayl, CropLiveSearchResult } from "../interfaces"; import { BotPosition } from "../../devices/interfaces"; import { fakeCropLiveSearchResult } from "../../__test_support__/fake_crop_search_result"; +import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; describe("designer reducer", () => { - const oldState = (): DesignerState => { - return { - selectedPlants: undefined, - hoveredPlant: { - plantUUID: undefined, - icon: "" - }, - hoveredPlantListItem: undefined, - cropSearchQuery: "", - cropSearchResults: [], - cropSearchInProgress: false, - chosenLocation: { x: undefined, y: undefined, z: undefined }, - currentPoint: undefined, - openedSavedGarden: undefined, - }; - }; + const oldState = fakeDesignerState; it("sets search query", () => { const action: ReduxAction = { diff --git a/frontend/farm_designer/interfaces.ts b/frontend/farm_designer/interfaces.ts index 3cf93efa3..a79792402 100644 --- a/frontend/farm_designer/interfaces.ts +++ b/frontend/farm_designer/interfaces.ts @@ -264,6 +264,7 @@ export interface CameraCalibrationData { } export interface CurrentPointPayl { + name?: string; cx: number; cy: number; r: number; diff --git a/frontend/farm_designer/map/__tests__/util_test.ts b/frontend/farm_designer/map/__tests__/util_test.ts index b82acadf9..a9acb4891 100644 --- a/frontend/farm_designer/map/__tests__/util_test.ts +++ b/frontend/farm_designer/map/__tests__/util_test.ts @@ -337,10 +337,12 @@ describe("getMode()", () => { expect(getMode()).toEqual(Mode.addPlant); mockPath = "/app/designer/move_to"; expect(getMode()).toEqual(Mode.moveTo); - mockPath = "/app/designer/plants/create_point"; + mockPath = "/app/designer/points/add"; expect(getMode()).toEqual(Mode.createPoint); mockPath = "/app/designer/saved_gardens"; expect(getMode()).toEqual(Mode.templateView); + mockPath = "/app/designer/groups/1"; + expect(getMode()).toEqual(Mode.addPointToGroup); }); }); diff --git a/frontend/farm_designer/map/drawn_point/drawn_point.tsx b/frontend/farm_designer/map/drawn_point/drawn_point.tsx index 5424e7169..b5166d30e 100644 --- a/frontend/farm_designer/map/drawn_point/drawn_point.tsx +++ b/frontend/farm_designer/map/drawn_point/drawn_point.tsx @@ -18,7 +18,7 @@ export function DrawnPoint(props: DrawnPointProps) { stroke={color ? color : "green"} strokeOpacity={0.75} strokeWidth={3} - fill="none"> + fill={"none"}> ; diff --git a/frontend/farm_designer/map/garden_map.tsx b/frontend/farm_designer/map/garden_map.tsx index 8667d653f..61434dd2e 100644 --- a/frontend/farm_designer/map/garden_map.tsx +++ b/frontend/farm_designer/map/garden_map.tsx @@ -126,6 +126,7 @@ export class GardenMap extends switch (getMode()) { case Mode.boxSelect: case Mode.moveTo: + case Mode.points: case Mode.createPoint: return undefined; // For modes without plant interaction default: diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts index 9999b615f..e264fe448 100644 --- a/frontend/farm_designer/map/interfaces.ts +++ b/frontend/farm_designer/map/interfaces.ts @@ -72,6 +72,9 @@ export interface GardenPointProps { point: TaggedGenericPointer; } +export interface GardenPointState { +} + interface DragHelpersBaseProps { dragging: boolean; mapTransformProps: MapTransformProps; @@ -140,6 +143,7 @@ export enum Mode { editPlant = "editPlant", addPlant = "addPlant", moveTo = "moveTo", + points = "points", createPoint = "createPoint", templateView = "templateView", addPointToGroup = "addPointToGroup", diff --git a/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.tsx b/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.tsx index 4a7341d80..350489220 100644 --- a/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.tsx +++ b/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.tsx @@ -29,7 +29,9 @@ import { fakeMapTransformProps } from "../../../../../__test_support__/map_transform_props"; import { movePlant } from "../../../../actions"; -import { fakeCropLiveSearchResult } from "../../../../../__test_support__/fake_crop_search_result"; +import { + fakeCropLiveSearchResult +} from "../../../../../__test_support__/fake_crop_search_result"; import { error } from "../../../../../toast/toast"; describe("newPlantKindAndBody()", () => { diff --git a/frontend/farm_designer/map/layers/plants/plant_layer.tsx b/frontend/farm_designer/map/layers/plants/plant_layer.tsx index b789780ce..8605cb67c 100644 --- a/frontend/farm_designer/map/layers/plants/plant_layer.tsx +++ b/frontend/farm_designer/map/layers/plants/plant_layer.tsx @@ -25,7 +25,8 @@ export function PlantLayer(props: PlantLayerProps) { const selected = !!(currentPlant && (p.uuid === currentPlant.uuid)); const grayscale = !!(selectedForDel && (selectedForDel.includes(p.uuid))); const plantCategory = unpackUUID(p.uuid).kind === "PlantTemplate" - ? "saved_gardens/templates" : "plants"; + ? "saved_gardens/templates" + : "plants"; const plant = - {plant} - ; - } else { - return {plant} + : {plant} ; - } })} ; } diff --git a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx index a68745f46..3d71754bf 100644 --- a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx +++ b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx @@ -113,12 +113,12 @@ describe("", () => { describe("SpreadOverlapHelper functions", () => { it("getDiscreteColor()", () => { + expect(getDiscreteColor(0, 100)).toEqual("none"); expect(getDiscreteColor(10, 100)).toEqual("green"); expect(getDiscreteColor(20, 100)).toEqual("green"); - expect(getDiscreteColor(91, 100)).toEqual("red"); - expect(getDiscreteColor(61, 100)).toEqual("orange"); - expect(getDiscreteColor(31, 100)).toEqual("yellow"); - expect(getDiscreteColor(-2, 100)).toEqual("none"); + expect(getDiscreteColor(40, 100)).toEqual("yellow"); + expect(getDiscreteColor(70, 100)).toEqual("orange"); + expect(getDiscreteColor(100, 100)).toEqual("red"); }); it("getContinuousColor()", () => { diff --git a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx index eb4e15b63..72bd170d9 100644 --- a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx +++ b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx @@ -101,7 +101,7 @@ describe("", () => { getConfigValue={jest.fn()} />); clickButton(wrapper, 0, "point creator"); expect(history.push).toHaveBeenCalledWith( - "/app/designer/plants/create_point"); + "/app/designer/points/add"); }); it("shows historic points", () => { diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index f93f9a48f..315c796ec 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -40,10 +40,11 @@ export const PointsSubMenu = ({ toggle, getConfigValue }: { getConfigValue: GetWebAppConfigValue }) =>
- + {!DevSettings.futureFeaturesEnabled() && + } { if (pathArray[4] === "select") { return Mode.boxSelect; } if (pathArray[4] === "crop_search") { return Mode.addPlant; } if (pathArray[3] === "move_to") { return Mode.moveTo; } - if (pathArray[4] === "create_point") { return Mode.createPoint; } + if (pathArray[3] === "points") { + if (pathArray[4] === "add") { return Mode.createPoint; } + return Mode.points; + } if (savedGardenOpen(pathArray)) { return Mode.templateView; } } return Mode.none; @@ -337,6 +340,7 @@ export const maybeNoPointer = case Mode.boxSelect: case Mode.clickToAdd: case Mode.moveTo: + case Mode.points: case Mode.createPoint: return { "pointerEvents": "none" }; default: diff --git a/frontend/farm_designer/panel_header.tsx b/frontend/farm_designer/panel_header.tsx index 9f9acde59..a9ed50527 100644 --- a/frontend/farm_designer/panel_header.tsx +++ b/frontend/farm_designer/panel_header.tsx @@ -9,8 +9,10 @@ export enum Panel { Plants = "Plants", FarmEvents = "FarmEvents", SavedGardens = "SavedGardens", + Tools = "Tools", Settings = "Settings", - Groups = "Groups" + Points = "Points", + Groups = "Groups", } type Tabs = keyof typeof Panel; @@ -20,7 +22,9 @@ export const TAB_COLOR: { [key in Panel]: string } = { [Panel.Plants]: "green", [Panel.FarmEvents]: "yellow", [Panel.SavedGardens]: "green", + [Panel.Tools]: "gray", [Panel.Settings]: "gray", + [Panel.Points]: "gray", [Panel.Groups]: "blue", }; @@ -31,8 +35,10 @@ export const TAB_ICON: { [key in Panel]: string } = { [Panel.Plants]: iconFile("plant"), [Panel.FarmEvents]: iconFile("calendar"), [Panel.SavedGardens]: iconFile("gardens"), - [Panel.Settings]: iconFile("gardens"), - [Panel.Groups]: iconFile("groups") + [Panel.Tools]: iconFile("tool"), + [Panel.Settings]: iconFile("settings"), + [Panel.Points]: iconFile("point"), + [Panel.Groups]: iconFile("groups"), }; const getCurrentTab = (): Tabs => { @@ -43,8 +49,12 @@ const getCurrentTab = (): Tabs => { return Panel.FarmEvents; } else if (pathArray.includes("saved_gardens")) { return Panel.SavedGardens; + } else if (pathArray.includes("tools")) { + return Panel.Tools; } else if (pathArray.includes("settings")) { return Panel.Settings; + } else if (pathArray.includes("points")) { + return Panel.Points; } else if (pathArray.includes("groups")) { return Panel.Groups; } else { @@ -84,9 +94,15 @@ export function DesignerNavTabs(props: { hidden?: boolean }) { {DevSettings.futureFeaturesEnabled() && } + {DevSettings.futureFeaturesEnabled() && + } {DevSettings.futureFeaturesEnabled() && } + {DevSettings.futureFeaturesEnabled() && + }
diff --git a/frontend/farm_designer/plants/__tests__/create_points_test.tsx b/frontend/farm_designer/plants/__tests__/create_points_test.tsx index 601f89804..726d1b783 100644 --- a/frontend/farm_designer/plants/__tests__/create_points_test.tsx +++ b/frontend/farm_designer/plants/__tests__/create_points_test.tsx @@ -103,7 +103,7 @@ describe("", () => { const wrapper = shallow(); wrapper.instance().getPointData(); expect(wrapper.instance().state).toEqual({ - color: "green", cx: 0, cy: 0, r: 1 + color: "green", cx: 0, cy: 0, r: 1, name: "Created Point" }); }); diff --git a/frontend/farm_designer/plants/__tests__/map_state_to_props_test.ts b/frontend/farm_designer/plants/__tests__/map_state_to_props_test.ts index 826dd3911..3a25babd1 100644 --- a/frontend/farm_designer/plants/__tests__/map_state_to_props_test.ts +++ b/frontend/farm_designer/plants/__tests__/map_state_to_props_test.ts @@ -1,7 +1,11 @@ import { mapStateToProps } from "../map_state_to_props"; import { fakeState } from "../../../__test_support__/fake_state"; -import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; -import { fakePlant } from "../../../__test_support__/fake_state/resources"; +import { + buildResourceIndex +} from "../../../__test_support__/resource_index_builder"; +import { + fakePlant, fakePlantTemplate +} from "../../../__test_support__/fake_state/resources"; describe("mapStateToProps()", () => { it("returns findPlant()", () => { @@ -15,4 +19,16 @@ describe("mapStateToProps()", () => { expect(result.findPlant("10")).toEqual( expect.objectContaining({ uuid })); }); + + it("finds plant template", () => { + const state = fakeState(); + const template = fakePlantTemplate(); + template.body.id = 10; + state.resources = buildResourceIndex([template]); + const uuid = Object.keys(state.resources.index.all)[0]; + state.resources.consumers.farm_designer.openedSavedGarden = "uuid"; + const result = mapStateToProps(state); + expect(result.findPlant("10")).toEqual( + expect.objectContaining({ uuid })); + }); }); diff --git a/frontend/farm_designer/plants/__tests__/point_info_test.tsx b/frontend/farm_designer/plants/__tests__/point_info_test.tsx new file mode 100644 index 000000000..28f16b1af --- /dev/null +++ b/frontend/farm_designer/plants/__tests__/point_info_test.tsx @@ -0,0 +1,44 @@ +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +let mockPath = "/app/designer/points/1"; +jest.mock("../../../history", () => ({ + getPathArray: jest.fn(() => mockPath.split("/")), + history: { push: jest.fn() } +})); + +import * as React from "react"; +import { mount } from "enzyme"; +import { EditPoint, EditPointProps, mapStateToProps } from "../point_info"; +import { fakePoint } from "../../../__test_support__/fake_state/resources"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; + +describe("", () => { + const fakeProps = (): EditPointProps => ({ + findPoint: fakePoint, + dispatch: jest.fn(), + }); + + it("renders redirect", () => { + mockPath = "/app/designer/points"; + const wrapper = mount(); + expect(wrapper.text()).toContain("Redirecting..."); + }); + + it("renders with points", () => { + mockPath = "/app/designer/points/1"; + const wrapper = mount(); + expect(wrapper.text()).toContain("Edit Point 1"); + }); +}); + +describe("mapStateToProps()", () => { + it("returns props", () => { + const state = fakeState(); + const point = fakePoint(); + point.body.id = 1; + state.resources = buildResourceIndex([point]); + const props = mapStateToProps(state); + expect(props.findPoint(1)).toEqual(point); + }); +}); diff --git a/frontend/farm_designer/plants/__tests__/point_inventory_test.tsx b/frontend/farm_designer/plants/__tests__/point_inventory_test.tsx new file mode 100644 index 000000000..bb0739bdf --- /dev/null +++ b/frontend/farm_designer/plants/__tests__/point_inventory_test.tsx @@ -0,0 +1,78 @@ +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +jest.mock("../../../history", () => ({ + push: jest.fn(), + getPathArray: () => [], +})); + +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { Points, PointsProps } from "../point_inventory"; +import { fakePoint } from "../../../__test_support__/fake_state/resources"; +import { push } from "../../../history"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { + buildResourceIndex +} from "../../../__test_support__/resource_index_builder"; +import { mapStateToProps } from "../point_inventory"; + +describe("", () => { + const fakeProps = (): PointsProps => ({ + points: [], + dispatch: jest.fn(), + }); + + it("renders no points", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("No points yet."); + }); + + it("renders points", () => { + const p = fakeProps(); + p.points = [fakePoint()]; + const wrapper = mount(); + expect(wrapper.text()).toContain("Point 1"); + }); + + it("navigates to point info", () => { + const p = fakeProps(); + p.points = [fakePoint()]; + p.points[0].body.id = 1; + const wrapper = mount(); + wrapper.find(".point-search-item").first().simulate("click"); + expect(push).toHaveBeenCalledWith("/app/designer/points/1"); + }); + + it("changes search term", () => { + const p = fakeProps(); + p.points = [fakePoint(), fakePoint()]; + p.points[0].body.name = "point 0"; + p.points[1].body.name = "point 1"; + const wrapper = shallow(); + wrapper.find("input").first().simulate("change", + { currentTarget: { value: "0" } }); + expect(wrapper.state().searchTerm).toEqual("0"); + }); + + it("filters points", () => { + const p = fakeProps(); + p.points = [fakePoint(), fakePoint()]; + p.points[0].body.name = "point 0"; + p.points[1].body.name = "point 1"; + const wrapper = mount(); + wrapper.setState({ searchTerm: "0" }); + expect(wrapper.text()).not.toContain("point 1"); + }); +}); + +describe("mapStateToProps()", () => { + it("returns props", () => { + const state = fakeState(); + const point = fakePoint(); + const discarded = fakePoint(); + discarded.body.discarded_at = "2016-05-22T05:00:00.000Z"; + state.resources = buildResourceIndex([point, discarded]); + const props = mapStateToProps(state); + expect(props.points).toEqual([point]); + }); +}); diff --git a/frontend/farm_designer/plants/create_points.tsx b/frontend/farm_designer/plants/create_points.tsx index 318eab7de..c5aa907b0 100644 --- a/frontend/farm_designer/plants/create_points.tsx +++ b/frontend/farm_designer/plants/create_points.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { connect } from "react-redux"; import { Everything, ResourceColor } from "../../interfaces"; import { initSave } from "../../api/crud"; @@ -30,6 +29,7 @@ export interface CreatePointsProps { } interface CreatePointsState { + name: string; cx: number; cy: number; r: number; @@ -51,6 +51,7 @@ export class CreatePoints getPointData = () => { const point = this.props.currentPoint; this.setState({ + name: point ? point.name : "Created Point", cx: point ? point.cx : 0, cy: point ? point.cy : 0, r: point ? point.r : 1, @@ -63,7 +64,9 @@ export class CreatePoints type: Actions.SET_CURRENT_POINT_DATA, payload: undefined }); - this.setState({ cx: undefined, cy: undefined, r: undefined, color: undefined }); + this.setState({ + cx: undefined, cy: undefined, r: undefined, color: undefined + }); } componentWillUnmount() { @@ -77,14 +80,21 @@ export class CreatePoints }); } - /** Update number fields. */ - updateNumberValue = (key: keyof Omit) => { + /** Update fields. */ + updateValue = (key: keyof CreatePointsState) => { return (e: React.SyntheticEvent) => { - const value = parseIntInput(e.currentTarget.value); + const { value } = e.currentTarget; this.setState({ [key]: value }); if (this.props.currentPoint) { const point = clone(this.props.currentPoint); - point[key] = value; + switch (key) { + case "name": + case "color": + point[key] = value; + break; + default: + point[key] = parseIntInput(value); + } this.props.dispatch({ type: Actions.SET_CURRENT_POINT_DATA, payload: point @@ -96,30 +106,42 @@ export class CreatePoints changeColor = (color: ResourceColor) => { this.setState({ color }); if (this.props.currentPoint) { - const { cx, cy, r } = this.props.currentPoint; + const { cx, cy, r, name } = this.props.currentPoint; this.props.dispatch({ type: Actions.SET_CURRENT_POINT_DATA, - payload: { cx, cy, r, color } + payload: { cx, cy, r, color, name } }); } this.forceUpdate(); } createPoint = () => { - const { cx, cy, r, color } = this.state; + const { cx, cy, r, color, name } = this.state; const body: GenericPointer = { pointer_type: "GenericPointer", - name: "Created Point", + name: name || "Created Point", meta: { color, created_by: "farm-designer" }, - x: (cx || 0), - y: (cy || 0), + x: cx || 0, + y: cy || 0, z: 0, - radius: (r || 1), + radius: r || 1, }; this.props.dispatch(initSave("Point", body)); this.cancel(); } + PointName = () => + + + + + + ; + PointProperties = () => { const { cx, cy, r, color } = this.state; return @@ -128,7 +150,7 @@ export class CreatePoints @@ -136,7 +158,7 @@ export class CreatePoints @@ -144,7 +166,7 @@ export class CreatePoints @@ -196,8 +218,10 @@ export class CreatePoints panelName={"point-creation"} panelColor={"brown"} title={t("Create point")} + backTo={"/app/designer/points"} description={Content.CREATE_POINTS_DESCRIPTION} /> + diff --git a/frontend/farm_designer/plants/plant_panel.tsx b/frontend/farm_designer/plants/plant_panel.tsx index 973483cf6..dc5c1735c 100644 --- a/frontend/farm_designer/plants/plant_panel.tsx +++ b/frontend/farm_designer/plants/plant_panel.tsx @@ -167,7 +167,7 @@ interface ListItemProps { children: React.ReactChild; } -const ListItem = (props: ListItemProps) => +export const ListItem = (props: ListItemProps) =>
  • {props.name} diff --git a/frontend/farm_designer/plants/point_info.tsx b/frontend/farm_designer/plants/point_info.tsx new file mode 100644 index 000000000..01219d7fc --- /dev/null +++ b/frontend/farm_designer/plants/point_info.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import { + DesignerPanel, DesignerPanelHeader, DesignerPanelContent +} from "./designer_panel"; +import { t } from "../../i18next_wrapper"; +import { history, getPathArray } from "../../history"; +import { Everything } from "../../interfaces"; +import { TaggedPoint } from "farmbot"; +import { maybeFindPointById } from "../../resources/selectors"; + +export interface EditPointProps { + dispatch: Function; + findPoint(id: number): TaggedPoint | undefined; +} + +export const mapStateToProps = (props: Everything): EditPointProps => ({ + dispatch: props.dispatch, + findPoint: id => maybeFindPointById(props.resources.index, id), +}); + +@connect(mapStateToProps) +export class EditPoint extends React.Component { + get stringyID() { return getPathArray()[4] || ""; } + get point() { + if (this.stringyID) { + return this.props.findPoint(parseInt(this.stringyID)); + } + } + + fallback = () => { + history.push("/app/designer/points"); + return {t("Redirecting...")}; + } + + default = (point: TaggedPoint) => { + return + + + + + ; + } + + render() { + return this.point ? this.default(this.point) : this.fallback(); + } +} diff --git a/frontend/farm_designer/plants/point_inventory.tsx b/frontend/farm_designer/plants/point_inventory.tsx new file mode 100644 index 000000000..702caaf39 --- /dev/null +++ b/frontend/farm_designer/plants/point_inventory.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import { PointInventoryItem } from "./point_inventory_item"; +import { Everything } from "../../interfaces"; +import { DesignerNavTabs, Panel } from "../panel_header"; +import { + EmptyStateWrapper, EmptyStateGraphic +} from "../../ui/empty_state_wrapper"; +import { Content } from "../../constants"; +import { + DesignerPanel, DesignerPanelContent, DesignerPanelTop +} from "./designer_panel"; +import { selectAllGenericPointers } from "../../resources/selectors"; +import { TaggedGenericPointer } from "farmbot"; +import { t } from "../../i18next_wrapper"; + +export interface PointsProps { + points: TaggedGenericPointer[]; + dispatch: Function; +} + +interface PointsState { + searchTerm: string; +} + +export function mapStateToProps(props: Everything): PointsProps { + return { + points: selectAllGenericPointers(props.resources.index) + .filter(x => !x.body.discarded_at), + dispatch: props.dispatch, + }; +} + +@connect(mapStateToProps) +export class Points extends React.Component { + + state: PointsState = { searchTerm: "" }; + + update = ({ currentTarget }: React.SyntheticEvent) => { + this.setState({ searchTerm: currentTarget.value }); + } + + render() { + return + + + + + + 0} + graphic={EmptyStateGraphic.no_crop_results} + title={t("No points yet.")} + text={Content.NO_POINTS} + colorScheme={"points"}> + {this.props.points + .filter(p => p.body.name.toLowerCase() + .includes(this.state.searchTerm.toLowerCase())) + .map(p => { + return ; + })} + + + ; + } +} diff --git a/frontend/farm_designer/plants/point_inventory_item.tsx b/frontend/farm_designer/plants/point_inventory_item.tsx new file mode 100644 index 000000000..ad5dd86b5 --- /dev/null +++ b/frontend/farm_designer/plants/point_inventory_item.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { TaggedGenericPointer } from "farmbot"; +import { Saucer } from "../../ui"; +import { push } from "../../history"; + +export interface PointInventoryItemProps { + tpp: TaggedGenericPointer; + dispatch: Function; +} + +// The individual points that show up in the farm designer sub nav. +export class PointInventoryItem extends + React.Component { + + render() { + const point = this.props.tpp.body; + const pointId = (point.id || "ERR_NO_POINT_ID").toString(); + + const click = () => { + push(`/app/designer/points/${pointId}`); + }; + + const label = point.name || "Unknown point"; + + return

    + + + {label} + +

    + {`(${point.x}, ${point.y}) ⌀${point.radius * 2}`} +

    +
    ; + } +} diff --git a/frontend/farm_designer/point_groups/__tests__/group_detail_active_test.tsx b/frontend/farm_designer/point_groups/__tests__/group_detail_active_test.tsx index 32c28da10..635e195de 100644 --- a/frontend/farm_designer/point_groups/__tests__/group_detail_active_test.tsx +++ b/frontend/farm_designer/point_groups/__tests__/group_detail_active_test.tsx @@ -1,18 +1,18 @@ -jest.mock("../../../api/crud", () => { - return { - save: jest.fn(), - overwrite: jest.fn() - }; -}); +jest.mock("../../../api/crud", () => ({ + save: jest.fn(), + overwrite: jest.fn() +})); -jest.mock("../../actions", () => { - return { toggleHoveredPlant: jest.fn() }; -}); +jest.mock("../../actions", () => ({ + toggleHoveredPlant: jest.fn() +})); import React from "react"; import { GroupDetailActive, LittleIcon } from "../group_detail_active"; import { mount, shallow } from "enzyme"; -import { fakePointGroup, fakePlant } from "../../../__test_support__/fake_state/resources"; +import { + fakePointGroup, fakePlant +} from "../../../__test_support__/fake_state/resources"; import { save, overwrite } from "../../../api/crud"; import { toggleHoveredPlant } from "../../actions"; diff --git a/frontend/farm_designer/point_groups/__tests__/group_detail_test.tsx b/frontend/farm_designer/point_groups/__tests__/group_detail_test.tsx index 9746c3b5d..8c7fd94c6 100644 --- a/frontend/farm_designer/point_groups/__tests__/group_detail_test.tsx +++ b/frontend/farm_designer/point_groups/__tests__/group_detail_test.tsx @@ -1,4 +1,6 @@ -import { fakePointGroup, fakePlant } from "../../../__test_support__/fake_state/resources"; +import { + fakePointGroup, fakePlant +} from "../../../__test_support__/fake_state/resources"; const GOOD_ID = 9; const mockPlant = fakePlant(); @@ -24,7 +26,9 @@ import { GroupDetailActive } from "../group_detail_active"; import { GroupDetail } from "../group_detail"; import { fakeState } from "../../../__test_support__/fake_state"; import { createStore } from "redux"; -import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import { + buildResourceIndex +} from "../../../__test_support__/resource_index_builder"; import { push } from "../../../history"; describe("", () => { diff --git a/frontend/farm_designer/point_groups/__tests__/group_list_panel_test.tsx b/frontend/farm_designer/point_groups/__tests__/group_list_panel_test.tsx index 368a1b92a..4460ee1d5 100644 --- a/frontend/farm_designer/point_groups/__tests__/group_list_panel_test.tsx +++ b/frontend/farm_designer/point_groups/__tests__/group_list_panel_test.tsx @@ -1,24 +1,18 @@ -jest.mock("../../../history", () => { - return { - getPathArray: jest.fn(() => ["L", "O", "L"]), - history: { - push: jest.fn(), - } - }; -}); +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +jest.mock("../../../history", () => ({ + getPathArray: jest.fn(() => ["L", "O", "L"]), + history: { push: jest.fn() } +})); import React from "react"; -import { mount } from "enzyme"; -import { GroupListPanel, newUpdater } from "../group_list_panel"; -import { Provider } from "react-redux"; -import { createStore, DeepPartial } from "redux"; -import { fakeState } from "../../../__test_support__/fake_state"; -import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; +import { mount, shallow } from "enzyme"; +import { GroupListPanel, GroupListPanelProps } from "../group_list_panel"; import { fakePointGroup } from "../../../__test_support__/fake_state/resources"; import { history } from "../../../history"; describe("", () => { - const setUpTests = () => { + const fakeProps = (): GroupListPanelProps => { const fake1 = fakePointGroup(); fake1.body.name = "one"; fake1.body.id = 9; @@ -27,35 +21,34 @@ describe("", () => { const fake2 = fakePointGroup(); fake2.body.name = "two"; - const state = fakeState(); - state.resources = buildResourceIndex([fake1, fake2]); - const store = createStore(s => s, state); - - return { store, fake1, fake2 }; + return { dispatch: jest.fn(), groups: [fake1, fake2] }; }; - it("handles the `change` event", () => { - const setState = jest.fn(); - const fn = newUpdater(setState, "searchTerm"); - type E = React.SyntheticEvent; - const e: DeepPartial = { currentTarget: { value: "X" } }; - fn(e as E); - expect(setState).toHaveBeenCalledWith({ searchTerm: "X" }); + it("changes search term", () => { + const p = fakeProps(); + const wrapper = shallow(); + wrapper.find("input").first().simulate("change", + { currentTarget: { value: "one" } }); + expect(wrapper.state().searchTerm).toEqual("one"); }); it("renders relevant group data as a list", () => { - const { store, fake1, fake2 } = setUpTests(); - - const el = mount( - - ); - el.find(".plant-search-item").first().simulate("click"); + const p = fakeProps(); + const wrapper = mount(); + wrapper.find(".plant-search-item").first().simulate("click"); expect(history.push).toHaveBeenCalledWith("/app/designer/groups/9"); - const text = el.text(); - expect(text).toContain("3 items"); - expect(text).toContain("0 items"); - expect(text).toContain(fake2.body.name); - expect(text).toContain(fake1.body.name); + ["3 items", + "0 items", + p.groups[0].body.name, + p.groups[1].body.name].map(string => + expect(wrapper.text()).toContain(string)); + }); + + it("renders no groups", () => { + const p = fakeProps(); + p.groups = []; + const wrapper = mount(); + expect(wrapper.text().toLowerCase()).toContain("no groups yet"); }); }); diff --git a/frontend/farm_designer/point_groups/group_list_panel.tsx b/frontend/farm_designer/point_groups/group_list_panel.tsx index b844d933d..6e9e74d77 100644 --- a/frontend/farm_designer/point_groups/group_list_panel.tsx +++ b/frontend/farm_designer/point_groups/group_list_panel.tsx @@ -3,13 +3,17 @@ import { connect } from "react-redux"; import { Everything } from "../../interfaces"; import { Panel, DesignerNavTabs } from "../panel_header"; import { t } from "../../i18next_wrapper"; -import { DesignerPanel, DesignerPanelTop, DesignerPanelContent } from "../plants/designer_panel"; +import { + DesignerPanel, DesignerPanelTop, DesignerPanelContent +} from "../plants/designer_panel"; import { findAll } from "../../resources/find_all"; import { TaggedPointGroup } from "farmbot"; import { history } from "../../history"; import { GroupInventoryItem } from "./group_inventory_item"; +import { EmptyStateWrapper, EmptyStateGraphic } from "../../ui/empty_state_wrapper"; +import { Content } from "../../constants"; -interface GroupListPanelProps { +export interface GroupListPanelProps { dispatch: Function; groups: TaggedPointGroup[]; } @@ -23,20 +27,16 @@ function mapStateToProps(props: Everything): GroupListPanelProps { findAll(props.resources.index, "PointGroup"); return { groups, dispatch: props.dispatch }; } -/** I wanted this to be a member method of but testing was - * too wonky due to @connect(). If anyone knows a way to test this, feel free - * to do a non-curried solution. -RC*/ -export const newUpdater = - (cb: Function, key: keyof GroupListPanel["state"]) => - (e: React.SyntheticEvent) => { - cb({ [key]: e.currentTarget.value }); - }; @connect(mapStateToProps) export class GroupListPanel extends React.Component { state: State = { searchTerm: "" }; + update = ({ currentTarget }: React.SyntheticEvent) => { + this.setState({ searchTerm: currentTarget.value }); + } + navigate = (id: number) => history.push(`/app/designer/groups/${id}`); render() { @@ -47,21 +47,27 @@ export class GroupListPanel extends React.Component linkTo={"/app/designer/plants/select"} title={t("Add Group")}> - {this - .props - .groups - .filter(p => p.body.name.toLowerCase().includes(this.state.searchTerm.toLowerCase())) - .map(group => this.navigate(group.body.id || 0)} - />)} + 0} + title={t("No groups yet.")} + text={t(Content.NO_GROUPS)} + colorScheme="groups" + graphic={EmptyStateGraphic.plants}> + {this.props.groups + .filter(p => p.body.name.toLowerCase() + .includes(this.state.searchTerm.toLowerCase())) + .map(group => this.navigate(group.body.id || 0)} + />)} + ; } diff --git a/frontend/farm_designer/saved_gardens/saved_gardens.tsx b/frontend/farm_designer/saved_gardens/saved_gardens.tsx index ae3be4de4..74141452c 100644 --- a/frontend/farm_designer/saved_gardens/saved_gardens.tsx +++ b/frontend/farm_designer/saved_gardens/saved_gardens.tsx @@ -1,7 +1,6 @@ import * as React from "react"; import { Everything } from "../../interfaces"; import { connect } from "react-redux"; - import { history } from "../../history"; import { unselectPlant } from "../actions"; import { @@ -19,6 +18,7 @@ import { import { DevSettings } from "../../account/dev/dev_support"; import { DesignerNavTabs } from "../panel_header"; import { t } from "../../i18next_wrapper"; +import { EmptyStateWrapper, EmptyStateGraphic } from "../../ui/empty_state_wrapper"; export const mapStateToProps = (props: Everything): SavedGardensProps => ({ savedGardens: selectAllSavedGardens(props.resources.index), @@ -58,9 +58,14 @@ export class SavedGardens extends React.Component { plantTemplates={this.props.plantTemplates} dispatch={this.props.dispatch} />
    - {this.props.savedGardens.length > 0 - ? - :

    {t("No saved gardens yet.")}

    } + 0} + title={t("No saved gardens yet.")} + // text={t(Content.NO_GARDENS)} + colorScheme="gardens" + graphic={EmptyStateGraphic.plants}> + + ; } diff --git a/frontend/farm_designer/tools/__tests__/add_tool_test.tsx b/frontend/farm_designer/tools/__tests__/add_tool_test.tsx new file mode 100644 index 000000000..0b431377a --- /dev/null +++ b/frontend/farm_designer/tools/__tests__/add_tool_test.tsx @@ -0,0 +1,43 @@ +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +jest.mock("../../../api/crud", () => ({ initSave: jest.fn() })); + +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { AddTool, AddToolProps, mapStateToProps } from "../add_tool"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { SaveBtn } from "../../../ui"; +import { initSave } from "../../../api/crud"; + +describe("", () => { + const fakeProps = (): AddToolProps => ({ + dispatch: jest.fn(), + }); + + it("renders", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("Add new tool"); + }); + + it("edits tool name", () => { + const wrapper = shallow(); + expect(wrapper.state().toolName).toEqual(""); + wrapper.find("input").simulate("change", + { currentTarget: { value: "new name" } }); + expect(wrapper.state().toolName).toEqual("new name"); + }); + + it("saves", () => { + const wrapper = shallow(); + wrapper.setState({ toolName: "Foo" }); + wrapper.find(SaveBtn).simulate("click"); + expect(initSave).toHaveBeenCalledWith("Tool", { name: "Foo" }); + }); +}); + +describe("mapStateToProps()", () => { + it("returns props", () => { + const props = mapStateToProps(fakeState()); + expect(props.dispatch).toEqual(expect.any(Function)); + }); +}); diff --git a/frontend/farm_designer/tools/__tests__/edit_tool_test.tsx b/frontend/farm_designer/tools/__tests__/edit_tool_test.tsx new file mode 100644 index 000000000..9b3c8914e --- /dev/null +++ b/frontend/farm_designer/tools/__tests__/edit_tool_test.tsx @@ -0,0 +1,64 @@ +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +jest.mock("../../../api/crud", () => ({ edit: jest.fn() })); + +jest.mock("../../../history", () => ({ + history: { push: jest.fn() }, + getPathArray: () => "/app/designer/tools/1".split("/"), +})); + +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { EditTool, EditToolProps, mapStateToProps } from "../edit_tool"; +import { fakeTool } from "../../../__test_support__/fake_state/resources"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { + buildResourceIndex +} from "../../../__test_support__/resource_index_builder"; +import { SaveBtn } from "../../../ui"; +import { history } from "../../../history"; +import { edit } from "../../../api/crud"; + +describe("", () => { + const fakeProps = (): EditToolProps => ({ + findTool: jest.fn(() => fakeTool()), + dispatch: jest.fn(), + }); + + it("renders", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("Edit Foo"); + }); + + it("redirects", () => { + const p = fakeProps(); + p.findTool = jest.fn(() => undefined); + const wrapper = mount(); + expect(wrapper.text()).toContain("Redirecting..."); + }); + + it("edits tool name", () => { + const wrapper = shallow(); + wrapper.find("input").simulate("change", + { currentTarget: { value: "new name" } }); + expect(wrapper.state().toolName).toEqual("new name"); + }); + + it("saves", () => { + const wrapper = shallow(); + wrapper.find(SaveBtn).simulate("click"); + expect(edit).toHaveBeenCalledWith(expect.any(Object), { name: "Foo" }); + expect(history.push).toHaveBeenCalledWith("/app/designer/tools"); + }); +}); + +describe("mapStateToProps()", () => { + it("returns props", () => { + const state = fakeState(); + const tool = fakeTool(); + tool.body.id = 123; + state.resources = buildResourceIndex([tool]); + const props = mapStateToProps(state); + expect(props.findTool("" + tool.body.id)).toEqual(tool); + }); +}); diff --git a/frontend/farm_designer/tools/__tests__/index_test.tsx b/frontend/farm_designer/tools/__tests__/index_test.tsx new file mode 100644 index 000000000..251496acf --- /dev/null +++ b/frontend/farm_designer/tools/__tests__/index_test.tsx @@ -0,0 +1,86 @@ +jest.mock("react-redux", () => ({ connect: jest.fn() })); + +jest.mock("../../../history", () => ({ + history: { push: jest.fn() }, + getPathArray: () => "/app/designer/tools".split("/"), +})); + +import * as React from "react"; +import { mount, shallow } from "enzyme"; +import { Tools, ToolsProps, mapStateToProps } from "../index"; +import { + fakeTool, fakeToolSlot +} from "../../../__test_support__/fake_state/resources"; +import { history } from "../../../history"; +import { fakeState } from "../../../__test_support__/fake_state"; +import { buildResourceIndex } from "../../../__test_support__/resource_index_builder"; + +describe("", () => { + const fakeProps = (): ToolsProps => ({ + tools: [], + toolSlots: [], + dispatch: jest.fn(), + }); + + it("renders with no tools", () => { + const wrapper = mount(); + expect(wrapper.text()).toContain("Add a tool"); + }); + + it("renders with tools", () => { + const p = fakeProps(); + p.tools = [fakeTool()]; + p.tools[0].body.id = 1; + p.tools[0].body.status = "inactive"; + p.toolSlots = [fakeToolSlot()]; + p.toolSlots[0].body.x = 1; + const wrapper = mount(); + expect(wrapper.text()).toContain("Foo"); + expect(wrapper.text()).toContain("(1, 0, 0)"); + }); + + it("navigates to tool", () => { + const p = fakeProps(); + p.tools = [fakeTool()]; + p.tools[0].body.id = 1; + p.tools[0].body.status = "inactive"; + p.toolSlots = [fakeToolSlot()]; + p.toolSlots[0].body.tool_id = 2; + const wrapper = mount(); + wrapper.find("p").first().simulate("click"); + expect(history.push).toHaveBeenCalledWith("/app/designer/tools/2"); + wrapper.find("p").last().simulate("click"); + expect(history.push).toHaveBeenCalledWith("/app/designer/tools/1"); + }); + + it("changes search term", () => { + const p = fakeProps(); + p.tools = [fakeTool(), fakeTool()]; + p.tools[0].body.name = "tool 0"; + p.tools[1].body.name = "tool 1"; + const wrapper = shallow(); + wrapper.find("input").first().simulate("change", + { currentTarget: { value: "0" } }); + expect(wrapper.state().searchTerm).toEqual("0"); + }); + + it("filters tools", () => { + const p = fakeProps(); + p.tools = [fakeTool(), fakeTool()]; + p.tools[0].body.name = "tool 0"; + p.tools[1].body.name = "tool 1"; + const wrapper = mount(); + wrapper.setState({ searchTerm: "0" }); + expect(wrapper.text()).not.toContain("tool 1"); + }); +}); + +describe("mapStateToProps()", () => { + it("returns props", () => { + const state = fakeState(); + const tool = fakeTool(); + state.resources = buildResourceIndex([tool]); + const props = mapStateToProps(state); + expect(props.tools).toEqual([tool]); + }); +}); diff --git a/frontend/farm_designer/tools/add_tool.tsx b/frontend/farm_designer/tools/add_tool.tsx new file mode 100644 index 000000000..155d98c70 --- /dev/null +++ b/frontend/farm_designer/tools/add_tool.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { connect } from "react-redux"; +import { + DesignerPanel, DesignerPanelContent, DesignerPanelHeader +} from "../plants/designer_panel"; +import { Everything } from "../../interfaces"; +import { t } from "../../i18next_wrapper"; +import { SaveBtn } from "../../ui"; +import { SpecialStatus } from "farmbot"; +import { initSave } from "../../api/crud"; + +export interface AddToolProps { + dispatch: Function; +} + +export interface AddToolState { + toolName: string; +} + +export const mapStateToProps = (props: Everything): AddToolProps => ({ + dispatch: props.dispatch, +}); + +@connect(mapStateToProps) +export class AddTool extends React.Component { + state: AddToolState = { toolName: "" }; + render() { + return + + + + this.setState({ toolName: e.currentTarget.value })} /> + + this.props.dispatch(initSave("Tool", { name: this.state.toolName }))} + status={SpecialStatus.DIRTY} /> + + ; + } +} diff --git a/frontend/farm_designer/tools/edit_tool.tsx b/frontend/farm_designer/tools/edit_tool.tsx new file mode 100644 index 000000000..dfc9a53ab --- /dev/null +++ b/frontend/farm_designer/tools/edit_tool.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { connect } from "react-redux"; +import { + DesignerPanel, DesignerPanelContent, DesignerPanelHeader +} from "../plants/designer_panel"; +import { Everything } from "../../interfaces"; +import { t } from "../../i18next_wrapper"; +import { getPathArray } from "../../history"; +import { TaggedTool, SpecialStatus } from "farmbot"; +import { maybeFindToolById } from "../../resources/selectors"; +import { SaveBtn } from "../../ui"; +import { edit } from "../../api/crud"; +import { history } from "../../history"; + +export interface EditToolProps { + findTool(id: string): TaggedTool | undefined; + dispatch: Function; +} + +export interface EditToolState { + toolName: string; +} + +export const mapStateToProps = (props: Everything): EditToolProps => ({ + findTool: (id: string) => + maybeFindToolById(props.resources.index, parseInt(id)), + dispatch: props.dispatch, +}); + +@connect(mapStateToProps) +export class EditTool extends React.Component { + state: EditToolState = { toolName: this.tool ? this.tool.body.name || "" : "" }; + + get stringyID() { return getPathArray()[4] || ""; } + + get tool() { return this.props.findTool(this.stringyID); } + + fallback = () => { + history.push("/app/designer/tools"); + return {t("Redirecting...")}; + } + + default = (tool: TaggedTool) => + + + + + this.setState({ toolName: e.currentTarget.value })} /> + { + this.props.dispatch(edit(tool, { name: this.state.toolName })); + history.push("/app/designer/tools"); + }} + status={SpecialStatus.DIRTY} /> + + ; + + render() { + return this.tool ? this.default(this.tool) : this.fallback(); + } +} diff --git a/frontend/farm_designer/tools/index.tsx b/frontend/farm_designer/tools/index.tsx new file mode 100644 index 000000000..16c24404a --- /dev/null +++ b/frontend/farm_designer/tools/index.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { connect } from "react-redux"; +import { + DesignerPanel, DesignerPanelTop, DesignerPanelContent +} from "../plants/designer_panel"; +import { Everything } from "../../interfaces"; +import { DesignerNavTabs, Panel } from "../panel_header"; +import { + EmptyStateWrapper, EmptyStateGraphic +} from "../../ui/empty_state_wrapper"; +import { t } from "../../i18next_wrapper"; +import { TaggedTool, TaggedToolSlotPointer } from "farmbot"; +import { + selectAllTools, selectAllToolSlotPointers +} from "../../resources/selectors"; +import { Content } from "../../constants"; +import { history } from "../../history"; +import { Row, Col } from "../../ui"; +import { botPositionLabel } from "../map/layers/farmbot/bot_position_label"; + +export interface ToolsProps { + tools: TaggedTool[]; + toolSlots: TaggedToolSlotPointer[]; + dispatch: Function; +} + +export interface ToolsState { + searchTerm: string; +} + +export const mapStateToProps = (props: Everything): ToolsProps => ({ + tools: selectAllTools(props.resources.index), + toolSlots: selectAllToolSlotPointers(props.resources.index), + dispatch: props.dispatch, +}); + +@connect(mapStateToProps) +export class Tools extends React.Component { + state: ToolsState = { searchTerm: "" }; + + update = ({ currentTarget }: React.SyntheticEvent) => { + this.setState({ searchTerm: currentTarget.value }); + } + + getToolName = (toolId: number | undefined): string | undefined => { + const foundTool = this.props.tools.filter(tool => tool.body.id === toolId)[0]; + return foundTool ? foundTool.body.name : undefined; + }; + + render() { + const panelName = "tools"; + return + + + + + + 0} + graphic={EmptyStateGraphic.sequences} + title={t("Add a tool")} + text={Content.NO_TOOLS} + colorScheme={"tools"}> +
    + + {this.props.toolSlots + .filter(p => (this.getToolName(p.body.tool_id) || "").toLowerCase() + .includes(this.state.searchTerm.toLowerCase())) + .map(toolSlot => + )} +
    + + {this.props.tools + .filter(tool => tool.body.name && tool.body.name.toLowerCase() + .includes(this.state.searchTerm.toLowerCase())) + .filter(tool => tool.body.status === "inactive") + .map(tool => + )} +
    +
    +
    +
    ; + } +} + +interface ToolSlotInventoryItemProps { + toolSlot: TaggedToolSlotPointer; + getToolName(toolId: number | undefined): string | undefined; +} + +const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => { + const { x, y, z, tool_id } = props.toolSlot.body; + return + +

    history.push(`/app/designer/tools/${tool_id}`)}> + {props.getToolName(tool_id) || t("No tool")} +

    + + +

    {botPositionLabel({ x, y, z })}

    + +
    ; +}; + +interface ToolInventoryItemProps { + toolName: string; + toolId: number | undefined; +} + +const ToolInventoryItem = (props: ToolInventoryItemProps) => + + +

    history.push(`/app/designer/tools/${props.toolId}`)}> + {t(props.toolName)} +

    + +
    ; diff --git a/frontend/hotkeys.tsx b/frontend/hotkeys.tsx index 274f84559..b73047076 100644 --- a/frontend/hotkeys.tsx +++ b/frontend/hotkeys.tsx @@ -1,6 +1,5 @@ import * as React from "react"; - -import { links } from "./nav/nav_links"; +import { getLinks } from "./nav/nav_links"; import { sync } from "./devices/actions"; import { push, getPathArray } from "./history"; import { Row, Col } from "./ui/index"; @@ -60,6 +59,7 @@ export class HotKeys extends React.Component> { this.setState({ [property]: !this.state[property] }); private hotkeys(dispatch: Function, slug: string) { + const links = getLinks(); const idx = findIndex(links, { slug }); const right = "/app/" + (links[idx + 1] || links[0]).slug; const left = "/app/" + (links[idx - 1] || links[links.length - 1]).slug; diff --git a/frontend/link.tsx b/frontend/link.tsx index 85530ad5b..ac1296cbc 100644 --- a/frontend/link.tsx +++ b/frontend/link.tsx @@ -6,6 +6,7 @@ interface LinkProps extends React.AnchorHTMLAttributes { children?: React.ReactChild | React.ReactChild[]; style?: React.CSSProperties; className?: string; + disabled?: boolean; } export const maybeStripLegacyUrl = @@ -23,6 +24,8 @@ export const clickHandler = export class Link extends React.Component { render() { const { props } = this; - return ; + return props.disabled + ? + : ; } } diff --git a/frontend/nav/nav_links.tsx b/frontend/nav/nav_links.tsx index 85050f874..ba323581b 100644 --- a/frontend/nav/nav_links.tsx +++ b/frontend/nav/nav_links.tsx @@ -7,6 +7,8 @@ import { } from "./compute_editor_url_from_state"; import { Link } from "../link"; import { t } from "../i18next_wrapper"; +import { betterCompact } from "../util"; +import { DevSettings } from "../account/dev/dev_support"; /** Uses a slug and a child path to compute the `href` of a navbar link. */ export type LinkComputeFn = (slug: string, childPath: string) => string; @@ -24,7 +26,7 @@ interface NavLinkParams { computeHref?: LinkComputeFn } -export const links: NavLinkParams[] = [ +export const getLinks = (): NavLinkParams[] => betterCompact([ { name: "Farm Designer", icon: "leaf", slug: "designer" }, { name: "Controls", icon: "keyboard-o", slug: "controls" }, { name: "Device", icon: "cog", slug: "device" }, @@ -36,19 +38,21 @@ export const links: NavLinkParams[] = [ name: "Regimens", icon: "calendar-check-o", slug: "regimens", computeHref: computeEditorUrlFromState("Regimen") }, - { name: "Tools", icon: "wrench", slug: "tools" }, + DevSettings.futureFeaturesEnabled() + ? undefined + : { name: "Tools", icon: "wrench", slug: "tools" }, { name: "Farmware", icon: "crosshairs", slug: "farmware", computeHref: computeFarmwareUrlFromState }, { name: "Messages", icon: "list", slug: "messages" }, -]; +]); export const NavLinks = (props: NavLinksProps) => { const currPageSlug = getPathArray()[2]; return
    - {links.map(link => { + {getLinks().map(link => { const isActive = (currPageSlug === link.slug) ? "active" : ""; const childPath = link.slug === "designer" ? "/plants" : ""; const fn = link.computeHref || DEFAULT; diff --git a/frontend/resources/selectors_by_id.ts b/frontend/resources/selectors_by_id.ts index 7d2b2e214..3b81d7e78 100644 --- a/frontend/resources/selectors_by_id.ts +++ b/frontend/resources/selectors_by_id.ts @@ -8,6 +8,7 @@ import { isTaggedToolSlotPointer, sanityCheck, isTaggedPlantTemplate, + isTaggedGenericPointer, } from "./tagged_resources"; import { ResourceName, @@ -105,6 +106,13 @@ export function maybeFindPlantTemplateById(index: ResourceIndex, id: number) { if (resource && isTaggedPlantTemplate(resource)) { return resource; } } +/** Unlike other findById methods, this one allows undefined (missed) values */ +export function maybeFindPointById(index: ResourceIndex, id: number) { + const uuid = index.byKindAndId[joinKindAndId("Point", id)]; + const resource = index.references[uuid || "nope"]; + if (resource && isTaggedGenericPointer(resource)) { return resource; } +} + export let findRegimenById = (ri: ResourceIndex, regimen_id: number) => { const regimen = byId("Regimen")(ri, regimen_id); if (regimen && isTaggedRegimen(regimen) && sanityCheck(regimen)) { diff --git a/frontend/route_config.tsx b/frontend/route_config.tsx index 262817753..a7d5de1e2 100644 --- a/frontend/route_config.tsx +++ b/frontend/route_config.tsx @@ -91,7 +91,8 @@ const key = "FarmDesigner"; * * DO NOT RE-ORDER ITEMS FOR READABILITY--they are order-dependent. * Stuff will break if the route order is changed. - * (e.g., must be "a" then "a/:b/c" then "a/:b", 404 must be last, etc.) + * (e.g., must be ["a", "a/b", "a/b/:c/d", "a/b/:c", "a/:e"], + * 404 must be last, etc.) */ export const UNBOUND_ROUTES = [ route({ @@ -218,12 +219,28 @@ export const UNBOUND_ROUTES = [ }), route({ children: true, - $: "/designer/plants/create_point", + $: "/designer/points", + getModule, + key, + getChild: () => import("./farm_designer/plants/point_inventory"), + childKey: "Points" + }), + route({ + children: true, + $: "/designer/points/add", getModule, key, getChild: () => import("./farm_designer/plants/create_points"), childKey: "CreatePoints" }), + route({ + children: true, + $: "/designer/points/:point_id", + getModule, + key, + getChild: () => import("./farm_designer/plants/point_info"), + childKey: "EditPoint" + }), route({ children: true, $: "/designer/plants/crop_search", @@ -296,6 +313,30 @@ export const UNBOUND_ROUTES = [ getChild: () => import("./farm_designer/settings"), childKey: "DesignerSettings" }), + route({ + children: true, + $: "/designer/tools", + getModule, + key, + getChild: () => import("./farm_designer/tools"), + childKey: "Tools" + }), + route({ + children: true, + $: "/designer/tools/add", + getModule, + key, + getChild: () => import("./farm_designer/tools/add_tool"), + childKey: "AddTool" + }), + route({ + children: true, + $: "/designer/tools/:tool_id", + getModule, + key, + getChild: () => import("./farm_designer/tools/edit_tool"), + childKey: "EditTool" + }), route({ children: true, $: "/designer/groups", diff --git a/frontend/toast/__tests__/fb_toast_test.ts b/frontend/toast/__tests__/fb_toast_test.ts index 7d726432b..a0bd08c2e 100644 --- a/frontend/toast/__tests__/fb_toast_test.ts +++ b/frontend/toast/__tests__/fb_toast_test.ts @@ -100,4 +100,22 @@ describe("FBToast", () => { i.doPolling(); expect(i.detach).toHaveBeenCalled(); }); + + it("does polling: large timeout value", () => { + const [i] = newToast(); + i.isHovered = false; + i.timeout = 8; + i.detach = jest.fn(); + i.doPolling(); + expect(i.detach).not.toHaveBeenCalled(); + }); + + it("does polling: hovered", () => { + const [i] = newToast(); + i.isHovered = true; + i.timeout = 0; + i.detach = jest.fn(); + i.doPolling(); + expect(i.detach).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/toast/__tests__/toast_test.ts b/frontend/toast/__tests__/toast_test.ts index f14fb2ec1..9822a399a 100644 --- a/frontend/toast/__tests__/toast_test.ts +++ b/frontend/toast/__tests__/toast_test.ts @@ -26,6 +26,13 @@ describe("toasts", () => { console.warn); }); + it("pops a warning() toast with different title and color", () => { + warning("test suite msg", "new title", "purple"); + expect(createToastOnce) + .toHaveBeenCalledWith("test suite msg", "new title", "purple", + console.warn); + }); + it("pops a error() toast", () => { error("test suite msg 2"); expect(createToastOnce).toHaveBeenCalledWith("test suite msg 2", @@ -34,18 +41,37 @@ describe("toasts", () => { console.error); }); + it("pops a error() toast with different title and color", () => { + error("test suite msg", "new title", "purple"); + expect(createToastOnce) + .toHaveBeenCalledWith("test suite msg", "new title", "purple", + console.error); + }); + it("pops a success() toast", () => { success("test suite msg"); expect(createToast) .toHaveBeenCalledWith("test suite msg", "Success", "green"); }); + it("pops a success() toast with different title and color", () => { + success("test suite msg", "new title", "purple"); + expect(createToast) + .toHaveBeenCalledWith("test suite msg", "new title", "purple"); + }); + it("pops a info() toast", () => { info("test suite msg"); expect(createToast) .toHaveBeenCalledWith("test suite msg", "FYI", "blue"); }); + it("pops a info() toast with different title and color", () => { + info("test suite msg", "new title", "purple"); + expect(createToast) + .toHaveBeenCalledWith("test suite msg", "new title", "purple"); + }); + it("pops a busy() toast", () => { busy("test suite msg"); expect(createToast) @@ -64,6 +90,12 @@ describe("toasts", () => { .toHaveBeenCalledWith("test suite msg", "Did you know?", "dark-blue"); }); + it("pops a fun() toast with different title and color", () => { + fun("test suite msg", "new title", "purple"); + expect(createToast) + .toHaveBeenCalledWith("test suite msg", "new title", "purple"); + }); + it("adds the appropriate div to the DOM", () => { const count1 = document.querySelectorAll(".toast-container").item.length; expect(count1).toEqual(1); diff --git a/frontend/ui/empty_state_wrapper.tsx b/frontend/ui/empty_state_wrapper.tsx index c6bdb5913..30a133652 100644 --- a/frontend/ui/empty_state_wrapper.tsx +++ b/frontend/ui/empty_state_wrapper.tsx @@ -16,7 +16,7 @@ interface EmptyStateWrapperProps { text?: string; textElement?: JSX.Element; graphic: string; - colorScheme?: "plants" | "events"; + colorScheme?: "plants" | "events" | "gardens" | "points" | "tools" | "groups"; children?: React.ReactNode; } diff --git a/public/app-resources/img/icons/point.svg b/public/app-resources/img/icons/point.svg new file mode 100644 index 000000000..7d780d348 --- /dev/null +++ b/public/app-resources/img/icons/point.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + diff --git a/public/app-resources/img/icons/settings.svg b/public/app-resources/img/icons/settings.svg new file mode 100644 index 000000000..56f3929dc --- /dev/null +++ b/public/app-resources/img/icons/settings.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + diff --git a/public/app-resources/img/icons/tool.svg b/public/app-resources/img/icons/tool.svg new file mode 100644 index 000000000..5e0f9eb58 --- /dev/null +++ b/public/app-resources/img/icons/tool.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + +