Compare commits

...

71 Commits

Author SHA1 Message Date
Romeo Van Snick
20f27b7553 Use MultiAutocomplete in NumberInputWidget when arity is 1 2024-06-07 15:45:39 +02:00
Romeo Van Snick
4f4121eeee Limit to input to have arity 1 when multi is false 2024-06-07 15:45:05 +02:00
Romeo Van Snick
87f576ceef Allow picking arity of number parameters 2024-06-07 15:44:32 +02:00
Romeo Van Snick
883f90603a Add tests for filter and renderValue 2024-06-07 12:38:42 +02:00
Romeo Van Snick
14bbb67d14 Add missing forwardRef 2024-06-07 12:38:42 +02:00
Romeo Van Snick
58bcdd7747 Rename ValueType to TValue 2024-06-07 12:38:42 +02:00
Romeo Van Snick
ab75bd6f0d Move shared types to types file 2024-06-07 12:38:40 +02:00
Romeo Van Snick
a5b75f85a5 Remove the unused color prop 2024-06-07 11:50:59 +02:00
Romeo Van Snick
7c07a7ad44 Remove commented out code 2024-06-07 11:47:33 +02:00
Romeo Van Snick
15ef06c732 Close the suggestions menu using blur 2024-06-07 11:44:42 +02:00
Romeo Van Snick
4ee72318f8 Use removeMultiAutocompleteValue 2024-06-07 11:17:12 +02:00
Romeo Van Snick
e27c018bb5 Remove debugging logs 2024-06-06 18:35:49 +02:00
Romeo Van Snick
5e72037735 Fix sharing test 2024-06-06 17:59:40 +02:00
Romeo Van Snick
9c7854a253 Set viewport in test 2024-06-06 16:54:13 +02:00
Romeo Van Snick
42474d76c1 Fix sharing filter 2024-06-06 16:45:21 +02:00
Romeo Van Snick
50f978ac6b Fix email test 2024-06-06 16:45:21 +02:00
Romeo Van Snick
29ed6cbd49 Close the suggestions menu 2024-06-06 16:45:21 +02:00
Romeo Van Snick
bc54a53c74 Force click filter 2024-06-06 16:45:21 +02:00
Romeo Van Snick
af335c6945 Handle multiautocomplete inputs 2024-06-06 16:45:20 +02:00
Romeo Van Snick
94d66dccfb Use removeMultiAutocompleteValue 2024-06-06 16:45:20 +02:00
Romeo Van Snick
e37756ed78 Fix sharing test 2024-06-06 16:45:20 +02:00
Romeo Van Snick
c014975c23 Force click 2024-06-06 16:45:20 +02:00
Romeo Van Snick
32023d7001 Fix embedding tests 2024-06-06 16:45:20 +02:00
Romeo Van Snick
dfd9f7e8f3 Fix default behaviour for label 2024-06-06 16:45:20 +02:00
Romeo Van Snick
70cea1d476 Be more explicit about broken types in FieldValuesWidget 2024-06-06 16:45:20 +02:00
Romeo Van Snick
80e3235d68 Fix data prop type 2024-06-06 16:45:20 +02:00
Romeo Van Snick
9a04592252 Use renderValue in FieldValuesWidget 2024-06-06 16:45:20 +02:00
Romeo Van Snick
10cfe1f729 Allow rendering custom label for item 2024-06-06 16:45:19 +02:00
Romeo Van Snick
653f9ab499 Fix embedding tests 2024-06-06 16:45:19 +02:00
Romeo Van Snick
f9d07f332c Format field value 2024-06-06 16:45:19 +02:00
Romeo Van Snick
9d596dec03 Add filter 2024-06-06 16:45:18 +02:00
Romeo Van Snick
b3242a0cda Fix filter test 2024-06-06 16:44:53 +02:00
Romeo Van Snick
8b63059185 Handle multiselect 2024-06-06 16:44:52 +02:00
Romeo Van Snick
a36071bd6e Handle comma's in values 2024-06-06 16:44:52 +02:00
Romeo Van Snick
b9b5d814ff Use multiAutocompleteInput helper 2024-06-06 16:44:52 +02:00
Romeo Van Snick
9c8fba25f0 Remove unused variable 2024-06-06 16:44:52 +02:00
Romeo Van Snick
ba134a1af1 Use removeMultiAutocompleteValue 2024-06-06 16:44:52 +02:00
Romeo Van Snick
5265845b1c Use removeMultiAutocompleteValue 2024-06-06 16:44:52 +02:00
Romeo Van Snick
f084d6bfb1 Fix filter tests 2024-06-06 16:44:52 +02:00
Romeo Van Snick
80ccf66fa7 Fix embedding tests 2024-06-06 16:44:52 +02:00
Romeo Van Snick
50452a1877 Fix nested test 2024-06-06 16:44:52 +02:00
Romeo Van Snick
ff1ec7d54c Fix chained filter test 2024-06-06 16:44:51 +02:00
Romeo Van Snick
bb0d59f769 Add helpers to remove values 2024-06-06 16:44:51 +02:00
Romeo Van Snick
d76c6504b0 Fix embedding test 2024-06-06 16:44:51 +02:00
Romeo Van Snick
bb3afb6bfc Fix dasboard test 2024-06-06 16:44:51 +02:00
Romeo Van Snick
9a76837869 Fix type in NumberInput 2024-06-06 16:44:51 +02:00
Romeo Van Snick
99834a57b1 Fix tests for DefaultPicker 2024-06-06 16:44:51 +02:00
Romeo Van Snick
13a20d3df8 Fix tests for NumberInputWidget 2024-06-06 16:44:51 +02:00
Romeo Van Snick
47bfcfbbbc Find the value remove button in test 2024-06-06 16:44:51 +02:00
Romeo Van Snick
582e76e3e1 Use multiAutocompleteInput helper 2024-06-06 16:44:50 +02:00
Romeo Van Snick
2b153b05ab Fix filter helper 2024-06-06 16:44:50 +02:00
Romeo Van Snick
a49a1ed4b4 Handle MultiAutocomplete input in tests 2024-06-06 16:44:50 +02:00
Romeo Van Snick
6487a33a61 Add helpers to work with MultiSelect and MultiAutocomplete inputs 2024-06-06 16:44:49 +02:00
Romeo Van Snick
5e50404c72 Fix MultiAutocomplete tests 2024-06-06 16:44:24 +02:00
Romeo Van Snick
d3600bdae7 Fix typo 2024-06-06 16:44:24 +02:00
Romeo Van Snick
16a40b9529 Refine MultiAutocomplete type in FieldValuesWidget 2024-06-06 16:44:24 +02:00
Romeo Van Snick
1f4e33b51c Refine MultiAutocomplete type in NumberInputWidget 2024-06-06 16:44:24 +02:00
Romeo Van Snick
4e9f7ba6a3 Make MultiAutoComplete generic in its value type 2024-06-06 16:44:24 +02:00
Romeo Van Snick
d436b467cb Fix MultiAutocomplete types 2024-06-06 16:44:23 +02:00
Romeo Van Snick
788b613e42 Make value optional 2024-06-06 16:44:23 +02:00
Romeo Van Snick
80bc853634 Handle undefined values 2024-06-06 16:44:23 +02:00
Romeo Van Snick
6ae745a020 Do not allow adding multiple values when multi is false 2024-06-06 16:44:23 +02:00
Romeo Van Snick
5bdc256d5e Do not expect formatted number in values 2024-06-06 16:44:23 +02:00
Romeo Van Snick
26210bdeef Filter out empty items 2024-06-06 16:44:23 +02:00
Romeo Van Snick
5248e4bbaf Use MultiAutocomplete in NumberInputWidget 2024-06-06 16:44:23 +02:00
Romeo Van Snick
2d69c3ce2e Accept number values in MultiAutocomplete 2024-06-06 16:44:23 +02:00
Romeo Van Snick
ced515f7cf Handle numbers in MultiAutocomplete 2024-06-06 16:44:22 +02:00
Romeo Van Snick
368573803d Remove input className 2024-06-06 16:44:22 +02:00
Romeo Van Snick
c65cf249be Remove unnecessary tests for empty values 2024-06-06 16:44:22 +02:00
Romeo Van Snick
e9af08e034 Handle prefix on MultiAutocomplete 2024-06-06 16:44:22 +02:00
Romeo Van Snick
691fff56e1 Use MultiAutocomplete in dashboard filters 2024-06-06 16:44:22 +02:00
41 changed files with 692 additions and 259 deletions

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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();
});

