multiple plant selection and deletion ui

pull/458/head
gabrielburnworth 2017-09-19 00:12:11 -07:00
parent 6e37de86de
commit a77e96c6bf
20 changed files with 369 additions and 40 deletions

View File

@ -121,6 +121,9 @@
color: $white;
}
}
button {
margin-left: 5px;
}
}
.plant-inventory-panel {
@ -129,6 +132,13 @@
}
}
.plant-selection-panel {
.panel-content {
padding: 10rem 1.4rem;
pointer-events: none;
}
}
.crop-catalog-panel {
.back-arrow {
margin-top: 0rem;

View File

@ -12,7 +12,7 @@ describe("<FarmDesigner/>", () => {
dispatch: jest.fn(),
selectedPlant: undefined,
designer: {
selectedPlant: undefined,
selectedPlants: undefined,
hoveredPlant: {
plantUUID: undefined,
icon: ""

View File

@ -15,6 +15,7 @@ import { BotPosition, StepsPerMmXY } from "../devices/interfaces";
import { isNumber } from "lodash";
import { McuParams } from "farmbot/dist";
import { AxisNumberProperty, BotSize } from "./map/interfaces";
import { SelectionBoxData } from "./map/selection_box";
/** TODO: Use Enums */
export type BotOriginQuadrant = 1 | 2 | 3 | 4;
@ -94,7 +95,7 @@ export interface Crop {
}
export interface DesignerState {
selectedPlant: string | undefined;
selectedPlants: string[] | undefined;
hoveredPlant: HoveredPlantPayl;
cropSearchQuery: string;
cropSearchResults: CropLiveSearchResult[];
@ -180,6 +181,7 @@ export interface GardenMapState {
pageY: number | undefined;
activeDragXY: BotPosition | undefined;
activeDragSpread: number | undefined;
selectionBox: SelectionBoxData | undefined;
}
export type PlantOptions = Partial<PlantPointer>;

View File

@ -27,7 +27,7 @@ describe("<GardenPlant/>", () => {
crops: [],
dispatch: jest.fn(),
designer: {
selectedPlant: "",
selectedPlants: undefined,
hoveredPlant: {
plantUUID: "",
icon: ""

View File

@ -0,0 +1,33 @@
import * as React from "react";
import { SelectionBox, SelectionBoxProps } from "../selection_box";
import { shallow } from "enzyme";
describe("<SelectionBox/>", () => {
function fakeProps(): SelectionBoxProps {
return {
selectionBox: {
x0: 40,
y0: 30,
x1: 240,
y1: 130
}
};
}
it("renders selection box", () => {
const wrapper = shallow(<SelectionBox {...fakeProps() } />);
const boxProps = wrapper.find("rect").props();
expect(boxProps.x).toEqual(40);
expect(boxProps.y).toEqual(30);
expect(boxProps.width).toEqual(200);
expect(boxProps.height).toEqual(100);
});
it("doesn't render selection box: partially undefined", () => {
const p = fakeProps();
p.selectionBox.x1 = undefined;
const wrapper = shallow(<SelectionBox {...p } />);
expect(wrapper.html()).toEqual("<g id=\"selection-box\"></g>");
});
});

View File

@ -26,6 +26,8 @@ import { HoveredPlantLayer } from "./layers/hovered_plant_layer";
import { FarmBotLayer } from "./layers/farmbot_layer";
import { cachedCrop } from "../../open_farm/index";
import { DragHelperLayer } from "./layers/drag_helper_layer";
import { AxisNumberProperty } from "./interfaces";
import { SelectionBox, SelectionBoxData } from "./selection_box";
const DRAG_ERROR = `ERROR - Couldn't get zoom level of garden map, check the
handleDrop() or drag() method in garden_map.tsx`;
@ -46,7 +48,8 @@ export class GardenMap extends
this.setState({
isDragging: false, pageX: 0, pageY: 0,
activeDragXY: { x: undefined, y: undefined, z: undefined },
activeDragSpread: undefined
activeDragSpread: undefined,
selectionBox: undefined
});
}
@ -59,7 +62,29 @@ export class GardenMap extends
);
}
startDrag = (): void => {
getGardenCoordinates(
e: React.DragEvent<HTMLElement> | React.MouseEvent<SVGElement>
): AxisNumberProperty | undefined {
const el = document.querySelector("div.drop-area svg[id='drop-area-svg']");
const map = document.querySelector(".farm-designer-map");
const page = document.querySelector(".farm-designer");
if (el && map && page) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || DRAG_ERROR);
const { pageX, pageY } = e;
const params: ScreenToGardenParams = {
quadrant: this.props.botOriginQuadrant,
pageX: pageX + page.scrollLeft - this.props.gridOffset.x * zoomLvl,
pageY: pageY + map.scrollTop * zoomLvl - this.props.gridOffset.y * zoomLvl,
zoomLvl,
gridSize: this.props.gridSize
};
return translateScreenToGarden(params);
} else {
return undefined;
}
}
startDrag = (e: React.MouseEvent<SVGElement>): void => {
if (this.isEditing) {
this.setState({ isDragging: true });
const plant = this.getPlant();
@ -67,11 +92,25 @@ export class GardenMap extends
this.setActiveSpread(plant.body.openfarm_slug);
}
}
if (location.pathname.includes("select")) {
const gardenCoords = this.getGardenCoordinates(e);
if (gardenCoords) {
this.setState({
selectionBox: {
x0: gardenCoords.x, y0: gardenCoords.y, x1: undefined, y1: undefined
}
});
}
this.props.dispatch({ type: "SELECT_PLANT", payload: undefined });
}
}
get isEditing(): boolean { return location.pathname.includes("edit"); }
getPlant = (): TaggedPlantPointer | undefined => this.props.selectedPlant;
getPlant = (): TaggedPlantPointer | undefined =>
history.getCurrentLocation().pathname.split("/")[4] != "select"
? this.props.selectedPlant
: undefined;
handleDragOver = (e: React.DragEvent<HTMLElement>) => {
if (!this.isEditing &&
@ -93,22 +132,11 @@ export class GardenMap extends
handleDrop = (e: React.DragEvent<HTMLElement> | React.MouseEvent<SVGElement>) => {
e.preventDefault();
const el = document.querySelector("div.drop-area svg[id='drop-area-svg']");
const map = document.querySelector(".farm-designer-map");
const page = document.querySelector(".farm-designer");
if (el && map && page) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || DRAG_ERROR);
const { pageX, pageY } = e;
const gardenCoords = this.getGardenCoordinates(e);
if (gardenCoords) {
const crop = history.getCurrentLocation().pathname.split("/")[5];
const OFEntry = this.findCrop(crop);
const params: ScreenToGardenParams = {
quadrant: this.props.botOriginQuadrant,
pageX: pageX + page.scrollLeft - this.props.gridOffset.x * zoomLvl,
pageY: pageY + map.scrollTop * zoomLvl - this.props.gridOffset.y * zoomLvl,
zoomLvl,
gridSize: this.props.gridSize
};
const { x, y } = translateScreenToGarden(params);
const { x, y } = gardenCoords;
if (x < 0 || y < 0 || x > this.props.gridSize.x || y > this.props.gridSize.y) {
error(t("Outside of planting area. Plants must be placed within the grid."));
} else {
@ -142,6 +170,20 @@ export class GardenMap extends
}
}
getSelected(box: SelectionBoxData) {
const selected = this.props.plants.filter((p) => {
if (box && box.x0 && box.y0 && box.x1 && box.y1) {
return (
p.body.x >= Math.min(box.x0, box.x1) &&
p.body.x <= Math.max(box.x0, box.x1) &&
p.body.y >= Math.min(box.y0, box.y1) &&
p.body.y <= Math.max(box.y0, box.y1)
);
}
}).map(p => { return p.uuid; });
return selected.length > 0 ? selected : undefined;
}
drag = (e: React.MouseEvent<SVGElement>) => {
const plant = this.getPlant();
const map = document.querySelector(".farm-designer-map");
@ -158,6 +200,19 @@ export class GardenMap extends
});
this.props.dispatch(movePlant({ deltaX, deltaY, plant, gridSize }));
}
if (this.state.selectionBox && location.pathname.includes("select")) {
const current = this.getGardenCoordinates(e);
if (current) {
const box = this.state.selectionBox;
this.props.dispatch({
type: "SELECT_PLANT",
payload: this.getSelected(this.state.selectionBox)
});
this.setState({
selectionBox: { x0: box.x0, y0: box.y0, x1: current.x, y1: current.y }
});
}
}
}
render() {
@ -245,6 +300,8 @@ export class GardenMap extends
zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY}
plantAreaOffset={this.props.gridOffset} />
{this.state.selectionBox &&
<SelectionBox selectionBox={this.state.selectionBox} />}
</svg>
</svg>
</div>;

View File

@ -18,7 +18,7 @@ export class GardenPlant extends
}
click = () => {
this.props.dispatch({ type: "SELECT_PLANT", payload: this.props.uuid });
this.props.dispatch({ type: "SELECT_PLANT", payload: [this.props.uuid] });
this.props.dispatch({
type: "TOGGLE_HOVERED_PLANT", payload: {
plantUUID: this.props.uuid, icon: this.state.icon

View File

@ -10,7 +10,7 @@ describe("<HoveredPlantLayer/>", () => {
dragging: false,
currentPlant: undefined,
designer: {
selectedPlant: undefined,
selectedPlants: undefined,
hoveredPlant: {
plantUUID: undefined,
icon: ""
@ -33,8 +33,8 @@ describe("<HoveredPlantLayer/>", () => {
const icon = wrapper.find("image").props();
expect(icon.visibility).toBeTruthy();
expect(icon.opacity).toEqual(1);
expect(icon.x).toEqual(67.5);
expect(icon.width).toEqual(65);
expect(icon.x).toEqual(70);
expect(icon.width).toEqual(60);
});
it("shows selected plant indicators", () => {

View File

@ -24,7 +24,10 @@ export function PlantLayer(props: PlantLayerProps) {
.filter(c => !!c.body.spread)
.map(c => cropSpreadDict[c.body.slug] = c.body.spread);
const maybeNoPointer = history.getCurrentLocation().pathname.split("/")[6] == "add"
const pathName = history.getCurrentLocation().pathname;
const clickToAddMode = pathName.split("/")[6] == "add";
const selectMode = pathName.split("/")[4] == "select";
const maybeNoPointer = (clickToAddMode || selectMode)
? { "pointerEvents": "none" } : {};
return <g id="plant-layer">

View File

@ -0,0 +1,30 @@
import * as React from "react";
export type SelectionBoxData =
Record<"x0" | "y0" | "x1" | "y1", number | undefined>;
export interface SelectionBoxProps {
selectionBox: SelectionBoxData
}
export function SelectionBox(props: SelectionBoxProps) {
const { x0, y0, x1, y1 } = props.selectionBox;
if (x0 && y0 && x1 && y1) {
const x = Math.min(x0, x1);
const y = Math.min(y0, y1);
const width = Math.max(x0, x1) - x;
const height = Math.max(y0, y1) - y;
return <g id="selection-box">
<rect
x={x}
y={y}
width={width}
height={height}
fill="rgba(256,256,256,0.1)"
stroke="rgba(256,256,256,0.6)"
strokeWidth={2} />
</g>;
} else {
return <g id="selection-box" />;
}
}

View File

@ -30,8 +30,10 @@ describe("<EditPlantInfo />", () => {
push={jest.fn()}
dispatch={dispatch}
findPlant={fakePlant} />);
expect(wrapper.find("button").props().hidden).toBeFalsy();
wrapper.find("button").simulate("click");
const deleteButton = wrapper.find("button").first();
expect(deleteButton.props().hidden).toBeFalsy();
expect(deleteButton.text()).toEqual("Delete");
deleteButton.simulate("click");
expect(dispatch).toHaveBeenCalled();
});

View File

@ -18,6 +18,8 @@ describe("<PlantInfo />", () => {
expect(wrapper.text()).toContain("Strawberry Plant 1");
expect(wrapper.text().replace(/\s+/g, " "))
.toContain("Plant Type: Strawberry");
expect(wrapper.find("button").props().hidden).toBeTruthy();
const buttons = wrapper.find("button");
expect(buttons.first().props().hidden).toBeTruthy();
expect(buttons.last().props().hidden).toBeTruthy();
});
});

View File

@ -20,7 +20,7 @@ describe("<PlantPanel/>", () => {
const txt = el.text().toLowerCase();
expect(txt).toContain("1 days old");
expect(txt).toContain("(10, 30)");
el.find("button").simulate("click");
el.find("button").first().simulate("click");
expect(onDestroy.mock.calls.length).toEqual(1);
});

View File

@ -0,0 +1,80 @@
jest.mock("react-redux", () => ({
connect: jest.fn()
}));
import * as React from "react";
import { mount } from "enzyme";
import { SelectPlants, SelectPlantsProps } from "../select_plants";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
describe("<SelectPlants />", () => {
beforeEach(function () {
jest.clearAllMocks();
Object.defineProperty(location, "pathname", {
value: "//app/plants/select"
});
});
function fakeProps(): SelectPlantsProps {
const plant1 = fakePlant();
plant1.uuid = "plant.1";
plant1.body.name = "Strawberry";
const plant2 = fakePlant();
plant2.uuid = "plant.2";
plant2.body.name = "Blueberry";
return {
selected: ["plant.1"],
plants: [plant1, plant2],
dispatch: jest.fn(),
};
}
it("displays selected plant", () => {
const wrapper = mount(<SelectPlants {...fakeProps() } />);
expect(wrapper.text()).toContain("Strawberry");
expect(wrapper.text()).toContain("Delete");
});
it("displays multiple selected plants", () => {
const p = fakeProps();
p.selected = ["plant.1", "plant.2"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).toContain("Strawberry");
expect(wrapper.text()).toContain("Blueberry");
expect(wrapper.text()).toContain("Delete");
});
it("displays no selected plants: selection empty", () => {
const p = fakeProps();
p.selected = [];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).not.toContain("Strawberry Plant");
});
it("displays no selected plants: selection invalid", () => {
const p = fakeProps();
p.selected = ["not a uuid"];
const wrapper = mount(<SelectPlants {...p} />);
expect(wrapper.text()).not.toContain("Strawberry Plant");
});
it("selects all", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = mount(<SelectPlants {...p} />);
const selectAllButton = wrapper.find("button").at(1);
expect(selectAllButton.text()).toEqual("Select all");
selectAllButton.simulate("click");
expect(p.dispatch).toHaveBeenCalled();
});
it("selects none", () => {
const p = fakeProps();
p.dispatch = jest.fn();
const wrapper = mount(<SelectPlants {...p} />);
const selectNoneButton = wrapper.find("button").at(2);
expect(selectNoneButton.text()).toEqual("Select none");
selectNoneButton.simulate("click");
expect(p.dispatch).toHaveBeenCalled();
});
});

View File

@ -49,7 +49,7 @@ export class PlantInventoryItem extends
const click = () => {
push("/app/designer/plants/" + plantId);
dispatch({ type: "SELECT_PLANT", payload: tpp.uuid });
dispatch({ type: "SELECT_PLANT", payload: [tpp.uuid] });
};
// See `cachedIcon` for more details on this.

View File

@ -4,6 +4,7 @@ import { t } from "i18next";
import { Link } from "react-router";
import { FormattedPlantInfo } from "./map_state_to_props";
import { round } from "../map/util";
import { history } from "../../history";
interface PlantPanelProps {
info: FormattedPlantInfo;
@ -75,5 +76,12 @@ export function PlantPanel({ info, onDestroy }: PlantPanelProps) {
onClick={destroy} >
{t("Delete")}
</button>
<button
className="fb-button gray"
style={{ marginRight: "10px" }}
hidden={!onDestroy}
onClick={() => history.push("/app/designer/plants/select")} >
{t("Delete multiple")}
</button>
</div>;
}

View File

@ -0,0 +1,96 @@
import * as React from "react";
import { history } from "../../history";
import { t } from "i18next";
import { connect } from "react-redux";
import { Everything } from "../../interfaces";
import { TaggedPlantPointer } from "../../resources/tagged_resources";
import { selectAllPlantPointers } from "../../resources/selectors";
import { PlantInventoryItem } from "./plant_inventory_item";
import { destroy } from "../../api/crud";
import { error } from "farmbot-toastr";
export function mapStateToProps(props: Everything) {
const plants = selectAllPlantPointers(props.resources.index);
return {
selected: props
.resources
.consumers
.farm_designer
.selectedPlants,
plants,
dispatch: props.dispatch
};
}
export interface SelectPlantsProps {
plants: TaggedPlantPointer[];
dispatch: Function;
selected: string[];
}
@connect(mapStateToProps)
export class SelectPlants
extends React.Component<SelectPlantsProps, {}> {
destroySelected = (plantUUIDs: string[]) => {
if (plantUUIDs) {
plantUUIDs.map(uuid => {
this.props.dispatch(destroy(uuid))
.catch(() => error(t("Could not delete plant."), t("Error")));
});
history.push("/app/designer/plants");
}
}
render() {
const { selected, plants, dispatch } = this.props;
const selectedPlantData = selected ? selected.map(uuid => {
return plants.filter(p => { return p.uuid == uuid; })[0];
}) : undefined;
return <div
className="panel-container green-panel plant-selection-panel">
<div className="panel-header green-panel">
<p className="panel-title">
<a className="back-arrow"
onClick={() => history.push("/app/designer/plants")}>
<i className="fa fa-arrow-left"></i>
</a>
Select plants
</p>
<div className="panel-title">
<button className="fb-button red"
onClick={() => this.destroySelected(selected)}>
{t("Delete selected")}
</button>
<button className="fb-button gray"
onClick={() => this.props.dispatch({
type: "SELECT_PLANT",
payload: plants.map(p => p.uuid)
})}>
{t("Select all")}
</button>
<button className="fb-button gray"
onClick={() => this.props.dispatch({
type: "SELECT_PLANT",
payload: undefined
})}>
{t("Select none")}
</button>
</div>
</div>
<div className="panel-content">
<div className="thin-search-wrapper">
{selectedPlantData && selectedPlantData[0] &&
selectedPlantData.map(p =>
<PlantInventoryItem
key={p.uuid}
tpp={p}
dispatch={dispatch} />)}
</div>
</div>
</div>;
}
}

View File

@ -9,7 +9,7 @@ import { TaggedResource } from "../resources/tagged_resources";
import { Actions } from "../constants";
export let initialState: DesignerState = {
selectedPlant: undefined,
selectedPlants: undefined,
hoveredPlant: {
plantUUID: undefined,
icon: ""
@ -24,8 +24,8 @@ export let designer = generateReducer<DesignerState>(initialState)
state.cropSearchQuery = payload;
return state;
})
.add<string | undefined>(Actions.SELECT_PLANT, (s, { payload }) => {
s.selectedPlant = payload;
.add<string[] | undefined>(Actions.SELECT_PLANT, (s, { payload }) => {
s.selectedPlants = payload;
return s;
})
.add<HoveredPlantPayl>(Actions.TOGGLE_HOVERED_PLANT, (s, { payload }) => {
@ -38,6 +38,6 @@ export let designer = generateReducer<DesignerState>(initialState)
return state;
})
.add<TaggedResource>(Actions.DESTROY_RESOURCE_OK, (s, { payload }) => {
if (payload.uuid === s.selectedPlant) { s.selectedPlant = undefined; }
s.selectedPlants = undefined;
return s;
});

View File

@ -11,12 +11,10 @@ import { isNumber } from "lodash";
export function mapStateToProps(props: Everything) {
const plants = selectAllPlantPointers(props.resources.index);
const selectedPlant = plants
.filter(x => x.uuid === props
.resources
.consumers
.farm_designer
.selectedPlant)[0];
const maybeSelectedPlants = props.resources.consumers.farm_designer.selectedPlants;
const selectedPlant = maybeSelectedPlants
? plants.filter(x => x.uuid === maybeSelectedPlants[0])[0]
: undefined;
const { plantUUID } = props.resources.consumers.farm_designer.hoveredPlant;
const hoveredPlant = plants.filter(x => x.uuid === plantUUID)[0];

View File

@ -172,6 +172,14 @@ export class RootComponent extends React.Component<RootComponentProps, {}> {
.catch(errorLoading(cb));
},
},
{
path: "plants/select",
getComponent(_discard: void, cb: Function) {
import("./farm_designer/plants/select_plants")
.then(module => cb(undefined, module.SelectPlants))
.catch(errorLoading(cb));
},
},
{
path: "plants/:plant_id",
getComponent(_discard: void, cb: Function) {