Skip to content
Snippets Groups Projects
Unverified Commit 56357c04 authored by Ryan Laurie's avatar Ryan Laurie Committed by GitHub
Browse files

Inline Date Filters (#23464)

Add Inline Date picker to bulk filter modal
parent 02f26e07
No related branches found
No related tags found
No related merge requests found
Showing
with 620 additions and 22 deletions
......@@ -9,6 +9,7 @@ import { BooleanPickerCheckbox } from "metabase/query_builder/components/filters
import { BulkFilterSelect } from "../BulkFilterSelect";
import { InlineCategoryPicker } from "../InlineCategoryPicker";
import { InlineValuePicker } from "../InlineValuePicker";
import { InlineDatePicker } from "../InlineDatePicker";
import { FIELD_PRIORITY } from "./constants";
......@@ -91,6 +92,21 @@ export const BulkFilterItem = ({
handleChange={handleChange}
/>
);
case "type/DateTime":
case "type/DateTimeWithTZ":
case "type/DateTimeWithLocalTZ":
case "type/DateTimeWithZoneOffset":
case "type/DateTimeWithZoneID":
return (
<InlineDatePicker
query={query}
filter={filter}
newFilter={newFilter}
dimension={dimension}
onChange={handleChange}
onClear={handleClear}
/>
);
default:
return (
<BulkFilterSelect
......
export const FIELD_PRIORITY = [
"type/DateTime",
"type/DateTimeWithTZ",
"type/DateTimeWithLocalTZ",
"type/DateTimeWithZoneOffset",
"type/DateTimeWithZoneID",
"type/Boolean",
"list",
"type/Category",
......
......@@ -20,6 +20,7 @@ export interface BulkFilterSelectProps {
query: StructuredQuery;
filter?: Filter;
dimension: Dimension;
customTrigger?: ({ onClick }: { onClick: () => void }) => JSX.Element;
handleChange: (newFilter: Filter) => void;
handleClear: () => void;
}
......@@ -28,6 +29,7 @@ export const BulkFilterSelect = ({
query,
filter,
dimension,
customTrigger,
handleChange,
handleClear,
}: BulkFilterSelectProps): JSX.Element => {
......@@ -42,17 +44,21 @@ export const BulkFilterSelect = ({
return (
<TippyPopoverWithTrigger
sizeToFit
renderTrigger={({ onClick }) => (
<SelectFilterButton
hasValue={filter != null}
highlighted
aria-label={dimension.displayName()}
onClick={onClick}
onClear={handleClear}
>
{name}
</SelectFilterButton>
)}
renderTrigger={
customTrigger
? customTrigger
: ({ onClick }) => (
<SelectFilterButton
hasValue={filter != null}
highlighted
aria-label={dimension.displayName()}
onClick={onClick}
onClear={handleClear}
>
{name}
</SelectFilterButton>
)
}
popoverContent={({ closePopover }) => (
<SelectFilterPopover
query={query}
......
import styled from "@emotion/styled";
import { space } from "metabase/styled-components/theme";
import { color, alpha } from "metabase/lib/colors";
import Button from "metabase/core/components/Button";
export const OptionContainer = styled.div`
grid-column: span 2;
margin: ${space(2)} 0;
padding-bottom: ${space(2)};
font-weight: bold;
border-bottom: 1px solid ${color("border")};
`;
type OptionButtonProps = {
primaryColor?: string;
selected?: boolean;
};
export const OptionButton = styled(Button)<OptionButtonProps>`
border-color: ${({ selected }) =>
selected ? color("brand") : color("border")};
border-radius: ${space(1)};
margin-right: ${space(1)};
margin-bottom: ${space(1)};
background-color: ${({ selected }) =>
selected ? alpha("brand", 0.3) : color("white")};
color: ${({ selected, primaryColor = color("brand") }) =>
selected ? primaryColor : color("text-dark")};
padding-top: ${space(1)};
padding-bottom: ${space(1)};
&:hover {
background-color: ${({ selected }) =>
selected ? alpha("brand", 0.3) : color("white")};
border-color: ${color("brand")};
}
`;
export const ClearButton = styled.span`
color: ${alpha("brand", 0.5)};
margin-left: ${space(1)};
cursor: pointer;
&:hover {
color: ${color("brand")};
}
`;
import React, { useMemo, useCallback } from "react";
import _ from "underscore";
import { t } from "ttag";
import Filter from "metabase-lib/lib/queries/structured/Filter";
import { Filter as FilterExpression } from "metabase-types/types/Query";
import Icon from "metabase/components/Icon";
import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery";
import Dimension from "metabase-lib/lib/Dimension";
import { OPTIONS } from "metabase/query_builder/components/filters/pickers/DatePicker/DatePickerShortcuts";
import { BulkFilterSelect } from "../BulkFilterSelect";
import {
OptionButton,
OptionContainer,
ClearButton,
} from "./InlineDatePicker.styled";
const DATE_SHORTCUT_OPTIONS = [
OPTIONS.DAY_OPTIONS[0], // Today
OPTIONS.DAY_OPTIONS[1], // Yesterday
OPTIONS.DAY_OPTIONS[2], // Last Week
OPTIONS.MONTH_OPTIONS[0], // Last Month
];
interface InlineDatePickerProps {
query: StructuredQuery;
filter?: Filter;
newFilter: Filter;
dimension: Dimension;
onChange: (newFilter: Filter) => void;
onClear: () => void;
}
export function InlineDatePicker({
query,
filter,
newFilter,
dimension,
onChange,
onClear,
}: InlineDatePickerProps) {
const selectedFilterIndex = useMemo(() => {
if (!filter) {
return null;
}
const optionIndex = DATE_SHORTCUT_OPTIONS.findIndex(({ init }) =>
_.isEqual(filter, init(filter)),
);
return optionIndex !== -1 ? optionIndex : null;
}, [filter]);
const handleShortcutChange = (init: (filter: Filter) => FilterExpression) => {
onChange(new Filter(init(filter ?? newFilter), null, query));
};
const handleClear = useCallback(
e => {
e.stopPropagation();
onClear();
},
[onClear],
);
const shouldShowShortcutOptions = !filter || selectedFilterIndex !== null;
return (
<OptionContainer
data-testid="date-picker"
aria-label={dimension?.field()?.displayName()}
>
{shouldShowShortcutOptions &&
DATE_SHORTCUT_OPTIONS.map(({ displayName, init }, index) => (
<OptionButton
key={displayName}
selected={index === selectedFilterIndex}
onClick={
index === selectedFilterIndex
? onClear
: () => handleShortcutChange(init)
}
>
<span aria-selected={index === selectedFilterIndex}>
{displayName}
</span>
</OptionButton>
))}
<BulkFilterSelect
query={query}
filter={filter}
dimension={dimension}
handleChange={onChange}
handleClear={onClear}
customTrigger={({ onClick }) =>
filter && selectedFilterIndex === null ? (
<OptionButton selected onClick={onClick}>
<span>{filter.displayName({ includeDimension: false })}</span>
<ClearButton onClick={handleClear}>
<Icon name="close" size={12} />
</ClearButton>
</OptionButton>
) : (
<OptionButton onClick={onClick} aria-label={t`more options`}>
<Icon name="ellipsis" size={14} />
</OptionButton>
)
}
/>
</OptionContainer>
);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { metadata } from "__support__/sample_database_fixture";
import Field from "metabase-lib/lib/metadata/Field";
import Filter from "metabase-lib/lib/queries/structured/Filter";
import Question from "metabase-lib/lib/Question";
import { InlineDatePicker } from "./InlineDatePicker";
const dateField = new Field({
database_type: "test",
base_type: "type/DateTime",
semantic_type: "type/DateTime",
effective_type: "type/DateTime",
table_id: 8,
name: "date_field",
has_field_values: "none",
values: [],
dimensions: {},
dimension_options: [],
id: 138,
metadata,
});
metadata.fields[dateField.id] = dateField;
const card = {
dataset_query: {
database: 5,
query: {
"source-table": 8,
},
type: "query",
},
display: "table",
visualization_settings: {},
};
const question = new Question(card, metadata);
const dateDimension = dateField.dimension();
const query = question.query();
const newFilter = new Filter(
[null, ["field", dateField.id, null]],
null,
question.query(),
);
describe("InlineDatePicker", () => {
it("renders an inline date picker with shortcut buttons", () => {
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={undefined}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
screen.getByTestId("date-picker");
screen.getByText("Today");
screen.getByText("Yesterday");
screen.getByText("Last Week");
screen.getByText("Last Month");
screen.getByLabelText("more options");
});
it("populates an existing shortcut value", () => {
const testFilter = new Filter(
[
"time-interval",
["field", dateField.id, null],
-1,
"day",
{ include_current: false },
],
null,
query,
);
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
expect(screen.getByText("Yesterday")).toHaveAttribute(
"aria-selected",
"true",
);
});
it("populates an existing custom value", () => {
const testFilter = new Filter(
["between", ["field", dateField.id, null], "1605-11-05", "2005-11-05"],
null,
query,
);
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
screen.getByText("between November 5, 1605 November 5, 2005");
});
it("populates a complex custom value", () => {
// as of 6/20/22 this MBQL works, but throws warnings
const testFilter = new Filter(
[
"between",
["+", ["field", dateField.id, null], ["interval", 66, "year"]],
["relative-datetime", -22, "day"],
["relative-datetime", 0, "day"],
],
null,
query,
);
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
screen.getByText("Previous 22 Days, starting 66 years ago");
});
it("adds a shortcut value", () => {
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={undefined}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
screen.getByText("Last Week").click();
expect(changeSpy).toHaveBeenCalledWith([
"time-interval",
["field", dateField.id, null],
-1,
"week",
{ include_current: false },
]);
});
it("changes a shortcut value", () => {
const testFilter = new Filter(
[
"time-interval",
["field", dateField.id, null],
-1,
"day",
{ include_current: false },
],
null,
query,
);
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
expect(screen.getByText("Yesterday")).toHaveAttribute(
"aria-selected",
"true",
);
screen.getByText("Last Week").click();
expect(changeSpy).toHaveBeenCalledWith([
"time-interval",
["field", dateField.id, null],
-1,
"week",
{ include_current: false },
]);
});
it("clears a shortcut value", () => {
const testFilter = new Filter(
[
"time-interval",
["field", dateField.id, null],
-1,
"day",
{ include_current: false },
],
null,
query,
);
const changeSpy = jest.fn();
const clearSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={clearSpy}
/>,
);
expect(screen.getByText("Yesterday")).toHaveAttribute(
"aria-selected",
"true",
);
// clicking a selected shortcut value again de-selects it and clears the filter
screen.getByText("Yesterday").click();
expect(changeSpy).not.toHaveBeenCalled();
expect(clearSpy).toHaveBeenCalledTimes(1);
});
it.skip("adds a custom value", async () => {
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={undefined}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
screen.getByLabelText("more options").click();
await waitFor(() => screen.getByText("Relative dates..."));
screen.getByText("Relative dates...").click();
await waitFor(() => screen.getByTestId("relative-datetime-value"));
const input = screen.getByTestId("relative-datetime-value");
userEvent.clear(input);
userEvent.type(input, "88");
// FIXME: for some reason this button never gets enabled
await waitFor(() =>
expect(screen.getByText("Add filter")).not.toBeDisabled(),
);
screen.getByText("Add filter").click();
await waitFor(() => expect(changeSpy).toHaveBeenCalled());
expect(changeSpy).toHaveBeenCalledWith([
"time-interval",
["field", dateField.id, null],
-88,
"day",
]);
});
it.skip("changes a custom value", async () => {
const testFilter = new Filter(
["between", ["field", dateField.id, null], "1605-11-05", "2005-11-05"],
null,
query,
);
const changeSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={changeSpy}
/>,
);
const btn = screen.getByText("between November 5, 1605 November 5, 2005");
userEvent.click(btn);
await waitFor(() => screen.getByDisplayValue("11/05/1605"));
const input = screen.getByDisplayValue("11/05/1605");
userEvent.clear(input);
userEvent.type(input, "09/05/1995");
// FIXME: for some reason this button never gets enabled
await waitFor(() =>
expect(screen.getByText("Update filter")).not.toBeDisabled(),
);
screen.getByText("Update filter").click();
expect(changeSpy).toBeCalledWith([
"between",
["field", dateField.id, null],
"1995-09-05",
"2005-11-05",
]);
});
it("clears a custom value", () => {
const testFilter = new Filter(
["between", ["field", dateField.id, null], "1605-11-05", "2005-11-05"],
null,
query,
);
const changeSpy = jest.fn();
const clearSpy = jest.fn();
render(
<InlineDatePicker
query={query}
filter={testFilter}
newFilter={newFilter}
dimension={dateDimension}
onChange={changeSpy}
onClear={clearSpy}
/>,
);
const clearBtn = screen.getByLabelText("close icon");
userEvent.click(clearBtn);
expect(clearSpy).toBeCalledTimes(1);
expect(changeSpy).toBeCalledTimes(0);
});
});
export * from "./InlineDatePicker";
......@@ -25,7 +25,7 @@ function getDateTimeField(field: Field, bucketing?: string) {
type Option = {
displayName: string;
init: (filter: any) => any[];
init: (filter: Filter) => any;
};
const DAY_OPTIONS: Option[] = [
......@@ -133,6 +133,12 @@ const MISC_OPTIONS: Option[] = [
},
];
export const OPTIONS = {
DAY_OPTIONS,
MONTH_OPTIONS,
MISC_OPTIONS,
};
type Props = {
className?: string;
primaryColor?: string;
......
......@@ -368,20 +368,40 @@ describe("scenarios > filters > bulk filtering", () => {
it("can add a date shortcut filter", () => {
modal().within(() => {
cy.findByLabelText("Created At").click();
cy.findByText("Today").click();
cy.button("Apply").click();
cy.wait("@dataset");
});
cy.findByText("Today").click();
cy.findByLabelText("Created At").within(() => {
cy.findByText("Today").should("be.visible");
cy.findByText("Created At Today").should("be.visible");
cy.findByText("Showing 0 rows").should("be.visible");
});
it("can add a date shortcut filter from the popover", () => {
modal().within(() => {
cy.findByLabelText("Created At").within(() => {
cy.findByLabelText("more options").click();
});
});
cy.findByText("Last 3 Months").click();
modal().within(() => {
cy.findByText("Previous 3 Months");
cy.findByText("Apply").click();
cy.wait("@dataset");
});
// make sure select popover is closed
cy.findByText("Yesterday").should("not.exist");
cy.findByText("Created At Previous 3 Months").should("be.visible");
cy.findByText("Showing 0 rows").should("be.visible");
});
// if this gets flaky, disable, it's an issue with internal state in the datepicker component
it("can add a date range filter", () => {
modal().within(() => {
cy.findByLabelText("Created At").click();
cy.findByLabelText("Created At").within(() => {
cy.findByLabelText("more options").click();
});
});
cy.findByText("Specific dates...").click();
cy.findByText("Before").click();
......@@ -390,14 +410,24 @@ describe("scenarios > filters > bulk filtering", () => {
cy.get("input")
.eq(0)
.clear()
.type("01/01/2018");
.type("01/01/2017", { delay: 0 });
cy.findByText("Add filter").click();
});
cy.findByLabelText("Created At").within(() => {
cy.findByText("is before January 1, 2018").should("be.visible");
modal().within(() => {
cy.findByLabelText("Created At").within(() => {
cy.findByText("is before January 1, 2017").should("be.visible");
});
cy.findByText("Apply").click();
cy.wait("@dataset");
});
cy.findByText("Created At is before January 1, 2017").should(
"be.visible",
);
cy.findByText("Showing 744 rows").should("be.visible");
});
it.skip("Bug repro: can cancel adding date filter", () => {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment