FarmBot/Farmbot-Web-App

View on GitHub
frontend/farmware/farmware_forms.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import React from "react";
import {
  BlurableInput, DocSlug, DropDownItem, ExpandableHeader, FBSelect, ToolTip,
} from "../ui";
import { Pair, FarmwareConfig, TaggedFarmwareEnv } from "farmbot";
import { UserEnv } from "../devices/interfaces";
import { toString, snakeCase, isEqual } from "lodash";
import { FarmwareManifestInfo, SaveFarmwareEnv } from "./interfaces";
import { t } from "../i18next_wrapper";
import { Collapse } from "@blueprintjs/core";
import { destroy } from "../api/crud";
import { UUID } from "../resources/interfaces";
import { equals } from "../util";
import { FarmwareName } from "../sequences/step_tiles/tile_execute_script";
import { runFarmware } from "../devices/actions";

export interface FarmwareFormProps {
  farmware: FarmwareManifestInfo;
  env: UserEnv;
  userEnv: UserEnv;
  farmwareEnvs: TaggedFarmwareEnv[];
  saveFarmwareEnv: SaveFarmwareEnv;
  dispatch: Function;
  botOnline: boolean;
  hideAdvanced?: boolean;
  hideResets?: boolean;
  docPage?: DocSlug;
}

/** Namespace a Farmware config with the Farmware name. */
export function getConfigEnvName(farmwareName: string, configName: string) {
  return `${snakeCase(farmwareName)}_${configName}`;
}

enum DropdownInput {
  images = "verbose",
  logs = "log_verbosity",
}

const OPTION_DDIS = (): Record<DropdownInput, DropDownItem[]> => ({
  verbose: [
    { label: t("none"), value: 0 },
    { label: t("single input"), value: 1 },
    { label: t("colorized depth"), value: 2 },
    { label: t("grayscale depth"), value: 3 },
    { label: t("stereo inputs"), value: 4 },
    { label: t("collage"), value: 5 },
  ],
  log_verbosity: [
    { label: t("none"), value: 0 },
    { label: t("result toast"), value: 1 },
    { label: t("debug logs"), value: 2 },
  ]
});

interface FarmwareInputFieldProps {
  farmwareName: string;
  configName: string;
  value: string;
  saveEnv(value: string): void;
}

const FarmwareInputField = (props: FarmwareInputFieldProps) => {
  if (props.farmwareName == FarmwareName.MeasureSoilHeight) {
    switch (props.configName) {
      case DropdownInput.images:
      case DropdownInput.logs:
        const options = OPTION_DDIS()[props.configName];
        return <FBSelect
          key={props.value}
          list={options}
          selectedItem={options[parseInt(props.value)]}
          onChange={ddi => props.saveEnv("" + ddi.value)} />;
    }
  }
  return <BlurableInput type={"text"}
    onCommit={e => props.saveEnv(e.currentTarget.value)}
    value={props.value} />;
};

export interface ConfigFieldsProps {
  farmwareName: string;
  farmwareConfigs: FarmwareConfig[];
  getValue(farmwareName: string, currentConfig: FarmwareConfig): string;
  saveFarmwareEnv: SaveFarmwareEnv;
  userEnv: UserEnv;
  farmwareEnvs: TaggedFarmwareEnv[];
  dispatch: Function;
}

/** Return a div that includes all Farmware input fields. */
export function ConfigFields(props: ConfigFieldsProps): JSX.Element {
  const { farmwareName, farmwareConfigs, getValue, userEnv } = props;
  return <div className={"farmware-config-fields"}>
    {farmwareConfigs.map(config => {
      const configEnvName = getConfigEnvName(farmwareName, config.name);
      const saveEnv = (value: string) =>
        props.dispatch(props.saveFarmwareEnv(configEnvName, value));
      const value = getValue(farmwareName, config);
      const botValue = userEnv[configEnvName];
      const dropdown = farmwareName == FarmwareName.MeasureSoilHeight
        && Object.values(DropdownInput).includes(config.name as DropdownInput);
      return <div key={config.name} id={config.name} className={"config-field"}>
        <label>{config.label}</label>
        <div className={`farmware-input-group ${dropdown ? "dropdown" : ""}`}>
          <FarmwareInputField
            farmwareName={farmwareName}
            configName={config.name}
            value={value}
            saveEnv={saveEnv} />
          {botValue && !(value == botValue) &&
            <i className="fa fa-refresh" title={t("update to FarmBot's value")}
              onClick={() => saveEnv(botValue)} />}
          {!(value == config.value) &&
            <i className="fa fa-times-circle" title={t("reset to default")}
              onClick={() => saveEnv(config.value.toString())} />}
        </div>
      </div>;
    })}
  </div>;
}

interface FarmwareFormState { advanced: boolean; }
interface ConfigsProps { farmwareConfigs: FarmwareConfig[]; }
interface ClearConfigsButtonProps {
  label: string;
  prefix: string;
  dispatch: Function;
}

