FarmBot/Farmbot-Web-App

View on GitHub
frontend/sequences/locals_list/variable_form.tsx

Summary

Maintainability
F
6 days
Test Coverage
import React from "react";
import { Row, FBSelect, Color, BlurableInput, Help } from "../../ui";
import {
  variableFormList, NO_VALUE_SELECTED_DDI, sortVariables, heading, sequences2Ddi,
  LOCATION_PLACEHOLDER_DDI,
  peripherals2Ddi,
  sensors2Ddi,
} from "./variable_form_list";
import { convertDDItoVariable } from "../locals_list/handle_select";
import {
  VariableFormProps, AllowedVariableNodes, VariableNode, OnChange, VariableType,
} from "../locals_list/locals_list_support";
import {
  determineVector, determineDropdown, SequenceMeta, determineVarDDILabel,
  maybeFindVariable,
} from "../../resources/sequence_meta";
import { ResourceIndex, UUID } from "../../resources/interfaces";
import { DefaultValueForm } from "./default_value_form";
import { t } from "../../i18next_wrapper";
import { CoordinateInputBoxes } from "./location_form_coordinate_input_boxes";
import { generateNewVariableLabel } from "./locals_list";
import { error } from "../../toast/toast";
import { cloneDeep } from "lodash";
import { defensiveClone } from "../../util";
import { Numeric, Resource, Text } from "farmbot";
import { ToolTips } from "../../constants";
import { Position } from "@blueprintjs/core";
import {
  determineVariableType, newVariableLabel, VariableIcon,
} from "./new_variable";
import { jsonReplacer } from "../step_tiles";
import {
  selectAllPeripherals, selectAllSensors, selectAllSequences,
} from "../../resources/selectors_by_kind";
import { PERIPHERAL_HEADING, SENSOR_HEADING } from "../step_tiles/pin_support";

/**
 * If a variable with a matching label exists in local parameter applications
 * (step body, etc.), use it instead of the one in scope declarations.
 */
const maybeUseStepData = ({ resources, bodyVariables, variable, uuid }: {
  resources: ResourceIndex,
  bodyVariables: VariableNode[] | undefined,
  variable: SequenceMeta,
  uuid: UUID,
}): SequenceMeta => {
  const executeStepData = bodyVariables?.filter(v =>
    v.args.label === variable.celeryNode.args.label)[0];
  if (executeStepData) {
    return {
      celeryNode: executeStepData,
      vector: determineVector(executeStepData, resources, uuid),
      dropdown: determineDropdown(executeStepData, resources, uuid),
    };
  }
  return variable;
};

/**
 * Form with an "import from" dropdown and coordinate input boxes.
 * Can be used to set a specific value, import a value, or declare a variable.
 */
