FarmBot/Farmbot-Web-App

View on GitHub
frontend/points/point_inventory.tsx

Summary

Maintainability
F
3 days
Test Coverage
import React from "react";
import { connect } from "react-redux";
import { PointInventoryItem } from "./point_inventory_item";
import { Everything, PointsPanelState } from "../interfaces";
import { Panel } from "../farm_designer/panel_header";
import {
  EmptyStateWrapper, EmptyStateGraphic,
} from "../ui/empty_state_wrapper";
import { Actions, Content } from "../constants";
import {
  DesignerPanel, DesignerPanelContent, DesignerPanelTop,
} from "../farm_designer/designer_panel";
import {
  selectAllActivePoints, selectAllGenericPointers, selectAllPointGroups,
} from "../resources/selectors";
import { TaggedGenericPointer, TaggedPoint, TaggedPointGroup } from "farmbot";
import { t } from "../i18next_wrapper";
import { SearchField } from "../ui/search_field";
import {
  SortOptions, PointSortMenu, orderedPoints,
} from "../farm_designer/sort_options";
import { compact, isUndefined, mean, round, sortBy, uniq } from "lodash";
import { Collapse } from "@blueprintjs/core";
import { UUID } from "../resources/interfaces";
import { deletePoints } from "../api/delete_points";
import {
  EditSoilHeight,
  getSoilHeightColor, soilHeightColorQuery, soilHeightPoint, soilHeightQuery,
} from "./soil_height";
import { SourceFbosConfig } from "../devices/interfaces";
import { validFbosConfig } from "../util";
import { getFbosConfig } from "../resources/getters";
import { sourceFbosConfigValue } from "../settings/source_config_value";
import { Saucer, ToggleButton } from "../ui";
import { PanelSection } from "../plants/plant_inventory";
import { DEFAULT_CRITERIA } from "../point_groups/criteria/interfaces";
import { GroupInventoryItem } from "../point_groups/group_inventory_item";
import { createGroup } from "../point_groups/actions";
import { pointGroupSubset } from "../plants/select_plants";
import { Path } from "../internal_urls";
import { deleteAllIds } from "../api/delete_points_handler";
import { NavigationContext } from "../routes_helpers";

interface PointsSectionProps {
  title: string;
  color?: string;
  isOpen: boolean;
  toggleOpen(): void;
  toggleValue?: boolean;
  toggleAction?(): void;
  genericPoints: TaggedGenericPointer[];
  hoveredPoint: UUID | undefined;
  dispatch: Function;
  metaQuery: Record<string, string>;
  getColorOverride?(z: number): string;
  averageZ?: number;
  sourceFbosConfig?: SourceFbosConfig;
}

const PointsSection = (props: PointsSectionProps) => {
  const { genericPoints, isOpen, dispatch, averageZ, toggleAction } = props;
  return <div className={`points-section ${isOpen ? "open" : ""}`}>
    <div className={"points-section-header row grid-exp-1"} onClick={props.toggleOpen}>
      {props.color && <Saucer color={props.color} />}
      <label>{`${props.title} (${genericPoints.length})`}</label>
      {isOpen && toggleAction && <ToggleButton
        toggleValue={props.toggleValue}
        customText={{ textFalse: t("off"), textTrue: t("on") }}
        toggleAction={e => { e.stopPropagation(); toggleAction(); }} />}
      {isOpen && <button className={"fb-button red delete"}
        title={t("delete all")}
        onClick={e => {
          e.stopPropagation();
          confirm(t("Delete all {{ count }} points in section?",
            { count: genericPoints.length })) &&
            dispatch(deletePoints("points", { meta: props.metaQuery }));
        }}>
        {t("delete all")}
      </button>}
      <i className={`fa fa-caret-${isOpen ? "up" : "down"}`} />
    </div>
    <Collapse isOpen={isOpen}>
      {!isUndefined(averageZ) &&
        <EditSoilHeight
          sourceFbosConfig={props.sourceFbosConfig}
          averageZ={averageZ}
          dispatch={dispatch} />}
      {genericPoints.map(p => <PointInventoryItem
        key={p.uuid}
        tpp={p}
        colorOverride={props.getColorOverride?.(p.body.z)}
        hovered={props.hoveredPoint === p.uuid}
        dispatch={dispatch} />)}
    </Collapse>
  </div>;
};

