diff --git a/frontend/src/metabase/components/FieldValuesWidget.jsx b/frontend/src/metabase/components/FieldValuesWidget.jsx
index 117328095c26a6873b54da79b2bccadc5c24c066..6e8553aac652781636027909be5fcacb452788eb 100644
--- a/frontend/src/metabase/components/FieldValuesWidget.jsx
+++ b/frontend/src/metabase/components/FieldValuesWidget.jsx
@@ -69,19 +69,9 @@ class FieldValuesWidgetInner extends Component {
     disableSearch: false,
   };
 
-  // if [dashboard] parameter ID is specified use the fancy new Chain Filter API endpoints to fetch parameter values.
-  // Otherwise (e.g. for Cards) fall back to the old field/:id/values endpoint
-  useChainFilterEndpoints() {
-    return this.props.dashboard && this.props.dashboard.id;
-  }
-
-  parameterId() {
-    return this.props.parameter && this.props.parameter.id;
-  }
-
   componentDidMount() {
-    if (this.shouldList()) {
-      if (this.useChainFilterEndpoints()) {
+    if (shouldList(this.props.fields, this.props.disableSearch)) {
+      if (usesChainFilterEndpoints(this.props.dashboard)) {
         this.fetchDashboardParamValues();
       } else {
         const { fields, fetchFieldValues } = this.props;
@@ -120,120 +110,19 @@ class FieldValuesWidgetInner extends Component {
     }
   }
 
-  getSearchableTokenFieldPlaceholder(fields, firstField) {
-    let placeholder;
-
-    const names = new Set(
-      fields.map(field => stripId(this.searchField(field).display_name)),
-    );
-
-    if (names.size > 1) {
-      placeholder = t`Search`;
-    } else {
-      const [name] = names;
-
-      placeholder = t`Search by ${name}`;
-      if (firstField.isID() && firstField !== this.searchField(firstField)) {
-        placeholder += t` or enter an ID`;
-      }
-    }
-    return placeholder;
-  }
-
-  getNonSearchableTokenFieldPlaceholder(firstField) {
-    if (firstField.isID()) {
-      return t`Enter an ID`;
-    } else if (firstField.isString()) {
-      return t`Enter some text`;
-    } else if (firstField.isNumeric()) {
-      return t`Enter a number`;
-    }
-
-    // fallback
-    return t`Enter some text`;
-  }
-
-  getTokenFieldPlaceholder() {
-    const { fields, placeholder } = this.props;
-
-    if (placeholder) {
-      return placeholder;
-    }
-
-    const [firstField] = fields;
-
-    if (this.hasList()) {
-      return t`Search the list`;
-    } else if (this.isSearchable()) {
-      return this.getSearchableTokenFieldPlaceholder(fields, firstField);
-    } else {
-      return this.getNonSearchableTokenFieldPlaceholder(firstField);
-    }
-  }
-
-  shouldList() {
-    // Virtual fields come from questions that are based on other questions.
-    // Currently, the back end does not return `has_field_values` in their metadata,
-    // so we ignore them for now.
-    const nonVirtualFields = this.props.fields.filter(
-      field => typeof field.id === "number",
-    );
-
-    return (
-      !this.props.disableSearch &&
-      nonVirtualFields.every(field => field.has_field_values === "list")
-    );
-  }
-
-  hasList() {
-    const nonEmptyArray = a => a && a.length > 0;
-    return (
-      this.shouldList() &&
-      (this.useChainFilterEndpoints()
-        ? this.state.loadingState === "LOADED" &&
-          nonEmptyArray(this.state.options)
-        : this.props.fields.every(field => nonEmptyArray(field.values)))
-    );
-  }
-
-  isSearchable() {
-    const { fields, disableSearch } = this.props;
-    return (
-      !disableSearch &&
-      // search is available if:
-      // all fields have a valid search field
-      fields.every(this.searchField) &&
-      // at least one field is set to display as "search"
-      fields.some(f => f.has_field_values === "search") &&
-      // and all fields are either "search" or "list"
-      fields.every(
-        f => f.has_field_values === "search" || f.has_field_values === "list",
-      )
-    );
-  }
-
   onInputChange = value => {
-    if (value && this.isSearchable()) {
+    const { fields, disableSearch, disablePKRemappingForSearch } = this.props;
+
+    if (
+      value &&
+      isSearchable(fields, disableSearch, disablePKRemappingForSearch)
+    ) {
       this._search(value);
     }
 
     return value;
   };
 
-  searchField = field => {
-    if (this.props.disablePKRemappingForSearch && field.isPK()) {
-      return field.isSearchable() ? field : null;
-    }
-
-    const remappedField = field.remappedField();
-    if (remappedField && remappedField.isSearchable()) {
-      return remappedField;
-    }
-    return field.isSearchable() ? field : null;
-  };
-
-  showRemapping = () => this.props.fields.length === 1;
-
   search = async (value, cancelled) => {
     if (!value) {
       return;
@@ -242,7 +131,7 @@ class FieldValuesWidgetInner extends Component {
     const { fields } = this.props;
 
     let results;
-    if (this.useChainFilterEndpoints()) {
+    if (usesChainFilterEndpoints(this.props.dashboard)) {
       const { dashboard, parameter, parameters } = this.props;
       const args = {
         dashboardId: dashboard?.id,
@@ -260,7 +149,10 @@ class FieldValuesWidgetInner extends Component {
               {
                 value,
                 fieldId: field.id,
-                searchFieldId: this.searchField(field).id,
+                searchFieldId: searchField(
+                  field,
+                  this.props.disablePKRemappingForSearch,
+                ).id,
                 limit: this.props.maxResults,
               },
               { cancelled },
@@ -272,9 +164,12 @@ class FieldValuesWidgetInner extends Component {
       results = results.map(result => [].concat(result));
     }
 
-    if (this.showRemapping()) {
+    if (showRemapping(fields)) {
       const [field] = fields;
-      if (field.remappedField() === this.searchField(field)) {
+      if (
+        field.remappedField() ===
+        searchField(field, this.props.disablePKRemappingForSearch)
+      ) {
         this.props.addRemappings(field.id, results);
       }
     }
@@ -342,40 +237,6 @@ class FieldValuesWidgetInner extends Component {
     }
   }, 500);
 
-  renderOptions({ optionsList, isFocused, isAllSelected, isFiltered }) {
-    const { alwaysShowOptions, fields } = 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 === "LOADING") {
-          return <LoadingState />;
-        } else if (loadingState === "LOADED" && isFiltered) {
-          return <NoMatchState fields={fields.map(this.searchField)} />;
-        }
-      }
-    }
-  }
-
-  renderValue = (value, options) => {
-    const { fields, formatOptions } = this.props;
-    return (
-      <ValueComponent
-        value={value}
-        column={fields[0]}
-        maximumFractionDigits={20}
-        remap={this.showRemapping()}
-        {...formatOptions}
-        {...options}
-      />
-    );
-  };
-
   render() {
     const {
       value,
@@ -388,17 +249,40 @@ class FieldValuesWidgetInner extends Component {
       style,
       parameter,
       prefix,
+      disableSearch,
+      dashboard,
+      disablePKRemappingForSearch,
+      formatOptions,
+      placeholder,
     } = this.props;
-    const { loadingState } = this.state;
+    const { loadingState, options: stateOptions } = this.state;
 
-    const placeholder = this.getTokenFieldPlaceholder();
+    const tokenFieldPlaceholder = getTokenFieldPlaceholder({
+      fields,
+      disableSearch,
+      dashboard,
+      placeholder,
+      disablePKRemappingForSearch,
+      loadingState,
+      options: stateOptions,
+    });
 
     let options = [];
-    if (this.hasList() && !this.useChainFilterEndpoints()) {
+    if (
+      hasList({
+        fields,
+        disableSearch,
+        dashboard,
+        loadingState,
+        options: stateOptions,
+      }) &&
+      !usesChainFilterEndpoints(this.props.dashboard)
+    ) {
       options = dedupeValues(fields.map(field => field.values));
     } else if (
       loadingState === "LOADED" &&
-      (this.isSearchable() || this.useChainFilterEndpoints())
+      (isSearchable(fields, disableSearch, disablePKRemappingForSearch) ||
+        usesChainFilterEndpoints(this.props.dashboard))
     ) {
       options = this.state.options;
     } else {
@@ -406,8 +290,15 @@ class FieldValuesWidgetInner extends Component {
     }
 
     const isLoading = loadingState === "LOADING";
-    const isFetchingList = this.shouldList() && isLoading;
-    const hasListData = this.hasList();
+    const isFetchingList =
+      shouldList(this.props.fields, this.props.disableSearch) && isLoading;
+    const hasListData = hasList({
+      fields,
+      disableSearch,
+      dashboard,
+      loadingState,
+      options: stateOptions,
+    });
 
     return (
       <div
@@ -421,12 +312,14 @@ class FieldValuesWidgetInner extends Component {
         {hasListData && (
           <ListField
             isDashboardFilter={parameter}
-            placeholder={this.getTokenFieldPlaceholder()}
+            placeholder={tokenFieldPlaceholder}
             value={value.filter(v => v != null)}
             onChange={onChange}
             options={options}
             optionRenderer={option =>
-              this.renderValue(option[0], { autoLoad: false })
+              renderValue(fields, formatOptions, option[0], {
+                autoLoad: false,
+              })
             }
           />
         )}
@@ -435,7 +328,7 @@ class FieldValuesWidgetInner extends Component {
             prefix={prefix}
             value={value.filter(v => v != null)}
             onChange={onChange}
-            placeholder={placeholder}
+            placeholder={tokenFieldPlaceholder}
             updateOnInputChange
             // forwarded props
             multi={multi}
@@ -443,21 +336,23 @@ class FieldValuesWidgetInner extends Component {
             color={color}
             style={{ ...style, minWidth: "inherit" }}
             className={className}
-            parameter={this.props.parameter}
             optionsStyle={!parameter ? { maxHeight: "none" } : {}}
             // end forwarded props
             options={options}
             valueKey={0}
             valueRenderer={value =>
-              this.renderValue(value, { autoLoad: true, compact: false })
+              renderValue(fields, formatOptions, value, {
+                autoLoad: true,
+                compact: false,
+              })
             }
             optionRenderer={option =>
-              this.renderValue(option[0], { autoLoad: false })
+              renderValue(fields, formatOptions, option[0], { autoLoad: false })
             }
-            layoutRenderer={props => (
+            layoutRenderer={layoutProps => (
               <div>
-                {props.valuesList}
-                {this.renderOptions(props)}
+                {layoutProps.valuesList}
+                {renderOptions(this.state, this.props, layoutProps)}
               </div>
             )}
             filterOption={(option, filterString) => {
@@ -542,3 +437,201 @@ const OptionsMessage = ({ message }) => (
 OptionsMessage.propTypes = optionsMessagePropTypes;
 
 export default connect(mapStateToProps, mapDispatchToProps)(FieldValuesWidget);
+
+// if [dashboard] parameter ID is specified use the fancy new Chain Filter API endpoints to fetch parameter values.
+// Otherwise (e.g. for Cards) fall back to the old field/:id/values endpoint
+function usesChainFilterEndpoints(dashboard) {
+  return dashboard?.id;
+}
+
+function showRemapping(fields) {
+  return fields.length === 1;
+}
+
+function shouldList(fields, disableSearch) {
+  // Virtual fields come from questions that are based on other questions.
+  // Currently, the back end does not return `has_field_values` in their metadata,
+  // so we ignore them for now.
+  const nonVirtualFields = fields.filter(field => typeof field.id === "number");
+
+  return (
+    !disableSearch &&
+    nonVirtualFields.every(field => field.has_field_values === "list")
+  );
+}
+
+function getNonSearchableTokenFieldPlaceholder(firstField) {
+  if (firstField.isID()) {
+    return t`Enter an ID`;
+  } else if (firstField.isString()) {
+    return t`Enter some text`;
+  } else if (firstField.isNumeric()) {
+    return t`Enter a number`;
+  }
+
+  // fallback
+  return t`Enter some text`;
+}
+
+function searchField(field, disablePKRemappingForSearch) {
+  if (disablePKRemappingForSearch && field.isPK()) {
+    return field.isSearchable() ? field : null;
+  }
+
+  const remappedField = field.remappedField();
+  if (remappedField && remappedField.isSearchable()) {
+    return remappedField;
+  }
+  return field.isSearchable() ? field : null;
+}
+
+function getSearchableTokenFieldPlaceholder(
+  fields,
+  firstField,
+  disablePKRemappingForSearch,
+) {
+  let placeholder;
+
+  const names = new Set(
+    fields.map(field =>
+      stripId(searchField(field, disablePKRemappingForSearch).display_name),
+    ),
+  );
+
+  if (names.size > 1) {
+    placeholder = t`Search`;
+  } else {
+    const [name] = names;
+
+    placeholder = t`Search by ${name}`;
+    if (
+      firstField.isID() &&
+      firstField !== searchField(firstField, disablePKRemappingForSearch)
+    ) {
+      placeholder += t` or enter an ID`;
+    }
+  }
+  return placeholder;
+}
+
+function hasList({ fields, disableSearch, dashboard, loadingState, options }) {
+  const nonEmptyArray = a => a && a.length > 0;
+  return (
+    shouldList(fields, disableSearch) &&
+    (usesChainFilterEndpoints(dashboard)
+      ? loadingState === "LOADED" && nonEmptyArray(options)
+      : fields.every(field => nonEmptyArray(field.values)))
+  );
+}
+
+function isSearchable(fields, disableSearch, disablePKRemappingForSearch) {
+  return (
+    !disableSearch &&
+    // search is available if:
+    // all fields have a valid search field
+    fields.every(field => searchField(field, disablePKRemappingForSearch)) &&
+    // at least one field is set to display as "search"
+    fields.some(f => f.has_field_values === "search") &&
+    // and all fields are either "search" or "list"
+    fields.every(
+      f => f.has_field_values === "search" || f.has_field_values === "list",
+    )
+  );
+}
+
+function getTokenFieldPlaceholder({
+  fields,
+  disableSearch,
+  dashboard,
+  placeholder,
+  disablePKRemappingForSearch,
+  loadingState,
+  options,
+}) {
+  if (placeholder) {
+    return placeholder;
+  }
+
+  const [firstField] = fields;
+
+  if (
+    hasList({
+      fields,
+      disableSearch,
+      disablePKRemappingForSearch,
+      dashboard,
+      loadingState,
+      options,
+    })
+  ) {
+    return t`Search the list`;
+  } else if (isSearchable(fields, disableSearch, disablePKRemappingForSearch)) {
+    return getSearchableTokenFieldPlaceholder(
+      fields,
+      firstField,
+      disablePKRemappingForSearch,
+    );
+  } else {
+    return getNonSearchableTokenFieldPlaceholder(firstField);
+  }
+}
+
+function renderOptions(
+  state,
+  props,
+  { optionsList, isFocused, isAllSelected, isFiltered },
+) {
+  const {
+    alwaysShowOptions,
+    fields,
+    disableSearch,
+    dashboard,
+    disablePKRemappingForSearch,
+  } = props;
+  const { loadingState, options } = state;
+
+  if (alwaysShowOptions || isFocused) {
+    if (optionsList) {
+      return optionsList;
+    } else if (
+      hasList({
+        fields,
+        disableSearch,
+        dashboard,
+        loadingState,
+        options,
+      })
+    ) {
+      if (isAllSelected) {
+        return <EveryOptionState />;
+      }
+    } else if (
+      isSearchable(fields, disableSearch, disablePKRemappingForSearch)
+    ) {
+      if (loadingState === "LOADING") {
+        return <LoadingState />;
+      } else if (loadingState === "LOADED" && isFiltered) {
+        return (
+          <NoMatchState
+            fields={fields.map(field =>
+              searchField(field, disablePKRemappingForSearch),
+            )}
+          />
+        );
+      }
+    }
+  }
+}
+
+function renderValue(fields, formatOptions, value, options) {
+  return (
+    <ValueComponent
+      value={value}
+      column={fields[0]}
+      maximumFractionDigits={20}
+      remap={showRemapping(fields)}
+      {...formatOptions}
+      {...options}
+    />
+  );
+}