Compare commits
71 Commits
document-s
...
parameter-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f27b7553 | ||
|
|
4f4121eeee | ||
|
|
87f576ceef | ||
|
|
883f90603a | ||
|
|
14bbb67d14 | ||
|
|
58bcdd7747 | ||
|
|
ab75bd6f0d | ||
|
|
a5b75f85a5 | ||
|
|
7c07a7ad44 | ||
|
|
15ef06c732 | ||
|
|
4ee72318f8 | ||
|
|
e27c018bb5 | ||
|
|
5e72037735 | ||
|
|
9c7854a253 | ||
|
|
42474d76c1 | ||
|
|
50f978ac6b | ||
|
|
29ed6cbd49 | ||
|
|
bc54a53c74 | ||
|
|
af335c6945 | ||
|
|
94d66dccfb | ||
|
|
e37756ed78 | ||
|
|
c014975c23 | ||
|
|
32023d7001 | ||
|
|
dfd9f7e8f3 | ||
|
|
70cea1d476 | ||
|
|
80e3235d68 | ||
|
|
9a04592252 | ||
|
|
10cfe1f729 | ||
|
|
653f9ab499 | ||
|
|
f9d07f332c | ||
|
|
9d596dec03 | ||
|
|
b3242a0cda | ||
|
|
8b63059185 | ||
|
|
a36071bd6e | ||
|
|
b9b5d814ff | ||
|
|
9c8fba25f0 | ||
|
|
ba134a1af1 | ||
|
|
5265845b1c | ||
|
|
f084d6bfb1 | ||
|
|
80ccf66fa7 | ||
|
|
50452a1877 | ||
|
|
ff1ec7d54c | ||
|
|
bb0d59f769 | ||
|
|
d76c6504b0 | ||
|
|
bb3afb6bfc | ||
|
|
9a76837869 | ||
|
|
99834a57b1 | ||
|
|
13a20d3df8 | ||
|
|
47bfcfbbbc | ||
|
|
582e76e3e1 | ||
|
|
2b153b05ab | ||
|
|
a49a1ed4b4 | ||
|
|
6487a33a61 | ||
|
|
5e50404c72 | ||
|
|
d3600bdae7 | ||
|
|
16a40b9529 | ||
|
|
1f4e33b51c | ||
|
|
4e9f7ba6a3 | ||
|
|
d436b467cb | ||
|
|
788b613e42 | ||
|
|
80bc853634 | ||
|
|
6ae745a020 | ||
|
|
5bdc256d5e | ||
|
|
26210bdeef | ||
|
|
5248e4bbaf | ||
|
|
2d69c3ce2e | ||
|
|
ced515f7cf | ||
|
|
368573803d | ||
|
|
c65cf249be | ||
|
|
e9af08e034 | ||
|
|
691fff56e1 |
@@ -131,7 +131,7 @@ export function setFilterWidgetValue(
|
||||
) {
|
||||
filterWidget().eq(0).click();
|
||||
popover().within(() => {
|
||||
cy.icon("close").click();
|
||||
removeMultiAutocompleteValue(0);
|
||||
if (value) {
|
||||
cy.findByPlaceholderText(targetPlaceholder).type(value).blur();
|
||||
}
|
||||
@@ -249,6 +249,7 @@ export function tableHeaderClick(headerString) {
|
||||
cy.findByTextEnsureVisible(headerString).trigger("mouseup");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* selects the global new button
|
||||
* @param {*} menuItem optional, if provided, will click the New button and return the menu item with the text provided
|
||||
@@ -262,3 +263,24 @@ export function newButton(menuItem) {
|
||||
|
||||
return cy.findByTestId("app-bar").button("New");
|
||||
}
|
||||
|
||||
export function multiSelectInput(filter = ":eq(0)") {
|
||||
return cy.findByRole("combobox").filter(filter).get("input").last();
|
||||
}
|
||||
|
||||
export function multiAutocompleteInput(filter = ":eq(0)") {
|
||||
return cy.findAllByRole("combobox").filter(filter).get("input").last();
|
||||
}
|
||||
|
||||
export function multiAutocompleteValue(index, filter = ":eq(0)") {
|
||||
return cy
|
||||
.findAllByRole("combobox")
|
||||
.filter(filter)
|
||||
.get(`[value][index=${index}]`);
|
||||
}
|
||||
|
||||
export function removeMultiAutocompleteValue(index, filter) {
|
||||
return multiAutocompleteValue(index, filter)
|
||||
.findByRole("button", { hidden: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createPublicDashboardLink,
|
||||
openStaticEmbeddingModal,
|
||||
visitIframe,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { many_data_types_rows } from "e2e/support/test_tables_data";
|
||||
import { createMockActionParameter } from "metabase-types/api/mocks";
|
||||
@@ -1191,7 +1192,9 @@ describe(
|
||||
});
|
||||
|
||||
filterWidget().click();
|
||||
popover().find("input").first().type("{backspace}10");
|
||||
popover().within(() => {
|
||||
multiAutocompleteInput().type("{backspace}10");
|
||||
});
|
||||
cy.button("Update filter").click();
|
||||
|
||||
cy.button(actionName).click();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
modal,
|
||||
filter,
|
||||
filterField,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { createSegment } from "e2e/support/helpers/e2e-table-metadata-helpers";
|
||||
|
||||
@@ -169,7 +170,9 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
cy.contains(/Total\s+is less than/).click();
|
||||
popover().contains("Less than").click();
|
||||
popover().contains("Greater than").click();
|
||||
popover().find("input").type("{SelectAll}10");
|
||||
popover().within(() => {
|
||||
multiAutocompleteInput().type("{backspace}10", { force: true });
|
||||
});
|
||||
popover().contains("Update filter").click();
|
||||
|
||||
// confirm that the preview updated
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
tableHeaderClick,
|
||||
resetTestTable,
|
||||
main,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { createSegment } from "e2e/support/helpers/e2e-table-metadata-helpers";
|
||||
|
||||
@@ -229,7 +230,7 @@ describe("issue 14843", () => {
|
||||
popover().findByText(CC_NAME).click();
|
||||
selectFilterOperator("Not equal to");
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter a number").type("3");
|
||||
multiAutocompleteInput().type("3");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -315,8 +316,10 @@ describe("issue 18747", () => {
|
||||
|
||||
function addValueToParameterFilter() {
|
||||
filterWidget().click();
|
||||
popover().find("input").type("14");
|
||||
popover().contains("Add filter").click();
|
||||
popover().within(() => {
|
||||
multiAutocompleteInput().type("14");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
entityPickerModal,
|
||||
filterWidget,
|
||||
queryBuilderHeader,
|
||||
removeMultiAutocompleteValue,
|
||||
} from "e2e/support/helpers";
|
||||
import { b64hash_to_utf8 } from "metabase/lib/encoding";
|
||||
import {
|
||||
@@ -1447,7 +1448,7 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
|
||||
|
||||
cy.button(DASHBOARD_FILTER_TEXT.name).click();
|
||||
popover().within(() => {
|
||||
cy.icon("close").click();
|
||||
removeMultiAutocompleteValue(0);
|
||||
cy.findByPlaceholderText("Search by Name").type("Dell Adams");
|
||||
cy.button("Update filter").click();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
editDashboard,
|
||||
setFilter,
|
||||
sidebar,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { PEOPLE } = SAMPLE_DATABASE;
|
||||
@@ -93,45 +94,95 @@ describe("scenarios > dashboard > chained filter", () => {
|
||||
filterWidget().contains("Location 1").click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText(
|
||||
has_field_values === "search" ? "Search by City" : "Search the list",
|
||||
).type("An");
|
||||
cy.findByText("Anchorage");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
|
||||
cy.get("input").first().clear();
|
||||
if (has_field_values === "search") {
|
||||
multiAutocompleteInput().type("An");
|
||||
}
|
||||
if (has_field_values === "list") {
|
||||
cy.findByPlaceholderText("Search the list").type("An");
|
||||
}
|
||||
});
|
||||
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("Anchorage");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
});
|
||||
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
if (has_field_values === "search") {
|
||||
multiAutocompleteInput()
|
||||
.type("{backspace}{backspace}")
|
||||
// close the suggestion list
|
||||
.blur();
|
||||
}
|
||||
if (has_field_values === "list") {
|
||||
cy.findByPlaceholderText("Search the list").clear();
|
||||
}
|
||||
});
|
||||
|
||||
filterWidget().contains("AK").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("AK").click();
|
||||
cy.findByText("GA").click();
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("AK").click();
|
||||
cy.findByText("GA").click();
|
||||
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
|
||||
// do it again to make sure it isn't cached incorrectly
|
||||
filterWidget().contains("Location 1").click();
|
||||
popover().within(() => {
|
||||
cy.get("input").first().type("An");
|
||||
cy.findByText("Canton");
|
||||
cy.findByText("Anchorage").should("not.exist");
|
||||
if (has_field_values === "search") {
|
||||
multiAutocompleteInput().type("An");
|
||||
}
|
||||
if (has_field_values === "list") {
|
||||
cy.findByPlaceholderText("Search the list").type("An");
|
||||
}
|
||||
});
|
||||
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("Canton");
|
||||
cy.findByText("Anchorage").should("not.exist");
|
||||
});
|
||||
|
||||
if (has_field_values === "search") {
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
// close the suggestion list
|
||||
multiAutocompleteInput().blur();
|
||||
});
|
||||
}
|
||||
|
||||
filterWidget().contains("GA").click();
|
||||
popover().within(() => {
|
||||
cy.findByText("GA").click();
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("GA").click();
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
|
||||
// do it again without a state filter to make sure it isn't cached incorrectly
|
||||
filterWidget().contains("Location 1").click();
|
||||
popover().within(() => {
|
||||
cy.get("input").first().type("An");
|
||||
cy.findByText("Adrian");
|
||||
cy.findByText("Anchorage");
|
||||
cy.findByText("Canton");
|
||||
});
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
multiAutocompleteInput().type("An");
|
||||
});
|
||||
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("Adrian");
|
||||
cy.findByText("Anchorage");
|
||||
cy.findByText("Canton");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
dashboardParametersContainer,
|
||||
openQuestionActions,
|
||||
entityPickerModal,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { ORDERS_ID, PRODUCTS_ID, REVIEWS_ID } = SAMPLE_DATABASE;
|
||||
@@ -419,7 +420,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
dashboardParametersContainer().findByText("ID").click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByRole("textbox").type("1{enter}");
|
||||
multiAutocompleteInput().type("1,");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -469,7 +470,7 @@ describe("dashboard filters auto-wiring", () => {
|
||||
dashboardParametersContainer().findByText("ID").click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByRole("textbox").type("1{enter}");
|
||||
multiAutocompleteInput().type("1,");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
visitDashboard,
|
||||
setFilter,
|
||||
getDashboardCard,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
@@ -78,11 +79,7 @@ describe("scenarios > dashboard > filters > nested questions", () => {
|
||||
// Add multiple values (metabase#18113)
|
||||
filterWidget().click();
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Enter some text")
|
||||
.type("Gizmo")
|
||||
.blur()
|
||||
.type("Gadget")
|
||||
.blur();
|
||||
multiAutocompleteInput().type("Gizmo,Gadget").blur();
|
||||
});
|
||||
|
||||
cy.button("Add filter").click();
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("scenarios > dashboard > filters > text/category", () => {
|
||||
waitDashboardCardQuery();
|
||||
filterWidget()
|
||||
.eq(index)
|
||||
.contains(single ? value : /\d selections/);
|
||||
.contains(single ? value.replace(/"/g, "") : /\d selections/);
|
||||
|
||||
cy.log(`Make sure ${operator} filter returns correct result`);
|
||||
cy.findByTestId("dashcard")
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
import { restore, popover, visitDashboard } from "e2e/support/helpers";
|
||||
import {
|
||||
restore,
|
||||
popover,
|
||||
visitDashboard,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { PEOPLE, PEOPLE_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
|
||||
@@ -124,7 +129,7 @@ describe("scenarios > dashboard > OLD parameters", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("City").click();
|
||||
popover().within(() => {
|
||||
cy.get("input").type("Flagstaff{enter}");
|
||||
multiAutocompleteInput().type("Flagstaff{enter}");
|
||||
cy.findByText("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
updateDashboardCards,
|
||||
setFilter,
|
||||
spyRequestFinished,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
@@ -90,18 +91,27 @@ describe("scenarios > dashboard > parameters", () => {
|
||||
filterWidget().contains("Text").click();
|
||||
|
||||
// After typing "Ga", you should see this name
|
||||
popover().find("input").type("Ga");
|
||||
popover().within(() => multiAutocompleteInput().type("Ga"));
|
||||
cy.wait("@dashboard");
|
||||
popover().contains("Gabrielle Considine");
|
||||
popover().last().contains("Gabrielle Considine");
|
||||
|
||||
// Continue typing a "d" and you see "Gadget"
|
||||
popover().find("input").type("d");
|
||||
popover()
|
||||
.first()
|
||||
.within(() => multiAutocompleteInput().type("d"));
|
||||
cy.wait("@dashboard");
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByText("Gadget").click();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByText("Gadget").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
cy.location("search").should("eq", "?text=Gadget");
|
||||
cy.findAllByTestId("dashcard-container").first().should("contain", "0");
|
||||
@@ -724,7 +734,7 @@ describe("scenarios > dashboard > parameters", () => {
|
||||
filterWidget().click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByRole("textbox").type("Antwan Fisher");
|
||||
multiAutocompleteInput().type("Antwan Fisher");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -749,7 +759,7 @@ describe("scenarios > dashboard > parameters", () => {
|
||||
filterWidget().click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByRole("textbox").type("Antwan Fisher");
|
||||
multiAutocompleteInput().type("Antwan Fisher");
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const DASHBOARD_TEXT_FILTERS = [
|
||||
{
|
||||
operator: "Contains",
|
||||
single: true,
|
||||
value: "oo,aa",
|
||||
value: '"oo,aa"',
|
||||
representativeResult: "No results!",
|
||||
negativeAssertion: "148.23",
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export const DASHBOARD_TEXT_FILTERS = [
|
||||
{
|
||||
operator: "Does not contain",
|
||||
single: true,
|
||||
value: "oo,tt",
|
||||
value: '"oo,tt"',
|
||||
representativeResult: "37.65",
|
||||
negativeASsertion: "39.58",
|
||||
},
|
||||
@@ -47,7 +47,7 @@ export const DASHBOARD_TEXT_FILTERS = [
|
||||
{
|
||||
operator: "Starts with",
|
||||
single: true,
|
||||
value: "A,b",
|
||||
value: '"A,b"',
|
||||
representativeResult: "No results!",
|
||||
negativeASsertion: "85.72",
|
||||
},
|
||||
@@ -60,7 +60,7 @@ export const DASHBOARD_TEXT_FILTERS = [
|
||||
{
|
||||
operator: "Ends with",
|
||||
single: true,
|
||||
value: "e,s",
|
||||
value: '"e,s"',
|
||||
representativeResult: "No results!",
|
||||
negativeASsertion: "47.68",
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
filterWidget,
|
||||
addTextBoxWhileEditing,
|
||||
addHeadingWhileEditing,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
@@ -280,7 +281,7 @@ describe("scenarios > dashboard > parameters in text and heading cards", () => {
|
||||
saveDashboard();
|
||||
|
||||
filterWidget().click();
|
||||
cy.findByPlaceholderText("Enter a number").type("1{enter}");
|
||||
popover().within(() => multiAutocompleteInput().type("1"));
|
||||
cy.button("Add filter").click();
|
||||
getDashboardCard(0).findByText("Variable: 1").should("exist");
|
||||
getDashboardCard(1).findByText("Variable: 1").should("exist");
|
||||
@@ -289,7 +290,7 @@ describe("scenarios > dashboard > parameters in text and heading cards", () => {
|
||||
.findByText("1")
|
||||
.click();
|
||||
popover().within(() => {
|
||||
cy.findByRole("textbox").click().type("2{enter}");
|
||||
multiAutocompleteInput().type("2");
|
||||
cy.button("Update filter").click();
|
||||
});
|
||||
getDashboardCard(0).findByText("Variable: 1 and 2").should("exist");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
addOrUpdateDashboardCard,
|
||||
getDashboardCard,
|
||||
appBar,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
@@ -300,7 +301,7 @@ describe("scenarios > dashboard > title drill", () => {
|
||||
// update the parameter filter to a new value
|
||||
filterWidget().contains("Doohickey").click();
|
||||
popover().within(() => {
|
||||
cy.get("input").type("{backspace}Gadget{enter}");
|
||||
multiAutocompleteInput().type("{backspace}Gadget,");
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
|
||||
@@ -322,7 +323,7 @@ describe("scenarios > dashboard > title drill", () => {
|
||||
// make sure the unset id parameter works
|
||||
filterWidget().last().click();
|
||||
popover().within(() => {
|
||||
cy.get("input").type("5{enter}");
|
||||
multiAutocompleteInput().type("5");
|
||||
cy.findByText("Add filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
publishChanges,
|
||||
setEmbeddingParameter,
|
||||
assertEmbeddingParameter,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
|
||||
@@ -107,9 +108,7 @@ describe("scenarios > embedding > dashboard parameters", () => {
|
||||
});
|
||||
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Search by Name or enter an ID").type(
|
||||
"1{enter}3{enter}",
|
||||
);
|
||||
cy.findByPlaceholderText("Search by Name or enter an ID").type("1,3,");
|
||||
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
@@ -140,7 +139,7 @@ describe("scenarios > embedding > dashboard parameters", () => {
|
||||
openFilterOptions("Name");
|
||||
|
||||
cy.findByPlaceholderText("Search by Name").type("L");
|
||||
popover().findByText("Lina Heaney").click();
|
||||
popover().last().findByText("Lina Heaney").click();
|
||||
|
||||
cy.button("Add filter").click();
|
||||
|
||||
@@ -317,20 +316,34 @@ describe("scenarios > embedding > dashboard parameters", () => {
|
||||
|
||||
openFilterOptions("Id");
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Search by Name or enter an ID").type("Aly");
|
||||
|
||||
cy.contains("Alycia McCullough - 2016");
|
||||
multiAutocompleteInput().type("Aly");
|
||||
});
|
||||
|
||||
popover().last().contains("Alycia McCullough - 2016");
|
||||
|
||||
// close the suggestions popover
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
});
|
||||
|
||||
cy.log("should allow searching PEOPLE.NAME by PEOPLE.NAME");
|
||||
|
||||
openFilterOptions("Name");
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Search by Name").type("Aly");
|
||||
|
||||
cy.contains("Alycia McCullough");
|
||||
multiAutocompleteInput().type("{backspace}Aly");
|
||||
});
|
||||
|
||||
popover().last().contains("Alycia McCullough");
|
||||
|
||||
// close the suggestions popover
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
});
|
||||
|
||||
cy.log("should show values for PEOPLE.SOURCE");
|
||||
|
||||
openFilterOptions("Source");
|
||||
@@ -340,11 +353,18 @@ describe("scenarios > embedding > dashboard parameters", () => {
|
||||
|
||||
openFilterOptions("User");
|
||||
popover().within(() => {
|
||||
cy.findByPlaceholderText("Search by Name or enter an ID").type("Aly");
|
||||
|
||||
cy.contains("Alycia McCullough - 2016");
|
||||
multiAutocompleteInput().type("Aly");
|
||||
});
|
||||
|
||||
popover().last().contains("Alycia McCullough - 2016");
|
||||
|
||||
// close the suggestions popover
|
||||
popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
});
|
||||
|
||||
cy.log("should accept url parameters");
|
||||
|
||||
cy.location().then(location =>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
chartPathWithFillColor,
|
||||
echartsContainer,
|
||||
testPairedTooltipValues,
|
||||
multiAutocompleteInput,
|
||||
removeMultiAutocompleteValue,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import {
|
||||
@@ -97,6 +99,12 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("Kiana");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
cy.findByText("Anchorage").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.filter(":contains('Add filter')")
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -170,6 +178,12 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("Kiana");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
cy.findByText("Anchorage").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.filter(":contains('Add filter')")
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -212,10 +226,16 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findByPlaceholderText("Search by City").type("An");
|
||||
multiAutocompleteInput().type("An");
|
||||
cy.findByText("Kiana");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
cy.findByText("Anchorage").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.filter(":contains('Add filter')")
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -258,6 +278,12 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("Kiana");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
cy.findByText("Anchorage").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.filter(":contains('Add filter')")
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -297,6 +323,12 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("Kiana");
|
||||
cy.findByText("Anacoco").should("not.exist");
|
||||
cy.findByText("Anchorage").click();
|
||||
});
|
||||
|
||||
popover()
|
||||
.filter(":contains('Add filter')")
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -345,7 +377,6 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("Doohickey").should("not.exist");
|
||||
cy.findByText("Gadget").should("not.exist");
|
||||
cy.findByText("Widget").should("not.exist");
|
||||
|
||||
cy.button("Add filter").click();
|
||||
});
|
||||
|
||||
@@ -412,7 +443,8 @@ describe("scenarios > embedding > dashboard > linked filters (metabase#13639, me
|
||||
cy.findByText("2 selections").click();
|
||||
|
||||
// Remove one of the previously set filter values
|
||||
popover().findByText("29").closest("li").find(".Icon-close").click();
|
||||
popover().within(() => removeMultiAutocompleteValue(1));
|
||||
|
||||
cy.button("Update filter").click();
|
||||
|
||||
cy.findByTestId("table-row")
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
openQuestionsSidebar,
|
||||
visitEmbeddedPage,
|
||||
createQuestion,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
import {
|
||||
createMockDashboardCard,
|
||||
@@ -176,7 +177,7 @@ describe("issue 8030 + 32444", () => {
|
||||
cy.findByText(filterDetails.name).click();
|
||||
popover().within(() => {
|
||||
// the filter is connected only to the first card
|
||||
cy.get("input").type("1{enter}");
|
||||
multiAutocompleteInput().type("1{enter}");
|
||||
cy.findByText("Add filter").click();
|
||||
});
|
||||
cy.wait("@getCardQuery1");
|
||||
@@ -542,6 +543,7 @@ describe("issues 15119 and 16112", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(reviewerFilter.name).click();
|
||||
popover().contains("adam").click();
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Add filter").click();
|
||||
|
||||
cy.findByTestId("dashcard-container").should("contain", "adam");
|
||||
|
||||
@@ -84,7 +84,7 @@ export const DATE_FILTER_SUBTYPES = {
|
||||
},
|
||||
"Date Filter": {
|
||||
value: {
|
||||
timeBucket: "years",
|
||||
timeBucket: "month",
|
||||
},
|
||||
representativeResult: "Synergistic Steel Chair",
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export function clearWidgetValue() {
|
||||
}
|
||||
|
||||
export function setWidgetStringFilter(value) {
|
||||
popover().find("input").first().type(`${value}{enter}`);
|
||||
popover().find("input").not("[type=hidden]").first().type(`${value}{enter}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
queryBuilderMain,
|
||||
visitDashboard,
|
||||
getDashboardCard,
|
||||
removeMultiAutocompleteValue,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import * as FieldFilter from "./helpers/e2e-field-filter-helpers";
|
||||
@@ -1063,7 +1064,7 @@ describe("issue 31606", { tags: "@external" }, () => {
|
||||
});
|
||||
|
||||
popover().within(() => {
|
||||
cy.icon("close").click();
|
||||
removeMultiAutocompleteValue(0);
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
cy.findByTestId("sidebar-content").within(() => {
|
||||
@@ -1087,7 +1088,7 @@ describe("issue 31606", { tags: "@external" }, () => {
|
||||
filterWidget().click();
|
||||
|
||||
popover().within(() => {
|
||||
cy.icon("close").click();
|
||||
removeMultiAutocompleteValue(0);
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -223,6 +223,9 @@ describe("scenarios > filters > sql filters > field filter > String", () => {
|
||||
});
|
||||
|
||||
it("when set as the default value for a required filter", () => {
|
||||
// Use a bigger viewport to avoid elements being obscured by falling out of the screen.
|
||||
cy.viewport(1280, 1400);
|
||||
|
||||
SQLFilter.toggleRequired();
|
||||
|
||||
stringFilters.forEach(
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
clearFilterWidget,
|
||||
filterWidget,
|
||||
popover,
|
||||
removeMultiAutocompleteValue,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
import * as FieldFilter from "./helpers/e2e-field-filter-helpers";
|
||||
@@ -69,7 +71,7 @@ describe("scenarios > filters > sql filters > field filter", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
filterWidget().click();
|
||||
popover().within(() => {
|
||||
cy.icon("close").click();
|
||||
removeMultiAutocompleteValue(0);
|
||||
cy.findByText("Set to default").click();
|
||||
});
|
||||
filterWidget()
|
||||
@@ -82,7 +84,7 @@ describe("scenarios > filters > sql filters > field filter", () => {
|
||||
SQLFilter.toggleRequired();
|
||||
filterWidget().click();
|
||||
popover().within(() => {
|
||||
cy.get("input").type("10{enter}");
|
||||
multiAutocompleteInput().type("10,");
|
||||
cy.findByText("Update filter").click();
|
||||
});
|
||||
filterWidget().icon("time_history").click();
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
entityPickerModalTab,
|
||||
showDashboardCardActions,
|
||||
getDashboardCard,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
|
||||
@@ -158,7 +159,7 @@ describe("scenarios > question > download", () => {
|
||||
|
||||
filterWidget().contains("ID").click();
|
||||
|
||||
popover().find("input").type("1");
|
||||
popover().within(() => multiAutocompleteInput().type("1"));
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Add filter").click();
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
dashboardHeader,
|
||||
filterWidget,
|
||||
getFullName,
|
||||
multiAutocompleteInput,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { admin } = USERS;
|
||||
@@ -449,7 +450,7 @@ describeEE("issue 24223", () => {
|
||||
cy.findByText("Select…").click();
|
||||
popover().findByText("Title").click();
|
||||
cy.findByText("No default").click();
|
||||
popover().find("input").type("Awesome");
|
||||
popover().within(() => multiAutocompleteInput().type("Awesome"));
|
||||
popover().button("Add filter").click();
|
||||
|
||||
saveDashboard();
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
openEmbedModalFromMenu,
|
||||
getEmbedModalSharingPane,
|
||||
setFilter,
|
||||
multiAutocompleteInput,
|
||||
removeMultiAutocompleteValue,
|
||||
} from "e2e/support/helpers";
|
||||
|
||||
const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE;
|
||||
@@ -556,14 +558,20 @@ describe("scenarios > dashboard > subscriptions", () => {
|
||||
.findByText("Text")
|
||||
.click();
|
||||
cy.get("@subscriptionBar").findByText("Corbin Mertz").click();
|
||||
|
||||
popover().within(() => {
|
||||
removeMultiAutocompleteValue(0);
|
||||
multiAutocompleteInput().type("Sallie");
|
||||
});
|
||||
popover().last().findByText("Sallie Flatley").click();
|
||||
popover()
|
||||
.findByText("Corbin Mertz")
|
||||
.closest("li")
|
||||
.icon("close")
|
||||
.click();
|
||||
popover().find("input").type("Sallie");
|
||||
popover().findByText("Sallie Flatley").click();
|
||||
popover().contains("Update filter").click();
|
||||
.first()
|
||||
.within(() => {
|
||||
// to close the suggestion menu
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Update filter").click();
|
||||
});
|
||||
|
||||
cy.button("Save").click();
|
||||
|
||||
// verify existing subscription shows new default in UI
|
||||
@@ -652,14 +660,15 @@ describe("scenarios > dashboard > subscriptions", () => {
|
||||
.next("aside")
|
||||
.findByText("Corbin Mertz")
|
||||
.click();
|
||||
removeMultiAutocompleteValue(0, ":eq(1)");
|
||||
popover().within(() => multiAutocompleteInput().type("Sallie"));
|
||||
popover().last().findByText("Sallie Flatley").click();
|
||||
popover()
|
||||
.findByText("Corbin Mertz")
|
||||
.closest("li")
|
||||
.icon("close")
|
||||
.click();
|
||||
popover().find("input").type("Sallie");
|
||||
popover().findByText("Sallie Flatley").click();
|
||||
popover().contains("Update filter").click();
|
||||
.first()
|
||||
.within(() => {
|
||||
multiAutocompleteInput().blur();
|
||||
cy.button("Update filter").click();
|
||||
});
|
||||
cy.button("Save").click();
|
||||
|
||||
// verify existing subscription shows new default in UI
|
||||
@@ -684,8 +693,8 @@ describe("scenarios > dashboard > subscriptions", () => {
|
||||
cy.findByText("Emailed hourly").click();
|
||||
|
||||
cy.findAllByText("Corbin Mertz").last().click();
|
||||
popover().find("input").type("Bob");
|
||||
popover().findByText("Bobby Kessler").click();
|
||||
popover().within(() => multiAutocompleteInput().type("Bob"));
|
||||
popover().last().findByText("Bobby Kessler").click();
|
||||
popover().contains("Update filter").click();
|
||||
|
||||
cy.findAllByText("Text 1").last().click();
|
||||
@@ -788,9 +797,13 @@ function addParametersToDashboard() {
|
||||
|
||||
// add default value to the above filter
|
||||
cy.findByText("No default").click();
|
||||
popover().find("input").type("Corbin");
|
||||
popover().findByText("Corbin Mertz").click();
|
||||
popover().contains("Add filter").click();
|
||||
popover().within(() => {
|
||||
multiAutocompleteInput().type("Corbin");
|
||||
});
|
||||
|
||||
popover().last().findByText("Corbin Mertz").click();
|
||||
|
||||
popover().first().contains("Add filter").click({ force: true });
|
||||
|
||||
setFilter("Text or Category", "Is");
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ export const SINGLE_OR_MULTI_SELECTABLE_TYPES: Record<
|
||||
category: "any",
|
||||
id: "any",
|
||||
location: ["=", "!="],
|
||||
number: ["=", "!="],
|
||||
};
|
||||
|
||||
export const FIELD_FILTER_PARAMETER_TYPES = [
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("FilterPopover", () => {
|
||||
it("should have an operator selector", () => {
|
||||
setup({ filter: NUMERIC_FILTER });
|
||||
expect(screen.getByText("Equal to")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
expect(screen.getByText("1234")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("filter options", () => {
|
||||
|
||||
@@ -137,7 +137,6 @@ export function DefaultPicker({
|
||||
return (
|
||||
<FieldValuesWidget
|
||||
key={index}
|
||||
className={CS.input}
|
||||
value={values}
|
||||
onChange={onValuesChange}
|
||||
multi={operator.multi}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitForLoaderToBeRemoved,
|
||||
getByRole,
|
||||
} from "__support__/ui";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import type StructuredQuery from "metabase-lib/v1/queries/StructuredQuery";
|
||||
@@ -92,7 +93,7 @@ describe("Filters > DefaultPicker", () => {
|
||||
expect(
|
||||
await screen.findByTestId("field-values-widget"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -103,7 +104,7 @@ describe("Filters > DefaultPicker", () => {
|
||||
await screen.findByTestId("field-values-widget"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ugly Shoes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -113,7 +114,7 @@ describe("Filters > DefaultPicker", () => {
|
||||
expect(
|
||||
await screen.findByTestId("field-values-widget"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
const productTitles = [
|
||||
"Fancy Shoes",
|
||||
"Ugly Shoes",
|
||||
@@ -141,7 +142,8 @@ describe("Filters > DefaultPicker", () => {
|
||||
it("should update values for a multi-value filter", async () => {
|
||||
const { setValuesSpy } = await setup({ filter: stringQuery.filters()[0] });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
const input = getInput(combobox);
|
||||
|
||||
await userEvent.type(input, "Fancy Sandals");
|
||||
|
||||
@@ -155,9 +157,11 @@ describe("Filters > DefaultPicker", () => {
|
||||
const query = makeQuery({ filter: [">", ["field", PRODUCTS.ID, null], 1] });
|
||||
const { setValueSpy } = await setup({ filter: query.filters()[0] });
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
const input = getInput(combobox);
|
||||
|
||||
await userEvent.type(input, "{backspace}125");
|
||||
|
||||
await userEvent.type(input, "25");
|
||||
// index, value
|
||||
expect(setValueSpy).toHaveBeenLastCalledWith(0, 125);
|
||||
});
|
||||
@@ -177,3 +181,10 @@ describe("Filters > DefaultPicker", () => {
|
||||
expect(onCommitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function getInput(parent: HTMLElement) {
|
||||
/* eslint-disable-next-line testing-library/prefer-screen-queries */
|
||||
const input = getByRole(parent, "searchbox");
|
||||
expect(input).toBeInTheDocument();
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import ErrorBoundary from "metabase/ErrorBoundary";
|
||||
import { ListField } from "metabase/components/ListField";
|
||||
import LoadingSpinner from "metabase/components/LoadingSpinner";
|
||||
import SingleSelectListField from "metabase/components/SingleSelectListField";
|
||||
import TokenField, { parseStringValue } from "metabase/components/TokenField";
|
||||
import { parseStringValue } from "metabase/components/TokenField";
|
||||
import type { LayoutRendererArgs } from "metabase/components/TokenField/TokenField";
|
||||
import ValueComponent from "metabase/components/Value";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import Fields from "metabase/entities/fields";
|
||||
import { formatValue } from "metabase/lib/formatting";
|
||||
import { parseNumberValue } from "metabase/lib/number";
|
||||
import { defer } from "metabase/lib/promise";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
fetchParameterValues,
|
||||
} from "metabase/parameters/actions";
|
||||
import { addRemappings } from "metabase/redux/metadata";
|
||||
import { MultiAutocomplete } from "metabase/ui";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type Field from "metabase-lib/v1/metadata/Field";
|
||||
import type {
|
||||
@@ -68,7 +70,6 @@ function mapStateToProps(state: State, { fields = [] }: { fields: Field[] }) {
|
||||
}
|
||||
|
||||
export interface IFieldValuesWidgetProps {
|
||||
color?: "brand";
|
||||
maxResults?: number;
|
||||
style?: StyleHTMLAttributes<HTMLDivElement>;
|
||||
formatOptions?: Record<string, any>;
|
||||
@@ -95,7 +96,6 @@ export interface IFieldValuesWidgetProps {
|
||||
|
||||
multi?: boolean;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
prefix?: string;
|
||||
placeholder?: string;
|
||||
checkedColor?: string;
|
||||
@@ -106,10 +106,8 @@ export interface IFieldValuesWidgetProps {
|
||||
}
|
||||
|
||||
export function FieldValuesWidgetInner({
|
||||
color,
|
||||
maxResults = MAX_SEARCH_RESULTS,
|
||||
alwaysShowOptions = true,
|
||||
style = {},
|
||||
formatOptions = {},
|
||||
containerWidth,
|
||||
maxWidth = 500,
|
||||
@@ -128,7 +126,6 @@ export function FieldValuesWidgetInner({
|
||||
onChange,
|
||||
multi,
|
||||
autoFocus,
|
||||
className,
|
||||
prefix,
|
||||
placeholder,
|
||||
checkedColor,
|
||||
@@ -434,6 +431,32 @@ export function FieldValuesWidgetInner({
|
||||
: parseStringValue(value);
|
||||
};
|
||||
|
||||
const shouldCreate = (value: string | number) => {
|
||||
const res = parseFreeformValue(value);
|
||||
return res !== null;
|
||||
};
|
||||
|
||||
const renderStringOption = function (option: FieldValue): {
|
||||
label: string;
|
||||
value: string;
|
||||
} {
|
||||
const value = option[0];
|
||||
const column = fields[0];
|
||||
const label =
|
||||
formatValue(value, {
|
||||
...formatOptions,
|
||||
column,
|
||||
remap: showRemapping(fields),
|
||||
jsx: false,
|
||||
maximumFractionDigits: 20,
|
||||
// we know it is string | number because we are passing jsx: false
|
||||
})?.toString() ?? "<null>";
|
||||
|
||||
// @ts-expect-error: the type of the FieldValuesWidget value is
|
||||
// confusing: it accepts RowValue options but can only onChange(string[])
|
||||
return { value, label };
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div
|
||||
@@ -467,38 +490,20 @@ export function FieldValuesWidgetInner({
|
||||
checkedColor={checkedColor}
|
||||
/>
|
||||
) : (
|
||||
<TokenField
|
||||
prefix={prefix}
|
||||
value={value.filter(v => v != null)}
|
||||
<MultiAutocomplete<string>
|
||||
onSearchChange={onInputChange}
|
||||
onChange={onChange}
|
||||
value={value.filter(v => v !== null && v !== undefined)}
|
||||
data={options.map(renderStringOption)}
|
||||
renderValue={value => optionRenderer?.([value])}
|
||||
placeholder={tokenFieldPlaceholder}
|
||||
updateOnInputChange
|
||||
// forwarded props
|
||||
multi={multi}
|
||||
shouldCreate={shouldCreate}
|
||||
autoFocus={autoFocus}
|
||||
color={color}
|
||||
style={{ ...style, minWidth: "inherit" }}
|
||||
className={className}
|
||||
optionsStyle={
|
||||
!parameter && !showOptionsInPopover ? { maxHeight: "none" } : {}
|
||||
}
|
||||
// end forwarded props
|
||||
options={options}
|
||||
valueKey="0"
|
||||
valueRenderer={valueRenderer}
|
||||
optionRenderer={optionRenderer}
|
||||
layoutRenderer={layoutRenderer}
|
||||
filterOption={(option, filterString) => {
|
||||
const lowerCaseFilterString = filterString.toLowerCase();
|
||||
return option?.some?.(
|
||||
value =>
|
||||
value != null &&
|
||||
String(value).toLowerCase().includes(lowerCaseFilterString),
|
||||
);
|
||||
}}
|
||||
onInputChange={onInputChange}
|
||||
parseFreeformValue={parseFreeformValue}
|
||||
updateOnInputBlur
|
||||
prefix={prefix}
|
||||
// @ts-expect-error: we are actually using (string | number)[] here
|
||||
// but the type of the FieldValuesWidget props is string[]
|
||||
parseValue={parseFreeformValue}
|
||||
maxSelectedValues={multi ? undefined : 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -617,8 +622,6 @@ function renderValue({
|
||||
fields,
|
||||
formatOptions,
|
||||
value,
|
||||
autoLoad,
|
||||
compact,
|
||||
}: {
|
||||
fields: Field[];
|
||||
formatOptions: Record<string, any>;
|
||||
@@ -633,8 +636,6 @@ function renderValue({
|
||||
maximumFractionDigits={20}
|
||||
remap={showRemapping(fields)}
|
||||
{...formatOptions}
|
||||
autoLoad={autoLoad}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { setupFieldSearchValuesEndpoints } from "__support__/server-mocks";
|
||||
import {
|
||||
getBrokenUpTextMatcher,
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitForLoaderToBeRemoved,
|
||||
@@ -27,7 +24,6 @@ import {
|
||||
LISTABLE_PK_FIELD_VALUE,
|
||||
SEARCHABLE_FK_FIELD_ID,
|
||||
EXPRESSION_FIELD_ID,
|
||||
metadataWithSearchValuesField,
|
||||
} from "./testMocks";
|
||||
|
||||
async function setup({
|
||||
@@ -272,45 +268,4 @@ describe("FieldValuesWidget", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NoMatchState", () => {
|
||||
it("should display field title when one field passed and there are no matching results", async () => {
|
||||
const field = metadataWithSearchValuesField.field(PEOPLE.PASSWORD);
|
||||
const displayName = field?.display_name; // "Password"
|
||||
const searchValue = "somerandomvalue";
|
||||
|
||||
await setup({
|
||||
fields: [field],
|
||||
multi: true,
|
||||
disablePKRemappingForSearch: true,
|
||||
searchValue,
|
||||
});
|
||||
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText(`Search by ${displayName}`),
|
||||
searchValue,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
getBrokenUpTextMatcher(`No matching ${displayName} found.`),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display field title when multiple fields passed and no matching results found", async () => {
|
||||
const searchValue = "somerandomvalue";
|
||||
|
||||
await setup({
|
||||
fields: [metadata.field(PEOPLE.CITY), metadata.field(PEOPLE.NAME)],
|
||||
multi: true,
|
||||
disablePKRemappingForSearch: true,
|
||||
searchValue,
|
||||
});
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("Search"), searchValue);
|
||||
|
||||
expect(await screen.findByText(`No matching result`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "metabase/parameters/utils/ui";
|
||||
import { Icon } from "metabase/ui";
|
||||
import {
|
||||
deriveFieldOperatorFromParameter,
|
||||
getNumberParameterArity,
|
||||
getStringParameterArity,
|
||||
} from "metabase-lib/v1/parameters/utils/operators";
|
||||
@@ -341,7 +342,12 @@ function Widget({
|
||||
}
|
||||
|
||||
if (isNumberParameter(parameter)) {
|
||||
const arity = getNumberParameterArity(parameter);
|
||||
let arity = getNumberParameterArity(parameter);
|
||||
|
||||
if (!deriveFieldOperatorFromParameter(parameter).multi) {
|
||||
arity = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberInputWidget
|
||||
value={normalizedValue}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import TokenField from "metabase/components/TokenField";
|
||||
import NumericInput from "metabase/core/components/NumericInput";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { parseNumberValue } from "metabase/lib/number";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
Footer,
|
||||
TokenFieldWrapper,
|
||||
} from "metabase/parameters/components/widgets/Widget.styled";
|
||||
import { MultiAutocomplete } from "metabase/ui";
|
||||
import type { Parameter } from "metabase-types/api";
|
||||
|
||||
export type NumberInputWidgetProps = {
|
||||
@@ -58,22 +58,29 @@ export function NumberInputWidget({
|
||||
}
|
||||
};
|
||||
|
||||
function shouldCreate(value: string | number) {
|
||||
const res = parseNumberValue(value);
|
||||
return res !== null;
|
||||
}
|
||||
|
||||
const filteredUnsavedArrayValue = useMemo(
|
||||
() => unsavedArrayValue.filter((x): x is number => x !== undefined),
|
||||
[unsavedArrayValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<WidgetRoot className={className}>
|
||||
{label && <WidgetLabel>{label}</WidgetLabel>}
|
||||
{arity === "n" ? (
|
||||
{arity === "n" || arity === 1 ? (
|
||||
<TokenFieldWrapper>
|
||||
<TokenField
|
||||
multi
|
||||
updateOnInputChange
|
||||
autoFocus={autoFocus}
|
||||
value={unsavedArrayValue}
|
||||
parseFreeformValue={parseNumberValue}
|
||||
onChange={newValue => {
|
||||
setUnsavedArrayValue(newValue);
|
||||
}}
|
||||
options={[]}
|
||||
<MultiAutocomplete<number>
|
||||
onChange={setUnsavedArrayValue}
|
||||
value={filteredUnsavedArrayValue}
|
||||
placeholder={placeholder}
|
||||
shouldCreate={shouldCreate}
|
||||
parseValue={parseNumberValue}
|
||||
autoFocus={autoFocus}
|
||||
maxSelectedValues={typeof arity === "number" ? arity : undefined}
|
||||
/>
|
||||
</TokenFieldWrapper>
|
||||
) : (
|
||||
@@ -113,7 +120,7 @@ export function NumberInputWidget({
|
||||
);
|
||||
}
|
||||
|
||||
function normalize(value: number[] | undefined): number[] {
|
||||
function normalize(value: number[] | undefined): (number | undefined)[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, getByText, getByRole } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { createMockParameter } from "metabase-types/api/mocks";
|
||||
@@ -170,18 +170,23 @@ describe("NumberInputWidget", () => {
|
||||
});
|
||||
|
||||
describe("arity of n", () => {
|
||||
it("should render a token field input", () => {
|
||||
it("should render a multi autocomplete input", () => {
|
||||
const value = [1, 2, 3, 4];
|
||||
render(
|
||||
<NumberInputWidget
|
||||
arity="n"
|
||||
value={[1, 2, 3, 4]}
|
||||
value={value}
|
||||
setValue={mockSetValue}
|
||||
parameter={mockParameter}
|
||||
/>,
|
||||
);
|
||||
|
||||
const values = screen.getAllByRole("list")[0];
|
||||
expect(values).toHaveTextContent("1234");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
|
||||
for (const item of value) {
|
||||
const value = getValue(combobox, item);
|
||||
expect(value).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly parse number inputs", async () => {
|
||||
@@ -194,11 +199,14 @@ describe("NumberInputWidget", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await userEvent.type(input, "foo{enter}123abc{enter}456{enter}");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
const input = getInput(combobox);
|
||||
await userEvent.type(input, "foo,123abc,456,", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const values = screen.getAllByRole("list")[0];
|
||||
expect(values).toHaveTextContent("123456");
|
||||
expect(getValue(combobox, 123)).toBeInTheDocument();
|
||||
expect(getValue(combobox, 456)).toBeInTheDocument();
|
||||
|
||||
const button = screen.getByRole("button", { name: "Add filter" });
|
||||
await userEvent.click(button);
|
||||
@@ -215,8 +223,11 @@ describe("NumberInputWidget", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await userEvent.type(input, "{backspace}{backspace}");
|
||||
const combobox = screen.getByRole("combobox");
|
||||
const input = getInput(combobox);
|
||||
await userEvent.type(input, "{backspace}{backspace}", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Update filter" });
|
||||
|
||||
@@ -225,3 +236,15 @@ describe("NumberInputWidget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getValue(parent: HTMLElement, value: number) {
|
||||
/* eslint-disable-next-line testing-library/prefer-screen-queries */
|
||||
return getByText(parent, value.toString());
|
||||
}
|
||||
|
||||
function getInput(parent: HTMLElement) {
|
||||
/* eslint-disable-next-line testing-library/prefer-screen-queries */
|
||||
const input = getByRole(parent, "searchbox");
|
||||
expect(input).toBeInTheDocument();
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function StringInputWidget({
|
||||
|
||||
function normalize(value: string[] | undefined): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
return value.filter(x => x !== undefined);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -266,3 +266,17 @@ ReadOnlyXs.args = {
|
||||
<Canvas>
|
||||
<Story name="Read only, xs">{ReadOnlyXs}</Story>
|
||||
</Canvas>
|
||||
|
||||
#### With number values
|
||||
|
||||
export const WithNumberValues = DefaultTemplate.bind({});
|
||||
WithNumberValues.args = {
|
||||
data: [
|
||||
10, 20, 30, 40,
|
||||
],
|
||||
parseValues: value => parseInt(value, 10),
|
||||
};
|
||||
|
||||
<Canvas>
|
||||
<Story name="With Number Values">{WithNumberValues}</Story>
|
||||
</Canvas>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import type { MultiSelectProps, SelectItem } from "@mantine/core";
|
||||
import type { MultiSelectProps, SelectItemProps } from "@mantine/core";
|
||||
import { MultiSelect, Tooltip } from "@mantine/core";
|
||||
import { useUncontrolled } from "@mantine/hooks";
|
||||
import type { ClipboardEvent, FocusEvent } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ClipboardEvent, FocusEvent, ReactNode } from "react";
|
||||
import { useMemo, useState, forwardRef } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
import type { Base, SelectItem, FilterFn } from "./types";
|
||||
import { parseValues, unique } from "./utils";
|
||||
|
||||
export type MultiAutocompleteProps = Omit<MultiSelectProps, "shouldCreate"> & {
|
||||
shouldCreate?: (query: string, selectedValues: string[]) => boolean;
|
||||
export type MultiAutocompleteProps<TValue extends Base> = Omit<
|
||||
MultiSelectProps,
|
||||
"shouldCreate" | "data" | "filter" | "defaultValue" | "value" | "onChange"
|
||||
> & {
|
||||
shouldCreate?: (value: TValue, selectedValues: TValue[]) => boolean;
|
||||
filter?: FilterFn<TValue>;
|
||||
data?: ReadonlyArray<TValue | SelectItem<TValue>>;
|
||||
defaultValue?: TValue[];
|
||||
parseValue?: (str: string) => TValue | null;
|
||||
value?: TValue[];
|
||||
onChange?: (value: TValue[]) => void;
|
||||
renderValue?: (value: TValue) => ReactNode;
|
||||
};
|
||||
|
||||
export function MultiAutocomplete({
|
||||
data,
|
||||
export function MultiAutocomplete<TValue extends Base>({
|
||||
data = [],
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
searchValue: controlledSearchValue,
|
||||
@@ -26,9 +37,13 @@ export function MultiAutocomplete({
|
||||
onSearchChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
prefix,
|
||||
filter = defaultFilter,
|
||||
parseValue = defaultParseValue,
|
||||
renderValue,
|
||||
...props
|
||||
}: MultiAutocompleteProps) {
|
||||
const [selectedValues, setSelectedValues] = useUncontrolled({
|
||||
}: MultiAutocompleteProps<TValue>) {
|
||||
const [selectedValues, setSelectedValues] = useUncontrolled<TValue[]>({
|
||||
value: controlledValue,
|
||||
defaultValue,
|
||||
finalValue: [],
|
||||
@@ -39,7 +54,10 @@ export function MultiAutocomplete({
|
||||
finalValue: "",
|
||||
onChange: onSearchChange,
|
||||
});
|
||||
const [lastSelectedValues, setLastSelectedValues] = useState(selectedValues);
|
||||
|
||||
const [lastSelectedValues, setLastSelectedValues] =
|
||||
useState<TValue[]>(selectedValues);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const visibleValues = isFocused ? lastSelectedValues : [...selectedValues];
|
||||
|
||||
@@ -48,7 +66,7 @@ export function MultiAutocomplete({
|
||||
[data, lastSelectedValues],
|
||||
);
|
||||
|
||||
const handleChange = (newValues: string[]) => {
|
||||
const handleChange = (newValues: TValue[]) => {
|
||||
const values = unique(newValues);
|
||||
setSelectedValues(values);
|
||||
setLastSelectedValues(values);
|
||||
@@ -60,14 +78,19 @@ export function MultiAutocomplete({
|
||||
onFocus?.(event);
|
||||
};
|
||||
|
||||
function isValid(value: string) {
|
||||
return value !== "" && shouldCreate?.(value, lastSelectedValues);
|
||||
function isValid(value: TValue | null) {
|
||||
return (
|
||||
value !== null &&
|
||||
value !== "" &&
|
||||
!Number.isNaN(value) &&
|
||||
shouldCreate?.(value, lastSelectedValues)
|
||||
);
|
||||
}
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
|
||||
const values = parseValues(searchValue);
|
||||
const values = parseValues(searchValue, parseValue);
|
||||
const validValues = values.filter(isValid);
|
||||
|
||||
setSearchValue("");
|
||||
@@ -94,7 +117,7 @@ export function MultiAutocomplete({
|
||||
const pasted = event.clipboardData.getData("text");
|
||||
const text = `${before}${pasted}${after}`;
|
||||
|
||||
const values = parseValues(text);
|
||||
const values = parseValues(text, parseValue);
|
||||
const validValues = values.filter(isValid);
|
||||
|
||||
if (values.length > 0) {
|
||||
@@ -114,7 +137,7 @@ export function MultiAutocomplete({
|
||||
setSearchValue(newSearchValue);
|
||||
|
||||
if (newSearchValue !== "") {
|
||||
const values = parseValues(newSearchValue);
|
||||
const values = parseValues(newSearchValue, parseValue);
|
||||
if (values.length >= 1) {
|
||||
const value = values[0] ?? newSearchValue;
|
||||
if (isValid(value)) {
|
||||
@@ -134,7 +157,7 @@ export function MultiAutocomplete({
|
||||
last === "\n" ||
|
||||
(first === '"' && last === '"')
|
||||
) {
|
||||
const values = parseValues(newSearchValue);
|
||||
const values = parseValues(newSearchValue, parseValue);
|
||||
const validValues = values.filter(isValid);
|
||||
|
||||
if (values.length > 0) {
|
||||
@@ -166,40 +189,73 @@ export function MultiAutocomplete({
|
||||
<span />
|
||||
);
|
||||
|
||||
const CustomItemComponent = useMemo(
|
||||
() =>
|
||||
forwardRef<HTMLDivElement, SelectItemProps>(function CustomItem(
|
||||
props,
|
||||
ref,
|
||||
) {
|
||||
const customLabel =
|
||||
props.value !== undefined && renderValue?.(props.value as TValue);
|
||||
return (
|
||||
<ItemWrapper
|
||||
ref={ref}
|
||||
{...props}
|
||||
label={customLabel ?? props.label}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
[renderValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...props}
|
||||
// @ts-expect-error: Mantine's types expects a string value,
|
||||
// but does not depend on it being a string
|
||||
data={items}
|
||||
// @ts-expect-error: see above
|
||||
value={visibleValues}
|
||||
searchValue={searchValue}
|
||||
placeholder={placeholder}
|
||||
searchable
|
||||
autoFocus={autoFocus}
|
||||
// @ts-expect-error: see above
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onSearchChange={handleSearchChange}
|
||||
onPaste={handlePaste}
|
||||
rightSection={info}
|
||||
icon={prefix && <span data-testid="input-prefix">{prefix}</span>}
|
||||
// @ts-expect-error: see above
|
||||
filter={filter as FilterFn<TValue>}
|
||||
itemComponent={CustomItemComponent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectItem(item: string | SelectItem): SelectItem {
|
||||
function getSelectItem<TValue extends Base>(
|
||||
item: TValue | SelectItem<TValue>,
|
||||
): SelectItem<TValue> {
|
||||
if (typeof item === "string") {
|
||||
return { value: item, label: item };
|
||||
}
|
||||
|
||||
if (typeof item === "number") {
|
||||
return { value: item, label: item.toString() };
|
||||
}
|
||||
|
||||
if (!item.label) {
|
||||
return { value: item.value, label: item.value };
|
||||
return { value: item.value, label: item.value?.toString() ?? "" };
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
function getAvailableSelectItems(
|
||||
data: ReadonlyArray<string | SelectItem>,
|
||||
selectedValues: string[],
|
||||
function getAvailableSelectItems<TValue extends Base>(
|
||||
data: ReadonlyArray<TValue | SelectItem<TValue>>,
|
||||
selectedValues: TValue[],
|
||||
) {
|
||||
const all = [...data, ...selectedValues].map(getSelectItem);
|
||||
const seen = new Set();
|
||||
@@ -214,8 +270,42 @@ function getAvailableSelectItems(
|
||||
});
|
||||
}
|
||||
|
||||
function defaultShouldCreate(query: string, selectedValues: string[]) {
|
||||
function defaultShouldCreate<TValue extends Base>(
|
||||
value: TValue,
|
||||
selectedValues: TValue[],
|
||||
) {
|
||||
if (typeof value === "number") {
|
||||
return !selectedValues.some(selectedValue => selectedValue === value);
|
||||
}
|
||||
|
||||
return (
|
||||
query.trim().length > 0 && !selectedValues.some(value => value === query)
|
||||
value.trim().length > 0 &&
|
||||
!selectedValues.some(selectedValue => selectedValue === value)
|
||||
);
|
||||
}
|
||||
|
||||
function defaultFilter<TValue extends Base>(
|
||||
query: string,
|
||||
selected: boolean,
|
||||
item: SelectItem<TValue>,
|
||||
): boolean {
|
||||
if (selected || !item.label) {
|
||||
return false;
|
||||
}
|
||||
return item.label.toLowerCase().trim().includes(query.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function defaultParseValue<TValue extends Base>(str: string): TValue | null {
|
||||
// @ts-expect-error: for the default case we ignore the type
|
||||
return str;
|
||||
}
|
||||
|
||||
export const ItemWrapper = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
function ItemWrapper({ label, value, ...others }, ref) {
|
||||
return (
|
||||
<div ref={ref} {...others}>
|
||||
{label || value}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,9 +10,18 @@ const EXAMPLE_DATA = [
|
||||
{ label: "Bar (2)", value: "bar-2" },
|
||||
];
|
||||
|
||||
type SetupOpts = Omit<TestInputProps, "onChange">;
|
||||
const NUMERIC_DATA = [
|
||||
{ label: "1", value: 1 },
|
||||
{ label: "2", value: 2 },
|
||||
{ label: "3", value: 3 },
|
||||
];
|
||||
|
||||
function setup(opts: SetupOpts) {
|
||||
type SetupOpts<ValueType extends string | number> = Omit<
|
||||
TestInputProps<ValueType>,
|
||||
"onChange"
|
||||
>;
|
||||
|
||||
function setup<ValueType extends string | number>(opts: SetupOpts<ValueType>) {
|
||||
const onChange = jest.fn();
|
||||
render(<TestInput {...opts} onChange={onChange} aria-label="Filter value" />);
|
||||
|
||||
@@ -20,14 +29,17 @@ function setup(opts: SetupOpts) {
|
||||
return { onChange, input };
|
||||
}
|
||||
|
||||
type TestInputProps = MultiAutocompleteProps & {
|
||||
initialValue?: string[];
|
||||
};
|
||||
type TestInputProps<ValueType extends string | number> =
|
||||
MultiAutocompleteProps<ValueType> & {
|
||||
initialValue?: ValueType[];
|
||||
};
|
||||
|
||||
function TestInput(props: TestInputProps) {
|
||||
function TestInput<ValueType extends string | number>(
|
||||
props: TestInputProps<ValueType>,
|
||||
) {
|
||||
const [value, setValue] = useState(props.initialValue ?? []);
|
||||
|
||||
function handleChange(value: string[]) {
|
||||
function handleChange(value: ValueType[]) {
|
||||
setValue(value);
|
||||
props.onChange?.(value);
|
||||
}
|
||||
@@ -58,7 +70,7 @@ describe("MultiAutocomplete", () => {
|
||||
it("should not accept values when blurring if they are not accepted by shouldCreate", async () => {
|
||||
const { input, onChange } = setup({
|
||||
data: EXAMPLE_DATA,
|
||||
shouldCreate(value: string) {
|
||||
shouldCreate(value: string | number) {
|
||||
return value === "foo";
|
||||
},
|
||||
});
|
||||
@@ -166,7 +178,7 @@ describe("MultiAutocomplete", () => {
|
||||
it("should not accept values when entering a comma if they are not accepted by shouldCreate", async () => {
|
||||
const { input, onChange } = setup({
|
||||
data: EXAMPLE_DATA,
|
||||
shouldCreate(value: string) {
|
||||
shouldCreate(value: string | number) {
|
||||
return value === "foo";
|
||||
},
|
||||
});
|
||||
@@ -211,7 +223,7 @@ describe("MultiAutocomplete", () => {
|
||||
it("should accept comma-separated values, but omit values not accepted by shouldCreate", async () => {
|
||||
const { input, onChange } = setup({
|
||||
data: EXAMPLE_DATA,
|
||||
shouldCreate(value: string) {
|
||||
shouldCreate(value: string | number) {
|
||||
return value === "foo" || value === "bar";
|
||||
},
|
||||
});
|
||||
@@ -282,4 +294,109 @@ describe("MultiAutocomplete", () => {
|
||||
expect(onChange).toHaveBeenLastCalledWith(["כּטץ", "ףקמ"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
describe("numeric values", () => {
|
||||
it("should work with number values", async () => {
|
||||
const { input, onChange } = setup({
|
||||
data: NUMERIC_DATA,
|
||||
parseValue: str => parseFloat(str),
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await userEvent.type(input, "55,", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([55]);
|
||||
|
||||
await userEvent.type(input, "42,12.34,", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([55, 42, 12.34]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should accept comma-separated values when pasting", async () => {
|
||||
const { input, onChange } = setup<number>({
|
||||
data: NUMERIC_DATA,
|
||||
parseValue: str => parseFloat(str),
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await userEvent.paste("33.333,42");
|
||||
expect(onChange).toHaveBeenLastCalledWith([33.333, 42]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should not be possible to enter duplicate values", async () => {
|
||||
const { input, onChange } = setup<number>({
|
||||
data: NUMERIC_DATA,
|
||||
parseValue: str => parseFloat(str),
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await userEvent.type(input, "1,1,2,2,1,1,", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([1, 2]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should not accept NaN values", async () => {
|
||||
const { input, onChange } = setup<number>({
|
||||
data: NUMERIC_DATA,
|
||||
parseValue: str => parseFloat(str),
|
||||
});
|
||||
input.focus();
|
||||
await userEvent.paste("10,11,12,not-a-number");
|
||||
expect(onChange).toHaveBeenLastCalledWith([10, 11, 12]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should accept comma-separated values, but omit values not accepted by shouldCreate", async () => {
|
||||
const { input, onChange } = setup<number>({
|
||||
data: NUMERIC_DATA,
|
||||
parseValue: str => parseFloat(str),
|
||||
shouldCreate(value: number | string) {
|
||||
if (typeof value !== "number") {
|
||||
return false;
|
||||
}
|
||||
return value % 2 === 0;
|
||||
},
|
||||
});
|
||||
input.focus();
|
||||
await userEvent.paste("10,11,12,not-a-number");
|
||||
expect(onChange).toHaveBeenLastCalledWith([10, 12]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be possible to customize the way a value renders", async () => {
|
||||
const { input } = setup<string>({
|
||||
data: EXAMPLE_DATA,
|
||||
renderValue: (value: string) => `__${value.toUpperCase()}__`,
|
||||
});
|
||||
|
||||
await userEvent.type(input, "Fo", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
expect(screen.getByText("__FOO__")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be possible to customize what values get filtered", async () => {
|
||||
const { input } = setup<string>({
|
||||
data: EXAMPLE_DATA,
|
||||
filter: (_query, _selected, item) => !item.label?.endsWith(")"),
|
||||
});
|
||||
|
||||
await userEvent.type(input, "Ba", {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Bar (2)")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Base = string | number;
|
||||
|
||||
export type SelectItem<TValue extends Base> = {
|
||||
label?: string;
|
||||
value: TValue;
|
||||
};
|
||||
|
||||
export type FilterFn<TValue extends Base> = (
|
||||
query: string,
|
||||
selected: boolean,
|
||||
item: SelectItem<TValue>,
|
||||
) => boolean;
|
||||
@@ -1,8 +1,13 @@
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
|
||||
export function parseValues(str: string): string[] {
|
||||
import type { Base } from "./types";
|
||||
|
||||
export function parseValues<TValue extends Base>(
|
||||
str: string,
|
||||
parser: (str: string) => TValue | null = defaultParser,
|
||||
): TValue[] {
|
||||
try {
|
||||
return parse(str, {
|
||||
const strings = parse(str, {
|
||||
delimiter: [",", "\t", "\n"],
|
||||
skip_empty_lines: true,
|
||||
relax_column_count: true,
|
||||
@@ -11,11 +16,18 @@ export function parseValues(str: string): string[] {
|
||||
quote: '"',
|
||||
escape: "\\",
|
||||
}).flat();
|
||||
|
||||
return strings.map(parser);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function unique(values: string[]): string[] {
|
||||
function defaultParser<TValue extends Base>(str: string): TValue | null {
|
||||
// @ts-expect-error: for the default case we ignore the type
|
||||
return str;
|
||||
}
|
||||
|
||||
export function unique<TValue extends Base>(values: TValue[]): TValue[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
@@ -48,10 +48,19 @@ export const getMultiSelectOverrides =
|
||||
gap: theme.spacing.xs,
|
||||
padding: theme.spacing.xs,
|
||||
alignItems: "center",
|
||||
"[data-with-icon=true] &": {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
input: {
|
||||
padding: 0,
|
||||
boxSizing: "border-box",
|
||||
"&[data-with-icon]": {
|
||||
paddingLeft: theme.spacing.lg,
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
width: theme.spacing.lg,
|
||||
},
|
||||
value: {
|
||||
margin: 0,
|
||||
@@ -68,6 +77,9 @@ export const getMultiSelectOverrides =
|
||||
"&::-webkit-search-cancel-button": {
|
||||
display: "none",
|
||||
},
|
||||
"[data-with-icon=true] &:first-child": {
|
||||
marginLeft: 0,
|
||||
},
|
||||
},
|
||||
defaultValue: {
|
||||
padding: 0,
|
||||
|
||||
Reference in New Issue
Block a user