FarmBot/Farmbot-Web-App

View on GitHub
frontend/plants/plant_inventory.tsx

Summary

Maintainability
D
2 days
Test Coverage
import React from "react";
import { connect } from "react-redux";
import { PlantInventoryItem } from "./plant_inventory_item";
import { Everything, PlantsPanelState } from "../interfaces";
import { Panel } from "../farm_designer/panel_header";
import { getPlants } from "../farm_designer/state_to_props";
import { TaggedPlant } from "../farm_designer/map/interfaces";
import {
  EmptyStateWrapper, EmptyStateGraphic,
} from "../ui/empty_state_wrapper";
import { Actions, Content, DeviceSetting } from "../constants";
import {
  DesignerPanel, DesignerPanelContent, DesignerPanelTop,
} from "../farm_designer/designer_panel";
import { t } from "../i18next_wrapper";
import { SearchField } from "../ui/search_field";
import {
  selectAllActivePoints,
  selectAllPlantPointers,
  selectAllPlantTemplates,
  selectAllPointGroups, selectAllSavedGardens,
} from "../resources/selectors";
import {
  TaggedSavedGarden, TaggedPointGroup, TaggedPoint, TaggedPlantTemplate,
} from "farmbot";
import { GroupInventoryItem } from "../point_groups/group_inventory_item";
import { SavedGardenList } from "../saved_gardens/garden_list";
import { pointGroupSubset, uncategorizedGroupSubset } from "./select_plants";
import { Collapse, Position } from "@blueprintjs/core";
import { createGroup } from "../point_groups/actions";
import { DEFAULT_CRITERIA } from "../point_groups/criteria/interfaces";
import { deletePoints } from "../api/delete_points";
import { Path } from "../internal_urls";
import { WebAppNumberSetting } from "../settings/farm_designer_settings";
import { NumericSetting } from "../session_keys";
import { Help, Popover, Row } from "../ui";
import {
  GetWebAppConfigValue, getWebAppConfigValue,
} from "../config_storage/actions";
import { NavigationContext } from "../routes_helpers";

export interface PlantInventoryProps {
  plants: TaggedPlant[];
  dispatch: Function;
  hoveredPlantListItem: string | undefined;
  savedGardens: TaggedSavedGarden[];
  groups: TaggedPointGroup[];
  allPoints: TaggedPoint[];
  plantTemplates: TaggedPlantTemplate[];
  plantPointerCount: number;
  openedSavedGarden: number | undefined;
  plantsPanelState: PlantsPanelState;
  getConfigValue: GetWebAppConfigValue;
}

interface PlantInventoryState {
  searchTerm: string;
}

export function mapStateToProps(props: Everything): PlantInventoryProps {
  const { hoveredPlantListItem } = props.resources.consumers.farm_designer;
  return {
    plants: getPlants(props.resources),
    dispatch: props.dispatch,
    hoveredPlantListItem,
    groups: selectAllPointGroups(props.resources.index),
    savedGardens: selectAllSavedGardens(props.resources.index),
    allPoints: selectAllActivePoints(props.resources.index),
    plantTemplates: selectAllPlantTemplates(props.resources.index),
    plantPointerCount: selectAllPlantPointers(props.resources.index).length,
    openedSavedGarden: props.resources.consumers.farm_designer.openedSavedGarden,
    plantsPanelState: props.app.plantsPanelState,
    getConfigValue: getWebAppConfigValue(() => props),
  };
}

