FarmBot/Farmbot-Web-App

View on GitHub
frontend/plants/edit_plant_status.tsx

Summary

Maintainability
F
5 days
Test Coverage
import React from "react";
import {
  FBSelect, DropDownItem, ColorPicker, BlurableInput, NULL_CHOICE,
} from "../ui";
import { PlantOptions } from "../farm_designer/interfaces";
import {
  PlantStage, TaggedWeedPointer, PointType, TaggedPoint, TaggedPlantPointer,
  TaggedGenericPointer,
  TaggedCurve,
} from "farmbot";
import moment from "moment";
import { t } from "../i18next_wrapper";
import { UUID } from "../resources/interfaces";
import { edit, save } from "../api/crud";
import { EditPlantStatusProps } from "./plant_panel";
import { capitalize, mean, round, startCase } from "lodash";
import { TimeSettings } from "../interfaces";
import { Link } from "../link";
import { Path } from "../internal_urls";
import { useNavigate } from "react-router-dom";
import { Actions } from "../constants";
import { CurveType } from "../curves/templates";
import { curveToDdi, CURVE_KEY_LOOKUP } from "./curve_info";
import { CURVE_TYPES } from "../curves/curves_inventory";
import { betterCompact } from "../util";

export const PLANT_STAGE_DDI_LOOKUP = (): { [x: string]: DropDownItem } => ({
  planned: { label: t("Planned"), value: "planned" },
  planted: { label: t("Planted"), value: "planted" },
  sprouted: { label: t("Sprouted"), value: "sprouted" },
  harvested: { label: t("Harvested"), value: "harvested" },
  active: { label: t("Active"), value: "active" },
  removed: { label: t("Removed"), value: "removed" },
  pending: { label: t("Pending"), value: "pending" },
});
export const PLANT_STAGE_LIST = () => [
  PLANT_STAGE_DDI_LOOKUP().planned,
  PLANT_STAGE_DDI_LOOKUP().planted,
  PLANT_STAGE_DDI_LOOKUP().sprouted,
  PLANT_STAGE_DDI_LOOKUP().harvested,
  PLANT_STAGE_DDI_LOOKUP().removed,
  PLANT_STAGE_DDI_LOOKUP().pending,
];

export const ALL_STAGE_DDI_LOOKUP = (): Record<string, DropDownItem> => ({
  planned: PLANT_STAGE_DDI_LOOKUP().planned,
  planted: PLANT_STAGE_DDI_LOOKUP().planted,
  sprouted: PLANT_STAGE_DDI_LOOKUP().sprouted,
  harvested: PLANT_STAGE_DDI_LOOKUP().harvested,
  active: PLANT_STAGE_DDI_LOOKUP().active,
  removed: PLANT_STAGE_DDI_LOOKUP().removed,
  pending: PLANT_STAGE_DDI_LOOKUP().pending,
});
export const ALL_STAGE_LIST = () => [
  PLANT_STAGE_DDI_LOOKUP().planned,
  PLANT_STAGE_DDI_LOOKUP().planted,
  PLANT_STAGE_DDI_LOOKUP().sprouted,
  PLANT_STAGE_DDI_LOOKUP().harvested,
  PLANT_STAGE_DDI_LOOKUP().active,
  PLANT_STAGE_DDI_LOOKUP().removed,
  PLANT_STAGE_DDI_LOOKUP().pending,
];

export const WEED_STAGE_DDI_LOOKUP = (): Record<string, DropDownItem> => ({
  planned: PLANT_STAGE_DDI_LOOKUP().planned,
  planted: PLANT_STAGE_DDI_LOOKUP().planted,
  sprouted: PLANT_STAGE_DDI_LOOKUP().sprouted,
  harvested: PLANT_STAGE_DDI_LOOKUP().harvested,
  active: PLANT_STAGE_DDI_LOOKUP().active,
  removed: PLANT_STAGE_DDI_LOOKUP().removed,
  pending: PLANT_STAGE_DDI_LOOKUP().pending,
});
export const WEED_STAGE_LIST = () => [
  WEED_STAGE_DDI_LOOKUP().pending,
  WEED_STAGE_DDI_LOOKUP().active,
  WEED_STAGE_DDI_LOOKUP().removed,
];

/** Change `planted_at` value based on `plant_stage` update. */
const getUpdateByPlantStage = (plant_stage: PlantStage): PlantOptions => {
  const update: PlantOptions = { plant_stage };
  switch (plant_stage) {
    case "planned":
      update.planted_at = undefined;
      break;
    case "planted":
      update.planted_at = "" + moment().toISOString();
  }
  return update;
};

/** Select a `plant_stage` for a plant. */
export function EditPlantStatus(props: EditPlantStatusProps) {
  const { plantStatus, updatePlant, uuid } = props;
  return <div className="row grid-2-col">
    <label>{t("Status")}</label>
    <FBSelect
      list={PLANT_STAGE_LIST()}
      selectedItem={PLANT_STAGE_DDI_LOOKUP()[plantStatus]}
      onChange={ddi =>
        updatePlant(uuid, getUpdateByPlantStage(ddi.value as PlantStage))} />
  </div>;
}