export interface PointsProps {
  genericPoints: TaggedGenericPointer[];
  dispatch: Function;
  hoveredPoint: string | undefined;
  gridIds: string[];
  soilHeightLabels: boolean;
  sourceFbosConfig: SourceFbosConfig;
  groups: TaggedPointGroup[];
  allPoints: TaggedPoint[];
  pointsPanelState: PointsPanelState;
}

interface PointsState extends SortOptions {
  searchTerm: string;
  gridIds: string[];
  soilHeightColors: string[];
}

export function mapStateToProps(props: Everything): PointsProps {
  const { hoveredPoint, gridIds, soilHeightLabels,
  } = props.resources.consumers.farm_designer;
  const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index));
  const { hardware } = props.bot;
  return {
    genericPoints: selectAllGenericPointers(props.resources.index)
      .filter(x => x),
    dispatch: props.dispatch,
    hoveredPoint,
    gridIds,
    soilHeightLabels,
    sourceFbosConfig: sourceFbosConfigValue(fbosConfig, hardware.configuration),
    groups: selectAllPointGroups(props.resources.index),
    allPoints: selectAllActivePoints(props.resources.index),
    pointsPanelState: props.app.pointsPanelState,
  };
}

export class RawPoints extends React.Component<PointsProps, PointsState> {
  state: PointsState = {
    searchTerm: "", gridIds: [], soilHeightColors: [],
  };

  toggleGrid = (gridId: string) => () =>
    this.setState({
      gridIds: this.state.gridIds.includes(gridId)
        ? this.state.gridIds.filter(id => id != gridId)
        : this.state.gridIds.concat(gridId)
    });

  toggleSoilHeightPointColor = (color: string) => () =>
    this.setState({
      soilHeightColors: this.state.soilHeightColors.includes(color)
        ? this.state.soilHeightColors.filter(c => c != color)
        : this.state.soilHeightColors.concat(color)
    });

  toggleOpen = (section: keyof PointsPanelState) => () =>
    this.props.dispatch({
      type: Actions.TOGGLE_POINTS_PANEL_OPTION, payload: section,
    });

  static contextType = NavigationContext;
  context!: React.ContextType<typeof NavigationContext>;
  navigate = this.context;

  navigateById = (id: number | undefined) => () => this.navigate(Path.groups(id));

