From 372cbd3e5d211937b84d02a85a498fa225012db8 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Mon, 28 Oct 2024 09:52:05 -0400 Subject: [PATCH] Invert exclude filter checkbox state (#49178) --- ...dashboard-filters-reproductions.cy.spec.js | 7 +- .../DatePicker/DatePicker.unit.spec.tsx | 4 +- .../pickers/DatePicker/ExcludeDatePicker.tsx | 25 ++-- .../ExcludeDatePicker.unit.spec.tsx | 119 ++++++++++++++---- .../parameters/utils/date-formatting.ts | 2 +- .../ExcludeDatePicker/ExcludeDatePicker.tsx | 30 ++--- .../ExcludeDatePicker.unit.spec.tsx | 64 ++++++++-- 7 files changed, 179 insertions(+), 72 deletions(-) diff --git a/e2e/test/scenarios/filters-reproductions/dashboard-filters-reproductions.cy.spec.js b/e2e/test/scenarios/filters-reproductions/dashboard-filters-reproductions.cy.spec.js index 9c897490c9a..1d8b7b88c59 100644 --- a/e2e/test/scenarios/filters-reproductions/dashboard-filters-reproductions.cy.spec.js +++ b/e2e/test/scenarios/filters-reproductions/dashboard-filters-reproductions.cy.spec.js @@ -1410,13 +1410,13 @@ describe("issue 24235", () => { popover().within(() => { cy.findByText("Exclude...").click(); cy.findByText("Days of the week...").click(); - cy.findByText("Select none...").click(); + cy.findByText("Select all").click(); cy.findByText("Add filter").click(); }); filterWidget().click(); popover().within(() => { - cy.findByText("Select all...").click(); + cy.findByText("Select none").click(); cy.findByText("Update filter").click(); }); @@ -3013,9 +3013,10 @@ describe("issue 27579", () => { popover().within(() => { cy.findByText("Exclude...").click(); cy.findByText("Hours of the day...").click(); + cy.findByText("Select all").click(); cy.findByLabelText("12 AM").should("be.checked"); - cy.findByText("Select none...").click(); + cy.findByText("Select none").click(); cy.findByLabelText("12 AM").should("not.be.checked"); }); }); diff --git a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/DatePicker.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/DatePicker.unit.spec.tsx index 94e9b0b2a71..ada68e5006f 100644 --- a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/DatePicker.unit.spec.tsx +++ b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/DatePicker.unit.spec.tsx @@ -419,7 +419,7 @@ describe("DatePicker", () => { name: /12 AM/i, }); - expect(midnightCheckbox).toBeChecked(); + expect(midnightCheckbox).not.toBeChecked(); await userEvent.click(midnightCheckbox); @@ -429,7 +429,7 @@ describe("DatePicker", () => { 0, ]); - expect(midnightCheckbox).not.toBeChecked(); + expect(midnightCheckbox).toBeChecked(); }); }); }); diff --git a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.tsx b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.tsx index 91757b7019b..a0ae3cb377d 100644 --- a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.tsx +++ b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.tsx @@ -38,7 +38,7 @@ type Group = { displayName: string; init: (filter: Filter) => any[]; test: (filter: Filter) => boolean; - getOptions: () => Option[][]; + getOptionGroups: () => Option[][]; }; export const EXCLUDE_OPERATORS: Group[] = [ @@ -47,28 +47,28 @@ export const EXCLUDE_OPERATORS: Group[] = [ displayName: t`Days of the week...`, test: filter => isDayOfWeekDateFilter(filter), init: filter => getInitialDayOfWeekFilter(filter), - getOptions: EXCLUDE_OPTIONS["day-of-week"], + getOptionGroups: EXCLUDE_OPTIONS["day-of-week"], }, { name: "months", displayName: t`Months of the year...`, test: filter => isMonthOfYearDateFilter(filter), init: filter => getInitialMonthOfYearFilter(filter), - getOptions: EXCLUDE_OPTIONS["month-of-year"], + getOptionGroups: EXCLUDE_OPTIONS["month-of-year"], }, { name: "quarters", displayName: t`Quarters of the year...`, test: filter => isQuarterofYearDateFilter(filter), init: filter => getInitialQuarterOfYearFilter(filter), - getOptions: EXCLUDE_OPTIONS["quarter-of-year"], + getOptionGroups: EXCLUDE_OPTIONS["quarter-of-year"], }, { name: "hours", displayName: t`Hours of the day...`, test: filter => isHourOfDayDateFilter(filter), init: filter => getInitialHourOfDayFilter(filter), - getOptions: EXCLUDE_OPTIONS["hour-of-day"], + getOptionGroups: EXCLUDE_OPTIONS["hour-of-day"], }, ]; @@ -142,12 +142,13 @@ export default function ExcludeDatePicker({ ); } - const { getOptions } = temporalUnit; - const options = getOptions(); + const { getOptionGroups } = temporalUnit; + const optionGroups = getOptionGroups(); + const options = optionGroups.flat(); const update = (values: string[]) => onFilterChange([operator, field, ...values]); - const allSelected = values.length === 0; - const selectAllLabel = allSelected ? t`Select none...` : t`Select all...`; + const allSelected = values.length === options.length; + const selectAllLabel = allSelected ? t`Select none` : t`Select all`; return ( <div className={className}> @@ -156,12 +157,12 @@ export default function ExcludeDatePicker({ checkedColor={primaryColor} checked={allSelected} onChange={() => - update(allSelected ? options.flat().map(({ value }) => value) : []) + update(allSelected ? [] : options.map(({ value }) => value)) } /> <Separator /> <ExcludeContainer> - {options.map((inner, index) => ( + {optionGroups.map((inner, index) => ( <ExcludeColumn key={index}> {inner.map(({ displayName, value, test }) => { const isValueExcluded = values.find(value => test(value)) != null; @@ -169,7 +170,7 @@ export default function ExcludeDatePicker({ <ExcludeCheckBox key={value} label={<ExcludeLabel>{displayName}</ExcludeLabel>} - checked={!isValueExcluded} + checked={isValueExcluded} checkedColor={primaryColor} onChange={() => { if (!isValueExcluded) { diff --git a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.unit.spec.tsx b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.unit.spec.tsx index fa4f7784f95..a255e27d769 100644 --- a/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/admin/datamodel/components/filters/pickers/DatePicker/ExcludeDatePicker.unit.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { createMockMetadata } from "__support__/metadata"; import { checkNotNull } from "metabase/lib/types"; @@ -14,48 +15,118 @@ import ExcludeDatePicker from "./ExcludeDatePicker"; const metadata = createMockMetadata({ databases: [createSampleDatabase()], }); - const ordersTable = checkNotNull(metadata.table(ORDERS_ID)); const query = ordersTable.legacyQuery({ useStructuredQuery: true }); - const filter = new Filter( [null, ["field", ORDERS.CREATED_AT, null]], null, query, ); +const hours = Array.from({ length: 24 }, (_, i) => i); + +type SetupOpts = { + filter: Filter; +}; + +function setup({ filter }: SetupOpts) { + const onFilterChange = jest.fn(); + const onCommit = jest.fn(); + + render( + <ExcludeDatePicker + filter={filter} + onFilterChange={onFilterChange} + onCommit={onCommit} + />, + ); + + return { onFilterChange, onCommit }; +} describe("ExcludeDatePicker", () => { - it("is empty option should exclude empty values by applying not-null filter", () => { - const commitMock = jest.fn(); - render( - <ExcludeDatePicker - onFilterChange={jest.fn()} - onCommit={commitMock} - filter={filter} - />, + beforeAll(() => { + jest.useFakeTimers({ advanceTimers: true }); + jest.setSystemTime(new Date(2020, 0, 1)); + }); + + it("should allow to exclude hours", async () => { + const filter = new Filter( + ["!=", ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }]], + null, + query, ); + const { onFilterChange } = setup({ filter }); + expect(screen.getByLabelText("Select all")).not.toBeChecked(); + expect(screen.getByLabelText("8 AM")).not.toBeChecked(); + + await userEvent.click(screen.getByLabelText("8 AM")); + expect(onFilterChange).toHaveBeenCalledWith([ + "!=", + ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }], + 8, + ]); + }); - fireEvent.click(screen.getByText("Is empty")); + it("should allow to exclude all options", async () => { + const filter = new Filter( + [ + "!=", + ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }], + 8, + ], + null, + query, + ); + const { onFilterChange } = setup({ filter }); + expect(screen.getByLabelText("Select all")).not.toBeChecked(); + expect(screen.getByLabelText("8 AM")).toBeChecked(); - expect(commitMock).toHaveBeenCalledWith([ + await userEvent.click(screen.getByLabelText("Select all")); + expect(onFilterChange).toHaveBeenCalledWith([ + "!=", + ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }], + ...hours, + ]); + }); + + it("should allow to deselect all options", async () => { + const filter = new Filter( + [ + "!=", + ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }], + ...hours, + ], + null, + query, + ); + const { onFilterChange } = setup({ filter }); + expect(screen.getByLabelText("Select none")).toBeChecked(); + expect(screen.getByLabelText("8 AM")).toBeChecked(); + + await userEvent.click(screen.getByLabelText("Select none")); + expect(onFilterChange).toHaveBeenCalledWith([ + "!=", + ["field", ORDERS.CREATED_AT, { "temporal-unit": "hour-of-day" }], + ]); + }); + + it("is empty option should exclude empty values by applying not-null filter", async () => { + const { onCommit } = setup({ filter }); + + await userEvent.click(screen.getByText("Is empty")); + + expect(onCommit).toHaveBeenCalledWith([ "not-null", ["field", ORDERS.CREATED_AT, null], ]); }); - it("is not empty option should exclude non-empty values by applying is-null filter", () => { - const commitMock = jest.fn(); - render( - <ExcludeDatePicker - onFilterChange={jest.fn()} - onCommit={commitMock} - filter={filter} - />, - ); + it("is not empty option should exclude non-empty values by applying is-null filter", async () => { + const { onCommit } = setup({ filter }); - fireEvent.click(screen.getByText("Is not empty")); + await userEvent.click(screen.getByText("Is not empty")); - expect(commitMock).toHaveBeenCalledWith([ + expect(onCommit).toHaveBeenCalledWith([ "is-null", ["field", ORDERS.CREATED_AT, null], ]); diff --git a/frontend/src/metabase/parameters/utils/date-formatting.ts b/frontend/src/metabase/parameters/utils/date-formatting.ts index 11529baf3ca..69ca2f1f614 100644 --- a/frontend/src/metabase/parameters/utils/date-formatting.ts +++ b/frontend/src/metabase/parameters/utils/date-formatting.ts @@ -62,7 +62,7 @@ const serializersByOperatorName: Record< return null; } const options = operator - .getOptions() + .getOptionGroups() .flat() .filter( ({ test }) => _.find(values, (value: string) => test(value)) != null, diff --git a/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.tsx b/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.tsx index 65642dd607e..508fa89b0df 100644 --- a/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.tsx +++ b/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.tsx @@ -160,21 +160,17 @@ function ExcludeValuePicker({ onBack, }: ExcludeValuePickerProps) { const [values, setValues] = useState(initialValues); - const isEmpty = values.length === 0; - - const option = useMemo(() => { - return findExcludeUnitOption(unit); - }, [unit]); - - const groups = useMemo(() => { - return getExcludeValueOptionGroups(unit); - }, [unit]); + const option = useMemo(() => findExcludeUnitOption(unit), [unit]); + const groups = useMemo(() => getExcludeValueOptionGroups(unit), [unit]); + const options = groups.flat(); + const isAll = values.length === options.length; + const isNone = values.length === 0; const handleToggleAll = (isChecked: boolean) => { if (isChecked) { - setValues([]); - } else { setValues(groups.flatMap(groups => groups.map(({ value }) => value))); + } else { + setValues([]); } }; @@ -183,9 +179,9 @@ function ExcludeValuePicker({ isChecked: boolean, ) => { if (isChecked) { - setValues(values.filter(value => value !== option.value)); - } else { setValues([...values, option.value]); + } else { + setValues(values.filter(value => value !== option.value)); } }; @@ -199,8 +195,8 @@ function ExcludeValuePicker({ <Divider /> <Stack p="md"> <Checkbox - checked={isEmpty} - label={isEmpty ? t`Select none…` : t`Select all…`} + checked={isAll} + label={isAll ? t`Select none` : t`Select all`} onChange={event => handleToggleAll(event.target.checked)} /> <Divider /> @@ -211,7 +207,7 @@ function ExcludeValuePicker({ <Checkbox key={optionIndex} label={option.label} - checked={!values.includes(option.value)} + checked={values.includes(option.value)} onChange={event => handleToggleOption(option, event.target.checked) } @@ -223,7 +219,7 @@ function ExcludeValuePicker({ </Stack> <Divider /> <Group p="sm" position="right"> - <Button variant="filled" disabled={isEmpty} onClick={handleSubmit}> + <Button variant="filled" disabled={isNone} onClick={handleSubmit}> {isNew ? t`Add filter` : t`Update filter`} </Button> </Group> diff --git a/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.unit.spec.tsx b/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.unit.spec.tsx index 31351ae1aee..93420776c14 100644 --- a/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/filters/components/DatePicker/ExcludeDatePicker/ExcludeDatePicker.unit.spec.tsx @@ -42,10 +42,13 @@ describe("ExcludeDatePicker", () => { const { onChange } = setup({ isNew: true }); await userEvent.click(screen.getByText("Days of the week…")); - await userEvent.click(screen.getByText("Monday")); - await userEvent.click(screen.getByText("Sunday")); - await userEvent.click(screen.getByText("Add filter")); + await userEvent.click(screen.getByLabelText("Monday")); + await userEvent.click(screen.getByLabelText("Sunday")); + expect(screen.getByLabelText("Monday")).toBeChecked(); + expect(screen.getByLabelText("Sunday")).toBeChecked(); + expect(screen.getByLabelText("Tuesday")).not.toBeChecked(); + await userEvent.click(screen.getByRole("button", { name: "Add filter" })); expect(onChange).toHaveBeenCalledWith({ type: "exclude", operator: "!=", @@ -54,13 +57,48 @@ describe("ExcludeDatePicker", () => { }); }); + it("should allow to exclude all options", async () => { + const { onChange } = setup({ isNew: true }); + + await userEvent.click(screen.getByText("Days of the week…")); + expect(screen.getByLabelText("Select all")).not.toBeChecked(); + expect(screen.getByLabelText("Monday")).not.toBeChecked(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeDisabled(); + + await userEvent.click(screen.getByLabelText("Select all")); + expect(screen.getByLabelText("Select none")).toBeChecked(); + expect(screen.getByLabelText("Monday")).toBeChecked(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeEnabled(); + + await userEvent.click(screen.getByRole("button", { name: "Add filter" })); + expect(onChange).toHaveBeenCalledWith({ + type: "exclude", + operator: "!=", + unit: "day-of-week", + values: [1, 2, 3, 4, 5, 6, 7], + }); + }); + + it("should allow to deselect all options", async () => { + const { onChange } = setup({ isNew: true }); + + await userEvent.click(screen.getByText("Days of the week…")); + await userEvent.click(screen.getByLabelText("Select all")); + await userEvent.click(screen.getByLabelText("Select none")); + + expect(screen.getByLabelText("Select all")).not.toBeChecked(); + expect(screen.getByLabelText("Monday")).not.toBeChecked(); + expect(screen.getByRole("button", { name: "Add filter" })).toBeDisabled(); + expect(onChange).not.toHaveBeenCalled(); + }); + it("should allow to exclude months", async () => { const { onChange } = setup({ isNew: true }); await userEvent.click(screen.getByText("Months of the year…")); - await userEvent.click(screen.getByText("January")); - await userEvent.click(screen.getByText("December")); - await userEvent.click(screen.getByText("Add filter")); + await userEvent.click(screen.getByLabelText("January")); + await userEvent.click(screen.getByLabelText("December")); + await userEvent.click(screen.getByRole("button", { name: "Add filter" })); expect(onChange).toHaveBeenCalledWith({ type: "exclude", @@ -74,9 +112,9 @@ describe("ExcludeDatePicker", () => { const { onChange } = setup({ isNew: true }); await userEvent.click(screen.getByText("Quarters of the year…")); - await userEvent.click(screen.getByText("1st")); - await userEvent.click(screen.getByText("4th")); - await userEvent.click(screen.getByText("Add filter")); + await userEvent.click(screen.getByLabelText("1st")); + await userEvent.click(screen.getByLabelText("4th")); + await userEvent.click(screen.getByRole("button", { name: "Add filter" })); expect(onChange).toHaveBeenCalledWith({ type: "exclude", @@ -90,10 +128,10 @@ describe("ExcludeDatePicker", () => { const { onChange } = setup({ isNew: true }); await userEvent.click(screen.getByText("Hours of the day…")); - await userEvent.click(screen.getByText("12 AM")); - await userEvent.click(screen.getByText("2 AM")); - await userEvent.click(screen.getByText("5 PM")); - await userEvent.click(screen.getByText("Add filter")); + await userEvent.click(screen.getByLabelText("12 AM")); + await userEvent.click(screen.getByLabelText("2 AM")); + await userEvent.click(screen.getByLabelText("5 PM")); + await userEvent.click(screen.getByRole("button", { name: "Add filter" })); expect(onChange).toHaveBeenCalledWith({ type: "exclude", -- GitLab