Repository: FarmBot/Farmbot-Web-App (ts)

Issue:
Folders Improvements
- [x] Font weight of all folders and sequences should be normal, with only the selected item bolded
- [x] Always show the 3-dot and 3-bar icons on mobile
- [x] Change the 3-bar icon to `fa-arrows` to match sequence commands
- [x] All folders should start closed on app load

Updated list (July, 2022)
- [ ] Allow dragging/dropping and show the ghost image of a sequence when dragging/dropping into folders and into another sequence as a command when the drag+drop is initiated anywhere on the sequence (rather than just the icon cluster)
- [ ] Cursor should be `grabbing` or `move` style when a sequence or command is being dragged-and-dropped
- [ ] Folder open/close state should be restored after a user clears their search term
- [ ] New folders and sequences made inside a folder should initialize with the parent folder's color
- [ ] When moving a sequence into a folder, change the styling of the folder itself (fa-folder-open, white background, dashed gray border) rather than adding a new item below the folder.  The overall length of the sequences panel content should not change, which will help UX when there are a lot of folders.
<img width="412" alt="Open folder" src="https://user-images.githubusercontent.com/5524043/184455037-b61b7547-46d8-48b1-80b9-cd5bfe1aa401.png">
 

Pull request:
Folders UI changes
fixes #1649

Patch:
diff --git a/frontend/css/sequences.scss b/frontend/css/sequences.scss
index 6bbe8500a..1800ef26c 100644
--- a/frontend/css/sequences.scss
+++ b/frontend/css/sequences.scss
@@ -864,6 +864,31 @@
     .folder > div:not(:first-child), ul {
       margin-left: 1rem;
     }
+    .folder-list-item {
+      &.moving {
+        .drop-visual {
+          position: absolute;
+          width: 75%;
+          height: 75%;
+          margin-top: 0.5rem;
+          margin-left: 3rem;
+          border: 2px dashed $gray;
+          background: $white;
+        }
+      }
+      &.hovered {
+        .drop-visual {
+          border-color: $medium_gray !important;
+        }
+      }
+      &.not-dragging {
+        &:hover {
+          .drop-visual {
+            border-color: $medium_gray !important;
+          }
+        }
+      }
+    }
   }
   .folder-list-item,
   .sequence-list-item {
@@ -957,6 +982,7 @@
       line-height: 2.5rem;
     }
     .folder-name {
+      position: relative;
       width: 100%;
       margin-right: 3rem;
       margin-left: 6rem;
diff --git a/frontend/draggable/interfaces.ts b/frontend/draggable/interfaces.ts
index 523f3b312..406a7e553 100644
--- a/frontend/draggable/interfaces.ts
+++ b/frontend/draggable/interfaces.ts
@@ -53,4 +53,6 @@ export interface StepDraggerProps {
   children?: React.ReactNode;
   draggerId: number;
   resourceUuid?: UUID;
+  onDragStart?: () => void;
+  onDragEnd?: () => void;
 }
diff --git a/frontend/draggable/step_dragger.tsx b/frontend/draggable/step_dragger.tsx
index b38de5c9b..4d44d165d 100644
--- a/frontend/draggable/step_dragger.tsx
+++ b/frontend/draggable/step_dragger.tsx
@@ -23,9 +23,12 @@ const stepDragEventHandler = (dispatch: Function,
   step: Step,
   intent: DataXferIntent,
   draggerId: number,
-  resourceUuid?: UUID) => {
+  resourceUuid: UUID | undefined,
+  onDragStart?: () => void,
+) => {
   return (ev: React.DragEvent<HTMLElement>) => {
     dispatch(stepPut(step, ev, intent, draggerId, resourceUuid));
+    onDragStart?.();
   };
 };
 
@@ -36,7 +39,9 @@ export function StepDragger(props: StepDraggerProps) {
       step,
       intent,
       draggerId,
-      resourceUuid)}>
+      resourceUuid,
+      props.onDragStart)}
+    onDragEnd={props.onDragEnd}>
     {children}
   </div>;
 }
diff --git a/frontend/folders/actions.ts b/frontend/folders/actions.ts
index e5f4317d5..e146b1d04 100644
--- a/frontend/folders/actions.ts
+++ b/frontend/folders/actions.ts
@@ -37,7 +37,7 @@ const DEFAULTS = (): Folder => ({
   parent_id: 0,
 });
 
-export const addNewSequenceToFolder = (folder_id?: number) => {
+export const addNewSequenceToFolder = (config: DeepPartial<Folder> = {}) => {
   const uuidMap = store.getState().resources.index.byKind["Sequence"];
   const seqCount = Object.keys(uuidMap).length;
   const newSequence = {
@@ -46,10 +46,10 @@ export const addNewSequenceToFolder = (folder_id?: number) => {
       version: -999,
       locals: { kind: "scope_declaration", args: {} },
     },
-    color: "gray",
-    folder_id,
+    color: config.color || "gray",
+    folder_id: config.id,
     kind: "sequence",
-    body: []
+    body: [],
   };
   store.dispatch(init("Sequence", newSequence));
   push(Path.sequences(urlFriendly(newSequence.name)));
diff --git a/frontend/folders/component.tsx b/frontend/folders/component.tsx
index 9596f245b..056c91e9b 100644
--- a/frontend/folders/component.tsx
+++ b/frontend/folders/component.tsx
@@ -79,6 +79,8 @@ export const FolderListItem = (props: FolderItemProps) => {
     }}
     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]
@@ -87,7 +89,8 @@ export const FolderListItem = (props: FolderItemProps) => {
       <ColorPicker
         current={sequence.body.color}
         onChange={color => sequenceEditMaybeSave(sequence, { color })} />
-      <Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}>
+      <Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}
+        draggable={false}>
         <p>{nameWithSaveIndicator}</p>
       </Link>
       <TestButton
@@ -183,6 +186,7 @@ export const SequenceButtonCluster =
         title={t("copy sequence")}
         onClick={() => dispatch(copySequence(sequence))} />
       <i className={"fa fa-arrows-v cluster-icon"}
+        title={t("move sequence")}
         onMouseDown={() => props.startSequenceMove(sequence.uuid)}
         onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
     </div>;
@@ -218,12 +222,18 @@ export const FolderButtonCluster =
       {node.kind !== "terminal" &&
         <div className={"stack-wrapper cluster-icon"}
           title={t("Create subfolder")}
-          onClick={() => { close(); createFolder({ parent_id: node.id }); }}>
+          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(node.id); }}>
+        onClick={() => {
+          close();
+          addNewSequenceToFolder({ id: node.id, color: node.color });
+        }}>
         <PlusStack icon={"fa-server"} />
       </div>
     </div>;
@@ -247,16 +257,33 @@ export const FolderNameInput = ({ node }: FolderNameInputProps) =>
 export const FolderNameEditor = (props: FolderNodeProps) => {
   const { node } = props;
   const [settingsOpen, setSettingsOpen] = React.useState(false);
-  return <div className={["folder-list-item",
-    (props.searchTerm && node.name.toLowerCase()
-      .includes(props.searchTerm.toLowerCase()))
-      ? "matched"
-      : ""].join(" ")}>
+  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-folder"}
+      saucerIcon={`fa-folder${props.movedSequenceUuid ? "-open" : ""}`}
       current={node.color}
       onChange={color => setFolderColor(node.id, color)} />
     <div className="folder-name">
@@ -311,17 +338,12 @@ const FolderNode = (props: FolderNodeProps) => {
       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>}
-    <SequenceDropArea
-      dropAreaVisible={!!props.movedSequenceUuid}
-      onMoveEnd={props.onMoveEnd}
-      toggleSequenceMove={props.toggleSequenceMove}
-      folderId={node.id}
-      folderName={node.name} />
     {!!node.open && folderNodes}
   </div>;
 };
@@ -371,6 +393,7 @@ export class Folders extends React.Component<FolderProps, FolderState> {
           toggleSequenceMove={this.toggleSequenceMove}
           startSequenceMove={this.startSequenceMove}
           onMoveEnd={this.endSequenceMove}
+          dragging={this.state.dragging}
           getWebAppConfigValue={this.props.getWebAppConfigValue}
           sequences={this.props.sequences} />;
       })}
@@ -385,15 +408,17 @@ export class Folders extends React.Component<FolderProps, FolderState> {
   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 });
+    this.setState({ movedSequenceUuid: undefined, dragging: false });
   };
 
   rootSequences = () => this.props.rootFolder.noFolder
@@ -429,13 +454,13 @@ export class Folders extends React.Component<FolderProps, FolderState> {
         <ul className="sequences-not-in-folders">
           {this.rootSequences()}
         </ul>
+        <this.Graph />
         <SequenceDropArea
           dropAreaVisible={!!this.state.movedSequenceUuid}
           onMoveEnd={this.endSequenceMove}
           toggleSequenceMove={this.toggleSequenceMove}
           folderId={0}
           folderName={"none"} />
-        <this.Graph />
       </EmptyStateWrapper>
     </div>;
   }
@@ -452,7 +477,7 @@ export const FolderPanelTop = (props: FolderPanelTopProps) =>
       onClick={props.toggleAll} />
     <button
       className="fb-button green"
-      title={t("Create subfolder")}
+      title={t("create subfolder")}
       onClick={() => { createFolder(); }}>
       <PlusStack icon={"fa-folder"} />
     </button>
diff --git a/frontend/folders/interfaces.ts b/frontend/folders/interfaces.ts
index 1c04800ca..0156a2069 100644
--- a/frontend/folders/interfaces.ts
+++ b/frontend/folders/interfaces.ts
@@ -73,6 +73,7 @@ export interface FolderState {
   toggleDirection: boolean;
   movedSequenceUuid?: string;
   stashedUuid?: string;
+  dragging?: boolean;
 }
 
 export interface FolderPanelTopProps {
@@ -87,6 +88,7 @@ export interface FolderNodeProps {
   movedSequenceUuid: string | undefined;
   startSequenceMove(sequenceUuid: UUID): void;
   toggleSequenceMove(sequenceUuid?: UUID): void;
+  dragging: boolean | undefined;
   onMoveEnd(folderId: number): void;
   dispatch: Function;
   resourceUsage: Record<UUID, boolean | undefined>;
diff --git a/frontend/link.tsx b/frontend/link.tsx
index dd662190b..b474f3859 100644
--- a/frontend/link.tsx
+++ b/frontend/link.tsx
@@ -7,6 +7,7 @@ interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
   style?: React.CSSProperties;
   className?: string;
   disabled?: boolean;
+  draggable?: boolean;
 }
 
 export const clickHandler =
diff --git a/frontend/photos/remote_env/constants.ts b/frontend/photos/remote_env/constants.ts
index 4cef5581d..838ea645c 100644
--- a/frontend/photos/remote_env/constants.ts
+++ b/frontend/photos/remote_env/constants.ts
@@ -50,9 +50,9 @@ export const WD_KEY_DEFAULTS = {
   WEED_DETECTOR_V_HI: 255,
   WEED_DETECTOR_V_LO: 50,
   WEED_DETECTOR_save_detected_plants: SPECIAL_VALUES.FALSE,
-  WEED_DETECTOR_use_bounds: SPECIAL_VALUES.FALSE,
-  WEED_DETECTOR_min_radius: 0,
-  WEED_DETECTOR_max_radius: 0,
+  WEED_DETECTOR_use_bounds: SPECIAL_VALUES.TRUE,
+  WEED_DETECTOR_min_radius: 1.5,
+  WEED_DETECTOR_max_radius: 50,
 };
 
 export type WEED_DETECTOR_KEY_PART =
diff --git a/frontend/resources/interfaces.ts b/frontend/resources/interfaces.ts
index da54452bb..73dc6769c 100644
--- a/frontend/resources/interfaces.ts
+++ b/frontend/resources/interfaces.ts
@@ -82,6 +82,7 @@ export interface ResourceIndex {
     localMetaAttributes: Record<number, FolderMeta>;
     searchTerm?: string;
     filteredFolders?: RootFolderNode | undefined;
+    stashedOpenState?: Record<number, boolean>;
   }
 }
 
diff --git a/frontend/resources/reducer.ts b/frontend/resources/reducer.ts
index 631884f91..507ebd6a9 100644
--- a/frontend/resources/reducer.ts
+++ b/frontend/resources/reducer.ts
@@ -208,15 +208,20 @@ export const resourceReducer =
           root: s.index.sequenceFolders.folders
         });
         const { localMetaAttributes } = s.index.sequenceFolders;
