From 675bcd73f2ddb0741988b4278741c7e0de26c5ef Mon Sep 17 00:00:00 2001
From: Tom Robinson <tlrobinson@gmail.com>
Date: Wed, 28 Feb 2018 17:44:46 +0700
Subject: [PATCH] Various FieldValuesWidget polish

---
 .../metabase/components/FieldValuesWidget.jsx |  98 ++++++++++------
 .../src/metabase/components/TokenField.jsx    |  84 ++++++++------
 .../widgets/ParameterFieldWidget.jsx          |  24 +++-
 .../components/GuiQueryEditor.jsx             |   2 +-
 .../components/filters/FilterPopover.jsx      |  10 +-
 .../components/filters/FilterWidget.jsx       |   2 +-
 .../test/components/TokenField.unit.spec.js   | 106 +++++++++++-------
 .../components.unit.spec.js.snap              |   8 ++
 frontend/test/pulse/pulse.unit.spec.js        |   4 +-
 9 files changed, 219 insertions(+), 119 deletions(-)

diff --git a/frontend/src/metabase/components/FieldValuesWidget.jsx b/frontend/src/metabase/components/FieldValuesWidget.jsx
index a84182116b6..5bfe6afcf59 100644
--- a/frontend/src/metabase/components/FieldValuesWidget.jsx
+++ b/frontend/src/metabase/components/FieldValuesWidget.jsx
@@ -20,6 +20,7 @@ import { stripId } from "metabase/lib/formatting";
 import type Field from "metabase-lib/lib/metadata/Field";
 import type { FieldId } from "metabase/meta/types/Field";
 import type { Value } from "metabase/meta/types/Dataset";
+import type { LayoutRendererProps } from "metabase/components/TokenField";
 
 const MAX_SEARCH_RESULTS = 100;
 