export interface BulkUpdateBaseProps {
  allPoints: TaggedPoint[];
  selected: UUID[];
  dispatch: Function;
}

export interface PlantStatusBulkUpdateProps extends BulkUpdateBaseProps {
  pointerType: PointType;
}

/** Update `plant_stage` for multiple plants at once. */
export const PlantStatusBulkUpdate = (props: PlantStatusBulkUpdateProps) =>
  <div className="plant-status-bulk-update row grid-2-col">
    <p>{t("Update status to")}</p>
    <FBSelect
      key={JSON.stringify(props.selected)}
      list={props.pointerType == "Plant"
        ? PLANT_STAGE_LIST()
        : WEED_STAGE_LIST()}
      selectedItem={undefined}
      customNullLabel={t("Select a status")}
      onChange={ddi => {
        const plant_stage = ddi.value as PlantStage;
        const update = props.pointerType == "Plant"
          ? getUpdateByPlantStage(plant_stage)
          : { plant_stage };
        const points = props.allPoints.filter(point =>
          props.selected.includes(point.uuid)
          && point.kind === "Point"
          && (point.body.pointer_type == "Plant"
            || point.body.pointer_type == "Weed")
          && point.body.plant_stage != plant_stage);
        points.length > 0 && confirm(
          t("Change status to '{{ status }}' for {{ num }} items?",
            { status: plant_stage, num: points.length }))
          && points.map(point => {
            props.dispatch(edit(point, update));
            props.dispatch(save(point.uuid));
          });
      }} />
  </div>;

export interface PlantDateBulkUpdateProps extends BulkUpdateBaseProps {
  timeSettings: TimeSettings;
}

/** Update `planted_at` for multiple plants at once. */
export const PlantDateBulkUpdate = (props: PlantDateBulkUpdateProps) => {
  const plants = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type == "Plant")
    .map((p: TaggedPlantPointer) => p);
  return <div className={"plant-date-bulk-update row grid-2-col"}>
    <p>{t("Update start to")}</p>
    <BlurableInput
      type="date"
      value={moment().format("YYYY-MM-DD")}
      onCommit={e => {
        plants.length > 0 && confirm(
          t("Change start date to {{ date }} for {{ num }} items?", {
            date: moment(e.currentTarget.value).format("YYYY-MM-DD"),
            num: plants.length,
          }))
          && plants.map(plant => {
            props.dispatch(edit(plant, {
              planted_at: "" + moment(e.currentTarget.value)
                .utcOffset(props.timeSettings.utcOffset).toISOString()
            }));
            props.dispatch(save(plant.uuid));
          });
      }} />
  </div>;
};

/** Update `radius` for multiple plants at once. */
export const PointSizeBulkUpdate = (props: BulkUpdateBaseProps) => {
  const points = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type != "ToolSlot")
    .map((p: TaggedPlantPointer | TaggedWeedPointer | TaggedGenericPointer) => p);
  const averageSize = round(mean(points.map(p => p.body.radius)));
  const [radius, setRadius] = React.useState("" + (averageSize || 25));
  return <div className={"point-size-bulk-update row grid-2-col"}>
    <p>{t("Update radius to")}</p>
    <input
      value={radius}
      min={0}
      onChange={e => setRadius(e.currentTarget.value)}
      onBlur={() => {
        const radiusNum = parseInt(radius);
        isFinite(radiusNum) && points.length > 0 && confirm(
          t("Change radius to {{ radius }}mm for {{ num }} items?",
            { radius: radiusNum, num: points.length }))
          && points.map(point => {
            props.dispatch(edit(point, { radius: radiusNum }));
            props.dispatch(save(point.uuid));
          });
      }} />
  </div>;
};

/** Update `depth` for multiple points at once. */
export const PlantDepthBulkUpdate = (props: BulkUpdateBaseProps) => {
  const points = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type == "Plant")
    .map((p: TaggedPlantPointer) => p);
  const averageDepth = round(mean(points.map(p => p.body.depth)));
  const [depth, setDepth] = React.useState("" + (averageDepth || 0));
  return <div className={"plant-depth-bulk-update row grid-2-col"}>
    <p>{t("Update depth to")}</p>
    <input
      value={depth}
      min={0}
      onChange={e => setDepth(e.currentTarget.value)}
      onBlur={() => {
        const depthNum = parseFloat(depth);
        isFinite(depthNum) && points.length > 0 && confirm(
          t("Change depth to {{ depth }}mm for {{ num }} items?",
            { depth: depthNum, num: points.length }))
          && points.map(point => {
            props.dispatch(edit(point, { depth: depthNum }));
            props.dispatch(save(point.uuid));
          });
      }} />
  </div>;
};