/** Render a form with Farmware input fields. */
export class FarmwareForm
  extends React.Component<FarmwareFormProps, FarmwareFormState> {
  state: FarmwareFormState = { advanced: false };

  shouldComponentUpdate(
    nextProps: FarmwareFormProps,
    nextState: FarmwareFormState,
  ) {
    return !(equals(this.props, nextProps) && isEqual(this.state, nextState));
  }

  get farmwareEnvUuidLookup() {
    const lookup: Record<string, UUID> = {};
    this.props.farmwareEnvs.map(farmwareEnv => {
      lookup[farmwareEnv.body.key] = farmwareEnv.uuid;
    });
    return lookup;
  }

  Configs = ({ farmwareConfigs }: ConfigsProps) => {
    const {
      farmware, env, userEnv, farmwareEnvs, saveFarmwareEnv, dispatch,
    } = this.props;
    return <ConfigFields
      farmwareName={farmware.name}
      farmwareConfigs={farmwareConfigs}
      getValue={getValue(env)}
      userEnv={userEnv}
      farmwareEnvs={farmwareEnvs}
      saveFarmwareEnv={saveFarmwareEnv}
      dispatch={dispatch} />;
  };

  ClearConfigsButton = ({ label, prefix, dispatch }: ClearConfigsButtonProps) => {
    const { env } = this.props;
    const selectedKeys = Object.keys(env).filter(key => key.startsWith(prefix));
    return <button
      className={"fb-button red reset-configs"}
      title={t("Reset Farmware config values")}
      onClick={() => confirm(t("Reset {{ count }} values?",
        { count: selectedKeys.length })) &&
        selectedKeys.map(key =>
          dispatch(destroy(this.farmwareEnvUuidLookup[key])))}>
      {label}
    </button>;
  };

  render() {
    const { farmware, env, dispatch, botOnline } = this.props;

    const collapsed = (config: FarmwareConfig) =>
      farmware.name == FarmwareName.MeasureSoilHeight
      && ((nonZeroValue(env.measure_soil_height_calibration_factor)
        && nonZeroValue(env.measure_soil_height_measured_distance)) ||
        config.name != "measured_distance");
    const openConfigs = farmware.config.filter(config => !collapsed(config));
    const collapsedConfigs = farmware.config.filter(collapsed);

    return <div className={"farmware-form"}>
      {this.props.docPage && <ToolTip helpText={""} docPage={this.props.docPage} />}
      <button
        className={["fb-button green farmware-button",
          nonZeroValue(env.measure_soil_height_measured_distance)
            ? ""
            : "pseudo-disabled",
        ].join(" ")}
        disabled={!botOnline}
        title={t("Run Farmware")}
        onClick={() => run(env)(farmware.name, farmware.config)}>
        {runButtonText(farmware.name, env)}
      </button>
      <this.Configs farmwareConfigs={openConfigs} />
      {!this.props.hideAdvanced && collapsedConfigs.length > 0 &&
        <div className={"advanced-configs"}>
          <ExpandableHeader
            expanded={this.state.advanced}
            title={t("Advanced")}
            onClick={() => this.setState({ advanced: !this.state.advanced })} />
          <Collapse isOpen={this.state.advanced}>
            <this.Configs farmwareConfigs={collapsedConfigs} />
          </Collapse>
        </div>}
      {!this.props.hideResets && farmware.name == FarmwareName.MeasureSoilHeight
        && <this.ClearConfigsButton
          label={t("Reset calibration values")}
          prefix={"measure_soil_height_calibration_"}
          dispatch={dispatch} />}
      {!this.props.hideResets && <this.ClearConfigsButton
        label={t("Reset all values")}
        prefix={snakeCase(farmware.name)}
        dispatch={dispatch} />}
    </div>;
  }
}

/** Determine if a Farmware has requested inputs. */
export function needsFarmwareForm(farmware: FarmwareManifestInfo): Boolean {
  const needsWidget = farmware.config?.length > 0;
  return needsWidget;
}

/** Get a Farmware input value from FBOS. */
const getValue = (env: UserEnv) =>
  (farmwareName: string, currentConfig: FarmwareConfig) =>
    env[getConfigEnvName(farmwareName, currentConfig.name)]
    || toString(currentConfig.value);

/** Execute a Farmware using the provided inputs. */
const run = (env: UserEnv) =>
  (farmwareName: string, config: FarmwareConfig[]) => {
    const pairs = config.map<Pair>((x) => {
      const label = getConfigEnvName(farmwareName, x.name);
      const value = getValue(env)(farmwareName, x);
      return { kind: "pair", args: { value, label } };
    });
    runFarmware(farmwareName, pairs);
  };

/** Check if a FarmwareEnv has a value other than "0". */
const nonZeroValue = (value: string | undefined) =>
  parseFloat(value || "0") > 0;

/** Farmware Run button text. */
const runButtonText = (farmwareName: string, env: UserEnv) => {
  if (farmwareName == FarmwareName.MeasureSoilHeight) {
    if (!nonZeroValue(env.measure_soil_height_measured_distance)) {
      return t("Input required");
    }
    if (!nonZeroValue(env.measure_soil_height_calibration_factor)) {
      return t("Calibrate");
    }
    return t("Measure");
  }
  return t("Run");
};