-        Object /** Expand all folders when searching. */
-          .keys(localMetaAttributes)
-          .map(x => {
-            s
-              .index
-              .sequenceFolders
-              .localMetaAttributes[x as unknown as number]
-              .open = true;
+        const folderIds = Object.keys(localMetaAttributes) as unknown as number[];
+        /** Stash folder expansion state upon search start. */
+        if (!s.index.sequenceFolders.stashedOpenState) {
+          const stashedOpenState: Record<number, boolean> = {};
+          folderIds.map(id => {
+            stashedOpenState[id] =
+              !!s.index.sequenceFolders.localMetaAttributes[id].open;
           });
+          s.index.sequenceFolders.stashedOpenState = stashedOpenState;
+        }
+        /** Expand all folders when searching. */
+        folderIds.map(id => {
+          s.index.sequenceFolders.localMetaAttributes[id].open = true;
+        });
         const nextFolder = ingest({
           localMetaAttributes,
           folders
@@ -240,6 +245,14 @@ export const resourceReducer =
         s.index.sequenceFolders.filteredFolders = nextFolder;
       } else {
         s.index.sequenceFolders.filteredFolders = undefined;
+        /** Restore pre-search folder open state. */
+        const stashed = Object.entries(
+          s.index.sequenceFolders.stashedOpenState || {},
+        ) as unknown as [number, boolean][];
+        stashed.map(([id, open]) => {
+          s.index.sequenceFolders.localMetaAttributes[id].open = open;
+        });
+        s.index.sequenceFolders.stashedOpenState = undefined;
       }
       reindexFolders(s.index);
       return s;
diff --git a/frontend/resources/reducer_support.ts b/frontend/resources/reducer_support.ts
index 5a20a692e..7f990f98f 100644
--- a/frontend/resources/reducer_support.ts
+++ b/frontend/resources/reducer_support.ts
@@ -102,7 +102,8 @@ export const reindexFolders = (i: ResourceIndex) => {
     searchTerm: searchTerm,
     filteredFolders: searchTerm
       ? i.sequenceFolders.filteredFolders
-      : undefined
+      : undefined,
+    stashedOpenState: i.sequenceFolders.stashedOpenState,
   };
 
   if (i.sequenceFolders.filteredFolders) {


Test patch:
diff --git a/frontend/folders/__tests__/actions_test.ts b/frontend/folders/__tests__/actions_test.ts
index 9c5502257..8bd198365 100644
--- a/frontend/folders/__tests__/actions_test.ts
+++ b/frontend/folders/__tests__/actions_test.ts
@@ -92,11 +92,35 @@ describe("setFolderName", () => {
 });
 
 describe("addNewSequenceToFolder", () => {
-  it("Adds a new sequence to a folder", () => {
-    addNewSequenceToFolder(11);
+  it("adds a new sequence", () => {
+    addNewSequenceToFolder();
     expect(setActiveSequenceByName).toHaveBeenCalled();
     expect(init).toHaveBeenCalledWith("Sequence", expect.objectContaining({
-      name: "new sequence 1"
+      name: "new sequence 1",
+      color: "gray",
+      folder_id: undefined,
+    }));
+    expect(push).toHaveBeenCalledWith(Path.sequences("new_sequence_1"));
+  });
+
+  it("adds a new sequence to a folder", () => {
+    addNewSequenceToFolder({ id: 11 });
+    expect(setActiveSequenceByName).toHaveBeenCalled();
+    expect(init).toHaveBeenCalledWith("Sequence", expect.objectContaining({
+      name: "new sequence 1",
+      color: "gray",
+      folder_id: 11,
+    }));
+    expect(push).toHaveBeenCalledWith(Path.sequences("new_sequence_1"));
+  });
+
+  it("adds a new sequence to a folder with a color", () => {
+    addNewSequenceToFolder({ id: 11, color: "blue" });
+    expect(setActiveSequenceByName).toHaveBeenCalled();
+    expect(init).toHaveBeenCalledWith("Sequence", expect.objectContaining({
+      name: "new sequence 1",
+      color: "blue",
+      folder_id: 11,
     }));
     expect(push).toHaveBeenCalledWith(Path.sequences("new_sequence_1"));
   });
diff --git a/frontend/folders/__tests__/component_test.tsx b/frontend/folders/__tests__/component_test.tsx
index a2dfdb0b1..6b0d44850 100644
--- a/frontend/folders/__tests__/component_test.tsx
+++ b/frontend/folders/__tests__/component_test.tsx
@@ -395,6 +395,20 @@ describe("<FolderListItem />", () => {
     });
   });
 
+  it("starts sequence move: drag start", () => {
+    const p = fakeProps();
+    const wrapper = mount(<FolderListItem {...p} />);
+    wrapper.simulate("dragStart", { dataTransfer: { setData: jest.fn() } });
+    expect(p.startSequenceMove).toHaveBeenCalledWith(p.sequence.uuid);
+  });
+
+  it("starts sequence move: drag end", () => {
+    const p = fakeProps();
+    const wrapper = mount(<FolderListItem {...p} />);
+    wrapper.simulate("dragEnd");
+    expect(p.toggleSequenceMove).toHaveBeenCalled();
+  });
+
   it("starts sequence move", () => {
     const p = fakeProps();
     const wrapper = mount(<FolderListItem {...p} />);
@@ -452,7 +466,10 @@ describe("<FolderButtonCluster />", () => {
     const wrapper = mount(<FolderButtonCluster {...p} />);
     wrapper.find(".cluster-icon").at(2).simulate("click");
     expect(p.close).toHaveBeenCalled();
-    expect(createFolder).toHaveBeenCalledWith({ parent_id: p.node.id });
+    expect(createFolder).toHaveBeenCalledWith({
+      parent_id: p.node.id,
+      color: "gray",
+    });
   });
 
   it("creates new sequence", () => {
@@ -461,7 +478,10 @@ describe("<FolderButtonCluster />", () => {
     const wrapper = mount(<FolderButtonCluster {...p} />);
     wrapper.find(".cluster-icon").at(3).simulate("click");
     expect(p.close).toHaveBeenCalled();
-    expect(addNewSequenceToFolder).toHaveBeenCalledWith(1);
+    expect(addNewSequenceToFolder).toHaveBeenCalledWith({
+      id: 1,
+      color: "gray",
+    });
   });
 });
 
@@ -506,6 +526,7 @@ describe("<FolderNameEditor />", () => {
     menuOpen: undefined,
     syncStatus: undefined,
     searchTerm: undefined,
+    dragging: false,
   });
 
   it("renders", () => {
@@ -534,6 +555,35 @@ describe("<FolderNameEditor />", () => {
     expect(wrapper.find(".fa-ellipsis-v").hasClass("open")).toBeTruthy();
   });
 
+  it("hovers", () => {
+    const p = fakeProps();
+    const wrapper = mount(<FolderNameEditor {...p} />);
+    expect(wrapper.find(".folder-list-item").hasClass("hovered")).toBeFalsy();
+    wrapper.find(".folder-list-item").simulate("dragEnter");
+    expect(wrapper.find(".folder-list-item").hasClass("hovered")).toBeTruthy();
+    wrapper.find(".folder-list-item").simulate("dragLeave");
+    expect(wrapper.find(".folder-list-item").hasClass("hovered")).toBeFalsy();
+    wrapper.find(".folder-list-item").simulate("dragOver");
+    wrapper.find(".folder-list-item").simulate("dragEnter");
+    expect(wrapper.find(".folder-list-item").hasClass("hovered")).toBeTruthy();
+    wrapper.find(".folder-list-item").simulate("drop");
+    expect(wrapper.find(".folder-list-item").hasClass("hovered")).toBeFalsy();
+  });
+
+  it("renders: moving", () => {
+    const p = fakeProps();
+    p.movedSequenceUuid = "fake";
+    const wrapper = mount(<FolderNameEditor {...p} />);
+    expect(wrapper.find(".folder-list-item").hasClass("moving")).toBeTruthy();
+  });
+
+  it("renders: dragging", () => {
+    const p = fakeProps();
+    p.dragging = true;
+    const wrapper = mount(<FolderNameEditor {...p} />);
+    expect(wrapper.find(".folder-list-item").hasClass("not-dragging")).toBeFalsy();
+  });
+
   it("renders: folder closed", () => {
     const p = fakeProps();
     p.node.open = false;
diff --git a/frontend/folders/__tests__/reducer_test.ts b/frontend/folders/__tests__/reducer_test.ts
index fd32b6b69..90ace6a8b 100644
--- a/frontend/folders/__tests__/reducer_test.ts
+++ b/frontend/folders/__tests__/reducer_test.ts
@@ -81,12 +81,16 @@ describe("Actions.FOLDER_SEARCH", () => {
 
   it("searches folders", () => {
     const state = initialState();
+    state.index.sequenceFolders.stashedOpenState = { 1: true };
     const action = { type: Actions.FOLDER_SEARCH, payload: "" };
     const { index } = resourceReducer(state, action);
     expect(index.sequenceFolders.filteredFolders).toBeUndefined();
     expect(index.sequenceFolders.searchTerm).toBe("");
+    expect(index.sequenceFolders.localMetaAttributes[1].open).toBeTruthy();
+    expect(index.sequenceFolders.stashedOpenState).toBeUndefined();
 
     const action2 = { type: Actions.FOLDER_SEARCH, payload: "" };
+    state.index.sequenceFolders.stashedOpenState = undefined;
     const index2 = resourceReducer(state, action2).index;
     expect(index2.sequenceFolders.searchTerm).toBe("");
     expect(index2.sequenceFolders.filteredFolders).toBeUndefined();