export interface PlantCurvesBulkUpdateProps extends BulkUpdateBaseProps {
  curves: TaggedCurve[];
}

export interface PlantCurveBulkUpdateProps extends PlantCurvesBulkUpdateProps {
  curveType: CurveType;
}

export const PlantCurveBulkUpdate = (props: PlantCurveBulkUpdateProps) => {
  const points = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type == "Plant")
    .map((p: TaggedPlantPointer) => p);
  const curveName = CURVE_TYPES()[props.curveType];
  const curveKey = CURVE_KEY_LOOKUP[props.curveType];
  return <div className={"plant-curve-bulk-update row grid-2-col"}>
    <p>{t("Update {{ curveName }} curve to", { curveName })}</p>
    <FBSelect
      key={JSON.stringify(props.selected)}
      list={([NULL_CHOICE] as DropDownItem[])
        .concat(betterCompact(props.curves
          .filter(curve => curve.body.type == props.curveType)
          .map(curve => curveToDdi(curve))))}
      selectedItem={undefined}
      customNullLabel={t("Select a curve")}
      onChange={ddi => {
        const id = parseInt("" + ddi.value);
        ((id && isFinite(id)) || ddi.isNull) && points.length > 0 && confirm(
          t("Change {{ curveName }} curve for {{ num }} items?",
            { curveName, num: points.length }))
          && points.map(point => {
            props.dispatch(edit(point, {
              [curveKey]: ddi.isNull ? undefined : id,
            }));
            props.dispatch(save(point.uuid));
          });
      }} />
  </div>;
};

/** Update curves for multiple points at once. */
export const PlantCurvesBulkUpdate = (props: PlantCurvesBulkUpdateProps) => {
  return <div className={"plant-curves-bulk-update grid"}>
    {[CurveType.water, CurveType.spread, CurveType.height].map(curveType =>
      <PlantCurveBulkUpdate key={curveType} {...props} curveType={curveType} />)}
  </div>;
};

/** Update `meta.color` for multiple points at once. */
export const PointColorBulkUpdate = (props: BulkUpdateBaseProps) => {
  const points = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type != "ToolSlot")
    .map((p: TaggedWeedPointer | TaggedGenericPointer) => p);
  return <div className={"point-color-bulk-update row grid-2-col"}>
    <p>{t("Update color to")}</p>
    <ColorPicker
      current={"green"}
      onChange={color => {
        points.length > 0 && confirm(
          t("Change color to {{ color }} for {{ num }} items?",
            { color, num: points.length }))
          && points.map(point => {
            props.dispatch(edit(point, { meta: { color } }));
            props.dispatch(save(point.uuid));
          });
      }} />
  </div>;
};

export interface PlantSlugBulkUpdateProps extends BulkUpdateBaseProps {
  bulkPlantSlug: string | undefined;
}

/** Update `openfarm_slug` for multiple plants at once. */
export const PlantSlugBulkUpdate = (props: PlantSlugBulkUpdateProps) => {
  const plants = props.allPoints.filter(point =>
    props.selected.includes(point.uuid) && point.kind === "Point" &&
    point.body.pointer_type == "Plant")
    .map((p: TaggedPlantPointer) => p);
  const slug = props.bulkPlantSlug || plants[0]?.body.openfarm_slug;
  const navigate = useNavigate();
  return <div className={"plant-slug-bulk-update row grid-2-col"}>
    <p>{t("Update type to")}</p>
    <div>
      <Link
        title={t("View crop info")}
        to={Path.cropSearch(slug)}>
        {startCase(slug)}
      </Link>
      <i className={"fa fa-pencil fb-icon-button"}
        onClick={() => {
          props.dispatch({ type: Actions.SET_SLUG_BULK, payload: slug });
          navigate(Path.cropSearch());
        }} />
      <button className={"fb-button green"}
        onClick={() => {
          if (slug && plants.length > 0 && confirm(
            t("Change crop type to {{ slug }} for {{ num }} plants?", {
              slug,
              num: plants.length,
            }))) {
            plants.map(plant => {
              props.dispatch(edit(plant, {
                openfarm_slug: slug,
                name: capitalize(slug).replace(/-/g, " "),
              }));
              props.dispatch(save(plant.uuid));
            });
            props.dispatch({ type: Actions.SET_SLUG_BULK, payload: undefined });
          }
        }}>
        {t("apply")}
      </button>
    </div>
  </div>;
};

export interface EditWeedStatusProps {
  weed: TaggedWeedPointer;
  updateWeed(update: Partial<TaggedWeedPointer["body"]>): void;
}

/** Select a `plant_stage` for a weed. */
export const EditWeedStatus = (props: EditWeedStatusProps) =>
  <FBSelect
    key={props.weed.uuid}
    list={WEED_STAGE_LIST()}
    selectedItem={WEED_STAGE_DDI_LOOKUP()[props.weed.body.plant_stage]}
    onChange={ddi =>
      props.updateWeed({ plant_stage: ddi.value as PlantStage })} />;