diff --git a/e2e/support/helpers/e2e-dashboard-helpers.js b/e2e/support/helpers/e2e-dashboard-helpers.js index 0d5feaee7fcfd429ffeed67b053a528745587453..e19c04aee97b67fa29a05c66b912685c7594e0d2 100644 --- a/e2e/support/helpers/e2e-dashboard-helpers.js +++ b/e2e/support/helpers/e2e-dashboard-helpers.js @@ -138,8 +138,12 @@ export function setFilter(type, subType) { }); } +export function getRequiredToggle() { + return cy.findByLabelText("Always require a value"); +} + export function toggleRequiredParameter() { - cy.findByLabelText("Always require a value").click(); + getRequiredToggle().click(); } export function createEmptyTextBox() { diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index 4dc1a544d88f0be209c59e15e96de2d73aac2de5..3fa187c63d1ece711f3f8ea0fcd8507a5e0e46a9 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -1,5 +1,5 @@ import { METABASE_SECRET_KEY } from "e2e/support/cypress_data"; -import { modal } from "e2e/support/helpers/e2e-ui-elements-helpers"; +import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers"; /** * @typedef {object} QuestionResource @@ -197,6 +197,44 @@ export function openStaticEmbeddingModal({ }); } +export function closeStaticEmbeddingModal() { + modal().icon("close").click(); +} + +/** + * Open Static Embedding setup modal + * @param {"card" | "dashboard"} apiPath + * @param callback + */ +export function publishChanges(apiPath, callback) { + cy.intercept("PUT", `/api/${apiPath}/*`).as("publishChanges"); + + cy.button(/^(Publish|Publish changes)$/).click(); + + // TODO this could be simplified when we send one publish request instead of two + cy.wait(["@publishChanges", "@publishChanges"]).then(xhrs => { + // Unfortunately, the order of requests is not always the same. + // Therefore, we must first get the one that has the `embedding_params` and then assert on it. + const targetXhr = xhrs.find(({ request }) => + Object.keys(request.body).includes("embedding_params"), + ); + callback?.(targetXhr); + }); +} + +export function getParametersContainer() { + return cy.findByLabelText("Configuring parameters"); +} + +export function setEmbeddingParameter(name, value) { + getParametersContainer().findByLabelText(name).click(); + popover().contains(value).click(); +} + +export function assertEmbeddingParameter(name, value) { + getParametersContainer().findByLabelText(name).should("have.text", value); +} + // @param {("card"|"dashboard")} resourceType - The type of resource we are sharing export function openNewPublicLinkDropdown(resourceType) { cy.intercept("POST", `/api/${resourceType}/*/public_link`).as( diff --git a/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js index e9108a0b7f221ab9a764b1426953b94f0747472e..fe2fffc0a5cd3a7b4fe16bd742fb47358126bb5a 100644 --- a/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-dashboard.cy.spec.js @@ -16,9 +16,19 @@ import { setTokenFeatures, dashboardParametersContainer, goToTab, + editDashboard, + toggleRequiredParameter, + sidebar, + saveDashboard, + getRequiredToggle, + closeStaticEmbeddingModal, + publishChanges, + setEmbeddingParameter, + assertEmbeddingParameter, } from "e2e/support/helpers"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { addWidgetStringFilter } from "../native-filters/helpers/e2e-field-filter-helpers"; import { questionDetails, questionDetailsWithDefaults, @@ -103,7 +113,7 @@ describe("scenarios > embedding > dashboard parameters", () => { }); // publish the embedded dashboard so that we can directly navigate to its url - publishChanges(({ request }) => { + publishChanges("dashboard", ({ request }) => { assert.deepEqual(request.body.embedding_params, { id: "locked", name: "enabled", @@ -149,7 +159,7 @@ describe("scenarios > embedding > dashboard parameters", () => { cy.get("@allParameters").findByText("Editable").click(); popover().contains("Disabled").click(); - publishChanges(({ request }) => { + publishChanges("dashboard", ({ request }) => { assert.deepEqual(request.body.embedding_params, { name: "disabled", id: "disabled", @@ -205,6 +215,70 @@ describe("scenarios > embedding > dashboard parameters", () => { cy.findByText("Not Used Filter").should("not.exist"); }); }); + + it("should handle required parameters", () => { + visitDashboard("@dashboardId"); + editDashboard(); + + // Make one parameter required + getDashboardFilter("Name").click(); + toggleRequiredParameter(); + sidebar().findByText("Default value").next().click(); + addWidgetStringFilter("Ferne Rosenbaum"); + saveDashboard(); + + // Check that parameter visibility is correct + openStaticEmbeddingModal({ activeTab: "parameters", acceptTerms: true }); + assertEmbeddingParameter("Id", "Disabled"); + assertEmbeddingParameter("Name", "Editable"); + assertEmbeddingParameter("Source", "Disabled"); + assertEmbeddingParameter("User", "Disabled"); + assertEmbeddingParameter("Not Used Filter", "Disabled"); + + // We only expect name to be "enabled" because the rest + // weren't touched and therefore aren't changed, whereas + // "enabled" must be set by default for required params. + publishChanges("dashboard", ({ request }) => { + assert.deepEqual(request.body.embedding_params, { + name: "enabled", + }); + }); + + visitIframe(); + + // Filter widget must be visible + filterWidget().contains("Name"); + // Its default value must be in the URL + cy.location("search").should("contain", "name=Ferne%20Rosenbaum"); + // And the default should be applied giving us only 1 result + cy.get(".ScalarValue").invoke("text").should("eq", "1"); + }); + + it("should (dis)allow setting parameters as required for a published embedding", () => { + visitDashboard("@dashboardId"); + + // Set an "editable" and "locked" parameters and leave the rest "disabled" + openStaticEmbeddingModal({ activeTab: "parameters", acceptTerms: true }); + setEmbeddingParameter("Name", "Editable"); + setEmbeddingParameter("Source", "Locked"); + publishChanges("dashboard", ({ request }) => { + assert.deepEqual(request.body.embedding_params, { + name: "enabled", + source: "locked", + }); + }); + + closeStaticEmbeddingModal(); + editDashboard(); + + // Check each parameter's required state + assertRequiredEnabledForName({ name: "Name", enabled: true }); + assertRequiredEnabledForName({ name: "Source", enabled: true }); + // The rest must be disabled + assertRequiredEnabledForName({ name: "Id", enabled: false }); + assertRequiredEnabledForName({ name: "User", enabled: false }); + assertRequiredEnabledForName({ name: "Not Used Filter", enabled: false }); + }); }); context("API", () => { @@ -450,9 +524,9 @@ describe("scenarios > embedding > dashboard parameters with defaults", () => { openStaticEmbeddingModal({ activeTab: "parameters" }); // ID param is disabled by default - setParameter("Name", "Editable"); - setParameter("Source", "Locked"); - publishChanges(({ request }) => { + setEmbeddingParameter("Name", "Editable"); + setEmbeddingParameter("Source", "Locked"); + publishChanges("dashboard", ({ request }) => { assert.deepEqual(request.body.embedding_params, { source: "locked", name: "enabled", @@ -578,27 +652,13 @@ function openFilterOptions(name) { filterWidget().contains(name).click(); } -function publishChanges(callback) { - cy.intercept("PUT", "/api/dashboard/*").as("publishChanges"); - - cy.button(/^(Publish|Publish changes)$/).click(); - - cy.wait(["@publishChanges", "@publishChanges"]).then(xhrs => { - // Unfortunately, the order of requests is not always the same. - // Therefore, we must first get the one that has the `embedding_params` and then assert on it. - const targetXhr = xhrs.find(({ request }) => - Object.keys(request.body).includes("embedding_params"), - ); - callback && callback(targetXhr); - }); +function getDashboardFilter(name) { + return cy + .findByTestId("edit-dashboard-parameters-widget-container") + .findByText(name); } -function setParameter(name, filter) { - cy.findByLabelText("Configuring parameters") - .parent() - .findByText(name) - .siblings("a") - .click(); - - popover().contains(filter).click(); +function assertRequiredEnabledForName({ name, enabled }) { + getDashboardFilter(name).click(); + getRequiredToggle().should(enabled ? "be.enabled" : "not.be.enabled"); } diff --git a/e2e/test/scenarios/embedding/embedding-native.cy.spec.js b/e2e/test/scenarios/embedding/embedding-native.cy.spec.js index eead04bd26c2a7672d0c6db358778580376ef682..5b860f42f70c5fee26cb088cb5f40a290193fe49 100644 --- a/e2e/test/scenarios/embedding/embedding-native.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-native.cy.spec.js @@ -6,8 +6,13 @@ import { visitEmbeddedPage, visitIframe, openStaticEmbeddingModal, + closeStaticEmbeddingModal, + publishChanges, + setEmbeddingParameter, + assertEmbeddingParameter, } from "e2e/support/helpers"; +import * as SQLFilter from "../native-filters/helpers/e2e-sql-filter-helpers"; import { questionDetails } from "./shared/embedding-native"; import { questionDetailsWithDefaults } from "./shared/embedding-dashboard"; @@ -18,16 +23,25 @@ describe("scenarios > embedding > native questions", () => { }); context("UI", () => { - beforeEach(() => { - cy.createNativeQuestion(questionDetails, { + function createAndVisitQuestion({ requiredTagName, defaultValue } = {}) { + const details = structuredClone(questionDetails); + + if (requiredTagName) { + details.native["template-tags"][requiredTagName].default = defaultValue; + details.native["template-tags"][requiredTagName].required = true; + } + + cy.createNativeQuestion(details, { visitQuestion: true, }); openStaticEmbeddingModal({ activeTab: "parameters" }); - }); + } it("should not display disabled parameters", () => { - publishChanges(({ request }) => { + createAndVisitQuestion(); + + publishChanges("card", ({ request }) => { assert.deepEqual(request.body.embedding_params, {}); }); @@ -44,25 +58,15 @@ describe("scenarios > embedding > native questions", () => { }); it("should display and work with enabled parameters while hiding the locked one", () => { - setParameter("Order ID", "Editable"); - setParameter("Created At", "Editable"); - setParameter("Total", "Locked"); - setParameter("State", "Editable"); - setParameter("Product ID", "Editable"); - - // We must enter a value for a locked parameter - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Previewing locked parameters") - .parent() - .within(() => { - cy.findByText("Total").click(); - }); + createAndVisitQuestion(); - // Total is greater than or equal to 0 - cy.findByPlaceholderText("Enter a number").type("0").blur(); - cy.button("Add filter").click(); + setEmbeddingParameter("Order ID", "Editable"); + setEmbeddingParameter("Created At", "Editable"); + setEmbeddingParameter("Total", "Locked"); + setEmbeddingParameter("State", "Editable"); + setEmbeddingParameter("Product ID", "Editable"); - publishChanges(({ request }) => { + publishChanges("card", ({ request }) => { const actual = request.body.embedding_params; const expected = { @@ -133,6 +137,59 @@ describe("scenarios > embedding > native questions", () => { "?id=926&created_at=&state=KS&product_id=10", ); }); + + it("should handle required parameters", () => { + createAndVisitQuestion({ requiredTagName: "total", defaultValue: [100] }); + + assertEmbeddingParameter("Total", "Editable"); + + publishChanges("card", ({ request }) => { + const actual = request.body.embedding_params; + + // We only expect total to be "enabled" because the rest + // weren't touched and therefore aren't changed, whereas + // "enabled" must be set by default for required params. + const expected = { + total: "enabled", + }; + + assert.deepEqual(actual, expected); + }); + + visitIframe(); + + // Filter widget must be visible + filterWidget().contains("Total"); + + // And its default value must be in the URL + cy.location("search").should("eq", "?total=100"); + }); + + it("should (dis)allow setting parameters as required for a published embedding", () => { + createAndVisitQuestion(); + // Make one parameter editable and one locked + setEmbeddingParameter("Order ID", "Editable"); + setEmbeddingParameter("Total", "Locked"); + + publishChanges("card"); + closeStaticEmbeddingModal(); + + cy.findByTestId("native-query-editor-container") + .findByText("Open Editor") + .click(); + + // Open variable editor + cy.findByTestId("native-query-editor-sidebar").icon("variable").click(); + + // Now check that all disabled parameters can't be required and the rest can + assertRequiredEnabledForName({ name: "id", enabled: true }); + assertRequiredEnabledForName({ name: "total", enabled: true }); + // disabled parameters + assertRequiredEnabledForName({ name: "created_at", enabled: false }); + assertRequiredEnabledForName({ name: "source", enabled: false }); + assertRequiredEnabledForName({ name: "state", enabled: false }); + assertRequiredEnabledForName({ name: "product_id", enabled: false }); + }); }); context("API", () => { @@ -270,9 +327,9 @@ describe("scenarios > embedding > native questions with default parameters", () openStaticEmbeddingModal({ activeTab: "parameters" }); // Note: ID is disabled - setParameter("Source", "Locked"); - setParameter("Name", "Editable"); - publishChanges(({ request }) => { + setEmbeddingParameter("Source", "Locked"); + setEmbeddingParameter("Name", "Editable"); + publishChanges("card", ({ request }) => { assert.deepEqual(request.body.embedding_params, { source: "locked", name: "enabled", @@ -291,28 +348,10 @@ describe("scenarios > embedding > native questions with default parameters", () }); }); -function setParameter(name, filter) { - cy.findByLabelText("Configuring parameters") - .parent() - .within(() => { - cy.findByText(name).siblings("a").click(); - }); - - popover().contains(filter).click(); -} - -function publishChanges(callback) { - cy.intercept("PUT", "/api/card/*").as("publishChanges"); - - cy.button(/^(Publish|Publish changes)$/).click(); - - cy.wait(["@publishChanges", "@publishChanges"]).then(xhrs => { - // Unfortunately, the order of requests is not always the same. - // Therefore, we must first get the one that has the `embedding_params` and then assert on it. - const targetXhr = xhrs.find(({ request }) => - Object.keys(request.body).includes("embedding_params"), +function assertRequiredEnabledForName({ name, enabled }) { + cy.findByTestId(`tag-editor-variable-${name}`).within(() => { + SQLFilter.getRequiredInput().should( + enabled ? "be.enabled" : "not.be.enabled", ); - - callback && callback(targetXhr); }); } diff --git a/e2e/test/scenarios/embedding/shared/embedding-native.js b/e2e/test/scenarios/embedding/shared/embedding-native.js index 34f649edc8c998299c997e16554302aff823ec3e..7ed5ab420b7d32bcb86ffb07d51de2117659683a 100644 --- a/e2e/test/scenarios/embedding/shared/embedding-native.js +++ b/e2e/test/scenarios/embedding/shared/embedding-native.js @@ -46,7 +46,6 @@ export const questionDetails = { dimension: ["field", ORDERS.TOTAL, null], "widget-type": "number/>=", default: [0], - required: true, }, source: { id: "44038e73-f909-1bed-0974-2a42ce8979e8", diff --git a/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js b/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js index 32a96d465beefd4973ff36ec972f55f317262c36..8c04f91ce22709a5ebcceda798aec434f3eabc30 100644 --- a/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js +++ b/e2e/test/scenarios/native-filters/helpers/e2e-sql-filter-helpers.js @@ -58,11 +58,19 @@ export function setDefaultValue(value) { // UI PATTERNS +export function getRequiredToggle() { + return cy.findByText("Always require a value"); +} + +export function getRequiredInput() { + return cy.findByLabelText("Always require a value"); +} + /** * Toggle the required SQL filter on or off. It is off by default. */ export function toggleRequired() { - cy.findByText("Always require a value").click(); + getRequiredToggle().click(); } // FILTER QUERY diff --git a/frontend/src/metabase-lib/parameters/utils/cards.ts b/frontend/src/metabase-lib/parameters/utils/cards.ts index 20b8fbdadf5b320862b6bfa073959b1957e5af25..0ca6663283ad2bdff0067b0b6fcb8a539894fb5c 100644 --- a/frontend/src/metabase-lib/parameters/utils/cards.ts +++ b/frontend/src/metabase-lib/parameters/utils/cards.ts @@ -21,7 +21,7 @@ export function getCardUiParameters( const valuePopulatedParameters: (Parameter[] | ParameterWithTarget[]) & { value?: any; - } = getValuePopulatedParameters(parameters, parameterValues); + } = getValuePopulatedParameters({ parameters, values: parameterValues }); const question = new Question(card, metadata); return valuePopulatedParameters.map(parameter => { diff --git a/frontend/src/metabase-lib/parameters/utils/parameter-values.js b/frontend/src/metabase-lib/parameters/utils/parameter-values.js index 9895fe410cd184588af8a11ed7caaac554ad5eac..a4aabd4ee097b7095555ad1a19f5a3af20769ae5 100644 --- a/frontend/src/metabase-lib/parameters/utils/parameter-values.js +++ b/frontend/src/metabase-lib/parameters/utils/parameter-values.js @@ -9,12 +9,39 @@ import { export const PULSE_PARAM_EMPTY = null; export const PULSE_PARAM_USE_DEFAULT = undefined; -export function getValuePopulatedParameters(parameters, parameterValues) { +/** + * In some cases, we need to use default parameter value in place of an absent one. + * Please use this function when dealing with the required parameters. + */ +export function getParameterValue({ + parameter, + values = {}, + defaultRequired = false, +}) { + const value = values?.[parameter.id]; + const useDefault = defaultRequired && parameter.required; + return value ?? (useDefault ? parameter.default : null); +} + +/** + * In some cases, we need to use default parameter value in place of an absent one. + * Please use this function when dealing with the required parameters. + */ +export function getValuePopulatedParameters({ + parameters, + values = {}, + defaultRequired = false, +}) { return parameters.map(parameter => ({ ...parameter, - value: parameterValues?.[parameter.id] ?? null, + value: getParameterValue({ + parameter, + values, + defaultRequired, + }), })); } + export function getDefaultValuePopulatedParameters( parameters, parameterValues, diff --git a/frontend/src/metabase-lib/parameters/utils/parameter-values.unit.spec.js b/frontend/src/metabase-lib/parameters/utils/parameter-values.unit.spec.js index d098de792b7aa0724bf84f633a8c88bde96174a3..b1a3f528b4556f2176f4e0bcb78c0d6c3bb74921 100644 --- a/frontend/src/metabase-lib/parameters/utils/parameter-values.unit.spec.js +++ b/frontend/src/metabase-lib/parameters/utils/parameter-values.unit.spec.js @@ -73,9 +73,12 @@ describe("parameters/utils/parameter-values", () => { describe("getValuePopulatedParameters", () => { it("should return an array of parameter objects with the `value` property set if it exists in the given `parameterValues` id, value map, and null if it doesn't exist", () => { expect( - getValuePopulatedParameters([parameter1, parameter2], { - [parameter1.id]: "parameter1 value", - [parameter2.id]: "parameter2 value", + getValuePopulatedParameters({ + parameters: [parameter1, parameter2], + values: { + [parameter1.id]: "parameter1 value", + [parameter2.id]: "parameter2 value", + }, }), ).toEqual([ { @@ -90,9 +93,9 @@ describe("parameters/utils/parameter-values", () => { }); it("should return null value if the parameter doesn't exist in the parameterValues arg", () => { - expect(getValuePopulatedParameters([parameter1], {})).toEqual([ - { ...parameter1, value: null }, - ]); + expect( + getValuePopulatedParameters({ parameters: [parameter1], values: {} }), + ).toEqual([{ ...parameter1, value: null }]); }); it("should handle there being an undefined or null parameterValues object", () => { @@ -106,11 +109,14 @@ describe("parameters/utils/parameter-values", () => { value: null, }, ]; - expect(getValuePopulatedParameters([parameter1, parameter2])).toEqual( - parametersWithNulls, - ); expect( - getValuePopulatedParameters([parameter1, parameter2], null), + getValuePopulatedParameters({ parameters: [parameter1, parameter2] }), + ).toEqual(parametersWithNulls); + expect( + getValuePopulatedParameters({ + parameters: [parameter1, parameter2], + values: null, + }), ).toEqual(parametersWithNulls); }); }); diff --git a/frontend/src/metabase-types/api/dashboard.ts b/frontend/src/metabase-types/api/dashboard.ts index e0a8ae4722516a1f07fb71e46530bc751c8231b5..c20df18063483ca1486383ed894953210f21db84 100644 --- a/frontend/src/metabase-types/api/dashboard.ts +++ b/frontend/src/metabase-types/api/dashboard.ts @@ -7,6 +7,7 @@ import type { ParameterTarget, } from "metabase-types/api"; +import type { EmbeddingParameters } from "metabase/public/lib/types"; import type { ActionDisplayType, WritebackAction } from "./actions"; import type { SearchModelType } from "./search"; import type { Card, CardId, CardDisplayType } from "./card"; @@ -43,8 +44,8 @@ export interface Dashboard { auto_apply_filters: boolean; archived: boolean; public_uuid: string | null; - embedding_params?: Record<string, string> | null; width: "full" | "fixed"; + embedding_params?: EmbeddingParameters | null; /* Indicates whether static embedding for this dashboard has been published */ enable_embedding: boolean; diff --git a/frontend/src/metabase/core/components/Toggle/Toggle.styled.tsx b/frontend/src/metabase/core/components/Toggle/Toggle.styled.tsx index 5b421087b087fd2ba3bae4e1b2028ce03d1cd74d..2920d18c004a9b2bc1ef59d2eb28b673c66a302f 100644 --- a/frontend/src/metabase/core/components/Toggle/Toggle.styled.tsx +++ b/frontend/src/metabase/core/components/Toggle/Toggle.styled.tsx @@ -44,6 +44,12 @@ export const ToggleRoot = styled.input<ToggleRootProps>` transition: background-color 0.3s; flex-shrink: 0; + &[disabled] { + cursor: not-allowed; + opacity: 0.5; + background-color: ${color("bg-medium")}; + } + &:after { content: ""; width: ${props => (props.small ? "13px" : "20px")}; diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.tsx b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.tsx index 7dc465146951ca9345ad350d290e1411ecf421e0..454ce2b31746c351fa1890077a8540000f0dc091 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.tsx +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.tsx @@ -38,6 +38,7 @@ import type { StoreDashcard, } from "metabase-types/store"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import type Database from "metabase-lib/metadata/Database"; import type { UiParameter } from "metabase-lib/parameters/types"; import type Metadata from "metabase-lib/metadata/Metadata"; @@ -181,6 +182,9 @@ interface DashboardProps { columnKey: string, settings?: Record<string, unknown> | null, ) => void; + getEmbeddedParameterVisibility: ( + slug: string, + ) => EmbeddingParameterVisibility | null; } function DashboardInner(props: DashboardProps) { @@ -453,10 +457,10 @@ function DashboardInner(props: DashboardProps) { const parametersWidget = ( <SyncedParametersList - parameters={getValuePopulatedParameters( + parameters={getValuePopulatedParameters({ parameters, - isAutoApplyFilters ? parameterValues : draftParameterValues, - )} + values: isAutoApplyFilters ? parameterValues : draftParameterValues, + })} editingParameter={editingParameter} hideParameters={hiddenParameterSlugs} dashboard={dashboard} diff --git a/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx b/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx index c75886441d572d84e133c0c41504e7d8964f9a64..87b3966e00ed11636074ce291619a531e517beef 100644 --- a/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardSidebars.jsx @@ -46,6 +46,7 @@ DashboardSidebars.propTypes = { closeSidebar: PropTypes.func.isRequired, setDashboardAttribute: PropTypes.func, selectedTabId: PropTypes.number, + getEmbeddedParameterVisibility: PropTypes.func.isRequired, }; export function DashboardSidebars({ @@ -75,6 +76,7 @@ export function DashboardSidebars({ closeSidebar, setDashboardAttribute, selectedTabId, + getEmbeddedParameterVisibility, }) { const handleAddCard = useCallback( cardId => { @@ -136,6 +138,7 @@ export function DashboardSidebars({ ); return ( <ParameterSidebar + getEmbeddedParameterVisibility={getEmbeddedParameterVisibility} parameter={parameter} otherParameters={otherParameters} onChangeName={setParameterName} diff --git a/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx index 2cf147204c996584c105c62e3899185997735377..e8f517e488bb2d9f2a5429267cb83f4bf5da2539 100644 --- a/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/AutomaticDashboardApp.jsx @@ -157,10 +157,10 @@ class AutomaticDashboardAppInner extends Component { <div className="px1 pt1"> <SyncedParametersList className="mt1" - parameters={getValuePopulatedParameters( + parameters={getValuePopulatedParameters({ parameters, - parameterValues, - )} + values: parameterValues, + })} setParameterValue={setParameterValue} /> </div> diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp/DashboardApp.tsx b/frontend/src/metabase/dashboard/containers/DashboardApp/DashboardApp.tsx index f9cad90a6d78f2ea057d5bad60cd8bc8096eb4d8..b5d2d1db0e7a75fddcf347b72d5f2978f32743de 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp/DashboardApp.tsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp/DashboardApp.tsx @@ -44,6 +44,7 @@ import type { ParameterValueOrArray, } from "metabase-types/api"; import type { SelectedTabId, State, StoreDashcard } from "metabase-types/store"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import type Database from "metabase-lib/metadata/Database"; import type { UiParameter } from "metabase-lib/parameters/types"; import type Metadata from "metabase-lib/metadata/Metadata"; @@ -76,6 +77,7 @@ import { getSelectedTabId, getSidebar, getSlowCards, + getEmbeddedParameterVisibility, } from "../../selectors"; type OwnProps = { @@ -116,6 +118,9 @@ type StateProps = { selectedTabId: SelectedTabId; isAutoApplyFilters: boolean; isNavigatingBackToDashboard: boolean; + getEmbeddedParameterVisibility: ( + slug: string, + ) => EmbeddingParameterVisibility | null; }; type DispatchProps = { @@ -146,6 +151,7 @@ const mapStateToProps = (state: State): StateProps => { parameters: getParameters(state), parameterValues: getParameterValues(state), draftParameterValues: getDraftParameterValues(state), + metadata, loadingStartTime: getLoadingStartTime(state), clickBehaviorSidebarDashcard: getClickBehaviorSidebarDashcard(state), @@ -160,6 +166,8 @@ const mapStateToProps = (state: State): StateProps => { selectedTabId: getSelectedTabId(state), isAutoApplyFilters: getIsAutoApplyFilters(state), isNavigatingBackToDashboard: getIsNavigatingBackToDashboard(state), + getEmbeddedParameterVisibility: (slug: string) => + getEmbeddedParameterVisibility(state, slug), }; }; diff --git a/frontend/src/metabase/dashboard/selectors.ts b/frontend/src/metabase/dashboard/selectors.ts index 186441f07032bc9b5b7bdca608c541dcabe3e936..2bdc7d68556a4807e9519a88f06cc91a8c2a85a3 100644 --- a/frontend/src/metabase/dashboard/selectors.ts +++ b/frontend/src/metabase/dashboard/selectors.ts @@ -28,6 +28,7 @@ import type { EditParameterSidebarState, State, } from "metabase-types/store"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import Question from "metabase-lib/Question"; import { isQuestionDashCard } from "./utils"; @@ -395,6 +396,22 @@ export const getDashcardParameterMappingOptions = createCachedSelector( return props.card.id ?? props.dashcard.id; }); +// Embeddings might be published without passing embedding_params to the server, +// in which case it's an empty object. We should treat such situations with +// caution, assuming that an absent parameter is "disabled". +export function getEmbeddedParameterVisibility( + state: State, + slug: string, +): EmbeddingParameterVisibility | null { + const dashboard = getDashboard(state); + if (!dashboard?.enable_embedding) { + return null; + } + + const embeddingParams = dashboard.embedding_params ?? {}; + return embeddingParams[slug] ?? "disabled"; +} + export const getIsHeaderVisible = createSelector( [getIsEmbedded, getEmbedOptions], (isEmbedded, embedOptions) => !isEmbedded || !!embedOptions.header, diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx index 0b01f22675faf1b265df160a94b694404bbd4cf6..a45278663b7de4487b3aca27b1021a603dab64b0 100644 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.styled.tsx @@ -29,17 +29,3 @@ export const SettingValueWidget = styled(ParameterValueWidget)` border-radius: 0.5rem; background-color: ${color("white")}; `; - -export const SettingRequiredContainer = styled.div` - display: flex; - gap: 0.5rem; - margin: 0.5rem 0; -`; - -export const SettingRequiredLabel = styled.label` - display: block; - margin-top: 0.35rem; - font-weight: 700; - color: ${color("text-medium")}; - cursor: pointer; -`; diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx index 5f629a820f575de5585ece52c1edc40d8f519202..a9f9ede63b1770b20425bedd564744847b4404d6 100644 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.tsx @@ -7,17 +7,16 @@ import type { ValuesSourceConfig, ValuesSourceType, } from "metabase-types/api"; -import { TextInput } from "metabase/ui"; -import Toggle from "metabase/core/components/Toggle"; +import { Text, TextInput } from "metabase/ui"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import { canUseCustomSource } from "metabase-lib/parameters/utils/parameter-source"; import { getIsMultiSelect } from "../../utils/dashboards"; import { isSingleOrMultiSelectable } from "../../utils/parameter-type"; import ValuesSourceSettings from "../ValuesSourceSettings"; +import { RequiredParamToggle } from "../RequiredParamToggle"; import { SettingLabel, SettingLabelError, - SettingRequiredContainer, - SettingRequiredLabel, SettingSection, SettingsRoot, SettingValueWidget, @@ -33,9 +32,10 @@ export interface ParameterSettingsProps { onChangeSourceType: (sourceType: ValuesSourceType) => void; onChangeSourceConfig: (sourceConfig: ValuesSourceConfig) => void; onChangeRequired: (value: boolean) => void; + embeddedParameterVisibility: EmbeddingParameterVisibility | null; } -const ParameterSettings = ({ +export const ParameterSettings = ({ parameter, isParameterSlugUsed, onChangeName, @@ -45,6 +45,7 @@ const ParameterSettings = ({ onChangeSourceType, onChangeSourceConfig, onChangeRequired, + embeddedParameterVisibility, }: ParameterSettingsProps): JSX.Element => { const [tempLabelValue, setTempLabelValue] = useState(parameter.name); @@ -78,6 +79,8 @@ const ParameterSettings = ({ [onChangeSourceType, onChangeSourceConfig], ); + const isEmbeddedDisabled = embeddedParameterVisibility === "disabled"; + return ( <SettingsRoot> <SettingSection> @@ -132,21 +135,33 @@ const ParameterSettings = ({ setValue={onChangeDefaultValue} /> - <SettingRequiredContainer> - <Toggle - id={`parameter-setting-required_${parameter.id}`} - value={parameter.required} - onChange={onChangeRequired} - /> - <div> - <SettingRequiredLabel - htmlFor={`parameter-setting-required_${parameter.id}`} - > - {t`Always require a value`} - </SettingRequiredLabel> - <p>{t`When enabled, people can change the value or reset it, but can't clear it entirely.`}</p> - </div> - </SettingRequiredContainer> + <RequiredParamToggle + // This forces the toggle to be a new instance when the parameter changes, + // so that toggles don't slide, which is confusing. + key={`required_param_toggle_${parameter.id}`} + uniqueId={parameter.id} + disabled={isEmbeddedDisabled} + value={parameter.required ?? false} + onChange={onChangeRequired} + disabledTooltip={ + <> + <Text lh={1.4}> + {t`This filter is set to disabled in an embedded dashboard.`} + </Text> + <Text lh={1.4}> + {t`To always require a value, first visit embedding settings, + make this filter editable or locked, re-publish the + dashboard, then return to this page.`} + </Text> + <Text size="sm"> + {t`Note`}:{" "} + {t`making it locked, will require updating the + embedding code before proceeding, otherwise the embed will + break.`} + </Text> + </> + } + ></RequiredParamToggle> </SettingSection> </SettingsRoot> ); @@ -170,5 +185,3 @@ function getLabelError({ } return null; } - -export { ParameterSettings }; diff --git a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx index 671177a3901ef0d04b9c9508b8f5b71d2e3ef6fb..f219c49c735f0472df5515bf9328647dd163a4f0 100644 --- a/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSettings/ParameterSettings.unit.spec.tsx @@ -103,6 +103,7 @@ const setup = ({ parameter = createMockUiParameter() }: SetupOpts = {}) => { renderWithProviders( <ParameterSettings + embeddedParameterVisibility={null} parameter={parameter} isParameterSlugUsed={jest.fn()} onChangeName={onChangeName} diff --git a/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.tsx b/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.tsx index 02cc7622a2dfbb133df8d3b5c961c6cc0320e557..c57af08e5ca3cc2972322ef4b906c81ef1908109 100644 --- a/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.tsx @@ -10,6 +10,7 @@ import type { ValuesSourceType, } from "metabase-types/api"; import { slugify } from "metabase/lib/formatting"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import { canUseLinkedFilters } from "../../utils/linked-filters"; import { ParameterSettings } from "../ParameterSettings"; import ParameterLinkedFilters from "../ParameterLinkedFilters"; @@ -44,6 +45,9 @@ export interface ParameterSidebarProps { onRemoveParameter: (parameterId: ParameterId) => void; onShowAddParameterPopover: () => void; onClose: () => void; + getEmbeddedParameterVisibility: ( + slug: string, + ) => EmbeddingParameterVisibility | null; } export const ParameterSidebar = ({ @@ -60,6 +64,7 @@ export const ParameterSidebar = ({ onRemoveParameter, onShowAddParameterPopover, onClose, + getEmbeddedParameterVisibility, }: ParameterSidebarProps): JSX.Element => { const parameterId = parameter.id; const tabs = useMemo(() => getTabs(parameter), [parameter]); @@ -142,6 +147,9 @@ export const ParameterSidebar = ({ {tab === "settings" ? ( <ParameterSettings parameter={parameter} + embeddedParameterVisibility={getEmbeddedParameterVisibility( + parameter.slug, + )} isParameterSlugUsed={isParameterSlugUsed} onChangeName={handleNameChange} onChangeDefaultValue={handleDefaultValueChange} diff --git a/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.unit.spec.tsx b/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.unit.spec.tsx index 2383d6b3001efcc798a26e6e6702bde8cc9cc06a..5bc487c12256374fce139ca113435a0612a30312 100644 --- a/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.unit.spec.tsx +++ b/frontend/src/metabase/parameters/components/ParameterSidebar/ParameterSidebar.unit.spec.tsx @@ -53,6 +53,7 @@ const setup = ({ onShowAddParameterPopover={jest.fn()} onClose={jest.fn()} onChangeRequired={jest.fn()} + getEmbeddedParameterVisibility={() => null} /> </div> ); diff --git a/frontend/src/metabase/parameters/components/ParameterWidget/ParameterWidget.jsx b/frontend/src/metabase/parameters/components/ParameterWidget/ParameterWidget.jsx index 1912cf3aa861693762c90f188be7102e520aba59..86cdb3ace2d2dccc9692f14f79c1e09dfbf47cd1 100644 --- a/frontend/src/metabase/parameters/components/ParameterWidget/ParameterWidget.jsx +++ b/frontend/src/metabase/parameters/components/ParameterWidget/ParameterWidget.jsx @@ -18,6 +18,8 @@ export class ParameterWidget extends Component { static propTypes = { parameter: PropTypes.object, commitImmediately: PropTypes.bool, + setParameterValueToDefault: PropTypes.func, + enableParameterRequiredBehavior: PropTypes.bool, }; static defaultProps = { diff --git a/frontend/src/metabase/parameters/components/RequiredParamToggle/RequierParamToggle.styled.tsx b/frontend/src/metabase/parameters/components/RequiredParamToggle/RequierParamToggle.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7505e701294c8bb3910e4fb7220345f78b2e3da4 --- /dev/null +++ b/frontend/src/metabase/parameters/components/RequiredParamToggle/RequierParamToggle.styled.tsx @@ -0,0 +1,12 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const SettingRequiredLabel = styled.label` + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.35rem; + font-weight: 700; + color: ${color("text-medium")}; + cursor: pointer; +`; diff --git a/frontend/src/metabase/parameters/components/RequiredParamToggle/RequiredParamToggle.tsx b/frontend/src/metabase/parameters/components/RequiredParamToggle/RequiredParamToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..193f1625cb3fc122f30705b3e8b7f65a5c3d8a91 --- /dev/null +++ b/frontend/src/metabase/parameters/components/RequiredParamToggle/RequiredParamToggle.tsx @@ -0,0 +1,46 @@ +import { t } from "ttag"; +import type { ReactNode } from "react"; +import { Icon, HoverCard, Stack, Flex, Text } from "metabase/ui"; +import Toggle from "metabase/core/components/Toggle"; +import { SettingRequiredLabel } from "./RequierParamToggle.styled"; + +interface RequiredParamToggleProps { + disabled?: boolean; + uniqueId: string; + value: boolean; + onChange: (value: boolean) => void; + disabledTooltip: ReactNode; +} + +export function RequiredParamToggle(props: RequiredParamToggleProps) { + const { disabled, value, onChange, uniqueId, disabledTooltip } = props; + const id = `required_param_toggle_${uniqueId}`; + + return ( + <Flex gap="sm" mt="md"> + <Toggle disabled={disabled} id={id} value={value} onChange={onChange} /> + <div> + <SettingRequiredLabel htmlFor={id}> + {t`Always require a value`} + {disabled && ( + <HoverCard position="top-end" shadow="xs"> + <HoverCard.Target> + <Icon name="info_filled" /> + </HoverCard.Target> + <HoverCard.Dropdown w={320}> + <Stack p="md" spacing="sm"> + {disabledTooltip} + </Stack> + </HoverCard.Dropdown> + </HoverCard> + )} + </SettingRequiredLabel> + + <Text + mt="sm" + lh={1.2} + >{t`When enabled, people can change the value or reset it, but can't clear it entirely.`}</Text> + </div> + </Flex> + ); +} diff --git a/frontend/src/metabase/parameters/components/RequiredParamToggle/index.ts b/frontend/src/metabase/parameters/components/RequiredParamToggle/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..efb3081295e65627d6c25ad924613fc232eddc5e --- /dev/null +++ b/frontend/src/metabase/parameters/components/RequiredParamToggle/index.ts @@ -0,0 +1 @@ +export { RequiredParamToggle } from "./RequiredParamToggle"; diff --git a/frontend/src/metabase/public/components/EmbedFrame/EmbedFrame.tsx b/frontend/src/metabase/public/components/EmbedFrame/EmbedFrame.tsx index b959fb85e5352b8986a22368f0c720df32c0dc74..1e3dca6b542888440ede4e494b28081102c7fda2 100644 --- a/frontend/src/metabase/public/components/EmbedFrame/EmbedFrame.tsx +++ b/frontend/src/metabase/public/components/EmbedFrame/EmbedFrame.tsx @@ -59,6 +59,8 @@ interface OwnProps { hiddenParameterSlugs?: string; setParameterValue?: (parameterId: ParameterId, value: any) => void; children: ReactNode; + enableParameterRequiredBehavior?: boolean; + setParameterValueToDefault: (id: ParameterId) => void; } interface StateProps { @@ -100,6 +102,8 @@ function EmbedFrame({ draftParameterValues, hiddenParameterSlugs, setParameterValue, + setParameterValueToDefault, + enableParameterRequiredBehavior, }: Props) { const [hasInnerScroll, setInnerScroll] = useState(true); @@ -158,14 +162,18 @@ function EmbedFrame({ className="mt1" question={question} dashboard={dashboard} - parameters={getValuePopulatedParameters( + parameters={getValuePopulatedParameters({ parameters, - _.isEmpty(draftParameterValues) + values: _.isEmpty(draftParameterValues) ? parameterValues : draftParameterValues, - )} + })} setParameterValue={setParameterValue} hideParameters={hideParameters} + setParameterValueToDefault={setParameterValueToDefault} + enableParameterRequiredBehavior={ + enableParameterRequiredBehavior + } /> {dashboard && <FilterApplyButton />} </ParametersWidgetContainer> diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ParametersSettings.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ParametersSettings.tsx index 510918373169c35c6edeec4f6843032a3564b6c6..0099c09af50d7c4380120a172a6b2c767eb76bab 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ParametersSettings.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/ParametersSettings.tsx @@ -8,6 +8,7 @@ import { ParameterWidget as StaticParameterWidget } from "metabase/parameters/co import type { EmbeddingParameters, EmbeddingParametersValues, + EmbeddingParameterVisibility, EmbedResourceParameter, EmbedResourceType, } from "metabase/public/lib/types"; @@ -39,13 +40,18 @@ export const ParametersSettings = ({ }: ParametersSettingsProps): JSX.Element => { const valuePopulatedLockedParameters = useMemo( () => - getValuePopulatedParameters( - lockedParameters, - parameterValues, - ) as EmbedResourceParameterWithValue[], + getValuePopulatedParameters({ + parameters: lockedParameters, + values: parameterValues, + defaultRequired: true, + }) as EmbedResourceParameterWithValue[], [lockedParameters, parameterValues], ); + const hasRequiredParameters = resourceParameters.some( + param => param.required, + ); + return resourceParameters.length > 0 ? ( <> <StaticEmbedSetupPaneSettingsContentSection @@ -57,7 +63,14 @@ export const ParametersSettings = ({ {resourceParameters.map(parameter => ( <div key={parameter.id} className="flex align-center"> <Icon name={getIconForParameter(parameter)} className="mr2" /> - <h3>{parameter.name}</h3> + <h3> + {parameter.name} + {parameter.required && ( + <Text color="error" span> + * + </Text> + )} + </h3> <Select buttonProps={{ "aria-label": parameter.name, @@ -67,16 +80,28 @@ export const ParametersSettings = ({ onChange={(e: ChangeEvent<HTMLSelectElement>) => onChangeEmbeddingParameters({ ...embeddingParams, - [parameter.slug]: e.target.value, + [parameter.slug]: e.target + .value as EmbeddingParameterVisibility, }) } > - <Option icon="close" value="disabled">{t`Disabled`}</Option> + <Option + icon="close" + value="disabled" + disabled={parameter.required} + >{t`Disabled`}</Option> <Option icon="pencil" value="enabled">{t`Editable`}</Option> <Option icon="lock" value="locked">{t`Locked`}</Option> </Select> </div> ))} + + {hasRequiredParameters && ( + <Text size="xm"> + <strong>{t`Note`}: </strong> + {t`Parameters marked with a red asterisk are required and can't be disabled.`} + </Text> + )} </Stack> </StaticEmbedSetupPaneSettingsContentSection> @@ -95,9 +120,16 @@ export const ParametersSettings = ({ className="m0" parameter={parameter} parameters={valuePopulatedLockedParameters} - setValue={(value: string) => { - onChangeParameterValue(parameter.id, value); + setValue={(value: string) => + onChangeParameterValue(parameter.id, value) + } + setParameterValueToDefault={() => { + onChangeParameterValue( + parameter.id, + parameter.default as any, + ); }} + enableParameterRequiredBehavior /> ))} </Stack> diff --git a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx index ff14cce6984d68962b3636e500ae19a1b4c87fbd..1db71d6acb402d5be9826c72b9ca27272a54ef81 100644 --- a/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/StaticEmbedSetupPane/StaticEmbedSetupPane.tsx @@ -19,6 +19,7 @@ import { } from "metabase/public/lib/embed"; import { getEmbedServerCodeExampleOptions } from "metabase/public/lib/code"; +import { getParameterValue } from "metabase-lib/parameters/utils/parameter-values"; import { DEFAULT_DISPLAY_OPTIONS } from "./config"; import { ServerEmbedCodePane } from "./ServerEmbedCodePane"; import { EmbedModalContentStatusBar } from "./EmbedModalContentStatusBar"; @@ -290,19 +291,24 @@ function getDefaultEmbeddingParams( resource: EmbedResource, resourceParameters: EmbedResourceParameter[], ): EmbeddingParameters { - return filterValidResourceParameters( - resourceParameters, - resource.embedding_params || {}, + const validSlugs = resourceParameters.map(param => param.slug); + // We first pick only dashboard parameters with valid slugs + const defaultParams = _.pick(resource.embedding_params || {}, validSlugs); + // Then pick valid required dashboard parameters + const validRequiredParams = resourceParameters.filter( + param => param.slug && param.required, ); -} - -function filterValidResourceParameters( - resourceParameters: EmbedResourceParameter[], - embeddingParams: EmbeddingParameters, -) { - const validParameters = resourceParameters.map(parameter => parameter.slug); - return _.pick(embeddingParams, validParameters); + // And for each required parameter set its value to "enabled" + // (Editable) because this is the default for a required parameter. + // This is needed to save embedding_params when a user clicks + // "Publish" without changing parameter visibility. + return validRequiredParams.reduce((acc, param) => { + if (!acc[param.slug] || acc[param.slug] === "disabled") { + acc[param.slug] = "enabled"; + } + return acc; + }, defaultParams); } function getPreviewParamsBySlug({ @@ -322,7 +328,11 @@ function getPreviewParamsBySlug({ return Object.fromEntries( lockedParameters.map(parameter => [ parameter.slug, - parameterValues[parameter.id] ?? null, + getParameterValue({ + parameter, + values: parameterValues, + defaultRequired: true, + }), ]), ); } diff --git a/frontend/src/metabase/public/containers/PublicDashboard.jsx b/frontend/src/metabase/public/containers/PublicDashboard.jsx index 30c650b2a47c923ae5d7f2ee5e385cc3a2d5eb61..219f9e80bc9f48ef8eec19e607b870c46810cc0e 100644 --- a/frontend/src/metabase/public/containers/PublicDashboard.jsx +++ b/frontend/src/metabase/public/containers/PublicDashboard.jsx @@ -159,6 +159,7 @@ class PublicDashboard extends Component { draftParameterValues, isFullscreen, isNightMode, + setParameterValueToDefault, } = this.props; const buttons = !isWithinIframe() @@ -178,6 +179,8 @@ class PublicDashboard extends Component { actionButtons={ buttons.length > 0 && <div className="flex">{buttons}</div> } + setParameterValueToDefault={setParameterValueToDefault} + enableParameterRequiredBehavior > <LoadingAndErrorWrapper className={cx({ diff --git a/frontend/src/metabase/public/containers/PublicQuestion/PublicQuestion.jsx b/frontend/src/metabase/public/containers/PublicQuestion/PublicQuestion.jsx index 1e542ce9873ef62ea62ed495df3b91160818119c..a051dd16bf46edf5361814924fc7c95938b1889b 100644 --- a/frontend/src/metabase/public/containers/PublicQuestion/PublicQuestion.jsx +++ b/frontend/src/metabase/public/containers/PublicQuestion/PublicQuestion.jsx @@ -119,6 +119,14 @@ class PublicQuestionInner extends Component { ); }; + setParameterValueToDefault = parameterId => { + const parameters = this.getParameters(); + const parameter = parameters.find(({ id }) => id === parameterId); + if (parameter) { + this.setParameterValue(parameterId, parameter.default); + } + }; + run = async () => { const { setErrorPage, @@ -166,6 +174,22 @@ class PublicQuestionInner extends Component { } }; + getParameters() { + const { metadata } = this.props; + const { card, initialized } = this.state; + + if (!initialized || !card) { + return []; + } + + return getCardUiParameters( + card, + metadata, + {}, + card.parameters || undefined, + ); + } + render() { const { params: { uuid, token }, @@ -184,19 +208,17 @@ class PublicQuestionInner extends Component { /> ); - const parameters = - card && - getCardUiParameters(card, metadata, {}, card.parameters || undefined); - return ( <EmbedFrame name={card && card.name} description={card && card.description} actionButtons={actionButtons} question={question} - parameters={initialized ? parameters : []} + parameters={this.getParameters()} parameterValues={parameterValues} setParameterValue={this.setParameterValue} + enableParameterRequiredBehavior + setParameterValueToDefault={this.setParameterValueToDefault} > <LoadingAndErrorWrapper className="flex-full" diff --git a/frontend/src/metabase/public/lib/types.ts b/frontend/src/metabase/public/lib/types.ts index 86d95f1012ee839b49be1645a723763139193540..7bc26225e5a157fc7e7d51fd29e62428e99bf171 100644 --- a/frontend/src/metabase/public/lib/types.ts +++ b/frontend/src/metabase/public/lib/types.ts @@ -13,9 +13,13 @@ export type EmbedResourceParameter = { name: string; slug: string; type: string; + required?: boolean; + default?: unknown; }; -export type EmbeddingParameters = Record<string, string>; +export type EmbeddingParameterVisibility = "disabled" | "enabled" | "locked"; + +export type EmbeddingParameters = Record<string, EmbeddingParameterVisibility>; export type EmbeddingParametersValues = Record<string, string>; diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.styled.tsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.styled.tsx index 90952b9504df2a9d4b8a97e01778bd7d4f0869f1..5e86092eeb7fc7c2067eff55469945c5252998d9 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.styled.tsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.styled.tsx @@ -55,7 +55,8 @@ export const ToggleContainer = styled.div` `; export const ToggleLabel = styled.label` - display: block; + display: flex; + gap: 0.5rem; margin-top: 0.35rem; font-weight: 700; color: ${color("text-medium")}; diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.tsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.tsx index daaeb9738eaa536f3280b668f5afa45fbb491af8..eeebd7fe2c180de25463da0ad7e91f0e377f6549 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.tsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorParam.tsx @@ -5,11 +5,10 @@ import { connect } from "react-redux"; import { Link } from "react-router"; import Schemas from "metabase/entities/schemas"; -import Toggle from "metabase/core/components/Toggle"; import InputBlurChange from "metabase/components/InputBlurChange"; import type { SelectChangeEvent } from "metabase/core/components/Select"; import Select, { Option } from "metabase/core/components/Select"; - +import { Text } from "metabase/ui"; import ValuesSourceSettings from "metabase/parameters/components/ValuesSourceSettings"; import { fetchField } from "metabase/redux/metadata"; @@ -30,6 +29,8 @@ import type { ValuesSourceType, } from "metabase-types/api"; import type { State } from "metabase-types/store"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; +import { RequiredParamToggle } from "metabase/parameters/components/RequiredParamToggle"; import type Metadata from "metabase-lib/metadata/Metadata"; import type Database from "metabase-lib/metadata/Database"; import type Table from "metabase-lib/metadata/Table"; @@ -49,13 +50,12 @@ import { InputContainer, TagContainer, TagName, - ToggleContainer, - ToggleLabel, } from "./TagEditorParam.styled"; interface Props { tag: TemplateTag; parameter: Parameter; + embeddedParameterVisibility?: EmbeddingParameterVisibility | null; database?: Database | null; databases: Database[]; databaseFields?: Field[]; @@ -202,7 +202,14 @@ class TagEditorParamInner extends Component<Props> { }; render() { - const { tag, database, databases, metadata, parameter } = this.props; + const { + tag, + database, + databases, + metadata, + parameter, + embeddedParameterVisibility, + } = this.props; let widgetOptions: { name?: string; type: string }[] = []; let field: Field | null = null; let table: Table | null | undefined = null; @@ -224,9 +231,10 @@ class TagEditorParamInner extends Component<Props> { tag["widget-type"] === "none" || !tag["widget-type"]; const hasNoWidgetLabel = !tag["display-name"]; const hasNoDefaultValue = !tag.default; + const isEmbeddedDisabled = embeddedParameterVisibility === "disabled"; return ( - <TagContainer> + <TagContainer data-testid={`tag-editor-variable-${tag.name}`}> <ContainerLabel paddingTop>{t`Variable name`}</ContainerLabel> <TagName>{tag.name}</TagName> @@ -386,19 +394,30 @@ class TagEditorParamInner extends Component<Props> { commitImmediately /> - <ToggleContainer> - <Toggle - id={`tag-editor-required_${tag.id}`} - value={tag.required} - onChange={this.setRequired} - /> - <div> - <ToggleLabel htmlFor={`tag-editor-required_${tag.id}`}> - {t`Always require a value`} - </ToggleLabel> - <p>{t`When enabled, people can change the value or reset it, but can't clear it entirely.`}</p> - </div> - </ToggleContainer> + <RequiredParamToggle + uniqueId={tag.id} + disabled={isEmbeddedDisabled} + value={tag.required ?? false} + onChange={this.setRequired} + disabledTooltip={ + <> + <Text lh={1.4}> + {t`This filter is set to disabled in an embedded question.`} + </Text> + <Text lh={1.4}> + {t`To always require a value, first visit embedding settings, + make this filter editable or locked, re-publish the + question, then return to this page.`} + </Text> + <Text size="sm"> + {t`Note`}:{" "} + {t`making it locked, will require updating the + embedding code before proceeding, otherwise the embed will + break.`} + </Text> + </> + } + /> </div> </TagContainer> ); diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.tsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.tsx index 69038bca9d6c7bc0277d6ff1da3e282da9c86fe5..120b0960afb411d36141ec7b920e551b85cbc9c8 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorSidebar.tsx @@ -1,5 +1,4 @@ import { Component } from "react"; -import PropTypes from "prop-types"; import { t } from "ttag"; import cx from "classnames"; import _ from "underscore"; @@ -17,6 +16,7 @@ import type { TemplateTag, TemplateTagId, } from "metabase-types/api"; +import type { EmbeddingParameterVisibility } from "metabase/public/lib/types"; import type NativeQuery from "metabase-lib/queries/NativeQuery"; import type Database from "metabase-lib/metadata/Database"; import type Field from "metabase-lib/metadata/Field"; @@ -25,6 +25,10 @@ import type Question from "metabase-lib/Question"; import { TagEditorParam } from "./TagEditorParam"; import { TagEditorHelp } from "./TagEditorHelp"; +type GetEmbeddedParamVisibility = ( + slug: string, +) => EmbeddingParameterVisibility; + interface TagEditorSidebarProps { card: Card; query: NativeQuery; @@ -37,6 +41,7 @@ interface TagEditorSidebarProps { setTemplateTagConfig: (tag: TemplateTag, config: Parameter) => void; setParameterValue: (tagId: TemplateTagId, value: RowValue) => void; onClose: () => void; + getEmbeddedParameterVisibility: GetEmbeddedParamVisibility; } interface TagEditorSidebarState { @@ -48,17 +53,6 @@ export class TagEditorSidebar extends Component<TagEditorSidebarProps> { section: "settings", }; - static propTypes = { - card: PropTypes.object.isRequired, - onClose: PropTypes.func.isRequired, - databaseFields: PropTypes.array, - sampleDatabaseId: PropTypes.number, - setDatasetQuery: PropTypes.func.isRequired, - setTemplateTag: PropTypes.func.isRequired, - setTemplateTagConfig: PropTypes.func.isRequired, - setParameterValue: PropTypes.func.isRequired, - }; - setSection(section: "settings" | "help") { this.setState({ section }); MetabaseAnalytics.trackStructEvent( @@ -80,6 +74,7 @@ export class TagEditorSidebar extends Component<TagEditorSidebarProps> { setTemplateTagConfig, setParameterValue, onClose, + getEmbeddedParameterVisibility, } = this.props; const tags = query.variableTemplateTags(); const database = question.database(); @@ -121,6 +116,7 @@ export class TagEditorSidebar extends Component<TagEditorSidebarProps> { setTemplateTag={setTemplateTag} setTemplateTagConfig={setTemplateTagConfig} setParameterValue={setParameterValue} + getEmbeddedParameterVisibility={getEmbeddedParameterVisibility} /> ) : ( <TagEditorHelp @@ -145,6 +141,7 @@ interface SettingsPaneProps { setTemplateTag: (tag: TemplateTag) => void; setTemplateTagConfig: (tag: TemplateTag, config: Parameter) => void; setParameterValue: (tagId: TemplateTagId, value: RowValue) => void; + getEmbeddedParameterVisibility: GetEmbeddedParamVisibility; } const SettingsPane = ({ @@ -156,14 +153,20 @@ const SettingsPane = ({ setTemplateTag, setTemplateTagConfig, setParameterValue, + getEmbeddedParameterVisibility, }: SettingsPaneProps) => ( <div> {tags.map(tag => ( - <div key={tag.name}> + <div key={tag.id}> <TagEditorParam tag={tag} key={tag.name} parameter={parametersById[tag.id]} + embeddedParameterVisibility={ + parametersById[tag.id] + ? getEmbeddedParameterVisibility(parametersById[tag.id].slug) + : null + } databaseFields={databaseFields} database={database} databases={databases} diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index b2105f650e6dda7150415e2e43847547cd63ef68..43a6af5b612bbddb710faa51e5f3ac28f6ad7cc2 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -89,6 +89,7 @@ import { isResultsMetadataDirty, getShouldShowUnsavedChangesWarning, getRequiredTemplateTags, + getEmbeddedParameterVisibility, } from "../selectors"; import * as actions from "../actions"; import { VISUALIZATION_SLOW_TIMEOUT } from "../constants"; @@ -182,6 +183,8 @@ const mapStateToProps = (state, props) => { reportTimezone: getSetting(state, "report-timezone-long"), requiredTemplateTags: getRequiredTemplateTags(state), + getEmbeddedParameterVisibility: slug => + getEmbeddedParameterVisibility(state, slug), }; }; diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 0815a3ca27b07afb043e3f157d04df741ec09626..5f7d19f7f0b389cc51a1ee0318b6d91f1a08a942 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -1024,3 +1024,24 @@ export const getRequiredTemplateTags = createSelector( .map(key => templateTags[key]) : [], ); + +export const getEmbeddingParameters = createSelector([getCard], card => { + if (!card?.enable_embedding) { + return {}; + } + + return card.embedding_params ?? {}; +}); + +// Embeddings might be published without passing embedding_params to the server, +// in which case it's an empty object. We should treat such situations with +// caution, assuming that an absent parameter is "disabled". +export function getEmbeddedParameterVisibility(state, slug) { + const card = getCard(state); + if (!card?.enable_embedding) { + return null; + } + + const embeddingParams = card.embedding_params ?? {}; + return embeddingParams[slug] ?? "disabled"; +}