FarmBot/Farmbot-Web-App

View on GitHub
frontend/folders/component.tsx

Summary

Maintainability
D
2 days
Test Coverage
import React from "react";
import {
  BlurableInput,
  EmptyStateWrapper,
  EmptyStateGraphic,
  ColorPicker,
  Popover,
  Markdown,
} from "../ui";
import {
  FolderUnion,
  FolderItemProps,
  FolderNodeProps,
  FolderProps,
  FolderState,
  ToggleFolderBtnProps,
  FolderPanelTopProps,
  SequenceDropAreaProps,
  FolderButtonClusterProps,
  FolderNameInputProps,
  SequenceDropAreaState,
  SequenceButtonClusterProps,
} from "./interfaces";
import {
  createFolder,
  deleteFolder,
  setFolderName,
  toggleFolderOpenState,
  toggleFolderEditState,
  toggleAll,
  updateSearchTerm,
  addNewSequenceToFolder,
  moveSequence,
  setFolderColor,
  dropSequence,
  sequenceEditMaybeSave,
} from "./actions";
import { Link } from "../link";
import { urlFriendly } from "../util";
import {
  setActiveSequenceByName,
} from "../sequences/set_active_sequence_by_name";
import { t } from "../i18next_wrapper";
import { Content } from "../constants";
import { StepDragger, NULL_DRAGGER_ID } from "../draggable/step_dragger";
import { variableList } from "../sequences/locals_list/variable_support";
import { UUID } from "../resources/interfaces";
import { SearchField } from "../ui/search_field";
import {
  deleteSequence, isSequencePublished,
} from "../sequences/sequence_editor_middle_active";
import { Path } from "../internal_urls";
import { copySequence } from "../sequences/actions";
import { TestButton, isMenuOpen } from "../sequences/test_button";
import { TaggedSequence } from "farmbot";
import { useNavigate } from "react-router-dom";

export const FolderListItem = (props: FolderItemProps) => {
  const { sequence, movedSequenceUuid, inUse } = props;
  const seqName = sequence.body.name;
  const url = Path.sequences(urlFriendly(seqName || ""));
  const moveSource = movedSequenceUuid === sequence.uuid ? "move-source" : "";
  const nameWithSaveIndicator = seqName + (sequence.specialStatus ? "*" : "");
  const active = Path.lastChunkEquals(urlFriendly(seqName)) ? "active" : "";
  const [settingsOpen, setSettingsOpen] = React.useState(false);
  const [descriptionOpen, setDescriptionOpen] = React.useState(false);
  const menuOpen = isMenuOpen(props.menuOpen,
    { component: "list", uuid: sequence.uuid });
  const hovered = menuOpen || settingsOpen || descriptionOpen
    ? "hovered"
    : "";
  const matched = (props.searchTerm &&
    seqName.toLowerCase().includes(props.searchTerm.toLowerCase()))
    ? "matched"
    : "";
  return <StepDragger
    dispatch={props.dispatch}
    step={{
      kind: "execute",
      args: { sequence_id: props.sequence.body.id || 0 },
      body: variableList(props.variableData)
    }}
    intent="step_splice"
    draggerId={NULL_DRAGGER_ID}
    onDragStart={() => props.startSequenceMove(sequence.uuid)}
    onDragEnd={() => props.toggleSequenceMove()}
    resourceUuid={sequence.uuid}>
    <li
      className={["sequence-list-item", active, moveSource, hovered, matched]
        .join(" ")}
      draggable={true}>
      <ColorPicker
        current={sequence.body.color}
        onChange={color => sequenceEditMaybeSave(sequence, { color })} />
      <Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}
        draggable={false}>
        <p>{nameWithSaveIndicator}</p>
      </Link>
      <TestButton component={"list"}
        syncStatus={props.syncStatus}
        sequence={sequence}
        resources={props.resources}
        menuOpen={props.menuOpen}
        dispatch={props.dispatch} />
      <Popover
        popoverClassName={"sequence-item-description"}
        isOpen={descriptionOpen}
        target={<i className={"fa fa-question-circle help-icon"}
          onClick={() => setDescriptionOpen(!descriptionOpen)} />}
        content={<SequenceItemDescription inUse={inUse} sequence={sequence} />} />
      <Popover usePortal={false}
        popoverClassName={"sequence-item-action-menu"}
        isOpen={settingsOpen}
        target={<i className={`fa fa-ellipsis-v ${settingsOpen ? "open" : ""}`}
          onClick={() => setSettingsOpen(!settingsOpen)} />}
        content={<SequenceButtonCluster {...props} />} />
    </li>
  </StepDragger>;
};

interface SequenceItemDescriptionProps {
  inUse: boolean;
  sequence: TaggedSequence;
}