export const VariableForm =
  // eslint-disable-next-line complexity
  (props: VariableFormProps) => {
    const { sequenceUuid, resources, bodyVariables, variable, variableType,
      allowedVariableNodes, hideGroups, removeVariable, onChange } = props;
    const { celeryNode, dropdown, vector, isDefault } = maybeUseStepData({
      resources, bodyVariables, variable, uuid: sequenceUuid
    });
    const variableListItems = generateVariableListItems({
      allowedVariableNodes, resources, sequenceUuid, variableType,
    });
    const displayGroups = !hideGroups;
    const list = variableFormList(resources, [], variableListItems,
      displayGroups, variableType);
    /** Variable name. */
    const { label } = celeryNode.args;
    const headerForm = allowedVariableNodes === AllowedVariableNodes.parameter;
    if (headerForm) {
      list.unshift({
        value: label,
        label: determineVarDDILabel({
          label, resources, uuid: sequenceUuid, forceExternal: true,
        }),
        headingId: "Variable",
      });
    }
    if (variable.isDefault && variableType != VariableType.Resource) {
      const defaultDDI = determineDropdown(variable.celeryNode, resources);
      defaultDDI.label = addDefaultTextToLabel(defaultDDI.label);
      list.unshift(defaultDDI);
    }
    const metaVariable =
      maybeFindVariable(celeryNode.args.label, resources, sequenceUuid);
    const usingDefaultValue = celeryNode.kind == "parameter_application" &&
      metaVariable?.celeryNode.kind == "parameter_declaration" &&
      JSON.stringify(celeryNode.args.data_value, jsonReplacer) ==
      JSON.stringify(metaVariable.celeryNode.args.default_value, jsonReplacer);
    const isDefaultValueForm =
      !!props.locationDropdownKey?.endsWith("default_value");
    if (variableType == VariableType.Resource) {
      if (isDefaultValueForm) {
        [
          { label: t("Sequence"), value: "Sequence", headingId: "Resource" },
          { label: t("Peripheral"), value: "Peripheral", headingId: "Resource" },
          { label: t("Sensor"), value: "Sensor", headingId: "Resource" },
        ].map(item => list.unshift(item));
      } else if (celeryNode.kind != "parameter_declaration") {
        if (variable.celeryNode.kind == "parameter_application") {
          const resourceType = (variable.celeryNode.args.data_value as Resource)
            .args.resource_type;
          resourceType == "Sequence" && heading("Sequence")
            .concat(sequences2Ddi(selectAllSequences(resources)))
            .map(item => list.push(item));
          resourceType == "Peripheral" && [PERIPHERAL_HEADING()]
            .concat(peripherals2Ddi(selectAllPeripherals(resources)))
            .map(item => list.push(item));
          resourceType == "Sensor" && [SENSOR_HEADING()]
            .concat(sensors2Ddi(selectAllSensors(resources)))
            .map(item => list.push(item));
        }
      }
    }
    if (variableType == VariableType.Location && isDefaultValueForm) {
      list.unshift(LOCATION_PLACEHOLDER_DDI());
    }
    const narrowLabel = !!removeVariable;
    return <div className={"location-form"}>
      <div className={"location-form-content"}>
        <Row className={isDefaultValueForm ? "grid-exp-2" : "grid-exp-3"}>
          {!props.hideWrapper && !isDefaultValueForm &&
            <VariableIcon variableType={variableType} />}
          <div>
            {!props.hideWrapper && isDefaultValueForm
              ? <p>{t("Default value")}</p>
              : <Label label={label} inUse={props.inUse || !removeVariable}
                allowedVariableNodes={allowedVariableNodes}
                labelOnly={props.labelOnly}
                variable={variable} onChange={onChange} />}
            {isDefaultValueForm &&
              <Help text={ToolTips.DEFAULT_VALUE} position={Position.TOP_LEFT} />}
            {isDefaultValueForm && isDefault &&
              <Help text={ToolTips.USING_DEFAULT_VARIABLE_VALUE}
                customIcon={"fa-exclamation-triangle"} onHover={true} />}
          </div>
          {([VariableType.Location, VariableType.Resource]
            .includes(variableType)
            || !isDefaultValueForm) &&
            <FBSelect
              key={props.locationDropdownKey}
              list={list}
              selectedItem={dropdown}
              customNullLabel={isDefaultValueForm
                ? LOCATION_PLACEHOLDER_DDI().label
                : NO_VALUE_SELECTED_DDI().label}
              onChange={ddi => {
                onChange(convertDDItoVariable({
                  identifierLabel: label,
                  allowedVariableNodes,
                  dropdown: ddi,
                  variableType,
                }), label);
              }} />}
          {variableType == VariableType.Number && isDefaultValueForm &&
            <NumericInput label={label} variableNode={variable.celeryNode}
              onChange={onChange} isDefaultValueForm={isDefaultValueForm} />}
          {variableType == VariableType.Text && isDefaultValueForm &&
            <TextInput label={label} variableNode={variable.celeryNode}
              onChange={onChange} isDefaultValueForm={isDefaultValueForm} />}
          {removeVariable && !isDefaultValueForm &&
            <div className={"trash"}>
              <i className={"fa fa-trash fb-icon-button"}
                style={props.inUse ? { color: Color.gray } : {}}
                onClick={() => removeVariable(label)} />
            </div>}
        </Row>
        {!isDefaultValueForm && variableType == VariableType.Number &&
          celeryNode.kind != "parameter_declaration" &&
          !usingDefaultValue && celeryNode.args.data_value.kind != "identifier" &&
          <Row className="grid-2-col">
            <div></div>
            <NumericInput label={label} variableNode={celeryNode}
              onChange={onChange} isDefaultValueForm={isDefaultValueForm} />
          </Row>}
        {!isDefaultValueForm && variableType == VariableType.Text &&
          celeryNode.kind != "parameter_declaration" &&
          !usingDefaultValue && celeryNode.args.data_value.kind != "identifier" &&
          <Row className="grid-2-col">
            <div></div>
            <TextInput label={label} variableNode={celeryNode}
              onChange={onChange} isDefaultValueForm={isDefaultValueForm} />
          </Row>}
        <CoordinateInputBoxes
          variableNode={celeryNode}
          vector={vector}
          hideWrapper={!!props.hideWrapper}
          narrowLabel={narrowLabel}
          onChange={onChange} />
        <DefaultValueForm
          key={props.locationDropdownKey}
          variableNode={celeryNode}
          resources={resources}
          removeVariable={removeVariable}
          onChange={onChange} />
      </div>
    </div>;
  };