View File

@@ -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");
});
});
}

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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")

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -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",
},

View File

@@ -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");

View File

@@ -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();
});

View File

@@ -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 =>

View File

@@ -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")

View File

@@ -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");

View File

@@ -84,7 +84,7 @@ export const DATE_FILTER_SUBTYPES = {
},
"Date Filter": {
value: {
timeBucket: "years",
timeBucket: "month",
},
representativeResult: "Synergistic Steel Chair",
},

View File

@@ -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}`);
}
/**

View File

@@ -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();
});

View File

@@ -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(

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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");

View File

@@ -149,6 +149,7 @@ export const SINGLE_OR_MULTI_SELECTABLE_TYPES: Record<
category: "any",
id: "any",
location: ["=", "!="],
number: ["=", "!="],
};
export const FIELD_FILTER_PARAMETER_TYPES = [

View File

@@ -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", () => {

View File

@@ -137,7 +137,6 @@ export function DefaultPicker({
return (
<FieldValuesWidget
key={index}
className={CS.input}
value={values}
onChange={onValuesChange}
multi={operator.multi}

View File

@@ -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;
}

View File

@@ -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}
/>
);
}

View File

@@ -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();
});
});
});

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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 [];
}

View File

@@ -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>

View File

@@ -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>
);
},
);

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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,