// eslint-disable-next-line complexity
const SequenceItemDescription = (props: SequenceItemDescriptionProps) => {
  const { sequence, inUse } = props;
  const deprecatedSteps = JSON.stringify(props.sequence.body.body)
    .includes("resource_update");
  const { pinned, forked, sequence_version_id, description } = props.sequence.body;
  const imported = sequence_version_id && !forked;
  const published = isSequencePublished(sequence);
  const hasInfo = deprecatedSteps || inUse || pinned || forked || imported
    || published;
  return <div className={"sequence-item-help help-text-content"}>
    <div className={"info-grid-wrapper"}>
      {deprecatedSteps &&
        <InfoRow className={"fa fa-exclamation-triangle"}
          description={t(Content.INCLUDES_DEPRECATED_STEPS)} />}
      {inUse &&
        <InfoRow className={"in-use fa fa-hdd-o"}
          description={t(Content.IN_USE)} />}
      {pinned &&
        <InfoRow className={"fa fa-thumb-tack"}
          description={t(Content.IS_PINNED)} />}
      {forked &&
        <InfoRow className={"fa fa-chain-broken"}
          description={t("Imported and edited publicly shared sequence.")} />}
      {imported &&
        <InfoRow className={"fa fa-link"}
          description={t("Imported publicly shared sequence.")} />}
      {published &&
        <InfoRow className={"fa fa-globe"}
          description={t("Published as a publicly shared sequence.")} />}
    </div>
    {hasInfo && <hr />}
    <label>{t("Description")}</label>
    <Markdown>{description || t("This sequence has no description.")}</Markdown>
  </div>;
};

interface InfoRowProps {
  className: string;
  description: string;
}

/** Fragments used for CSS grid to work properly. */
const InfoRow = (props: InfoRowProps) =>
  <React.Fragment>
    <i className={props.className} />
    <p>{props.description}</p>
  </React.Fragment>;

export const SequenceButtonCluster =
  (props: SequenceButtonClusterProps) => {
    const { dispatch, getWebAppConfigValue, sequence } = props;
    const navigate = useNavigate();
    return <div className="folder-button-cluster">
      <i
        className={"fa fa-trash cluster-icon"}
        title={t("delete sequence")}
        onClick={deleteSequence({
          navigate,
          sequenceUuid: sequence.uuid,
          getWebAppConfigValue,
          dispatch,
        })} />
      <i
        className={"fa fa-copy cluster-icon"}
        title={t("copy sequence")}
        onClick={() => dispatch(copySequence(navigate, sequence))} />
      <i className={"fa fa-arrows-v cluster-icon"}
        title={t("move sequence")}
        onMouseDown={() => props.startSequenceMove(sequence.uuid)}
        onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
    </div>;
  };

const ToggleFolderBtn = (props: ToggleFolderBtnProps) => {
  return <button className="fb-button gray"
    title={t("toggle folder open")}
    onClick={props.onClick}>
    <i className={`fa fa-chevron-${props.expanded ? "right" : "down"}`} />
  </button>;
};

interface PlusStackProps extends React.HTMLProps<HTMLDivElement> {
  icon: string;
}

const PlusStack = (props: PlusStackProps) =>
  <div className={"fa-stack fa-2x"} {...props}>
    <i className={`fa ${props.icon} fa-stack-2x`} />
    <i className={"fa fa-plus fa-stack-1x"} />
  </div>;

export const FolderButtonCluster =
  ({ node, close }: FolderButtonClusterProps) => {
    const navigate = useNavigate();
    return <div className={"folder-button-cluster"}>
      <i className={"fa fa-trash cluster-icon"}
        title={t("delete folder")}
        onClick={() => { deleteFolder(node.id); }} />
      <i className={"fa fa-pencil cluster-icon"}
        title={t("edit folder")}
        onClick={() => { close(); toggleFolderEditState(node.id); }} />
      {node.kind !== "terminal" &&
        <div className={"stack-wrapper cluster-icon"}
          title={t("Create subfolder")}
          onClick={() => {
            close();
            createFolder({ parent_id: node.id, color: node.color });
          }}>
          <PlusStack icon={"fa-folder"} />
        </div>}
      <div className={"stack-wrapper cluster-icon"}
        title={t("add new sequence")}
        onClick={() => {
          close();
          addNewSequenceToFolder(navigate, { id: node.id, color: node.color });
        }}>
        <PlusStack icon={"fa-server"} />
      </div>
    </div>;
  };