  render() {
    const { dispatch } = this.props;
    const gridIds = compact(uniq(this.props.genericPoints
      .map(p => p.body.meta.gridId)));
    const points = orderedPoints(this.props.genericPoints, this.state)
      .filter(p => p.body.name.toLowerCase()
        .includes(this.state.searchTerm.toLowerCase()));
    const soilHeightPoints = points.filter(soilHeightPoint);
    const sortedSoilHeightPoints = this.state.sortBy
      ? soilHeightPoints
      : sortBy(soilHeightPoints, "body.z").reverse();
    const soilHeightPointColors = compact(uniq(sortedSoilHeightPoints.map(p =>
      p.body.meta.color)));
    const pointGroups = pointGroupSubset(this.props.groups, "GenericPointer");
    const filteredGroups = pointGroups
      .filter(p => p.body.name.toLowerCase()
        .includes(this.state.searchTerm.toLowerCase()));
    const standardPoints = points
      .filter(p => !soilHeightPoint(p))
      .filter(p => !p.body.meta.gridId);
    return <DesignerPanel panelName={"point-inventory"} panel={Panel.Points}>
      <DesignerPanelTop panel={Panel.Points}>
        <SearchField nameKey={"points"}
          searchTerm={this.state.searchTerm}
          placeholder={t("Search your points...")}
          customLeftIcon={<PointSortMenu
            sortOptions={this.state} onChange={u => this.setState(u)} />}
          onChange={searchTerm => this.setState({ searchTerm })} />
      </DesignerPanelTop>
      <DesignerPanelContent panelName={"points"}>
        <PanelSection isOpen={this.props.pointsPanelState.groups}
          panel={Panel.Points}
          toggleOpen={this.toggleOpen("groups")}
          itemCount={pointGroups.length}
          addNew={() => dispatch(createGroup({
            criteria: {
              ...DEFAULT_CRITERIA,
              string_eq: { pointer_type: ["GenericPointer"] },
            },
          }))}
          addTitle={t("add new group")}
          addClassName={"plus-group"}
          title={t("Point Groups")}>
          {filteredGroups
            .map(group => <GroupInventoryItem
              key={group.uuid}
              group={group}
              allPoints={this.props.allPoints}
              hovered={false}
              dispatch={dispatch}
              onClick={this.navigateById(group.body.id)}
            />)}
        </PanelSection>
        <PanelSection isOpen={this.props.pointsPanelState.points}
          panel={Panel.Points}
          toggleOpen={this.toggleOpen("points")}
          itemCount={standardPoints.length}
          addNew={() => this.navigate(Path.points("add"))}
          addTitle={t("add point")}
          addClassName={"plus-point"}
          extraHeaderContent={this.props.pointsPanelState.points &&
            standardPoints.length > 0 &&
            <button className={"fb-button red delete"}
              title={t("delete all")}
              onClick={deleteAllIds("points", standardPoints)}>
              {t("delete all")}
            </button>}
          title={t("Points")}>
          <EmptyStateWrapper
            notEmpty={this.props.genericPoints.length > 0}
            graphic={EmptyStateGraphic.points}
            title={t("No points yet.")}
            text={Content.NO_POINTS}
            colorScheme={"points"}>
            {standardPoints.map(p =>
              <PointInventoryItem
                key={p.uuid}
                tpp={p}
                hovered={this.props.hoveredPoint === p.uuid}
                dispatch={dispatch} />)}
          </EmptyStateWrapper>
        </PanelSection>
        {sortedSoilHeightPoints.length > 0 &&
          <PointsSection
            title={soilHeightPointColors.length > 1
              ? t("All Soil Height")
              : t("Soil Height")}
            isOpen={this.props.pointsPanelState.soilHeight}
            toggleOpen={this.toggleOpen("soilHeight")}
            toggleValue={this.props.soilHeightLabels}
            toggleAction={() => dispatch({
              type: Actions.TOGGLE_SOIL_HEIGHT_LABELS, payload: undefined
            })}
            genericPoints={sortedSoilHeightPoints}
            metaQuery={soilHeightQuery}
            getColorOverride={getSoilHeightColor(sortedSoilHeightPoints)}
            averageZ={round(mean(sortedSoilHeightPoints.map(p => p.body.z)))}
            sourceFbosConfig={this.props.sourceFbosConfig}
            hoveredPoint={this.props.hoveredPoint}
            dispatch={dispatch} />}
        {soilHeightPointColors.length > 1 &&
          soilHeightPointColors.map(color =>
            <PointsSection key={color}
              title={t("Soil Height")}
              color={color}
              isOpen={this.state.soilHeightColors.includes(color)}
              toggleOpen={this.toggleSoilHeightPointColor(color)}
              genericPoints={sortedSoilHeightPoints
                .filter(p => p.body.meta.color == color)}
              metaQuery={soilHeightColorQuery(color)}
              getColorOverride={getSoilHeightColor(sortedSoilHeightPoints)}
              averageZ={round(mean(sortedSoilHeightPoints
                .filter(p => p.body.meta.color == color).map(p => p.body.z)))}
              hoveredPoint={this.props.hoveredPoint}
              dispatch={dispatch} />)}
        {gridIds.map(gridId => {
          const gridPoints = points.filter(p => p.body.meta.gridId == gridId);
          const pointName = gridPoints[0].body.name;
          return <PointsSection
            key={gridId}
            title={t("{{ name }} Grid", { name: pointName })}
            isOpen={this.state.gridIds.includes(gridId)}
            toggleOpen={this.toggleGrid(gridId)}
            toggleValue={!this.props.gridIds.includes(gridId)}
            toggleAction={() => dispatch({
              type: Actions.TOGGLE_GRID_ID, payload: gridId
            })}
            genericPoints={gridPoints}
            metaQuery={{ gridId }}
            hoveredPoint={this.props.hoveredPoint}
            dispatch={dispatch} />;
        })}
      </DesignerPanelContent>
    </DesignerPanel>;
  }
}

export const Points = connect(mapStateToProps)(RawPoints);
// eslint-disable-next-line import/no-default-export
export default Points;