@@ -42,10 +43,10 @@ type Props = {
   placeholder?: string,
   maxWidth?: number,
   minWidth?: number,
+  alwaysShowOptions?: boolean,
 };
 
 type State = {
-  focused: boolean,
   loadingState: "INIT" | "LOADING" | "LOADED",
   options: [Value, ?string][],
   lastValue: string,
@@ -61,7 +62,6 @@ export class FieldValuesWidget extends Component {
   constructor(props: Props) {
     super(props);
     this.state = {
-      focused: false,
       options: [],
       loadingState: "INIT",
       lastValue: "",
@@ -189,6 +189,36 @@ export class FieldValuesWidget extends Component {
     }
   }, 500);
 
+  renderOptions({
+    optionsList,
+    isFocused,
+    isAllSelected,
+  }: LayoutRendererProps) {
+    const { alwaysShowOptions, field, searchField } = this.props;
+    const { loadingState } = this.state;
+    if (alwaysShowOptions || isFocused) {
+      if (optionsList) {
+        return optionsList;
+      } else if (this.hasList()) {
+        if (isAllSelected) {
+          return <EveryOptionState />;
+        }
+      } else if (this.isSearchable()) {
+        if (loadingState === "INIT") {
+          return alwaysShowOptions && <SearchState />;
+        } else if (loadingState === "LOADING") {
+          return <LoadingState />;
+        } else if (loadingState === "LOADED") {
+          if (isAllSelected) {
+            return alwaysShowOptions && <SearchState />;
+          } else {
+            return <NoMatchState field={searchField || field} />;
+          }
+        }
+      }
+    }
+  }
+
   render() {
     const {
       value,
@@ -270,25 +300,10 @@ export class FieldValuesWidget extends Component {
               autoLoad={false}
             />
           )}
-          layoutRenderer={({ valuesList, optionsList, isFiltered }) => (
+          layoutRenderer={props => (
             <div>
-              {valuesList}
-              {this.props.alwaysShowOptions || this.state.focused
-                ? optionsList ||
-                  (this.hasList() && !isFiltered ? (
-                    <OptionsMessage
-                      message={t`Including every option in your filter probably won’t do much…`}
-                    />
-                  ) : this.isSearchable() && loadingState === "LOADED" ? (
-                    <OptionsMessage
-                      message={jt`No matching ${(
-                        <strong>
-                          &nbsp;{(searchField || field).display_name}&nbsp;
-                        </strong>
-                      )} found.`}
-                    />
-                  ) : null)
-                : null}
+              {props.valuesList}
+              {this.renderOptions(props)}
             </div>
           )}
           filterOption={(option, filterString) =>
@@ -319,29 +334,38 @@ export class FieldValuesWidget extends Component {
             }
             return v;
           }}
-          onFocus={() => this.setState({ focused: true })}
-          onBlur={() => this.setState({ focused: false })}
         />
-        {this.isSearchable() && loadingState === "INIT" ? (
-          <div
-            className="flex layout-centered align-center"
-            style={{ minHeight: 100 }}
-          >
-            <Icon name="search" size={35} className="text-grey-1" />
-          </div>
-        ) : this.isSearchable() && loadingState === "LOADING" ? (
-          <div
-            className="flex layout-centered align-center"
-            style={{ minHeight: 100 }}
-          >
-            <LoadingSpinner size={32} />
-          </div>
-        ) : null}
       </div>
     );
   }
 }
 
+const LoadingState = () => (
+  <div className="flex layout-centered align-center" style={{ minHeight: 100 }}>
+    <LoadingSpinner size={32} />
+  </div>
+);
+
+const SearchState = () => (
+  <div className="flex layout-centered align-center" style={{ minHeight: 100 }}>
+    <Icon name="search" size={35} className="text-grey-1" />
+  </div>
+);
+
+const NoMatchState = ({ field }) => (
+  <OptionsMessage
+    message={jt`No matching ${(
+      <strong>&nbsp;{field.display_name}&nbsp;</strong>
+    )} found.`}
+  />
+);
+
+const EveryOptionState = () => (
+  <OptionsMessage
+    message={t`Including every option in your filter probably won’t do much…`}
+  />
+);
+
 const OptionsMessage = ({ message }) => (
   <div className="flex layout-centered p4">{message}</div>
 );
diff --git a/frontend/src/metabase/components/TokenField.jsx b/frontend/src/metabase/components/TokenField.jsx
index 816e7597c6a..d3559b67d2f 100644
--- a/frontend/src/metabase/components/TokenField.jsx
+++ b/frontend/src/metabase/components/TokenField.jsx
@@ -23,13 +23,21 @@ import {
 import { isObscured } from "metabase/lib/dom";
 
 const inputBoxClasses = cxs({
-  maxHeight: "130px",
+  maxHeight: 130,
   overflow: "scroll",
 });
 
 type Value = any;
 type Option = any;
 
+export type LayoutRendererProps = {
+  valuesList: React$Element<any>,
+  optionsList: ?React$Element<any>,
+  isFocused: boolean,
+  isAllSelected: boolean,
+  onClose: () => void,
+};
+
 type Props = {
   value: Value[],
   onChange: (value: Value[]) => void,
@@ -61,12 +69,7 @@ type Props = {
 
   valueRenderer: (value: Value) => React$Element<any>,
   optionRenderer: (option: Option) => React$Element<any>,
-  layoutRenderer: ({
-    valuesList: React$Element<any>,
-    optionsList: ?React$Element<any>,
-    focused: boolean,
-    onClose: () => void,
-  }) => React$Element<any>,
+  layoutRenderer: (props: LayoutRendererProps) => React$Element<any>,
 };
 
 type State = {
@@ -74,7 +77,8 @@ type State = {
   searchValue: string,
   filteredOptions: Option[],
   selectedOptionValue: ?Value,
-  focused: boolean,
+  isFocused: boolean,
+  isAllSelected: boolean,
   listIsHovered: boolean,
 };
 
@@ -93,7 +97,8 @@ export default class TokenField extends Component {
       searchValue: "",
       filteredOptions: [],
       selectedOptionValue: null,
-      focused: props.autoFocus || false,
+      isFocused: props.autoFocus || false,
+      isAllSelected: false,
       listIsHovered: false,
     };
   }
@@ -204,18 +209,30 @@ export default class TokenField extends Component {
         String(this._label(option) || "").indexOf(searchValue) >= 0;
     }
 
-    let filteredOptions = options.filter(
-      option =>
-        // filter out options who have already been selected, unless:
+    let selectedCount = 0;
+    let filteredOptions = options.filter(option => {
+      const isSelected = selectedValues.has(
+        JSON.stringify(this._value(option)),
+      );
+      const isLastFreeform =
+        this._isLastFreeformValue(this._value(option)) &&
+        this._isLastFreeformValue(searchValue);
+      const isMatching = filterOption(option, searchValue);
+      if (isSelected) {
+        selectedCount++;
+      }
+      // filter out options who have already been selected, unless:
+      return (
         // remove selected is disabled
         (!removeSelected ||
           // or it's not in the selectedValues
-          !selectedValues.has(JSON.stringify(this._value(option))) ||
+          !isSelected ||
           // or it's the current "freeform" value, which updates as we type
-          (this._isLastFreeformValue(this._value(option)) &&
-            this._isLastFreeformValue(searchValue))) &&
-        filterOption(option, searchValue),
-    );
+          isLastFreeform) &&
+        // and it's matching
+        isMatching
+      );
+    });
 
     if (
       selectedOptionValue == null ||
@@ -235,6 +252,7 @@ export default class TokenField extends Component {
     this.setState({
       filteredOptions,
       selectedOptionValue,
+      isAllSelected: options.length > 0 && selectedCount === options.length,
     });
   };
 
@@ -324,7 +342,7 @@ export default class TokenField extends Component {
     if (this.props.onFocus) {
       this.props.onFocus();
     }
-    this.setState({ focused: true, searchValue: this.state.inputValue }, () =>
+    this.setState({ isFocused: true, searchValue: this.state.inputValue }, () =>
       this._updateFilteredValues(this.props),
     );
   };
@@ -333,9 +351,7 @@ export default class TokenField extends Component {
     if (this.props.onBlur) {
       this.props.onBlur();
     }
-    setTimeout(() => {
-      this.setState({ focused: false });
-    }, 100);
+    this.setState({ isFocused: false });
   };
 
   onInputPaste = (e: SyntheticClipboardEvent) => {
@@ -364,7 +380,7 @@ export default class TokenField extends Component {
   };
 
   onClose = () => {
-    this.setState({ focused: false });
+    this.setState({ isFocused: false });
   };
 
   addSelectedOption(e: SyntheticKeyboardEvent) {
@@ -481,11 +497,12 @@ export default class TokenField extends Component {
       inputValue,
       searchValue,
       filteredOptions,
-      focused,
+      isFocused,
+      isAllSelected,
       selectedOptionValue,
     } = this.state;
 
-    if (!multi && focused) {
+    if (!multi && isFocused) {
       inputValue = inputValue || value[0];
       value = [];
     }
@@ -497,7 +514,7 @@ export default class TokenField extends Component {
       parseFreeformValue &&
       value[value.length - 1] === parseFreeformValue(inputValue)
     ) {
-      if (focused) {
+      if (isFocused) {
         // if focused, don't render the last value
         value = value.slice(0, -1);
       } else {
@@ -507,7 +524,7 @@ export default class TokenField extends Component {
     }
 
     // if not focused we won't get key events to accept the selected value, so don't render as selected
-    if (!focused) {
+    if (!isFocused) {
       selectedOptionValue = null;
     }
 
@@ -522,7 +539,7 @@ export default class TokenField extends Component {
           "m1 p0 pb1 bordered rounded flex flex-wrap bg-white scroll-x scroll-y",
           inputBoxClasses,
           {
-            [`border-grey-2`]: this.state.focused,
+            [`border-grey-2`]: this.state.isFocused,
           },
         )}
         style={this.props.style}
@@ -544,6 +561,7 @@ export default class TokenField extends Component {
                   this.removeValue(v);
                   e.preventDefault();
                 }}
+                onMouseDown={e => e.preventDefault()}
               >
                 <Icon name="close" className="" size={12} />
               </a>
@@ -558,7 +576,7 @@ export default class TokenField extends Component {
             size={10}
             placeholder={placeholder}
             value={inputValue}
-            autoFocus={focused}
+            autoFocus={isFocused}
             onKeyDown={this.onInputKeyDown}
             onChange={this.onInputChange}
             onFocus={this.onInputFocus}
@@ -603,6 +621,7 @@ export default class TokenField extends Component {
                   this.clearInputValue(filteredOptions.length === 1);
                   e.preventDefault();
                 }}
+                onMouseDown={e => e.preventDefault()}
               >
                 {optionRenderer(option)}
               </div>
@@ -614,7 +633,8 @@ export default class TokenField extends Component {
     return layoutRenderer({
       valuesList,
       optionsList,
-      focused,
+      isFocused,
+      isAllSelected,
       isFiltered: !!searchValue,
       onClose: this.onClose,
     });
@@ -626,14 +646,14 @@ const dedup = array => Array.from(new Set(array));
 const DefaultTokenFieldLayout = ({
   valuesList,
   optionsList,
-  focused,
+  isFocused,
   onClose,
 }) => (
   <OnClickOutsideWrapper handleDismissal={onClose}>
     <div>
       {valuesList}
       <Popover
-        isOpen={focused && !!optionsList}
+        isOpen={isFocused && !!optionsList}
         hasArrow={false}
         tetherOptions={{
           attachment: "top left",
@@ -650,6 +670,6 @@ const DefaultTokenFieldLayout = ({
 DefaultTokenFieldLayout.propTypes = {
   valuesList: PropTypes.element.isRequired,
   optionsList: PropTypes.element,
-  focused: PropTypes.bool,
+  isFocused: PropTypes.bool,
   onClose: PropTypes.func,
 };
diff --git a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx
index fbc50d0f224..7edef827224 100644
--- a/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx
+++ b/frontend/src/metabase/parameters/components/widgets/ParameterFieldWidget.jsx
@@ -1,6 +1,7 @@
 /* @flow */
 
 import React, { Component } from "react";
+import ReactDOM from "react-dom";
 
 import { t } from "c-3po";
 
@@ -24,18 +25,24 @@ type Props = {
 type State = {
   value: any[],
   isFocused: boolean,
+  widgetWidth: ?number,
 };
 
+const BORDER_WIDTH = 2;
+
 // TODO: rename this something else since we're using it for more than searching and more than text
 export default class ParameterFieldWidget extends Component<*, Props, State> {
   props: Props;
   state: State;
 
+  _unfocusedElement: React$Component<any, any, any>;
+
   constructor(props: Props) {
     super(props);
     this.state = {
       isFocused: false,
       value: props.value,
+      widgetWidth: null,
     };
   }
 
@@ -58,6 +65,16 @@ export default class ParameterFieldWidget extends Component<*, Props, State> {
     }
   }
 
+  componentDidUpdate() {
+    let element = ReactDOM.findDOMNode(this._unfocusedElement);
+    if (!this.state.isFocused && element) {
+      const parameterWidgetElement = element.parentNode.parentNode.parentNode;
+      if (parameterWidgetElement.clientWidth !== this.state.widgetWidth) {
+        this.setState({ widgetWidth: parameterWidgetElement.clientWidth });
+      }
+    }
+  }
+
   render() {
     let { setValue, isEditing, field, parentFocusChanged } = this.props;
     let { value, isFocused } = this.state;
@@ -82,6 +99,7 @@ export default class ParameterFieldWidget extends Component<*, Props, State> {
     if (!isFocused) {
       return (
         <div
+          ref={_ => (this._unfocusedElement = _)}
           className="flex-full cursor-pointer"
           onClick={() => focusChanged(true)}
         >
@@ -115,8 +133,10 @@ export default class ParameterFieldWidget extends Component<*, Props, State> {
             autoFocus
             color="brand"
             style={{
-              borderWidth: 2,
-              minWidth: 182,
+              borderWidth: BORDER_WIDTH,
+              minWidth: this.state.widgetWidth
+                ? this.state.widgetWidth + BORDER_WIDTH * 2
+                : null,
             }}
             maxWidth={400}
           />
diff --git a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
index 9e81615d30c..3134d5503f9 100644
--- a/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
+++ b/frontend/src/metabase/query_builder/components/GuiQueryEditor.jsx
@@ -171,7 +171,7 @@ export default class GuiQueryEditor extends Component {
             triggerElement={addFilterButton}
             triggerClasses="flex align-center"
             getTarget={() => this.refs.addFilterTarget}
-            horizontalAttachments={["left"]}
+            horizontalAttachments={["left", "center"]}
             autoWidth
           >
             <FilterPopover
diff --git a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx
index be62084eb66..df9ea37b2be 100644
--- a/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx
+++ b/frontend/src/metabase/query_builder/components/filters/FilterPopover.jsx
@@ -202,7 +202,7 @@ export default class FilterPopover extends Component {
 
   renderPicker(filter: FieldFilter, field: Field) {
     let operator: ?Operator = field.operators_lookup[filter[0]];
-    return (
+    let fieldWidgets =
       operator &&
       operator.fields.map((operatorField, index) => {
         if (!operator) {
@@ -279,8 +279,12 @@ export default class FilterPopover extends Component {
             {operator.multi ? t`true` : t`false`}
           </span>
         );
-      })
-    );
+      });
+    if (fieldWidgets && fieldWidgets.filter(f => f).length > 0) {
+      return fieldWidgets;
+    } else {
+      return <div className="mb1" />;
+    }
   }
 
   onCommit = () => {
diff --git a/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx b/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx
index e547c0dae0f..00601621387 100644
--- a/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx
+++ b/frontend/src/metabase/query_builder/components/filters/FilterWidget.jsx
@@ -159,7 +159,7 @@ export default class FilterWidget extends Component {
           className="FilterPopover"
           isInitiallyOpen={this.props.filter[1] === null}
           onClose={this.close}
-          horizontalAttachments={["left"]}
+          horizontalAttachments={["left", "center"]}
           autoWidth
         >
           <FilterPopover
diff --git a/frontend/test/components/TokenField.unit.spec.js b/frontend/test/components/TokenField.unit.spec.js
index f50194da904..e13be0899b6 100644
--- a/frontend/test/components/TokenField.unit.spec.js
+++ b/frontend/test/components/TokenField.unit.spec.js
@@ -62,21 +62,26 @@ class TokenFieldWithStateAndDefaults extends React.Component {
 }
 
 describe("TokenField", () => {
-  let component, input;
+  let component;
+  const input = () => component.find("input");
   const value = () => component.state().value;
   const options = () => component.find(MockOption).map(o => o.text());
   const values = () => component.find(MockValue).map(v => v.text());
-  const blur = () => input.simulate("blur");
-  const focus = () => input.simulate("focus");
-  const type = str => input.simulate("change", { target: { value: str } });
+  const blur = () => input().simulate("blur");
+  const focus = () => input().simulate("focus");
+  const type = str => input().simulate("change", { target: { value: str } });
   const focusAndType = str => focus() && type(str);
-  const keyDown = keyCode => input.simulate("keydown", { keyCode });
+  const keyDown = keyCode => input().simulate("keydown", { keyCode });
   const clickOption = (n = 0) =>
     component
       .find(MockOption)
       .at(n)
       .simulate("click");
 
+  afterEach(() => {
+    component = null;
+  });
+
   it("should render with no options or values", () => {
     component = mount(<TokenFieldWithStateAndDefaults />);
     expect(values()).toEqual([]);
@@ -112,8 +117,6 @@ describe("TokenField", () => {
         options={["bar", "baz"]}
       />,
     );
-    input = component.find("input");
-
     focusAndType("nope");
     expect(options()).toEqual([]);
     type("bar");
@@ -128,7 +131,6 @@ describe("TokenField", () => {
         parseFreeformValue={value => value}
       />,
     );
-    input = component.find("input");
     focusAndType("yep");
     expect(value()).toEqual([]);
     keyDown(KEYCODE_ENTER);
@@ -140,10 +142,7 @@ describe("TokenField", () => {
       <TokenFieldWithStateAndDefaults value={[]} options={["bar", "baz"]} />,
     );
     expect(value()).toEqual([]);
-    component
-      .find(MockOption)
-      .first()
-      .simulate("click");
+    clickOption(0);
     expect(value()).toEqual(["bar"]);
   });
 
@@ -152,10 +151,7 @@ describe("TokenField", () => {
       <TokenFieldWithStateAndDefaults value={[]} options={["bar", "baz"]} />,
     );
     expect(options()).toEqual(["bar", "baz"]);
-    component
-      .find(MockOption)
-      .first()
-      .simulate("click");
+    clickOption(0);
     await delay(100);
     expect(options()).toEqual(["baz"]);
   });
@@ -164,15 +160,11 @@ describe("TokenField", () => {
     component = mount(
       <TokenFieldWithStateAndDefaults value={[]} options={["foo", "bar"]} />,
     );
-    input = component.find("input");
 
     focus();
     expect(value()).toEqual([]);
     type("ba");
-    component
-      .find(MockOption)
-      .first()
-      .simulate("click");
+    clickOption(0);
     expect(value()).toEqual(["bar"]);
   });
 
@@ -187,7 +179,6 @@ describe("TokenField", () => {
           updateOnInputChange
         />,
       );
-      input = component.find("input");
     });
 
     it("should add freeform value immediately if updateOnInputChange is provided", () => {
@@ -200,13 +191,9 @@ describe("TokenField", () => {
       focusAndType("Do");
       expect(value()).toEqual(["Do"]);
 
-      // click the first option
-      component
-        .find(MockOption)
-        .first()
-        .simulate("click");
+      clickOption(0);
       expect(value()).toEqual(["Doohickey"]);
-      expect(input.props().value).toEqual("");
+      expect(input().props().value).toEqual("");
     });
 
     it("should only add one option when filtered and enter is pressed", async () => {
@@ -217,7 +204,7 @@ describe("TokenField", () => {
       // press enter
       keyDown(KEYCODE_ENTER);
       expect(value()).toEqual(["Doohickey"]);
-      expect(input.props().value).toEqual("");
+      expect(input().props().value).toEqual("");
     });
 
     it("shouldn't hide option matching input freeform value", () => {
@@ -250,7 +237,7 @@ describe("TokenField", () => {
       expect(options()).toEqual(["Gadget", "Gizmo"]);
       keyDown(KEYCODE_ENTER);
       expect(options()).toEqual(["Gizmo"]);
-      expect(component.find("input").props().value).toEqual("");
+      expect(input().props().value).toEqual("");
     });
 
     it("should reset the search when focusing", () => {
@@ -316,7 +303,6 @@ describe("TokenField", () => {
             onChange={spy}
           />,
         );
-        input = component.find("input");
 
         // limit our options by typing
         focusAndType("G");
@@ -324,7 +310,7 @@ describe("TokenField", () => {
         // the initially selected option should be the first option
         expect(component.state().selectedOptionValue).toBe(DEFAULT_OPTIONS[1]);
 
-        input.simulate("keydown", {
+        input().simulate("keydown", {
           keyCode: KEYCODE_DOWN,
           preventDefault: jest.fn(),
         });
@@ -332,7 +318,7 @@ describe("TokenField", () => {
         // the next possible option should be selected now
         expect(component.state().selectedOptionValue).toBe(DEFAULT_OPTIONS[2]);
 
-        input.simulate("keydown", {
+        input().simulate("keydown", {
           keyCode: key,
           preventDefalut: jest.fn(),
         });
@@ -355,9 +341,8 @@ describe("TokenField", () => {
           multi
         />,
       );
-      input = component.find("input");
       focusAndType("asdf");
-      input.simulate("keydown", {
+      input().simulate("keydown", {
         keyCode: KEYCODE_TAB,
         preventDefault: preventDefault,
       });
@@ -373,8 +358,7 @@ describe("TokenField", () => {
           multi
         />,
       );
-      input = component.find("input");
-      input.simulate("paste", {
+      input().simulate("paste", {
         clipboardData: {
           getData: () => "1,2,3",
         },
@@ -396,9 +380,8 @@ describe("TokenField", () => {
           updateOnInputChange
         />,
       );
-      input = component.find("input");
       focusAndType("asdf");
-      input.simulate("keydown", {
+      input().simulate("keydown", {
         keyCode: KEYCODE_TAB,
         preventDefault: preventDefault,
       });
@@ -413,8 +396,7 @@ describe("TokenField", () => {
           updateOnInputChange
         />,
       );
-      input = component.find("input");
-      input.simulate("paste", {
+      input().simulate("paste", {
         clipboardData: {
           getData: () => "1,2,3",
         },
@@ -425,4 +407,46 @@ describe("TokenField", () => {
       expect(preventDefault).toHaveBeenCalled();
     });
   });
+
+  describe("custom layoutRenderer", () => {
+    let layoutRenderer;
+    beforeEach(() => {
+      layoutRenderer = jest
+        .fn()
+        .mockImplementation(({ valuesList, optionsList }) => (
+          <div>
+            {valuesList}
+            {optionsList}
+          </div>
+        ));
+      component = mount(
+        <TokenFieldWithStateAndDefaults
+          options={["hello"]}
+          layoutRenderer={layoutRenderer}
+        />,
+      );
+    });
+    it("should be called with isFiltered=true when filtered", () => {
+      let call = layoutRenderer.mock.calls.pop();
+      expect(call[0].isFiltered).toEqual(false);
+      expect(call[0].isAllSelected).toEqual(false);
+      focus();
+      type("blah");
+      call = layoutRenderer.mock.calls.pop();
+      expect(call[0].optionList).toEqual(undefined);
+      expect(call[0].isFiltered).toEqual(true);
+      expect(call[0].isAllSelected).toEqual(false);
+    });
+    it("should be called with isAllSelected=true when all options are selected", () => {
+      let call = layoutRenderer.mock.calls.pop();
+      expect(call[0].isFiltered).toEqual(false);
+      expect(call[0].isAllSelected).toEqual(false);
+      focus();
+      keyDown(KEYCODE_ENTER);
+      call = layoutRenderer.mock.calls.pop();
+      expect(call[0].optionList).toEqual(undefined);
+      expect(call[0].isFiltered).toEqual(false);
+      expect(call[0].isAllSelected).toEqual(true);
+    });
+  });
 });
diff --git a/frontend/test/internal/__snapshots__/components.unit.spec.js.snap b/frontend/test/internal/__snapshots__/components.unit.spec.js.snap
index 3c6895034a8..819163c5566 100644
--- a/frontend/test/internal/__snapshots__/components.unit.spec.js.snap
+++ b/frontend/test/internal/__snapshots__/components.unit.spec.js.snap
@@ -794,6 +794,7 @@ exports[`TokenField should render "" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Doohickey
@@ -804,6 +805,7 @@ exports[`TokenField should render "" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Gadget
@@ -814,6 +816,7 @@ exports[`TokenField should render "" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Gizmo
@@ -824,6 +827,7 @@ exports[`TokenField should render "" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Widget
@@ -872,6 +876,7 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Doohickey
@@ -882,6 +887,7 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Gadget
@@ -892,6 +898,7 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Gizmo
@@ -902,6 +909,7 @@ exports[`TokenField should render "updateOnInputChange" correctly 1`] = `
       <div
         className="py1 pl1 pr2 block rounded text-bold text-brand-hover inline-block full cursor-pointer bg-grey-0-hover"
         onClick={[Function]}
+        onMouseDown={[Function]}
       >
         <span>
           Widget
diff --git a/frontend/test/pulse/pulse.unit.spec.js b/frontend/test/pulse/pulse.unit.spec.js
index 477ecbf6de4..54b730cdb9f 100644
--- a/frontend/test/pulse/pulse.unit.spec.js
+++ b/frontend/test/pulse/pulse.unit.spec.js
@@ -38,7 +38,7 @@ describe("recipient picker", () => {
         wrapper
           .find(TokenField)
           .dive()
-          .state().focused,
+          .state().isFocused,
       ).toBe(true);
     });
     it("should not be focused if there are existing recipients", () => {
@@ -55,7 +55,7 @@ describe("recipient picker", () => {
         wrapper
           .find(TokenField)
           .dive()
-          .state().focused,
+          .state().isFocused,
       ).toBe(false);
     });
   });
-- 
GitLab