Skip to content
Snippets Groups Projects
Unverified Commit e64daa4f authored by metabase-bot[bot]'s avatar metabase-bot[bot] Committed by GitHub
Browse files

Fix UX for picking multiple values in filters (#40278) (#40361)

parent 0662d4a7
No related branches found
No related tags found
No related merge requests found
Showing
with 452 additions and 130 deletions
......@@ -301,8 +301,9 @@ describe("scenarios > visualizations > table", () => {
popover().within(() => {
cy.findByText("Filter by this column").click();
cy.findByPlaceholderText("Search by Password").type("e").blur();
cy.findByPlaceholderText("Search by Password").type("e");
cy.wait("@findSuggestions");
cy.findByPlaceholderText("Search by Password").blur();
});
popover().then($popover => {
......
......@@ -31,7 +31,7 @@ interface FilterValuePickerProps<T> {
interface FilterValuePickerOwnProps extends FilterValuePickerProps<string> {
placeholder: string;
canAddValue: (query: string) => boolean;
shouldCreate: (query: string) => boolean;
}
function FilterValuePicker({
......@@ -42,7 +42,7 @@ function FilterValuePicker({
placeholder,
autoFocus = false,
compact = false,
canAddValue,
shouldCreate,
onChange,
onFocus,
onBlur,
......@@ -71,6 +71,7 @@ function FilterValuePicker({
fieldValues={fieldData.values}
selectedValues={selectedValues}
placeholder={t`Search the list`}
shouldCreate={shouldCreate}
autoFocus={autoFocus}
compact={compact}
onChange={onChange}
......@@ -88,7 +89,7 @@ function FilterValuePicker({
fieldValues={fieldData?.values ?? []}
selectedValues={selectedValues}
placeholder={t`Search by ${columnInfo.displayName}`}
canAddValue={canAddValue}
shouldCreate={shouldCreate}
autoFocus={autoFocus}
onChange={onChange}
/>
......@@ -99,7 +100,7 @@ function FilterValuePicker({
<StaticValuePicker
selectedValues={selectedValues}
placeholder={placeholder}
canAddValue={canAddValue}
shouldCreate={shouldCreate}
autoFocus={autoFocus}
onChange={onChange}
onFocus={onFocus}
......@@ -113,7 +114,7 @@ export function StringFilterValuePicker({
values,
...props
}: FilterValuePickerProps<string>) {
const canAddValue = (query: string) => {
const shouldCreate = (query: string) => {
return query.trim().length > 0 && !values.includes(query);
};
......@@ -123,7 +124,7 @@ export function StringFilterValuePicker({
column={column}
values={values}
placeholder={isKeyColumn(column) ? t`Enter an ID` : t`Enter some text`}
canAddValue={canAddValue}
shouldCreate={shouldCreate}
/>
);
}
......@@ -134,7 +135,7 @@ export function NumberFilterValuePicker({
onChange,
...props
}: FilterValuePickerProps<number>) {
const canAddValue = (query: string) => {
const shouldCreate = (query: string) => {
const number = parseFloat(query);
return isFinite(number) && !values.includes(number);
};
......@@ -145,7 +146,7 @@ export function NumberFilterValuePicker({
column={column}
values={values.map(value => String(value))}
placeholder={isKeyColumn(column) ? t`Enter an ID` : t`Enter a number`}
canAddValue={canAddValue}
shouldCreate={shouldCreate}
onChange={newValue => onChange(newValue.map(value => parseFloat(value)))}
/>
);
......
......@@ -498,8 +498,6 @@ describe("StringFilterValuePicker", () => {
});
userEvent.type(screen.getByPlaceholderText("Search by Email"), "a@b.com");
userEvent.hover(screen.getByText("a@b.com"));
userEvent.click(screen.getByText("a@b.com"));
expect(onChange).toHaveBeenLastCalledWith(["a@b.com"]);
});
......@@ -518,28 +516,28 @@ describe("StringFilterValuePicker", () => {
});
userEvent.type(screen.getByLabelText("Filter value"), "a@b.com");
expect(screen.getByText("a@b.com")).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
expect(onChange).toHaveBeenLastCalledWith(["a@b.com"]);
});
it("should not allow to create a value when there is the exact match in search results", async () => {
it("should not show free-form input in search results", async () => {
const { onChange } = await setupStringPicker({
query,
stageIndex,
column,
values: ["a@b.com"],
searchValues: {
"a@b.com": createMockFieldValues({
"a@b": createMockFieldValues({
field_id: PEOPLE.EMAIL,
values: [["a@b.com"]],
}),
},
});
userEvent.type(screen.getByLabelText("Filter value"), "a@b.com");
userEvent.type(screen.getByLabelText("Filter value"), "a@b");
act(() => jest.advanceTimersByTime(1000));
expect(screen.getByText("a@b.com")).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
expect(screen.queryByText("a@b")).not.toBeInTheDocument();
expect(onChange).toHaveBeenLastCalledWith(["a@b.com", "a@b"]);
});
});
......
import type { ChangeEvent } from "react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { t } from "ttag";
import {
Checkbox,
MultiSelect,
MultiAutocomplete,
Stack,
Text,
TextInput,
......@@ -12,7 +12,7 @@ import {
} from "metabase/ui";
import type { FieldValue } from "metabase-types/api";
import { getEffectiveOptions } from "../utils";
import { getEffectiveOptions, getFieldOptions } from "../utils";
import { ColumnGrid } from "./ListValuePicker.styled";
import { LONG_OPTION_LENGTH, MAX_INLINE_OPTIONS } from "./constants";
......@@ -22,50 +22,22 @@ interface ListValuePickerProps {
fieldValues: FieldValue[];
selectedValues: string[];
placeholder?: string;
shouldCreate: (query: string) => boolean;
autoFocus?: boolean;
compact?: boolean;
onChange: (newValues: string[]) => void;
}
export function ListValuePicker({
fieldValues,
selectedValues,
placeholder,
autoFocus,
compact,
onChange,
}: ListValuePickerProps) {
if (!compact) {
return (
<CheckboxListPicker
fieldValues={fieldValues}
selectedValues={selectedValues}
placeholder={placeholder}
autoFocus={autoFocus}
onChange={onChange}
/>
);
export function ListValuePicker(props: ListValuePickerProps) {
if (!props.compact) {
return <CheckboxListPicker {...props} />;
}
if (fieldValues.length <= MAX_INLINE_OPTIONS) {
return (
<CheckboxGridPicker
fieldValues={fieldValues}
selectedValues={selectedValues}
placeholder={placeholder}
onChange={onChange}
/>
);
if (props.fieldValues.length <= MAX_INLINE_OPTIONS) {
return <CheckboxGridPicker {...props} />;
}
return (
<MultiSelectPicker
fieldValues={fieldValues}
selectedValues={selectedValues}
placeholder={placeholder}
onChange={onChange}
/>
);
return <AutocompletePicker {...props} />;
}
function CheckboxListPicker({
......@@ -145,20 +117,22 @@ function CheckboxGridPicker({
);
}
export function MultiSelectPicker({
export function AutocompletePicker({
fieldValues,
selectedValues,
placeholder,
shouldCreate,
autoFocus,
onChange,
}: ListValuePickerProps) {
const options = getEffectiveOptions(fieldValues, selectedValues);
const options = useMemo(() => getFieldOptions(fieldValues), [fieldValues]);
return (
<MultiSelect
<MultiAutocomplete
data={options}
value={selectedValues}
placeholder={placeholder}
shouldCreate={shouldCreate}
autoFocus={autoFocus}
searchable
aria-label={t`Filter value`}
......
......@@ -2,13 +2,13 @@ import { useMemo, useState } from "react";
import { useAsync, useDebounce } from "react-use";
import { t } from "ttag";
import { MultiSelect } from "metabase/ui";
import { MultiAutocomplete } from "metabase/ui";
import type { FieldId, FieldValue } from "metabase-types/api";
import { getEffectiveOptions } from "../utils";
import { getFieldOptions } from "../utils";
import { SEARCH_DEBOUNCE } from "./constants";
import { shouldSearch, getSearchValues, getOptimisticOptions } from "./utils";
import { shouldSearch, getSearchValues } from "./utils";
interface SearchValuePickerProps {
fieldId: FieldId;
......@@ -16,7 +16,7 @@ interface SearchValuePickerProps {
fieldValues: FieldValue[];
selectedValues: string[];
placeholder?: string;
canAddValue: (query: string) => boolean;
shouldCreate: (query: string) => boolean;
autoFocus?: boolean;
onChange: (newValues: string[]) => void;
}
......@@ -27,7 +27,7 @@ export function SearchValuePicker({
fieldValues: initialFieldValues,
selectedValues,
placeholder,
canAddValue,
shouldCreate,
autoFocus,
onChange,
}: SearchValuePickerProps) {
......@@ -39,10 +39,7 @@ export function SearchValuePicker({
[fieldId, searchFieldId, searchQuery],
);
const options = useMemo(
() => getEffectiveOptions(fieldValues, selectedValues),
[fieldValues, selectedValues],
);
const options = useMemo(() => getFieldOptions(fieldValues), [fieldValues]);
const handleSearchChange = (newSearchValue: string) => {
setSearchValue(newSearchValue);
......@@ -60,14 +57,15 @@ export function SearchValuePicker({
useDebounce(handleSearchTimeout, SEARCH_DEBOUNCE, [searchValue]);
return (
<MultiSelect
data={getOptimisticOptions(options, searchValue, canAddValue)}
<MultiAutocomplete
data={options}
value={selectedValues}
searchValue={searchValue}
placeholder={placeholder}
searchable
autoFocus={autoFocus}
aria-label={t`Filter value`}
shouldCreate={shouldCreate}
onChange={onChange}
onSearchChange={handleSearchChange}
/>
......
import { MetabaseApi } from "metabase/services";
import type { SelectItem } from "metabase/ui";
import type { FieldId, FieldValue } from "metabase-types/api";
import { SEARCH_LIMIT } from "./constants";
......@@ -32,16 +31,3 @@ export function shouldSearch(
return !isExtensionOfLastSearch || hasMoreValues;
}
export function getOptimisticOptions(
options: SelectItem[],
searchValue: string,
canAddValue: (query: string) => boolean,
) {
const isValid = canAddValue(searchValue);
const isExisting = options.some(({ label }) => label === searchValue);
return isValid && !isExisting
? [{ value: searchValue, label: searchValue }, ...options]
: options;
}
import type { FocusEvent } from "react";
import { useState } from "react";
import { t } from "ttag";
import { MultiSelect } from "metabase/ui";
import { MultiAutocomplete } from "metabase/ui";
interface StaticValuePickerProps {
selectedValues: string[];
placeholder?: string;
canAddValue: (query: string) => boolean;
shouldCreate: (query: string) => boolean;
autoFocus?: boolean;
onChange: (newValues: string[]) => void;
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
......@@ -17,59 +16,24 @@ interface StaticValuePickerProps {
export function StaticValuePicker({
selectedValues,
placeholder,
canAddValue,
shouldCreate,
autoFocus,
onChange,
onFocus,
onBlur,
}: StaticValuePickerProps) {
const [lastValues, setLastValues] = useState(selectedValues);
const [isFocused, setIsFocused] = useState(false);
const visibleValues = isFocused ? lastValues : selectedValues;
const [searchValue, setSearchValue] = useState("");
const handleChange = (newValues: string[]) => {
setLastValues(newValues);
onChange(newValues);
};
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
setLastValues(selectedValues);
onFocus?.(event);
};
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
setLastValues(selectedValues);
setSearchValue("");
onBlur?.(event);
};
const handleSearchChange = (newSearchValue: string) => {
setSearchValue(newSearchValue);
const isValid = canAddValue(newSearchValue);
if (isValid) {
onChange?.([...lastValues, newSearchValue]);
} else {
onChange?.(lastValues);
}
};
return (
<MultiSelect
data={visibleValues}
value={visibleValues}
searchValue={searchValue}
<MultiAutocomplete
data={[]}
value={selectedValues}
placeholder={placeholder}
searchable
autoFocus={autoFocus}
aria-label={t`Filter value`}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSearchChange={handleSearchChange}
shouldCreate={shouldCreate}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
/>
);
}
......@@ -29,7 +29,7 @@ export function canSearchFieldValues(
);
}
function getFieldOptions(fieldValues: FieldValue[]): SelectItem[] {
export function getFieldOptions(fieldValues: FieldValue[]): SelectItem[] {
return fieldValues
.filter(([value]) => value != null)
.map(([value, label = value]) => ({
......
import { Canvas, Story, Meta } from "@storybook/addon-docs";
import { MultiAutocomplete, Stack } from "metabase/ui";
export const args = {
data: [],
size: "md",
label: "Field type",
description: undefined,
error: undefined,
placeholder: "No semantic type",
disabled: false,
readOnly: false,
withAsterisk: false,
dropdownPosition: "flip",
};
export const sampleArgs = {
data: ["Doohickey", "Gadget", "Gizmo", "Widget"],
value: ["Gadget"],
description: "Determines how Metabase displays the field",
error: "required",
};
export const argTypes = {
data: {
control: { type: "json" },
},
size: {
options: ["xs", "md"],
control: { type: "inline-radio" },
},
label: {
control: { type: "text" },
},
description: {
control: { type: "text" },
},
error: {
control: { type: "text" },
},
placeholder: {
control: { type: "text" },
},
disabled: {
control: { type: "boolean" },
},
readOnly: {
control: { type: "boolean" },
},
withAsterisk: {
control: { type: "boolean" },
},
dropdownPosition: {
options: ["bottom", "top", "flip"],
control: { type: "inline-radio" },
},
};
<Meta
title="Inputs/MultiAutocomplete"
component={MultiAutocomplete}
args={args}
argTypes={argTypes}
/>
# MultiAutocomplete
Our themed wrapper around [Mantine MultiSelect](https://v6.mantine.dev/core/multi-select/) with autocomplete features.
## Docs
- [Figma File](https://www.figma.com/file/21uCY0czmCfb6QBjRce0I8/Input-%2F-MultiSelect?type=design&node-id=1-96&mode=design&t=Mk8RP6HsneuWzHtr-0)
- [Mantine MultiSelect Docs](https://v6.mantine.dev/core/multi-select/)
## Examples
export const DefaultTemplate = args => (
<MultiAutocomplete {...args} shouldCreate={query => query.length > 0} />
);
export const VariantTemplate = args => (
<Stack>
<DefaultTemplate {...args} />
<DefaultTemplate {...args} variant="unstyled" />
</Stack>
);
export const Default = DefaultTemplate.bind({});
Default.args = {
data: sampleArgs.data,
};
<Canvas>
<Story name="Default">{Default}</Story>
</Canvas>
### Size - md
export const EmptyMd = VariantTemplate.bind({});
<Canvas>
<Story name="Empty, md">{EmptyMd}</Story>
</Canvas>
#### Asterisk
export const AsteriskMd = VariantTemplate.bind({});
AsteriskMd.args = {
withAsterisk: true,
};
<Canvas>
<Story name="Asterisk, md">{AsteriskMd}</Story>
</Canvas>
#### Clearable
export const ClearableMd = VariantTemplate.bind({});
ClearableMd.args = {
data: sampleArgs.data,
defaultValue: sampleArgs.value,
clearable: true,
withAsterisk: true,
};
<Canvas>
<Story name="Clearable, md">{ClearableMd}</Story>
</Canvas>
#### Description
export const DescriptionMd = VariantTemplate.bind({});
DescriptionMd.args = {
data: sampleArgs.data,
description: sampleArgs.description,
withAsterisk: true,
};
<Canvas>
<Story name="Description, md">{DescriptionMd}</Story>
</Canvas>
#### Disabled
export const DisabledMd = VariantTemplate.bind({});
DisabledMd.args = {
data: sampleArgs.data,
description: sampleArgs.description,
disabled: true,
withAsterisk: true,
};
<Canvas>
<Story name="Disabled, md">{DisabledMd}</Story>
</Canvas>
#### Error
export const ErrorMd = VariantTemplate.bind({});
ErrorMd.args = {
data: sampleArgs.data,
description: sampleArgs.description,
error: sampleArgs.error,
withAsterisk: true,
};
<Canvas>
<Story name="Error, md">{ErrorMd}</Story>
</Canvas>
#### Read only
export const ReadOnlyMd = VariantTemplate.bind({});
ReadOnlyMd.args = {
data: sampleArgs.data,
defaultValue: sampleArgs.value,
description: sampleArgs.description,
readOnly: true,
withAsterisk: true,
};
<Canvas>
<Story name="Read only, md">{ReadOnlyMd}</Story>
</Canvas>
### Size - xs
export const EmptyXs = VariantTemplate.bind({});
EmptyXs.args = {
...EmptyMd.args,
size: "xs",
};
<Canvas>
<Story name="Empty, xs">{EmptyXs}</Story>
</Canvas>
#### Asterisk
export const AsteriskXs = VariantTemplate.bind({});
AsteriskXs.args = {
...AsteriskMd.args,
size: "xs",
};
<Canvas>
<Story name="Asterisk, xs">{AsteriskXs}</Story>
</Canvas>
#### Clearable
export const ClearableXs = VariantTemplate.bind({});
ClearableXs.args = {
...ClearableMd.args,
size: "xs",
};
<Canvas>
<Story name="Clearable, xs">{ClearableXs}</Story>
</Canvas>
#### Description
export const DescriptionXs = VariantTemplate.bind({});
DescriptionXs.args = {
...DescriptionMd.args,
size: "xs",
};
<Canvas>
<Story name="Description, xs">{DescriptionXs}</Story>
</Canvas>
#### Disabled
export const DisabledXs = VariantTemplate.bind({});
DisabledXs.args = {
...DisabledMd.args,
size: "xs",
};
<Canvas>
<Story name="Disabled, xs">{DisabledXs}</Story>
</Canvas>
#### Error
export const ErrorXs = VariantTemplate.bind({});
ErrorXs.args = {
...ErrorMd.args,
size: "xs",
};
<Canvas>
<Story name="Error, xs">{ErrorXs}</Story>
</Canvas>
#### Read only
export const ReadOnlyXs = VariantTemplate.bind({});
ReadOnlyXs.args = {
...ReadOnlyMd.args,
size: "xs",
};
<Canvas>
<Story name="Read only, xs">{ReadOnlyXs}</Story>
</Canvas>
import type { MultiSelectProps, SelectItem } from "@mantine/core";
import { MultiSelect } from "@mantine/core";
import { useUncontrolled } from "@mantine/hooks";
import type { ClipboardEvent, FocusEvent } from "react";
import { useMemo, useState } from "react";
export function MultiAutocomplete({
data,
value: controlledValue,
defaultValue,
searchValue: controlledSearchValue,
placeholder,
autoFocus,
shouldCreate,
onChange,
onSearchChange,
onFocus,
onBlur,
...props
}: MultiSelectProps) {
const [selectedValues, setSelectedValues] = useUncontrolled({
value: controlledValue,
defaultValue,
finalValue: [],
onChange,
});
const [searchValue, setSearchValue] = useUncontrolled({
value: controlledSearchValue,
finalValue: "",
onChange: onSearchChange,
});
const [lastSelectedValues, setLastSelectedValues] = useState(selectedValues);
const [isFocused, setIsFocused] = useState(false);
const visibleValues = isFocused ? lastSelectedValues : selectedValues;
const items = useMemo(
() => getAvailableSelectItems(data, lastSelectedValues),
[data, lastSelectedValues],
);
const handleChange = (newValues: string[]) => {
setSelectedValues(newValues);
setLastSelectedValues(newValues);
};
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
setLastSelectedValues(selectedValues);
onFocus?.(event);
};
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
setLastSelectedValues(selectedValues);
setSearchValue("");
onBlur?.(event);
};
const handleSearchChange = (newSearchValue: string) => {
setSearchValue(newSearchValue);
const isValid = shouldCreate?.(newSearchValue, []);
if (isValid) {
setSelectedValues([...lastSelectedValues, newSearchValue]);
} else {
setSelectedValues(lastSelectedValues);
}
};
const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
const text = event.clipboardData.getData("Text");
const values = text.split(/[\n,]/g);
if (values.length > 1) {
const uniqueValues = [...new Set(values)];
const validValues = uniqueValues.filter(value =>
shouldCreate?.(value, []),
);
if (validValues.length > 0) {
event.preventDefault();
const newSelectedValues = [...lastSelectedValues, ...validValues];
setSelectedValues(newSelectedValues);
setLastSelectedValues(newSelectedValues);
}
}
};
return (
<MultiSelect
{...props}
data={items}
value={visibleValues}
searchValue={searchValue}
placeholder={placeholder}
searchable
autoFocus={autoFocus}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSearchChange={handleSearchChange}
onPaste={handlePaste}
/>
);
}
function getSelectItem(item: string | SelectItem): SelectItem {
if (typeof item === "string") {
return { value: item };
} else {
return item;
}
}
function getAvailableSelectItems(
data: ReadonlyArray<string | SelectItem>,
selectedValues: string[],
) {
const items = [
...data.map(getSelectItem),
...selectedValues.map(getSelectItem),
];
const mapping = items.reduce((map: Map<string, string>, option) => {
if (!map.has(option.value)) {
map.set(option.value, option.label ?? option.value);
}
return map;
}, new Map<string, string>());
return [...mapping.entries()].map(([value, label]) => ({ value, label }));
}
export * from "./MultiAutocomplete";
......@@ -5,6 +5,7 @@ export * from "./DateInput";
export * from "./DatePicker";
export * from "./FileInput";
export * from "./Input";
export * from "./MultiAutocomplete";
export * from "./MultiSelect";
export * from "./NumberInput";
export * from "./Radio";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment