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

Issue:
Sequence list icon cluster revamp
- [ ] Add quick-access RUN buttons
- [ ] All other quick actions in 3-dot menu
- [ ] Add all sequence infos into tooltip

<img width="908" alt="Screen Shot 2022-07-22 at 12 45 41 PM" src="https://user-images.githubusercontent.com/5524043/180516825-a83499a1-80e3-4924-abb3-29b1cdb5bf00.png">


Pull request:
Sequence list info and actions
fixes #2364

Patch:
diff --git a/Gemfile.lock b/Gemfile.lock
index 58ee93b3a..54e1ad365 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -71,8 +71,8 @@ GEM
       minitest (>= 5.1)
       tzinfo (~> 2.0)
       zeitwerk (~> 2.3)
-    addressable (2.8.0)
-      public_suffix (>= 2.0.2, < 5.0)
+    addressable (2.8.1)
+      public_suffix (>= 2.0.2, < 6.0)
     amq-protocol (2.3.2)
     bcrypt (3.1.18)
     builder (3.2.4)
@@ -120,7 +120,7 @@ GEM
       railties (>= 5.0.0)
     faker (2.22.0)
       i18n (>= 1.8.11, < 2)
-    faraday (1.10.1)
+    faraday (1.10.2)
       faraday-em_http (~> 1.0)
       faraday-em_synchrony (~> 1.0)
       faraday-excon (~> 1.1)
@@ -233,7 +233,7 @@ GEM
       method_source (~> 1.0)
     pry-rails (0.3.9)
       pry (>= 0.10.4)
-    public_suffix (4.0.7)
+    public_suffix (5.0.0)
     rabbitmq_http_api_client (2.2.0)
       addressable (~> 2.7)
       faraday (~> 1.3)
@@ -281,7 +281,7 @@ GEM
       thor (~> 1.0)
     rake (13.0.6)
     rbtree (0.4.5)
-    redis (4.7.1)
+    redis (4.8.0)
     representable (3.2.0)
       declarative (< 0.1.0)
       trailblazer-option (>= 0.1.1, < 0.2.0)
diff --git a/frontend/css/sequences.scss b/frontend/css/sequences.scss
index 653ddeea0..2287ebebf 100644
--- a/frontend/css/sequences.scss
+++ b/frontend/css/sequences.scss
@@ -344,6 +344,13 @@
         display: flex !important;
       }
     }
+    &.hovered {
+      .fa-question-circle,
+      .run-btn,
+      .fa-ellipsis-v {
+        display: block;
+      }
+    }
   }
 }
 
@@ -787,33 +794,40 @@
       width: 3rem;
       font-size: 1.1rem;
     }