export class RawPlants
  extends React.Component<PlantInventoryProps, PlantInventoryState> {
  state: PlantInventoryState = {
    searchTerm: "",
  };

  get noResult() {
    return <p>{`${t("Do you want to")} `}
      <a onClick={() => {
        this.props.dispatch({
          type: Actions.SEARCH_QUERY_CHANGE,
          payload: this.state.searchTerm,
        });
        this.navigate(Path.cropSearch());
        this.props.dispatch({ type: Actions.SET_SLUG_BULK, payload: undefined });
      }}>
        {t("search all crops?")}
      </a>
    </p>;
  }

  toggleOpen = (section: keyof PlantsPanelState) => () =>
    this.props.dispatch({
      type: Actions.TOGGLE_PLANTS_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, plantsPanelState, plants } = this.props;
    const filteredPlants = plants
      .filter(p => p.body.name.toLowerCase()
        .includes(this.state.searchTerm.toLowerCase()));
    const plantGroups = pointGroupSubset(this.props.groups, "Plant");
    const filteredGroups = plantGroups
      .filter(p => p.body.name.toLowerCase()
        .includes(this.state.searchTerm.toLowerCase()));
    const uncategorizedGroups = uncategorizedGroupSubset(this.props.groups);
    const noSearchResults = this.state.searchTerm && filteredPlants.length == 0;
    return <DesignerPanel panelName={"plant-inventory"} panel={Panel.Plants}>
      <DesignerPanelTop panel={Panel.Plants} withButton={true}>
        <SearchField nameKey={"plants"}
          searchTerm={this.state.searchTerm}
          placeholder={t("Search your plants...")}
          onChange={searchTerm => this.setState({ searchTerm })} />
        <Popover
          position={Position.BOTTOM}
          popoverClassName={"plants-panel-settings-menu"}
          target={<i className={"fa fa-gear fb-icon-button"} />}
          content={<Row>
            <label>{t(DeviceSetting.defaultPlantDepth)}</label>
            <Help text={Content.DEFAULT_PLANT_DEPTH} />
            <WebAppNumberSetting dispatch={dispatch}
              getConfigValue={this.props.getConfigValue}
              numberSetting={NumericSetting.default_plant_depth} />
          </Row>} />
      </DesignerPanelTop>
      <DesignerPanelContent panelName={"plant"}>
        <PanelSection isOpen={plantsPanelState.groups}
          panel={Panel.Plants}
          toggleOpen={this.toggleOpen("groups")}
          itemCount={plantGroups.length}
          addNew={() => dispatch(createGroup({
            criteria: {
              ...DEFAULT_CRITERIA,
              string_eq: { pointer_type: ["Plant"] },
            },
          }))}
          addTitle={t("add new group")}
          addClassName={"plus-group"}
          title={t("Plant Groups")}>
          <div className={"plant-groups"}>
            {filteredGroups
              .map(group => <GroupInventoryItem
                key={group.uuid}
                group={group}
                allPoints={this.props.allPoints}
                hovered={false}
                dispatch={dispatch}
                onClick={this.navigateById(group.body.id)}
              />)}
          </div>
          {uncategorizedGroups.length > 0
            ? <label style={{ marginLeft: "1rem" }}>{t("uncategorized")}</label>
            : <div />}
          <div className={"uncategorized-groups"}>
            {uncategorizedGroups
              .map(group => <GroupInventoryItem
                key={group.uuid}
                group={group}
                allPoints={this.props.allPoints}
                hovered={false}
                dispatch={dispatch}
                onClick={this.navigateById(group.body.id)}
              />)}
          </div>
        </PanelSection>
        <PanelSection isOpen={plantsPanelState.savedGardens}
          panel={Panel.Plants}
          toggleOpen={this.toggleOpen("savedGardens")}
          itemCount={this.props.savedGardens.length}
          addNew={() => this.navigate(Path.savedGardens("add"))}
          addTitle={t("add new saved garden")}
          addClassName={"plus-saved-garden"}
          title={t("Gardens")}>
          <SavedGardenList {...this.props} searchTerm={this.state.searchTerm} />
        </PanelSection>
        <PanelSection isOpen={plantsPanelState.plants}
          panel={Panel.Plants}
          toggleOpen={this.toggleOpen("plants")}
          itemCount={plants.length}
          addNew={() => {
            this.navigate(Path.cropSearch());
            dispatch({ type: Actions.SET_SLUG_BULK, payload: undefined });
          }}
          addTitle={t("add plant")}
          addClassName={"plus-plant"}
          title={t("Plants")}
          extraHeaderContent={
            !this.props.openedSavedGarden && plantsPanelState.plants &&
            <button className={"fb-button red delete"}
              title={t("delete all plants in garden")}
              onClick={e => {
                e.stopPropagation();
                confirm(t("Delete all {{ count }} plants in your main garden?",
                  { count: plants.length })) &&
                  dispatch(deletePoints("plants",
                    { pointer_type: "Plant" }));
              }}>
              {t("delete all")}
            </button>}>
          <EmptyStateWrapper
            notEmpty={plants.length > 0 && !noSearchResults}
            graphic={noSearchResults
              ? EmptyStateGraphic.no_crop_results
              : EmptyStateGraphic.plants}
            title={noSearchResults
              ? t("No results in your garden")
              : t("Get growing!")}
            text={noSearchResults ? undefined : Content.NO_PLANTS}
            textElement={noSearchResults ? this.noResult : undefined}
            colorScheme={"plants"}>
            {filteredPlants.map(p =>
              <PlantInventoryItem
                key={p.uuid}
                plant={p}
                hovered={this.props.hoveredPlantListItem === p.uuid}
                dispatch={dispatch} />)}
          </EmptyStateWrapper>
        </PanelSection>
      </DesignerPanelContent>
    </DesignerPanel>;
  }
}

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

export interface PanelSectionProps {
  panel: Panel;
  isOpen: boolean;
  toggleOpen(): void;
  itemCount: number;
  title: string;
  addNew(): void;
  addTitle: string;
  addClassName: string;
  children: JSX.Element | JSX.Element[];
  extraHeaderContent?: JSX.Element | false;
  extraHeaderTitle?: JSX.Element | false;
}

export const PanelSection = (props: PanelSectionProps) => {
  const { isOpen } = props;
  return <div className={`panel-section ${isOpen ? "open" : ""}`}>
    <div className={"section-header"}
      onClick={props.toggleOpen}>
      <label>{`${props.title} (${props.itemCount})`}</label>
      {props.extraHeaderTitle}
      <div className="row">
        {props.extraHeaderContent}
        {isOpen && <div
          onClick={e => {
            e.stopPropagation();
            props.addNew();
          }}
          className={[
            "fb-button green",
            props.addClassName,
          ].join(" ")}>
          <i className={"fa fa-plus"} title={props.addTitle} />
        </div>}
        <i className={`fa fa-caret-${isOpen ? "up" : "down"}`} />
      </div>
    </div>
    <Collapse isOpen={isOpen}>
      {props.children}
    </Collapse>
  </div>;
};