export const FolderNameInput = ({ node }: FolderNameInputProps) =>
  <div className="folder-name-input">
    <BlurableInput value={node.name} autoFocus={true} autoSelect={true}
      onCommit={e => {
        setFolderName(node.id, e.currentTarget.value);
        toggleFolderEditState(node.id);
      }} />
    <button
      className="fb-button green"
      title={t("save folder name")}
      onClick={() => toggleFolderEditState(node.id)}>
      <i className="fa fa-check" />
    </button>
  </div>;

export const FolderNameEditor = (props: FolderNodeProps) => {
  const { node } = props;
  const [settingsOpen, setSettingsOpen] = React.useState(false);
  const [hovered, setHovered] = React.useState(false);
  return <div
    className={[
      "folder-list-item",
      (props.searchTerm && node.name.toLowerCase()
        .includes(props.searchTerm.toLowerCase()))
        ? "matched"
        : "",
      hovered ? "hovered" : "",
      props.movedSequenceUuid ? "moving" : "",
      !props.dragging ? "not-dragging" : "",
    ].join(" ")}
    onClick={() => props.onMoveEnd(node.id)}
    onDrop={e => {
      setHovered(false);
      dropSequence(node.id)(e);
      props.toggleSequenceMove();
    }}
    onDragOver={e => e.preventDefault()}
    onDragEnter={() => setHovered(true)}
    onDragLeave={() => setHovered(false)}>
    <i className={`fa fa-chevron-${node.open ? "down" : "right"}`}
      title={"Open/Close Folder"}
      onClick={() => toggleFolderOpenState(node.id)} />
    <div className={"drop-visual"} />
    <ColorPicker
      saucerIcon={[
        "fa",
        props.movedSequenceUuid ? "fa-folder-open" : "fa-folder",
      ].join(" ")}
      current={node.color}
      onChange={color => setFolderColor(node.id, color)} />
    <div className="folder-name">
      {node.editing
        ? <FolderNameInput node={node} />
        : <p>{node.name}</p>}
    </div>
    <Popover className="folder-settings-icon" usePortal={false}
      isOpen={settingsOpen}
      target={<i className={`fa fa-ellipsis-v ${settingsOpen ? "open" : ""}`}
        onClick={() => setSettingsOpen(!settingsOpen)} />}
      content={<FolderButtonCluster node={node}
        close={() => setSettingsOpen(false)} />} />
  </div>;
};

const FolderNode = (props: FolderNodeProps) => {
  const { node, sequences } = props;

  const sequenceItems = node.content
    .filter(seqUuid => sequences[seqUuid])
    .map(seqUuid =>
      <FolderListItem
        sequence={sequences[seqUuid]}
        key={"F" + seqUuid}
        dispatch={props.dispatch}
        variableData={props.sequenceMetas[seqUuid]}
        inUse={!!props.resourceUsage[seqUuid]}
        toggleSequenceMove={props.toggleSequenceMove}
        startSequenceMove={props.startSequenceMove}
        getWebAppConfigValue={props.getWebAppConfigValue}
        menuOpen={props.menuOpen}
        syncStatus={props.syncStatus}
        resources={props.resources}
        searchTerm={props.searchTerm}
        movedSequenceUuid={props.movedSequenceUuid} />);

  const childFolders: FolderUnion[] = node.children || [];
  const folderNodes = childFolders.map(folder =>
    <FolderNode
      node={folder}
      key={folder.id}
      sequences={sequences}
      dispatch={props.dispatch}
      sequenceMetas={props.sequenceMetas}
      resourceUsage={props.resourceUsage}
      movedSequenceUuid={props.movedSequenceUuid}
      getWebAppConfigValue={props.getWebAppConfigValue}
      menuOpen={props.menuOpen}
      syncStatus={props.syncStatus}
      resources={props.resources}
      searchTerm={props.searchTerm}
      toggleSequenceMove={props.toggleSequenceMove}
      startSequenceMove={props.startSequenceMove}
      dragging={props.dragging}
      onMoveEnd={props.onMoveEnd} />);

  return <div className="folder">
    <FolderNameEditor {...props} />
    {!!node.open && <ul className="in-folder-sequences">{sequenceItems}</ul>}
    {!!node.open && folderNodes}
  </div>;
};

export class SequenceDropArea
  extends React.Component<SequenceDropAreaProps, SequenceDropAreaState> {
  state: SequenceDropAreaState = { hovered: false };
  render() {
    const { dropAreaVisible, folderId, onMoveEnd, folderName } = this.props;
    const visible = dropAreaVisible ? "visible" : "";
    const hovered = this.state.hovered ? "hovered" : "";
    return <div
      className={`folder-drop-area ${visible} ${hovered}`}
      onClick={() => onMoveEnd(folderId)}
      onDrop={e => {
        this.setState({ hovered: false });
        dropSequence(folderId)(e);
        this.props.toggleSequenceMove();
      }}
      onDragOver={e => e.preventDefault()}
      onDragEnter={() => this.setState({ hovered: true })}
      onDragLeave={() => this.setState({ hovered: false })}>
      {folderId
        ? `${t("Move into")} ${folderName}`
        : t("Move out of folders")}
    </div>;
  }
}