-    .folder-settings-icon,
-    .fa-arrows-v {
+    .folder-settings-icon {
       position: absolute;
       right: 0;
     }
-    .sequence-settings-icon {
-      .bp-popover-target {
-        position: absolute;
-        right: 2rem;
+    .fb-button-popover-wrapper {
+      margin-right: 0.25rem;
+      button {
+        margin-top: 0.65rem;
       }
     }
-    .fa-arrows-v, .fa-ellipsis-v {
+    .fa-question-circle,
+    .run-btn,
+    .fa-ellipsis-v {
       display: none;
     }
+    .fa-question-circle,
+    .run-btn,
     .fa-ellipsis-v {
       &.open {
         display: block;
       }
     }
     @media screen and (max-width: 450px) {
-      .fa-arrows-v, .fa-ellipsis-v {
+      .fa-question-circle,
+      .run-btn,
+      .fa-ellipsis-v {
         display: block;
         margin-right: 0.5rem;
       }
     }
     &:hover {
-      .fa-arrows-v, .fa-ellipsis-v {
+      .fa-question-circle,
+      .run-btn,
+      .fa-ellipsis-v {
         display: block;
       }
     }
@@ -830,7 +844,7 @@
       height: 1.2rem;
     }
     a {
-      width: 100%;
+      width: 74%;
     }
     p {
       overflow: hidden;
@@ -872,20 +886,6 @@
       display: flex;
       position: absolute;
       right: 0;
-      .fa-exclamation-triangle {
-        color: $orange;
-      }
-      .fa-hdd-o,
-      .fa-exclamation-triangle,
-      .fa-thumb-tack,
-      .fa-chain-broken,
-      .fa-link,
-      .fa-globe {
-        width: 1.75rem;
-      }
-      .icon-spacer {
-        margin-right: 5rem;
-      }
     }
     button {
       margin-left: 0.5rem;
@@ -913,6 +913,7 @@
       top: 0.55rem;
       margin: auto;
       margin-top: 0.6rem;
+      margin-right: 0;
     }
   }
   .folder-list-item {
@@ -930,6 +931,66 @@
   }
 }
 
+.sequence-item-help {
+  .fa-exclamation-triangle {
+    color: $orange;
+  }
+  .fa-hdd-o,
+  .fa-exclamation-triangle,
+  .fa-thumb-tack,
+  .fa-chain-broken,
+  .fa-link,
+  .fa-globe {
+    display: inline;
+    margin-right: 1rem;
+  }
+  p {
+    display: inline;
+    vertical-align: top;
+    line-height: 1.75rem;
+    color: $off_white;
+  }
+  hr {
+    border-color: $gray;
+    margin-left: -1rem;
+    margin-top: 0.5rem;
+    margin-bottom: 0.5rem;
+    width: 106.5%;
+  }
+  label {
+    display: block;
+    color: $off_white;
+  }
+}
+
+.sequence-item-action-menu {
+  i {
+    color: $off_white;
+    margin-left: 0.5rem !important;
+    margin-right: 0.5rem !important;
+  }
+}
+
+.sequence-item-description,
+.sequence-item-action-menu {
+  .bp4-popover-arrow-fill {
+    fill: $dark_gray;
+  }
+  .bp4-popover-content {
+    background: $dark_gray;
+  }
+}
+
+.sequence-item-description {
+  .bp4-popover-content {
+    max-height: 20rem;
+    overflow-y: auto;
+    width: 32rem;
+    color: $white;
+    background: $dark_gray;
+  }
+}
+
 .featured-sequence-list {
   .saucer {
     margin-left: 0.4rem !important;
diff --git a/frontend/folders/component.tsx b/frontend/folders/component.tsx
index 5e35a2025..8e3d93d2c 100644
--- a/frontend/folders/component.tsx
+++ b/frontend/folders/component.tsx
@@ -5,6 +5,7 @@ import {
   EmptyStateGraphic,
   ColorPicker,
   Popover,
+  Markdown,
 } from "../ui";
 import {
   FolderUnion,
@@ -52,6 +53,8 @@ import {
 } from "../sequences/sequence_editor_middle_active";
 import { Path } from "../internal_urls";
 import { copySequence } from "../sequences/actions";
+import { TestButton } from "../sequences/test_button";
+import { TaggedSequence } from "farmbot";
 
 export const FolderListItem = (props: FolderItemProps) => {
   const { sequence, movedSequenceUuid, inUse } = props;
@@ -60,12 +63,11 @@ export const FolderListItem = (props: FolderItemProps) => {
   const moveSource = movedSequenceUuid === sequence.uuid ? "move-source" : "";
   const nameWithSaveIndicator = seqName + (sequence.specialStatus ? "*" : "");
   const active = lastUrlChunk() === urlFriendly(seqName) ? "active" : "";
-  const deprecatedSteps = JSON.stringify(props.sequence.body.body)
-    .includes("resource_update");
-  const { pinned, forked, sequence_version_id } = props.sequence.body;
-  const imported = sequence_version_id && !forked;
-  const published = isSequencePublished(sequence);
   const [settingsOpen, setSettingsOpen] = React.useState(false);
+  const [descriptionOpen, setDescriptionOpen] = React.useState(false);
+  const hovered = props.menuOpen == sequence.uuid || settingsOpen || descriptionOpen
+    ? "hovered"
+    : "";
   return <StepDragger
     dispatch={props.dispatch}
     step={{
@@ -76,7 +78,7 @@ export const FolderListItem = (props: FolderItemProps) => {
     intent="step_splice"
     draggerId={NULL_DRAGGER_ID}
     resourceUuid={sequence.uuid}>
-    <li className={`sequence-list-item ${active} ${moveSource}`}
+    <li className={`sequence-list-item ${active} ${moveSource} ${hovered}`}
       draggable={true}>
       <ColorPicker
         current={sequence.body.color}
@@ -84,59 +86,98 @@ export const FolderListItem = (props: FolderItemProps) => {
       <Link to={url} key={sequence.uuid} onClick={setActiveSequenceByName}>
         <p>{nameWithSaveIndicator}</p>
       </Link>
-      <div className={"sequence-list-item-icons"}>
-        {deprecatedSteps &&
-          <i className={"fa fa-exclamation-triangle"}
-            title={t(Content.INCLUDES_DEPRECATED_STEPS)} />}
-        {inUse &&
-          <i className={"in-use fa fa-hdd-o"}
-            title={t(Content.IN_USE)} />}
-        {pinned &&
-          <i className={"fa fa-thumb-tack"}
-            title={t(Content.IS_PINNED)} />}
-        {forked &&
-          <i className={"fa fa-chain-broken"}
-            title={t("Imported and edited publicly shared sequence.")} />}
-        {imported &&
-          <i className={"fa fa-link"}
-            title={t("Imported publicly shared sequence.")} />}
-        {published &&
-          <i className={"fa fa-globe"}
-            title={t("Published as a publicly shared sequence.")} />}
-        <div className={"icon-spacer"} />
-        <Popover className={"sequence-settings-icon"} usePortal={false}
-          isOpen={settingsOpen}
-          target={<i className={`fa fa-ellipsis-v ${settingsOpen ? "open" : ""}`}
-            onClick={() => setSettingsOpen(!settingsOpen)} />}
-          content={<SequenceButtonCluster {...props} />} />
-        <i className={"fa fa-arrows-v"}
-          onMouseDown={() => props.startSequenceMove(sequence.uuid)}
-          onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
-      </div>
+      <TestButton
+        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"}>
+    {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.")} />}
+    {hasInfo && <hr />}
+    <label>{t("Description")}</label>
+    <Markdown>{description || t("This sequence has no description.")}</Markdown>
+  </div>;
+};
+
+interface InfoRowProps {
+  className: string;
+  description: string;
+}
+
+const InfoRow = (props: InfoRowProps) =>
+  <div className={"sequence-item-icon-info-row"}>
+    <i className={props.className} />
+    <p>{props.description}</p>
+  </div>;
+
 export const SequenceButtonCluster =
   (props: SequenceButtonClusterProps) => {
     const { dispatch, getWebAppConfigValue, sequence } = props;
     return <div className="folder-button-cluster">
-      <button
-        className={"fb-button yellow"}
-        title={t("copy sequence")}
-        onClick={() => dispatch(copySequence(sequence))}>
-        <i className={"fa fa-copy"} />
-      </button>
-      <button
-        className={"fb-button red"}
+      <i
+        className={"fa fa-trash"}
         title={t("delete sequence")}
         onClick={deleteSequence({
           sequenceUuid: sequence.uuid,
           getWebAppConfigValue,
           dispatch,
-        })}>
-        <i className={"fa fa-trash"} />
-      </button>
+        })} />
+      <i
+        className={"fa fa-copy"}
+        title={t("copy sequence")}
+        onClick={() => dispatch(copySequence(sequence))} />
+      <i className={"fa fa-arrows-v"}
+        onMouseDown={() => props.startSequenceMove(sequence.uuid)}
+        onMouseUp={() => props.toggleSequenceMove(sequence.uuid)} />
     </div>;
   };
 
@@ -248,6 +289,9 @@ const FolderNode = (props: FolderNodeProps) => {
         toggleSequenceMove={props.toggleSequenceMove}
         startSequenceMove={props.startSequenceMove}
         getWebAppConfigValue={props.getWebAppConfigValue}
+        menuOpen={props.menuOpen}
+        syncStatus={props.syncStatus}
+        resources={props.resources}
         movedSequenceUuid={props.movedSequenceUuid} />);
 
   const childFolders: FolderUnion[] = node.children || [];
@@ -261,6 +305,9 @@ const FolderNode = (props: FolderNodeProps) => {
       resourceUsage={props.resourceUsage}
       movedSequenceUuid={props.movedSequenceUuid}
       getWebAppConfigValue={props.getWebAppConfigValue}
+      menuOpen={props.menuOpen}
+      syncStatus={props.syncStatus}
+      resources={props.resources}
       toggleSequenceMove={props.toggleSequenceMove}
       startSequenceMove={props.startSequenceMove}
       onMoveEnd={props.onMoveEnd} />);
@@ -315,6 +362,9 @@ export class Folders extends React.Component<FolderProps, FolderState> {
           dispatch={this.props.dispatch}
           sequenceMetas={this.props.sequenceMetas}
           resourceUsage={this.props.resourceUsage}
+          menuOpen={this.props.menuOpen}
+          syncStatus={this.props.syncStatus}
+          resources={this.props.resources}
           movedSequenceUuid={this.state.movedSequenceUuid}
           toggleSequenceMove={this.toggleSequenceMove}
           startSequenceMove={this.startSequenceMove}
@@ -354,6 +404,9 @@ export class Folders extends React.Component<FolderProps, FolderState> {
         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}
         toggleSequenceMove={this.toggleSequenceMove}
         startSequenceMove={this.startSequenceMove}
         movedSequenceUuid={this.state.movedSequenceUuid} />);
diff --git a/frontend/folders/interfaces.ts b/frontend/folders/interfaces.ts
index eec53e7e3..1e08362fd 100644
--- a/frontend/folders/interfaces.ts
+++ b/frontend/folders/interfaces.ts
@@ -1,8 +1,8 @@
 import { Color } from "farmbot/dist/corpus";
-import { TaggedSequence } from "farmbot";
+import { SyncStatus, TaggedSequence } from "farmbot";
 import { DeepPartial } from "redux";
 import { Folder } from "farmbot/dist/resources/api_resources";
-import { VariableNameSet, UUID } from "../resources/interfaces";
+import { VariableNameSet, UUID, ResourceIndex } from "../resources/interfaces";
 import { GetWebAppConfigValue } from "../config_storage/actions";
 
 export interface FolderMeta {
@@ -66,6 +66,9 @@ export interface FolderProps {
   resourceUsage: Record<UUID, boolean | undefined>;
   sequenceMetas: Record<UUID, VariableNameSet | undefined>;
   getWebAppConfigValue: GetWebAppConfigValue;
+  resources: ResourceIndex;
+  menuOpen: UUID | undefined;
+  syncStatus: SyncStatus | undefined;
 }
 
 export interface FolderState {
@@ -91,12 +94,17 @@ export interface FolderNodeProps {
   resourceUsage: Record<UUID, boolean | undefined>;
   sequenceMetas: Record<UUID, VariableNameSet | undefined>;
   getWebAppConfigValue: GetWebAppConfigValue;
+  resources: ResourceIndex;
+  menuOpen: UUID | undefined;
+  syncStatus: SyncStatus | undefined;
 }
 
 export interface SequenceButtonClusterProps {
   sequence: TaggedSequence;
   getWebAppConfigValue: GetWebAppConfigValue;
   dispatch: Function;
+  startSequenceMove(sequenceUuid: UUID): void;
+  toggleSequenceMove(sequenceUuid?: UUID): void;
 }
 
 export interface FolderButtonClusterProps {
@@ -117,6 +125,9 @@ export interface FolderItemProps {
   variableData: VariableNameSet | undefined;
   inUse: boolean;
   getWebAppConfigValue: GetWebAppConfigValue;
+  resources: ResourceIndex;
+  menuOpen: UUID | undefined;
+  syncStatus: SyncStatus | undefined;
 }
 
 export interface SequenceDropAreaProps {
diff --git a/frontend/folders/map_state_to_props.ts b/frontend/folders/map_state_to_props.ts
index f1d784c1a..88ce058c5 100644
--- a/frontend/folders/map_state_to_props.ts
+++ b/frontend/folders/map_state_to_props.ts
@@ -23,5 +23,8 @@ export function mapStateToFolderProps(props: Everything): FolderProps {
     sequenceMetas: props.resources.index.sequenceMetas,
     resourceUsage: resourceUsageList(props.resources.index.inUse),
     getWebAppConfigValue: getWebAppConfigValue(() => props),
+    menuOpen: props.resources.consumers.sequences.menuOpen,
+    resources: props.resources.index,
+    syncStatus: props.bot.hardware.informational_settings.sync_status,
   };
 }
diff --git a/frontend/settings/account/actions.ts b/frontend/settings/account/actions.ts
index 0931c5084..af864c3c6 100644
--- a/frontend/settings/account/actions.ts
+++ b/frontend/settings/account/actions.ts
@@ -10,12 +10,8 @@ import { t } from "../../i18next_wrapper";
 export function deleteUser(payload: DeletionRequest): Thunk {
   return (_, getState) => {
     const { auth } = getState();
-    auth && axios({
-      method: "delete",
-      url: API.current.usersPath,
-      data: payload,
-      params: { force: true }
-    })
+    auth && axios.delete(API.current.usersPath,
+      { data: payload, params: { force: true } })
       .then(() => {
         alert("We're sorry to see you go. :(");
         Session.clear();
@@ -28,10 +24,6 @@ export function deleteUser(payload: DeletionRequest): Thunk {
 
 export const resetAccount = (payload: DeletionRequest): Thunk =>
   (_, getState) =>
-    getState().auth && axios({
-      method: "post",
-      url: API.current.accountResetPath,
-      data: payload,
-    })
+    getState().auth && axios.post(API.current.accountResetPath, payload)
       .then(() => alert(t("Account has been reset.")))
       .catch((err: UnsafeError) => toastErrors({ err }));
diff --git a/package.json b/package.json
index 428a0a9db..e4c996016 100644
--- a/package.json
+++ b/package.json
@@ -30,15 +30,15 @@
   "author": "farmbot.io",
   "license": "MIT",
   "dependencies": {
-    "@blueprintjs/core": "4.8.0",
-    "@blueprintjs/popover2": "1.5.1",
-    "@blueprintjs/select": "4.5.3",
+    "@blueprintjs/core": "4.9.1",
+    "@blueprintjs/popover2": "1.6.1",
+    "@blueprintjs/select": "4.6.1",
     "@monaco-editor/react": "4.4.5",
     "@parcel/transformer-sass": "2.7.0",
     "@parcel/transformer-typescript-tsc": "2.7.0",
-    "@types/lodash": "4.14.183",
+    "@types/lodash": "4.14.184",
     "@types/markdown-it": "12.2.3",
-    "@types/node": "18.7.6",
+    "@types/node": "18.7.13",
     "@types/promise-timeout": "1.3.0",
     "@types/react": "18.0.17",
     "@types/react-color": "3.0.6",
@@ -77,10 +77,9 @@
   },
   "devDependencies": {
     "@types/enzyme": "3.10.12",
-    "@types/jest": "28.1.7",
-    "@types/moxios": "0.4.15",
-    "@typescript-eslint/eslint-plugin": "5.33.1",
-    "@typescript-eslint/parser": "5.33.1",
+    "@types/jest": "28.1.8",
+    "@typescript-eslint/eslint-plugin": "5.34.0",
+    "@typescript-eslint/parser": "5.34.0",
     "cheerio": "1.0.0-rc.10",
     "coveralls": "3.1.1",
     "enzyme": "3.11.0",
@@ -88,7 +87,7 @@
     "eslint": "8.22.0",
     "eslint-plugin-eslint-comments": "3.2.0",
     "eslint-plugin-import": "2.26.0",
-    "eslint-plugin-jest": "26.8.3",
+    "eslint-plugin-jest": "26.8.7",
     "eslint-plugin-no-null": "1.0.2",
     "eslint-plugin-promise": "6.0.0",
     "eslint-plugin-react": "7.30.1",
@@ -100,12 +99,11 @@
     "jest-skipped-reporter": "0.0.5",
     "jshint": "2.13.5",
     "madge": "5.0.1",
-    "moxios": "0.4.0",
     "parse5": "6.0.1",
     "raf": "3.4.1",
     "react-addons-test-utils": "15.6.2",
     "react-test-renderer": "16.13.1",
-    "sass": "1.54.4",
+    "sass": "1.54.5",
     "sass-lint": "1.13.1",
     "ts-jest": "28.0.8",
     "tslint": "6.1.3",


Test patch:
diff --git a/frontend/folders/__tests__/component_test.tsx b/frontend/folders/__tests__/component_test.tsx
index 8aa0fdef0..7da876f09 100644
--- a/frontend/folders/__tests__/component_test.tsx
+++ b/frontend/folders/__tests__/component_test.tsx
@@ -28,8 +28,10 @@ jest.mock("@blueprintjs/core", () => ({
 }));
 
 import { PopoverProps } from "../../ui/popover";
+let mockPopover = ({ target, content }: PopoverProps) =>
+  <div>{target}{content}</div>;
 jest.mock("../../ui/popover", () => ({
-  Popover: ({ target, content }: PopoverProps) => <div>{target}{content}</div>,
+  Popover: jest.fn((p: PopoverProps) => mockPopover(p)),
 }));
 
 jest.mock("@blueprintjs/select", () => ({
@@ -70,6 +72,7 @@ import { SpecialStatus, Color, SequenceBodyItem } from "farmbot";
 import { SearchField } from "../../ui/search_field";
 import { Path } from "../../internal_urls";
 import { copySequence } from "../../sequences/actions";
+import { fakeResourceIndex } from "../../sequences/locals_list/test_helpers";
 
 const fakeRootFolder = (): FolderNodeInitial => ({
   kind: "initial",
@@ -107,6 +110,9 @@ describe("<Folders />", () => {
     resourceUsage: {},
     sequenceMetas: {},
     getWebAppConfigValue: jest.fn(),
+    resources: fakeResourceIndex(),
+    menuOpen: undefined,
+    syncStatus: undefined,
   });
 
   it("renders empty state", () => {
@@ -251,6 +257,14 @@ describe("<FolderListItem />", () => {
     variableData: undefined,
     inUse: false,
     getWebAppConfigValue: jest.fn(),
+    resources: fakeResourceIndex(),
+    menuOpen: undefined,
+    syncStatus: undefined,
+  });
+
+  beforeEach(() => {
+    mockPopover = ({ target, content }: PopoverProps) =>
+      <div>{target}{content}</div>;
   });
 
   it("renders", () => {
@@ -289,7 +303,7 @@ describe("<FolderListItem />", () => {
     const p = fakeProps();
     p.inUse = true;
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".in-use").length).toEqual(1);
+    expect(wrapper.find(".in-use").length).toBeGreaterThanOrEqual(1);
   });
 
   it("renders: in use and has bad steps", () => {
@@ -299,8 +313,9 @@ describe("<FolderListItem />", () => {
       { kind: "resource_update", args: {} } as unknown as SequenceBodyItem,
     ];
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".in-use").length).toEqual(1);
-    expect(wrapper.find(".fa-exclamation-triangle").length).toEqual(1);
+    expect(wrapper.find(".in-use").length).toBeGreaterThanOrEqual(1);
+    expect(wrapper.find(".fa-exclamation-triangle").length)
+      .toBeGreaterThanOrEqual(1);
   });
 
   it("renders: in use and pinned", () => {
@@ -308,8 +323,8 @@ describe("<FolderListItem />", () => {
     p.inUse = true;
     p.sequence.body.pinned = true;
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".in-use").length).toEqual(1);
-    expect(wrapper.find(".fa-thumb-tack").length).toEqual(1);
+    expect(wrapper.find(".in-use").length).toBeGreaterThanOrEqual(1);
+    expect(wrapper.find(".fa-thumb-tack").length).toBeGreaterThanOrEqual(1);
   });
 
   it("renders: imported", () => {
@@ -318,7 +333,7 @@ describe("<FolderListItem />", () => {
     p.sequence.body.forked = false;
     p.sequence.body.sequence_versions = [1];
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".fa-link").length).toEqual(1);
+    expect(wrapper.find(".fa-link").length).toBeGreaterThanOrEqual(1);
   });
 
   it("renders: forked", () => {
@@ -327,7 +342,7 @@ describe("<FolderListItem />", () => {
     p.sequence.body.forked = true;
     p.sequence.body.sequence_versions = [1];
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".fa-chain-broken").length).toEqual(1);
+    expect(wrapper.find(".fa-chain-broken").length).toBeGreaterThanOrEqual(1);
   });
 
   it("renders: published", () => {
@@ -336,7 +351,27 @@ describe("<FolderListItem />", () => {
     p.sequence.body.forked = false;
     p.sequence.body.sequence_versions = [1];
     const wrapper = mount(<FolderListItem {...p} />);
-    expect(wrapper.find(".fa-globe").length).toEqual(1);
+    expect(wrapper.find(".fa-globe").length).toBeGreaterThanOrEqual(1);
+  });
+
+  it("renders: no description", () => {
+    const p = fakeProps();
+    p.sequence.body.description = "";
+    const wrapper = mount(<FolderListItem {...p} />);
+    expect(wrapper.text().toLowerCase())
+      .toContain("this sequence has no description");
+  });
+
+  it("opens pop-ups", () => {
+    mockPopover = ({ target, content, isOpen }: PopoverProps) =>
+      <div>{target}{isOpen ? content : ""}</div>;
+    const wrapper = mount(<FolderListItem {...fakeProps()} />);
+    expect(wrapper.find(".fa-copy").length).toEqual(0);
+    expect(wrapper.text().toLowerCase()).not.toContain("description");
+    wrapper.find(".fa-question-circle").simulate("click");
+    wrapper.find(".fa-ellipsis-v").simulate("click");
+    expect(wrapper.find(".fa-copy").length).toEqual(1);
+    expect(wrapper.text().toLowerCase()).toContain("description");
   });
 
   it("changes color", () => {
@@ -353,14 +388,14 @@ describe("<FolderListItem />", () => {
 
   it("starts sequence move", () => {
     const p = fakeProps();
-    const wrapper = shallow(<FolderListItem {...p} />);
+    const wrapper = mount(<FolderListItem {...p} />);
     wrapper.find(".fa-arrows-v").simulate("mouseDown");
     expect(p.startSequenceMove).toHaveBeenCalledWith(p.sequence.uuid);
   });
 
   it("toggles sequence move", () => {
     const p = fakeProps();
-    const wrapper = shallow(<FolderListItem {...p} />);
+    const wrapper = mount(<FolderListItem {...p} />);
     wrapper.find(".fa-arrows-v").simulate("mouseUp");
     expect(p.toggleSequenceMove).toHaveBeenCalledWith(p.sequence.uuid);
   });
@@ -369,7 +404,7 @@ describe("<FolderListItem />", () => {
     const p = fakeProps();
     const wrapper = mount(<FolderListItem {...p} />);
     wrapper.find(".fa-ellipsis-v").simulate("click");
-    wrapper.find(".fb-button.yellow").simulate("click");
+    wrapper.find(".fa-copy").simulate("click");
     expect(copySequence).toHaveBeenCalledWith(p.sequence);
   });
 });
@@ -458,6 +493,9 @@ describe("<FolderNameEditor />", () => {
     resourceUsage: {},
     sequenceMetas: {},
     getWebAppConfigValue: jest.fn(),
+    resources: fakeResourceIndex(),
+    menuOpen: undefined,
+    syncStatus: undefined,
   });
 
   it("renders", () => {
diff --git a/frontend/password_reset/__tests__/password_reset_test.tsx b/frontend/password_reset/__tests__/password_reset_test.tsx
index bd19bb265..eee4c8367 100644
--- a/frontend/password_reset/__tests__/password_reset_test.tsx
+++ b/frontend/password_reset/__tests__/password_reset_test.tsx
@@ -1,49 +1,46 @@
+let mockPut = Promise.resolve();
+jest.mock("axios", () => ({
+  put: jest.fn(() => mockPut),
+}));
+
 import React from "react";
-import moxios from "moxios";
 import { mount } from "enzyme";
 import { API } from "../../api";
 import { error } from "../../toast/toast";
 import { formEvent, inputEvent } from "../../__test_support__/fake_html_events";
-import { PasswordReset, State } from "../password_reset";
-import { AxiosResponse } from "axios";
+import { PasswordReset } from "../password_reset";
+import axios from "axios";
 
 describe("<PasswordReset/>", () => {
-  API.setBaseUrl("localhost");
-
-  beforeEach(() => moxios.install());
-  afterEach(() => moxios.uninstall());
-
-  const respondWith =
-    (response: { status: number, response: {} }) =>
-      new Promise<Partial<AxiosResponse>>(
-        (resolve: (value: Partial<AxiosResponse>) => void, reject) =>
-          moxios.wait(() => {
-            moxios.requests.mostRecent()
-              .respondWith(response)
-              .then(resolve, reject);
-          }));
+  API.setBaseUrl("");
 
   it("handles form submission errors", async () => {
+    jest.useFakeTimers();
+    mockPut = Promise.reject({ response: { data: "error" } });
     const wrapper = mount<PasswordReset>(<PasswordReset />);
     const e = formEvent();
-    wrapper.instance().submit(e);
+    await wrapper.instance().submit(e);
     expect(e.preventDefault).toHaveBeenCalled();
-    await respondWith({ status: 400, response: { err: "xyz" } });
-    expect(error).toHaveBeenCalledWith("Err: xyz");
+    await expect(axios.put).toHaveBeenCalledWith(":///api/password_resets/",
+      { id: "", password: "", password_confirmation: "" });
+    await expect(error).toHaveBeenCalledWith("Error: error");
+    jest.runAllTimers();
   });
 
   it("handles missing TOS agreement", async () => {
-    const pr = new PasswordReset({});
+    mockPut = Promise.reject({ response: { data: "error", status: 451 } });
+    const wrapper = mount<PasswordReset>(<PasswordReset />);
     const e = formEvent();
-    pr.submit(e);
+    await wrapper.instance().submit(e);
     expect(e.preventDefault).toHaveBeenCalled();
-    await respondWith({ status: 451, response: { err: "xyz" } });
-    expect(error).not.toHaveBeenCalled();
+    await expect(axios.put).toHaveBeenCalledWith(":///api/password_resets/",
+      { id: "", password: "", password_confirmation: "" });
+    await expect(error).not.toHaveBeenCalled();
     expect(window.location.assign).toHaveBeenCalledWith("/tos_update");
   });
 
   it("resets the users password", async () => {
-    expect.assertions(5);
+    mockPut = Promise.resolve();
     const el = mount(<PasswordReset />);
     el.setState({
       password: "knocknock",
@@ -51,14 +48,9 @@ describe("<PasswordReset/>", () => {
       serverURL: "localhost",
       serverPort: "3000"
     });
-    el.find("form").simulate("submit", formEvent());
-    const resp = await respondWith({ status: 200, response: {} });
-    expect(resp.config?.url).toContain("api/password_resets");
-    expect(resp.config?.method).toEqual("put");
-    const json: State = JSON.parse("" + resp.config?.data);
-    expect(Object.keys(json)).toContain("password");
-    expect(Object.keys(json)).toContain("password_confirmation");
-    expect(json.password).toEqual("knocknock");
+    await el.find("form").simulate("submit", formEvent());
+    expect(axios.put).toHaveBeenCalledWith(":///api/password_resets/",
+      { id: "", password: "knocknock", password_confirmation: "knocknock" });
   });
 
   it("has a form set()ter", () => {
diff --git a/frontend/sequences/panel/__tests__/list_test.tsx b/frontend/sequences/panel/__tests__/list_test.tsx
index 35cd257a3..4d0015683 100644
--- a/frontend/sequences/panel/__tests__/list_test.tsx
+++ b/frontend/sequences/panel/__tests__/list_test.tsx
@@ -121,7 +121,7 @@ describe("<DesignerSequenceList />", () => {
     const wrapper = await mount(<DesignerSequenceList {...p} />);
     wrapper.update();
     expect(wrapper.find(".show-on-hover").length).toEqual(2);
-    wrapper.find(".help-icon").first().simulate("click");
+    wrapper.find(".help-icon").last().simulate("click");
     expect(wrapper.find(".show-on-hover").length).toEqual(1);
   });
 
diff --git a/frontend/settings/account/__tests__/actions_test.ts b/frontend/settings/account/__tests__/actions_test.ts
index a89d42d00..4ebac7c10 100644
--- a/frontend/settings/account/__tests__/actions_test.ts
+++ b/frontend/settings/account/__tests__/actions_test.ts
@@ -1,58 +1,74 @@
 jest.mock("../../../toast_errors", () => ({ toastErrors: jest.fn() }));
 
+let mockPost = Promise.resolve();
+let mockDelete = Promise.resolve();
+jest.mock("axios", () => ({
+  post: jest.fn(() => mockPost),
+  delete: jest.fn(() => mockDelete),
+}));
+
 import { API } from "../../../api/api";
-import moxios from "moxios";
 import { deleteUser, resetAccount } from "../actions";
 import { toastErrors } from "../../../toast_errors";
+import axios from "axios";
 
-describe("deleteUser()", () => {
-  beforeEach(() => moxios.install());
-  afterEach(() => moxios.uninstall());
-
-  interface TestArgs {
-    fn: Function;
-    method: "post" | "delete";
-    url: string;
-    code: number;
-  }
+API.setBaseUrl("http://localhost:3000");
+const data = { password: "Foo!" };
+const errorResponse = { response: { data: "error" } };
 
-  /** Set test title string for test case. */
-  const ts = (testCase: TestArgs) => Object.assign(testCase, {
-    toString: () => [testCase.method, testCase.url, testCase.code].join(" "),
+describe("deleteUser()", () => {
+  it("deletes user", async () => {
+    mockDelete = Promise.resolve();
+    window.alert = jest.fn();
+    const dispatch = jest.fn();
+    const getState = jest.fn();
+    getState.mockImplementation(() => ({ auth: {} }));
+    await deleteUser(data)(dispatch, getState);
+    expect(axios.delete).toHaveBeenCalledWith("http://localhost:3000/api/users/",
+      { data, params: { force: true } });
+    expect(toastErrors).not.toHaveBeenCalled();
+    expect(alert).toHaveBeenCalledWith("We're sorry to see you go. :(");
   });
 
-  it.each<TestArgs | jest.DoneCallback>([
-    ts({ fn: deleteUser, method: "delete", url: "api/users", code: 200 }),
-    ts({ fn: deleteUser, method: "delete", url: "api/users", code: 422 }),
-    ts({ fn: resetAccount, method: "post", url: "api/device/reset", code: 200 }),
-    ts({ fn: resetAccount, method: "post", url: "api/device/reset", code: 422 }),
-  ])("%s", (testArgs: TestArgs, done: jest.DoneCallback) => {
-    expect.assertions(4);
-    API.setBaseUrl("http://example.com:80");
-    const thunk = testArgs.fn({ password: "Foo!" });
+  it("doesn't delete user", async () => {
+    mockDelete = Promise.reject(errorResponse);
+    window.alert = jest.fn();
     const dispatch = jest.fn();
     const getState = jest.fn();
     getState.mockImplementation(() => ({ auth: {} }));
+    await deleteUser(data)(dispatch, getState);
+    await expect(axios.delete).toHaveBeenCalledWith(
+      "http://localhost:3000/api/users/",
+      { data, params: { force: true } });
+    expect(toastErrors).toHaveBeenCalledWith({ err: errorResponse });
+    expect(alert).not.toHaveBeenCalled();
+  });
+});
+
+describe("resetAccount()", () => {
+  it("resets account", async () => {
+    mockPost = Promise.resolve();
     window.alert = jest.fn();
-    thunk(dispatch, getState);
+    const dispatch = jest.fn();
+    const getState = jest.fn();
+    getState.mockImplementation(() => ({ auth: {} }));
+    await resetAccount(data)(dispatch, getState);
+    expect(axios.post).toHaveBeenCalledWith(
+      "http://localhost:3000/api/device/reset", data);
+    expect(toastErrors).not.toHaveBeenCalled();
+    expect(alert).toHaveBeenCalledWith("Account has been reset.");
+  });
 
-    moxios.wait(() => {
-      const request = moxios.requests.mostRecent();
-      request.respondWith({
-        status: testArgs.code,
-        response: {},
-      }).then(resp => {
-        expect(resp.config.url).toContain(testArgs.url);
-        expect(resp.config.method).toBe(testArgs.method);
-        if (testArgs.code == 200) {
-          expect(window.alert).toHaveBeenCalled();
-          expect(toastErrors).not.toHaveBeenCalled();
-        } else {
-          expect(window.alert).not.toHaveBeenCalled();
-          expect(toastErrors).toHaveBeenCalled();
-        }
-        done();
-      }, console.log);
-    });
+  it("doesn't reset account", async () => {
+    mockPost = Promise.reject(errorResponse);
+    window.alert = jest.fn();
+    const dispatch = jest.fn();
+    const getState = jest.fn();
+    getState.mockImplementation(() => ({ auth: {} }));
+    await resetAccount(data)(dispatch, getState);
+    expect(axios.post).toHaveBeenCalledWith(
+      "http://localhost:3000/api/device/reset", data);
+    expect(toastErrors).toHaveBeenCalledWith({ err: errorResponse });
+    expect(alert).not.toHaveBeenCalled();
   });
 });
diff --git a/frontend/settings/account/__tests__/change_password_test.tsx b/frontend/settings/account/__tests__/change_password_test.tsx
index 9286d0ce4..cd69e9e3d 100644
--- a/frontend/settings/account/__tests__/change_password_test.tsx
+++ b/frontend/settings/account/__tests__/change_password_test.tsx
@@ -1,12 +1,16 @@
+let mockPatch = Promise.resolve();
+jest.mock("axios", () => ({
+  patch: jest.fn(() => mockPatch),
+}));
+
 import React from "react";
-import moxios from "moxios";
 import { mount } from "enzyme";
 import { ChangePassword, ChangePWState } from "../change_password";
 import { SpecialStatus } from "farmbot";
 import { API } from "../../../api/api";
-import { error } from "../../../toast/toast";
-import { AxiosResponse } from "axios";
+import { error, success } from "../../../toast/toast";
 import { inputEvent } from "../../../__test_support__/fake_html_events";
+import axios from "axios";
 
 describe("<ChangePassword />", () => {
   it("doesn't fire maybeClearForm() if form is filled", () => {
@@ -111,46 +115,37 @@ describe("<ChangePassword />", () => {
   describe("AJAX", () => {
     API.setBaseUrl("localhost");
 
-    beforeEach(() => moxios.install());
-    afterEach(() => moxios.uninstall());
-
-    const respondWith =
-      (response: { status: number, response: {} }) =>
-        new Promise<Partial<AxiosResponse>>(
-          (resolve: (value: Partial<AxiosResponse>) => void, reject) =>
-            moxios.wait(() => {
-              moxios.requests.mostRecent()
-                .respondWith(response)
-                .then(resolve, reject);
-            }));
-
     it("saves (KO)", async () => {
+      mockPatch = Promise.reject({ response: { data: "error" } });
       window.confirm = () => true;
       const el = mount<ChangePassword>(<ChangePassword />);
-      el.instance().state.form = {
+      const form = {
         password: "xxxxxxxx",
         new_password: "bbbbbbbb",
         new_password_confirmation: "bbbbbbbb"
       };
-      el.instance().save();
-      const resp = await respondWith({ status: 422, response: { bad: "data" } });
-      expect(resp.config?.url).toContain("api/users");
-      expect(resp.config?.method).toEqual("patch");
+      el.instance().state.form = form;
+      await el.instance().save();
+      expect(axios.patch).toHaveBeenCalledWith("http://localhost/api/users/", form);
+      expect(error).toHaveBeenCalledWith("Error: error");
+      expect(success).not.toHaveBeenCalled();
       expect(el.instance().state.status).toBe(SpecialStatus.SAVED);
     });
 
     it("saves (OK)", async () => {
+      mockPatch = Promise.resolve();
       window.confirm = () => true;
       const el = mount<ChangePassword>(<ChangePassword />);
-      el.instance().state.form = {
+      const form = {
         password: "aaaaaaaa",
         new_password: "bbbbbbbb",
         new_password_confirmation: "bbbbbbbb"
       };
-      el.instance().save();
-      const resp = await respondWith({ status: 200, response: {} });
-      expect(resp.config?.url).toContain("api/users");
-      expect(resp.config?.method).toEqual("patch");
+      el.instance().state.form = form;
+      await el.instance().save();
+      expect(axios.patch).toHaveBeenCalledWith("http://localhost/api/users/", form);
+      expect(success).toHaveBeenCalledWith("Your password is changed.");
+      expect(error).not.toHaveBeenCalled();
       expect(el.instance().state.status).toBe(SpecialStatus.SAVED);
     });
   });