Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow multiple values for string operators in the query builder #42101

Merged
merged 12 commits into from
May 8, 2024
81 changes: 76 additions & 5 deletions e2e/test/scenarios/filters/filter-types.cy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ const STRING_CASES = [
expectedDisplayName: "Title contains Al",
expectedRowCount: 47,
},
{
title: "contains, multiple options",
columnName: "Title",
operator: "Contains",
values: ["Al", "Me"],
expectedDisplayName: "Title contains 2 selections",
expectedRowCount: 68,
},
{
title: "contains, multiple options, case sensitive",
columnName: "Title",
operator: "Contains",
values: ["Al", "Me"],
options: ["Case sensitive"],
expectedDisplayName: "Title contains 2 selections",
expectedRowCount: 28,
},
{
title: "contains, case sensitive",
columnName: "Title",
Expand All @@ -60,6 +77,23 @@ const STRING_CASES = [
expectedDisplayName: "Title does not contain Al",
expectedRowCount: 153,
},
{
title: "does not contain, multiple options",
columnName: "Title",
operator: "Does not contain",
values: ["Al", "Me"],
expectedDisplayName: "Title does not contain 2 selections",
expectedRowCount: 132,
},
{
title: "does not contain, multiple options, case sensitive",
columnName: "Title",
operator: "Does not contain",
values: ["Al", "Me"],
options: ["Case sensitive"],
expectedDisplayName: "Title does not contain 2 selections",
expectedRowCount: 172,
},
{
title: "does not contain, case sensitive",
columnName: "Title",
Expand All @@ -77,14 +111,31 @@ const STRING_CASES = [
expectedDisplayName: "Title starts with sm",
expectedRowCount: 11,
},
{
title: "starts with, multiple options",
columnName: "Title",
operator: "Starts with",
values: ["sm", "Me"],
expectedDisplayName: "Title starts with 2 selections",
expectedRowCount: 25,
},
{
title: "starts with, multiple options, case sensitive",
columnName: "Title",
operator: "Starts with",
values: ["sm", "Me"],
options: ["Case sensitive"],
expectedDisplayName: "Title starts with 2 selections",
expectedRowCount: 14,
},
{
title: "starts with, case sensitive",
columnName: "Title",
operator: "Starts with",
values: ["Sm"],
values: ["sm"],
options: ["Case sensitive"],
expectedDisplayName: "Title starts with Sm",
expectedRowCount: 11,
expectedDisplayName: "Title starts with sm",
expectedRowCount: 0,
},
{
title: "ends with",
Expand All @@ -94,6 +145,23 @@ const STRING_CASES = [
expectedDisplayName: "Title ends with At",
expectedRowCount: 22,
},
{
title: "ends with, multiple options",
columnName: "Title",
operator: "Ends with",
values: ["At", "es"],
expectedDisplayName: "Title ends with 2 selections",
expectedRowCount: 38,
},
{
title: "ends with, multiple options, case sensitive",
columnName: "Title",
operator: "Ends with",
values: ["At", "es"],
options: ["Case sensitive"],
expectedDisplayName: "Title ends with 2 selections",
expectedRowCount: 16,
},
{
title: "ends with, case sensitive",
columnName: "Title",
Expand Down Expand Up @@ -416,8 +484,11 @@ describe("scenarios > filters > filter types", () => {
popover().findByText(columnName).click();
selectFilterOperator(operator);
popover().within(() => {
values.forEach((value, index) => {
cy.findByLabelText("Filter value").focus().type(value).blur();
values.forEach(value => {
cy.findByLabelText("Filter value")
.focus()
.type(value)
.realPress("Tab");
});
options.forEach(option => cy.findByText(option).click());
cy.button("Add filter").click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useMemo, useState } from "react";
import { t } from "ttag";

import { getColumnIcon } from "metabase/common/utils/columns";
import type { OperatorType } from "metabase/querying/hooks/use-string-filter";
import { useStringFilter } from "metabase/querying/hooks/use-string-filter";
import { Grid, TextInput } from "metabase/ui";
import { Grid, MultiAutocomplete } from "metabase/ui";
import type * as Lib from "metabase-lib";

import { StringFilterValuePicker } from "../../FilterValuePicker";
Expand All @@ -24,11 +25,10 @@ export function StringFilterEditor({
const [isFocused, setIsFocused] = useState(false);

const {
type,
operator,
availableOptions,
values,
valueCount,
hasMultipleValues,
options,
getDefaultValues,
getFilterClause,
Expand Down Expand Up @@ -90,8 +90,7 @@ export function StringFilterEditor({
stageIndex={stageIndex}
column={column}
values={values}
valueCount={valueCount}
hasMultipleValues={hasMultipleValues}
type={type}
onChange={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
Expand All @@ -107,8 +106,7 @@ interface StringValueInputProps {
stageIndex: number;
column: Lib.ColumnMetadata;
values: string[];
valueCount: number;
hasMultipleValues?: boolean;
type: OperatorType;
onChange: (values: string[]) => void;
onFocus: () => void;
onBlur: () => void;
Expand All @@ -119,13 +117,12 @@ function StringValueInput({
stageIndex,
column,
values,
valueCount,
hasMultipleValues,
type,
onChange,
onFocus,
onBlur,
}: StringValueInputProps) {
if (hasMultipleValues) {
if (type === "exact") {
return (
<StringFilterValuePicker
query={query}
Expand All @@ -140,13 +137,14 @@ function StringValueInput({
);
}

if (valueCount === 1) {
if (type === "partial") {
return (
<TextInput
value={values[0]}
<MultiAutocomplete
data={[]}
value={values}
placeholder={t`Enter some text`}
aria-label={t`Filter value`}
onChange={event => onChange([event.target.value])}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,8 @@ describe("StringFilterEditor", () => {
filter,
});

await userEvent.clear(screen.getByDisplayValue("Ga"));
await userEvent.type(
screen.getByPlaceholderText("Enter some text"),
"Wi",
);
const input = screen.getByLabelText("Filter value");
await userEvent.type(input, "{backspace}Wi");
await userEvent.tab();

expect(getNextFilterName()).toBe("Category starts with Wi");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { FormEvent } from "react";
import { useMemo } from "react";
import { t } from "ttag";

import type { OperatorType } from "metabase/querying/hooks/use-string-filter";
import { useStringFilter } from "metabase/querying/hooks/use-string-filter";
import { Box, Checkbox, Flex, TextInput } from "metabase/ui";
import { Box, Checkbox, Flex, MultiAutocomplete } from "metabase/ui";
import * as Lib from "metabase-lib";

import { StringFilterValuePicker } from "../../FilterValuePicker";
Expand All @@ -28,12 +29,10 @@ export function StringFilterPicker({
);

const {
type,
operator,
availableOptions,
values,
valueCount,
hasMultipleValues,
hasCaseSensitiveOption,
options,
isValid,
getDefaultValues,
Expand Down Expand Up @@ -86,12 +85,11 @@ export function StringFilterPicker({
stageIndex={stageIndex}
column={column}
values={values}
valueCount={valueCount}
hasMultipleValues={hasMultipleValues}
type={type}
onChange={setValues}
/>
<FilterPickerFooter isNew={isNew} canSubmit={isValid}>
{hasCaseSensitiveOption && (
{type === "partial" && (
<CaseSensitiveOption
value={options["case-sensitive"] ?? false}
onChange={newValue => setOptions({ "case-sensitive": newValue })}
Expand All @@ -108,8 +106,7 @@ interface StringValueInputProps {
stageIndex: number;
column: Lib.ColumnMetadata;
values: string[];
valueCount: number;
hasMultipleValues?: boolean;
type: OperatorType;
onChange: (values: string[]) => void;
}

Expand All @@ -118,11 +115,10 @@ function StringValueInput({
stageIndex,
column,
values,
valueCount,
hasMultipleValues,
type,
onChange,
}: StringValueInputProps) {
if (hasMultipleValues) {
if (type === "exact") {
return (
<Box p="md" mah="25vh" style={{ overflow: "auto" }}>
<StringFilterValuePicker
Expand All @@ -137,16 +133,17 @@ function StringValueInput({
);
}

if (valueCount === 1) {
if (type === "partial") {
return (
<Flex p="md">
<TextInput
value={values[0]}
<MultiAutocomplete
value={values}
data={[]}
placeholder={t`Enter some text`}
autoFocus
w="100%"
aria-label={t`Filter value`}
onChange={event => onChange([event.target.value])}
onChange={onChange}
/>
</Flex>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,25 +204,6 @@ describe("StringFilterPicker", () => {
expect(getNextFilterColumnName()).toBe("Product → Description");
});

it("should add a filter with one value via keyboard", async () => {
const { onChange, getNextFilterParts, getNextFilterColumnName } = setup();

await setOperator("Contains");
const input = screen.getByPlaceholderText("Enter some text");
await userEvent.type(input, "{enter}");
expect(onChange).not.toHaveBeenCalled();

await userEvent.type(input, "green{enter}");
expect(onChange).toHaveBeenCalled();
expect(getNextFilterParts()).toMatchObject({
operator: "contains",
column: expect.anything(),
values: ["green"],
options: { "case-sensitive": false },
});
expect(getNextFilterColumnName()).toBe("Product → Description");
});

it("should add a case-sensitive filter", async () => {
const { getNextFilterParts, getNextFilterColumnName } = setup();

Expand Down Expand Up @@ -391,8 +372,10 @@ describe("StringFilterPicker", () => {
it("should update a filter", async () => {
const { getNextFilterParts, getNextFilterColumnName } = setup(opts);

const input = screen.getByRole("textbox", { name: "Filter value" });
await userEvent.clear(input);
const input = screen.getByLabelText("Filter value");
expect(screen.getByDisplayValue("abc")).toBeInTheDocument();
await userEvent.type(input, "{backspace}");
expect(screen.queryByDisplayValue("abc")).not.toBeInTheDocument();

await userEvent.type(input, "foo");
await userEvent.click(screen.getByLabelText("Case sensitive"));
Expand Down Expand Up @@ -539,8 +522,7 @@ describe("StringFilterPicker", () => {

await setOperator("Contains");

expect(screen.getByDisplayValue("Gadget")).toBeInTheDocument();
expect(screen.queryByDisplayValue("Gizmo")).not.toBeInTheDocument();
expect(screen.getByDisplayValue("Gadget,Gizmo")).toBeInTheDocument();
expect(updateButton).toBeEnabled();

await setOperator("Is empty");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,34 @@ import type { OperatorOption } from "./types";
export const OPERATOR_OPTIONS: Record<string, OperatorOption> = {
"=": {
operator: "=",
valueCount: 1,
hasMultipleValues: true,
type: "exact",
},
"!=": {
operator: "!=",
valueCount: 1,
hasMultipleValues: true,
type: "exact",
},
contains: {
operator: "contains",
valueCount: 1,
hasCaseSensitiveOption: true,
type: "partial",
},
"does-not-contain": {
operator: "does-not-contain",
valueCount: 1,
hasCaseSensitiveOption: true,
type: "partial",
},
"starts-with": {
operator: "starts-with",
valueCount: 1,
hasCaseSensitiveOption: true,
type: "partial",
},
"ends-with": {
operator: "ends-with",
valueCount: 1,
hasCaseSensitiveOption: true,
type: "partial",
},
"is-empty": {
operator: "is-empty",
valueCount: 0,
type: "empty",
},
"not-empty": {
operator: "not-empty",
valueCount: 0,
type: "empty",
},
};