export class Folders extends React.Component<FolderProps, FolderState> {
  state: FolderState = { toggleDirection: false };

  Graph = () => {
    return <div className="folders">
      {this.props.rootFolder.folders.map(grandparent => {
        return <FolderNode
          node={grandparent}
          key={grandparent.id}
          dispatch={this.props.dispatch}
          sequenceMetas={this.props.sequenceMetas}
          resourceUsage={this.props.resourceUsage}
          menuOpen={this.props.menuOpen}
          syncStatus={this.props.syncStatus}
          resources={this.props.resources}
          searchTerm={this.props.searchTerm}
          movedSequenceUuid={this.state.movedSequenceUuid}
          toggleSequenceMove={this.toggleSequenceMove}
          startSequenceMove={this.startSequenceMove}
          onMoveEnd={this.endSequenceMove}
          dragging={this.state.dragging}
          getWebAppConfigValue={this.props.getWebAppConfigValue}
          sequences={this.props.sequences} />;
      })}
    </div>;
  };

  toggleAll = () => {
    toggleAll(this.state.toggleDirection);
    this.setState({ toggleDirection: !this.state.toggleDirection });
  };

  startSequenceMove = (seqUuid: UUID) => this.setState({
    movedSequenceUuid: seqUuid,
    stashedUuid: this.state.movedSequenceUuid,
    dragging: true,
  });

  toggleSequenceMove = (seqUuid?: UUID) => this.setState({
    movedSequenceUuid: this.state.stashedUuid ? undefined : seqUuid,
    dragging: false,
  });

  endSequenceMove = (folderId: number) => {
    moveSequence(this.state.movedSequenceUuid || "", folderId);
    this.setState({ movedSequenceUuid: undefined, dragging: false });
  };

  rootSequences = () => this.props.rootFolder.noFolder
    .filter(seqUuid => this.props.sequences[seqUuid])
    .map(seqUuid =>
      <FolderListItem
        key={seqUuid}
        dispatch={this.props.dispatch}
        variableData={this.props.sequenceMetas[seqUuid]}
        inUse={!!this.props.resourceUsage[seqUuid]}
        sequence={this.props.sequences[seqUuid]}
        getWebAppConfigValue={this.props.getWebAppConfigValue}
        menuOpen={this.props.menuOpen}
        syncStatus={this.props.syncStatus}
        resources={this.props.resources}
        searchTerm={this.props.searchTerm}
        toggleSequenceMove={this.toggleSequenceMove}
        startSequenceMove={this.startSequenceMove}
        movedSequenceUuid={this.state.movedSequenceUuid} />);

  render() {
    return <div className="folders-panel">
      <FolderPanelTop
        searchTerm={this.props.searchTerm}
        toggleDirection={this.state.toggleDirection}
        toggleAll={this.toggleAll} />
      <EmptyStateWrapper
        notEmpty={Object.values(this.props.sequences).length > 0
          || this.props.rootFolder.folders.length > 0}
        graphic={EmptyStateGraphic.sequences}
        title={t("No Sequences.")}
        text={Content.NO_SEQUENCES}>
        <this.Graph />
        <ul className="sequences-not-in-folders">
          {this.rootSequences()}
        </ul>
        <SequenceDropArea
          dropAreaVisible={!!this.state.movedSequenceUuid}
          onMoveEnd={this.endSequenceMove}
          toggleSequenceMove={this.toggleSequenceMove}
          folderId={0}
          folderName={"none"} />
      </EmptyStateWrapper>
    </div>;
  }
}

export const FolderPanelTop = (props: FolderPanelTopProps) => {
  const navigate = useNavigate();
  return <div className="panel-top with-button">
    <SearchField nameKey={"sequences"}
      placeholder={t("Search sequences...")}
      searchTerm={props.searchTerm || ""}
      onChange={updateSearchTerm} />
    <ToggleFolderBtn
      expanded={props.toggleDirection}
      onClick={props.toggleAll} />
    <button
      className="fb-button green"
      title={t("create subfolder")}
      onClick={() => { createFolder(); }}>
      <PlusStack icon={"fa-folder"} />
    </button>
    <button
      className="fb-button green"
      title={t("add new sequence")}
      onClick={() => { addNewSequenceToFolder(navigate); }}>
      <PlusStack icon={"fa-server"} />
    </button>
  </div>;
};