From d5bd7cac1fad8c243324d0455f43c95462f42844 Mon Sep 17 00:00:00 2001 From: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com> Date: Fri, 17 May 2024 12:37:14 +0300 Subject: [PATCH] Maintain user-level state in dashboards for filters (#39813) --- .../dashboard-cards/click-behavior.cy.spec.js | 5 + ...ters-sql-required-simple-filter.cy.spec.js | 4 +- .../dashboard-filters/parameters.cy.spec.js | 122 ++++++++++++++++-- ...flter-cc-dropped-on-second-edit.cy.spec.js | 3 +- ...ully-deal-with-corrupted-filter.cy.spec.js | 9 +- .../v1/parameters/utils/parameter-values.js | 7 +- .../utils/parameter-values.unit.spec.js | 53 ++++++++ frontend/src/metabase-types/api/dashboard.ts | 4 + .../src/metabase-types/api/mocks/dashboard.ts | 1 + .../dashboard/actions/data-fetching-typed.ts | 8 +- .../metabase/dashboard/actions/parameters.ts | 11 +- .../components/DashboardTabs/test-utils.ts | 1 + .../parameters/components/ParametersList.jsx | 3 +- .../parameters/utils/parameter-values.ts | 16 ++- .../utils/parameter-values.unit.spec.js | 24 ++++ 15 files changed, 250 insertions(+), 21 deletions(-) diff --git a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js index a5e3fb59e87..fd88ff4e7c1 100644 --- a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js +++ b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js @@ -27,6 +27,7 @@ import { visitEmbeddedPage, visitIframe, entityPickerModal, + filterWidget, } from "e2e/support/helpers"; import { b64hash_to_utf8 } from "metabase/lib/encoding"; import { @@ -964,6 +965,10 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => { }); }); + cy.log("reset filter state"); + + filterWidget().icon("close").click(); + testChangingBackToDefaultBehavior(); }); diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js index ed5231ab489..76e86d71bff 100644 --- a/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-sql-required-simple-filter.cy.spec.js @@ -113,8 +113,8 @@ describe("scenarios > dashboard > filters > SQL > simple filter > required ", () saveDashboard(); - // The URL query params should include the parameter with an empty value - cy.location("search").should("eq", "?text="); + // The URL query params should include the last used parameter value + cy.location("search").should("eq", "?text=Bar"); }); }); diff --git a/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js index acd4d3b8414..4089163fa9b 100644 --- a/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/parameters.cy.spec.js @@ -29,9 +29,17 @@ import { spyRequestFinished, entityPickerModal, } from "e2e/support/helpers"; - -const { ORDERS_ID, ORDERS, PRODUCTS, PRODUCTS_ID, REVIEWS_ID } = - SAMPLE_DATABASE; +import { createMockParameter } from "metabase-types/api/mocks"; + +const { + ORDERS_ID, + ORDERS, + PRODUCTS, + PRODUCTS_ID, + REVIEWS_ID, + PEOPLE, + PEOPLE_ID, +} = SAMPLE_DATABASE; describe("scenarios > dashboard > parameters", () => { const cards = [ @@ -246,14 +254,15 @@ describe("scenarios > dashboard > parameters", () => { cy.findByText("Remove").click(); cy.location("search").should("eq", `?${endsWith.slug}=zmo`); - cy.button("Save").click(); + saveDashboard(); + + cy.log( + "There should only be one filter remaining and its value is preserved", + ); - cy.log("There should only be one filter remaining and its value cleared"); filterWidget().contains(new RegExp(`${endsWith.name}`, "i")); - cy.location("search").should("eq", `?${endsWith.slug}=`); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("37.65"); + cy.location("search").should("eq", `?${endsWith.slug}=zmo`); }); it("should handle mismatch between filter types (metabase#9299, metabase#16181)", () => { @@ -1126,6 +1135,103 @@ describe("scenarios > dashboard > parameters", () => { }); }); }); + + describe("preserve last used value", () => { + beforeEach(() => { + const textFilter = createMockParameter({ + name: "Text", + slug: "string", + id: "5aefc726", + type: "string/=", + sectionId: "string", + }); + + const peopleQuestionDetails = { + query: { "source-table": PEOPLE_ID, limit: 5 }, + }; + + cy.createDashboardWithQuestions({ + dashboardDetails: { + parameters: [textFilter], + }, + questions: [peopleQuestionDetails], + }).then(({ dashboard, questions: cards }) => { + const [peopleCard] = cards; + + updateDashboardCards({ + dashboard_id: dashboard.id, + cards: [ + { + card_id: peopleCard.id, + parameter_mappings: [ + { + parameter_id: textFilter.id, + card_id: peopleCard.id, + target: ["dimension", ["field", PEOPLE.NAME, null]], + }, + ], + }, + ], + }); + + visitDashboard(dashboard.id); + + cy.wrap(dashboard.id).as("dashboardId"); + }); + }); + + it("should retain the last used value for a dashboard filter", () => { + cy.intercept("GET", "/api/**/items?pinned_state*").as("getPinnedItems"); + + filterWidget().click(); + + popover().within(() => { + cy.findByRole("textbox").type("Antwan Fisher"); + cy.button("Add filter").click(); + }); + + getDashboardCard().findByText("7750 Michalik Lane").should("be.visible"); + + cy.visit("/collection/root"); + cy.wait("@getPinnedItems"); + + cy.get("@dashboardId").then(dashboardId => visitDashboard(dashboardId)); + + filterWidget() + .findByRole("listitem") + .should("have.text", "Antwan Fisher"); + + cy.log("verify filter resetting works"); + + filterWidget().icon("close").click(); + getDashboardCard().findByText("761 Fish Hill Road").should("be.visible"); + }); + + it("should allow resetting last used value", () => { + filterWidget().click(); + + popover().within(() => { + cy.findByRole("textbox").type("Antwan Fisher"); + cy.button("Add filter").click(); + }); + + getDashboardCard().findByText("7750 Michalik Lane").should("be.visible"); + + cy.log("reset filter values from url by visiting dashboard by id"); + + cy.get("@dashboardId").then(dashboardId => visitDashboard(dashboardId)); + + filterWidget().icon("close").click(); + + getDashboardCard().findByText("761 Fish Hill Road").should("be.visible"); + + cy.log("verify filter value is not specified after reload"); + + cy.get("@dashboardId").then(dashboardId => visitDashboard(dashboardId)); + + getDashboardCard().findByText("761 Fish Hill Road").should("be.visible"); + }); + }); }); function isFilterSelected(filter, bool) { diff --git a/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js index d382eb9afb2..e385cdf27b1 100644 --- a/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/22788-flter-cc-dropped-on-second-edit.cy.spec.js @@ -90,7 +90,8 @@ describe("issue 22788", () => { saveDashboard(); - addFilterAndAssert(); + cy.findAllByText("Gizmo"); + cy.findAllByText("Doohickey").should("not.exist"); }); }); diff --git a/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js b/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js index 5681b002adc..61a307e2e59 100644 --- a/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/reproductions/24500-gracefully-deal-with-corrupted-filter.cy.spec.js @@ -119,11 +119,16 @@ describe("issues 15279 and 24500", () => { saveDashboard(); // Check the list filter again - filterWidget().contains("List").click(); + filterWidget().contains("List").parent().click(); cy.wait("@values"); - // Check that the search filter works + cy.log("Check that the search filter works"); + + // reset filter value + filterWidget().contains("Search").parent().icon("close").click(); + filterWidget().contains("Search").click(); + cy.findByPlaceholderText("Search by Name").type("Lora Cronin"); cy.button("Add filter").click(); diff --git a/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.js b/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.js index 221e57100b5..78c6140e8f2 100644 --- a/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.js +++ b/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.js @@ -18,10 +18,14 @@ export function getParameterValue({ parameter, values = {}, defaultRequired = false, + lastUsedParameterValue = null, }) { const value = values?.[parameter.id]; const useDefault = defaultRequired && parameter.required; - return value ?? (useDefault ? parameter.default : null); + + return ( + lastUsedParameterValue ?? value ?? (useDefault ? parameter.default : null) + ); } /** @@ -133,6 +137,7 @@ export function normalizeParameterValue(type, value) { export function getParameterValuesBySlug(parameters, parameterValuesById) { parameters = parameters ?? []; parameterValuesById = parameterValuesById ?? {}; + return Object.fromEntries( parameters.map(parameter => [ parameter.slug, diff --git a/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.unit.spec.js b/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.unit.spec.js index bf95dcd2b38..37f0f9f6de5 100644 --- a/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.unit.spec.js +++ b/frontend/src/metabase-lib/v1/parameters/utils/parameter-values.unit.spec.js @@ -2,7 +2,9 @@ import { getValuePopulatedParameters, getParameterValuesBySlug, normalizeParameterValue, + getParameterValue, } from "metabase-lib/v1/parameters/utils/parameter-values"; +import { createMockParameter } from "metabase-types/api/mocks"; describe("parameters/utils/parameter-values", () => { let field1; @@ -196,6 +198,57 @@ describe("parameters/utils/parameter-values", () => { }); }); + describe("getParameterValue", () => { + const parameter = createMockParameter({ + id: 333, + slug: "baz", + default: "parameter default value", + fields: [field1], + required: true, + }); + + it("should return last_used_param_value if there is any", () => { + expect( + getParameterValue({ + parameter, + values: { [parameter.id]: "parameter value" }, + defaultRequired: true, + lastUsedParameterValue: "last used value", + }), + ).toBe("last used value"); + }); + + it("should return value when there is no last_used_param_value", () => { + expect( + getParameterValue({ + parameter, + values: { [parameter.id]: "parameter value" }, + defaultRequired: true, + }), + ).toBe("parameter value"); + }); + + it("should return default value if parameter is required and default is required", () => { + expect( + getParameterValue({ + parameter, + values: {}, + defaultRequired: true, + }), + ).toBe("parameter default value"); + }); + + it("should return null if no default value required", () => { + expect( + getParameterValue({ + parameter, + values: {}, + defaultRequired: false, + }), + ).toBe(null); + }); + }); + describe("normalizeParameterValue", () => { it("should return same value for location/category parameters", () => { expect(normalizeParameterValue("category", "foo")).toEqual("foo"); diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index 60f31b85c11..a334d962ad8 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -52,6 +52,10 @@ export interface Dashboard { last_name: string; timestamp: string; }; + last_used_param_values: Record< + ParameterId, + string | number | boolean | null | string[] | number[] + >; auto_apply_filters: boolean; archived: boolean; public_uuid: string | null; diff --git a/frontend/src/metabase-types/api/mocks/dashboard.ts b/frontend/src/metabase-types/api/mocks/dashboard.ts index 5e95ad14e73..4e2e0c2a841 100644 --- a/frontend/src/metabase-types/api/mocks/dashboard.ts +++ b/frontend/src/metabase-types/api/mocks/dashboard.ts @@ -26,6 +26,7 @@ export const createMockDashboard = (opts?: Partial<Dashboard>): Dashboard => ({ last_name: "Doe", timestamp: "2018-01-01", }, + last_used_param_values: {}, auto_apply_filters: true, archived: false, public_uuid: null, diff --git a/frontend/src/metabase/dashboard/actions/data-fetching-typed.ts b/frontend/src/metabase/dashboard/actions/data-fetching-typed.ts index 17f3a47f83b..f052e723d1c 100644 --- a/frontend/src/metabase/dashboard/actions/data-fetching-typed.ts +++ b/frontend/src/metabase/dashboard/actions/data-fetching-typed.ts @@ -169,9 +169,15 @@ export const fetchDashboard = createAsyncThunk( questions, ); + const lastUsedParametersValues = result["last_used_param_values"] ?? {}; + const parameterValuesById = preserveParameters ? getParameterValues(getState()) - : getParameterValuesByIdFromQueryParams(parameters, queryParams); + : getParameterValuesByIdFromQueryParams( + parameters, + queryParams, + lastUsedParametersValues, + ); entities = entities ?? normalize(result, dashboard).entities; diff --git a/frontend/src/metabase/dashboard/actions/parameters.ts b/frontend/src/metabase/dashboard/actions/parameters.ts index bdc1a89c7a5..c16e77f30a5 100644 --- a/frontend/src/metabase/dashboard/actions/parameters.ts +++ b/frontend/src/metabase/dashboard/actions/parameters.ts @@ -147,6 +147,7 @@ export const removeParameter = createThunkAction( updateParameters(dispatch, getState, parameters => parameters.filter(p => p.id !== parameterId), ); + return { id: parameterId }; }, ); @@ -267,6 +268,7 @@ export const setParameterName = createThunkAction( updateParameter(dispatch, getState, parameterId, parameter => setParamName(parameter, name), ); + return { id: parameterId, name }; }, ); @@ -372,6 +374,7 @@ export const setParameterFilteringParameters = createThunkAction( ...parameter, filteringParameters, })); + return { id: parameterId, filteringParameters }; }, ); @@ -379,12 +382,13 @@ export const setParameterFilteringParameters = createThunkAction( export const SET_PARAMETER_VALUE = "metabase/dashboard/SET_PARAMETER_VALUE"; export const setParameterValue = createThunkAction( SET_PARAMETER_VALUE, - (parameterId: ParameterId, value: any) => (_dispatch, getState) => { + (parameterId: ParameterId, value: unknown) => (_dispatch, getState) => { const isSettingDraftParameterValues = !getIsAutoApplyFilters(getState()); + const isValueEmpty = isParameterValueEmpty(value); return { id: parameterId, - value: isParameterValueEmpty(value) ? PULSE_PARAM_EMPTY : value, + value: isValueEmpty ? PULSE_PARAM_EMPTY : value, isDraft: isSettingDraftParameterValues, }; }, @@ -466,6 +470,7 @@ export const setParameterIsMultiSelect = createThunkAction( ...parameter, isMultiSelect: isMultiSelect, })); + return { id: parameterId, isMultiSelect }; }, ); @@ -480,6 +485,7 @@ export const setParameterQueryType = createThunkAction( ...parameter, values_query_type: queryType, })); + return { id: parameterId, queryType }; }, ); @@ -494,6 +500,7 @@ export const setParameterSourceType = createThunkAction( ...parameter, values_source_type: sourceType, })); + return { id: parameterId, sourceType }; }, ); diff --git a/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts b/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts index d4e5ba7f4b8..e1bb757e665 100644 --- a/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts +++ b/frontend/src/metabase/dashboard/components/DashboardTabs/test-utils.ts @@ -40,6 +40,7 @@ export const TEST_DASHBOARD_STATE: DashboardState = { last_name: "", timestamp: "", }, + last_used_param_values: {}, dashcards: [1, 2], tabs: [ getDefaultTab({ tabId: 1, dashId: 1, name: "Tab 1" }), diff --git a/frontend/src/metabase/parameters/components/ParametersList.jsx b/frontend/src/metabase/parameters/components/ParametersList.jsx index dbb1b9b4ce3..59b6204b742 100644 --- a/frontend/src/metabase/parameters/components/ParametersList.jsx +++ b/frontend/src/metabase/parameters/components/ParametersList.jsx @@ -66,7 +66,8 @@ function ParametersList({ setEditingParameter={setEditingParameter} setValue={ setParameterValue && - (value => setParameterValue(valuePopulatedParameter.id, value)) + (value => + setParameterValue(valuePopulatedParameter.id, value, dashboard?.id)) } setParameterValueToDefault={setParameterValueToDefault} enableParameterRequiredBehavior={enableParameterRequiredBehavior} diff --git a/frontend/src/metabase/parameters/utils/parameter-values.ts b/frontend/src/metabase/parameters/utils/parameter-values.ts index 72292618696..1ad8a7ea0a2 100644 --- a/frontend/src/metabase/parameters/utils/parameter-values.ts +++ b/frontend/src/metabase/parameters/utils/parameter-values.ts @@ -3,6 +3,7 @@ import type { FieldFilterUiParameter } from "metabase-lib/v1/parameters/types"; import { getParameterType } from "metabase-lib/v1/parameters/utils/parameter-type"; import type { Parameter, + ParameterId, ParameterValue, ParameterValueOrArray, } from "metabase-types/api"; @@ -10,8 +11,10 @@ import type { export function getParameterValueFromQueryParams( parameter: Parameter, queryParams: Record<string, string | string[] | null | undefined>, + lastUsedParametersValues?: Record<ParameterId, unknown>, ) { queryParams = queryParams || {}; + lastUsedParametersValues = lastUsedParametersValues || {}; const maybeParameterValue = queryParams[parameter.slug || parameter.id]; @@ -19,9 +22,11 @@ export function getParameterValueFromQueryParams( if (maybeParameterValue === "") { return null; } else if (maybeParameterValue == null) { - // try to use the default if the parameter is not present in the query params - return parameter.default ?? null; + // first try to use last used parameter value then try to use the default if + // the parameter is not present in the query params + return lastUsedParametersValues[parameter.id] ?? parameter.default ?? null; } + const parsedValue = parseParameterValue(maybeParameterValue, parameter); return normalizeParameterValueForWidget(parsedValue, parameter); } @@ -100,11 +105,16 @@ function normalizeParameterValueForWidget( export function getParameterValuesByIdFromQueryParams( parameters: Parameter[], queryParams: Record<string, string | string[] | null | undefined>, + lastUsedParametersValues?: Record<ParameterId, unknown>, ) { return Object.fromEntries( parameters.map(parameter => [ parameter.id, - getParameterValueFromQueryParams(parameter, queryParams), + getParameterValueFromQueryParams( + parameter, + queryParams, + lastUsedParametersValues, + ), ]), ); } diff --git a/frontend/src/metabase/parameters/utils/parameter-values.unit.spec.js b/frontend/src/metabase/parameters/utils/parameter-values.unit.spec.js index 784d8018cd0..3ec2a18c552 100644 --- a/frontend/src/metabase/parameters/utils/parameter-values.unit.spec.js +++ b/frontend/src/metabase/parameters/utils/parameter-values.unit.spec.js @@ -266,6 +266,30 @@ describe("parameters/utils/parameter-values", () => { ); }); + describe("last used param value", () => { + it("should use query parameter over last used param value", () => { + expect( + getParameterValueFromQueryParams( + parameter2, + { + [parameter2.slug]: "parameter 2 value", + }, + { [parameter2.id]: "last used value" }, + ), + ).toEqual(["parameter 2 value"]); + }); + + it("should use last used param value when query parameter is empty", () => { + expect( + getParameterValueFromQueryParams( + parameter2, + {}, + { [parameter2.id]: "last used value" }, + ), + ).toEqual("last used value"); + }); + }); + describe("for number filter type", () => { const numberParameter = { id: 111, -- GitLab