group panel updates

pull/1730/head
gabrielburnworth 2020-03-13 14:21:44 -07:00
parent ec878e0dae
commit fe9ff346a8
54 changed files with 1609 additions and 831 deletions

View File

@ -16,4 +16,5 @@ export const fakeDesignerState = (): DesignerState => ({
currentPoint: undefined,
openedSavedGarden: undefined,
tryGroupSortType: undefined,
editGroupAreaInMap: false,
});

View File

@ -858,7 +858,7 @@ export namespace Content {
export const CRITERIA_SELECTION_COUNT =
trim(`Criteria additions can only be removed by changing criteria.
Click and drag in the map to modify zone selection criteria.
Click and drag in the map to modify selection criteria.
Criteria will be applied at the time of sequence execution. The final
selection at that time may differ from the selection currently
displayed.`);
@ -1151,6 +1151,7 @@ export enum Actions {
SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA",
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
TRY_SORT_TYPE = "TRY_SORT_TYPE",
EDIT_GROUP_AREA_IN_MAP = "EDIT_GROUP_AREA_IN_MAP",
// Regimens
PUSH_WEEK = "PUSH_WEEK",

View File

@ -210,16 +210,6 @@
@extend %panel-item-base;
padding-top: 0.6rem;
}
.groups-panel-content {
padding: 0px;
}
.groups-list-wrapper {
padding: 0.5em 0em;
}
.group-delete-btn {
float: left;
margin-top: 1em;
}
.plant-search-item-name {
display: inline-block;
vertical-align: middle;

View File

@ -809,7 +809,6 @@
.weeds-inventory-panel,
.zones-inventory-panel,
.group-detail-panel,
.groups-panel {
.panel-content {
max-height: calc(100vh - 19rem);
@ -821,6 +820,31 @@
.group-detail-panel {
.panel-content {
max-height: calc(100vh - 14rem);
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 5rem;
.group-member-display {
i[class*=fa-caret-] {
float: right;
font-size: 2rem;
margin-top: 1.5rem;
}
.groups-list-wrapper {
padding: 0.5em 0em;
}
}
.group-member-display,
.group-sort-section {
.bp3-popover-wrapper {
display: inline;
margin-left: 1rem;
}
}
.group-delete-btn {
float: left;
margin-top: 1em;
}
.group-criteria {
margin-top: 1rem;
.criteria-heading {
@ -829,7 +853,61 @@
.fb-button {
margin-top: 0.5rem;
}
.group-criteria-presets {
.point-type-checkboxes {
.point-type-section {
.fb-checkbox {
display: inline;
margin-right: 1rem;
vertical-align: top;
}
p {
display: inline;
text-transform: uppercase;
}
.point-type-checkbox {
position: relative;
height: 2rem;
margin-top: 0.75rem;
cursor: pointer;
.fb-checkbox {
display: inline-block;
height: 2rem;
}
i[class*=fa-caret-] {
position: absolute;
right: -0.5rem;
width: 3rem;
font-size: 2rem;
padding-left: 1rem;
}
}
.plant-criteria-options,
.point-criteria-options,
.tool-criteria-options {
margin-left: 3rem;
p {
&.category {
display: block;
padding-top: 1rem;
padding-bottom: 1rem;
text-transform: none;
font-size: 1.2rem;
font-weight: bold;
}
}
hr {
margin: 0.5rem;
}
.lt-gt-criteria {
margin-bottom: 1rem;
.row {
margin-left: 0 !important;
}
}
}
}
}
.criteria-radio-presets {
input[type="radio"] {
width: auto;
margin-right: 1rem;
@ -845,29 +923,28 @@
.criteria-slug {
margin-top: 1rem;
}
.location-criteria {
.row {
margin-top: 1rem;
p {
font-size: 1.4rem;
font-weight: bold;
}
label {
margin-top: 0;
}
}
}
.day-criteria {
p {
display: inline;
vertical-align: bottom;
}
input {
line-height: 1.75rem;
}
}
.string-eq-criteria {
margin-top: 1rem;
.row {
margin-top: 1rem;
}
code {
display: inline-block;
margin-top: 2rem;
font-size: 1.2rem;
font-weight: bold;
color: $black;
background: none;
}
}
.number-eq-criteria,
.number-gt-lt-criteria {
@ -877,11 +954,49 @@
}
p {
text-align: center;
margin-top: 0.5rem;
line-height: 2.75rem;
font-size: 1.2rem;
}
}
.expandable-header {
margin-top: 3rem;
.fb-toggle-button {
width: 85px;
margin-top: 0;
&.red {
background: $dark_gray !important;
}
}
.clear-criteria {
margin-top: 2rem;
}
.basic,
.advanced {
margin-left: 1rem;
.day-criteria {
.row {
margin-left: 0;
}
div[class*=col-] {
padding: 0;
padding-right: 0.75rem;
}
}
}
.advanced {
.row {
margin-left: 0;
}
div[class*=col-] {
padding: 0;
}
.col-xs-9 {
margin-right: 0.5rem;
}
.col-xs-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
margin-top: 0.4rem;
text-align: center;
}
}
}
.criteria-point-count-breakdown {
@ -910,19 +1025,34 @@
}
}
.zone-info-panel {
.panel-content {
.location-criteria {
.row {
margin-top: 1rem;
p {
font-size: 1.4rem;
font-weight: bold;
}
label {
margin-top: 0;
}
}
.lt-gt-criteria,
.location-criteria {
display: inline-block;
.row {
margin-left: 0;
div[class*=col-] {
padding: 0;
text-align: center;
}
margin-top: 1rem;
p {
display: block !important;
text-transform: uppercase;
font-size: 1.1rem;
margin-top: 0.75rem;
}
label {
margin-top: 0.5rem;
}
}
.edit-in-map {
float: right;
button {
margin: 1rem !important;
width: 5rem !important;
}
label {
margin-top: 1.1rem !important;
}
}
}

View File

@ -1309,15 +1309,6 @@ ul {
display: inline;
position: relative;
margin-right: 1rem;
&.partial:after {
content: "";
position: absolute;
left: 0.75rem;
bottom: 1.2rem;
border: solid $dark_gray;
border-width: 0 0 3px 0;
padding: 0.6rem 0.3rem;
}
}
.bp3-popover-wrapper,
.bp3-popover-target {

View File

@ -138,6 +138,15 @@ select {
padding: 0.6rem 0.3rem;
}
}
&.partial:after {
content: "";
position: absolute;
left: 0.75rem;
bottom: 1.2rem;
border: solid $dark_gray;
border-width: 0 0 3px 0;
padding: 0.6rem 0.3rem;
}
&.large {
input[type="checkbox"] {
width: 3rem;
@ -155,8 +164,10 @@ select {
}
}
&.disabled {
cursor: not-allowed;
input[type="checkbox"] {
cursor: not-allowed;
background: $light_gray;
pointer-events: none;
&:checked:after {
border-color: $gray;
}

View File

@ -63,7 +63,7 @@ export class BooleanMCUInputGroup
{caution &&
<i className="fa fa-exclamation-triangle caution-icon" />}
</label>
<Help text={tooltip} requireClick={true} position={Position.TOP_RIGHT} />
<Help text={tooltip} position={Position.TOP_RIGHT} />
</Col>
{!this.newFormat && <this.Toggles />}
</Row>

View File

@ -39,8 +39,7 @@ export class CalibrationRow extends React.Component<CalibrationRowProps> {
<label>
{t(this.props.title)}
</label>
<Help text={t(this.props.toolTip)}
requireClick={true} position={Position.TOP_RIGHT} />
<Help text={t(this.props.toolTip)} position={Position.TOP_RIGHT} />
</Col>
{!this.newFormat && <this.Axes />}
</Row>

View File

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

View File

@ -20,7 +20,7 @@ export const SingleSettingRow =
<Row>
<Col xs={newFormat ? 12 : 6} className={"widget-body-tooltips"}>
<label>{t(label)}</label>
<Help text={tooltip} requireClick={true} position={Position.TOP_RIGHT} />
<Help text={tooltip} position={Position.RIGHT} />
</Col>
{settingType === "button"
? <Col xs={newFormat ? 5 : 2} className={"centered-button-div"}>

View File

@ -59,7 +59,7 @@ export class NumericMCUInputGroup
<label>
{t(label)}
</label>
<Help text={tooltip} requireClick={true} position={Position.TOP_RIGHT} />
<Help text={tooltip} position={Position.TOP_RIGHT} />
</Col>
{!this.newFormat && <this.Inputs />}
</Row>

View File

@ -76,7 +76,7 @@ export class PinGuardMCUInputGroup
<label>
{t("Pin Number")}
</label>
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER} requireClick={true}
<Help text={ToolTips.PIN_GUARD_PIN_NUMBER}
position={Position.TOP_RIGHT} />
</Col>
<Col xs={5} className="no-pad">

View File

@ -75,7 +75,7 @@ export const PinBindingsContent = (props: PinBindingsContentProps) => {
return <div className="pin-bindings">
<Row>
{newFormat && <Help text={ToolTips.PIN_BINDINGS}
position={Position.TOP_RIGHT} requireClick={true} />}
position={Position.TOP_RIGHT} />}
<StockPinBindingsButton
dispatch={dispatch} firmwareHardware={firmwareHardware} />
<Popover

View File

@ -118,6 +118,7 @@ export interface DesignerState {
currentPoint: CurrentPointPayl | undefined;
openedSavedGarden: string | undefined;
tryGroupSortType: PointGroupSortType | "nn" | undefined;
editGroupAreaInMap: boolean;
}
export type TaggedExecutable = TaggedSequence | TaggedRegimen;

View File

@ -32,7 +32,7 @@ jest.mock("../drawn_point/drawn_point_actions", () => ({
jest.mock("../background/selection_box_actions", () => ({
startNewSelectionBox: jest.fn(),
resizeBox: jest.fn(),
maybeUpdateGroupCriteria: jest.fn(),
maybeUpdateGroup: jest.fn(),
}));
jest.mock("../../move_to", () => ({ chooseLocation: jest.fn() }));
@ -61,7 +61,7 @@ import {
dropPlant, beginPlantDrag, maybeSavePlantLocation, dragPlant,
} from "../layers/plants/plant_actions";
import {
startNewSelectionBox, resizeBox, maybeUpdateGroupCriteria,
startNewSelectionBox, resizeBox, maybeUpdateGroup,
} from "../background/selection_box_actions";
import { getGardenCoordinates } from "../util";
import { chooseLocation } from "../../move_to";
@ -158,7 +158,7 @@ describe("<GardenMap/>", () => {
wrapper.setState({ isDragging: true });
wrapper.find(".drop-area-svg").simulate("mouseUp", DEFAULT_EVENT);
expect(maybeSavePlantLocation).toHaveBeenCalled();
expect(maybeUpdateGroupCriteria).toHaveBeenCalled();
expect(maybeUpdateGroup).toHaveBeenCalled();
expect(wrapper.instance().state.isDragging).toBeFalsy();
});
@ -224,7 +224,9 @@ describe("<GardenMap/>", () => {
});
it("starts drag on background: selecting zone", () => {
const wrapper = mount(<GardenMap {...fakeProps()} />);
const p = fakeProps();
p.designer.editGroupAreaInMap = true;
const wrapper = mount(<GardenMap {...p} />);
mockMode = Mode.editGroup;
const e = { pageX: 1000, pageY: 2000 };
wrapper.find(".drop-area-background").simulate("mouseDown", e);
@ -255,7 +257,9 @@ describe("<GardenMap/>", () => {
});
it("drags: selecting zone", () => {
const wrapper = shallow(<GardenMap {...fakeProps()} />);
const p = fakeProps();
p.designer.editGroupAreaInMap = true;
const wrapper = shallow(<GardenMap {...p} />);
mockMode = Mode.editGroup;
const e = { pageX: 2000, pageY: 2000 };
wrapper.find(".drop-area-svg").simulate("mouseMove", e);

View File

@ -8,18 +8,25 @@ jest.mock("../../../point_groups/criteria", () => ({
editGtLtCriteria: jest.fn(),
}));
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
}));
import {
fakePlant, fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import {
getSelected, resizeBox, startNewSelectionBox, ResizeSelectionBoxProps,
StartNewSelectionBoxProps,
maybeUpdateGroupCriteria,
MaybeUpdateGroupCriteriaProps,
maybeUpdateGroup,
MaybeUpdateGroupProps,
} from "../selection_box_actions";
import { Actions } from "../../../../constants";
import { history } from "../../../../history";
import { editGtLtCriteria } from "../../../point_groups/criteria";
import { overwrite, save } from "../../../../api/crud";
import { cloneDeep } from "lodash";
describe("getSelected", () => {
it("returns some", () => {
@ -156,24 +163,55 @@ describe("startNewSelectionBox", () => {
});
});
describe("maybeUpdateGroupCriteria()", () => {
const fakeProps = (): MaybeUpdateGroupCriteriaProps => ({
describe("maybeUpdateGroup()", () => {
const fakeProps = (): MaybeUpdateGroupProps => ({
selectionBox: { x0: 0, y0: 0, x1: undefined, y1: undefined },
dispatch: jest.fn(),
group: fakePointGroup(),
shouldDisplay: () => true,
editGroupAreaInMap: false,
boxSelected: undefined,
});
it("updates group", () => {
const p = fakeProps();
p.editGroupAreaInMap = false;
const plant1 = fakePlant();
const plant2 = fakePlant();
p.boxSelected = [plant1.uuid, plant2.uuid];
p.group && (p.group.body.point_ids = [plant1.body.id || 0]);
maybeUpdateGroup(p);
expect(editGtLtCriteria).not.toHaveBeenCalled();
const expectedBody = cloneDeep(p.group?.body);
expectedBody && (expectedBody.point_ids = [
plant1.body.id || 0, plant2.body.id || 0,
]);
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(save).not.toHaveBeenCalled();
});
it("updates criteria", () => {
const p = fakeProps();
maybeUpdateGroupCriteria(p);
p.editGroupAreaInMap = true;
maybeUpdateGroup(p);
expect(editGtLtCriteria).toHaveBeenCalledWith(p.group, p.selectionBox);
});
it("doesn't update criteria", () => {
const p = fakeProps();
p.shouldDisplay = () => false;
maybeUpdateGroupCriteria(p);
maybeUpdateGroup(p);
expect(editGtLtCriteria).not.toHaveBeenCalled();
});
it("handles missing group or box", () => {
const p = fakeProps();
p.group = undefined;
p.selectionBox = undefined;
maybeUpdateGroup(p);
expect(p.dispatch).not.toHaveBeenCalled();
expect(editGtLtCriteria).not.toHaveBeenCalled();
expect(overwrite).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
});

View File

@ -1,4 +1,4 @@
import { isNumber } from "lodash";
import { isNumber, uniq, cloneDeep, isEqual } from "lodash";
import { TaggedPlant, AxisNumberProperty, Mode } from "../interfaces";
import { SelectionBoxData } from "./selection_box";
import { GardenMapState } from "../../interfaces";
@ -8,6 +8,9 @@ import { getMode } from "../util";
import { editGtLtCriteria } from "../../point_groups/criteria";
import { TaggedPointGroup } from "farmbot";
import { ShouldDisplay, Feature } from "../../../devices/interfaces";
import { overwrite } from "../../../api/crud";
import { unpackUUID } from "../../../util";
import { UUID } from "../../../resources/interfaces";
/** Return all plants within the selection box. */
export const getSelected = (
@ -85,17 +88,32 @@ export const startNewSelectionBox = (props: StartNewSelectionBoxProps) => {
}
};
export interface MaybeUpdateGroupCriteriaProps {
export interface MaybeUpdateGroupProps {
selectionBox: SelectionBoxData | undefined;
dispatch: Function;
group: TaggedPointGroup | undefined;
shouldDisplay: ShouldDisplay;
editGroupAreaInMap: boolean;
boxSelected: UUID[] | undefined;
}
export const maybeUpdateGroupCriteria =
(props: MaybeUpdateGroupCriteriaProps) => {
if (props.selectionBox && props.group &&
props.shouldDisplay(Feature.criteria_groups)) {
props.dispatch(editGtLtCriteria(props.group, props.selectionBox));
export const maybeUpdateGroup =
(props: MaybeUpdateGroupProps) => {
if (props.selectionBox && props.group) {
if (props.editGroupAreaInMap
&& props.shouldDisplay(Feature.criteria_groups)) {
props.dispatch(editGtLtCriteria(props.group, props.selectionBox));
} else {
const nextGroupBody = cloneDeep(props.group.body);
props.boxSelected?.map(uuid => {
const { kind, remoteId } = unpackUUID(uuid);
remoteId && kind == "Point" && nextGroupBody.point_ids.push(remoteId);
});
nextGroupBody.point_ids = uniq(nextGroupBody.point_ids);
if (!isEqual(props.group.body.point_ids, nextGroupBody.point_ids)) {
props.dispatch(overwrite(props.group, nextGroupBody));
props.dispatch(selectPlant(undefined));
}
}
}
};

View File

@ -11,7 +11,7 @@ import {
import {
Grid, MapBackground,
TargetCoordinate,
SelectionBox, resizeBox, startNewSelectionBox, maybeUpdateGroupCriteria,
SelectionBox, resizeBox, startNewSelectionBox, maybeUpdateGroup,
} from "./background";
import {
PlantLayer,
@ -88,11 +88,13 @@ export class GardenMap extends
isDragging: this.state.isDragging,
dispatch: this.props.dispatch,
});
maybeUpdateGroupCriteria({
maybeUpdateGroup({
selectionBox: this.state.selectionBox,
group: this.group,
dispatch: this.props.dispatch,
shouldDisplay: this.props.shouldDisplay,
editGroupAreaInMap: this.props.designer.editGroupAreaInMap,
boxSelected: this.props.designer.selectedPlants,
});
this.setState({
isDragging: false, qPageX: 0, qPageY: 0,
@ -142,7 +144,7 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
plantActions: !this.props.designer.editGroupAreaInMap,
});
break;
case Mode.createPoint:
@ -179,7 +181,7 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
plantActions: !this.props.designer.editGroupAreaInMap,
});
break;
default:
@ -283,7 +285,7 @@ export class GardenMap extends
gardenCoords: this.getGardenCoordinates(e),
setMapState: this.setMapState,
dispatch: this.props.dispatch,
plantActions: false,
plantActions: !this.props.designer.editGroupAreaInMap,
});
break;
case Mode.boxSelect:

View File

@ -7,7 +7,6 @@ import {
import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<ZonesLayer />", () => {
const fakeProps = (): ZonesLayerProps => ({
@ -69,7 +68,6 @@ describe("<ZonesLayer />", () => {
const p = fakeProps();
p.visible = false;
p.groups[0].body.id = 1;
p.groups[0].body.criteria = undefined as unknown as PointGroup["criteria"];
p.currentGroup = p.groups[0].uuid;
const wrapper = svgMount(<ZonesLayer {...p} />);
expect(wrapper.html())

View File

@ -9,7 +9,7 @@ import {
import {
fakeMapTransformProps,
} from "../../../../../__test_support__/map_transform_props";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { DEFAULT_CRITERIA } from "../../../../point_groups/criteria/interfaces";
const fakeProps = (): ZonesProps => ({
group: fakePointGroup(),
@ -25,7 +25,7 @@ describe("<Zones0D />", () => {
it("renders none: no data", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
p.group.body.criteria = DEFAULT_CRITERIA;
const wrapper = svgMount(<Zones0D {...p} />);
expect(wrapper.find("#zones-0D-1").length).toEqual(1);
expect(wrapper.find("circle").length).toEqual(0);
@ -63,7 +63,7 @@ describe("<Zones1D />", () => {
it("renders none: no data", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
p.group.body.criteria = DEFAULT_CRITERIA;
const wrapper = svgMount(<Zones1D {...p} />);
expect(wrapper.find("#zones-1D-1").length).toEqual(1);
expect(wrapper.find("line").length).toEqual(0);
@ -110,7 +110,7 @@ describe("<Zones2D />", () => {
it("renders none", () => {
const p = fakeProps();
p.group.body.id = 1;
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
p.group.body.criteria = DEFAULT_CRITERIA;
const wrapper = svgMount(<Zones2D {...p} />);
expect(wrapper.find("#zones-2D-1").length).toEqual(1);
expect(wrapper.find("rect").length).toEqual(0);

View File

@ -26,9 +26,9 @@ type Point = { x: number, y: number };
export enum ZoneType { points, lines, area, none }
export const getZoneType = (group: TaggedPointGroup): ZoneType => {
const numEq = group.body.criteria?.number_eq || {};
const numGt = group.body.criteria?.number_gt || {};
const numLt = group.body.criteria?.number_lt || {};
const numEq = group.body.criteria.number_eq;
const numGt = group.body.criteria.number_gt;
const numLt = group.body.criteria.number_lt;
const hasXEq = !!numEq.x?.length;
const hasYEq = !!numEq.y?.length;
if (hasXEq && hasYEq) {
@ -46,8 +46,8 @@ export const getZoneType = (group: TaggedPointGroup): ZoneType => {
/** Bounds for area selected by criteria or bot extents. */
const getBoundary = (props: GetBoundaryProps): Boundary => {
const { criteria } = props.group.body;
const gt = criteria?.number_gt || {};
const lt = criteria?.number_lt || {};
const gt = criteria.number_gt;
const lt = criteria.number_lt;
const x1 = gt.x || 0;
const x2 = lt.x || props.botSize.x.value;
const y1 = gt.y || 0;
@ -67,8 +67,8 @@ const filter: <T extends Point | Line>(
/** Coordinates selected by both x and y number equal values. */
const getPoints =
(boundary: Boundary, group: TaggedPointGroup): Point[] => {
const xs = group.body.criteria?.number_eq.x;
const ys = group.body.criteria?.number_eq.y;
const xs = group.body.criteria.number_eq.x;
const ys = group.body.criteria.number_eq.y;
const points: Point[] = [];
xs?.map(x => ys?.map(y => points.push({ x, y })));
return filter<Point>(boundary, points);
@ -95,12 +95,12 @@ export const Zones0D = (props: ZonesProps) => {
/** Lines selected by an x or y number equal value. */
const getLines =
(boundary: Boundary, group: TaggedPointGroup): Line[] => {
const xs = group.body.criteria?.number_eq.x;
const ys = group.body.criteria?.number_eq.y;
const xs = group.body.criteria.number_eq.x;
const ys = group.body.criteria.number_eq.y;
const onlyXs = !!xs?.length && !ys?.length;
const onlyYs = !!ys?.length && !xs?.length;
const xLineData = onlyXs ? xs?.map(x => ({ x })) : undefined;
const yLineData = onlyYs ? ys?.map(y => ({ y })) : undefined;
const xLineData = (onlyXs && xs) ? xs.map(x => ({ x })) : undefined;
const yLineData = (onlyYs && ys) ? ys.map(y => ({ y })) : undefined;
return filter<Line>(boundary, xLineData || yLineData);
};

View File

@ -80,7 +80,7 @@ const LayerToggles = (props: GardenMapLegendProps) => {
{DevSettings.futureFeaturesEnabled() &&
<LayerToggle
value={props.showZones}
label={t("Zones?")}
label={t("areas?")}
onClick={toggle(BooleanSetting.show_zones)} />}
{DevSettings.futureFeaturesEnabled() && props.hasSensorReadings &&
<LayerToggle

View File

@ -17,13 +17,13 @@ import {
import { save, edit } from "../../../api/crud";
import { SpecialStatus } from "farmbot";
import { DEFAULT_CRITERIA } from "../criteria/interfaces";
import { Content } from "../../../constants";
describe("<GroupDetailActive/>", () => {
const fakeProps = (): GroupDetailActiveProps => {
const plant = fakePlant();
plant.body.id = 1;
const group = fakePointGroup();
group.body.criteria = DEFAULT_CRITERIA;
group.specialStatus = SpecialStatus.DIRTY;
group.body.name = "XYZ";
group.body.point_ids = [plant.body.id];
@ -34,6 +34,7 @@ describe("<GroupDetailActive/>", () => {
shouldDisplay: () => true,
slugs: [],
hovered: undefined,
editGroupAreaInMap: false,
};
};
@ -45,11 +46,29 @@ describe("<GroupDetailActive/>", () => {
expect(save).toHaveBeenCalledWith(p.group.uuid);
});
it("is already saved", () => {
const p = fakeProps();
p.group.specialStatus = SpecialStatus.SAVED;
const el = new GroupDetailActive(p);
el.saveGroup();
expect(p.dispatch).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
it("toggles icon view", () => {
const p = fakeProps();
const wrapper = mount<GroupDetailActive>(<GroupDetailActive {...p} />);
expect(wrapper.state().iconDisplay).toBeTruthy();
wrapper.instance().toggleIconShow();
expect(wrapper.state().iconDisplay).toBeFalsy();
});
it("renders", () => {
const p = fakeProps();
p.group.specialStatus = SpecialStatus.SAVED;
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.find("input").first().prop("defaultValue")).toContain("XYZ");
expect(wrapper.find(".groups-list-wrapper").length).toEqual(1);
expect(wrapper.text()).not.toContain("saving");
});
@ -109,6 +128,12 @@ describe("<GroupDetailActive/>", () => {
const p = fakeProps();
p.group.body.sort_type = "random";
const wrapper = mount(<GroupDetailActive {...p} />);
expect(wrapper.text()).toContain(Content.SORT_DESCRIPTION);
expect(wrapper.html()).toContain("exclamation-triangle");
});
it("doesn't show icons", () => {
const wrapper = mount(<GroupDetailActive {...fakeProps()} />);
wrapper.setState({ iconDisplay: false });
expect(wrapper.find(".groups-list-wrapper").length).toEqual(0);
});
});

View File

@ -25,6 +25,7 @@ describe("<GroupListPanel />", () => {
group1.body.point_ids = [1, 2, 3];
const group2 = fakePointGroup();
group2.body.name = "two";
group2.body.criteria.day.days_ago = -1;
const point1 = fakePlant();
point1.body.id = 1;
const point2 = fakePlant();

View File

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

View File

@ -2,13 +2,12 @@ import { selectPointsByCriteria, pointsSelectedByGroup } from "..";
import {
fakePoint, fakePlant, fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import moment from "moment";
import { DEFAULT_CRITERIA } from "../interfaces";
import { DEFAULT_CRITERIA, PointGroupCriteria } from "../interfaces";
import { cloneDeep } from "lodash";
describe("selectPointsByCriteria()", () => {
const fakeCriteria = (): PointGroup["criteria"] =>
const fakeCriteria = (): PointGroupCriteria =>
cloneDeep(DEFAULT_CRITERIA);
it("matches color", () => {
@ -44,6 +43,7 @@ describe("selectPointsByCriteria()", () => {
it("matches positions: gt/lt", () => {
const criteria = fakeCriteria();
criteria.string_eq = {};
criteria.number_gt = { x: 100 };
criteria.number_lt = { x: 500 };
const matchingPoint = fakePoint();
@ -57,6 +57,7 @@ describe("selectPointsByCriteria()", () => {
it("matches age greater than 1 day old", () => {
const criteria = fakeCriteria();
criteria.string_eq = {};
criteria.day = { days_ago: 1, op: ">" };
const matchingPoint = fakePoint();
matchingPoint.body.created_at = "2020-01-20T20:00:00.000Z";
@ -70,6 +71,7 @@ describe("selectPointsByCriteria()", () => {
it("matches age less than 1 day old", () => {
const criteria = fakeCriteria();
criteria.string_eq = {};
criteria.day = { days_ago: 1, op: "<" };
const matchingPoint = fakePoint();
matchingPoint.body.created_at = "2020-02-20T20:00:00.000Z";

View File

@ -14,20 +14,17 @@ import {
} from "../../../../__test_support__/fake_state/resources";
import { cloneDeep } from "lodash";
import { overwrite, save } from "../../../../api/crud";
import { ExpandableHeader } from "../../../../ui";
import { PointGroup } from "farmbot/dist/resources/api_resources";
describe("<GroupCriteria />", () => {
const fakeProps = (): GroupCriteriaProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
slugs: [],
editGroupAreaInMap: false,
});
it("renders", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<GroupCriteria {...p} />);
const wrapper = mount(<GroupCriteria {...fakeProps()} />);
["criteria", "age selection"].map(string =>
expect(wrapper.text().toLowerCase()).toContain(string));
});
@ -35,17 +32,17 @@ describe("<GroupCriteria />", () => {
it("clears criteria", () => {
const p = fakeProps();
const wrapper = mount(<GroupCriteria {...p} />);
wrapper.find("button").first().simulate("click");
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(save).toHaveBeenCalledWith(p.group.uuid);
});
it("expands section", () => {
it("toggles advanced view", () => {
const wrapper = mount(<GroupCriteria {...fakeProps()} />);
expect(wrapper.text()).not.toContain("number criteria");
wrapper.find(ExpandableHeader).simulate("click");
wrapper.find("ToggleButton").first().simulate("click");
expect(wrapper.text()).toContain("number criteria");
});
});

View File

@ -5,20 +5,25 @@ jest.mock("../../../../api/crud", () => ({
import {
editCriteria, toggleEqCriteria,
togglePointSelection, toggleStringCriteria, editGtLtCriteria,
editGtLtCriteria,
togglePointTypeCriteria,
toggleAndEditEqCriteria,
clearCriteriaField,
removeEqCriteriaValue,
editGtLtCriteriaField,
} from "..";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { overwrite, save } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { DEFAULT_CRITERIA } from "../interfaces";
import { DEFAULT_CRITERIA, PointGroupCriteria } from "../interfaces";
import { inputEvent } from "../../../../__test_support__/fake_html_events";
describe("editCriteria()", () => {
it("edits criteria: all empty", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
group.body.criteria = DEFAULT_CRITERIA;
editCriteria(group, {})(jest.fn());
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = DEFAULT_CRITERIA;
@ -35,7 +40,7 @@ describe("editCriteria()", () => {
it("edits criteria: full update", () => {
const group = fakePointGroup();
const criteria: PointGroup["criteria"] = {
const criteria: PointGroupCriteria = {
day: { days_ago: 1, op: "<" },
string_eq: { openfarm_slug: ["slug"] },
number_eq: { x: [0] },
@ -52,47 +57,162 @@ describe("editCriteria()", () => {
describe("toggleEqCriteria()", () => {
it("adds criteria", () => {
const result = toggleEqCriteria({})("openfarm_slug", "slug");
expect(result).toEqual({ openfarm_slug: ["slug"] });
const eqCriteria = {};
toggleEqCriteria(eqCriteria)("openfarm_slug", "slug");
expect(eqCriteria).toEqual({ openfarm_slug: ["slug"] });
});
it("removes criteria", () => {
const result = toggleEqCriteria({ openfarm_slug: ["slug"] })(
const eqCriteria = { openfarm_slug: ["slug"] };
toggleEqCriteria(eqCriteria)(
"openfarm_slug", "slug");
expect(result).toEqual({});
expect(eqCriteria).toEqual({});
});
it("toggles on", () => {
const eqCriteria = { openfarm_slug: ["slug"] };
toggleEqCriteria(eqCriteria, "on")(
"openfarm_slug", "slug");
expect(eqCriteria).toEqual({ openfarm_slug: ["slug"] });
});
it("toggles off", () => {
const eqCriteria = {};
toggleEqCriteria(eqCriteria, "off")("openfarm_slug", "slug");
expect(eqCriteria).toEqual({});
});
});
const dispatch = jest.fn(x => x(jest.fn()));
describe("togglePointSelection()", () => {
it("adds criteria", () => {
describe("toggleAndEditEqCriteria()", () => {
it("toggles criteria on", () => {
const group = fakePointGroup();
togglePointSelection(group)({ openfarm_slug: "slug" })(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
expectedBody.criteria.string_eq = { openfarm_slug: ["mint"] };
toggleAndEditEqCriteria(group, "openfarm_slug", "mint")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("toggles criteria on for point type", () => {
const group = fakePointGroup();
const expectedBody = cloneDeep(group.body);
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant", "ToolSlot"],
openfarm_slug: ["apple"],
"meta.color": ["red"],
};
group.body.criteria.number_eq = {
pullout_direction: [0]
};
expectedBody.criteria.string_eq = {
pointer_type: ["Plant"],
openfarm_slug: ["apple", "mint"],
};
expectedBody.criteria.number_eq = {};
toggleAndEditEqCriteria(group, "openfarm_slug", "mint", "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("toggles off", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant", "ToolSlot"],
openfarm_slug: ["mint"],
"meta.color": ["red"],
};
group.body.criteria.number_eq = {
pullout_direction: [0],
};
const expectedBody = cloneDeep(group.body);
delete expectedBody.criteria.string_eq.openfarm_slug;
toggleAndEditEqCriteria(group, "openfarm_slug", "mint", "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("toggles on: empty criteria", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {
openfarm_slug: undefined,
"meta.color": undefined,
};
group.body.criteria.number_eq = {
pullout_direction: undefined,
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_eq = { pullout_direction: [0] };
toggleAndEditEqCriteria(group, "pullout_direction", 0, "ToolSlot")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("toggleStringCriteria()", () => {
it("adds criteria", () => {
describe("togglePointTypeCriteria()", () => {
it("toggles on", () => {
const group = fakePointGroup();
toggleStringCriteria(group, "openfarm_slug", "slug")(dispatch);
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer"],
openfarm_slug: ["mint"],
"meta.color": ["red"],
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
expectedBody.criteria.string_eq.pointer_type?.push("Plant");
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("handles missing criteria", () => {
it("toggles off", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
toggleStringCriteria(group, "openfarm_slug", "slug")(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = cloneDeep(DEFAULT_CRITERIA);
expectedBody.criteria.string_eq = { openfarm_slug: ["slug"] };
group.body.criteria.string_eq = {
pointer_type: ["GenericPointer", "Plant"],
openfarm_slug: ["mint"],
"meta.color": ["red"],
};
expectedBody.criteria.string_eq = {
pointer_type: ["GenericPointer"],
"meta.color": ["red"],
};
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("toggles on: empty criteria", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = {};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = { pointer_type: ["Plant"] };
togglePointTypeCriteria(group, "Plant")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("toggles off: empty criteria", () => {
const group = fakePointGroup();
group.body.criteria.string_eq = { pointer_type: ["ToolSlot"] };
group.body.criteria.number_eq = {
pullout_direction: undefined,
};
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.string_eq = {};
togglePointTypeCriteria(group, "ToolSlot")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("clearCriteriaField()", () => {
it("clears field", () => {
const group = fakePointGroup();
const expectedBody = cloneDeep(group.body);
group.body.criteria.string_eq = { plant_stage: ["planted"] };
expectedBody.criteria.string_eq = {};
clearCriteriaField(group, ["string_eq"], "plant_stage")(dispatch);
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
@ -117,16 +237,41 @@ describe("editGtLtCriteria()", () => {
expect(overwrite).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
});
});
it("handles missing criteria", () => {
describe("removeEqCriteriaValue()", () => {
it("removes value", () => {
const group = fakePointGroup();
group.body.criteria = undefined as unknown as PointGroup["criteria"];
const box = { x0: 1, y0: 2, x1: 3, y1: 4 };
editGtLtCriteria(group, box)(dispatch);
group.body.criteria.string_eq = { plant_stage: ["planted", "planned"] };
removeEqCriteriaValue(group, group.body.criteria.string_eq,
"string_eq", "plant_stage", "planned")(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria = cloneDeep(DEFAULT_CRITERIA);
expectedBody.criteria.number_gt = { x: 1, y: 2 };
expectedBody.criteria.number_lt = { x: 3, y: 4 };
expectedBody.criteria.string_eq = { plant_stage: ["planted"] };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
});
describe("editGtLtCriteriaField()", () => {
it("changes value", () => {
const group = fakePointGroup();
const e = inputEvent("1");
editGtLtCriteriaField(group, "number_lt", "radius")(e)(dispatch);
const expectedBody = cloneDeep(group.body);
expectedBody.criteria.number_lt = { radius: 1 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});
it("clears incompatible criteria", () => {
const group = fakePointGroup();
const expectedBody = cloneDeep(group.body);
group.body.criteria.string_eq = { plant_stage: ["planted"] };
const e = inputEvent("1");
editGtLtCriteriaField(
group, "number_lt", "radius", "GenericPointer",
)(e)(dispatch);
expectedBody.criteria.number_lt = { radius: 1 };
expect(overwrite).toHaveBeenCalledWith(group, expectedBody);
expect(save).toHaveBeenCalledWith(group.uuid);
});

View File

@ -1,56 +1,93 @@
const mockToggle = jest.fn();
jest.mock("../edit", () => ({
togglePointSelection: jest.fn(() => mockToggle),
toggleStringCriteria: jest.fn(),
togglePointTypeCriteria: jest.fn(),
toggleAndEditEqCriteria: jest.fn(),
clearCriteriaField: jest.fn(),
}));
import React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import {
CheckboxSelections, togglePointSelection, criteriaSelected,
CheckboxSelections, togglePointTypeCriteria, clearCriteriaField,
} from "..";
import { CheckboxSelectionsProps } from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { Checkbox } from "../../../../ui";
describe("<CheckboxSelections />", () => {
const fakeProps = (): CheckboxSelectionsProps => ({
dispatch: jest.fn(),
group: fakePointGroup(),
slugs: ["mint"],
});
it("renders", () => {
it("renders all criteria", () => {
const STRINGS = [
"planted", "mint",
"farm designer", "radius", "green",
"positive x",
];
const wrapper = mount(<CheckboxSelections {...fakeProps()} />);
STRINGS.map(string =>
expect(wrapper.text().toLowerCase()).not.toContain(string.toLowerCase()));
wrapper.setState({ Plant: true, GenericPointer: true, ToolSlot: true });
STRINGS.map(string =>
expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase()));
});
it("clears sub criteria", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
p.group.body.criteria.string_eq = { plant_stage: ["planned"] };
const wrapper = mount(<CheckboxSelections {...p} />);
["planted plants", "detected weeds", "created points", "created weeds",
].map(string =>
expect(wrapper.text()).toContain(string));
wrapper.setState({ Plant: true, GenericPointer: false, ToolSlot: false });
wrapper.find(".plant-criteria-options")
.find("input").first().simulate("change");
expect(clearCriteriaField).toHaveBeenCalledWith(
p.group, ["string_eq"], "plant_stage",
);
});
it("changes criteria", () => {
it("toggles section", () => {
const wrapper =
shallow<CheckboxSelections>(<CheckboxSelections {...fakeProps()} />);
expect(wrapper.state().Plant).toBeFalsy();
wrapper.instance().toggleMore("Plant")();
expect(wrapper.state().Plant).toBeTruthy();
});
it("toggles point type", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxSelections {...p} />);
wrapper.find("input").first().simulate("change");
expect(togglePointSelection).toHaveBeenCalledWith(p.group);
expect(mockToggle).toHaveBeenCalledWith({
plant_stage: "planted", pointer_type: "Plant"
});
});
});
describe("criteriaSelected()", () => {
it("returns selection state: false", () => {
const result = criteriaSelected(undefined)({ pointer_type: "Plant" });
expect(result).toEqual(false);
expect(togglePointTypeCriteria).toHaveBeenCalledWith(p.group, "Plant");
});
it("returns selection state: true", () => {
const result = criteriaSelected({
pointer_type: ["Plant"]
})({ pointer_type: "Plant" });
expect(result).toEqual(true);
it("stops propagation", () => {
const wrapper = mount(<CheckboxSelections {...fakeProps()} />);
const e = { stopPropagation: jest.fn() };
wrapper.find(".fb-checkbox").first().simulate("click", e);
expect(e.stopPropagation).toHaveBeenCalled();
});
it("is not disabled", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxSelections {...p} />);
const pointTypeBoxes = wrapper.find(".point-type-checkbox").find("input");
expect(pointTypeBoxes.first().props().disabled).toBeFalsy();
expect(pointTypeBoxes.at(1).props().disabled).toBeFalsy();
expect(pointTypeBoxes.last().props().disabled).toBeFalsy();
});
it("is disabled", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = { plant_stage: ["planted"] };
p.group.body.criteria.number_eq = { pullout_direction: [0] };
p.group.body.criteria.number_gt = { radius: 0 };
const wrapper = mount(<CheckboxSelections {...p} />);
const pointTypeBoxes = wrapper.find(".point-type-checkbox").find(Checkbox);
expect(pointTypeBoxes.first().props().disabled).toBeTruthy();
expect(pointTypeBoxes.at(1).props().disabled).toBeTruthy();
expect(pointTypeBoxes.last().props().disabled).toBeTruthy();
});
});

View File

@ -0,0 +1,70 @@
import { eqCriteriaSelected, criteriaHasKey, hasSubCriteria, typeDisabled } from "..";
import { DEFAULT_CRITERIA, PointGroupCriteria } from "../interfaces";
import { cloneDeep } from "lodash";
const fakeCriteria = (): PointGroupCriteria =>
cloneDeep(DEFAULT_CRITERIA);
describe("eqCriteriaSelected()", () => {
it("returns selected", () => {
const criteria = fakeCriteria();
criteria.number_eq = { pullout_direction: [0] };
const result = eqCriteriaSelected(criteria)("pullout_direction", 0);
expect(result).toEqual(true);
});
it("returns not selected", () => {
const criteria = fakeCriteria();
const result = eqCriteriaSelected(criteria)(
"pullout_direction", false as unknown as string);
expect(result).toEqual(false);
});
});
describe("criteriaHasKey()", () => {
it("has key", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
const result = criteriaHasKey(criteria, ["string_eq"], "plant_stage");
expect(result).toBeTruthy();
});
it("doesn't have key", () => {
const criteria = fakeCriteria();
criteria.string_eq = {};
const result = criteriaHasKey(criteria, ["string_eq"], "plant_stage");
expect(result).toBeFalsy();
});
});
describe("hasSubCriteria()", () => {
it("has criteria", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
const result = hasSubCriteria(criteria)("Plant");
expect(result).toBeTruthy();
});
it("doesn't have criteria", () => {
const criteria = fakeCriteria();
criteria.string_eq = { "meta.color": ["red"] };
const result = hasSubCriteria(criteria)("Plant");
expect(result).toBeFalsy();
});
});
describe("typeDisabled()", () => {
it("is disabled", () => {
const criteria = fakeCriteria();
criteria.string_eq = { "meta.color": ["red"] };
const result = typeDisabled(criteria, "Plant");
expect(result).toBeTruthy();
});
it("isn't disabled", () => {
const criteria = fakeCriteria();
criteria.string_eq = { plant_stage: ["planted"] };
const result = typeDisabled(criteria, "Plant");
expect(result).toBeFalsy();
});
});

View File

@ -1,13 +1,22 @@
jest.mock("../../../../api/crud", () => ({
overwrite: jest.fn(),
save: jest.fn(),
jest.mock("../edit", () => ({
editCriteria: jest.fn(),
editGtLtCriteriaField: jest.fn(() => jest.fn()),
removeEqCriteriaValue: jest.fn(),
clearCriteriaField: jest.fn(),
}));
import React from "react";
import { mount, shallow } from "enzyme";
import {
EqCriteriaSelection,
NumberCriteriaSelection, DaySelection, LocationSelection, AddCriteria,
NumberCriteriaSelection,
DaySelection,
LocationSelection,
NumberLtGtInput,
removeEqCriteriaValue,
clearCriteriaField,
editCriteria,
editGtLtCriteriaField,
} from "..";
import {
EqCriteriaSelectionProps,
@ -15,23 +24,21 @@ import {
CriteriaSelectionProps,
DEFAULT_CRITERIA,
LocationSelectionProps,
GroupCriteriaProps,
NumberLtGtInputProps,
} from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { overwrite } from "../../../../api/crud";
import { cloneDeep } from "lodash";
import { FBSelect } from "../../../../ui";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { Actions } from "../../../../constants";
describe("<EqCriteriaSelection<string> />", () => {
const fakeProps = (): EqCriteriaSelectionProps<string> => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
dispatch: jest.fn(),
type: "string",
criteriaField: {},
eqCriteria: {},
criteriaKey: "string_eq",
});
@ -43,12 +50,16 @@ describe("<EqCriteriaSelection<string> />", () => {
it("removes criteria", () => {
const p = fakeProps();
p.criteriaField = { openfarm_slug: ["slug"] };
p.eqCriteria = { openfarm_slug: ["slug"] };
const wrapper = mount(<EqCriteriaSelection<string> {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.string_eq = {};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(removeEqCriteriaValue).toHaveBeenCalledWith(
p.group,
{ openfarm_slug: ["slug"] },
"string_eq",
"openfarm_slug",
"slug",
);
});
});
@ -56,24 +67,29 @@ describe("<NumberCriteriaSelection />", () => {
const fakeProps = (): NumberCriteriaProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
dispatch: jest.fn(),
criteriaKey: "number_lt",
});
it("renders", () => {
const p = fakeProps();
p.criteria.number_lt = { x: 1 };
const wrapper = mount(<NumberCriteriaSelection {...p} />);
expect(wrapper.text()).toContain("<");
});
it("removes criteria", () => {
const p = fakeProps();
p.criteria.number_lt = { x: 1 };
p.criteriaKey = "number_gt";
p.criteria.number_gt = { x: 1 };
const wrapper = mount(<NumberCriteriaSelection {...p} />);
expect(wrapper.text()).toContain(">");
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_lt = {};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(clearCriteriaField).toHaveBeenCalledWith(
p.group,
["number_gt"],
"x",
);
});
});
@ -81,16 +97,17 @@ describe("<DaySelection />", () => {
const fakeProps = (): CriteriaSelectionProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
dispatch: jest.fn(),
});
it("changes operator", () => {
const p = fakeProps();
const wrapper = shallow(<DaySelection {...p} />);
wrapper.find(FBSelect).simulate("change", { label: "", value: "<" });
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.day.op = "<";
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(editCriteria).toHaveBeenCalledWith(
p.group,
{ day: { days_ago: 0, op: "<" } },
);
});
it("changes day value", () => {
@ -99,16 +116,46 @@ describe("<DaySelection />", () => {
wrapper.find("input").last().simulate("change", {
currentTarget: { value: "1" }
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.day.days_ago = 1;
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
expect(editCriteria).toHaveBeenCalledWith(
p.group,
{ day: { days_ago: 1, op: "<" } },
);
});
});
describe("<NumberLtGtInput />", () => {
const fakeProps = (): NumberLtGtInputProps => ({
criteriaKey: "x",
group: fakePointGroup(),
dispatch: jest.fn(),
});
it("handles missing criteria", () => {
it("changes number_gt", () => {
const p = fakeProps();
p.criteria = {} as PointGroup["criteria"];
const wrapper = shallow(<DaySelection {...p} />);
expect(wrapper.find("input").last().props().value).toEqual(0);
const wrapper = shallow(<NumberLtGtInput {...p} />);
wrapper.find("input").first().simulate("blur", {
currentTarget: { value: "1" }
});
expect(editGtLtCriteriaField).toHaveBeenCalledWith(
p.group,
"number_gt",
"x",
undefined,
);
});
it("changes number_lt", () => {
const p = fakeProps();
const wrapper = shallow(<NumberLtGtInput {...p} />);
wrapper.find("input").last().simulate("blur", {
currentTarget: { value: "1" }
});
expect(editGtLtCriteriaField).toHaveBeenCalledWith(
p.group,
"number_lt",
"x",
undefined,
);
});
});
@ -116,84 +163,17 @@ describe("<LocationSelection />", () => {
const fakeProps = (): LocationSelectionProps => ({
criteria: DEFAULT_CRITERIA,
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn())),
dispatch: jest.fn(),
editGroupAreaInMap: false,
});
it("changes number_gt", () => {
it("toggles selection box behavior", () => {
const p = fakeProps();
const wrapper = shallow(<LocationSelection {...p} />);
wrapper.find("input").first().simulate("blur", {
currentTarget: { value: "1" }
const wrapper = mount(<LocationSelection {...p} />);
wrapper.find("button").first().simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.EDIT_GROUP_AREA_IN_MAP,
payload: true
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_gt = { x: 1 };
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("changes number_lt", () => {
const p = fakeProps();
const wrapper = shallow(<LocationSelection {...p} />);
wrapper.find("input").last().simulate("blur", {
currentTarget: { value: "1" }
});
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.number_lt = { x: 1, y: 1 };
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("handles missing criteria", () => {
const p = fakeProps();
p.criteria = {} as PointGroup["criteria"];
const wrapper = shallow(<LocationSelection {...p} />);
expect(wrapper.find("input").first().props().defaultValue).toEqual(undefined);
expect(wrapper.find("input").last().props().defaultValue).toEqual(undefined);
});
});
describe("<AddCriteria />", () => {
const fakeProps = (): GroupCriteriaProps => ({
slugs: [],
group: fakePointGroup(),
dispatch: jest.fn(x => x(jest.fn(y => y(jest.fn())))),
});
it("renders", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
plant_stage: ["planted"],
};
const wrapper = mount(<AddCriteria {...p} />);
expect(wrapper.find("input").at(0).props().value).toEqual("Plant Type");
expect(wrapper.find("input").at(1).props().value).toEqual("Slug");
expect(wrapper.find("input").at(2).props().value).toEqual("Point Type");
expect(wrapper.find("input").at(3).props().value).toEqual("Plants");
expect(wrapper.find("input").at(4).props().value).toEqual("Plant Status");
expect(wrapper.find("input").at(5).props().value).toEqual("Planted");
});
it("removes criteria", () => {
const p = fakeProps();
p.group.body.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
plant_stage: ["planted"],
};
const wrapper = mount(<AddCriteria {...p} />);
wrapper.find("button").last().simulate("click");
const expectedBody = cloneDeep(p.group.body);
expectedBody.criteria.string_eq = {
openfarm_slug: ["slug"],
pointer_type: ["Plant"],
};
expect(overwrite).toHaveBeenCalledWith(p.group, expectedBody);
});
it("handles missing criteria", () => {
const p = fakeProps();
p.group.body.criteria = undefined as unknown as PointGroup["criteria"];
const wrapper = mount(<AddCriteria {...p} />);
expect(wrapper.text()).toEqual("SelectNone");
});
});

View File

@ -0,0 +1,32 @@
jest.mock("../edit", () => ({
toggleAndEditEqCriteria: jest.fn(),
}));
import React from "react";
import { mount } from "enzyme";
import { toggleAndEditEqCriteria } from "..";
import { CheckboxListProps } from "../interfaces";
import {
fakePointGroup,
} from "../../../../__test_support__/fake_state/resources";
import { CheckboxList } from "../subcriteria";
describe("<CheckboxList />", () => {
const fakeProps = (): CheckboxListProps<string> => ({
criteriaKey: "openfarm_slug",
list: [{ label: "label", value: "value" }],
dispatch: jest.fn(),
group: fakePointGroup(),
pointerType: "Plant",
disabled: false,
});
it("toggles criteria", () => {
const p = fakeProps();
const wrapper = mount(<CheckboxList {...p} />);
expect(wrapper.text()).toContain("label");
wrapper.find("input").first().simulate("change");
expect(toggleAndEditEqCriteria).toHaveBeenCalledWith(
p.group, "openfarm_slug", "value", "Plant");
});
});

View File

@ -1,32 +1,28 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { cloneDeep, capitalize } from "lodash";
import { Row, Col, FBSelect, DropDownItem } from "../../../ui";
import { editCriteria, toggleStringCriteria } from ".";
import { cloneDeep, uniq } from "lodash";
import { Row, Col } from "../../../ui";
import { editCriteria } from ".";
import {
AddEqCriteriaProps,
AddEqCriteriaState,
NumberCriteriaProps,
AddNumberCriteriaState,
AddStringCriteriaProps,
} from "./interfaces";
import {
PLANT_STAGE_DDI_LOOKUP, PLANT_STAGE_LIST,
} from "../../plants/edit_plant_status";
export class AddEqCriteria<T extends string | number>
extends React.Component<AddEqCriteriaProps<T>, AddEqCriteriaState> {
state: AddEqCriteriaState = { key: "", value: "" };
commit = () => {
const { dispatch, group, criteriaKey, criteriaField } = this.props;
const tempEqCriteria = cloneDeep(criteriaField || {});
const { dispatch, group, criteriaKey, eqCriteria } = this.props;
const tempEqCriteria = cloneDeep(eqCriteria);
const tempValues = tempEqCriteria[this.state.key] || [];
const value = this.props.type == "number"
? parseInt(this.state.value)
: this.state.value;
this.state.value && tempValues.push(value as T);
tempEqCriteria[this.state.key] = tempValues;
tempEqCriteria[this.state.key] = uniq(tempValues);
dispatch(editCriteria(group, { [criteriaKey]: tempEqCriteria }));
this.setState({ key: "", value: "" });
}
@ -62,115 +58,13 @@ export class AddEqCriteria<T extends string | number>
}
}
export const CRITERIA_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
pointer_type: { label: t("Point Type"), value: "pointer_type" },
plant_stage: { label: t("Plant Status"), value: "plant_stage" },
openfarm_slug: { label: t("Plant Type"), value: "openfarm_slug" },
});
export const CRITERIA_TYPE_LIST = () => [
CRITERIA_TYPE_DDI_LOOKUP().pointer_type,
CRITERIA_TYPE_DDI_LOOKUP().plant_stage,
CRITERIA_TYPE_DDI_LOOKUP().openfarm_slug,
];
export const POINTER_TYPE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
Plant: { label: t("Plants"), value: "Plant" },
GenericPointer: { label: t("Points"), value: "GenericPointer" },
ToolSlot: { label: t("Slots"), value: "ToolSlot" },
});
export const POINTER_TYPE_LIST = () => [
POINTER_TYPE_DDI_LOOKUP().Plant,
POINTER_TYPE_DDI_LOOKUP().GenericPointer,
POINTER_TYPE_DDI_LOOKUP().ToolSlot,
];
export class AddStringCriteria
extends React.Component<AddStringCriteriaProps, AddEqCriteriaState> {
state: AddEqCriteriaState = { key: "", value: "" };
commit = () => {
if (this.state.key && this.state.value) {
this.props.dispatch(toggleStringCriteria(this.props.group,
this.state.key, this.state.value));
this.setState({ key: "", value: "" });
}
}
get key() { return JSON.stringify(this.props.group.body.criteria || {}); }
change = (ddi: DropDownItem) => this.setState({ value: "" + ddi.value });
get selected() {
switch (this.state.key) {
case "openfarm_slug":
return this.state.value
? { label: t(capitalize(this.state.value)), value: this.state.value }
: undefined;
case "pointer_type":
return this.state.value
? POINTER_TYPE_DDI_LOOKUP()[this.state.value]
: undefined;
case "plant_stage":
return this.state.value
? PLANT_STAGE_DDI_LOOKUP()[this.state.value]
: undefined;
default:
return undefined;
}
}
get options() {
switch (this.state.key) {
case "openfarm_slug":
return this.props.slugs.map(slug =>
({ label: t(capitalize(slug)), value: slug }));
case "pointer_type":
return POINTER_TYPE_LIST();
case "plant_stage":
return PLANT_STAGE_LIST();
default:
return [];
}
}
render() {
const noKey = this.options.length < 1;
return <div className={"add-string-criteria"}>
<Row>
<Col xs={5}>
<FBSelect key={this.key}
customNullLabel={t("Select")}
list={CRITERIA_TYPE_LIST()}
selectedItem={CRITERIA_TYPE_DDI_LOOKUP()[this.state.key]}
onChange={ddi => this.setState({ key: "" + ddi.value })} />
</Col>
<Col xs={5}>
<FBSelect key={this.key}
extraClass={noKey ? "disabled" : ""}
list={this.options}
selectedItem={this.selected}
onChange={this.change} />
</Col>
<Col xs={2}>
<button className="fb-button green"
title={t("add string criteria")}
onClick={this.commit}>
<i className="fa fa-plus" />
</button>
</Col>
</Row>
</div>;
}
}
export class AddNumberCriteria
extends React.Component<NumberCriteriaProps, AddNumberCriteriaState> {
state: AddNumberCriteriaState = { key: "", value: 0 };
commit = () => {
const { dispatch, group, criteriaKey } = this.props;
const tempNumberCriteria =
cloneDeep(group.body.criteria?.[criteriaKey] || {});
const tempNumberCriteria = cloneDeep(group.body.criteria[criteriaKey]);
tempNumberCriteria[this.state.key] = this.state.value;
dispatch(editCriteria(group, { [criteriaKey]: tempNumberCriteria }));
this.setState({ key: "", value: 0 });

View File

@ -1,16 +1,17 @@
import { every, get, isEqual, uniq, gt, lt, isNumber } from "lodash";
import { every, get, uniq, gt, lt, isNumber } from "lodash";
import { TaggedPoint, TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import moment from "moment";
import { DEFAULT_CRITERIA } from "./interfaces";
import { PointGroupCriteria } from "./interfaces";
/** Check if a string or number criteria field is empty. */
const eqCriteriaEmpty =
(eqCriteria: Record<string, (string | number)[] | undefined>) =>
every(Object.values(eqCriteria).map(values => !values?.length));
/** Check if a point matches the criteria in the provided category. */
const checkCriteria =
(criteria: PointGroup["criteria"], now: moment.Moment) =>
(point: TaggedPoint, criteriaKey: keyof PointGroup["criteria"]) => {
(criteria: PointGroupCriteria, now: moment.Moment) =>
(point: TaggedPoint, criteriaKey: keyof PointGroupCriteria) => {
switch (criteriaKey) {
case "string_eq":
case "number_eq":
@ -38,18 +39,19 @@ const checkCriteria =
}
};
/** Check if a point matches all criteria provided. */
export const selectPointsByCriteria = (
criteria: PointGroup["criteria"] | undefined,
criteria: PointGroupCriteria,
allPoints: TaggedPoint[],
now = moment(),
): TaggedPoint[] => {
if (!criteria || isEqual(criteria, DEFAULT_CRITERIA)) { return []; }
const check = checkCriteria(criteria, now);
return allPoints.filter(point =>
every(Object.keys(criteria).map((key: keyof PointGroup["criteria"]) =>
every(Object.keys(criteria).map((key: keyof PointGroupCriteria) =>
check(point, key))));
};
/** Return all points selected by group manual additions and criteria. */
export const pointsSelectedByGroup =
(group: TaggedPointGroup, allPoints: TaggedPoint[]) =>
uniq(allPoints

View File

@ -2,63 +2,68 @@ import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { overwrite, save } from "../../../api/crud";
import {
CheckboxSelections, DaySelection, EqCriteriaSelection,
NumberCriteriaSelection, LocationSelection, AddCriteria,
DaySelection, EqCriteriaSelection,
NumberCriteriaSelection, LocationSelection, CheckboxSelections,
} from ".";
import {
GroupCriteriaProps, GroupPointCountBreakdownProps, GroupCriteriaState,
DEFAULT_CRITERIA,
DEFAULT_CRITERIA, ClearCriteriaProps,
} from "./interfaces";
import { ExpandableHeader } from "../../../ui";
import { Collapse } from "@blueprintjs/core";
import { ToggleButton } from "../../../controls/toggle_button";
export class GroupCriteria extends
React.Component<GroupCriteriaProps, GroupCriteriaState> {
state: GroupCriteriaState = { advanced: false, clearCount: 0 };
render() {
const { group, dispatch, slugs } = this.props;
const criteria = group.body.criteria || {};
const criteria = group.body.criteria;
const commonProps = { group, criteria, dispatch };
return <div className="group-criteria">
<label className="criteria-heading">{t("criteria")}</label>
<button className="fb-button red"
title={t("clear all criteria")}
onClick={() => {
dispatch(overwrite(group, {
...group.body, criteria: DEFAULT_CRITERIA
}));
dispatch(save(group.uuid));
}}>
{t("clear all criteria")}
</button>
<div className="group-criteria-presets">
<label>{t("presets")}</label>
<CheckboxSelections group={group} dispatch={dispatch} />
</div>
<DaySelection {...commonProps} />
<LocationSelection {...commonProps} />
<label>{t("additional criteria")}</label>
<AddCriteria group={group} dispatch={dispatch} slugs={slugs} />
<ExpandableHeader
expanded={this.state.advanced}
title={t("Advanced")}
onClick={() => this.setState({ advanced: !this.state.advanced })} />
<Collapse isOpen={this.state.advanced}>
<label>{t("string criteria")}</label>
<EqCriteriaSelection<string> {...commonProps}
type={"string"} criteriaField={criteria.string_eq}
criteriaKey={"string_eq"} />
<label>{t("number criteria")}</label>
<EqCriteriaSelection<number> {...commonProps}
type={"number"} criteriaField={criteria.number_eq}
criteriaKey={"number_eq"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_lt"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_gt"} />
</Collapse>
<ToggleButton
title={t("toggle advanced view")}
toggleValue={!this.state.advanced}
customText={{ textTrue: t("basic"), textFalse: t("advanced") }}
toggleAction={() => this.setState({ advanced: !this.state.advanced })} />
{!this.state.advanced
? <div className={"basic"}>
<CheckboxSelections group={group} dispatch={dispatch} slugs={slugs} />
<DaySelection {...commonProps} />
<LocationSelection {...commonProps}
editGroupAreaInMap={this.props.editGroupAreaInMap} />
</div>
: <div className={"advanced"}>
<DaySelection {...commonProps} />
<label>{t("string criteria")}</label>
<EqCriteriaSelection<string> {...commonProps}
type={"string"} eqCriteria={criteria.string_eq}
criteriaKey={"string_eq"} />
<label>{t("number criteria")}</label>
<EqCriteriaSelection<number> {...commonProps}
type={"number"} eqCriteria={criteria.number_eq}
criteriaKey={"number_eq"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_lt"} />
<NumberCriteriaSelection {...commonProps} criteriaKey={"number_gt"} />
</div>}
<ClearCriteria dispatch={dispatch} group={group} />
</div>;
}
}
/** Reset all group criteria to defaults. */
const ClearCriteria = (props: ClearCriteriaProps) =>
<button className="clear-criteria fb-button red no-float"
title={t("clear all criteria")}
onClick={() => {
props.dispatch(overwrite(props.group, {
...props.group.body, criteria: DEFAULT_CRITERIA
}));
props.dispatch(save(props.group.uuid));
}}>
{t("clear all criteria")}
</button>;
/** Show counts of manual and criteria selections. */
export const GroupPointCountBreakdown = (props: GroupPointCountBreakdownProps) =>
<div className={"criteria-point-count-breakdown"}>
<div className={"manual-group-member-count"}>

View File

@ -1,64 +1,130 @@
import { overwrite, save } from "../../../api/crud";
import { TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { cloneDeep, isNumber } from "lodash";
import { SelectionBoxData } from "../../map/background";
import { DEFAULT_CRITERIA } from "./interfaces";
import {
PointGroupCriteria, POINTER_TYPES, EqCriteria, PointerType,
StrAndNumCriteriaKeys,
} from "./interfaces";
/** Update and save group criteria. */
export const editCriteria =
(group: TaggedPointGroup, update: Partial<PointGroup["criteria"]>) =>
(group: TaggedPointGroup, update: Partial<PointGroupCriteria>) =>
(dispatch: Function) => {
const criteria = {
string_eq: update.string_eq || group.body.criteria?.string_eq || {},
day: update.day || group.body.criteria?.day || DEFAULT_CRITERIA.day,
number_eq: update.number_eq || group.body.criteria?.number_eq || {},
number_gt: update.number_gt || group.body.criteria?.number_gt || {},
number_lt: update.number_lt || group.body.criteria?.number_lt || {},
string_eq: update.string_eq || group.body.criteria.string_eq,
day: update.day || group.body.criteria.day,
number_eq: update.number_eq || group.body.criteria.number_eq,
number_gt: update.number_gt || group.body.criteria.number_gt,
number_lt: update.number_lt || group.body.criteria.number_lt,
};
dispatch(overwrite(group, { ...group.body, criteria }));
dispatch(save(group.uuid));
};
/** Toggle string or number equal criteria. */
export const toggleEqCriteria = <T extends string | number>(
eqCriteria: Record<string, T[] | undefined>,
) =>
(key: string, value: T): Record<string, T[] | undefined> => {
const values: T[] = eqCriteria[key] || [];
if (values.includes(value)) {
eqCriteria: EqCriteria<T>,
direction?: "on" | "off",
) => (key: string, value: T) => {
const values: T[] = eqCriteria[key] || [];
if (values.includes(value)) {
if (direction != "on") {
const newValues = values.filter(s => s != value);
eqCriteria[key] = newValues;
!newValues.length && delete eqCriteria[key];
} else {
}
} else {
if (direction != "off") {
values.push(value);
eqCriteria[key] = values;
}
return eqCriteria;
}
};
/**
* Toggle and save string or number equal criteria.
* When adding criteria with a pointerType provided, clear incompatible criteria.
*/
export const toggleAndEditEqCriteria = <T extends string | number>(
group: TaggedPointGroup,
key: string,
value: T,
pointerType?: PointerType,
) =>
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
const criteriaField = typeof value == "string" ? "string_eq" : "number_eq";
const tempEqCriteria = tempCriteria[criteriaField] as EqCriteria<T>;
const wasOff = !tempEqCriteria[key]?.includes(value);
toggleEqCriteria<T>(tempEqCriteria)(key, value);
pointerType && wasOff && clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
dispatch(editCriteria(group, tempCriteria));
};
export const togglePointSelection =
(group: TaggedPointGroup) => (toggleCriteria: Record<string, string>) =>
/** Clear incompatible criteria. */
const clearSubCriteria = (
pointerTypes: PointerType[],
tempCriteria: PointGroupCriteria,
) => {
const toggleStrEq = toggleEqCriteria<string>(tempCriteria.string_eq, "off");
const toggleNumEq = toggleEqCriteria<number>(tempCriteria.number_eq, "off");
if (pointerTypes.includes("Plant")) {
Object.entries(tempCriteria.string_eq).map(([key, values]) =>
["openfarm_slug", "plant_stage"].includes(key)
&& values?.map(v => toggleStrEq(key, v)));
toggleStrEq("pointer_type", "Plant");
}
if (pointerTypes.includes("GenericPointer")) {
Object.entries(tempCriteria.string_eq).map(([key, values]) =>
key.includes("meta") && values?.map(v => toggleStrEq(key, v)));
delete tempCriteria.number_lt.radius;
delete tempCriteria.number_gt.radius;
toggleStrEq("pointer_type", "GenericPointer");
}
if (pointerTypes.includes("ToolSlot")) {
tempCriteria.number_eq.pullout_direction?.map(value =>
toggleNumEq("pullout_direction", value));
toggleStrEq("pointer_type", "ToolSlot");
}
};
/**
* Toggle and save pointer_type string equal criteria.
* When removing pointer_type criteria, clear pointer_type-specific criteria.
*/
export const togglePointTypeCriteria =
(group: TaggedPointGroup, pointerType: PointerType) =>
(dispatch: Function) => {
const stringCriteria = {};
const toggle = toggleEqCriteria<string>(stringCriteria);
Object.entries(toggleCriteria).map(([key, value]) => toggle(key, value));
dispatch(editCriteria(group, { string_eq: stringCriteria }));
const tempCriteria = cloneDeep(group.body.criteria);
const wasOn = tempCriteria.string_eq.pointer_type?.includes(pointerType);
const toggle = toggleEqCriteria<string>(tempCriteria.string_eq);
toggle("pointer_type", pointerType);
wasOn && clearSubCriteria([pointerType], tempCriteria);
dispatch(editCriteria(group, tempCriteria));
};
export const toggleStringCriteria =
(group: TaggedPointGroup, key: string, value: string) =>
(dispatch: Function) => {
const tempStringCriteria = cloneDeep(group.body.criteria?.string_eq || {});
toggleEqCriteria<string>(tempStringCriteria)(key, value);
dispatch(editCriteria(group, { string_eq: tempStringCriteria }));
};
/** Clear and save all fields in the provided criteria categories. */
export const clearCriteriaField = (
group: TaggedPointGroup,
categories: StrAndNumCriteriaKeys,
field: string,
) =>
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
categories.map(category => delete tempCriteria[category][field]);
dispatch(editCriteria(group, tempCriteria));
};
/** For map selection box actions maybeUpdateGroup. */
export const editGtLtCriteria =
(group: TaggedPointGroup, box: SelectionBoxData) =>
(dispatch: Function) => {
if (!(isNumber(box.x0) && isNumber(box.y0)
&& isNumber(box.x1) && isNumber(box.y1))) { return; }
const tempGtCriteria = cloneDeep(group.body.criteria?.number_gt || {});
const tempLtCriteria = cloneDeep(group.body.criteria?.number_lt || {});
const tempGtCriteria = cloneDeep(group.body.criteria.number_gt);
const tempLtCriteria = cloneDeep(group.body.criteria.number_lt);
tempGtCriteria.x = Math.min(box.x0, box.x1);
tempGtCriteria.y = Math.min(box.y0, box.y1);
tempLtCriteria.x = Math.max(box.x0, box.x1);
@ -68,3 +134,36 @@ export const editGtLtCriteria =
number_lt: tempLtCriteria,
}));
};
/** For EqCriteriaSelection form. */
export const removeEqCriteriaValue = <T extends string | number>(
group: TaggedPointGroup,
eqCriteria: EqCriteria<T>,
eqCriteriaName: string,
key: string,
value: T,
) => (dispatch: Function) => {
const tempCriteriaField = cloneDeep(eqCriteria);
toggleEqCriteria<T>(tempCriteriaField, "off")(key, value);
dispatch(editCriteria(group, { [eqCriteriaName]: tempCriteriaField }));
};
/**
* For criteria form NumberLtGtInput.
* Clear incompatible criteria if pointer_type is provided.
*/
export const editGtLtCriteriaField = (
group: TaggedPointGroup,
criteriaField: "number_gt" | "number_lt",
criteriaKey: string,
pointerType?: PointerType,
) =>
(e: React.FormEvent<HTMLInputElement>) =>
(dispatch: Function) => {
const tempCriteria = cloneDeep(group.body.criteria);
pointerType && clearSubCriteria(
POINTER_TYPES.filter(x => x != pointerType), tempCriteria);
tempCriteria[criteriaField][criteriaKey] =
parseInt(e.currentTarget.value);
dispatch(editCriteria(group, tempCriteria));
};

View File

@ -3,4 +3,6 @@ export * from "./apply";
export * from "./component";
export * from "./edit";
export * from "./presets";
export * from "./selected";
export * from "./show";
export * from "./subcriteria";

View File

@ -1,21 +1,28 @@
import { TaggedPointGroup } from "farmbot";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { PointGroup, Point } from "farmbot/dist/resources/api_resources";
export const DEFAULT_CRITERIA: Readonly<PointGroup["criteria"]> = {
export type PointGroupCriteria = PointGroup["criteria"];
export type StringEqCriteria = PointGroupCriteria["string_eq"];
export type PointerType = Point["pointer_type"];
export type StrAndNumCriteriaKeys = (keyof Omit<PointGroupCriteria, "day">)[];
export type EqCriteria<T> = Record<string, T[] | undefined>;
export const POINTER_TYPES: PointerType[] =
["Plant", "GenericPointer", "ToolSlot"];
export const DEFAULT_CRITERIA: Readonly<PointGroupCriteria> = {
day: { op: "<", days_ago: 0 },
number_eq: {},
number_gt: {},
number_lt: {},
string_eq: {},
string_eq: { pointer_type: ["Plant"] },
};
export type EqCriteria = Record<string, (string | number)[] | undefined> | undefined;
export type StringEqCriteria = PointGroup["criteria"]["string_eq"] | undefined;
export interface GroupCriteriaProps {
dispatch: Function;
group: TaggedPointGroup;
slugs: string[];
editGroupAreaInMap: boolean;
}
export interface GroupCriteriaState {
@ -23,24 +30,30 @@ export interface GroupCriteriaState {
clearCount: number;
}
export interface ClearCriteriaProps {
dispatch: Function;
group: TaggedPointGroup;
}
export interface GroupPointCountBreakdownProps {
manualCount: number;
totalCount: number;
}
export interface CriteriaSelectionProps {
criteria: PointGroup["criteria"];
criteria: PointGroupCriteria;
group: TaggedPointGroup;
dispatch: Function;
}
export interface LocationSelectionProps extends CriteriaSelectionProps {
editGroupAreaInMap: boolean;
}
export interface EqCriteriaSelectionProps<T> extends CriteriaSelectionProps {
type: "string" | "number";
criteriaField: Record<string, T[] | undefined> | undefined;
criteriaKey: keyof PointGroup["criteria"];
eqCriteria: EqCriteria<T>;
criteriaKey: keyof PointGroupCriteria;
}
export interface NumberCriteriaProps extends CriteriaSelectionProps {
@ -51,31 +64,64 @@ export interface AddEqCriteriaProps<T> {
dispatch: Function;
group: TaggedPointGroup;
type: "string" | "number";
criteriaField: Record<string, T[] | undefined> | undefined;
criteriaKey: keyof PointGroup["criteria"];
eqCriteria: EqCriteria<T>;
criteriaKey: keyof PointGroupCriteria;
}
export interface AddEqCriteriaState {
key: string;
value: string;
}
export interface AddCriteriaState {
key: string;
value: string;
}
export interface AddStringCriteriaProps {
group: TaggedPointGroup;
dispatch: Function;
slugs: string[];
}
export interface AddNumberCriteriaState {
key: string;
value: number;
}
export interface SubCriteriaProps {
dispatch: Function;
group: TaggedPointGroup;
disabled: boolean;
}
export interface PlantSubCriteriaProps extends SubCriteriaProps {
slugs: string[];
}
export interface CheckboxSelectionsProps {
dispatch: Function;
group: TaggedPointGroup;
slugs: string[];
}
export interface CheckboxSelectionsState {
Plant: boolean;
GenericPointer: boolean;
ToolSlot: boolean;
}
export interface NumberLtGtInputProps {
criteriaKey: "x" | "y" | "radius";
group: TaggedPointGroup;
dispatch: Function;
inputWidth?: number;
labelWidth?: number;
disabled?: boolean;
pointerType?: PointerType;
}
export interface ClearCategoryProps {
group: TaggedPointGroup;
criteriaCategories: StrAndNumCriteriaKeys;
criteriaKey: string;
dispatch: Function;
}
export interface CheckboxListProps<T> {
criteriaKey: string;
list: { label: string, value: T }[];
dispatch: Function;
group: TaggedPointGroup;
pointerType: PointerType;
disabled?: boolean;
}

View File

@ -1,58 +1,77 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { every } from "lodash";
import { togglePointSelection } from ".";
import { CheckboxSelectionsProps, StringEqCriteria } from "./interfaces";
import {
togglePointTypeCriteria,
eqCriteriaSelected,
hasSubCriteria,
typeDisabled,
PlantCriteria,
PointCriteria,
ToolCriteria,
} from ".";
import {
CheckboxSelectionsProps,
CheckboxSelectionsState,
PointerType,
} from "./interfaces";
import { Checkbox } from "../../../ui";
const CRITERIA_PRESETS = (): {
description: string, criteria: Record<string, string>
}[] => [
{
description: t("planted plants"),
criteria: {
"pointer_type": "Plant",
"plant_stage": "planted",
}
},
{
description: t("detected weeds"),
criteria: {
"meta.created_by": "plant-detection",
"meta.color": "red",
}
},
{
description: t("created points"),
criteria: {
"meta.created_by": "farm-designer",
"meta.type": "point",
}
},
{
description: t("created weeds"),
criteria: {
"meta.created_by": "farm-designer",
"meta.type": "weed",
}
},
const CRITERIA_POINT_TYPES =
(): { label: string, pointerType: PointerType }[] => [
{ label: t("Plants"), pointerType: "Plant" },
{ label: t("Points and Weeds"), pointerType: "GenericPointer" },
{ label: t("Slots"), pointerType: "ToolSlot" },
];
export const CheckboxSelections = (props: CheckboxSelectionsProps) => {
const toggle = togglePointSelection(props.group);
const stringCriteria = props.group.body.criteria?.string_eq;
const selected = criteriaSelected(stringCriteria);
return <div className={"criteria-checkbox-presets"}>
{CRITERIA_PRESETS().map((selector, index) =>
<div className="criteria-preset-checkbox" key={index}>
<input type="radio"
onChange={() => props.dispatch(toggle(selector.criteria))}
checked={selected(selector.criteria)} />
<p>{selector.description}</p>
</div>)}
</div>;
};
export class CheckboxSelections extends React.Component
<CheckboxSelectionsProps, Partial<CheckboxSelectionsState>> {
state: CheckboxSelectionsState = {
Plant: false, GenericPointer: false, ToolSlot: false
};
export const criteriaSelected = (stringCriteria: StringEqCriteria) =>
(selectionCriteria: Record<string, string>) =>
every(Object.entries(selectionCriteria).map(([key, value]) =>
stringCriteria?.[key]?.includes(value)));
toggleMore = (section: keyof CheckboxSelectionsState) => () =>
this.setState({ [section]: !this.state[section] });
render() {
const { group, dispatch, slugs } = this.props;
const { criteria } = group.body;
const selected = eqCriteriaSelected<string>(criteria);
return <div className={"point-type-checkboxes"}>
{CRITERIA_POINT_TYPES().map(({ label, pointerType }, index) => {
const typeSelected = selected("pointer_type", pointerType);
const partial = hasSubCriteria(criteria)(pointerType) && !typeSelected;
return <div className="point-type-section" key={index}>
<div className="point-type-checkbox"
onClick={this.toggleMore(pointerType)}>
<Checkbox
onChange={() =>
dispatch(togglePointTypeCriteria(group, pointerType))}
checked={typeSelected}
partial={partial}
title={t(label)}
disabled={typeDisabled(criteria, pointerType)}
onClick={e => e.stopPropagation()} />
<p>{label}</p>
<i className={
`fa fa-caret-${this.state[pointerType] ? "up" : "down"}`}
title={this.state[pointerType]
? t("hide additional criteria")
: t("show additional criteria")} />
</div>
{this.state.Plant && pointerType == "Plant" &&
<PlantCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} slugs={slugs} />}
{this.state.GenericPointer && pointerType == "GenericPointer" &&
<PointCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} />}
{this.state.ToolSlot && pointerType == "ToolSlot" &&
<ToolCriteria
disabled={!typeSelected && !partial}
group={group} dispatch={dispatch} />}
</div>;
})}
</div>;
}
}

View File

@ -0,0 +1,76 @@
import { isNumber, some } from "lodash";
import {
StringEqCriteria,
PointerType,
PointGroupCriteria,
StrAndNumCriteriaKeys,
POINTER_TYPES,
} from "./interfaces";
/** Check if equal criteria values exist. */
export const eqCriteriaSelected =
<T extends string | number>(criteria: PointGroupCriteria) =>
(key: string, value: T): boolean => {
if (typeof value == "string") {
return !!criteria.string_eq[key]?.includes(value);
}
if (typeof value == "number") {
return !!criteria.number_eq[key]?.includes(value);
}
return false;
};
/** Check if string equal criteria fields exist. */
const strCriteriaHasKey = (stringCriteria: StringEqCriteria) =>
(key: string) => (stringCriteria[key]?.length || 0) > 0;
/** Check if number criteria fields exist. */
const numCriteriaHasKey = (criteria: PointGroupCriteria) =>
(key: string) => (criteria.number_eq[key]?.length || 0) > 0
|| isNumber(criteria.number_lt[key])
|| isNumber(criteria.number_gt[key]);
/** Check if a string or number criteria field exists. */
export const criteriaHasKey = (
criteria: PointGroupCriteria,
categories: StrAndNumCriteriaKeys,
key: string,
) =>
some(categories.map(category => {
if (category == "string_eq") {
return strCriteriaHasKey(criteria.string_eq)(key);
} else {
return numCriteriaHasKey(criteria)(key);
}
}));
/** Check for point type specific sub criteria. */
export const hasSubCriteria = (criteria: PointGroupCriteria) =>
(pointerType: PointerType) => {
const selected = strCriteriaHasKey(criteria.string_eq);
const numSelected = numCriteriaHasKey(criteria);
switch (pointerType) {
case "GenericPointer":
return !!(
selected("meta.type")
|| selected("meta.color")
|| selected("meta.created_by")
|| numSelected("radius"));
case "Plant":
return !!(
selected("openfarm_slug")
|| selected("plant_stage"));
case "ToolSlot":
return !!(
numSelected("tool_id")
|| numSelected("pullout_direction")
|| numSelected("gantry_mounted"));
}
};
/** Check for criteria specific to other point types. */
export const typeDisabled =
(criteria: PointGroupCriteria, pointerType: PointerType): boolean =>
some(POINTER_TYPES
.filter(x => x != pointerType)
.map(hasSubCriteria(criteria)));

View File

@ -1,34 +1,35 @@
import * as React from "react";
import { cloneDeep, capitalize } from "lodash";
import { Row, Col, FBSelect, DropDownItem } from "../../../ui";
import {
AddEqCriteria, toggleEqCriteria, editCriteria, AddNumberCriteria,
POINTER_TYPE_DDI_LOOKUP, AddStringCriteria,
CRITERIA_TYPE_DDI_LOOKUP, toggleStringCriteria,
AddEqCriteria, editCriteria, AddNumberCriteria,
editGtLtCriteriaField,
removeEqCriteriaValue,
clearCriteriaField,
} from ".";
import {
EqCriteriaSelectionProps, NumberCriteriaProps,
CriteriaSelectionProps, LocationSelectionProps, GroupCriteriaProps,
AddCriteriaState,
DEFAULT_CRITERIA,
CriteriaSelectionProps, LocationSelectionProps,
NumberLtGtInputProps,
PointGroupCriteria,
} from "./interfaces";
import { t } from "../../../i18next_wrapper";
import { PointGroup } from "farmbot/dist/resources/api_resources";
import { PLANT_STAGE_DDI_LOOKUP } from "../../plants/edit_plant_status";
import { ToggleButton } from "../../../controls/toggle_button";
import { Actions } from "../../../constants";
/** Add and view string or number equal criteria. */
export class EqCriteriaSelection<T extends string | number>
extends React.Component<EqCriteriaSelectionProps<T>> {
render() {
const { criteriaField, criteriaKey, group, dispatch } = this.props;
const { eqCriteria, criteriaKey, group, dispatch } = this.props;
return <div className={`${this.props.type}-eq-criteria`}>
<AddEqCriteria<T> group={group} dispatch={dispatch}
type={this.props.type} criteriaField={criteriaField}
type={this.props.type} eqCriteria={eqCriteria}
criteriaKey={criteriaKey} />
{criteriaField && Object.entries(criteriaField)
{eqCriteria && Object.entries(eqCriteria)
.map(([key, values]: [string, T[]], keyIndex) =>
values && values.length > 0 &&
<div key={keyIndex}>
<label>{key}</label>
<code>{key}</code>
{values.map((value, valueIndex) =>
<Row key={"" + keyIndex + valueIndex}>
<Col xs={9}>
@ -39,13 +40,8 @@ export class EqCriteriaSelection<T extends string | number>
<Col xs={2}>
<button className="fb-button red"
title={t("remove criteria")}
onClick={() => {
const tempCriteriaField = cloneDeep(criteriaField);
toggleEqCriteria<T>(tempCriteriaField)(key, value);
dispatch(editCriteria(group, {
[criteriaKey]: tempCriteriaField
}));
}}>
onClick={() => dispatch(removeEqCriteriaValue(
group, eqCriteria, criteriaKey, key, value))}>
<i className="fa fa-minus" />
</button>
</Col>
@ -55,6 +51,7 @@ export class EqCriteriaSelection<T extends string | number>
}
}
/** Add and view > or < number criteria. */
export const NumberCriteriaSelection = (props: NumberCriteriaProps) => {
const criteriaField = props.criteria[props.criteriaKey];
return <div className={"number-gt-lt-criteria"}>
@ -70,21 +67,13 @@ export const NumberCriteriaSelection = (props: NumberCriteriaProps) => {
{props.criteriaKey == "number_gt" ? ">" : "<"}
</Col>
<Col xs={4}>
<input key={"" + keyIndex}
name="value"
disabled={true}
value={value} />
<p>{value}</p>
</Col>
<Col xs={2}>
<button className="fb-button red"
title={t("remove number criteria")}
onClick={() => {
const tempNumberCriteria = cloneDeep(criteriaField);
delete tempNumberCriteria[key];
props.dispatch(editCriteria(props.group, {
[props.criteriaKey]: tempNumberCriteria
}));
}}>
onClick={() => props.dispatch(clearCriteriaField(
props.group, [props.criteriaKey], key))}>
<i className="fa fa-minus" />
</button>
</Col>
@ -98,9 +87,10 @@ const DAY_OPERATOR_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
[">"]: { label: t("greater than"), value: ">" },
});
/** Edit and view day criteria. */
export const DaySelection = (props: CriteriaSelectionProps) => {
const { group, criteria, dispatch } = props;
const dayCriteria = criteria.day || cloneDeep(DEFAULT_CRITERIA.day);
const dayCriteria = criteria.day;
return <div className="day-criteria">
<label>{t("Age selection")}</label>
<Row>
@ -112,7 +102,7 @@ export const DaySelection = (props: CriteriaSelectionProps) => {
onChange={ddi => dispatch(editCriteria(group, {
day: {
days_ago: dayCriteria.days_ago,
op: ddi.value as PointGroup["criteria"]["day"]["op"]
op: ddi.value as PointGroupCriteria["day"]["op"]
}
}))} />
</Col>
@ -131,96 +121,64 @@ export const DaySelection = (props: CriteriaSelectionProps) => {
</div>;
};
export const LocationSelection = (props: LocationSelectionProps) => {
const { group, criteria, dispatch } = props;
const gtCriteria = criteria.number_gt || {};
const ltCriteria = criteria.number_lt || {};
return <div className="location-criteria">
<label>{t("Location selection")}</label>
{["x", "y"].map(axis =>
<Row key={axis}>
<Col xs={4}>
<input key={JSON.stringify(gtCriteria)}
type="number"
name={`${axis}-number-gt`}
defaultValue={gtCriteria[axis]}
onBlur={e => {
const tempGtCriteria = cloneDeep(gtCriteria);
tempGtCriteria[axis] = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { number_gt: tempGtCriteria }));
}} />
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={1}>
<label>{axis}</label>
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={4}>
<input key={JSON.stringify(ltCriteria)}
type="number"
name={`${axis}-number-lt`}
defaultValue={ltCriteria[axis]}
onBlur={e => {
const tempLtCriteria = cloneDeep(ltCriteria);
tempLtCriteria[axis] = parseInt(e.currentTarget.value);
dispatch(editCriteria(group, { number_lt: tempLtCriteria }));
}} />
</Col>
</Row>)}
</div>;
/** Edit number < and > criteria. */
export const NumberLtGtInput = (props: NumberLtGtInputProps) => {
const { group, dispatch, criteriaKey, pointerType } = props;
const gtCriteria = props.group.body.criteria.number_gt;
const ltCriteria = props.group.body.criteria.number_lt;
return <Row>
<Col xs={props.inputWidth || 4}>
<input key={JSON.stringify(gtCriteria)}
type="number"
name={`${criteriaKey}-number-gt`}
defaultValue={gtCriteria[criteriaKey]}
disabled={props.disabled}
onBlur={e => dispatch(editGtLtCriteriaField(
group, "number_gt", criteriaKey, pointerType)(e))} />
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={props.labelWidth || 1}>
<p>{criteriaKey}</p>
</Col>
<Col xs={1}>
<p>{"<"}</p>
</Col>
<Col xs={props.inputWidth || 4}>
<input key={JSON.stringify(ltCriteria)}
type="number"
name={`${criteriaKey}-number-lt`}
defaultValue={ltCriteria[criteriaKey]}
disabled={props.disabled}
onBlur={e => dispatch(editGtLtCriteriaField(
group, "number_lt", criteriaKey, pointerType)(e))} />
</Col>
</Row>;
};
export class AddCriteria
extends React.Component<GroupCriteriaProps, AddCriteriaState> {
labelLookup = (key: string, value: string) => {
switch (key) {
case "openfarm_slug":
return capitalize(value);
case "pointer_type":
return POINTER_TYPE_DDI_LOOKUP()[value].label;
case "plant_stage":
return PLANT_STAGE_DDI_LOOKUP()[value].label;
}
}
render() {
const { props } = this;
const stringCriteria = this.props.group.body.criteria?.string_eq || {};
const displayedCriteria = Object.entries(stringCriteria)
.filter(([key, _values]) =>
["openfarm_slug", "pointer_type", "plant_stage"].includes(key));
return <div className={"add-criteria"}>
<AddStringCriteria
group={props.group} dispatch={props.dispatch} slugs={props.slugs} />
{displayedCriteria.map(([key, values]) =>
values && values.map((value, index) =>
<div key={key + index} className={"criteria-string"}>
<Row>
<Col xs={5}>
<input value={CRITERIA_TYPE_DDI_LOOKUP()[key].label}
name="key"
disabled={true} />
</Col>
<Col xs={5}>
<input value={this.labelLookup(key, value)}
name="value"
disabled={true} />
</Col>
<Col xs={2}>
<button className="fb-button red"
title={t("remove criteria")}
onClick={() => props.dispatch(
toggleStringCriteria(props.group, key, value))}>
<i className="fa fa-minus" />
</button>
</Col>
</Row>
</div>))}
</div>;
}
}
/** Form inputs to define a 2D group criteria area. */
export const LocationSelection = (props: LocationSelectionProps) =>
<div className="location-criteria">
<label>{t("Location selection")}</label>
{["x", "y"].map((axis: "x" | "y") =>
<NumberLtGtInput
key={axis}
criteriaKey={axis}
group={props.group}
dispatch={props.dispatch} />)}
<div className={"edit-in-map"}>
<ToggleButton
title={props.editGroupAreaInMap
? t("map boxes will change location criteria")
: t("map boxes will manually add plants")}
customText={{ textFalse: t("off"), textTrue: t("on") }}
toggleValue={props.editGroupAreaInMap}
toggleAction={() =>
props.dispatch({
type: Actions.EDIT_GROUP_AREA_IN_MAP,
payload: !props.editGroupAreaInMap
})} />
<label>{t("edit in map")}</label>
</div>
</div>;

View File

@ -0,0 +1,230 @@
import * as React from "react";
import { t } from "../../../i18next_wrapper";
import { capitalize, uniq } from "lodash";
import {
NumberLtGtInput,
toggleAndEditEqCriteria,
clearCriteriaField,
eqCriteriaSelected,
criteriaHasKey,
} from ".";
import {
CheckboxListProps,
SubCriteriaProps,
PlantSubCriteriaProps,
ClearCategoryProps,
} from "./interfaces";
import { PLANT_STAGE_LIST } from "../../plants/edit_plant_status";
import { DIRECTION_CHOICES } from "../../tools/tool_slot_edit_components";
import { Checkbox } from "../../../ui";
/** "All" (any) checkbox to show or choose state of criteria subcategory. */
const ClearCategory = (props: ClearCategoryProps) => {
const { group, criteriaCategories, criteriaKey, dispatch } = props;
const all =
!criteriaHasKey(group.body.criteria, criteriaCategories, criteriaKey);
return <div className="criteria-checkbox-list-item">
<Checkbox
onChange={() =>
dispatch(clearCriteriaField(group, criteriaCategories, criteriaKey))}
checked={all}
disabled={all}
title={t("clear selections")}
customDisabledText={t("selections empty")} />
<p>{t("all")}</p>
</div>;
};
/** List of criteria toggle checkboxes. */
export const CheckboxList =
<T extends string | number>(props: CheckboxListProps<T>) => {
const { criteria } = props.group.body;
const selected = eqCriteriaSelected<T>(criteria);
const toggle = toggleAndEditEqCriteria;
return <div className={"criteria-checkbox-list"}>
{props.list.map(({ label, value }: { label: string, value: T }, index) =>
<div className="criteria-checkbox-list-item" key={index}>
<Checkbox
onChange={() => props.dispatch(toggle<T>(
props.group, props.criteriaKey, value, props.pointerType))}
checked={selected(props.criteriaKey, value)}
title={t(label)}
disabled={props.disabled} />
<p>{label}</p>
</div>)}
</div>;
};
/** Criteria specific to plants. */
export const PlantCriteria = (props: PlantSubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const commonProps = { group, dispatch, disabled };
return <div className={"plant-criteria-options"}>
<PlantStage {...commonProps} />
<PlantType {...commonProps} slugs={props.slugs} />
</div>;
};
const PlantStage = (props: SubCriteriaProps) =>
<div className={"plant-stage-criteria"}>
<p className={"category"}>{t("Stage")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"plant_stage"}
dispatch={props.dispatch} />
<CheckboxList<string>
disabled={props.disabled}
pointerType={"Plant"}
criteriaKey={"plant_stage"}
group={props.group}
dispatch={props.dispatch}
list={PLANT_STAGE_LIST().map(ddi =>
({ label: ddi.label, value: "" + ddi.value }))} />
</div>;
const PlantType = (props: PlantSubCriteriaProps) =>
<div className={"plant-type-criteria"}>
<p className={"category"}>{t("Type")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"openfarm_slug"}
dispatch={props.dispatch} />
<CheckboxList<string>
disabled={props.disabled}
pointerType={"Plant"}
criteriaKey={"openfarm_slug"}
group={props.group}
dispatch={props.dispatch}
list={uniq(props.slugs
.concat(props.group.body.criteria.string_eq.openfarm_slug || []))
.map(slug =>
({ label: capitalize(slug).replace("-", " "), value: slug }))} />
</div>;
/** Criteria specific to map points. */
export const PointCriteria = (props: SubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const commonProps = { group, dispatch, disabled };
return <div className={"point-criteria-options"}>
<PointType {...commonProps} />
<PointSource {...commonProps} />
<Color {...commonProps} />
<Radius {...commonProps} />
</div>;
};
const PointType = (props: SubCriteriaProps) =>
<div className={"point-type-criteria"}>
<p className={"category"}>{t("Type")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.type"}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
criteriaKey={"meta.type"}
group={props.group}
dispatch={props.dispatch}
list={[
{ label: t("Weeds"), value: "weed" },
{ label: t("Points"), value: "point" },
]} />
</div>;
const PointSource = (props: SubCriteriaProps) =>
<div className={"point-source-criteria"}>
<p className={"category"}>{t("Source")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.created_by"}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
criteriaKey={"meta.created_by"}
group={props.group}
dispatch={props.dispatch}
list={[
{ label: t("Weed Detector"), value: "plant-detection" },
{ label: t("Farm Designer"), value: "farm-designer" },
]} />
</div>;
const Radius = (props: SubCriteriaProps) =>
<div className={"radius-criteria"}>
<p className={"category"}>{t("Radius")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["number_gt", "number_lt"]}
criteriaKey={"radius"}
dispatch={props.dispatch} />
<div className={"lt-gt-criteria"}>
<NumberLtGtInput
disabled={props.disabled}
criteriaKey={"radius"}
inputWidth={3}
labelWidth={2}
group={props.group}
pointerType={"GenericPointer"}
dispatch={props.dispatch} />
</div>
</div>;
const Color = (props: SubCriteriaProps) =>
<div className={"color-criteria"}>
<p className={"category"}>{t("Color")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["string_eq"]}
criteriaKey={"meta.color"}
dispatch={props.dispatch} />
<CheckboxList
disabled={props.disabled}
pointerType={"GenericPointer"}
criteriaKey={"meta.color"}
group={props.group}
dispatch={props.dispatch}
list={[
{ label: t("Green"), value: "green" },
{ label: t("Red"), value: "red" },
{ label: t("Cyan"), value: "cyan" },
{ label: t("Blue"), value: "blue" },
{ label: t("Yellow"), value: "yellow" },
{ label: t("Orange"), value: "orange" },
{ label: t("Purple"), value: "purple" },
{ label: t("Pink"), value: "pink" },
{ label: t("Gray"), value: "gray" },
]} />
</div>;
/** Criteria specific to tools. */
export const ToolCriteria = (props: SubCriteriaProps) => {
const { group, dispatch, disabled } = props;
const commonProps = { group, dispatch, disabled };
return <div className={"tool-criteria-options"}>
<PulloutDirection {...commonProps} />
</div>;
};
const PulloutDirection = (props: SubCriteriaProps) =>
<div className={"pullout-direction-criteria"}>
<p className={"category"}>{t("Direction")}</p>
<ClearCategory
group={props.group}
criteriaCategories={["number_eq"]}
criteriaKey={"pullout_direction"}
dispatch={props.dispatch} />
<CheckboxList<number>
disabled={props.disabled}
pointerType={"ToolSlot"}
criteriaKey={"pullout_direction"}
group={props.group}
dispatch={props.dispatch}
list={DIRECTION_CHOICES().map(ddi =>
({ label: ddi.label, value: parseInt("" + ddi.value) }))} />
</div>;

View File

@ -24,6 +24,7 @@ interface GroupDetailProps {
shouldDisplay: ShouldDisplay;
slugs: string[];
hovered: UUID | undefined;
editGroupAreaInMap: boolean;
}
/** Find a group from a URL-provided ID. */
@ -35,6 +36,8 @@ export const findGroupFromUrl = (groups: TaggedPointGroup[]) => {
};
function mapStateToProps(props: Everything): GroupDetailProps {
const { hoveredPlantListItem, editGroupAreaInMap } =
props.resources.consumers.farm_designer;
return {
allPoints: selectAllActivePoints(props.resources.index),
group: findGroupFromUrl(selectAllPointGroups(props.resources.index)),
@ -42,7 +45,8 @@ function mapStateToProps(props: Everything): GroupDetailProps {
shouldDisplay: getShouldDisplayFn(props.resources.index, props.bot),
slugs: uniq(selectAllPlantPointers(props.resources.index)
.map(p => p.body.openfarm_slug)),
hovered: props.resources.consumers.farm_designer.hoveredPlantListItem,
hovered: hoveredPlantListItem,
editGroupAreaInMap,
};
}

View File

@ -14,6 +14,7 @@ import {
} from "./criteria";
import { Content } from "../../constants";
import { UUID } from "../../resources/interfaces";
import { Help } from "../../ui";
export interface GroupDetailActiveProps {
dispatch: Function;
@ -22,13 +23,17 @@ export interface GroupDetailActiveProps {
shouldDisplay: ShouldDisplay;
slugs: string[];
hovered: UUID | undefined;
editGroupAreaInMap: boolean;
}
type State = { timerId?: ReturnType<typeof setInterval> };
interface GroupDetailActiveState {
timerId?: ReturnType<typeof setInterval>;
iconDisplay: boolean;
}
export class GroupDetailActive
extends React.Component<GroupDetailActiveProps, State> {
state: State = {};
extends React.Component<GroupDetailActiveProps, GroupDetailActiveState> {
state: GroupDetailActiveState = { iconDisplay: true };
update = ({ currentTarget }: React.SyntheticEvent<HTMLInputElement>) => {
this.props.dispatch(edit(this.props.group, { name: currentTarget.value }));
@ -76,6 +81,8 @@ export class GroupDetailActive
(typeof timerId == "number") && clearInterval(timerId);
}
toggleIconShow = () => this.setState({ iconDisplay: !this.state.iconDisplay });
render() {
const { group, dispatch } = this.props;
return <ErrorBoundary>
@ -86,37 +93,18 @@ export class GroupDetailActive
defaultValue={group.body.name}
onChange={this.update}
onBlur={this.saveGroup} />
<div className={"group-sort-section"}>
<label>
{t("SORT BY")}
</label>
<Paths
key={JSON.stringify(this.pointsSelectedByGroup
.map(p => p.body.id))}
pathPoints={this.pointsSelectedByGroup}
dispatch={dispatch}
group={group} />
<p>
{group.body.sort_type == "random" && t(Content.SORT_DESCRIPTION)}
</p>
</div>
<label>
{t("GROUP MEMBERS ({{count}})", { count: this.icons.length })}
</label>
{this.props.shouldDisplay(Feature.criteria_groups) &&
<GroupPointCountBreakdown
manualCount={group.body.point_ids.length}
totalCount={this.pointsSelectedByGroup.length} />}
<p>{t("Click plants in map to add or remove.")}</p>
{this.props.shouldDisplay(Feature.criteria_groups) &&
this.pointsSelectedByGroup.length != group.body.point_ids.length &&
<p>{t(Content.CRITERIA_SELECTION_COUNT)}</p>}
<div className="groups-list-wrapper">
{this.icons}
</div>
<GroupSortSelection group={group} dispatch={dispatch}
pointsSelectedByGroup={this.pointsSelectedByGroup} />
<GroupMemberDisplay group={group} dispatch={dispatch}
pointsSelectedByGroup={this.pointsSelectedByGroup}
icons={this.icons}
iconDisplay={this.state.iconDisplay}
toggleIconShow={this.toggleIconShow}
shouldDisplay={this.props.shouldDisplay} />
{this.props.shouldDisplay(Feature.criteria_groups) &&
<GroupCriteria dispatch={dispatch}
group={group} slugs={this.props.slugs} />}
group={group} slugs={this.props.slugs}
editGroupAreaInMap={this.props.editGroupAreaInMap} />}
<DeleteButton
className="group-delete-btn"
dispatch={dispatch}
@ -127,3 +115,62 @@ export class GroupDetailActive
</ErrorBoundary>;
}
}
interface GroupSortSelectionProps {
group: TaggedPointGroup;
dispatch: Function;
pointsSelectedByGroup: TaggedPoint[];
}
/** Choose and view group point sort method. */
const GroupSortSelection = (props: GroupSortSelectionProps) =>
<div className={"group-sort-section"}>
<label>
{t("SORT BY")}
</label>
{props.group.body.sort_type == "random" &&
<Help
text={Content.SORT_DESCRIPTION}
customIcon={"exclamation-triangle"} />}
<Paths
key={JSON.stringify(props.pointsSelectedByGroup
.map(p => p.body.id))}
pathPoints={props.pointsSelectedByGroup}
dispatch={props.dispatch}
group={props.group} />
</div>;
interface GroupMemberDisplayProps {
group: TaggedPointGroup;
dispatch: Function;
pointsSelectedByGroup: TaggedPoint[];
shouldDisplay: ShouldDisplay;
icons: JSX.Element[];
iconDisplay: boolean;
toggleIconShow(): void;
}
/** View group point counts and icon list. */
const GroupMemberDisplay = (props: GroupMemberDisplayProps) =>
<div className="group-member-display">
<label>
{t("GROUP MEMBERS ({{count}})", { count: props.icons.length })}
</label>
<Help text={`${t("Click plants in map to add or remove.")} ${(
props.shouldDisplay(Feature.criteria_groups) &&
props.pointsSelectedByGroup.length != props.group.body.point_ids.length)
? t(Content.CRITERIA_SELECTION_COUNT) : ""}`} />
<i onClick={props.toggleIconShow}
className={`fa fa-caret-${props.iconDisplay ? "up" : "down"}`}
title={props.iconDisplay
? t("hide icons")
: t("show icons")} />
{props.shouldDisplay(Feature.criteria_groups) &&
<GroupPointCountBreakdown
manualCount={props.group.body.point_ids.length}
totalCount={props.pointsSelectedByGroup.length} />}
{props.iconDisplay &&
<div className="groups-list-wrapper">
{props.icons}
</div>}
</div>;

View File

@ -23,6 +23,7 @@ export const initialState: DesignerState = {
currentPoint: undefined,
openedSavedGarden: undefined,
tryGroupSortType: undefined,
editGroupAreaInMap: false,
};
export const designer = generateReducer<DesignerState>(initialState)
@ -89,4 +90,8 @@ export const designer = generateReducer<DesignerState>(initialState)
.add<PointGroupSortType | undefined>(Actions.TRY_SORT_TYPE, (s, { payload }) => {
s.tryGroupSortType = payload;
return s;
})
.add<boolean>(Actions.EDIT_GROUP_AREA_IN_MAP, (s, { payload }) => {
s.editGroupAreaInMap = payload;
return s;
});

View File

@ -77,7 +77,7 @@ export class RawTools extends React.Component<ToolsProps, ToolsState> {
<div className="mounted-tool">
<div className="mounted-tool-header">
<label>{t("mounted tool")}</label>
<Help text={Content.MOUNTED_TOOL} requireClick={true} />
<Help text={Content.MOUNTED_TOOL} />
</div>
<ToolSelection
tools={this.props.tools}

View File

@ -41,8 +41,8 @@ export const SlotDirectionInputRow = (props: SlotDirectionInputRowProps) =>
})} />
<FBSelect
key={props.toolPulloutDirection}
list={DIRECTION_CHOICES}
selectedItem={DIRECTION_CHOICES_DDI[props.toolPulloutDirection]}
list={DIRECTION_CHOICES()}
selectedItem={DIRECTION_CHOICES_DDI()[props.toolPulloutDirection]}
onChange={ddi => props.onChange({
pullout_direction: parseInt("" + ddi.value)
})} />
@ -209,7 +209,7 @@ export const newSlotDirection =
export const positionIsDefined = (position: BotPosition): boolean =>
isNumber(position.x) && isNumber(position.y) && isNumber(position.z);
export const DIRECTION_CHOICES_DDI: { [index: number]: DropDownItem } = {
export const DIRECTION_CHOICES_DDI = (): { [index: number]: DropDownItem } => ({
[ToolPulloutDirection.NONE]:
{ label: t("None"), value: ToolPulloutDirection.NONE },
[ToolPulloutDirection.POSITIVE_X]:
@ -220,12 +220,12 @@ export const DIRECTION_CHOICES_DDI: { [index: number]: DropDownItem } = {
{ label: t("Positive Y"), value: ToolPulloutDirection.POSITIVE_Y },
[ToolPulloutDirection.NEGATIVE_Y]:
{ label: t("Negative Y"), value: ToolPulloutDirection.NEGATIVE_Y },
};
});
export const DIRECTION_CHOICES: DropDownItem[] = [
DIRECTION_CHOICES_DDI[ToolPulloutDirection.NONE],
DIRECTION_CHOICES_DDI[ToolPulloutDirection.POSITIVE_X],
DIRECTION_CHOICES_DDI[ToolPulloutDirection.NEGATIVE_X],
DIRECTION_CHOICES_DDI[ToolPulloutDirection.POSITIVE_Y],
DIRECTION_CHOICES_DDI[ToolPulloutDirection.NEGATIVE_Y],
export const DIRECTION_CHOICES = (): DropDownItem[] => [
DIRECTION_CHOICES_DDI()[ToolPulloutDirection.NONE],
DIRECTION_CHOICES_DDI()[ToolPulloutDirection.POSITIVE_X],
DIRECTION_CHOICES_DDI()[ToolPulloutDirection.NEGATIVE_X],
DIRECTION_CHOICES_DDI()[ToolPulloutDirection.POSITIVE_Y],
DIRECTION_CHOICES_DDI()[ToolPulloutDirection.NEGATIVE_Y],
];

View File

@ -53,7 +53,8 @@ export class RawEditZone extends React.Component<EditZoneProps, {}> {
<LocationSelection
group={zone}
criteria={zone.body.criteria}
dispatch={this.props.dispatch} />
dispatch={this.props.dispatch}
editGroupAreaInMap={true} />
</div>
: <span>{t("Redirecting")}...</span>}
</DesignerPanelContent>

View File

@ -68,7 +68,7 @@ const LogSetting = (props: LogSettingProps) => {
<label>
{t(label)}
</label>
<Help text={t(toolTip)} position={Position.LEFT_TOP} requireClick={true} />
<Help text={t(toolTip)} position={Position.LEFT_TOP} />
<ToggleButton
toggleValue={config.value}
dim={!config.consistent}

View File

@ -77,7 +77,7 @@ export const SequenceSetting = (props: SequenceSettingProps) => {
<label>
{t(props.label)}
</label>
<Help text={t(props.description)} requireClick={true} />
<Help text={t(props.description)} />
<ToggleButton
toggleValue={value}
toggleAction={() => proceed() &&

View File

@ -41,6 +41,6 @@ export function StepIconGroup(props: StepIconBarProps) {
<StepUpDownButtonPopover onMove={onMove} />
<i className="fa fa-clone step-control" onClick={onClone} />
<i className="fa fa-trash step-control" onClick={onTrash} />
<Help text={helpText} requireClick={true} position={Position.TOP} />
<Help text={helpText} position={Position.TOP} />
</span>;
}

View File

@ -0,0 +1,25 @@
import * as React from "react";
import { t } from "../i18next_wrapper";
interface CheckboxProps {
onChange(): void;
checked: boolean;
title: string;
disabled?: boolean;
partial?: boolean;
onClick?: (e: React.FormEvent) => void;
customDisabledText?: string;
}
export const Checkbox = (props: CheckboxProps) =>
<div className={["fb-checkbox",
props.partial ? "partial" : "",
props.disabled ? "disabled" : "",
].join(" ")}
title={props.disabled ? props.customDisabledText ?? t("incompatible") : ""}
onClick={props.onClick}>
<input type="checkbox"
title={props.title}
onChange={props.onChange}
checked={props.checked} />
</div>;

View File

@ -1,19 +1,22 @@
import * as React from "react";
import { Popover, PopoverInteractionKind, PopoverPosition } from "@blueprintjs/core";
import {
Popover, PopoverInteractionKind, PopoverPosition, Position,
} from "@blueprintjs/core";
import { t } from "../i18next_wrapper";
interface HelpProps {
text: string;
requireClick?: boolean;
onHover?: boolean;
position?: PopoverPosition;
customIcon?: string;
}
export function Help(props: HelpProps) {
return <Popover
position={props.position}
interactionKind={props.requireClick
? PopoverInteractionKind.CLICK : PopoverInteractionKind.HOVER}
position={props.position || Position.TOP_RIGHT}
interactionKind={props.onHover
? PopoverInteractionKind.HOVER
: PopoverInteractionKind.CLICK}
popoverClassName={"help"}>
<i className={`fa fa-${props.customIcon || "question-circle"} help-icon`} />
<div className={"help-text-content"}>{t(props.text)}</div>

View File

@ -1,6 +1,7 @@
export * from "./back_arrow";
export * from "./blurable_input";
export * from "./center_panel";
export * from "./checkbox";
export * from "./color_picker";
export * from "./colors";
export * from "./column";