Compare commits
17 Commits
python-run
...
mblib-betw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eabdcb235b | ||
|
|
4331f8c48b | ||
|
|
34624175ad | ||
|
|
7c8b0c4305 | ||
|
|
6767c44c55 | ||
|
|
3257befd93 | ||
|
|
eb4449b20a | ||
|
|
f43cc8cb8d | ||
|
|
56243d7e70 | ||
|
|
d9b11797cd | ||
|
|
c3aa9adf5b | ||
|
|
31bf68ffc8 | ||
|
|
f8f09063e5 | ||
|
|
093c27e281 | ||
|
|
2845e35e79 | ||
|
|
cc31cdfa31 | ||
|
|
68f191e24b |
@@ -237,17 +237,15 @@ describe("(metabase#46714)", () => {
|
||||
cy.findByText("Total").click();
|
||||
});
|
||||
|
||||
cy.findByLabelText("Filter operator")
|
||||
.should("have.text", "Between")
|
||||
.click();
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
H.popover().last().findByText("Less than").click();
|
||||
cy.findByLabelText("Filter operator").should("have.text", "Less than");
|
||||
H.popover().findByPlaceholderText("Enter a number").clear().type("1000");
|
||||
cy.findByLabelText("Filter operator").should("have.text", "Range").click();
|
||||
H.popover().findByText("Range").click();
|
||||
cy.findByLabelText("Filter operator").should("have.text", "Range");
|
||||
H.popover().findByPlaceholderText("End of range").clear().type("1000");
|
||||
H.popover().findByText("Add filter").click();
|
||||
|
||||
cy.findByTestId("filter-pill").should(
|
||||
"have.text",
|
||||
// broken
|
||||
"Total is less than 1000",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1899,10 +1899,10 @@ describe("scenarios > admin > datamodel", () => {
|
||||
FieldSection.getPreviewButton().click();
|
||||
PreviewSection.getPreviewTypeInput().findByText("Filtering").click();
|
||||
PreviewSection.get()
|
||||
.findByPlaceholderText("Min")
|
||||
.findByPlaceholderText("Start of range")
|
||||
.should("be.visible");
|
||||
PreviewSection.get()
|
||||
.findByPlaceholderText("Max")
|
||||
.findByPlaceholderText("End of range")
|
||||
.should("be.visible");
|
||||
|
||||
cy.reload();
|
||||
|
||||
@@ -12,6 +12,7 @@ type CastTestCase = {
|
||||
columns: string[];
|
||||
firstRows: string[][];
|
||||
};
|
||||
inputSelector?: string;
|
||||
};
|
||||
|
||||
const TEXT_TEST_CASES: CastTestCase[] = [
|
||||
@@ -202,7 +203,8 @@ const FLOAT_TEST_CASES: CastTestCase[] = [
|
||||
{
|
||||
name: "FloatExpression",
|
||||
expression: 'float(concat([ID], ".3333"))',
|
||||
filterOperator: "Less than",
|
||||
filterOperator: "Range",
|
||||
inputSelector: "End of range",
|
||||
filterValue: "2",
|
||||
expectedRowCount: 1,
|
||||
},
|
||||
@@ -328,10 +330,14 @@ function removeCustomColumn({ name }: CastTestCase) {
|
||||
H.getNotebookStep("expression").findByText(name).icon("close").click();
|
||||
}
|
||||
|
||||
function addOperatorFilter({ filterOperator, filterValue }: CastTestCase) {
|
||||
function addOperatorFilter({
|
||||
filterOperator,
|
||||
filterValue,
|
||||
inputSelector = "Filter value",
|
||||
}: CastTestCase) {
|
||||
H.selectFilterOperator(filterOperator);
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type(filterValue);
|
||||
cy.findByLabelText(inputSelector).type(filterValue);
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
}
|
||||
@@ -377,7 +383,6 @@ function testFilterWithExpressions(
|
||||
H.assertQueryBuilderRowCount(testCase.expectedRowCount);
|
||||
|
||||
if (testCase.expectedTableData) {
|
||||
// @ts-expect-error: assertTableData is not typed
|
||||
H.assertTableData(testCase.expectedTableData);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("scenarios > custom column > literals", () => {
|
||||
H.getNotebookStep("expression").button("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Column").click();
|
||||
cy.findByPlaceholderText("Min").type("5");
|
||||
cy.findByPlaceholderText("Start of range").type("5");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
H.getNotebookStep("filter").button("Summarize").click();
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("Dashboard > Dashboard Questions", () => {
|
||||
.findByText("Add filters to narrow your answer")
|
||||
.click();
|
||||
H.popover().findByText("Discount").click();
|
||||
cy.findByPlaceholderText("Min").type("1");
|
||||
cy.findByPlaceholderText("Start of range").type("1");
|
||||
H.popover().button("Add filter").click();
|
||||
cy.button("Visualize").click();
|
||||
|
||||
|
||||
@@ -29,13 +29,11 @@ describe("issue 9339", () => {
|
||||
H.openOrdersTable();
|
||||
|
||||
H.tableHeaderClick("Total");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Filter by this column").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
cy.findByPlaceholderText("Enter a number").type("9339,1234").blur();
|
||||
H.popover().findByText("Filter by this column").click();
|
||||
H.selectFilterOperator("Range");
|
||||
cy.findByPlaceholderText("Start of range").type("9339,1234").blur();
|
||||
cy.findByDisplayValue("9339").should("be.visible");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("1,234").should("not.exist");
|
||||
H.popover().findByText("1,234").should("not.exist");
|
||||
cy.button("Add filter").should("be.enabled");
|
||||
});
|
||||
});
|
||||
@@ -990,15 +988,14 @@ describe("issue 36508", () => {
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Summaries").click();
|
||||
cy.findByText("Distinct values of Email").click();
|
||||
cy.findByText("Between").click();
|
||||
cy.findByText("Range").click();
|
||||
});
|
||||
|
||||
H.popover()
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.findByText("Equal to").should("exist");
|
||||
cy.findByText("Greater than").should("exist");
|
||||
cy.findByText("Less than").should("exist");
|
||||
cy.findByText("Range").should("exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,10 @@ describe("scenarios > filters > bigint (metabase#5816)", () => {
|
||||
name: "SQL NUMBER",
|
||||
native: {
|
||||
query: `SELECT ${MIN_BIGINT_VALUE} AS NUMBER
|
||||
UNION ALL
|
||||
SELECT 0 AS NUMBER
|
||||
UNION ALL
|
||||
SELECT ${MAX_BIGINT_VALUE} AS NUMBER`,
|
||||
UNION ALL
|
||||
SELECT 0 AS NUMBER
|
||||
UNION ALL
|
||||
SELECT ${MAX_BIGINT_VALUE} AS NUMBER`,
|
||||
"template-tags": {},
|
||||
},
|
||||
display: "table",
|
||||
@@ -47,10 +47,10 @@ SELECT ${MAX_BIGINT_VALUE} AS NUMBER`,
|
||||
name: "SQL NUMBER",
|
||||
native: {
|
||||
query: `SELECT CAST('${NEGATIVE_DECIMAL_VALUE}' AS DECIMAL) AS NUMBER
|
||||
UNION ALL
|
||||
SELECT CAST(0 AS DECIMAL) AS NUMBER
|
||||
UNION ALL
|
||||
SELECT CAST('${POSITIVE_DECIMAL_VALUE}' AS DECIMAL) AS NUMBER`,
|
||||
UNION ALL
|
||||
SELECT CAST(0 AS DECIMAL) AS NUMBER
|
||||
UNION ALL
|
||||
SELECT CAST('${POSITIVE_DECIMAL_VALUE}' AS DECIMAL) AS NUMBER`,
|
||||
"template-tags": {},
|
||||
},
|
||||
display: "table",
|
||||
@@ -151,8 +151,9 @@ SELECT CAST('${POSITIVE_DECIMAL_VALUE}' AS DECIMAL) AS NUMBER`,
|
||||
|
||||
cy.log("> operator");
|
||||
testFilter({
|
||||
filterOperator: "Greater than",
|
||||
setFilterValue: () => cy.findByLabelText("Filter value").type(minValue),
|
||||
filterOperator: "Range",
|
||||
setFilterValue: () =>
|
||||
cy.findByLabelText("Start of range").type(minValue),
|
||||
filterDisplayName: `NUMBER is greater than ${minValue}`,
|
||||
filteredRowCount: 2,
|
||||
});
|
||||
@@ -181,34 +182,34 @@ SELECT CAST('${POSITIVE_DECIMAL_VALUE}' AS DECIMAL) AS NUMBER`,
|
||||
filteredRowCount: 3,
|
||||
});
|
||||
|
||||
cy.log("between operator - min value");
|
||||
cy.log("range operator - min value");
|
||||
testFilter({
|
||||
filterOperator: "Between",
|
||||
filterOperator: "Range",
|
||||
setFilterValue: () => {
|
||||
cy.findByPlaceholderText("Min").type(minValue);
|
||||
cy.findByPlaceholderText("Max").type("0");
|
||||
cy.findByPlaceholderText("Start of range").type(minValue);
|
||||
cy.findByPlaceholderText("End of range").type("0");
|
||||
},
|
||||
filterDisplayName: `NUMBER is between ${minValue} and 0`,
|
||||
filteredRowCount: 2,
|
||||
});
|
||||
|
||||
cy.log("between operator - max value");
|
||||
cy.log("range operator - max value");
|
||||
testFilter({
|
||||
filterOperator: "Between",
|
||||
filterOperator: "Range",
|
||||
setFilterValue: () => {
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Max").type(maxValue);
|
||||
cy.findByPlaceholderText("Start of range").type("0");
|
||||
cy.findByPlaceholderText("End of range").type(maxValue);
|
||||
},
|
||||
filterDisplayName: `NUMBER is between 0 and ${maxValue}`,
|
||||
filteredRowCount: 2,
|
||||
});
|
||||
|
||||
cy.log("between operator - min and max values");
|
||||
cy.log("range operator - min and max values");
|
||||
testFilter({
|
||||
filterOperator: "Between",
|
||||
filterOperator: "Range",
|
||||
setFilterValue: () => {
|
||||
cy.findByPlaceholderText("Min").type(minValue);
|
||||
cy.findByPlaceholderText("Max").type(maxValue);
|
||||
cy.findByPlaceholderText("Start of range").type(minValue);
|
||||
cy.findByPlaceholderText("End of range").type(maxValue);
|
||||
},
|
||||
filterDisplayName: `NUMBER is between ${minValue} and ${maxValue}`,
|
||||
filteredRowCount: 3,
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Summaries").click();
|
||||
cy.findByText("Count").click();
|
||||
cy.findByPlaceholderText("Min").type("500");
|
||||
cy.findByPlaceholderText("Start of range").type("500");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
@@ -155,7 +155,7 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
H.visitQuestionAdhoc(filteredQuestionDetails);
|
||||
H.queryBuilderFiltersPanel().findByText("Quantity is less than 30").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type("{backspace}{backspace}25");
|
||||
cy.findByLabelText("End of range").type("{backspace}{backspace}25");
|
||||
cy.button("Update filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
@@ -537,8 +537,8 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
it("applies a between filter", () => {
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Price").click();
|
||||
cy.findByPlaceholderText("Min").type("50");
|
||||
cy.findByPlaceholderText("Max").type("80");
|
||||
cy.findByPlaceholderText("Start of range").type("50");
|
||||
cy.findByPlaceholderText("End of range").type("80");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
@@ -550,9 +550,9 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
|
||||
it("applies a greater than filter", () => {
|
||||
H.popover().findByText("Price").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type("50");
|
||||
cy.findByLabelText("Start of range").type("50");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
@@ -565,7 +565,7 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
it("infers a <= filter from an invalid between filter", () => {
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Price").click();
|
||||
cy.findByPlaceholderText("Max").type("50");
|
||||
cy.findByPlaceholderText("End of range").type("50");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
@@ -599,9 +599,9 @@ describe("scenarios > filters > bulk filtering", () => {
|
||||
cy.findByText("Category").should("not.exist");
|
||||
cy.findByText("Price").click();
|
||||
});
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type("90");
|
||||
cy.findByLabelText("Start of range").type("90");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset");
|
||||
|
||||
@@ -242,9 +242,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
H.visitQuestionAdhoc(tableQuestionWithExpression, { mode: "notebook" });
|
||||
H.filter({ mode: "notebook" });
|
||||
H.clauseStepPopover().findByText("Total100").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("250.5");
|
||||
cy.findByPlaceholderText("Start of range").type("250.5");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Total100 is greater than 250.5");
|
||||
@@ -320,9 +320,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
H.visitQuestionAdhoc(tableWithAggregations, { mode: "notebook" });
|
||||
addNewFilter();
|
||||
H.clauseStepPopover().findByText("Sum of Quantity").click();
|
||||
H.selectFilterOperator("Less than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("350");
|
||||
cy.findByPlaceholderText("End of range").type("350");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Sum of Quantity is less than 350", { stage: 1 });
|
||||
@@ -369,9 +369,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
});
|
||||
H.filter({ mode: "notebook" });
|
||||
H.clauseStepPopover().findByText("Total100").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("250.5");
|
||||
cy.findByPlaceholderText("Start of range").type("250.5");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Total100 is greater than 250.5");
|
||||
@@ -465,9 +465,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
});
|
||||
addNewFilter();
|
||||
H.clauseStepPopover().findByText("Sum of Quantity").click();
|
||||
H.selectFilterOperator("Less than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("350");
|
||||
cy.findByPlaceholderText("End of range").type("350");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Sum of Quantity is less than 350", { stage: 1 });
|
||||
@@ -518,9 +518,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
});
|
||||
H.filter({ mode: "notebook" });
|
||||
H.clauseStepPopover().findByText("Total100").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("250.5");
|
||||
cy.findByPlaceholderText("Start of range").type("250.5");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Total100 is greater than 250.5");
|
||||
@@ -614,9 +614,9 @@ describe("scenarios > filters > filter sources", () => {
|
||||
});
|
||||
addNewFilter();
|
||||
H.clauseStepPopover().findByText("Sum of QUANTITY").click();
|
||||
H.selectFilterOperator("Less than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.clauseStepPopover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("350");
|
||||
cy.findByPlaceholderText("End of range").type("350");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
assertFilterName("Sum of QUANTITY is less than 350", { stage: 1 });
|
||||
|
||||
@@ -205,34 +205,38 @@ const NUMBER_CASES = [
|
||||
{
|
||||
title: "greater than",
|
||||
columnName: "Price",
|
||||
operator: "Greater than",
|
||||
operator: "Range",
|
||||
values: ["47.5"],
|
||||
expectedDisplayName: "Price is greater than 47.5",
|
||||
expectedRowCount: 111,
|
||||
inputSelector: "Start of range",
|
||||
},
|
||||
{
|
||||
title: "less than",
|
||||
columnName: "Price",
|
||||
operator: "Less than",
|
||||
operator: "Range",
|
||||
values: ["47.5"],
|
||||
expectedDisplayName: "Price is less than 47.5",
|
||||
expectedRowCount: 89,
|
||||
inputSelector: "End of range",
|
||||
},
|
||||
{
|
||||
title: "greater than or equal to",
|
||||
columnName: "Price",
|
||||
operator: "Greater than or equal to",
|
||||
operator: "Range",
|
||||
values: ["47.5"],
|
||||
expectedDisplayName: "Price is greater than or equal to 47.5",
|
||||
expectedRowCount: 111,
|
||||
inputSelector: "Start of range",
|
||||
},
|
||||
{
|
||||
title: "less than or equal to",
|
||||
columnName: "Price",
|
||||
operator: "Less than or equal to",
|
||||
operator: "Range",
|
||||
values: ["47.5"],
|
||||
expectedDisplayName: "Price is less than or equal to 47.5",
|
||||
expectedRowCount: 89,
|
||||
inputSelector: "End of range",
|
||||
},
|
||||
{
|
||||
title: "is empty",
|
||||
@@ -451,7 +455,7 @@ const RELATIVE_DATE_CASES = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("scenarios > filters > filter types", () => {
|
||||
describe("scenarios > filters > filter types > ", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
@@ -502,6 +506,7 @@ describe("scenarios > filters > filter types", () => {
|
||||
values = [],
|
||||
expectedDisplayName,
|
||||
expectedRowCount,
|
||||
inputSelector = "Filter value",
|
||||
}) => {
|
||||
it(title, () => {
|
||||
H.openProductsTable({ mode: "notebook" });
|
||||
@@ -511,7 +516,7 @@ describe("scenarios > filters > filter types", () => {
|
||||
H.selectFilterOperator(operator);
|
||||
H.clauseStepPopover().within(() => {
|
||||
values.forEach((value) => {
|
||||
cy.findByLabelText("Filter value")
|
||||
cy.findByLabelText(inputSelector)
|
||||
.focus()
|
||||
.type(`${value},`, { delay: 50 })
|
||||
.blur();
|
||||
|
||||
@@ -1053,6 +1053,14 @@ describe("scenarios > question > filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should show column name and close popover on click", () => {
|
||||
H.openOrdersTable();
|
||||
H.tableHeaderColumn("Total").click();
|
||||
H.popover().findByText("Filter by this column").click();
|
||||
H.popover().should("contain", "Total").findByText("Total").click();
|
||||
H.popover({ skipVisibilityCheck: true }).should("not.exist");
|
||||
});
|
||||
|
||||
it("should render the selected item in view", () => {
|
||||
cy.viewport(1280, 320);
|
||||
H.openReviewsTable({ mode: "notebook" });
|
||||
|
||||
@@ -24,17 +24,7 @@ describe("operators in questions", () => {
|
||||
unexpected: ["Is null", "Not null"],
|
||||
},
|
||||
number: {
|
||||
expected: [
|
||||
"Equal to",
|
||||
"Not equal to",
|
||||
"Greater than",
|
||||
"Less than",
|
||||
"Between",
|
||||
"Greater than or equal to",
|
||||
"Less than or equal to",
|
||||
"Is empty",
|
||||
"Not empty",
|
||||
],
|
||||
expected: ["Equal to", "Not equal to", "Range", "Is empty", "Not empty"],
|
||||
unexpected: ["Is null", "Not null"],
|
||||
},
|
||||
relativeDates: {
|
||||
@@ -86,7 +76,7 @@ describe("operators in questions", () => {
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Price").click();
|
||||
cy.findByText("Between").click();
|
||||
cy.findByText("Range").click();
|
||||
});
|
||||
|
||||
cy.findByRole("menu").within(() => {
|
||||
|
||||
@@ -546,8 +546,8 @@ function addNumberBetweenFilter({ tableName, columnName, minValue, maxValue }) {
|
||||
cy.findByText(tableName).click();
|
||||
}
|
||||
cy.findByText(columnName).click();
|
||||
cy.findByPlaceholderText("Min").type(String(minValue));
|
||||
cy.findByPlaceholderText("Max").type(String(maxValue));
|
||||
cy.findByPlaceholderText("Start of range").type(String(minValue));
|
||||
cy.findByPlaceholderText("End of range").type(String(maxValue));
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,8 +181,8 @@ describe("issue 32037", () => {
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Total").click();
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Max").type("100");
|
||||
cy.findByPlaceholderText("Start of range").type("0");
|
||||
cy.findByPlaceholderText("End of range").type("100");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
cy.button("Save changes").should("be.enabled");
|
||||
|
||||
@@ -165,9 +165,9 @@ describe("scenarios > models", () => {
|
||||
|
||||
H.filter();
|
||||
H.popover().findByText("COUN").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type("30");
|
||||
cy.findByLabelText("Start of range").type("30");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.wait("@dataset").then(({ response }) => {
|
||||
@@ -405,9 +405,9 @@ describe("scenarios > models", () => {
|
||||
cy.findByText("Products").click();
|
||||
cy.findByText("Price").click();
|
||||
});
|
||||
H.selectFilterOperator("Less than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("50");
|
||||
cy.findByPlaceholderText("End of range").type("50");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ describe("admin > permissions > sandboxes (tested via the API)", () => {
|
||||
H.openNotebook();
|
||||
H.filter({ mode: "notebook" });
|
||||
H.popover().findByText("Total").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("100");
|
||||
cy.findByPlaceholderText("Start of range").type("100");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("admin > permissions > sandboxes (tested via the API)", () => {
|
||||
cy.log("Make sure user is still sandboxed");
|
||||
H.assertDatasetReqIsSandboxed({
|
||||
columnId: ORDERS.USER_ID,
|
||||
// TODO: typo in param name
|
||||
columnAssetion: ATTRIBUTE_VALUE,
|
||||
});
|
||||
cy.get(".test-TableInteractive-cellWrapper--firstColumn").should(
|
||||
|
||||
@@ -238,9 +238,9 @@ describe("issue 27104", () => {
|
||||
// The following line is the main assertion.
|
||||
H.popover().button("Back").should("have.text", "Count");
|
||||
// The rest of the test is not really needed for this reproduction.
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("0").blur();
|
||||
cy.findByPlaceholderText("Start of range").type("0").blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -820,8 +820,8 @@ describe.skip("issue 10493", () => {
|
||||
H.modal().within(() => {
|
||||
cy.findByText("Summaries").click();
|
||||
cy.findByTestId("filter-column-Count").within(() => {
|
||||
cy.findByPlaceholderText("Min").type("0");
|
||||
cy.findByPlaceholderText("Max").type("30000");
|
||||
cy.findByPlaceholderText("Start of range").type("0");
|
||||
cy.findByPlaceholderText("End of range").type("30000");
|
||||
});
|
||||
cy.button("Apply filters").click();
|
||||
});
|
||||
|
||||
@@ -488,7 +488,7 @@ describe("issue 48752", () => {
|
||||
H.getNotebookStep("summarize").button("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Sum of Subtotal").click();
|
||||
cy.findByPlaceholderText("Min").type("10");
|
||||
cy.findByPlaceholderText("Start of range").type("10");
|
||||
cy.findByText("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
const { H } = cy;
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import type {
|
||||
DashboardDetails,
|
||||
StructuredQuestionDetails,
|
||||
} from "e2e/support/helpers";
|
||||
import { moveDnDKitListElement } from "e2e/support/helpers";
|
||||
|
||||
const { ORDERS_ID, ORDERS, PEOPLE_ID, PEOPLE } = SAMPLE_DATABASE;
|
||||
|
||||
const questionWith2TemporalBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
const questionWith2TemporalBreakoutsDetails: StructuredQuestionDetails = {
|
||||
name: "Test question",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
@@ -28,7 +32,7 @@ const questionWith2TemporalBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
},
|
||||
};
|
||||
|
||||
const multiStageQuestionWith2TemporalBreakoutsDetails: H.StructuredQuestionDetails =
|
||||
const multiStageQuestionWith2TemporalBreakoutsDetails: StructuredQuestionDetails =
|
||||
{
|
||||
name: "Test question",
|
||||
query: {
|
||||
@@ -37,7 +41,7 @@ const multiStageQuestionWith2TemporalBreakoutsDetails: H.StructuredQuestionDetai
|
||||
},
|
||||
};
|
||||
|
||||
const questionWith2NumBinsBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
const questionWith2NumBinsBreakoutsDetails: StructuredQuestionDetails = {
|
||||
name: "Test question",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
@@ -67,7 +71,7 @@ const questionWith2NumBinsBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
},
|
||||
};
|
||||
|
||||
const multiStageQuestionWith2NumBinsBreakoutsDetails: H.StructuredQuestionDetails =
|
||||
const multiStageQuestionWith2NumBinsBreakoutsDetails: StructuredQuestionDetails =
|
||||
{
|
||||
name: "Test question",
|
||||
query: {
|
||||
@@ -76,7 +80,7 @@ const multiStageQuestionWith2NumBinsBreakoutsDetails: H.StructuredQuestionDetail
|
||||
},
|
||||
};
|
||||
|
||||
const questionWith2BinWidthBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
const questionWith2BinWidthBreakoutsDetails: StructuredQuestionDetails = {
|
||||
name: "Test question",
|
||||
query: {
|
||||
"source-table": PEOPLE_ID,
|
||||
@@ -106,7 +110,7 @@ const questionWith2BinWidthBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
},
|
||||
};
|
||||
|
||||
const multiStageQuestionWith2BinWidthBreakoutsDetails: H.StructuredQuestionDetails =
|
||||
const multiStageQuestionWith2BinWidthBreakoutsDetails: StructuredQuestionDetails =
|
||||
{
|
||||
name: "Test question",
|
||||
query: {
|
||||
@@ -115,7 +119,7 @@ const multiStageQuestionWith2BinWidthBreakoutsDetails: H.StructuredQuestionDetai
|
||||
},
|
||||
};
|
||||
|
||||
const questionWith5TemporalBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
const questionWith5TemporalBreakoutsDetails: StructuredQuestionDetails = {
|
||||
name: "Test question",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
@@ -155,7 +159,7 @@ const questionWith5TemporalBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
},
|
||||
};
|
||||
|
||||
const questionWith5NumBinsBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
const questionWith5NumBinsBreakoutsDetails: StructuredQuestionDetails = {
|
||||
name: "Test question",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
@@ -209,7 +213,7 @@ const questionWith5NumBinsBreakoutsDetails: H.StructuredQuestionDetails = {
|
||||
},
|
||||
};
|
||||
|
||||
const dashboardDetails: H.DashboardDetails = {
|
||||
const dashboardDetails: DashboardDetails = {
|
||||
parameters: [
|
||||
{
|
||||
id: "1",
|
||||
@@ -361,7 +365,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -431,7 +435,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
bucket1Name,
|
||||
bucket2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
columnPattern: RegExp;
|
||||
bucketLabel: string;
|
||||
bucket1Name: string;
|
||||
@@ -561,7 +565,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -608,7 +612,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
questionDetails,
|
||||
columnNamePattern,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
columnNamePattern: RegExp;
|
||||
}) {
|
||||
H.createQuestion(questionDetails, { visitQuestion: true });
|
||||
@@ -774,7 +778,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
expression1,
|
||||
expression2,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
expression1: string;
|
||||
expression2: string;
|
||||
}) {
|
||||
@@ -898,7 +902,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: string;
|
||||
column1MaxValue: string;
|
||||
@@ -941,10 +945,10 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
}) {
|
||||
H.popover().within(() => {
|
||||
cy.findByText(columnName).click();
|
||||
cy.findByPlaceholderText("Min")
|
||||
cy.findByPlaceholderText("Start of range")
|
||||
.clear()
|
||||
.type(String(columnMinValue));
|
||||
cy.findByPlaceholderText("Max")
|
||||
cy.findByPlaceholderText("End of range")
|
||||
.clear()
|
||||
.type(String(columnMaxValue));
|
||||
cy.button("Add filter").click();
|
||||
@@ -960,7 +964,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: number;
|
||||
column1MaxValue: number;
|
||||
@@ -1051,7 +1055,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -1118,7 +1122,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -1223,7 +1227,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: string;
|
||||
column1MaxValue: string;
|
||||
@@ -1264,10 +1268,10 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Summaries").click();
|
||||
cy.findByText(columnName).click();
|
||||
cy.findByPlaceholderText("Min")
|
||||
cy.findByPlaceholderText("Start of range")
|
||||
.clear()
|
||||
.type(String(columnMinValue));
|
||||
cy.findByPlaceholderText("Max")
|
||||
cy.findByPlaceholderText("End of range")
|
||||
.clear()
|
||||
.type(String(columnMaxValue));
|
||||
cy.button("Apply filter").click();
|
||||
@@ -1283,7 +1287,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: number;
|
||||
column1MaxValue: number;
|
||||
@@ -1383,7 +1387,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
tableColumn1Name,
|
||||
tableColumn2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
queryColumn1Name: string;
|
||||
queryColumn2Name: string;
|
||||
tableColumn1Name: string;
|
||||
@@ -1477,7 +1481,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: string;
|
||||
column1MaxValue: string;
|
||||
@@ -1524,10 +1528,10 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
}) {
|
||||
H.popover().within(() => {
|
||||
cy.findAllByText(columnName).click();
|
||||
cy.findByPlaceholderText("Min")
|
||||
cy.findByPlaceholderText("Start of range")
|
||||
.clear()
|
||||
.type(String(columnMinValue));
|
||||
cy.findByPlaceholderText("Max")
|
||||
cy.findByPlaceholderText("End of range")
|
||||
.clear()
|
||||
.type(String(columnMaxValue));
|
||||
cy.button("Add filter").click();
|
||||
@@ -1543,7 +1547,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column2MinValue,
|
||||
column2MaxValue,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column1MinValue: number;
|
||||
column1MaxValue: number;
|
||||
@@ -1636,7 +1640,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -1706,7 +1710,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
column1Name,
|
||||
column2Name,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
column1Name: string;
|
||||
column2Name: string;
|
||||
}) {
|
||||
@@ -1798,7 +1802,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
questionDetails,
|
||||
columnName,
|
||||
}: {
|
||||
questionDetails: H.StructuredQuestionDetails;
|
||||
questionDetails: StructuredQuestionDetails;
|
||||
columnName: string;
|
||||
}) {
|
||||
H.createQuestion(questionDetails).then(({ body: card }) => {
|
||||
|
||||
@@ -203,8 +203,8 @@ describe("scenarios > question > native query drill", () => {
|
||||
H.tableHeaderClick("QUANTITY");
|
||||
H.popover().findByText("Filter by this column").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Min").type("2");
|
||||
cy.findByPlaceholderText("Max").type("5");
|
||||
cy.findByPlaceholderText("Start of range").type("2");
|
||||
cy.findByPlaceholderText("End of range").type("5");
|
||||
cy.button("Add filter").click();
|
||||
cy.wait("@dataset");
|
||||
});
|
||||
|
||||
@@ -99,13 +99,11 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("ID is between 96 and 97").click();
|
||||
H.popover().findByText("Between").click();
|
||||
H.popover().findByText("Range").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Is not");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Greater than");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Less than");
|
||||
cy.contains("Range");
|
||||
});
|
||||
|
||||
it("should append indexes to duplicate custom expression names (metabase#12104)", () => {
|
||||
|
||||
@@ -185,9 +185,9 @@ describe("issue 17524", () => {
|
||||
|
||||
H.filter();
|
||||
H.popover().findByText("ID").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByLabelText("Filter value").type("1");
|
||||
cy.findByLabelText("Start of range").type("1");
|
||||
cy.button("Apply filter").click();
|
||||
});
|
||||
cy.get("polygon");
|
||||
@@ -965,9 +965,9 @@ const addCountGreaterThan2Filter = () => {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
cy.findAllByTestId("action-buttons").last().button("Filter").click();
|
||||
H.popover().findByText("Count").click();
|
||||
H.selectFilterOperator("Greater than");
|
||||
H.selectFilterOperator("Range");
|
||||
H.popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("2");
|
||||
cy.findByPlaceholderText("Start of range").type("2");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -376,8 +376,8 @@ export const triggerVisualizationQueryChange = async () => {
|
||||
|
||||
const popover = screen.getByRole("dialog");
|
||||
await userEvent.click(within(popover).getByText("Total"));
|
||||
const maxInput = within(popover).getByPlaceholderText("Max");
|
||||
await userEvent.type(maxInput, "1000");
|
||||
const endInput = within(popover).getByPlaceholderText("End of range");
|
||||
await userEvent.type(endInput, "1000");
|
||||
await userEvent.click(await screen.findByText("Apply filter"));
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export const columnFilterDrill: Drill<Lib.ColumnFilterDrillThruInfo> = ({
|
||||
drill,
|
||||
}) => {
|
||||
const { query, column, stageIndex } = Lib.filterDrillDetails(drill);
|
||||
const shouldShowBackButton =
|
||||
Lib.isNumeric(column) && !Lib.isCoordinate(column);
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -18,7 +20,13 @@ export const columnFilterDrill: Drill<Lib.ColumnFilterDrillThruInfo> = ({
|
||||
title: t`Filter by this column`,
|
||||
buttonType: "horizontal",
|
||||
icon: "filter",
|
||||
popover: getFilterPopover({ question, query, column, stageIndex }),
|
||||
popover: getFilterPopover({
|
||||
question,
|
||||
query,
|
||||
column,
|
||||
stageIndex,
|
||||
shouldShowBackButton,
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -17,7 +17,8 @@ export function getFilterPopover({
|
||||
column,
|
||||
stageIndex,
|
||||
initialFilter,
|
||||
}: FilterPopoverProps) {
|
||||
shouldShowBackButton = false,
|
||||
}: FilterPopoverProps & { shouldShowBackButton?: boolean }) {
|
||||
return function FilterDrillPopover({
|
||||
onChangeCardAndRun,
|
||||
onClose,
|
||||
@@ -35,6 +36,7 @@ export function getFilterPopover({
|
||||
onChangeCardAndRun({ nextCard });
|
||||
onClose();
|
||||
}}
|
||||
onBack={shouldShowBackButton ? onClose : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.toggleButton {
|
||||
border-right: 1px solid var(--mb-color-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root .input {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import {
|
||||
type NumberOrEmptyValue,
|
||||
type UiCoordinateFilterOperator,
|
||||
useCoordinateFilter,
|
||||
} from "metabase/querying/filters/hooks/use-coordinate-filter";
|
||||
import { Box, Flex, Stack, Text } from "metabase/ui";
|
||||
import { Box } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import { NumberFilterValuePicker } from "../../FilterValuePicker";
|
||||
import { NumberFilterInput } from "../../NumberFilterInput";
|
||||
import { FilterOperatorPicker } from "../FilterOperatorPicker";
|
||||
import { FilterPickerFooter } from "../FilterPickerFooter";
|
||||
import { FilterPickerHeader } from "../FilterPickerHeader";
|
||||
import { COMBOBOX_PROPS, WIDTH } from "../constants";
|
||||
import { WIDTH } from "../constants";
|
||||
import type { FilterChangeOpts, FilterPickerWidgetProps } from "../types";
|
||||
|
||||
import { CoordinateColumnPicker } from "./CoordinateColumnPicker";
|
||||
import { CoordinateValueInput } from "./CoordinateValueInput";
|
||||
|
||||
export function CoordinateFilterPicker({
|
||||
query,
|
||||
@@ -57,7 +54,10 @@ export function CoordinateFilterPicker({
|
||||
filter,
|
||||
});
|
||||
|
||||
const handleOperatorChange = (newOperator: Lib.CoordinateFilterOperator) => {
|
||||
const [leftInclusive, setLeftInclusive] = useState(true);
|
||||
const [rightInclusive, setRightInclusive] = useState(true);
|
||||
|
||||
const handleOperatorChange = (newOperator: UiCoordinateFilterOperator) => {
|
||||
setOperator(newOperator);
|
||||
setValues(getDefaultValues(newOperator, values));
|
||||
};
|
||||
@@ -114,6 +114,10 @@ export function CoordinateFilterPicker({
|
||||
valueCount={valueCount}
|
||||
hasMultipleValues={hasMultipleValues}
|
||||
onChange={setValues}
|
||||
leftInclusive={leftInclusive}
|
||||
rightInclusive={rightInclusive}
|
||||
onLeftInclusiveChange={setLeftInclusive}
|
||||
onRightInclusiveChange={setRightInclusive}
|
||||
/>
|
||||
<FilterPickerFooter
|
||||
isNew={isNew}
|
||||
@@ -125,117 +129,3 @@ export function CoordinateFilterPicker({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface CoordinateValueInputProps {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
column: Lib.ColumnMetadata;
|
||||
values: NumberOrEmptyValue[];
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
onChange: (values: NumberOrEmptyValue[]) => void;
|
||||
}
|
||||
|
||||
function CoordinateValueInput({
|
||||
query,
|
||||
stageIndex,
|
||||
column,
|
||||
values,
|
||||
valueCount,
|
||||
hasMultipleValues,
|
||||
onChange,
|
||||
}: CoordinateValueInputProps) {
|
||||
if (hasMultipleValues) {
|
||||
return (
|
||||
<Box p="md" mah="25vh" style={{ overflow: "auto" }}>
|
||||
<NumberFilterValuePicker
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={column}
|
||||
values={values.filter(isNotNull)}
|
||||
autoFocus
|
||||
comboboxProps={COMBOBOX_PROPS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 1) {
|
||||
return (
|
||||
<Flex p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Enter a number`}
|
||||
autoFocus
|
||||
w="100%"
|
||||
aria-label={t`Filter value`}
|
||||
onChange={(newValue) => onChange([newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 2) {
|
||||
return (
|
||||
<Flex align="center" justify="center" p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Min`}
|
||||
autoFocus
|
||||
onChange={(newValue) => onChange([newValue, values[1]])}
|
||||
/>
|
||||
<Text mx="sm">{t`and`}</Text>
|
||||
<NumberFilterInput
|
||||
value={values[1]}
|
||||
placeholder={t`Max`}
|
||||
onChange={(newValue) => onChange([values[0], newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 4) {
|
||||
return (
|
||||
<Stack align="center" justify="center" gap="sm" p="md">
|
||||
<NumberFilterInput
|
||||
label={t`Upper latitude`}
|
||||
value={values[0]}
|
||||
placeholder="90"
|
||||
autoFocus
|
||||
onChange={(newValue) =>
|
||||
onChange([newValue, values[1], values[2], values[3]])
|
||||
}
|
||||
/>
|
||||
<Flex align="center" justify="center" gap="sm">
|
||||
<NumberFilterInput
|
||||
label={t`Left longitude`}
|
||||
value={values[1]}
|
||||
placeholder="-180"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], newValue, values[2], values[3]])
|
||||
}
|
||||
/>
|
||||
<NumberFilterInput
|
||||
label={t`Right longitude`}
|
||||
value={values[3]}
|
||||
placeholder="180"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], values[1], values[2], newValue])
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
<NumberFilterInput
|
||||
label={t`Lower latitude`}
|
||||
value={values[2]}
|
||||
placeholder="-90"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], values[1], newValue, values[3]])
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { renderWithProviders, screen, within } from "__support__/ui";
|
||||
import { getIcon, renderWithProviders, screen, within } from "__support__/ui";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
@@ -30,16 +30,7 @@ const BETWEEN_TEST_CASES = [
|
||||
[-10, 10.5],
|
||||
];
|
||||
|
||||
const EXPECTED_OPERATORS = [
|
||||
"Is",
|
||||
"Is not",
|
||||
"Inside",
|
||||
"Between",
|
||||
"Greater than",
|
||||
"Greater than or equal to",
|
||||
"Less than",
|
||||
"Less than or equal to",
|
||||
];
|
||||
const EXPECTED_OPERATORS = ["Is", "Is not", "Inside", "Range"];
|
||||
|
||||
type SetupOpts = {
|
||||
query?: Lib.Query;
|
||||
@@ -117,9 +108,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByText("User → Latitude")).toBeInTheDocument();
|
||||
expect(screen.getByText("Between")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Min")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("Max")).toHaveValue("");
|
||||
expect(screen.getByText("Range")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Start of range")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("End of range")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Add filter" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -142,9 +133,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
async (_title, value) => {
|
||||
const { getNextFilterParts, getNextFilterColumnNames } = setup();
|
||||
|
||||
await setOperator("Less than");
|
||||
await setOperator("Range");
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Enter a number"),
|
||||
screen.getByPlaceholderText("End of range"),
|
||||
String(value),
|
||||
);
|
||||
await userEvent.click(screen.getByText("Add filter"));
|
||||
@@ -163,8 +154,8 @@ describe("CoordinateFilterPicker", () => {
|
||||
const { onChange, getNextFilterParts, getNextFilterColumnNames } =
|
||||
setup();
|
||||
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Range");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await userEvent.type(input, "{enter}");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
@@ -188,9 +179,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
name: "Add filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, String(leftValue));
|
||||
await userEvent.type(rightInput, String(rightValue));
|
||||
await userEvent.click(addFilterButton);
|
||||
@@ -211,9 +202,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
name: "Add filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, "5");
|
||||
await userEvent.type(rightInput, "-10.5");
|
||||
await userEvent.click(addFilterButton);
|
||||
@@ -231,9 +222,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
const { onChange, getNextFilterParts, getNextFilterColumnNames } =
|
||||
setup();
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, "5");
|
||||
await userEvent.type(rightInput, "-10.5{enter}");
|
||||
|
||||
@@ -333,7 +324,7 @@ describe("CoordinateFilterPicker", () => {
|
||||
it("should add a filter with many values", async () => {
|
||||
const { getNextFilterParts, getNextFilterColumnNames } = setup();
|
||||
|
||||
await userEvent.click(screen.getByText("Between"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
await userEvent.click(screen.getByText("Is"));
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await userEvent.type(input, "5");
|
||||
@@ -353,7 +344,7 @@ describe("CoordinateFilterPicker", () => {
|
||||
|
||||
it("should handle invalid input", async () => {
|
||||
setup();
|
||||
await userEvent.click(screen.getByText("Between"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
await userEvent.click(screen.getByText("Is"));
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Enter a number"),
|
||||
@@ -377,8 +368,8 @@ describe("CoordinateFilterPicker", () => {
|
||||
'should add a filter via the "$label" button when the add button is enabled',
|
||||
async ({ label, run }) => {
|
||||
const { getNextFilterChangeOpts } = setup({ withAddButton: true });
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Range");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await userEvent.type(input, "15");
|
||||
await userEvent.click(screen.getByRole("button", { name: label }));
|
||||
expect(getNextFilterChangeOpts()).toMatchObject({ run });
|
||||
@@ -398,7 +389,7 @@ describe("CoordinateFilterPicker", () => {
|
||||
setup(opts);
|
||||
|
||||
expect(screen.getByText("User → Latitude")).toBeInTheDocument();
|
||||
expect(screen.getByText("Greater than")).toBeInTheDocument();
|
||||
expect(screen.getByText("Range")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(String(value))).toBeInTheDocument();
|
||||
expect(screen.getByText("Update filter")).toBeEnabled();
|
||||
},
|
||||
@@ -413,10 +404,10 @@ describe("CoordinateFilterPicker", () => {
|
||||
});
|
||||
const { getNextFilterParts, getNextFilterColumnNames } = setup(opts);
|
||||
|
||||
await setOperator("Greater than");
|
||||
await userEvent.clear(screen.getByPlaceholderText("Enter a number"));
|
||||
await setOperator("Range");
|
||||
await userEvent.clear(screen.getByPlaceholderText("Start of range"));
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Enter a number"),
|
||||
screen.getByPlaceholderText("Start of range"),
|
||||
`${value}`,
|
||||
);
|
||||
await userEvent.click(screen.getByText("Update filter"));
|
||||
@@ -443,7 +434,7 @@ describe("CoordinateFilterPicker", () => {
|
||||
setup(opts);
|
||||
|
||||
expect(screen.getByText("User → Latitude")).toBeInTheDocument();
|
||||
expect(screen.getByText("Between")).toBeInTheDocument();
|
||||
expect(screen.getByText("Range")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue(String(leftValue)),
|
||||
).toBeInTheDocument();
|
||||
@@ -466,9 +457,9 @@ describe("CoordinateFilterPicker", () => {
|
||||
name: "Update filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.clear(leftInput);
|
||||
await userEvent.type(leftInput, `${leftValue}`);
|
||||
expect(updateButton).toBeEnabled();
|
||||
@@ -569,7 +560,7 @@ describe("CoordinateFilterPicker", () => {
|
||||
it("should list operators", async () => {
|
||||
setup(createQueryWithCoordinateFilter({ operator: "<" }));
|
||||
|
||||
await userEvent.click(screen.getByText("Less than"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
const menuItems = within(menu).getAllByRole("menuitem");
|
||||
|
||||
@@ -586,12 +577,12 @@ describe("CoordinateFilterPicker", () => {
|
||||
});
|
||||
const { getNextFilterParts, getNextFilterColumnNames } = setup(opts);
|
||||
|
||||
await setOperator("Greater than");
|
||||
await setOperator("Range");
|
||||
await userEvent.click(screen.getByText("Update filter"));
|
||||
|
||||
const filterParts = getNextFilterParts();
|
||||
expect(filterParts).toMatchObject({
|
||||
operator: ">",
|
||||
operator: "<=",
|
||||
values: [11],
|
||||
column: expect.anything(),
|
||||
});
|
||||
@@ -615,22 +606,16 @@ describe("CoordinateFilterPicker", () => {
|
||||
expect(screen.getByText("200")).toBeInTheDocument();
|
||||
expect(updateButton).toBeEnabled();
|
||||
|
||||
await setOperator("Between");
|
||||
await setOperator("Range");
|
||||
|
||||
expect(screen.getByDisplayValue("-100")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
|
||||
expect(updateButton).toBeEnabled();
|
||||
|
||||
await setOperator("Greater than");
|
||||
|
||||
expect(screen.getByDisplayValue("-100")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("200")).not.toBeInTheDocument();
|
||||
expect(updateButton).toBeEnabled();
|
||||
|
||||
await setOperator("Inside");
|
||||
|
||||
expect(screen.getByDisplayValue("-100")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("200")).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("200")).toBeInTheDocument();
|
||||
expect(updateButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -641,4 +626,38 @@ describe("CoordinateFilterPicker", () => {
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("between filter", () => {
|
||||
it("should change inclusiveness on click and keypress", async () => {
|
||||
setup(createQueryWithCoordinateFilter({ operator: "between" }));
|
||||
|
||||
const greaterButton = screen.getByRole("button", {
|
||||
name: /toggle greater inclusiveness/,
|
||||
});
|
||||
const lessButton = screen.getByRole("button", {
|
||||
name: /toggle less inclusiveness/,
|
||||
});
|
||||
|
||||
expect(greaterButton).toBeInTheDocument();
|
||||
expect(lessButton).toBeInTheDocument();
|
||||
|
||||
expect(getIcon("greater_than_or_equal")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(greaterButton);
|
||||
|
||||
expect(getIcon("greater_than")).toBeInTheDocument();
|
||||
|
||||
expect(getIcon("less_than_or_equal")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(lessButton);
|
||||
|
||||
expect(getIcon("less_than")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(lessButton);
|
||||
await userEvent.click(greaterButton);
|
||||
|
||||
expect(getIcon("greater_than_or_equal")).toBeInTheDocument();
|
||||
expect(getIcon("less_than_or_equal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import type { NumberOrEmptyValue } from "metabase/querying/filters/hooks/use-coordinate-filter";
|
||||
import { Box, Flex, Icon, Stack, Text } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
import { NumberFilterValuePicker } from "../../FilterValuePicker";
|
||||
import { NumberFilterInput } from "../../NumberFilterInput";
|
||||
import { COMBOBOX_PROPS } from "../constants";
|
||||
|
||||
import S from "./CoordinateFilterPicker.module.css";
|
||||
|
||||
interface CoordinateValueInputProps {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
column: Lib.ColumnMetadata;
|
||||
values: NumberOrEmptyValue[];
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
onChange: (values: NumberOrEmptyValue[]) => void;
|
||||
leftInclusive?: boolean;
|
||||
rightInclusive?: boolean;
|
||||
onLeftInclusiveChange: (value: boolean) => void;
|
||||
onRightInclusiveChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function CoordinateValueInput({
|
||||
query,
|
||||
stageIndex,
|
||||
column,
|
||||
values,
|
||||
valueCount,
|
||||
hasMultipleValues,
|
||||
onChange,
|
||||
leftInclusive = true,
|
||||
rightInclusive = true,
|
||||
onLeftInclusiveChange,
|
||||
onRightInclusiveChange,
|
||||
}: CoordinateValueInputProps) {
|
||||
if (hasMultipleValues) {
|
||||
return (
|
||||
<Box p="md" mah="25vh" style={{ overflow: "auto" }}>
|
||||
<NumberFilterValuePicker
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={column}
|
||||
values={values.filter(isNotNull)}
|
||||
autoFocus
|
||||
comboboxProps={COMBOBOX_PROPS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 1) {
|
||||
return (
|
||||
<Flex p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Enter a number`}
|
||||
autoFocus
|
||||
w="100%"
|
||||
aria-label={t`Filter value`}
|
||||
onChange={(newValue) => onChange([newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 2) {
|
||||
return (
|
||||
<Flex direction="column" p="md" gap="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
w="100%"
|
||||
placeholder={t`Start of range`}
|
||||
autoFocus
|
||||
onChange={(newValue) => onChange([newValue, values[1]])}
|
||||
leftSection={
|
||||
<ToggleButton
|
||||
aria-label="toggle greater inclusiveness"
|
||||
onChange={() => onLeftInclusiveChange(!leftInclusive)}
|
||||
>
|
||||
<Icon
|
||||
name={leftInclusive ? "greater_than_or_equal" : "greater_than"}
|
||||
/>
|
||||
</ToggleButton>
|
||||
}
|
||||
classNames={{
|
||||
root: S.root,
|
||||
input: S.input,
|
||||
}}
|
||||
/>
|
||||
<NumberFilterInput
|
||||
value={values[1]}
|
||||
placeholder={t`End of range`}
|
||||
onChange={(newValue) => onChange([values[0], newValue])}
|
||||
leftSection={
|
||||
<ToggleButton
|
||||
aria-label="toggle less inclusiveness"
|
||||
onChange={() => onRightInclusiveChange(!rightInclusive)}
|
||||
>
|
||||
<Icon
|
||||
name={rightInclusive ? "less_than_or_equal" : "less_than"}
|
||||
/>
|
||||
</ToggleButton>
|
||||
}
|
||||
classNames={{
|
||||
root: S.root,
|
||||
input: S.input,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" c="text-secondary">
|
||||
{t`You can leave one of these fields blank`}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 4) {
|
||||
return (
|
||||
<Stack align="center" justify="center" gap="sm" p="md">
|
||||
<NumberFilterInput
|
||||
label={t`Upper latitude`}
|
||||
value={values[0]}
|
||||
placeholder="90"
|
||||
autoFocus
|
||||
onChange={(newValue) =>
|
||||
onChange([newValue, values[1], values[2], values[3]])
|
||||
}
|
||||
/>
|
||||
<Flex align="center" justify="center" gap="sm">
|
||||
<NumberFilterInput
|
||||
label={t`Left longitude`}
|
||||
value={values[1]}
|
||||
placeholder="-180"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], newValue, values[2], values[3]])
|
||||
}
|
||||
/>
|
||||
<NumberFilterInput
|
||||
label={t`Right longitude`}
|
||||
value={values[3]}
|
||||
placeholder="180"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], values[1], values[2], newValue])
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
<NumberFilterInput
|
||||
label={t`Lower latitude`}
|
||||
value={values[2]}
|
||||
placeholder="-90"
|
||||
onChange={(newValue) =>
|
||||
onChange([values[0], values[1], newValue, values[3]])
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
children,
|
||||
onChange,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: React.ReactNode;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Flex
|
||||
className={S.toggleButton}
|
||||
component="span"
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
miw="40px"
|
||||
c="text-primary"
|
||||
fz="18px"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onChange}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === " ") {
|
||||
onChange();
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export { CoordinateValueInput };
|
||||
@@ -390,7 +390,7 @@ describe("FilterPicker", () => {
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Total"));
|
||||
await userEvent.click(screen.getByText("Between"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
await userEvent.click(screen.getByText("Equal to"));
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await userEvent.type(input, "100");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.FilterFooterRoot {
|
||||
&:not(:only-child) {
|
||||
&.separator:not(:only-child) {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import cx from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
@@ -11,6 +12,7 @@ interface FilterPickerFooterProps {
|
||||
isNew: boolean;
|
||||
isValid: boolean;
|
||||
withAddButton: boolean;
|
||||
withSeparator?: boolean;
|
||||
children?: ReactNode;
|
||||
onAddButtonClick: () => void;
|
||||
}
|
||||
@@ -19,11 +21,18 @@ export function FilterPickerFooter({
|
||||
isNew,
|
||||
isValid,
|
||||
withAddButton = false,
|
||||
withSeparator = true,
|
||||
children,
|
||||
onAddButtonClick,
|
||||
}: FilterPickerFooterProps) {
|
||||
return (
|
||||
<Flex className={S.FilterFooterRoot} p="md" justify="space-between">
|
||||
<Flex
|
||||
className={cx(S.FilterFooterRoot, {
|
||||
[S.separator]: withSeparator,
|
||||
})}
|
||||
p="md"
|
||||
justify="space-between"
|
||||
>
|
||||
{isValidElement(children) ? children : <Box />}
|
||||
<FilterSubmitButton
|
||||
isNew={isNew}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("MultiStageFilterPicker", () => {
|
||||
|
||||
await userEvent.click(screen.getByText("Summaries"));
|
||||
await userEvent.click(screen.getByText("Count"));
|
||||
await userEvent.type(screen.getByPlaceholderText("Min"), "10");
|
||||
await userEvent.type(screen.getByPlaceholderText("Start of range"), "10");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Apply filter" }));
|
||||
|
||||
const newQuery = getNewQuery();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.toggleButton {
|
||||
border-right: 1px solid var(--mb-color-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.root .input {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import {
|
||||
type NumberOrEmptyValue,
|
||||
type UiNumberFilterOperator,
|
||||
useNumberFilter,
|
||||
} from "metabase/querying/filters/hooks/use-number-filter";
|
||||
import { Box, Flex, Text } from "metabase/ui";
|
||||
import { Box } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import { NumberFilterValuePicker } from "../../FilterValuePicker";
|
||||
import { NumberFilterInput } from "../../NumberFilterInput";
|
||||
import { FilterOperatorPicker } from "../FilterOperatorPicker";
|
||||
import { FilterPickerFooter } from "../FilterPickerFooter";
|
||||
import { FilterPickerHeader } from "../FilterPickerHeader";
|
||||
import { COMBOBOX_PROPS, WIDTH } from "../constants";
|
||||
import { WIDTH } from "../constants";
|
||||
import type { FilterChangeOpts, FilterPickerWidgetProps } from "../types";
|
||||
|
||||
import { NumberValueInput } from "./NumberValueInput";
|
||||
|
||||
export function NumberFilterPicker({
|
||||
query,
|
||||
stageIndex,
|
||||
@@ -51,9 +48,16 @@ export function NumberFilterPicker({
|
||||
filter,
|
||||
});
|
||||
|
||||
const handleOperatorChange = (newOperator: Lib.NumberFilterOperator) => {
|
||||
const [leftInclusive, setLeftInclusive] = useState(true);
|
||||
const [rightInclusive, setRightInclusive] = useState(true);
|
||||
|
||||
const handleOperatorChange = (newOperator: UiNumberFilterOperator) => {
|
||||
setOperator(newOperator);
|
||||
setValues(getDefaultValues(newOperator, values));
|
||||
if (newOperator === "between") {
|
||||
setLeftInclusive(true);
|
||||
setRightInclusive(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (opts: FilterChangeOpts) => {
|
||||
@@ -63,7 +67,7 @@ export function NumberFilterPicker({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event: FormEvent) => {
|
||||
const handleFormSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
handleFilterChange({ run: true });
|
||||
};
|
||||
@@ -98,10 +102,15 @@ export function NumberFilterPicker({
|
||||
valueCount={valueCount}
|
||||
hasMultipleValues={hasMultipleValues}
|
||||
onChange={setValues}
|
||||
leftInclusive={leftInclusive}
|
||||
rightInclusive={rightInclusive}
|
||||
onLeftInclusiveChange={setLeftInclusive}
|
||||
onRightInclusiveChange={setRightInclusive}
|
||||
/>
|
||||
<FilterPickerFooter
|
||||
isNew={isNew}
|
||||
isValid={isValid}
|
||||
withSeparator={false}
|
||||
withAddButton={withAddButton}
|
||||
onAddButtonClick={handleAddButtonClick}
|
||||
/>
|
||||
@@ -109,75 +118,3 @@ export function NumberFilterPicker({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberValueInputProps {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
column: Lib.ColumnMetadata;
|
||||
values: NumberOrEmptyValue[];
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
onChange: (values: NumberOrEmptyValue[]) => void;
|
||||
}
|
||||
|
||||
function NumberValueInput({
|
||||
query,
|
||||
stageIndex,
|
||||
column,
|
||||
values,
|
||||
valueCount,
|
||||
hasMultipleValues,
|
||||
onChange,
|
||||
}: NumberValueInputProps) {
|
||||
if (hasMultipleValues) {
|
||||
return (
|
||||
<Box p="md" mah="25vh" style={{ overflow: "auto" }}>
|
||||
<NumberFilterValuePicker
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={column}
|
||||
values={values.filter(isNotNull)}
|
||||
autoFocus
|
||||
comboboxProps={COMBOBOX_PROPS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 1) {
|
||||
return (
|
||||
<Flex p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Enter a number`}
|
||||
autoFocus
|
||||
w="100%"
|
||||
aria-label={t`Filter value`}
|
||||
onChange={(newValue) => onChange([newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 2) {
|
||||
return (
|
||||
<Flex align="center" justify="center" p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Min`}
|
||||
autoFocus
|
||||
onChange={(newValue) => onChange([newValue, values[1]])}
|
||||
/>
|
||||
<Text mx="sm">{t`and`}</Text>
|
||||
<NumberFilterInput
|
||||
value={values[1]}
|
||||
placeholder={t`Max`}
|
||||
onChange={(newValue) => onChange([values[0], newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { renderWithProviders, screen, within } from "__support__/ui";
|
||||
import { getIcon, renderWithProviders, screen, within } from "__support__/ui";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { columnFinder } from "metabase-lib/test-helpers";
|
||||
@@ -33,11 +33,7 @@ const BETWEEN_TEST_CASES = [
|
||||
const EXPECTED_OPERATORS = [
|
||||
"Equal to",
|
||||
"Not equal to",
|
||||
"Between",
|
||||
"Greater than",
|
||||
"Greater than or equal to",
|
||||
"Less than",
|
||||
"Less than or equal to",
|
||||
"Range",
|
||||
"Is empty",
|
||||
"Not empty",
|
||||
];
|
||||
@@ -112,9 +108,9 @@ describe("NumberFilterPicker", () => {
|
||||
setup();
|
||||
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("Between")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Min")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("Max")).toHaveValue("");
|
||||
expect(screen.getByText("Range")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Start of range")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("End of range")).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: "Add filter" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -137,9 +133,9 @@ describe("NumberFilterPicker", () => {
|
||||
async (_title, value) => {
|
||||
const { getNextFilterParts, getNextFilterColumnName } = setup();
|
||||
|
||||
await setOperator("Greater than");
|
||||
await setOperator("Range");
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Enter a number"),
|
||||
screen.getByPlaceholderText("Start of range"),
|
||||
String(value),
|
||||
);
|
||||
await userEvent.click(screen.getByText("Add filter"));
|
||||
@@ -158,8 +154,8 @@ describe("NumberFilterPicker", () => {
|
||||
const { onChange, getNextFilterParts, getNextFilterColumnName } =
|
||||
setup();
|
||||
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Range");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await userEvent.type(input, "{enter}");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
@@ -182,8 +178,8 @@ describe("NumberFilterPicker", () => {
|
||||
{ query, column },
|
||||
);
|
||||
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Range");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await userEvent.type(input, "9007199254740993{enter}");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(getNextFilterParts()).toMatchObject({
|
||||
@@ -204,9 +200,9 @@ describe("NumberFilterPicker", () => {
|
||||
name: "Add filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, String(leftValue));
|
||||
await userEvent.type(rightInput, String(rightValue));
|
||||
await userEvent.click(addFilterButton);
|
||||
@@ -227,9 +223,9 @@ describe("NumberFilterPicker", () => {
|
||||
name: "Add filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, "5");
|
||||
await userEvent.type(rightInput, "-10.5");
|
||||
await userEvent.click(addFilterButton);
|
||||
@@ -247,9 +243,9 @@ describe("NumberFilterPicker", () => {
|
||||
const { onChange, getNextFilterParts, getNextFilterColumnName } =
|
||||
setup();
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.type(leftInput, "5");
|
||||
await userEvent.type(rightInput, "-10.5{enter}");
|
||||
|
||||
@@ -267,7 +263,7 @@ describe("NumberFilterPicker", () => {
|
||||
it("should add a filter with many values", async () => {
|
||||
const { getNextFilterParts, getNextFilterColumnName } = setup();
|
||||
|
||||
await userEvent.click(screen.getByText("Between"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
await userEvent.click(screen.getByText("Equal to"));
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await userEvent.type(input, "-5");
|
||||
@@ -305,7 +301,7 @@ describe("NumberFilterPicker", () => {
|
||||
it("should handle invalid input", async () => {
|
||||
setup();
|
||||
|
||||
await userEvent.click(screen.getByText("Between"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
await userEvent.click(screen.getByText("Equal to"));
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("Enter a number"),
|
||||
@@ -329,8 +325,8 @@ describe("NumberFilterPicker", () => {
|
||||
'should add a filter via the "$label" button when the add button is enabled',
|
||||
async ({ label, run }) => {
|
||||
const { getNextFilterChangeOpts } = setup({ withAddButton: true });
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Range");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await userEvent.type(input, "15");
|
||||
await userEvent.click(screen.getByRole("button", { name: label }));
|
||||
expect(getNextFilterChangeOpts()).toMatchObject({ run });
|
||||
@@ -351,7 +347,6 @@ describe("NumberFilterPicker", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("Greater than")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(String(value))).toBeInTheDocument();
|
||||
expect(screen.getByText("Update filter")).toBeEnabled();
|
||||
},
|
||||
@@ -367,8 +362,8 @@ describe("NumberFilterPicker", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Enter a number");
|
||||
await setOperator("Greater than");
|
||||
const input = screen.getByPlaceholderText("Start of range");
|
||||
await setOperator("Range");
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, `${value}`);
|
||||
await userEvent.click(screen.getByText("Update filter"));
|
||||
@@ -396,7 +391,7 @@ describe("NumberFilterPicker", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("Between")).toBeInTheDocument();
|
||||
expect(screen.getByText("Range")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue(String(leftValue)),
|
||||
).toBeInTheDocument();
|
||||
@@ -420,9 +415,9 @@ describe("NumberFilterPicker", () => {
|
||||
name: "Update filter",
|
||||
});
|
||||
|
||||
await setOperator("Between");
|
||||
const leftInput = screen.getByPlaceholderText("Min");
|
||||
const rightInput = screen.getByPlaceholderText("Max");
|
||||
await setOperator("Range");
|
||||
const leftInput = screen.getByPlaceholderText("Start of range");
|
||||
const rightInput = screen.getByPlaceholderText("End of range");
|
||||
await userEvent.clear(leftInput);
|
||||
await userEvent.type(leftInput, `${leftValue}`);
|
||||
expect(updateButton).toBeEnabled();
|
||||
@@ -493,7 +488,7 @@ describe("NumberFilterPicker", () => {
|
||||
it("should list operators", async () => {
|
||||
setup(createQueryWithNumberFilter({ operator: "<" }));
|
||||
|
||||
await userEvent.click(screen.getByText("Less than"));
|
||||
await userEvent.click(screen.getByText("Range"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
const menuItems = within(menu).getAllByRole("menuitem");
|
||||
|
||||
@@ -511,12 +506,12 @@ describe("NumberFilterPicker", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await setOperator("Greater than");
|
||||
await setOperator("Range");
|
||||
await userEvent.click(screen.getByText("Update filter"));
|
||||
|
||||
const filterParts = getNextFilterParts();
|
||||
expect(filterParts).toMatchObject({
|
||||
operator: ">",
|
||||
operator: "<=",
|
||||
column: expect.anything(),
|
||||
values: [11],
|
||||
});
|
||||
@@ -538,7 +533,7 @@ describe("NumberFilterPicker", () => {
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(updateButton).toBeEnabled();
|
||||
|
||||
await setOperator("Greater than");
|
||||
await setOperator("Range");
|
||||
|
||||
expect(screen.getByDisplayValue("10")).toBeInTheDocument();
|
||||
expect(screen.queryByText("20")).not.toBeInTheDocument();
|
||||
@@ -564,4 +559,38 @@ describe("NumberFilterPicker", () => {
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("between filter", () => {
|
||||
it("should change inclusiveness on click and keypress", async () => {
|
||||
setup(createQueryWithNumberFilter({ operator: "between" }));
|
||||
|
||||
const greaterButton = screen.getByRole("button", {
|
||||
name: /toggle greater inclusiveness/,
|
||||
});
|
||||
const lessButton = screen.getByRole("button", {
|
||||
name: /toggle less inclusiveness/,
|
||||
});
|
||||
|
||||
expect(greaterButton).toBeInTheDocument();
|
||||
expect(lessButton).toBeInTheDocument();
|
||||
|
||||
expect(getIcon("greater_than_or_equal")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(greaterButton);
|
||||
|
||||
expect(getIcon("greater_than")).toBeInTheDocument();
|
||||
|
||||
expect(getIcon("less_than_or_equal")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(lessButton);
|
||||
|
||||
expect(getIcon("less_than")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(lessButton);
|
||||
await userEvent.click(greaterButton);
|
||||
|
||||
expect(getIcon("greater_than_or_equal")).toBeInTheDocument();
|
||||
expect(getIcon("less_than_or_equal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import type { NumberOrEmptyValue } from "metabase/querying/filters/hooks/use-number-filter";
|
||||
import { Box, Flex, Icon, Text } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
import { NumberFilterValuePicker } from "../../FilterValuePicker";
|
||||
import { NumberFilterInput } from "../../NumberFilterInput";
|
||||
import { COMBOBOX_PROPS } from "../constants";
|
||||
|
||||
import S from "./NumberFilterPicker.module.css";
|
||||
|
||||
interface NumberValueInputProps {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
column: Lib.ColumnMetadata;
|
||||
values: NumberOrEmptyValue[];
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
onChange: (values: NumberOrEmptyValue[]) => void;
|
||||
leftInclusive?: boolean;
|
||||
rightInclusive?: boolean;
|
||||
onLeftInclusiveChange: (value: boolean) => void;
|
||||
onRightInclusiveChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function NumberValueInput({
|
||||
query,
|
||||
stageIndex,
|
||||
column,
|
||||
values,
|
||||
valueCount,
|
||||
hasMultipleValues,
|
||||
onChange,
|
||||
leftInclusive = true,
|
||||
rightInclusive = true,
|
||||
onLeftInclusiveChange,
|
||||
onRightInclusiveChange,
|
||||
}: NumberValueInputProps) {
|
||||
if (hasMultipleValues) {
|
||||
return (
|
||||
<Box p="md" mah="25vh" style={{ overflow: "auto" }}>
|
||||
<NumberFilterValuePicker
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={column}
|
||||
values={values.filter(isNotNull)}
|
||||
autoFocus
|
||||
comboboxProps={COMBOBOX_PROPS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 1) {
|
||||
return (
|
||||
<Flex p="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
placeholder={t`Enter a number`}
|
||||
autoFocus
|
||||
w="100%"
|
||||
aria-label={t`Filter value`}
|
||||
onChange={(newValue) => onChange([newValue])}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueCount === 2) {
|
||||
return (
|
||||
<Flex direction="column" p="md" gap="md">
|
||||
<NumberFilterInput
|
||||
value={values[0]}
|
||||
w="100%"
|
||||
placeholder={t`Start of range`}
|
||||
aria-label={t`Start of range`}
|
||||
autoFocus
|
||||
onChange={(newValue) => onChange([newValue, values[1]])}
|
||||
leftSection={
|
||||
<ToggleButton
|
||||
aria-label="toggle greater inclusiveness"
|
||||
onChange={() => onLeftInclusiveChange(!leftInclusive)}
|
||||
>
|
||||
<Icon
|
||||
name={leftInclusive ? "greater_than_or_equal" : "greater_than"}
|
||||
/>
|
||||
</ToggleButton>
|
||||
}
|
||||
classNames={{
|
||||
root: S.root,
|
||||
input: S.input,
|
||||
}}
|
||||
/>
|
||||
<NumberFilterInput
|
||||
value={values[1]}
|
||||
placeholder={t`End of range`}
|
||||
aria-label={t`End of range`}
|
||||
onChange={(newValue) => onChange([values[0], newValue])}
|
||||
leftSection={
|
||||
<ToggleButton
|
||||
aria-label="toggle less inclusiveness"
|
||||
onChange={() => onRightInclusiveChange(!rightInclusive)}
|
||||
>
|
||||
<Icon
|
||||
name={rightInclusive ? "less_than_or_equal" : "less_than"}
|
||||
/>
|
||||
</ToggleButton>
|
||||
}
|
||||
classNames={{
|
||||
root: S.root,
|
||||
input: S.input,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" c="text-secondary">
|
||||
{t`You can leave one of these fields blank`}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
children,
|
||||
onChange,
|
||||
...rest
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: React.ReactNode;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Flex
|
||||
className={S.toggleButton}
|
||||
component="span"
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
miw="40px"
|
||||
c="text-primary"
|
||||
fz="18px"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onChange}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === " ") {
|
||||
onChange();
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export { NumberValueInput };
|
||||
@@ -1,9 +1,7 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
import type { OperatorOption } from "./types";
|
||||
import type { OperatorOption, UiCoordinateFilterOperator } from "./types";
|
||||
|
||||
export const OPERATOR_OPTIONS: Record<
|
||||
Lib.CoordinateFilterOperator,
|
||||
UiCoordinateFilterOperator,
|
||||
OperatorOption
|
||||
> = {
|
||||
"=": {
|
||||
@@ -20,24 +18,9 @@ export const OPERATOR_OPTIONS: Record<
|
||||
operator: "inside",
|
||||
valueCount: 4,
|
||||
},
|
||||
">": {
|
||||
operator: ">",
|
||||
valueCount: 1,
|
||||
},
|
||||
"<": {
|
||||
operator: "<",
|
||||
valueCount: 1,
|
||||
},
|
||||
between: {
|
||||
operator: "between",
|
||||
valueCount: 2,
|
||||
},
|
||||
">=": {
|
||||
operator: ">=",
|
||||
valueCount: 1,
|
||||
},
|
||||
"<=": {
|
||||
operator: "<=",
|
||||
valueCount: 1,
|
||||
name: "Range",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { FilterOperatorOption } from "metabase/querying/filters/types";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
type CoordinatePickerOperator =
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| "<"
|
||||
| "between"
|
||||
| "inside"
|
||||
| ">="
|
||||
| "<=";
|
||||
|
||||
export interface OperatorOption
|
||||
extends FilterOperatorOption<CoordinatePickerOperator> {
|
||||
extends FilterOperatorOption<UiCoordinateFilterOperator> {
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
}
|
||||
|
||||
export type NumberOrEmptyValue = Lib.NumberFilterValue | null;
|
||||
|
||||
export type UiCoordinateFilterOperator = Exclude<
|
||||
Lib.CoordinateFilterOperator,
|
||||
">" | "<" | "<=" | ">="
|
||||
>;
|
||||
|
||||
export type UiCoordinateFilterParts = Omit<
|
||||
Lib.CoordinateFilterParts,
|
||||
"values" | "operator"
|
||||
> & {
|
||||
values: NumberOrEmptyValue[];
|
||||
operator: UiCoordinateFilterOperator;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import type { NumberOrEmptyValue } from "./types";
|
||||
import type { NumberOrEmptyValue, UiCoordinateFilterOperator } from "./types";
|
||||
import {
|
||||
canPickColumns,
|
||||
getAvailableColumns,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getFilterClause,
|
||||
getOptionByOperator,
|
||||
isValidFilter,
|
||||
normalizeCoordinateFilterParts,
|
||||
} from "./utils";
|
||||
|
||||
interface UseCoordinateFilterProps {
|
||||
@@ -28,11 +29,21 @@ export function useCoordinateFilter({
|
||||
column,
|
||||
filter,
|
||||
}: UseCoordinateFilterProps) {
|
||||
const filterParts = useMemo(
|
||||
() =>
|
||||
filter ? Lib.coordinateFilterParts(query, stageIndex, filter) : null,
|
||||
[query, stageIndex, filter],
|
||||
);
|
||||
const filterParts = useMemo(() => {
|
||||
if (!filter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filterParts = Lib.coordinateFilterParts(query, stageIndex, filter);
|
||||
|
||||
if (!filterParts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedFilterParts = normalizeCoordinateFilterParts(filterParts);
|
||||
|
||||
return normalizedFilterParts;
|
||||
}, [query, stageIndex, filter]);
|
||||
|
||||
const availableOptions = useMemo(
|
||||
() => getAvailableOptions(query, stageIndex, column),
|
||||
@@ -69,7 +80,7 @@ export function useCoordinateFilter({
|
||||
isValid,
|
||||
getDefaultValues,
|
||||
getFilterClause: (
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
secondColumn: Lib.ColumnMetadata | undefined,
|
||||
values: NumberOrEmptyValue[],
|
||||
) => getFilterClause(operator, column, secondColumn, values),
|
||||
|
||||
@@ -5,30 +5,30 @@ import * as Lib from "metabase-lib";
|
||||
import { columnFinder, createQuery } from "metabase-lib/test-helpers";
|
||||
import { PEOPLE_ID } from "metabase-types/api/mocks/presets";
|
||||
|
||||
import type { NumberOrEmptyValue } from "./types";
|
||||
import type { NumberOrEmptyValue, UiCoordinateFilterOperator } from "./types";
|
||||
import { useCoordinateFilter } from "./use-coordinate-filter";
|
||||
|
||||
interface CreateFilterCase {
|
||||
operator: Lib.CoordinateFilterOperator;
|
||||
operator: UiCoordinateFilterOperator;
|
||||
values: number[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface UpdateFilterCase {
|
||||
operator: Lib.CoordinateFilterOperator;
|
||||
operator: UiCoordinateFilterOperator;
|
||||
expression: Lib.ExpressionClause;
|
||||
values: number[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface CoerceFilterCase {
|
||||
operator: Lib.CoordinateFilterOperator;
|
||||
operator: UiCoordinateFilterOperator;
|
||||
values: NumberOrEmptyValue[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface ValidateFilterCase {
|
||||
operator: Lib.CoordinateFilterOperator;
|
||||
operator: UiCoordinateFilterOperator;
|
||||
values: NumberOrEmptyValue[];
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ describe("useCoordinateFilter", () => {
|
||||
expectedDisplayName: "Latitude is not equal to 10",
|
||||
},
|
||||
{
|
||||
operator: ">",
|
||||
operator: "between",
|
||||
values: [10],
|
||||
expectedDisplayName: "Latitude is greater than 10",
|
||||
expectedDisplayName: "Latitude is greater than or equal to 10",
|
||||
},
|
||||
])(
|
||||
'should allow to create a filter for "$operator" operator',
|
||||
@@ -183,7 +183,7 @@ describe("useCoordinateFilter", () => {
|
||||
values: [],
|
||||
},
|
||||
{
|
||||
operator: ">",
|
||||
operator: "!=",
|
||||
values: [null],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import { OPERATOR_OPTIONS } from "./constants";
|
||||
import type { NumberOrEmptyValue, OperatorOption } from "./types";
|
||||
import type {
|
||||
NumberOrEmptyValue,
|
||||
OperatorOption,
|
||||
UiCoordinateFilterOperator,
|
||||
UiCoordinateFilterParts,
|
||||
} from "./types";
|
||||
|
||||
export function getAvailableOptions(
|
||||
query: Lib.Query,
|
||||
@@ -21,13 +26,13 @@ export function getAvailableOptions(
|
||||
);
|
||||
}
|
||||
|
||||
export function getOptionByOperator(operator: Lib.CoordinateFilterOperator) {
|
||||
export function getOptionByOperator(operator: UiCoordinateFilterOperator) {
|
||||
return OPERATOR_OPTIONS[operator];
|
||||
}
|
||||
|
||||
export function getDefaultOperator(
|
||||
availableOptions: OperatorOption[],
|
||||
): Lib.CoordinateFilterOperator {
|
||||
): UiCoordinateFilterOperator {
|
||||
return getDefaultAvailableOperator(availableOptions, "between");
|
||||
}
|
||||
|
||||
@@ -47,7 +52,7 @@ export function getAvailableColumns(
|
||||
|
||||
export function getDefaultSecondColumn(
|
||||
columns: Lib.ColumnMetadata[],
|
||||
filterParts: Lib.CoordinateFilterParts | null,
|
||||
filterParts: UiCoordinateFilterParts | null,
|
||||
): Lib.ColumnMetadata | undefined {
|
||||
return filterParts?.longitudeColumn ?? columns[0];
|
||||
}
|
||||
@@ -60,7 +65,7 @@ export function canPickColumns(
|
||||
}
|
||||
|
||||
export function getDefaultValues(
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
values: NumberOrEmptyValue[],
|
||||
): NumberOrEmptyValue[] {
|
||||
const { valueCount, hasMultipleValues } = OPERATOR_OPTIONS[operator];
|
||||
@@ -74,7 +79,7 @@ export function getDefaultValues(
|
||||
}
|
||||
|
||||
export function isValidFilter(
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
secondColumn: Lib.ColumnMetadata | undefined,
|
||||
values: NumberOrEmptyValue[],
|
||||
@@ -83,7 +88,7 @@ export function isValidFilter(
|
||||
}
|
||||
|
||||
export function getFilterClause(
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
secondColumn: Lib.ColumnMetadata | undefined,
|
||||
values: NumberOrEmptyValue[],
|
||||
@@ -95,7 +100,7 @@ export function getFilterClause(
|
||||
}
|
||||
|
||||
function getFilterParts(
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
secondColumn: Lib.ColumnMetadata | undefined,
|
||||
values: NumberOrEmptyValue[],
|
||||
@@ -111,7 +116,7 @@ function getFilterParts(
|
||||
}
|
||||
|
||||
function getSimpleFilterParts(
|
||||
operator: Lib.CoordinateFilterOperator,
|
||||
operator: UiCoordinateFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
values: NumberOrEmptyValue[],
|
||||
): Lib.CoordinateFilterParts | undefined {
|
||||
@@ -194,3 +199,69 @@ function getInsideFilterParts(
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCoordinateFilterParts({
|
||||
operator,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values,
|
||||
}: Lib.CoordinateFilterParts) {
|
||||
if (operator === ">") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values: [values[0], null],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === "<") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values: [null, values[0]],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === "<=") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values: [null, values[0]],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === ">=") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values: [values[0], null],
|
||||
options: {
|
||||
minInclusive: true,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
operator,
|
||||
column,
|
||||
longitudeColumn,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,47 +1,28 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { OperatorOption, UiNumberFilterOperator } from "./types";
|
||||
|
||||
import type { OperatorOption } from "./types";
|
||||
|
||||
export const OPERATOR_OPTIONS: Record<
|
||||
Lib.NumberFilterOperator,
|
||||
OperatorOption
|
||||
> = {
|
||||
"=": {
|
||||
operator: "=",
|
||||
valueCount: 1,
|
||||
hasMultipleValues: true,
|
||||
},
|
||||
"!=": {
|
||||
operator: "!=",
|
||||
valueCount: 1,
|
||||
hasMultipleValues: true,
|
||||
},
|
||||
">": {
|
||||
operator: ">",
|
||||
valueCount: 1,
|
||||
},
|
||||
"<": {
|
||||
operator: "<",
|
||||
valueCount: 1,
|
||||
},
|
||||
between: {
|
||||
operator: "between",
|
||||
valueCount: 2,
|
||||
},
|
||||
">=": {
|
||||
operator: ">=",
|
||||
valueCount: 1,
|
||||
},
|
||||
"<=": {
|
||||
operator: "<=",
|
||||
valueCount: 1,
|
||||
},
|
||||
"is-null": {
|
||||
operator: "is-null",
|
||||
valueCount: 0,
|
||||
},
|
||||
"not-null": {
|
||||
operator: "not-null",
|
||||
valueCount: 0,
|
||||
},
|
||||
};
|
||||
export const OPERATOR_OPTIONS: Record<UiNumberFilterOperator, OperatorOption> =
|
||||
{
|
||||
"=": {
|
||||
operator: "=",
|
||||
valueCount: 1,
|
||||
hasMultipleValues: true,
|
||||
},
|
||||
"!=": {
|
||||
operator: "!=",
|
||||
valueCount: 1,
|
||||
hasMultipleValues: true,
|
||||
},
|
||||
between: {
|
||||
operator: "between",
|
||||
valueCount: 2,
|
||||
name: "Range",
|
||||
},
|
||||
"is-null": {
|
||||
operator: "is-null",
|
||||
valueCount: 0,
|
||||
},
|
||||
"not-null": {
|
||||
operator: "not-null",
|
||||
valueCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { FilterOperatorOption } from "metabase/querying/filters/types";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
export interface OperatorOption
|
||||
extends FilterOperatorOption<Lib.NumberFilterOperator> {
|
||||
extends FilterOperatorOption<UiNumberFilterOperator> {
|
||||
valueCount: number;
|
||||
hasMultipleValues?: boolean;
|
||||
}
|
||||
|
||||
export type NumberOrEmptyValue = Lib.NumberFilterValue | null;
|
||||
|
||||
export type UiNumberFilterOperator = Exclude<
|
||||
Lib.NumberFilterOperator,
|
||||
">" | "<" | "<=" | ">="
|
||||
>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import type { NumberOrEmptyValue } from "./types";
|
||||
import type { NumberOrEmptyValue, UiNumberFilterOperator } from "./types";
|
||||
import {
|
||||
getAvailableOptions,
|
||||
getDefaultOperator,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getFilterClause,
|
||||
getOptionByOperator,
|
||||
isValidFilter,
|
||||
normalizeNumberFilterParts,
|
||||
} from "./utils";
|
||||
|
||||
interface UseNumberFilterProps {
|
||||
@@ -25,10 +26,20 @@ export function useNumberFilter({
|
||||
column,
|
||||
filter,
|
||||
}: UseNumberFilterProps) {
|
||||
const filterParts = useMemo(
|
||||
() => (filter ? Lib.numberFilterParts(query, stageIndex, filter) : null),
|
||||
[query, stageIndex, filter],
|
||||
);
|
||||
const filterParts = useMemo(() => {
|
||||
if (!filter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filterParts = Lib.numberFilterParts(query, stageIndex, filter);
|
||||
if (!filterParts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sugaredFilterParts = normalizeNumberFilterParts(filterParts);
|
||||
|
||||
return sugaredFilterParts;
|
||||
}, [query, stageIndex, filter]);
|
||||
|
||||
const availableOptions = useMemo(
|
||||
() => getAvailableOptions(query, stageIndex, column),
|
||||
@@ -57,7 +68,7 @@ export function useNumberFilter({
|
||||
isValid,
|
||||
getDefaultValues,
|
||||
getFilterClause: (
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
values: NumberOrEmptyValue[],
|
||||
) => getFilterClause(operator, column, values),
|
||||
setOperator,
|
||||
|
||||
@@ -14,37 +14,37 @@ import {
|
||||
createSampleDatabase,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
|
||||
import type { NumberOrEmptyValue } from "./types";
|
||||
import type { NumberOrEmptyValue, UiNumberFilterOperator } from "./types";
|
||||
import { useNumberFilter } from "./use-number-filter";
|
||||
|
||||
interface CreateFilterCase {
|
||||
operator: Lib.NumberFilterOperator;
|
||||
operator: UiNumberFilterOperator;
|
||||
values: number[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface UpdateFilterCase {
|
||||
operator: Lib.NumberFilterOperator;
|
||||
operator: UiNumberFilterOperator;
|
||||
expression: Lib.ExpressionClause;
|
||||
values: number[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface CoerceFilterCase {
|
||||
operator: Lib.NumberFilterOperator;
|
||||
operator: UiNumberFilterOperator;
|
||||
values: NumberOrEmptyValue[];
|
||||
expectedDisplayName: string;
|
||||
}
|
||||
|
||||
interface ValidateFilterCase {
|
||||
operator: Lib.NumberFilterOperator;
|
||||
operator: UiNumberFilterOperator;
|
||||
values: NumberOrEmptyValue[];
|
||||
}
|
||||
|
||||
interface DefaultOperatorCase {
|
||||
title: string;
|
||||
column: Lib.ColumnMetadata;
|
||||
expectedOperator: Lib.NumberFilterOperator;
|
||||
expectedOperator: UiNumberFilterOperator;
|
||||
}
|
||||
|
||||
const METADATA = createMockMetadata({
|
||||
@@ -86,7 +86,7 @@ describe("useNumberFilter", () => {
|
||||
expectedDisplayName: "Total is not equal to 10",
|
||||
},
|
||||
{
|
||||
operator: ">",
|
||||
operator: "between",
|
||||
values: [10],
|
||||
expectedDisplayName: "Total is greater than 10",
|
||||
},
|
||||
@@ -199,7 +199,7 @@ describe("useNumberFilter", () => {
|
||||
values: [],
|
||||
},
|
||||
{
|
||||
operator: ">",
|
||||
operator: "between",
|
||||
values: [null],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
import * as Lib from "metabase-lib";
|
||||
|
||||
import { OPERATOR_OPTIONS } from "./constants";
|
||||
import type { NumberOrEmptyValue, OperatorOption } from "./types";
|
||||
import type {
|
||||
NumberOrEmptyValue,
|
||||
OperatorOption,
|
||||
UiNumberFilterOperator,
|
||||
} from "./types";
|
||||
|
||||
export function getAvailableOptions(
|
||||
query: Lib.Query,
|
||||
@@ -21,7 +25,7 @@ export function getAvailableOptions(
|
||||
);
|
||||
}
|
||||
|
||||
export function getOptionByOperator(operator: Lib.NumberFilterOperator) {
|
||||
export function getOptionByOperator(operator: UiNumberFilterOperator) {
|
||||
return OPERATOR_OPTIONS[operator];
|
||||
}
|
||||
|
||||
@@ -29,7 +33,7 @@ export function getDefaultOperator(
|
||||
query: Lib.Query,
|
||||
column: Lib.ColumnMetadata,
|
||||
availableOptions: OperatorOption[],
|
||||
): Lib.NumberFilterOperator {
|
||||
): UiNumberFilterOperator {
|
||||
const fieldValuesInfo = Lib.fieldValuesSearchInfo(query, column);
|
||||
|
||||
const desiredOperator =
|
||||
@@ -38,11 +42,12 @@ export function getDefaultOperator(
|
||||
fieldValuesInfo.hasFieldValues !== "none"
|
||||
? "="
|
||||
: "between";
|
||||
|
||||
return getDefaultAvailableOperator(availableOptions, desiredOperator);
|
||||
}
|
||||
|
||||
export function getDefaultValues(
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
values: NumberOrEmptyValue[],
|
||||
): NumberOrEmptyValue[] {
|
||||
const { valueCount, hasMultipleValues } = OPERATOR_OPTIONS[operator];
|
||||
@@ -56,7 +61,7 @@ export function getDefaultValues(
|
||||
}
|
||||
|
||||
export function isValidFilter(
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
values: NumberOrEmptyValue[],
|
||||
) {
|
||||
@@ -64,7 +69,7 @@ export function isValidFilter(
|
||||
}
|
||||
|
||||
export function getFilterClause(
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
values: NumberOrEmptyValue[],
|
||||
) {
|
||||
@@ -73,7 +78,7 @@ export function getFilterClause(
|
||||
}
|
||||
|
||||
function getFilterParts(
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
values: NumberOrEmptyValue[],
|
||||
): Lib.NumberFilterParts | undefined {
|
||||
@@ -86,7 +91,7 @@ function getFilterParts(
|
||||
}
|
||||
|
||||
function getSimpleFilterParts(
|
||||
operator: Lib.NumberFilterOperator,
|
||||
operator: UiNumberFilterOperator,
|
||||
column: Lib.ColumnMetadata,
|
||||
values: NumberOrEmptyValue[],
|
||||
): Lib.NumberFilterParts | undefined {
|
||||
@@ -134,3 +139,63 @@ function getBetweenFilterParts(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNumberFilterParts({
|
||||
operator,
|
||||
column,
|
||||
values,
|
||||
}: Lib.NumberFilterParts) {
|
||||
if (operator === ">") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
values: [values[0], null],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === "<") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
values: [null, values[0]],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === "<=") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
values: [null, values[0]],
|
||||
options: {
|
||||
minInclusive: false,
|
||||
maxInclusive: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === ">=") {
|
||||
return {
|
||||
operator: "between" as const,
|
||||
column,
|
||||
values: [values[0], null],
|
||||
options: {
|
||||
minInclusive: true,
|
||||
maxInclusive: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
operator,
|
||||
column,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface GroupItem {
|
||||
|
||||
export type FilterOperatorOption<T extends Lib.FilterOperatorName> = {
|
||||
operator: T;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type DatePickerOperator =
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.85105 12.152C4.66971 12.2267 4.50438 12.2427 4.35505 12.2C4.21638 12.1573 4.10438 12.0773 4.01905 11.96C3.93371 11.8427 3.88571 11.7147 3.87505 11.576C3.86438 11.4267 3.89638 11.288 3.97105 11.16C4.04571 11.0213 4.16838 10.9147 4.33905 10.84L11.315 7.91199V8.90399L4.33905 5.97599C4.16838 5.90133 4.04571 5.79999 3.97105 5.67199C3.89638 5.53333 3.86438 5.38933 3.87505 5.23999C3.88571 5.09066 3.93371 4.95733 4.01905 4.83999C4.10438 4.72266 4.21638 4.64266 4.35505 4.59999C4.50438 4.55733 4.66971 4.57333 4.85105 4.64799L11.251 7.36799C11.7417 7.57066 11.987 7.91199 11.987 8.39199C11.987 8.63733 11.923 8.85066 11.795 9.03199C11.6777 9.20266 11.4964 9.34133 11.251 9.44799L4.85105 12.152Z" fill="currentcolor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 831 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.91519 9.696C4.72319 9.76 4.55786 9.76534 4.41919 9.712C4.28052 9.648 4.17386 9.55734 4.09919 9.44C4.02452 9.312 3.98719 9.17334 3.98719 9.024C3.98719 8.87467 4.02986 8.736 4.11519 8.608C4.21119 8.48 4.34986 8.38934 4.53119 8.336L11.4432 6.144V7.12L4.53119 4.944C4.34986 4.89067 4.21119 4.8 4.11519 4.672C4.02986 4.544 3.98719 4.41067 3.98719 4.272C3.98719 4.12267 4.02452 3.98934 4.09919 3.872C4.17386 3.744 4.28052 3.65334 4.41919 3.6C4.55786 3.536 4.72319 3.536 4.91519 3.6L11.3312 5.696C11.5765 5.77067 11.7525 5.888 11.8592 6.048C11.9765 6.19734 12.0352 6.39467 12.0352 6.64C12.0352 6.87467 11.9765 7.072 11.8592 7.232C11.7525 7.392 11.5765 7.51467 11.3312 7.6L4.91519 9.696ZM4.69119 12C4.46719 12 4.29119 11.9413 4.16319 11.824C4.03519 11.696 3.97119 11.52 3.97119 11.296C3.97119 11.072 4.03519 10.9013 4.16319 10.784C4.29119 10.656 4.46719 10.592 4.69119 10.592H11.3152C11.5499 10.592 11.7259 10.656 11.8432 10.784C11.9712 10.9013 12.0352 11.072 12.0352 11.296C12.0352 11.52 11.9712 11.696 11.8432 11.824C11.7259 11.9413 11.5499 12 11.3152 12H4.69119Z" fill="currentcolor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -183,6 +183,10 @@ import google_sheet_component from "./google_sheet.svg?component";
|
||||
import google_sheet_source from "./google_sheet.svg?source";
|
||||
import grabber_component from "./grabber.svg?component";
|
||||
import grabber_source from "./grabber.svg?source";
|
||||
import greater_than_component from "./greater_than.svg?component";
|
||||
import greater_than_source from "./greater_than.svg?source";
|
||||
import greater_than_or_equal_component from "./greater_than_or_equal.svg?component";
|
||||
import greater_than_or_equal_source from "./greater_than_or_equal.svg?source";
|
||||
import grid_component from "./grid.svg?component";
|
||||
import grid_source from "./grid.svg?source";
|
||||
import group_component from "./group.svg?component";
|
||||
@@ -225,6 +229,10 @@ import ldap_component from "./ldap.svg?component";
|
||||
import ldap_source from "./ldap.svg?source";
|
||||
import learn_component from "./learn.svg?component";
|
||||
import learn_source from "./learn.svg?source";
|
||||
import less_than_component from "./less_than.svg?component";
|
||||
import less_than_source from "./less_than.svg?source";
|
||||
import less_than_or_equal_component from "./less_than_or_equal.svg?component";
|
||||
import less_than_or_equal_source from "./less_than_or_equal.svg?source";
|
||||
import lightbulb_component from "./lightbulb.svg?component";
|
||||
import lightbulb_source from "./lightbulb.svg?source";
|
||||
import line_component from "./line.svg?component";
|
||||
@@ -1150,6 +1158,22 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
|
||||
component: segment_component,
|
||||
source: segment_source,
|
||||
},
|
||||
less_than: {
|
||||
component: less_than_component,
|
||||
source: less_than_source,
|
||||
},
|
||||
greater_than: {
|
||||
component: greater_than_component,
|
||||
source: greater_than_source,
|
||||
},
|
||||
less_than_or_equal: {
|
||||
component: less_than_or_equal_component,
|
||||
source: less_than_or_equal_source,
|
||||
},
|
||||
greater_than_or_equal: {
|
||||
component: greater_than_or_equal_component,
|
||||
source: greater_than_or_equal_source,
|
||||
},
|
||||
shield: {
|
||||
component: shield_component,
|
||||
source: shield_source,
|
||||
@@ -1569,6 +1593,10 @@ export type IconName =
|
||||
| "table_spaced"
|
||||
| "collection"
|
||||
| "beaker"
|
||||
| "less_than"
|
||||
| "less_than_or_equal"
|
||||
| "greater_than"
|
||||
| "greater_than_or_equal"
|
||||
| "eye_filled";
|
||||
|
||||
export const iconNames = Object.keys(Icons) as unknown as IconName[];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentcolor" d="m11.171 12.152-6.4-2.704c-.49-.203-.736-.555-.736-1.056 0-.245.059-.448.176-.608.128-.17.315-.31.56-.416l6.4-2.72c.181-.075.341-.09.48-.048.15.043.267.123.352.24a.78.78 0 0 1 .048.832c-.064.128-.187.23-.368.304L4.707 8.904v-.992l6.976 2.928c.181.075.304.181.368.32a.708.708 0 0 1 .096.416.74.74 0 0 1-.144.384.638.638 0 0 1-.352.24c-.139.043-.299.027-.48-.048Z"/></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.0912 9.696L4.67519 7.6C4.44052 7.51467 4.26452 7.392 4.14719 7.232C4.02986 7.072 3.97119 6.87467 3.97119 6.64C3.97119 6.39467 4.02986 6.19734 4.14719 6.048C4.26452 5.888 4.44052 5.77067 4.67519 5.696L11.0912 3.6C11.2832 3.536 11.4485 3.536 11.5872 3.6C11.7259 3.65334 11.8325 3.744 11.9072 3.872C11.9819 3.98934 12.0192 4.12267 12.0192 4.272C12.0192 4.41067 11.9712 4.544 11.8752 4.672C11.7899 4.8 11.6565 4.89067 11.4752 4.944L4.56319 7.12V6.144L11.4752 8.336C11.6565 8.38934 11.7899 8.48 11.8752 8.608C11.9712 8.736 12.0192 8.87467 12.0192 9.024C12.0192 9.17334 11.9819 9.312 11.9072 9.44C11.8325 9.55734 11.7259 9.648 11.5872 9.712C11.4485 9.76534 11.2832 9.76 11.0912 9.696ZM4.69119 12C4.45652 12 4.27519 11.9413 4.14719 11.824C4.02986 11.696 3.97119 11.52 3.97119 11.296C3.97119 11.072 4.02986 10.9013 4.14719 10.784C4.27519 10.656 4.45652 10.592 4.69119 10.592H11.3152C11.5392 10.592 11.7152 10.656 11.8432 10.784C11.9712 10.9013 12.0352 11.072 12.0352 11.296C12.0352 11.52 11.9712 11.696 11.8432 11.824C11.7152 11.9413 11.5392 12 11.3152 12H4.69119Z" fill="currentcolor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -832,7 +832,7 @@
|
||||
(defclause >=, field OrderComparable, value-or-field OrderComparable)
|
||||
|
||||
;; :between is INCLUSIVE just like SQL !!!
|
||||
(defclause between field OrderComparable, min OrderComparable, max OrderComparable)
|
||||
(defclause between field OrderComparable, min OrderComparable, max OrderComparable, options (optional :map))
|
||||
|
||||
;; SUGAR CLAUSE: This is automatically written as a pair of `:between` clauses by the `:desugar` middleware.
|
||||
(defclause ^:sugar inside
|
||||
|
||||
@@ -372,6 +372,19 @@
|
||||
(for [x (concat [x y] more)]
|
||||
(into [op field x] tail))))))
|
||||
|
||||
(defn desugar-between
|
||||
"Rewrite :between with inclusive options as compound comparisons"
|
||||
[m]
|
||||
(lib.util.match/replace m
|
||||
[:between field min-val max-val (opts :guard map?)]
|
||||
(let [{:keys [min-inclusive max-inclusive]
|
||||
:or {min-inclusive true, max-inclusive true}} opts]
|
||||
(if (and min-inclusive max-inclusive)
|
||||
[:between field min-val max-val] ; Standard between without options
|
||||
[:and
|
||||
[(if min-inclusive :>= :>) field min-val]
|
||||
[(if max-inclusive :<= :<) field max-val]]))))
|
||||
|
||||
(defn desugar-current-relative-datetime
|
||||
"Replace `relative-datetime` clauses like `[:relative-datetime :current]` with `[:relative-datetime 0 <unit>]`.
|
||||
`<unit>` is inferred from the `:field` the clause is being compared to (if any), otherwise falls back to `default.`"
|
||||
@@ -474,6 +487,7 @@
|
||||
desugar-in
|
||||
desugar-multi-argument-comparisons
|
||||
desugar-does-not-contain
|
||||
desugar-between ; Should occur before time-interval and inside, which generate (currently vanilla) :between clauses
|
||||
desugar-time-interval
|
||||
desugar-relative-time-interval
|
||||
desugar-is-null-and-not-null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase.lib.fe-util
|
||||
(:require
|
||||
[inflections.core :as inflections]
|
||||
[medley.core :as m]
|
||||
[metabase.legacy-mbql.normalize :as mbql.normalize]
|
||||
[metabase.lib.card :as lib.card]
|
||||
[metabase.lib.common :as lib.common]
|
||||
@@ -336,15 +337,20 @@
|
||||
[:map
|
||||
[:operator ::lib.schema.filter/number-filter-operator]
|
||||
[:column ::lib.schema.metadata/column]
|
||||
[:values [:sequential NumberFilterValue]]])
|
||||
[:values [:sequential NumberFilterValue]]
|
||||
[:options ::lib.schema.filter/number-filter-options]])
|
||||
|
||||
(mu/defn number-filter-clause :- ::lib.schema.expression/expression
|
||||
"Creates a numeric filter clause based on FE-friendly filter parts. It should be possible to destructure each created
|
||||
expression with [[number-filter-parts]]."
|
||||
[operator :- ::lib.schema.filter/number-filter-operator
|
||||
column :- ::lib.schema.metadata/column
|
||||
values :- [:maybe [:sequential NumberFilterValue]]]
|
||||
(expression-clause-with-in operator (into [column] (map number->expression-arg) values) {}))
|
||||
values :- [:maybe [:sequential NumberFilterValue]]
|
||||
options :- ::lib.schema.filter/number-filter-options]
|
||||
(expression-clause-with-in operator (into [column] (map number->expression-arg) values)
|
||||
(->> (select-keys options [:min-inclusive :max-inclusive])
|
||||
;; True is the default, so omit them unless they're explicitly false.
|
||||
(m/filter-vals false?))))
|
||||
|
||||
(mu/defn number-filter-parts :- [:maybe NumberFilterParts]
|
||||
"Destructures a numeric filter clause created by [[number-filter-clause]]. Returns `nil` if the clause does not match
|
||||
@@ -361,24 +367,31 @@
|
||||
:values (mapv expression-arg->number values)})]
|
||||
(lib.util.match/match-lite filter-clause
|
||||
;; no arguments
|
||||
[(op :guard #{:is-null :not-null}) _ (col-ref :guard number-col?) & (args :len 0 :guard (every? number-arg? args))]
|
||||
(result op col-ref args)
|
||||
[(op :guard #{:is-null :not-null}) _ (col-ref :guard number-col?)]
|
||||
{:operator op, :column (ref->col col-ref), :values [], :options {}}
|
||||
|
||||
;; multiple arguments, `:=`
|
||||
[(op :guard #{:= :in}) _ (col-ref :guard number-col?) & (args :guard (every? number-arg? args))]
|
||||
(result op col-ref args)
|
||||
[(_ :guard #{:= :in}) _ (col-ref :guard number-col?) & (args :guard #(every? number-arg? %))]
|
||||
{:operator :=, :column (ref->col col-ref), :values (mapv expression-arg->number args), :options {}}
|
||||
|
||||
;; multiple arguments, `:!=`
|
||||
[(op :guard #{:!= :not-in}) _ (col-ref :guard number-col?) & (args :guard (every? number-arg? args))]
|
||||
(result op col-ref args)
|
||||
[(_ :guard #{:!= :not-in}) _ (col-ref :guard number-col?) & (args :guard #(every? number-arg? %))]
|
||||
{:operator :!=, :column (ref->col col-ref), :values (mapv expression-arg->number args), :options {}}
|
||||
|
||||
;; exactly 1 argument
|
||||
[(op :guard #{:> :>= :< :<=}) _ (col-ref :guard number-col?) & (args :len 1 :guard (every? number-arg? args))]
|
||||
(result op col-ref args)
|
||||
[(op :guard #{:> :>= :< :<=}) _ (col-ref :guard number-col?) (arg :guard number-arg?)]
|
||||
{:operator op, :column (ref->col col-ref), :values [(expression-arg->number arg)], :options {}}
|
||||
|
||||
;; exactly 2 arguments
|
||||
[(op :guard #{:between}) _ (col-ref :guard number-col?) & (args :len 2 :guard (every? number-arg? args))]
|
||||
(result op col-ref args))))
|
||||
[(op :guard #{:between}) opts (col-ref :guard number-col?) (start :guard number-arg?) (end :guard number-arg?)]
|
||||
{:operator op
|
||||
:column (ref->col col-ref)
|
||||
:values [(expression-arg->number start) (expression-arg->number end)]
|
||||
:options (select-keys opts [:min-inclusive :max-inclusive])}
|
||||
|
||||
;; do not match inner clauses
|
||||
_
|
||||
nil)))
|
||||
|
||||
(def ^:private CoordinateFilterParts
|
||||
[:map
|
||||
|
||||
@@ -1073,20 +1073,22 @@
|
||||
(defn ^:export number-filter-clause
|
||||
"Creates a numeric filter clause based on FE-friendly filter parts. It should be possible to destructure each created
|
||||
expression with [[number-filter-parts]]."
|
||||
[operator column values]
|
||||
[operator column values options]
|
||||
(lib.core/number-filter-clause (keyword operator)
|
||||
column
|
||||
(js->clj values)))
|
||||
(js->clj values)
|
||||
(js-obj->cljs-map options)))
|
||||
|
||||
(defn ^:export number-filter-parts
|
||||
"Destructures a numeric filter clause created by [[number-filter-clause]]. Returns `nil` if the clause does not match
|
||||
the expected shape."
|
||||
[a-query stage-number a-filter-clause]
|
||||
(when-let [filter-parts (lib.core/number-filter-parts a-query stage-number a-filter-clause)]
|
||||
(let [{:keys [operator column values]} filter-parts]
|
||||
(let [{:keys [operator column values options]} filter-parts]
|
||||
#js {:operator (name operator)
|
||||
:column column
|
||||
:values (to-array (map clj->js values))})))
|
||||
:values (to-array (map clj->js values))
|
||||
:options (cljs-map->js-obj options)})))
|
||||
|
||||
(defn ^:export coordinate-filter-clause
|
||||
"Creates a coordinate filter clause based on FE-friendly filter parts. It should be possible to destructure each
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
[metabase.lib.schema.temporal-bucketing :as temporal-bucketing]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(defn- comparable-args-schema [compared-position-pairs]
|
||||
[:fn
|
||||
{:error/message "arguments should be comparable"}
|
||||
(fn [[_tag _opts & args]]
|
||||
(let [argv (vec args)]
|
||||
(or expression/*suppress-expression-type-check?*
|
||||
(every? true? (map (fn [[i j]]
|
||||
(expression/comparable-expressions? (get argv i) (get argv j)))
|
||||
compared-position-pairs)))))])
|
||||
|
||||
(defn- tuple-clause-of-comparables-schema
|
||||
"Helper intended for use with [[define-mbql-clause]]. Create a clause schema with `:tuple` and ensure that
|
||||
the elements of `args` at positions specified by the pairs in `compared-position-pairs` can be compared."
|
||||
@@ -18,14 +28,7 @@
|
||||
{:pre [(simple-keyword? tag)]}
|
||||
[:and
|
||||
(apply mbql-clause/tuple-clause-schema tag args)
|
||||
[:fn
|
||||
{:error/message "arguments should be comparable"}
|
||||
(fn [[_tag _opts & args]]
|
||||
(let [argv (vec args)]
|
||||
(or expression/*suppress-expression-type-check?*
|
||||
(every? true? (map (fn [[i j]]
|
||||
(expression/comparable-expressions? (get argv i) (get argv j)))
|
||||
compared-position-pairs)))))]]))
|
||||
(comparable-args-schema compared-position-pairs)]))
|
||||
|
||||
(mr/def ::default-filter-operator
|
||||
"Filter operators that should be supported by any column type. Note that the FE allows only `:is-empty` and
|
||||
@@ -46,6 +49,14 @@
|
||||
"Numeric filter operators supported by the FE."
|
||||
[:enum :is-null :not-null := :!= :> :>= :< :<= :between])
|
||||
|
||||
(mr/def ::number-filter-options
|
||||
"Number filter operator options. Only set for `:between` operator currently."
|
||||
[:map
|
||||
;; For :between. Defaults to true.
|
||||
[:min-inclusive {:optional true} :boolean]
|
||||
;; For :between. Defaults to true.
|
||||
[:max-inclusive {:optional true} :boolean]])
|
||||
|
||||
(mr/def ::coordinate-filter-operator
|
||||
"Coordinate filter operators supported by the FE. Note that the FE does not support `:is-null` and `:not-null` for
|
||||
coordinate columns."
|
||||
@@ -92,12 +103,11 @@
|
||||
#_x [:ref ::expression/orderable]
|
||||
#_y [:ref ::expression/orderable]))
|
||||
|
||||
(mbql-clause/define-mbql-clause-with-schema-fn (tuple-clause-of-comparables-schema #{[0 1] [0 2]})
|
||||
:between :- :type/Boolean
|
||||
;; TODO -- should we enforce that min is <= max (for literal number values?)
|
||||
#_expr [:ref ::expression/orderable]
|
||||
#_min [:ref ::expression/orderable]
|
||||
#_max [:ref ::expression/orderable])
|
||||
(mbql-clause/define-mbql-clause :between :- :type/Boolean
|
||||
[:schema [:catn {:error/message "Valid :between clause"}
|
||||
[:tag [:= {:decode/normalize common/normalize-keyword} :between]]
|
||||
[:options [:merge ::common/options ::number-filter-options]]
|
||||
[:args [:repeat {:min 3, :max 3} [:schema [:ref ::expression/orderable]]]]]])
|
||||
|
||||
;; sugar: a pair of `:between` clauses
|
||||
(mbql-clause/define-mbql-clause-with-schema-fn (tuple-clause-of-comparables-schema #{[0 2] [0 4] [1 3] [1 5]})
|
||||
|
||||
@@ -584,6 +584,42 @@
|
||||
(mbql.u/desugar-filter-clause
|
||||
[:does-not-contain {:case-sensitive false} [:field 1 nil] "ABC" "XYZ" "LMN"])))))))
|
||||
|
||||
(t/deftest ^:parallel desugar-between-test
|
||||
(t/testing "desugar-between with inclusive boundaries"
|
||||
(t/testing "leaves simple between alone"
|
||||
(t/is (= [:between [:field 1 nil] 10 20]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20]))))
|
||||
(t/testing "both inclusive (default) - uses standard between without options"
|
||||
(t/is (= [:between [:field 1 nil] 10 20]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20 {}])))
|
||||
(t/is (= [:between [:field 1 nil] 10 20]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20 {:min-inclusive true, :max-inclusive true}])))))
|
||||
|
||||
(t/testing "desugar-between with exclusive boundaries"
|
||||
(t/testing "min-inclusive false only"
|
||||
(t/is (= [:and [:> [:field 1 nil] 10] [:<= [:field 1 nil] 20]]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20 {:min-inclusive false}]))))
|
||||
(t/testing "max-inclusive false only"
|
||||
(t/is (= [:and [:>= [:field 1 nil] 10] [:< [:field 1 nil] 20]]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20 {:max-inclusive false}]))))
|
||||
(t/testing "both exclusive"
|
||||
(t/is (= [:and [:> [:field 1 nil] 10] [:< [:field 1 nil] 20]]
|
||||
(mbql.u/desugar-between [:between [:field 1 nil] 10 20 {:min-inclusive false, :max-inclusive false}])))))
|
||||
|
||||
(t/testing "desugar-between with expressions"
|
||||
(t/is (= [:and [:> [:expression "price"] 100] [:<= [:expression "price"] 200]]
|
||||
(mbql.u/desugar-between [:between [:expression "price"] 100 200 {:min-inclusive false}]))))
|
||||
|
||||
(t/testing "desugar-between with different field types"
|
||||
(t/testing "with field options"
|
||||
(t/is (= [:and [:> [:field 1 {:temporal-unit :day}] 10] [:< [:field 1 {:temporal-unit :day}] 20]]
|
||||
(mbql.u/desugar-between [:between [:field 1 {:temporal-unit :day}] 10 20
|
||||
{:min-inclusive false, :max-inclusive false}]))))
|
||||
(t/testing "with nested field references"
|
||||
(t/is (= [:and [:>= [:field "price" {:base-type :type/Float}] 10.5] [:< [:field "price" {:base-type :type/Float}] 20.5]]
|
||||
(mbql.u/desugar-between [:between [:field "price" {:base-type :type/Float}] 10.5 20.5
|
||||
{:max-inclusive false}]))))))
|
||||
|
||||
(t/deftest ^:parallel desugar-temporal-extract-test
|
||||
(t/testing "desugaring :get-year, :get-month, etc"
|
||||
(doseq [[[op mode] unit] mbql.u/temporal-extract-ops->unit]
|
||||
|
||||
43
test/metabase/lib/convert_desugar_integration_test.cljc
Normal file
43
test/metabase/lib/convert_desugar_integration_test.cljc
Normal file
@@ -0,0 +1,43 @@
|
||||
(ns metabase.lib.convert-desugar-integration-test
|
||||
"Integration tests for the interaction between lib.convert and legacy MBQL desugaring.
|
||||
|
||||
These tests ensure that pMBQL queries converted to legacy MBQL format can be properly
|
||||
processed by the desugar middleware."
|
||||
(:require
|
||||
[clojure.test :refer [are deftest is testing]]
|
||||
[metabase.legacy-mbql.util :as mbql.u]
|
||||
[metabase.lib.convert :as lib.convert]))
|
||||
|
||||
(deftest ^:parallel between-with-options-desugar-integration-test
|
||||
(testing "between with options from lib.convert can be desugared"
|
||||
(are [pmbql-filter expected-desugared]
|
||||
(let [pmbql-query {:lib/type :mbql/query
|
||||
:database 1
|
||||
:stages [{:lib/type :mbql.stage/mbql
|
||||
:source-table 1
|
||||
:filters [pmbql-filter]}]}
|
||||
legacy-query (lib.convert/->legacy-MBQL pmbql-query)
|
||||
legacy-filter (get-in legacy-query [:query :filter])
|
||||
desugared (mbql.u/desugar-filter-clause legacy-filter)]
|
||||
(is (= expected-desugared desugared)
|
||||
"Desugar produces expected result"))
|
||||
|
||||
;; Standard between - no desugaring needed
|
||||
[:between {} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20]
|
||||
|
||||
;; min-inclusive false
|
||||
[:between {:min-inclusive false} [:field {} 1] 10 20]
|
||||
[:and [:> [:field 1 nil] 10] [:<= [:field 1 nil] 20]]
|
||||
|
||||
;; max-inclusive false
|
||||
[:between {:max-inclusive false} [:field {} 1] 10 20]
|
||||
[:and [:>= [:field 1 nil] 10] [:< [:field 1 nil] 20]]
|
||||
|
||||
;; both false
|
||||
[:between {:min-inclusive false, :max-inclusive false} [:field {} 1] 10 20]
|
||||
[:and [:> [:field 1 nil] 10] [:< [:field 1 nil] 20]]
|
||||
|
||||
;; both explicitly true - desugaring has no effect
|
||||
[:between {:min-inclusive true, :max-inclusive true} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20])))
|
||||
@@ -561,6 +561,49 @@
|
||||
{"and" [:and [:field 1 nil] [:field 2 nil]]}
|
||||
[:expression "and"]))
|
||||
|
||||
(deftest ^:parallel round-trip-between-with-options-test
|
||||
(testing "between filter with options produces valid transitional legacy MBQL"
|
||||
(are [pmbql-filter expected-legacy-filter]
|
||||
(let [pmbql-query {:lib/type :mbql/query
|
||||
:database 1
|
||||
:stages [{:lib/type :mbql.stage/mbql
|
||||
:source-table 1
|
||||
:filters [pmbql-filter]}]}
|
||||
legacy-query (lib.convert/->legacy-MBQL pmbql-query)
|
||||
legacy-filter (get-in legacy-query [:query :filter])]
|
||||
;; The legacy query must be valid according to the schema
|
||||
(is (= expected-legacy-filter legacy-filter)
|
||||
"Conversion produces expected legacy filter")
|
||||
;; And it must validate against the legacy schema (with our schema update)
|
||||
(is (try
|
||||
(mbql.s/validate-query legacy-query)
|
||||
true
|
||||
(catch Exception _
|
||||
false))
|
||||
"Legacy query validates against schema"))
|
||||
|
||||
;; Standard between (both inclusive) - no options needed
|
||||
[:between {} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20]
|
||||
|
||||
;; min-inclusive false - produces transitional format with options
|
||||
[:between {:min-inclusive false} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20 {:min-inclusive false}]
|
||||
|
||||
;; max-inclusive false - produces transitional format with options
|
||||
[:between {:max-inclusive false} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20 {:max-inclusive false}]
|
||||
|
||||
;; both false - produces transitional format with options
|
||||
[:between {:min-inclusive false, :max-inclusive false} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20 {:min-inclusive false, :max-inclusive false}]
|
||||
|
||||
;; both explicitly true - produces transitional format with options
|
||||
[:between {:min-inclusive true, :max-inclusive true} [:field {} 1] 10 20]
|
||||
[:between [:field 1 nil] 10 20 {:min-inclusive true, :max-inclusive true}])))
|
||||
|
||||
; Test moved to metabase.lib.convert-desugar-integration-test namespace
|
||||
|
||||
(deftest ^:parallel round-trip-segments-test
|
||||
(test-round-trip {:database 282
|
||||
:type :query
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[metabase.lib.field :as lib.field]
|
||||
[metabase.lib.filter :as lib.filter]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.options :as lib.options]
|
||||
[metabase.lib.query :as lib.query]
|
||||
[metabase.lib.ref :as lib.ref]
|
||||
[metabase.lib.test-metadata :as meta]
|
||||
@@ -462,15 +463,30 @@
|
||||
(lib.filter/<= column 10) {:operator :<=, :column column, :values [10]}
|
||||
(lib.filter/between column 10 20) {:operator :between, :column column, :values [10 20]}
|
||||
|
||||
;; :between with options
|
||||
(-> (lib.filter/between column 10 20)
|
||||
(lib.options/update-options assoc :min-inclusive false))
|
||||
{:operator :between, :column column, :values [10 20], :options {:min-inclusive false}}
|
||||
|
||||
(-> (lib.filter/between column 10 20)
|
||||
(lib.options/update-options assoc :max-inclusive false))
|
||||
{:operator :between, :column column, :values [10 20], :options {:max-inclusive false}}
|
||||
|
||||
(-> (lib.filter/between column 10 20)
|
||||
(lib.options/update-options assoc :min-inclusive false :max-inclusive false))
|
||||
{:operator :between, :column column, :values [10 20], :options {:min-inclusive false
|
||||
:max-inclusive false}}
|
||||
|
||||
;; bigint
|
||||
(lib.filter/= column bigint-clause) {:operator :=, :column column, :values [bigint-value]}
|
||||
(lib.filter/!= column bigint-clause) {:operator :!=, :column column, :values [bigint-value]}
|
||||
(lib.filter/> column bigint-clause) {:operator :>, :column column, :values [bigint-value]}}]
|
||||
(let [{:keys [operator column values]} parts]
|
||||
(let [{:keys [operator column values options]} parts]
|
||||
(is (=? parts (lib.fe-util/number-filter-parts query -1 clause)))
|
||||
(is (=? parts (lib.fe-util/number-filter-parts query -1 (lib.fe-util/number-filter-clause operator
|
||||
column
|
||||
values)))))))
|
||||
values
|
||||
(or options {}))))))))
|
||||
(testing "unsupported clauses"
|
||||
(are [clause] (nil? (lib.fe-util/number-filter-parts query -1 clause))
|
||||
(lib.filter/= 10 column)
|
||||
@@ -478,6 +494,51 @@
|
||||
(lib.expression/+ column 10)
|
||||
(lib.filter/and (lib.filter/= column 10) true)))))
|
||||
|
||||
(deftest ^:parallel between-filter-with-options-fe-util-test
|
||||
(let [query (lib.tu/venues-query)
|
||||
column (m/filter-vals some? (meta/field-metadata :venues :price))]
|
||||
(testing "between filter options survive full FE/BE roundtrip"
|
||||
(let [fe-parts {:operator :between
|
||||
:column column
|
||||
:values [10 20]
|
||||
:options {:min-inclusive false :max-inclusive true}}
|
||||
filter-clause (lib.fe-util/number-filter-clause (:operator fe-parts)
|
||||
(:column fe-parts)
|
||||
(:values fe-parts)
|
||||
(:options fe-parts))
|
||||
roundtrip-parts (lib.fe-util/number-filter-parts query -1 filter-clause)]
|
||||
;; The roundtrip drops true values since they're the default
|
||||
(is (= {:min-inclusive false} (:options roundtrip-parts)))
|
||||
(is (= :between (:operator roundtrip-parts)))
|
||||
(is (= [10 20] (:values roundtrip-parts)))))
|
||||
|
||||
(testing "Test with nil/empty values"
|
||||
(is (nil? (lib.fe-util/number-filter-parts
|
||||
query -1
|
||||
(lib.fe-util/expression-clause :between [column nil 20] {:min-inclusive false})))))
|
||||
|
||||
(testing "options are preserved through expression-parts/expression-clause"
|
||||
(let [clause (-> (lib.filter/between column 5 25)
|
||||
(lib.options/update-options assoc :min-inclusive false))
|
||||
parts (lib/expression-parts query clause)
|
||||
rebuilt (lib/expression-clause (:operator parts) (:args parts) (:options parts))
|
||||
final-parts (lib.fe-util/number-filter-parts query -1 rebuilt)]
|
||||
(is (= {:min-inclusive false} (:options final-parts)))))
|
||||
|
||||
(testing "defaults are elided"
|
||||
(let [fe-parts {:operator :between
|
||||
:column column
|
||||
:values [5 25]
|
||||
:options {:min-inclusive true :max-inclusive true}}
|
||||
filter-clause (lib.fe-util/number-filter-clause (:operator fe-parts)
|
||||
(:column fe-parts)
|
||||
(:values fe-parts)
|
||||
(:options fe-parts))
|
||||
roundtrip-parts (lib.fe-util/number-filter-parts query -1 filter-clause)]
|
||||
;; Either nil or empty map is acceptable when all options are defaults
|
||||
(is (or (nil? (:options roundtrip-parts))
|
||||
(= {} (:options roundtrip-parts))))))))
|
||||
|
||||
(deftest ^:parallel coordinate-filter-parts-test
|
||||
(let [query (lib.query/query meta/metadata-provider (meta/table-metadata :orders))
|
||||
lat-column (m/filter-vals some? (meta/field-metadata :people :latitude))
|
||||
|
||||
188
test/metabase/query_processor_test/between_options_test.clj
Normal file
188
test/metabase/query_processor_test/between_options_test.clj
Normal file
@@ -0,0 +1,188 @@
|
||||
(ns ^:mb/driver-tests metabase.query-processor-test.between-options-test
|
||||
"End-to-end tests for :between filter with min/max inclusive options"
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.lib.convert :as lib.convert]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
[metabase.query-processor.preprocess :as qp.preprocess]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest between-filter-desugaring-test
|
||||
(testing ":between filter with options gets desugared to compound comparisons"
|
||||
(mt/dataset test-data
|
||||
(testing "Exclusive min boundary"
|
||||
(let [query (mt/mbql-query venues
|
||||
{:filter [:between [:field (mt/id :venues :price) nil] 1 3
|
||||
{:min-inclusive false}]})
|
||||
preprocessed (qp.preprocess/preprocess query)]
|
||||
(testing "Desugared to :and with :> and :<="
|
||||
(is (= [:and
|
||||
[:> [:field (mt/id :venues :price) nil] [:value 1 :any]]
|
||||
[:<= [:field (mt/id :venues :price) nil] [:value 3 :any]]]
|
||||
(-> (get-in preprocessed [:query :filter])
|
||||
(update-in [1 2] (constantly [:value 1 :any]))
|
||||
(update-in [2 2] (constantly [:value 3 :any]))))))
|
||||
(testing "Query executes successfully"
|
||||
(let [result (qp/process-query query)]
|
||||
(is (map? result))
|
||||
(is (contains? result :data))
|
||||
(is (pos? (count (get-in result [:data :rows])))))))))))
|
||||
|
||||
(deftest between-filter-sql-generation-test
|
||||
(testing ":between filter options affect generated SQL"
|
||||
(mt/dataset test-data
|
||||
(doseq [[test-case filter-clause expected-pattern]
|
||||
[["Default (both inclusive)"
|
||||
[:between [:field (mt/id :venues :price) nil] 1 3]
|
||||
#"\"PRICE\" BETWEEN"]
|
||||
|
||||
["Min exclusive"
|
||||
[:between [:field (mt/id :venues :price) nil] 1 3 {:min-inclusive false}]
|
||||
#"\"PRICE\" > 1.* AND .*\"PRICE\" <="]
|
||||
|
||||
["Max exclusive"
|
||||
[:between [:field (mt/id :venues :price) nil] 1 3 {:max-inclusive false}]
|
||||
#"\"PRICE\" >= 1.* AND .*\"PRICE\" <"]
|
||||
|
||||
["Both exclusive"
|
||||
[:between [:field (mt/id :venues :price) nil] 1 3 {:min-inclusive false :max-inclusive false}]
|
||||
#"\"PRICE\" > 1.* AND .*\"PRICE\" < 3"]]]
|
||||
(testing test-case
|
||||
(let [query (mt/mbql-query venues
|
||||
{:filter filter-clause
|
||||
:limit 1})
|
||||
compiled (qp.compile/compile query)
|
||||
native-query (:query compiled)]
|
||||
(when (string? native-query)
|
||||
(is (re-find expected-pattern native-query)
|
||||
(str "SQL should match pattern for " test-case ": " native-query)))))))))
|
||||
|
||||
(deftest between-filter-results-test
|
||||
(testing ":between filter with options returns correct results"
|
||||
(mt/dataset test-data
|
||||
(let [price-index 5
|
||||
run-query (fn [min max options]
|
||||
(mt/rows
|
||||
(mt/run-mbql-query venues
|
||||
{:filter (into [:between [:field (mt/id :venues :price) nil] min max]
|
||||
(when options [options]))
|
||||
:order-by [[:asc [:field (mt/id :venues :price) nil]]]})))]
|
||||
(doseq [[test-name
|
||||
min max options expected-prices]
|
||||
[["Default inclusive returns boundary values"
|
||||
2 3 nil #{2 3}]
|
||||
|
||||
["Min exclusive excludes lower boundary"
|
||||
2 3 {:min-inclusive false} #{3}]
|
||||
|
||||
["Max exclusive excludes upper boundary"
|
||||
2 3 {:max-inclusive false} #{2}]
|
||||
|
||||
["Both exclusive excludes both boundaries"
|
||||
1 4 {:min-inclusive false :max-inclusive false} #{2 3}]]]
|
||||
(testing test-name
|
||||
(let [result (run-query min max options)]
|
||||
(is (pos? (count result)))
|
||||
(is (every? #(expected-prices (nth % price-index)) result)))))))))
|
||||
|
||||
(deftest between-filter-pmbql-roundtrip-test
|
||||
(testing ":between filter options survive pMBQL roundtrip"
|
||||
(mt/dataset test-data
|
||||
(testing "Options preserved through query processing"
|
||||
(let [pmbql-query {:lib/type :mbql/query
|
||||
:database (mt/id)
|
||||
:stages [{:lib/type :mbql.stage/mbql
|
||||
:source-table (mt/id :venues)
|
||||
:filters [[:between
|
||||
{:min-inclusive false
|
||||
:max-inclusive true}
|
||||
[:field {} (mt/id :venues :price)]
|
||||
1 3]]}]}
|
||||
legacy-query (lib.convert/->legacy-MBQL pmbql-query)
|
||||
processed-query (qp/process-query legacy-query)]
|
||||
(testing "Legacy query has options in correct position"
|
||||
(is (= [:between
|
||||
[:field (mt/id :venues :price) nil]
|
||||
1 3
|
||||
{:min-inclusive false :max-inclusive true}]
|
||||
(get-in legacy-query [:query :filter]))))
|
||||
|
||||
(testing "Query executes successfully"
|
||||
(is (map? processed-query))
|
||||
(is (contains? processed-query :data)))
|
||||
|
||||
(testing "Options preserved in processed query metadata"
|
||||
(let [filter-from-processed (get-in processed-query [:data :query :filter])]
|
||||
(when (and (vector? filter-from-processed)
|
||||
(= :between (first filter-from-processed))
|
||||
(= 5 (count filter-from-processed)))
|
||||
(is (= {:min-inclusive false :max-inclusive true}
|
||||
(last filter-from-processed)))))))))))
|
||||
|
||||
(deftest between-filter-dataset-api-test
|
||||
(testing ":between filter with options works through dataset API"
|
||||
(mt/dataset test-data
|
||||
(let [test-between-case (fn [test-name options expected-prices expected-sql-patterns]
|
||||
(testing test-name
|
||||
(let [query (mt/mbql-query venues
|
||||
{:filter (into [:between [:field (mt/id :venues :price) nil] 1 3]
|
||||
(when options [options]))})
|
||||
result (mt/user-http-request :rasta :post 202 "dataset" query)
|
||||
price-index 5
|
||||
rows (get-in result [:data :rows])
|
||||
prices (set (map #(nth % price-index) rows))]
|
||||
(is (= expected-prices prices)
|
||||
(str test-name " should return prices " expected-prices))
|
||||
|
||||
(testing "Native query shows correct SQL"
|
||||
(let [native-form (get-in result [:data :native_form :query])]
|
||||
(is (string? native-form))
|
||||
(doseq [pattern expected-sql-patterns]
|
||||
(is (re-find pattern native-form))))))))]
|
||||
(test-between-case "Default inclusive boundaries"
|
||||
nil
|
||||
#{1 2 3}
|
||||
[#"\"PRICE\" BETWEEN 1 AND 3"])
|
||||
(test-between-case "Min-inclusive false"
|
||||
{:min-inclusive false}
|
||||
#{2 3}
|
||||
[#"\"PRICE\" > 1" #"\"PRICE\" <= 3"])
|
||||
(test-between-case "Max-inclusive false"
|
||||
{:max-inclusive false}
|
||||
#{1 2}
|
||||
[#"\"PRICE\" >= 1" #"\"PRICE\" < 3"])
|
||||
(test-between-case "Both boundaries exclusive"
|
||||
{:min-inclusive false :max-inclusive false}
|
||||
#{2}
|
||||
[#"\"PRICE\" > 1" #"\"PRICE\" < 3"])))))
|
||||
|
||||
(deftest between-filter-api-test
|
||||
(testing ":between filter with options works through API card execution"
|
||||
(mt/test-drivers (mt/normal-drivers-with-feature :left-join)
|
||||
(mt/dataset test-data
|
||||
(let [test-api-case (fn [test-name options expected-prices]
|
||||
(testing test-name
|
||||
(mt/with-temp [:model/Card card {:dataset_query
|
||||
(mt/mbql-query venues
|
||||
{:filter (into [:between [:field (mt/id :venues :price) nil] 1 3]
|
||||
(when options [options]))})}]
|
||||
(let [result (mt/user-http-request :rasta :post 202
|
||||
(format "card/%d/query" (:id card)))
|
||||
price-index 5
|
||||
rows (get-in result [:data :rows])
|
||||
prices (set (map #(nth % price-index) rows))]
|
||||
(is (= expected-prices prices)
|
||||
(str test-name " should return prices " expected-prices))))))]
|
||||
(test-api-case "Default inclusive boundaries"
|
||||
nil
|
||||
#{1 2 3})
|
||||
(test-api-case "Min-inclusive false"
|
||||
{:min-inclusive false}
|
||||
#{2 3})
|
||||
(test-api-case "Max-inclusive false"
|
||||
{:max-inclusive false}
|
||||
#{1 2})
|
||||
(test-api-case "Both boundaries exclusive"
|
||||
{:min-inclusive false :max-inclusive false}
|
||||
#{2}))))))
|
||||
Reference in New Issue
Block a user