const addDefaultTextToLabel = (label: string) => `${t("Default value")} - ${label}`;

export interface NumericInputProps {
  variableNode: VariableNode;
  onChange: OnChange;
  label: string;
  isDefaultValueForm: boolean;
}

export const NumericInput = (props: NumericInputProps) => {
  const { variableNode } = props;
  const isPlaceholder = variableNode.kind == "parameter_application"
    && variableNode.args.data_value.kind == "number_placeholder";
  const argsValue = variableNode.kind == "parameter_declaration"
    ? (variableNode.args.default_value as Numeric).args.number
    : (variableNode.args.data_value as Numeric).args.number;
  return <div className={"numeric-variable-input"}>
    <BlurableInput type={isPlaceholder ? "text" : "number"}
      className={"number-input"}
      clearBtn={props.isDefaultValueForm}
      disabled={isPlaceholder}
      keyCallback={(key, _buffer) => {
        if (key || !props.isDefaultValueForm) { return; }
        const editableVariable = defensiveClone(variableNode);
        if (editableVariable.kind == "parameter_declaration") {
          if (editableVariable.args.default_value.kind == "numeric") {
            editableVariable.args.default_value =
              { kind: "number_placeholder", args: {} };
          } else {
            editableVariable.args.default_value =
              { kind: "numeric", args: { number: 0 } };
          }
        } else {
          if (editableVariable.args.data_value.kind == "numeric") {
            editableVariable.args.data_value =
              { kind: "number_placeholder", args: {} };
          } else {
            editableVariable.args.data_value =
              { kind: "numeric", args: { number: 0 } };
          }
        }
        props.onChange(editableVariable, props.label);
      }}
      onCommit={e => {
        if (isPlaceholder) { return; }
        const editableVariable = defensiveClone(variableNode);
        const value = parseFloat(e.currentTarget.value);
        if (editableVariable.kind == "parameter_declaration") {
          (editableVariable.args.default_value as Numeric).args.number = value;
        } else {
          (editableVariable.args.data_value as Numeric).args.number = value;
        }
        props.onChange(editableVariable, props.label);
      }}
      value={isPlaceholder ? t("None") : argsValue} />
  </div>;
};

export interface TextInputProps {
  variableNode: VariableNode;
  onChange: OnChange;
  label: string;
  isDefaultValueForm: boolean;
}

export const TextInput = (props: TextInputProps) => {
  const { variableNode } = props;
  const isPlaceholder = variableNode.kind == "parameter_application"
    && variableNode.args.data_value.kind == "text_placeholder";
  const argsValue = variableNode.kind == "parameter_declaration"
    ? (variableNode.args.default_value as Text).args.string
    : (variableNode.args.data_value as Text).args.string;
  return <div className={"text-variable-input"}>
    <BlurableInput type={"text"}
      className={"string-input"}
      clearBtn={props.isDefaultValueForm}
      disabled={isPlaceholder}
      keyCallback={(key, _buffer) => {
        if (key || !props.isDefaultValueForm) { return; }
        const editableVariable = defensiveClone(variableNode);
        if (editableVariable.kind == "parameter_declaration") {
          if (editableVariable.args.default_value.kind == "text") {
            editableVariable.args.default_value =
              { kind: "text_placeholder", args: {} };
          } else {
            editableVariable.args.default_value =
              { kind: "text", args: { string: "" } };
          }
        } else {
          if (editableVariable.args.data_value.kind == "text") {
            editableVariable.args.data_value =
              { kind: "text_placeholder", args: {} };
          } else {
            editableVariable.args.data_value =
              { kind: "text", args: { string: "" } };
          }
        }
        props.onChange(editableVariable, props.label);
      }}
      onCommit={e => {
        if (isPlaceholder) { return; }
        const editableVariable = defensiveClone(variableNode);
        const value = e.currentTarget.value;
        if (editableVariable.kind == "parameter_declaration") {
          (editableVariable.args.default_value as Text).args.string = value;
        } else {
          (editableVariable.args.data_value as Text).args.string = value;
        }
        props.onChange(editableVariable, props.label);
      }}
      value={isPlaceholder ? t("None") : argsValue} />
  </div>;
};

export interface LabelProps {
  label: string;
  inUse: boolean | undefined;
  variable: SequenceMeta;
  onChange: OnChange;
  allowedVariableNodes: AllowedVariableNodes;
  labelOnly?: boolean;
}

interface LabelState {
  labelValue: string;
}

export class Label extends React.Component<LabelProps, LabelState> {
  state: LabelState = { labelValue: this.props.label };

  setLabelValue = (e: React.FormEvent<HTMLInputElement>) =>
    this.setState({ labelValue: e.currentTarget.value });

  UneditableLabel = () => {
    const { labelValue } = this.state;
    const { allowedVariableNodes } = this.props;
    const value = labelValue == "parent" ? t("Location") : labelValue;
    return (allowedVariableNodes == AllowedVariableNodes.parameter
      && !this.props.labelOnly)
      ? <input
        style={{ background: Color.lightGray }}
        value={value}
        readOnly={true}
        onClick={() => error(t("Can't edit variable name while in use."))} />
      : <p className={"variable-label"}>{value}</p>;
  };

  render() {
    const { labelValue } = this.state;
    const { label, inUse, variable, onChange } = this.props;
    return !inUse
      ? <input value={labelValue}
        onBlur={() => {
          const editableVariable = cloneDeep(variable.celeryNode);
          if (editableVariable.args.label != labelValue) {
            editableVariable.args.label = labelValue;
            onChange(editableVariable, label);
          }
        }}
        onChange={this.setLabelValue} />
      : <this.UneditableLabel />;
  }
}

interface GenerateVariableListProps {
  allowedVariableNodes: AllowedVariableNodes;
  resources: ResourceIndex;
  sequenceUuid: UUID;
  headingId?: string;
  variableType: VariableType;
}

export const generateVariableListItems = (props: GenerateVariableListProps) => {
  const { allowedVariableNodes, resources, sequenceUuid } = props;
  const headingId = props.headingId || "Variable";
  const variables = sortVariables(Object.values(
    resources.sequenceMetas[sequenceUuid] || [])).map(v => v.celeryNode);
  const displayVariables = allowedVariableNodes !== AllowedVariableNodes.variable;
  if (!displayVariables) { return []; }
  const headerForm = allowedVariableNodes === AllowedVariableNodes.parameter;
  if (headerForm) { return []; }
  const oldVariables = variables
    .filter(v => determineVariableType(v) == props.variableType)
    .map(variable_ => ({
      value: variable_.args.label,
      label: determineVarDDILabel({
        label: variable_.args.label,
        resources,
        uuid: sequenceUuid,
      }),
      headingId,
    }));
  const newVarLabel = generateNewVariableLabel(variables,
    newVariableLabel(props.variableType));
  const newVariable = [{
    value: newVarLabel,
    label: determineVarDDILabel({
      label: newVarLabel,
      resources,
      uuid: sequenceUuid,
    }),
    headingId,
  }];
  return oldVariables.concat(newVariable);
};