diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx index 7123bde68419ab23964a84818fad909eebc81255..b86ce3e37d2309456d0fd35afa8060a2d8ca7041 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useCallback } from "react"; import Filter from "metabase-lib/lib/queries/structured/Filter"; import Dimension from "metabase-lib/lib/Dimension"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; -import { isBoolean, isString } from "metabase/lib/schema_metadata"; +import { isBoolean, isString, isNumber } from "metabase/lib/schema_metadata"; import { BooleanPickerCheckbox } from "metabase/query_builder/components/filters/pickers/BooleanPicker"; import { BulkFilterSelect } from "../BulkFilterSelect"; @@ -85,6 +85,8 @@ export const BulkFilterItem = ({ case "type/PK": case "type/FK": case "type/Text": + case "type/Integer": + case "type/Float": return ( <InlineValuePicker filter={filter ?? newFilter} @@ -129,6 +131,11 @@ const getNewFilter = (query: StructuredQuery, dimension: Dimension): Filter => { useDefaultOperator: !isBooleanField, }); + const isNumericField = isNumber(field); + if (isNumericField) { + filter = filter.setOperator("between"); + } + const isTextField = isString(field) && field.has_field_values !== "list"; if (isTextField) { filter = filter.setOperator("contains"); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx index e475220efb78dd5f8c744139d85ea9140ae63071..2a7b8483f98980183bf61acf889bcf1cd9e084eb 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/BulkFilterItem.unit.spec.tsx @@ -29,7 +29,7 @@ const booleanField = new Field({ const intField = new Field({ database_type: "test", - semantic_type: "", + semantic_type: "type/Integer", table_id: 8, name: "int_num", has_field_values: "none", @@ -43,7 +43,7 @@ const intField = new Field({ const floatField = new Field({ database_type: "test", - semantic_type: "", + semantic_type: "type/Integer", table_id: 8, name: "float_num", has_field_values: "none", @@ -145,6 +145,8 @@ const pkDimension = pkField.dimension(); const fkDimension = fkField.dimension(); const textDimension = textField.dimension(); +const store = getStore(); + describe("BulkFilterItem", () => { it("renders a boolean picker for a boolean filter", () => { const testFilter = new Filter( @@ -169,7 +171,7 @@ describe("BulkFilterItem", () => { expect(screen.getByLabelText("false")).not.toBeChecked(); }); - it("renders a bulk filter select for integer field type", () => { + it("renders a value picker integer field type", () => { const testFilter = new Filter( ["=", ["field", intField.id, null], 99], null, @@ -178,24 +180,23 @@ describe("BulkFilterItem", () => { const changeSpy = jest.fn(); render( - <BulkFilterItem - query={query} - filter={testFilter} - dimension={intDimension} - onAddFilter={changeSpy} - onChangeFilter={changeSpy} - onRemoveFilter={changeSpy} - />, + <Provider store={store}> + <BulkFilterItem + query={query} + filter={testFilter} + dimension={intDimension} + onAddFilter={changeSpy} + onChangeFilter={changeSpy} + onRemoveFilter={changeSpy} + /> + </Provider>, ); - expect(screen.queryByText("true")).toBeNull(); - expect(screen.getByTestId("select-button")).toHaveAttribute( - "aria-label", - "int_num", - ); + screen.getByTestId("value-picker"); + screen.getByLabelText(intField.name); }); - it("renders a bulk filter select for float field type", () => { + it("renders a value picker for float field type", () => { const testFilter = new Filter( ["=", ["field", floatField.id, null], 99], null, @@ -204,20 +205,39 @@ describe("BulkFilterItem", () => { const changeSpy = jest.fn(); render( - <BulkFilterItem - query={query} - filter={testFilter} - dimension={floatDimension} - onAddFilter={changeSpy} - onChangeFilter={changeSpy} - onRemoveFilter={changeSpy} - />, + <Provider store={store}> + <BulkFilterItem + query={query} + filter={testFilter} + dimension={floatDimension} + onAddFilter={changeSpy} + onChangeFilter={changeSpy} + onRemoveFilter={changeSpy} + /> + </Provider>, ); - expect(screen.queryByText("true")).toBeNull(); - expect(screen.getByTestId("select-button")).toHaveAttribute( - "aria-label", - "float_num", + screen.getByTestId("value-picker"); + screen.getByLabelText(floatField.name); + }); + + it("defaults to a between picker for float field type", () => { + const changeSpy = jest.fn(); + + render( + <Provider store={store}> + <BulkFilterItem + query={query} + filter={undefined} + dimension={floatDimension} + onAddFilter={changeSpy} + onChangeFilter={changeSpy} + onRemoveFilter={changeSpy} + /> + </Provider>, ); + screen.getByTestId("value-picker"); + screen.getByLabelText(floatField.name); + screen.getByText("Between"); }); it("renders a category picker for category type", () => { @@ -227,7 +247,6 @@ describe("BulkFilterItem", () => { query, ); const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> @@ -251,7 +270,6 @@ describe("BulkFilterItem", () => { query, ); const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> @@ -276,7 +294,6 @@ describe("BulkFilterItem", () => { query, ); const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> @@ -301,7 +318,6 @@ describe("BulkFilterItem", () => { query, ); const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> @@ -322,7 +338,6 @@ describe("BulkFilterItem", () => { it("defaults key filters to 'is' operator", () => { const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> @@ -341,7 +356,6 @@ describe("BulkFilterItem", () => { it("defaults text filters to 'contains' operator", () => { const changeSpy = jest.fn(); - const store = getStore(); render( <Provider store={store}> diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts index a193585019329d978af6944ad5cb1a464ac353f9..a2281df989659538b3f9fa03819068cbe84f0c45 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterItem/constants.ts @@ -4,8 +4,10 @@ export const FIELD_PRIORITY = [ "type/DateTimeWithLocalTZ", "type/DateTimeWithZoneOffset", "type/DateTimeWithZoneID", - "type/Boolean", "list", + "type/Integer", + "type/Float", + "type/Boolean", "type/Category", "type/PK", "type/FK", diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/BulkFilterModal.tsx b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/BulkFilterModal.tsx index 357ba6a56f38db2e920c38af22e1e2f9a92605ec..035836e9ce617b184dd42efe499060c2a79b83d1 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/BulkFilterModal.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/BulkFilterModal.tsx @@ -22,6 +22,8 @@ import { ModalTitle, } from "./BulkFilterModal.styled"; +import { fixBetweens } from "./utils"; + export interface BulkFilterModalProps { question: Question; onClose?: () => void; @@ -66,7 +68,8 @@ const BulkFilterModal = ({ }, [query]); const handleApplyQuery = useCallback(() => { - query.clean().update(undefined, { run: true }); + const preCleanedQuery = fixBetweens(query); + preCleanedQuery.clean().update(undefined, { run: true }); onClose?.(); }, [query, onClose]); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.ts b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f956dd48707ea268c29a99bebe93c5e622042fe --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.ts @@ -0,0 +1,64 @@ +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import Filter from "metabase-lib/lib/queries/structured/Filter"; + +// fix between filters with missing or misordered arguments +export function fixBetweens(query: StructuredQuery): StructuredQuery { + const betweenFilters = query + .filters() + .filter(filter => filter.operatorName() === "between"); + + // find the first invalid filter (if any), and fix it recursively + for (const filter of betweenFilters) { + const validArgumentsCount = countValidArumgents(filter); + if (validArgumentsCount === 1) { + return fixBetweens(handleEmptyBetween(filter)); + } else if (validArgumentsCount === 2 && hasBackwardsArguments(filter)) { + return fixBetweens(swapFilterArguments(filter)); + } + } + + return query; +} + +/** + * if a numeric field has an invalid between filter that has only one value + * we will intuit that the user wants a >= or <= filter + * + * @param filter a filter with a single valid argument + * @returns a new query with this filter's operator changed + */ +export function handleEmptyBetween(filter: Filter): StructuredQuery { + const [validArgument] = filter.arguments().filter(isValidArgument); + + const newOperator = isValidArgument(filter.arguments()[0]) ? ">=" : "<="; + + const newFilter = filter + .setOperator(newOperator) + .setArguments([validArgument]); + + return filter.replace(newFilter); +} + +/** + * swaps the filter's arguments + * @param filter a filter with backwards arguments + * @returns a new query with this filter's arguments swapped + */ +export function swapFilterArguments(filter: Filter): StructuredQuery { + const [lowerArgument, upperArgument] = filter.arguments(); + const newFilter = filter.setArguments([upperArgument, lowerArgument]); + return filter.replace(newFilter); +} + +const isValidArgument = (arg: number | null | undefined) => + !(arg === null || arg === undefined); + +const countValidArumgents = (filter: Filter) => + filter + .arguments() + .reduce((count, arg) => (isValidArgument(arg) ? count + 1 : count), 0); + +export const hasBackwardsArguments = (filter: Filter) => { + const [lowerArgument, upperArgument] = filter.arguments(); + return lowerArgument > upperArgument; +}; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.unit.spec.ts b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ed7f2cf884c5ddd6fa2f0e62e691ad88e7b412 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/filters/modals/BulkFilterModal/utils.unit.spec.ts @@ -0,0 +1,172 @@ +import { SAMPLE_DATABASE, ORDERS } from "__support__/sample_database_fixture"; + +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import Filter from "metabase-lib/lib/queries/structured/Filter"; + +import { + fixBetweens, + hasBackwardsArguments, + swapFilterArguments, + handleEmptyBetween, +} from "./utils"; + +const makeQuery = (query = {}): StructuredQuery => { + return new StructuredQuery(ORDERS.question(), { + type: "query", + database: SAMPLE_DATABASE?.id, + query: { + "source-table": ORDERS.id, + ...query, + }, + }); +}; + +const query = makeQuery(); + +describe("BulkFilterModal utils", () => { + describe("hasBackwardsArguments", () => { + it("flags between filters with misordered arguments", () => { + const filter = new Filter([ + "between", + ["field", ORDERS.fields[1].id, null], + 20, + 10, + ]); + + const isBackwards = hasBackwardsArguments(filter); + expect(isBackwards).toBe(true); + }); + + it("hasBackwardsArguments identifies between filters with correctly ordered arguments", () => { + const filter = new Filter([ + "between", + ["field", ORDERS.fields[1].id, null], + 20, + 100, + ]); + + const isBackwards = hasBackwardsArguments(filter); + expect(isBackwards).toBe(false); + }); + }); + + describe("swapFilterArguments", () => { + it("swaps arguments in a between filter", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], 20, 10], + null, + query, + ); + filter.add(); + + const newQuery = swapFilterArguments(filter); + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([10, 20]); + }); + }); + + describe("handleEmptyBetween", () => { + it("replaces a between filter with an empty second argument with a >= filter", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], 20, null], + null, + query, + ); + const newQuery = handleEmptyBetween(filter); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([20]); + expect(newFilter.operatorName()).toEqual(">="); + }); + + it("replaces a between filter with an empty first argument with a <= filter", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], undefined, 30], + null, + query, + ); + const newQuery = handleEmptyBetween(filter); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([30]); + expect(newFilter.operatorName()).toEqual("<="); + }); + }); + + describe("fixBetweens", () => { + it("handles empty between filters", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], 30, null], + null, + query, + ); + const newQuery = fixBetweens(filter.add()); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([30]); + expect(newFilter.operatorName()).toEqual(">="); + }); + + it("handles backwards between filters", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], 30, 20], + null, + query, + ); + const newQuery = fixBetweens(filter.add()); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([20, 30]); + expect(newFilter.operatorName()).toEqual("between"); + }); + + it("ignores valid between filters", () => { + const filter = new Filter( + ["between", ["field", ORDERS.fields[1].id, null], 40, 50], + null, + query, + ); + const newQuery = fixBetweens(filter.add()); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([40, 50]); + expect(newFilter.operatorName()).toEqual("between"); + }); + + it("ignores non-betwee filters", () => { + const filter = new Filter( + ["=", ["field", ORDERS.fields[1].id, null], 40, 50], + null, + query, + ); + const newQuery = fixBetweens(filter.add()); + + const newFilter = newQuery.filters()[0]; + expect(newFilter.arguments()).toEqual([40, 50]); + expect(newFilter.operatorName()).toEqual("="); + }); + + it("handles multiple invalid between filters", () => { + const testQuery = makeQuery({ + filter: [ + "and", + ["between", ["field", ORDERS.fields[1].id, null], 80, 50], + ["between", ["field", ORDERS.fields[1].id, null], 30, null], + ["=", ["field", ORDERS.fields[1].id, null], 8, 9, 7], + ], + }); + + const fixedQuery = fixBetweens(testQuery); + + const [newFilter1, newFilter2, newFilter3] = fixedQuery.filters(); + expect(newFilter1.arguments()).toEqual([50, 80]); + expect(newFilter1.operatorName()).toEqual("between"); + + expect(newFilter2.arguments()).toEqual([30]); + expect(newFilter2.operatorName()).toEqual(">="); + + expect(newFilter3.arguments()).toEqual([8, 9, 7]); + expect(newFilter3.operatorName()).toEqual("="); + }); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.styled.ts b/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.styled.ts index 835d858d9b9448e8a9c54f69d211522c38636245..b1ee1fcfb210fb1b576f990f1c7d11584978c2b6 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.styled.ts +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.styled.ts @@ -4,7 +4,7 @@ import { color } from "metabase/lib/colors"; import OperatorSelectorComponent from "metabase/query_builder/components/filters/OperatorSelector"; import FieldValuesWidget from "metabase/components/FieldValuesWidget"; -import Input from "metabase/core/components/Input"; +import NumericInput from "metabase/core/components/NumericInput"; export const OperatorSelector = styled(OperatorSelectorComponent)` margin-bottom: ${space(1)}; @@ -31,7 +31,7 @@ export const NumberSeparator = styled.span` padding: 0 ${space(1)}; `; -export const NumberInput = styled(Input)` +export const NumberInput = styled(NumericInput)` border-color: ${color("border")}; width: 10rem; `; diff --git a/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.tsx b/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.tsx index 63e6371efa10bc6a71a7201d7ec7e4b5072902be..071ed3e123d2173bee9cf35c8f6ca6f0425bde01 100644 --- a/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.tsx +++ b/frontend/src/metabase/query_builder/components/filters/modals/InlineValuePicker/InlineValuePicker.tsx @@ -100,14 +100,14 @@ function ValuesInput({ <NumberInput placeholder={t`min`} value={filterArguments[0] ?? ""} - onChange={e => onChange([e.target.value, filterArguments[1]])} + onChange={val => onChange([val, filterArguments[1]])} fullWidth /> <NumberSeparator>{t`and`}</NumberSeparator> <NumberInput placeholder={t`max`} value={filterArguments[1] ?? ""} - onChange={e => onChange([filterArguments[0], e.target.value])} + onChange={val => onChange([filterArguments[0], val])} fullWidth /> </BetweenContainer> diff --git a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js b/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js index fa37a669a798c4658ada8ba53af1f1df6ee4d6b0..473a61455eb178ad41ac7715245564295d177eb2 100644 --- a/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js +++ b/frontend/test/metabase/scenarios/filters/filter-bulk.cy.spec.js @@ -124,28 +124,14 @@ describe("scenarios > filters > bulk filtering", () => { modal().within(() => { cy.findByText("Summaries").click(); - cy.findByLabelText("Count").click(); - }); - - popover().within(() => { - cy.findByText("Equal to").click(); - }); - popover() - .eq(1) - .within(() => cy.findByText("Greater than").click()); + cy.findByPlaceholderText("min").type("500"); - popover().within(() => { - cy.findByPlaceholderText("Enter a number").type("500"); - cy.button("Add filter").click(); - }); - - modal().within(() => { cy.button("Apply").click(); cy.wait("@dataset"); }); - cy.findByText("Count is greater than 500").should("be.visible"); + cy.findByText("Count is greater than or equal to 500").should("be.visible"); cy.findByText("Showing 21 rows").should("be.visible"); }); @@ -576,6 +562,58 @@ describe("scenarios > filters > bulk filtering", () => { cy.findByText("Showing 2 rows").should("be.visible"); }); }); + + describe("number filters", () => { + beforeEach(() => { + visitQuestionAdhoc(productsQuestion); + openFilterModal(); + }); + + it("applies a between filter", () => { + modal().within(() => { + cy.findByLabelText("Price").within(() => { + cy.findByPlaceholderText("min").type("50"); + cy.findByPlaceholderText("max").type("80"); + }); + cy.button("Apply").click(); + }); + cy.findByText("Price between 50 80").should("be.visible"); + cy.findByText("Showing 72 rows").should("be.visible"); + }); + + it("applies a greater than filter", () => { + modal().within(() => { + cy.findByLabelText("Price").within(() => { + cy.findByTestId("select-button").click(); + }); + }); + + popover().within(() => { + cy.findByText("Greater than").click(); + }); + + modal().within(() => { + cy.findByPlaceholderText("Enter a number").type("50"); + cy.button("Apply").click(); + }); + + cy.findByText("Price is greater than 50").should("be.visible"); + cy.findByText("Showing 106 rows").should("be.visible"); + }); + + it("infers a <= filter from an invalid between filter", () => { + modal().within(() => { + cy.findByLabelText("Price").within(() => { + cy.findByPlaceholderText("max").type("50"); + }); + + cy.button("Apply").click(); + }); + + cy.findByText("Price is less than or equal to 50").should("be.visible"); + cy.findByText("Showing 94 rows").should("be.visible"); + }); + }); }); const modal = () => {