diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js index d5c974420b54d8f2813d885ec916079fc8a68426..6ab0e1b5b8334dea08f271695c86a50596188e5c 100644 --- a/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-date.cy.spec.js @@ -130,7 +130,7 @@ describe("scenarios > dashboard > filters > date", () => { saveDashboard(); // Updates the filter value - filterWidget().click(); + filterWidget().should("contain.text", "November 2023").click(); popover().findByText("December").click(); filterWidget().findByText("December 2023"); ensureDashboardCardHasText("76.83"); diff --git a/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js b/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js index 1386d763c4b16a96d6db58e143b154c3a1beedf5..8172c8627ad0d29577ba78d552911f81ee15ca0f 100644 --- a/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js +++ b/e2e/test/scenarios/question-reproductions/reproductions-3.cy.spec.js @@ -44,6 +44,7 @@ import { testTooltipPairs, join, visitQuestion, + tableHeaderClick, } from "e2e/support/helpers"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID, PEOPLE, PEOPLE_ID } = @@ -1333,6 +1334,63 @@ describe("issue 40399", () => { }); }); +describe("issue 43057", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + }); + + it("should differentiate between date and datetime filters with 00:00 time (metabase#43057)", () => { + openOrdersTable(); + + cy.log("set the date and verify the filter and results"); + cy.intercept("POST", "/api/dataset").as("dataset"); + tableHeaderClick("Created At"); + popover().within(() => { + cy.findByText("Filter by this column").click(); + cy.findByText("Specific dates…").click(); + cy.findByText("On").click(); + cy.findByLabelText("Date").clear().type("November 18, 2024"); + cy.button("Add filter").click(); + }); + cy.wait("@dataset"); + assertQueryBuilderRowCount(16); + cy.findByTestId("qb-filters-panel") + .findByText("Created At is on Nov 18, 2024") + .should("be.visible"); + + cy.log("set time to 00:00 and verify the filter and results"); + cy.findByTestId("qb-filters-panel") + .findByText("Created At is on Nov 18, 2024") + .click(); + popover().within(() => { + cy.button("Add time").click(); + cy.findByLabelText("Time").should("have.value", "00:00"); + cy.button("Update filter").click(); + }); + cy.wait("@dataset"); + assertQueryBuilderRowCount(1); + cy.findByTestId("qb-filters-panel") + .findByText("Created At is Nov 18, 2024, 12:00 AM") + .should("be.visible"); + + cy.log("remove time and verify the filter and results"); + cy.findByTestId("qb-filters-panel") + .findByText("Created At is Nov 18, 2024, 12:00 AM") + .click(); + popover().within(() => { + cy.findByLabelText("Time").should("have.value", "00:00"); + cy.button("Remove time").click(); + cy.button("Update filter").click(); + }); + cy.wait("@dataset"); + assertQueryBuilderRowCount(16); + cy.findByTestId("qb-filters-panel") + .findByText("Created At is on Nov 18, 2024") + .should("be.visible"); + }); +}); + describe("issue 19894", () => { beforeEach(() => { restore(); diff --git a/frontend/src/metabase-lib/filter.ts b/frontend/src/metabase-lib/filter.ts index c36820775a8c828e83713da76b7c085b1bfdeb01..a14a672e29b4e261f3f8991be56d4a99501c2cfb 100644 --- a/frontend/src/metabase-lib/filter.ts +++ b/frontend/src/metabase-lib/filter.ts @@ -295,9 +295,8 @@ export function isBooleanFilter( export function specificDateFilterClause( query: Query, stageIndex: number, - { operator, column, values }: SpecificDateFilterParts, + { operator, column, values, hasTime }: SpecificDateFilterParts, ): ExpressionClause { - const hasTime = values.some(hasTimeParts); const serializedValues = hasTime ? values.map(value => serializeDateTime(value)) : values.map(value => serializeDate(value)); @@ -335,16 +334,27 @@ export function specificDateFilterParts( return null; } - const values = serializedValues.map(value => deserializeDateTime(value)); - if (!isDefinedArray(values)) { - return null; + const dateValues = serializedValues.map(deserializeDate); + if (isDefinedArray(dateValues)) { + return { + operator, + column, + values: dateValues, + hasTime: false, + }; } - return { - operator, - column, - values, - }; + const dateTimeValues = serializedValues.map(deserializeDateTime); + if (isDefinedArray(dateTimeValues)) { + return { + operator, + column, + values: dateTimeValues, + hasTime: true, + }; + } + + return null; } export function isSpecificDateFilter( @@ -690,15 +700,6 @@ const TIME_FORMATS = ["HH:mm:ss.SSS[Z]", "HH:mm:ss.SSS", "HH:mm:ss", "HH:mm"]; const TIME_FORMAT_MS = "HH:mm:ss.SSS"; const DATE_TIME_FORMAT = `${DATE_FORMAT}T${TIME_FORMAT}`; -function hasTimeParts(date: Date): boolean { - return ( - date.getHours() !== 0 || - date.getMinutes() !== 0 || - date.getSeconds() !== 0 || - date.getMilliseconds() !== 0 - ); -} - function serializeDate(date: Date): string { return moment(date).format(DATE_FORMAT); } @@ -707,6 +708,15 @@ function serializeDateTime(date: Date): string { return moment(date).format(DATE_TIME_FORMAT); } +function deserializeDate(value: string): Date | null { + const date = moment(value, DATE_FORMAT, true); + if (!date.isValid()) { + return null; + } + + return date.toDate(); +} + function deserializeDateTime(value: string): Date | null { const dateTime = moment.parseZone(value, moment.ISO_8601, true); if (!dateTime.isValid()) { diff --git a/frontend/src/metabase-lib/filter.unit.spec.ts b/frontend/src/metabase-lib/filter.unit.spec.ts index a9b8dd661acd291b1e72a26355d0c9cca74d2908..099b7ead639493425e75a8e78331650e15afc27d 100644 --- a/frontend/src/metabase-lib/filter.unit.spec.ts +++ b/frontend/src/metabase-lib/filter.unit.spec.ts @@ -800,6 +800,7 @@ describe("filter", () => { operator, column, values, + hasTime: false, }), ); @@ -807,12 +808,38 @@ describe("filter", () => { operator, column: expect.anything(), values, + hasTime: false, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo).toBe(null); }, ); + it.each<Lib.SpecificDateFilterOperatorName>(["=", ">", "<"])( + 'should be able to create and destructure a specific datetime filter with "%s" operator and 1 value', + operator => { + const values = [new Date(2018, 2, 10, 20, 30)]; + const { filterParts, columnInfo, bucketInfo } = addSpecificDateFilter( + query, + Lib.specificDateFilterClause(query, 0, { + operator, + column, + values, + hasTime: true, + }), + ); + + expect(filterParts).toMatchObject({ + operator, + column: expect.anything(), + values, + hasTime: true, + }); + expect(columnInfo?.name).toBe(columnName); + expect(bucketInfo?.shortName).toBe("minute"); + }, + ); + it('should be able to create and destructure a specific date filter with "between" operator and 2 values', () => { moment.locale("ja"); const values = [new Date(2018, 2, 10), new Date(2019, 10, 20)]; @@ -822,6 +849,7 @@ describe("filter", () => { operator: "between", column, values, + hasTime: false, }), ); @@ -829,6 +857,7 @@ describe("filter", () => { operator: "between", column: expect.anything(), values, + hasTime: false, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo).toBe(null); @@ -847,6 +876,7 @@ describe("filter", () => { findTemporalBucket(query, column, "Day"), ), values, + hasTime: false, }), ); @@ -854,6 +884,7 @@ describe("filter", () => { operator, column: expect.anything(), values, + hasTime: false, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo).toBe(null); @@ -871,6 +902,7 @@ describe("filter", () => { findTemporalBucket(query, column, "Hour"), ), values, + hasTime: false, }), ); @@ -878,6 +910,7 @@ describe("filter", () => { operator: "between", column: expect.anything(), values, + hasTime: false, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo).toBe(null); @@ -886,13 +919,14 @@ describe("filter", () => { it.each<Lib.SpecificDateFilterOperatorName>(["=", ">", "<"])( 'should set "minute" temporal bucket with "%s" operator and 1 value if there are time parts', operator => { - const values = [new Date(2018, 2, 10, 30)]; + const values = [new Date(2018, 2, 10, 8)]; const { filterParts, columnInfo, bucketInfo } = addSpecificDateFilter( query, Lib.specificDateFilterClause(query, 0, { operator, column, values, + hasTime: true, }), ); @@ -900,6 +934,7 @@ describe("filter", () => { operator, column: expect.anything(), values, + hasTime: true, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo?.shortName).toBe("minute"); @@ -914,6 +949,7 @@ describe("filter", () => { operator: "between", column, values, + hasTime: true, }), ); @@ -921,31 +957,32 @@ describe("filter", () => { operator: "between", column: expect.anything(), values, + hasTime: true, }); expect(columnInfo?.name).toBe(columnName); expect(bucketInfo?.shortName).toBe("minute"); }); - it.each([ - ["yyyy-MM-DDTHH:mm:ssZ", "2020-01-05T10:20:00+01:00"], - ["yyyy-MM-DDTHH:mm:ss", "2020-01-05T10:20:00"], - ["yyyy-MM-DD", "2020-01-05"], - ])("should support %s date format", (format, arg) => { - const { filterParts } = addSpecificDateFilter( - query, - Lib.expressionClause("=", [column, arg]), - ); - expect(filterParts).toMatchObject({ - operator: "=", - column: expect.anything(), - values: [expect.any(Date)], - }); + it.each([["2020-01-05", new Date(2020, 1, 5)]])( + "should support %s date format", + arg => { + const { filterParts } = addSpecificDateFilter( + query, + Lib.expressionClause("=", [column, arg]), + ); + expect(filterParts).toMatchObject({ + operator: "=", + column: expect.anything(), + values: [expect.any(Date)], + hasTime: false, + }); - const value = filterParts?.values[0]; - expect(value?.getFullYear()).toBe(2020); - expect(value?.getMonth()).toBe(0); - expect(value?.getDate()).toBe(5); - }); + const value = filterParts?.values[0]; + expect(value?.getFullYear()).toBe(2020); + expect(value?.getMonth()).toBe(0); + expect(value?.getDate()).toBe(5); + }, + ); it.each([ ["2020-01-05T00:00:00.000", new Date(2020, 0, 5, 0, 0, 0, 0)], @@ -965,6 +1002,7 @@ describe("filter", () => { operator: "=", column: expect.anything(), values: [expect.any(Date)], + hasTime: true, }); const value = filterParts?.values[0]; @@ -1009,6 +1047,7 @@ describe("filter", () => { operator: "=", column: findColumn(query, tableName, "PRICE"), values: [new Date(2020, 1, 1)], + hasTime: false, }), ); diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index 4cc7196ad21613e99fb09c41423fa775bbe206d4..a4368dacd8d56bfb6baa93bb5ae60a0fd2070552 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -375,6 +375,7 @@ export type SpecificDateFilterParts = { operator: SpecificDateFilterOperatorName; column: ColumnMetadata; values: Date[]; + hasTime: boolean; }; export type RelativeDateFilterParts = { diff --git a/frontend/src/metabase/querying/components/DatePicker/DateOperatorPicker/utils.unit.spec.ts b/frontend/src/metabase/querying/components/DatePicker/DateOperatorPicker/utils.unit.spec.ts index 3339e9748504f0e338f344998c7e160ad94fc32a..ea5e697cd7dd9968d77a0f3bef1f35fadb2c8815 100644 --- a/frontend/src/metabase/querying/components/DatePicker/DateOperatorPicker/utils.unit.spec.ts +++ b/frontend/src/metabase/querying/components/DatePicker/DateOperatorPicker/utils.unit.spec.ts @@ -20,10 +20,15 @@ const DATE_NEXT_30DAYS = new Date(2015, 11, 20, 0, 0); const DATE_NEXT_YEAR = new Date(2016, 5, 15, 0, 0); const SPECIFIC_VALUES: SpecificDatePickerValue[] = [ - { type: "specific", operator: "=", values: [DATE] }, - { type: "specific", operator: ">", values: [DATE] }, - { type: "specific", operator: "<", values: [DATE] }, - { type: "specific", operator: "between", values: [DATE, DATE] }, + { type: "specific", operator: "=", values: [DATE], hasTime: false }, + { type: "specific", operator: ">", values: [DATE], hasTime: false }, + { type: "specific", operator: "<", values: [DATE], hasTime: false }, + { + type: "specific", + operator: "between", + values: [DATE, DATE], + hasTime: false, + }, ]; const RELATIVE_VALUES: RelativeDatePickerValue[] = [ @@ -87,6 +92,7 @@ describe("setOptionType", () => { type: "specific", operator: "=", values: [TODAY], + hasTime: false, }); }, ); @@ -98,11 +104,13 @@ describe("setOptionType", () => { type: "specific", operator, values: [DATE], + hasTime: false, }; expect(setOptionType(value, "=")).toEqual({ type: "specific", operator: "=", values: [DATE], + hasTime: false, }); }, ); @@ -112,11 +120,13 @@ describe("setOptionType", () => { type: "specific", operator: "between", values: [DATE, DATE_NEXT_YEAR], + hasTime: false, }; expect(setOptionType(value, "=")).toEqual({ type: "specific", operator: "=", values: [DATE_NEXT_YEAR], + hasTime: false, }); }); }); @@ -129,6 +139,7 @@ describe("setOptionType", () => { type: "specific", operator: ">", values: [TODAY], + hasTime: false, }); }, ); @@ -140,11 +151,13 @@ describe("setOptionType", () => { type: "specific", operator, values: [DATE], + hasTime: false, }; expect(setOptionType(value, ">")).toEqual({ type: "specific", operator: ">", values: [DATE], + hasTime: false, }); }, ); @@ -154,11 +167,13 @@ describe("setOptionType", () => { type: "specific", operator: "between", values: [DATE, DATE_NEXT_YEAR], + hasTime: false, }; expect(setOptionType(value, ">")).toEqual({ type: "specific", operator: ">", values: [DATE], + hasTime: false, }); }); }); @@ -171,6 +186,7 @@ describe("setOptionType", () => { type: "specific", operator: "<", values: [TODAY], + hasTime: false, }); }, ); @@ -182,11 +198,13 @@ describe("setOptionType", () => { type: "specific", operator, values: [DATE], + hasTime: false, }; expect(setOptionType(value, "<")).toEqual({ type: "specific", operator: "<", values: [DATE], + hasTime: false, }); }, ); @@ -196,11 +214,13 @@ describe("setOptionType", () => { type: "specific", operator: "between", values: [DATE, DATE_NEXT_YEAR], + hasTime: false, }; expect(setOptionType(value, "<")).toEqual({ type: "specific", operator: "<", values: [DATE_NEXT_YEAR], + hasTime: false, }); }); }); @@ -213,6 +233,7 @@ describe("setOptionType", () => { type: "specific", operator: "between", values: [PAST_30DAYS, TODAY], + hasTime: false, }); }, ); @@ -224,11 +245,13 @@ describe("setOptionType", () => { type: "specific", operator, values: [DATE], + hasTime: false, }; expect(setOptionType(value, "between")).toEqual({ type: "specific", operator: "between", values: [DATE_PAST_30DAYS, DATE], + hasTime: false, }); }, ); @@ -238,11 +261,13 @@ describe("setOptionType", () => { type: "specific", operator: ">", values: [DATE], + hasTime: false, }; expect(setOptionType(value, "between")).toEqual({ type: "specific", operator: "between", values: [DATE, DATE_NEXT_30DAYS], + hasTime: false, }); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/DatePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/DatePicker.unit.spec.tsx index c254acec3f07764d61116bf4aa86415c459a2c40..1d526a611ad63f663a070d12c476350549260314 100644 --- a/frontend/src/metabase/querying/components/DatePicker/DatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/DatePicker.unit.spec.tsx @@ -56,6 +56,7 @@ describe("DatePicker", () => { type: "specific", operator: ">", values: [new Date(2020, 1, 15)], + hasTime: false, }); }); @@ -65,6 +66,7 @@ describe("DatePicker", () => { type: "specific", operator: ">", values: [new Date(2020, 1, 15)], + hasTime: false, }, }); @@ -75,6 +77,7 @@ describe("DatePicker", () => { type: "specific", operator: ">", values: [new Date(2020, 1, 20)], + hasTime: false, }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.tsx index 5431d481afa0e6b7ddcc40101785eac2f9a7bbb2..06c0cbf3f0e7d7f27263ef132cac00345373c27e 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.tsx @@ -1,35 +1,38 @@ import type { FormEvent } from "react"; -import { useState } from "react"; import { t } from "ttag"; import { Box, Button, Divider, Group } from "metabase/ui"; import { TimeToggle } from "../TimeToggle"; -import { clearTimePart, hasTimeParts } from "../utils"; +import { clearTimePart } from "../utils"; import { DateRangePickerBody } from "./DateRangePickerBody"; +import type { DateRangePickerValue } from "./types"; -interface DateRangePickerProps { - value: [Date, Date]; +export interface DateRangePickerProps { + value: DateRangePickerValue; isNew: boolean; - onChange: (value: [Date, Date]) => void; + onChange: (value: DateRangePickerValue) => void; onSubmit: () => void; } export function DateRangePicker({ - value, + value: { dateRange, hasTime }, isNew, onChange, onSubmit, }: DateRangePickerProps) { - const [startDate, endDate] = value; - const [hasTime, setHasTime] = useState( - hasTimeParts(startDate) || hasTimeParts(endDate), - ); + const [startDate, endDate] = dateRange; + + const handleDateRangeChange = (newDateRange: [Date, Date]) => { + onChange({ dateRange: newDateRange, hasTime }); + }; const handleTimeToggle = () => { - setHasTime(!hasTime); - onChange([clearTimePart(startDate), clearTimePart(endDate)]); + onChange({ + dateRange: [clearTimePart(startDate), clearTimePart(endDate)], + hasTime: !hasTime, + }); }; const handleSubmit = (event: FormEvent) => { @@ -41,9 +44,9 @@ export function DateRangePicker({ <form onSubmit={handleSubmit}> <Box p="md"> <DateRangePickerBody - value={value} + value={dateRange} hasTime={hasTime} - onChange={onChange} + onChange={handleDateRangeChange} /> </Box> <Divider /> diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.unit.spec.tsx index 5ffd4b08fe934203c2dc32a04dd4f74267524cf9..4000df28fa73930c685ab2a908f1f2fba10990cc 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/DateRangePicker.unit.spec.tsx @@ -3,6 +3,7 @@ import _userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, within } from "__support__/ui"; import { DateRangePicker } from "./DateRangePicker"; +import type { DateRangePickerValue } from "./types"; const START_DATE = new Date(2020, 0, 10); const END_DATE = new Date(2020, 1, 9); @@ -11,7 +12,7 @@ const START_DATE_TIME = new Date(2020, 0, 10, 5, 20); const END_DATE_TIME = new Date(2020, 1, 9, 20, 30); interface SetupOpts { - value?: [Date, Date]; + value?: DateRangePickerValue; isNew?: boolean; } @@ -20,7 +21,7 @@ const userEvent = _userEvent.setup({ }); function setup({ - value = [START_DATE, END_DATE], + value = { dateRange: [START_DATE, END_DATE], hasTime: false }, isNew = false, }: SetupOpts = {}) { const onChange = jest.fn(); @@ -51,26 +52,29 @@ describe("SingleDatePicker", () => { await userEvent.click(within(calendars[0]).getByText("12")); await userEvent.click(within(calendars[1]).getByText("5")); - expect(onChange).toHaveBeenCalledWith([ - new Date(2020, 0, 12), - new Date(2020, 1, 5), - ]); + expect(onChange).toHaveBeenCalledWith({ + dateRange: [new Date(2020, 0, 12), new Date(2020, 1, 5)], + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to set the date range via the calendar when there is time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const calendars = screen.getAllByRole("table"); await userEvent.click(within(calendars[0]).getByText("12")); await userEvent.click(within(calendars[1]).getByText("5")); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 12, 5, 20), - new Date(2020, 1, 5, 20, 30), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 0, 12, 5, 20), new Date(2020, 1, 5, 20, 30)], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); @@ -80,10 +84,10 @@ describe("SingleDatePicker", () => { const input = screen.getByLabelText("Start date"); await userEvent.clear(input); await userEvent.type(input, "Feb 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 1, 15), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 1, 15), END_DATE], + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); await userEvent.type(input, "{enter}"); @@ -92,16 +96,19 @@ describe("SingleDatePicker", () => { it("should be able to set the date range start via the input when there is time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE_TIME, END_DATE], + value: { + dateRange: [START_DATE_TIME, END_DATE], + hasTime: true, + }, }); const input = screen.getByLabelText("Start date"); await userEvent.clear(input); await userEvent.type(input, "Feb 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 1, 15, 5, 20), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 1, 15, 5, 20), END_DATE], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); await userEvent.type(input, "{enter}"); @@ -115,86 +122,97 @@ describe("SingleDatePicker", () => { await userEvent.clear(input); await userEvent.type(input, "Jul 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE, - new Date(2020, 6, 15), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, new Date(2020, 6, 15)], + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to set the date range end via the input when there is time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE, END_DATE_TIME], + value: { + dateRange: [START_DATE, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("End date"); await userEvent.clear(input); await userEvent.type(input, "Jul 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE, - new Date(2020, 6, 15, 20, 30), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, new Date(2020, 6, 15, 20, 30)], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to add time", async () => { const { onChange, onSubmit } = setup(); + expect(screen.queryByLabelText("Start time")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("End time")).not.toBeInTheDocument(); await userEvent.click(screen.getByText("Add time")); - const input = screen.getByLabelText("Start time"); - await userEvent.clear(input); - await userEvent.type(input, "11:20"); - - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 10, 11, 20), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, END_DATE], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to update the start time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("Start time"); await userEvent.clear(input); await userEvent.type(input, "11:20"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 10, 11, 20), - END_DATE_TIME, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 0, 10, 11, 20), END_DATE_TIME], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to update the end time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("End time"); await userEvent.clear(input); await userEvent.type(input, "11:20"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE_TIME, - new Date(2020, 1, 9, 11, 20), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE_TIME, new Date(2020, 1, 9, 11, 20)], + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to remove time", async () => { const { onChange, onSubmit } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); await userEvent.click(screen.getByText("Remove time")); - expect(screen.queryByLabelText("Start time")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("End time")).not.toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith([START_DATE, END_DATE]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, END_DATE], + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.tsx index 271867b7dd9e322c04e7c7873ebd64b2d6904976..7de390bedcfaa1c2739016cedee8b5addc44b87a 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.tsx @@ -1,36 +1,38 @@ -import { useState } from "react"; - import { Box, Stack } from "metabase/ui"; import { TimeToggle } from "../../TimeToggle"; -import { clearTimePart, hasTimeParts } from "../../utils"; +import { clearTimePart } from "../../utils"; import { DateRangePickerBody } from "../DateRangePickerBody"; +import type { DateRangePickerValue } from "../types"; interface SimpleDateRangePickerProps { - value: [Date, Date]; - onChange: (value: [Date, Date]) => void; + value: DateRangePickerValue; + onChange: (value: DateRangePickerValue) => void; } export function SimpleDateRangePicker({ - value, + value: { dateRange, hasTime }, onChange, }: SimpleDateRangePickerProps) { - const [startDate, endDate] = value; - const [hasTime, setHasTime] = useState( - hasTimeParts(startDate) || hasTimeParts(endDate), - ); + const [startDate, endDate] = dateRange; + + const handleDateRangeChange = (newDateRange: [Date, Date]) => { + onChange({ dateRange: newDateRange, hasTime }); + }; const handleTimeToggle = () => { - setHasTime(!hasTime); - onChange([clearTimePart(startDate), clearTimePart(endDate)]); + onChange({ + dateRange: [clearTimePart(startDate), clearTimePart(endDate)], + hasTime: !hasTime, + }); }; return ( <Stack> <DateRangePickerBody - value={value} + value={dateRange} hasTime={hasTime} - onChange={onChange} + onChange={handleDateRangeChange} /> <Box> <TimeToggle pl={0} hasTime={hasTime} onClick={handleTimeToggle} /> diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.unit.spec.tsx index 6011b1e64063e89841d73baf328a6951172b9550..bb080b35857aa3804553a50c56d8005a7240d531 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/SimpleDateRangePicker/SimpleDateRangePicker.unit.spec.tsx @@ -2,6 +2,8 @@ import _userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, within } from "__support__/ui"; +import type { DateRangePickerValue } from "../types"; + import { SimpleDateRangePicker } from "./SimpleDateRangePicker"; const START_DATE = new Date(2020, 0, 10); @@ -11,14 +13,16 @@ const START_DATE_TIME = new Date(2020, 0, 10, 5, 20); const END_DATE_TIME = new Date(2020, 1, 9, 20, 30); interface SetupOpts { - value?: [Date, Date]; + value?: DateRangePickerValue; } const userEvent = _userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); -function setup({ value = [START_DATE, END_DATE] }: SetupOpts = {}) { +function setup({ + value = { dateRange: [START_DATE, END_DATE], hasTime: false }, +}: SetupOpts = {}) { const onChange = jest.fn(); renderWithProviders( @@ -41,25 +45,28 @@ describe("SimpleDateRangePicker", () => { await userEvent.click(within(calendars[0]).getByText("12")); await userEvent.click(within(calendars[1]).getByText("5")); - expect(onChange).toHaveBeenCalledWith([ - new Date(2020, 0, 12), - new Date(2020, 1, 5), - ]); + expect(onChange).toHaveBeenCalledWith({ + dateRange: [new Date(2020, 0, 12), new Date(2020, 1, 5)], + hasTime: false, + }); }); it("should be able to set the date range via the calendar when there is time", async () => { const { onChange } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const calendars = screen.getAllByRole("table"); await userEvent.click(within(calendars[0]).getByText("12")); await userEvent.click(within(calendars[1]).getByText("5")); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 12, 5, 20), - new Date(2020, 1, 5, 20, 30), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 0, 12, 5, 20), new Date(2020, 1, 5, 20, 30)], + hasTime: true, + }); }); it("should be able to set the date range start via the input", async () => { @@ -69,25 +76,28 @@ describe("SimpleDateRangePicker", () => { await userEvent.clear(input); await userEvent.type(input, "Feb 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 1, 15), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 1, 15), END_DATE], + hasTime: false, + }); }); it("should be able to set the date range start via the input when there is time", async () => { const { onChange } = setup({ - value: [START_DATE_TIME, END_DATE], + value: { + dateRange: [START_DATE_TIME, END_DATE], + hasTime: true, + }, }); const input = screen.getByLabelText("Start date"); await userEvent.clear(input); await userEvent.type(input, "Feb 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 1, 15, 5, 20), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 1, 15, 5, 20), END_DATE], + hasTime: true, + }); }); it("should be able to set the date range end via the input", async () => { @@ -97,80 +107,91 @@ describe("SimpleDateRangePicker", () => { await userEvent.clear(input); await userEvent.type(input, "Jul 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE, - new Date(2020, 6, 15), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, new Date(2020, 6, 15)], + hasTime: false, + }); }); it("should be able to set the date range end via the input when there is time", async () => { const { onChange } = setup({ - value: [START_DATE, END_DATE_TIME], + value: { + dateRange: [START_DATE, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("End date"); await userEvent.clear(input); await userEvent.type(input, "Jul 15, 2020"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE, - new Date(2020, 6, 15, 20, 30), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, new Date(2020, 6, 15, 20, 30)], + hasTime: true, + }); }); it("should be able to add time", async () => { const { onChange } = setup(); + expect(screen.queryByLabelText("Start time")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("End time")).not.toBeInTheDocument(); await userEvent.click(screen.getByText("Add time")); - const input = screen.getByLabelText("Start time"); - await userEvent.clear(input); - await userEvent.type(input, "11:20"); - - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 10, 11, 20), - END_DATE, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, END_DATE], + hasTime: true, + }); }); it("should be able to update the start time", async () => { const { onChange } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("Start time"); await userEvent.clear(input); await userEvent.type(input, "11:20"); - expect(onChange).toHaveBeenLastCalledWith([ - new Date(2020, 0, 10, 11, 20), - END_DATE_TIME, - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [new Date(2020, 0, 10, 11, 20), END_DATE_TIME], + hasTime: true, + }); }); it("should be able to update the end time", async () => { const { onChange } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); const input = screen.getByLabelText("End time"); await userEvent.clear(input); await userEvent.type(input, "11:20"); - expect(onChange).toHaveBeenLastCalledWith([ - START_DATE_TIME, - new Date(2020, 1, 9, 11, 20), - ]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE_TIME, new Date(2020, 1, 9, 11, 20)], + hasTime: true, + }); }); it("should be able to remove time", async () => { const { onChange } = setup({ - value: [START_DATE_TIME, END_DATE_TIME], + value: { + dateRange: [START_DATE_TIME, END_DATE_TIME], + hasTime: true, + }, }); await userEvent.click(screen.getByText("Remove time")); - expect(screen.queryByLabelText("Start time")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("End time")).not.toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith([START_DATE, END_DATE]); + expect(onChange).toHaveBeenLastCalledWith({ + dateRange: [START_DATE, END_DATE], + hasTime: false, + }); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/index.ts b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/index.ts index 32307a8520c14d169954f3b2469353eea7899351..99107c405131a9cacec90545d14d5cf35f7ec3b0 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/index.ts +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/index.ts @@ -1,2 +1,3 @@ export * from "./DateRangePicker"; export * from "./SimpleDateRangePicker"; +export * from "./types"; diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/types.ts b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..6eb080a5f6489f5089e0f8d38a442754a2031a7d --- /dev/null +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/DateRangePicker/types.ts @@ -0,0 +1,4 @@ +export interface DateRangePickerValue { + dateRange: [Date, Date]; + hasTime: boolean; +} diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.tsx index f47533071e905d17edbffdb1aa05964350509371..fc7b5ec09c39d5c77b68aa2ed1ada078dfe57ddf 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.tsx @@ -1,7 +1,13 @@ import type { SpecificDatePickerValue } from "../../types"; -import { SimpleDateRangePicker } from "../DateRangePicker"; -import { SimpleSingleDatePicker } from "../SingleDatePicker"; -import { getDate, isDateRange, setDate, setDateRange } from "../utils"; +import { + type DateRangePickerValue, + SimpleDateRangePicker, +} from "../DateRangePicker"; +import { + SimpleSingleDatePicker, + type SingleDatePickerValue, +} from "../SingleDatePicker"; +import { getDate, isDateRange, setDateTime, setDateTimeRange } from "../utils"; interface SimpleSpecificDatePickerProps { value: SpecificDatePickerValue; @@ -12,22 +18,25 @@ export function SimpleSpecificDatePicker({ value, onChange, }: SimpleSpecificDatePickerProps) { - const handleDateChange = (date: Date) => { - onChange(setDate(value, date)); + const handleDateChange = ({ date, hasTime }: SingleDatePickerValue) => { + onChange(setDateTime(value, date, hasTime)); }; - const handleDateRangeChange = (dates: [Date, Date]) => { - onChange(setDateRange(value, dates)); + const handleDateRangeChange = ({ + dateRange, + hasTime, + }: DateRangePickerValue) => { + onChange(setDateTimeRange(value, dateRange, hasTime)); }; return isDateRange(value.values) ? ( <SimpleDateRangePicker - value={value.values} + value={{ dateRange: value.values, hasTime: value.hasTime }} onChange={handleDateRangeChange} /> ) : ( <SimpleSingleDatePicker - value={getDate(value)} + value={{ date: getDate(value), hasTime: value.hasTime }} onChange={handleDateChange} /> ); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.unit.spec.tsx index f192e4281b2aed8e3404234e76d422a518e4b5a3..da92587df9ff4d6a865f9092425317ae1654bb22 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SimpleSpecificDatePicker/SimpleSpecificDatePicker.unit.spec.tsx @@ -27,6 +27,7 @@ describe("SimpleSpecificDatePicker", () => { type: "specific", operator: "=", values: [new Date(2015, 1, 10)], + hasTime: false, }, }); @@ -36,6 +37,7 @@ describe("SimpleSpecificDatePicker", () => { type: "specific", operator: "=", values: [new Date(2015, 1, 15)], + hasTime: false, }); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.tsx index 78975816bd6ffe36c00f8321b58c7716e409518e..5cf3009bfa821b38c134aa3112f48fe27c47da94 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.tsx @@ -1,33 +1,33 @@ -import { useState } from "react"; - import { Box, Stack } from "metabase/ui"; import { TimeToggle } from "../../TimeToggle"; -import { clearTimePart, hasTimeParts } from "../../utils"; +import { clearTimePart } from "../../utils"; import { SingleDatePickerBody } from "../SingleDatePickerBody"; +import type { SingleDatePickerValue } from "../types"; interface SimpleSingleDatePickerProps { - value: Date; - onChange: (value: Date) => void; + value: SingleDatePickerValue; + onChange: (value: SingleDatePickerValue) => void; } export function SimpleSingleDatePicker({ - value, + value: { date, hasTime }, onChange, }: SimpleSingleDatePickerProps) { - const [hasTime, setHasTime] = useState(hasTimeParts(value)); + const handleDateChange = (newDate: Date) => { + onChange({ date: newDate, hasTime }); + }; const handleTimeToggle = () => { - setHasTime(!hasTime); - onChange(clearTimePart(value)); + onChange({ date: clearTimePart(date), hasTime: !hasTime }); }; return ( <Stack> <SingleDatePickerBody - value={value} + value={date} hasTime={hasTime} - onChange={onChange} + onChange={handleDateChange} /> <Box> <TimeToggle pl={0} hasTime={hasTime} onClick={handleTimeToggle} /> diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.unit.spec.tsx index 3efc6066c5e07a12f93d403afd536de5fadaa4ad..f38b63fc6e4c3407a0788c5c64f9b6344df2d824 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SimpleSingleDatePicker/SimpleSingleDatePicker.unit.spec.tsx @@ -2,20 +2,22 @@ import _userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "__support__/ui"; +import type { SingleDatePickerValue } from "../types"; + import { SimpleSingleDatePicker } from "./SimpleSingleDatePicker"; const DATE = new Date(2020, 0, 10); const DATE_TIME = new Date(2020, 0, 10, 10, 20); interface SetupOpts { - value?: Date; + value?: SingleDatePickerValue; } const userEvent = _userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); -function setup({ value = DATE }: SetupOpts = {}) { +function setup({ value = { date: DATE, hasTime: false } }: SetupOpts = {}) { const onChange = jest.fn(); renderWithProviders( @@ -36,17 +38,23 @@ describe("SimpleSingleDatePicker", () => { await userEvent.click(screen.getByText("12")); - expect(onChange).toHaveBeenCalledWith(new Date(2020, 0, 12)); + expect(onChange).toHaveBeenCalledWith({ + date: new Date(2020, 0, 12), + hasTime: false, + }); }); it("should be able to set the date via the calendar when there is time", async () => { const { onChange } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); await userEvent.click(screen.getByText("12")); - expect(onChange).toHaveBeenCalledWith(new Date(2020, 0, 12, 10, 20)); + expect(onChange).toHaveBeenCalledWith({ + date: new Date(2020, 0, 12, 10, 20), + hasTime: true, + }); }); it("should be able to set the date via the input", async () => { @@ -57,12 +65,15 @@ describe("SimpleSingleDatePicker", () => { await userEvent.type(input, "Feb 15, 2020"); expect(screen.getByText("February 2020")).toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 1, 15)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 1, 15), + hasTime: false, + }); }); it("should be able to set the date via the input when there is time", async () => { const { onChange } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); const input = screen.getByLabelText("Date"); @@ -70,40 +81,48 @@ describe("SimpleSingleDatePicker", () => { await userEvent.type(input, "Feb 15, 2020"); expect(screen.getByText("February 2020")).toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 1, 15, 10, 20)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 1, 15, 10, 20), + hasTime: true, + }); }); it("should be able to add time", async () => { const { onChange } = setup(); await userEvent.click(screen.getByText("Add time")); - const input = screen.getByLabelText("Time"); - await userEvent.clear(input); - await userEvent.type(input, "10:20"); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10, 10, 20)); + expect(onChange).toHaveBeenLastCalledWith({ + date: DATE, + hasTime: true, + }); }); it("should be able to update the time", async () => { const { onChange } = setup({ - value: DATE_TIME, + value: { date: DATE, hasTime: true }, }); const input = screen.getByLabelText("Time"); await userEvent.clear(input); await userEvent.type(input, "20:30"); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10, 20, 30)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 0, 10, 20, 30), + hasTime: true, + }); }); it("should be able to remove time", async () => { const { onChange } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); await userEvent.click(screen.getByText("Remove time")); - expect(screen.queryByLabelText("Time")).not.toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 0, 10), + hasTime: false, + }); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.tsx index a3d895a3ffeeef76e0ad208808a2c743ac272433..3a716831a377f1a57a6134f16dce782407737134 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.tsx @@ -1,32 +1,33 @@ import type { FormEvent } from "react"; -import { useState } from "react"; import { t } from "ttag"; import { Box, Button, Divider, Group } from "metabase/ui"; import { TimeToggle } from "../TimeToggle"; -import { clearTimePart, hasTimeParts } from "../utils"; +import { clearTimePart } from "../utils"; import { SingleDatePickerBody } from "./SingleDatePickerBody"; +import type { SingleDatePickerValue } from "./types"; interface SingleDatePickerProps { - value: Date; + value: SingleDatePickerValue; isNew: boolean; - onChange: (value: Date) => void; + onChange: (value: SingleDatePickerValue) => void; onSubmit: () => void; } export function SingleDatePicker({ - value, + value: { date, hasTime }, isNew, onChange, onSubmit, }: SingleDatePickerProps) { - const [hasTime, setHasTime] = useState(hasTimeParts(value)); + const handleDateChange = (newDate: Date) => { + onChange({ date: newDate, hasTime }); + }; const handleTimeToggle = () => { - setHasTime(!hasTime); - onChange(clearTimePart(value)); + onChange({ date: clearTimePart(date), hasTime: !hasTime }); }; const handleSubmit = (event: FormEvent) => { @@ -38,9 +39,9 @@ export function SingleDatePicker({ <form onSubmit={handleSubmit}> <Box p="md"> <SingleDatePickerBody - value={value} + value={date} hasTime={hasTime} - onChange={onChange} + onChange={handleDateChange} /> </Box> <Divider /> diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.unit.spec.tsx index 194ac8eb0319add252a9be2efcb85b0d2e4841b5..bc4b5beb997ca5cac31565b2c5984953b9e0999b 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/SingleDatePicker.unit.spec.tsx @@ -3,12 +3,13 @@ import _userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "__support__/ui"; import { SingleDatePicker } from "./SingleDatePicker"; +import type { SingleDatePickerValue } from "./types"; const DATE = new Date(2020, 0, 10); const DATE_TIME = new Date(2020, 0, 10, 10, 20); interface SetupOpts { - value?: Date; + value?: SingleDatePickerValue; isNew?: boolean; } @@ -16,7 +17,10 @@ const userEvent = _userEvent.setup({ advanceTimers: jest.advanceTimersByTime, }); -function setup({ value = DATE, isNew = false }: SetupOpts = {}) { +function setup({ + value = { date: DATE, hasTime: false }, + isNew = false, +}: SetupOpts = {}) { const onChange = jest.fn(); const onSubmit = jest.fn(); @@ -43,18 +47,24 @@ describe("SingleDatePicker", () => { await userEvent.click(screen.getByText("12")); - expect(onChange).toHaveBeenCalledWith(new Date(2020, 0, 12)); + expect(onChange).toHaveBeenCalledWith({ + date: new Date(2020, 0, 12), + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to set the date via the calendar when there is time", async () => { const { onChange, onSubmit } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); await userEvent.click(screen.getByText("12")); - expect(onChange).toHaveBeenCalledWith(new Date(2020, 0, 12, 10, 20)); + expect(onChange).toHaveBeenCalledWith({ + date: new Date(2020, 0, 12, 10, 20), + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); @@ -65,7 +75,10 @@ describe("SingleDatePicker", () => { await userEvent.clear(input); await userEvent.type(input, "Feb 15, 2020"); expect(screen.getByText("February 2020")).toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 1, 15)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 1, 15), + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); await userEvent.type(input, "{enter}"); @@ -74,7 +87,7 @@ describe("SingleDatePicker", () => { it("should be able to set the date via the input when there is time", async () => { const { onChange, onSubmit } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); const input = screen.getByLabelText("Date"); @@ -82,7 +95,10 @@ describe("SingleDatePicker", () => { await userEvent.type(input, "Feb 15, 2020"); expect(screen.getByText("February 2020")).toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 1, 15, 10, 20)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 1, 15, 10, 20), + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); @@ -90,36 +106,38 @@ describe("SingleDatePicker", () => { const { onChange, onSubmit } = setup(); await userEvent.click(screen.getByText("Add time")); - const input = screen.getByLabelText("Time"); - await userEvent.clear(input); - await userEvent.type(input, "10:20"); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10, 10, 20)); + expect(onChange).toHaveBeenLastCalledWith({ date: DATE, hasTime: true }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to update the time", async () => { const { onChange, onSubmit } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); const input = screen.getByLabelText("Time"); await userEvent.clear(input); await userEvent.type(input, "20:30"); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10, 20, 30)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 0, 10, 20, 30), + hasTime: true, + }); expect(onSubmit).not.toHaveBeenCalled(); }); it("should be able to remove time", async () => { const { onChange, onSubmit } = setup({ - value: DATE_TIME, + value: { date: DATE_TIME, hasTime: true }, }); await userEvent.click(screen.getByText("Remove time")); - expect(screen.queryByLabelText("Time")).not.toBeInTheDocument(); - expect(onChange).toHaveBeenLastCalledWith(new Date(2020, 0, 10)); + expect(onChange).toHaveBeenLastCalledWith({ + date: new Date(2020, 0, 10), + hasTime: false, + }); expect(onSubmit).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/index.ts b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/index.ts index 83f536010a5af3c0da210dd15c8b003fd88e28a7..f87bded1516ceaf255ec370c75d7467706747ea4 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/index.ts +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/index.ts @@ -1,2 +1,3 @@ export * from "./SingleDatePicker"; export * from "./SimpleSingleDatePicker"; +export * from "./types"; diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/types.ts b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d7f0f3ccb61f560b5b6f5c84b9397f3e13fd840 --- /dev/null +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SingleDatePicker/types.ts @@ -0,0 +1,4 @@ +export interface SingleDatePickerValue { + date: Date; + hasTime: boolean; +} diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.tsx index d61c3bcb5d92a96c5525ec50cf9c5dfcfbf23d42..21117db7d52976f1efd286fb390167b1ad85b41d 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.tsx @@ -4,8 +4,11 @@ import { Divider, Flex, PopoverBackButton, Tabs } from "metabase/ui"; import type { DatePickerOperator, SpecificDatePickerValue } from "../types"; -import { DateRangePicker } from "./DateRangePicker"; -import { SingleDatePicker } from "./SingleDatePicker"; +import { DateRangePicker, type DateRangePickerValue } from "./DateRangePicker"; +import { + SingleDatePicker, + type SingleDatePickerValue, +} from "./SingleDatePicker"; import { TabList } from "./SpecificDatePicker.styled"; import { coerceValue, @@ -13,8 +16,8 @@ import { getDefaultValue, getTabs, isDateRange, - setDate, - setDateRange, + setDateTime, + setDateTimeRange, setOperator, } from "./utils"; @@ -46,12 +49,15 @@ export function SpecificDatePicker({ } }; - const handleDateChange = (date: Date) => { - setValue(setDate(value, date)); + const handleDateChange = ({ date, hasTime }: SingleDatePickerValue) => { + setValue(setDateTime(value, date, hasTime)); }; - const handleDateRangeChange = (dates: [Date, Date]) => { - setValue(setDateRange(value, dates)); + const handleDateRangeChange = ({ + dateRange, + hasTime, + }: DateRangePickerValue) => { + setValue(setDateTimeRange(value, dateRange, hasTime)); }; const handleSubmit = () => { @@ -75,14 +81,14 @@ export function SpecificDatePicker({ <Tabs.Panel key={tab.operator} value={tab.operator}> {isDateRange(value.values) ? ( <DateRangePicker - value={value.values} + value={{ dateRange: value.values, hasTime: value.hasTime }} isNew={isNew} onChange={handleDateRangeChange} onSubmit={handleSubmit} /> ) : ( <SingleDatePicker - value={getDate(value)} + value={{ date: getDate(value), hasTime: value.hasTime }} isNew={isNew} onChange={handleDateChange} onSubmit={handleSubmit} diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.unit.spec.tsx index 83722193332c1d1fb97123c1ef57f82693b6ba30..573eb2754a81b9ec713239d93a2abb88e0ced4b7 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/SpecificDatePicker.unit.spec.tsx @@ -55,6 +55,7 @@ describe("SpecificDatePicker", () => { type: "specific", operator: "=", values: [new Date(2020, 0, 15)], + hasTime: false, }); }); @@ -69,6 +70,7 @@ describe("SpecificDatePicker", () => { type: "specific", operator: "<", values: [new Date(2020, 0, 15)], + hasTime: false, }); }); @@ -84,6 +86,7 @@ describe("SpecificDatePicker", () => { type: "specific", operator: ">", values: [new Date(2020, 1, 15)], + hasTime: false, }); }); @@ -99,6 +102,7 @@ describe("SpecificDatePicker", () => { type: "specific", operator: "between", values: [new Date(2019, 11, 12), new Date(2020, 0, 5)], + hasTime: false, }); }); @@ -118,6 +122,7 @@ describe("SpecificDatePicker", () => { type: "specific", operator: "between", values: [new Date(2019, 11, 29), new Date(2020, 1, 15)], + hasTime: false, }); }); }); diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/TimeToggle/TimeToggle.tsx b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/TimeToggle/TimeToggle.tsx index 56669b70bbc4c7d1dcce01ed824bd6f8a180a8a2..b9946ede03f6997c8b41749919c4089b74d3c4d6 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/TimeToggle/TimeToggle.tsx +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/TimeToggle/TimeToggle.tsx @@ -9,14 +9,17 @@ interface TimeToggleProps extends ButtonProps { } export function TimeToggle({ hasTime, ...props }: TimeToggleProps) { + const label = hasTime ? t`Remove time` : t`Add time`; + return ( <Button c="text-medium" variant="subtle" leftIcon={<Icon name="clock" />} + aria-label={label} {...props} > - {hasTime ? t`Remove time` : t`Add time`} + {label} </Button> ); } diff --git a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/utils.ts b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/utils.ts index eb5c24ddeec4a125251804c48c62a3e9bb7d2678..086e85ed319b5d2c299813daac1f7405f9c9d065 100644 --- a/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/utils.ts +++ b/frontend/src/metabase/querying/components/DatePicker/SpecificDatePicker/utils.ts @@ -38,6 +38,7 @@ export function getOperatorDefaultValue( type: "specific", operator, values: [past30Days, today], + hasTime: false, }; case "=": case "<": @@ -46,6 +47,7 @@ export function getOperatorDefaultValue( type: "specific", operator, values: [today], + hasTime: false, }; } } @@ -77,15 +79,20 @@ export function getDate(value: SpecificDatePickerValue) { return value.values[0]; } -export function setDate(value: SpecificDatePickerValue, date: Date) { - return { ...value, values: [date] }; +export function setDateTime( + value: SpecificDatePickerValue, + date: Date, + hasTime: boolean, +) { + return { ...value, values: [date], hasTime }; } -export function setDateRange( +export function setDateTimeRange( value: SpecificDatePickerValue, - dates: [Date, Date], + dateRange: [Date, Date], + hasTime: boolean, ) { - return { ...value, values: dates }; + return { ...value, values: dateRange, hasTime }; } export function isDateRange(value: Date[]): value is [Date, Date] { @@ -108,15 +115,12 @@ export function clearTimePart(value: Date) { return dayjs(value).startOf("date").toDate(); } -export function hasTimeParts(value: Date) { - return value.getHours() !== 0 || value.getMinutes() !== 0; -} - export function coerceValue({ type, operator, values, -}: SpecificDatePickerValue) { + hasTime, +}: SpecificDatePickerValue): SpecificDatePickerValue { if (operator === "between") { const [startDate, endDate] = values; @@ -126,8 +130,9 @@ export function coerceValue({ values: dayjs(endDate).isBefore(startDate) ? [endDate, startDate] : [startDate, endDate], + hasTime, }; } - return { type, operator, values }; + return { type, operator, values, hasTime }; } diff --git a/frontend/src/metabase/querying/components/DatePicker/types.ts b/frontend/src/metabase/querying/components/DatePicker/types.ts index 3374c18a83ed90bf6213dd04678198a5532317ce..af27f99422a9582d5801934e9186a3774bdc60bc 100644 --- a/frontend/src/metabase/querying/components/DatePicker/types.ts +++ b/frontend/src/metabase/querying/components/DatePicker/types.ts @@ -28,6 +28,7 @@ export interface SpecificDatePickerValue { type: "specific"; operator: SpecificDatePickerOperator; values: Date[]; + hasTime: boolean; } export interface RelativeDatePickerValue { diff --git a/frontend/src/metabase/querying/components/FilterContent/DateFilterEditor/DateFilterEditor.unit.spec.tsx b/frontend/src/metabase/querying/components/FilterContent/DateFilterEditor/DateFilterEditor.unit.spec.tsx index ef139ba16e1736bf1cac2f38fc623a0b5388c8d2..c144b1f2063a36d8e0f9b37ccef8660c5d23511e 100644 --- a/frontend/src/metabase/querying/components/FilterContent/DateFilterEditor/DateFilterEditor.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/FilterContent/DateFilterEditor/DateFilterEditor.unit.spec.tsx @@ -149,6 +149,7 @@ describe("DateFilterEditor", () => { operator: "=", column, values: [new Date(2020, 1, 15)], + hasTime: false, }), ); const { getNextFilterName } = setup({ diff --git a/frontend/src/metabase/querying/components/FilterPicker/test-utils.ts b/frontend/src/metabase/querying/components/FilterPicker/test-utils.ts index 40f5dbfa49c04d3cf7e68036b5c60db7d469f236..d93e8b279ac4b28dc3e9cdd0a099880e0a4b0e85 100644 --- a/frontend/src/metabase/querying/components/FilterPicker/test-utils.ts +++ b/frontend/src/metabase/querying/components/FilterPicker/test-utils.ts @@ -240,11 +240,13 @@ export function createQueryWithSpecificDateFilter({ column = findDateTimeColumn(query), operator = "=", values = [new Date(2020, 1, 15)], + hasTime = false, }: SpecificDateFilterOpts = {}) { const clause = Lib.specificDateFilterClause(query, 0, { operator, column, values, + hasTime, }); return createFilteredQuery(query, clause); } diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesFilterPicker/TimeseriesFilterPicker.unit.spec.tsx b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesFilterPicker/TimeseriesFilterPicker.unit.spec.tsx index 9b011244ba9adfe34708c37a40f7692ffc961367..f4df8358cb75d674fe4772703aa623e32711101d 100644 --- a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesFilterPicker/TimeseriesFilterPicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesFilterPicker/TimeseriesFilterPicker.unit.spec.tsx @@ -17,6 +17,7 @@ function createDateFilter(query: Lib.Query) { operator: "=", column: findDateColumn(query), values: [new Date(2020, 0, 10)], + hasTime: false, }); } diff --git a/frontend/src/metabase/querying/hooks/use-date-filter/use-date-filter.unit.spec.ts b/frontend/src/metabase/querying/hooks/use-date-filter/use-date-filter.unit.spec.ts index d8145f4d5d3f612787d26df2bfe49e5f72b811fe..84194a346821d1bac07fdf8c8fc10164c02facf5 100644 --- a/frontend/src/metabase/querying/hooks/use-date-filter/use-date-filter.unit.spec.ts +++ b/frontend/src/metabase/querying/hooks/use-date-filter/use-date-filter.unit.spec.ts @@ -125,11 +125,13 @@ function getTestCases( type: "specific", operator: "=", values: [date1], + hasTime: false, }, expression: Lib.specificDateFilterClause(query, stageIndex, { operator: "=", column, values: [date1], + hasTime: false, }), expectedDisplayName: "Created At is on Jan 1, 2020", }, @@ -138,11 +140,13 @@ function getTestCases( type: "specific", operator: "<", values: [date1], + hasTime: false, }, expression: Lib.specificDateFilterClause(query, stageIndex, { operator: "<", column, values: [date1], + hasTime: false, }), expectedDisplayName: "Created At is before Jan 1, 2020", }, @@ -151,11 +155,13 @@ function getTestCases( type: "specific", operator: ">", values: [date1], + hasTime: false, }, expression: Lib.specificDateFilterClause(query, stageIndex, { operator: ">", column, values: [date1], + hasTime: false, }), expectedDisplayName: "Created At is after Jan 1, 2020", }, @@ -164,11 +170,13 @@ function getTestCases( type: "specific", operator: "between", values: [date1, date2], + hasTime: false, }, expression: Lib.specificDateFilterClause(query, stageIndex, { operator: "between", column, values: [date1, date2], + hasTime: false, }), expectedDisplayName: "Created At is Jan 1 – Mar 3, 2020", }, diff --git a/frontend/src/metabase/querying/hooks/use-date-filter/utils.ts b/frontend/src/metabase/querying/hooks/use-date-filter/utils.ts index 850a20fd8ef7217fa37824fe377e8e88d1d0796e..7dde522413d31f671ee43faf9716a28eb55649d1 100644 --- a/frontend/src/metabase/querying/hooks/use-date-filter/utils.ts +++ b/frontend/src/metabase/querying/hooks/use-date-filter/utils.ts @@ -42,6 +42,7 @@ function getSpecificDateValue( type: "specific", operator: filterParts.operator, values: filterParts.values, + hasTime: filterParts.hasTime, }; } @@ -101,7 +102,7 @@ export function getFilterClause( case "specific": return getSpecificFilterClause(query, stageIndex, column, value); case "relative": - return getRelativeFilterClause(query, stageIndex, column, value); + return getRelativeFilterClause(column, value); case "exclude": return getExcludeFilterClause(query, stageIndex, column, value); } @@ -117,12 +118,11 @@ function getSpecificFilterClause( operator: value.operator, column, values: value.values, + hasTime: value.hasTime, }); } function getRelativeFilterClause( - query: Lib.Query, - stageIndex: number, column: Lib.ColumnMetadata, value: RelativeDatePickerValue, ): Lib.ExpressionClause {