diff --git a/frontend/src/metabase-lib/lib/parameters/utils/click-behavior.js b/frontend/src/metabase-lib/lib/parameters/utils/click-behavior.js new file mode 100644 index 0000000000000000000000000000000000000000..0a1a815ea83d5dc26cdf5dcf8868c873df758e5a --- /dev/null +++ b/frontend/src/metabase-lib/lib/parameters/utils/click-behavior.js @@ -0,0 +1,292 @@ +import _ from "underscore"; +import { getIn } from "icepick"; + +import { parseTimestamp } from "metabase/lib/time"; +import { formatDateTimeForParameter } from "metabase/lib/formatting/date"; +import { isValidImplicitActionClickBehavior } from "metabase/writeback/utils"; +import { + dimensionFilterForParameter, + variableFilterForParameter, +} from "metabase-lib/lib/parameters/utils/filters"; +import { isa, isDate } from "metabase-lib/lib/types/utils/isa"; +import { TYPE } from "metabase-lib/lib/types/constants"; +import Question from "metabase-lib/lib/Question"; +import TemplateTagVariable from "metabase-lib/lib/variables/TemplateTagVariable"; +import { TemplateTagDimension } from "metabase-lib/lib/Dimension"; + +export function getDataFromClicked({ + extraData: { dashboard, parameterValuesBySlug, userAttributes } = {}, + dimensions = [], + data = [], +}) { + const column = [ + ...dimensions, + ...data.map(d => ({ + column: d.col, + // When the data is changed to a display value for use in tooltips, we can set clickBehaviorValue to the raw value for filtering. + value: d.clickBehaviorValue || d.value, + })), + ] + .filter(d => d.column != null) + .reduce( + (acc, { column, value }) => + acc[name] === undefined + ? { ...acc, [column.name.toLowerCase()]: { value, column } } + : acc, + {}, + ); + + const parameterByName = + dashboard == null + ? {} + : _.chain(dashboard.parameters) + .filter(p => parameterValuesBySlug[p.slug] != null) + .map(p => [ + p.name.toLowerCase(), + { value: parameterValuesBySlug[p.slug] }, + ]) + .object() + .value(); + + const parameterBySlug = _.mapObject(parameterValuesBySlug, value => ({ + value, + })); + + const parameter = + dashboard == null + ? {} + : _.chain(dashboard.parameters) + .filter(p => parameterValuesBySlug[p.slug] != null) + .map(p => [p.id, { value: parameterValuesBySlug[p.slug] }]) + .object() + .value(); + + const userAttribute = _.mapObject(userAttributes, value => ({ value })); + + return { column, parameter, parameterByName, parameterBySlug, userAttribute }; +} + +const { Text, Number, Temporal } = TYPE; + +function notRelativeDateOrRange({ type }) { + return type !== "date/range" && type !== "date/relative"; +} + +export function getTargetsWithSourceFilters({ + isDash, + isAction, + dashcard, + object, + metadata, +}) { + if (isAction) { + return getTargetsForAction(object); + } + return isDash + ? getTargetsForDashboard(object, dashcard) + : getTargetsForQuestion(object, metadata); +} + +function getTargetsForAction(action) { + const parameters = Object.values(action.parameters); + return parameters.map(parameter => { + const { id, name } = parameter; + return { + id, + name, + target: { type: "parameter", id }, + + // We probably don't want to allow everything + // and will need to add some filters eventually + sourceFilters: { + column: () => true, + parameter: () => true, + userAttribute: () => true, + }, + }; + }); +} + +function getTargetsForQuestion(question, metadata) { + const query = new Question(question, metadata).query(); + return query + .dimensionOptions() + .all() + .concat(query.variables()) + .map(o => { + let id, target; + if ( + o instanceof TemplateTagVariable || + o instanceof TemplateTagDimension + ) { + let name; + ({ id, name } = o.tag()); + target = { type: "variable", id: name }; + } else { + const dimension = ["dimension", o.mbql()]; + id = JSON.stringify(dimension); + target = { type: "dimension", id, dimension }; + } + let parentType; + let parameterSourceFilter = () => true; + const columnSourceFilter = c => isa(c.base_type, parentType); + if (o instanceof TemplateTagVariable) { + parentType = { text: Text, number: Number, date: Temporal }[ + o.tag().type + ]; + parameterSourceFilter = parameter => + variableFilterForParameter(parameter)(o); + } else if (o.field() != null) { + const { base_type } = o.field(); + parentType = + [Temporal, Number, Text].find(t => isa(base_type, t)) || base_type; + parameterSourceFilter = parameter => + dimensionFilterForParameter(parameter)(o); + } + + return { + id, + target, + name: o.displayName({ includeTable: true }), + sourceFilters: { + column: columnSourceFilter, + parameter: parameterSourceFilter, + userAttribute: () => parentType === Text, + }, + }; + }); +} + +function getTargetsForDashboard(dashboard, dashcard) { + return dashboard.parameters.map(parameter => { + const { type, id, name } = parameter; + const filter = baseTypeFilterForParameterType(type); + return { + id, + name, + target: { type: "parameter", id }, + sourceFilters: { + column: c => notRelativeDateOrRange(parameter) && filter(c.base_type), + parameter: sourceParam => { + // parameter IDs are generated client-side, so they might not be unique + // if dashboard is a clone, it will have identical parameter IDs to the original + const isSameParameter = + dashboard.id === dashcard.dashboard_id && + parameter.id === sourceParam.id; + return parameter.type === sourceParam.type && !isSameParameter; + }, + userAttribute: () => !parameter.type.startsWith("date"), + }, + }; + }); +} + +function baseTypeFilterForParameterType(parameterType) { + const [typePrefix] = parameterType.split("/"); + const allowedTypes = { + date: [TYPE.Temporal], + id: [TYPE.Integer, TYPE.UUID], + category: [TYPE.Text, TYPE.Integer], + location: [TYPE.Text], + }[typePrefix]; + if (allowedTypes === undefined) { + // default to showing everything + return () => true; + } + return baseType => + allowedTypes.some(allowedType => isa(baseType, allowedType)); +} + +export function clickBehaviorIsValid(clickBehavior) { + // opens action menu + if (clickBehavior == null) { + return true; + } + const { + type, + parameterMapping = {}, + linkType, + targetId, + linkTemplate, + } = clickBehavior; + if (type === "crossfilter") { + return Object.keys(parameterMapping).length > 0; + } + if (type === "action") { + return isValidImplicitActionClickBehavior(clickBehavior); + } + // if it's not a crossfilter/action, it's a link + if (linkType === "url") { + return (linkTemplate || "").length > 0; + } + // if we're linking to a Metabase entity we just need a targetId + if ( + linkType === "dashboard" || + linkType === "question" || + linkType === "page" + ) { + return targetId != null; + } + // we've picked "link" without picking a link type + return false; +} + +export function formatSourceForTarget( + source, + target, + { data, extraData, clickBehavior }, +) { + const datum = data[source.type][source.id.toLowerCase()] || []; + if (datum.column && isDate(datum.column)) { + if (target.type === "parameter") { + // we should serialize differently based on the target parameter type + const parameter = getParameter(target, { extraData, clickBehavior }); + if (parameter) { + return formatDateForParameterType( + datum.value, + parameter.type, + datum.column.unit, + ); + } + } else { + // If the target is a dimension or variable,, we serialize as a date to remove the timestamp. + // TODO: provide better serialization for field filter widget types + return formatDateForParameterType(datum.value, "date/single"); + } + } + return datum.value; +} + +function formatDateForParameterType(value, parameterType, unit) { + const m = parseTimestamp(value); + if (!m.isValid()) { + return String(value); + } + if (parameterType === "date/month-year") { + return m.format("YYYY-MM"); + } else if (parameterType === "date/quarter-year") { + return m.format("[Q]Q-YYYY"); + } else if (parameterType === "date/single") { + return m.format("YYYY-MM-DD"); + } else if (parameterType === "date/all-options") { + return formatDateTimeForParameter(value, unit); + } + return value; +} + +export function getTargetForQueryParams(target, { extraData, clickBehavior }) { + if (target.type === "parameter") { + const parameter = getParameter(target, { extraData, clickBehavior }); + return parameter && parameter.slug; + } + return target.id; +} + +function getParameter(target, { extraData, clickBehavior }) { + const parameterPath = + clickBehavior.type === "crossfilter" + ? ["dashboard", "parameters"] + : ["dashboards", clickBehavior.targetId, "parameters"]; + const parameters = getIn(extraData, parameterPath) || []; + return parameters.find(p => p.id === target.id); +} diff --git a/frontend/src/metabase-lib/lib/queries/drills/dashboard-click-drill.js b/frontend/src/metabase-lib/lib/queries/drills/dashboard-click-drill.js new file mode 100644 index 0000000000000000000000000000000000000000..71d6427476dacca96358d592d598edc3951939f5 --- /dev/null +++ b/frontend/src/metabase-lib/lib/queries/drills/dashboard-click-drill.js @@ -0,0 +1,221 @@ +import _ from "underscore"; +import { getIn } from "icepick"; +import querystring from "querystring"; +import * as Urls from "metabase/lib/urls"; +import { renderLinkURLForClick } from "metabase/lib/formatting/link"; +import { + formatSourceForTarget, + getDataFromClicked, + getTargetForQueryParams, +} from "metabase-lib/lib/parameters/utils/click-behavior"; +import Question from "metabase-lib/lib/Question"; + +export function getDashboardDrillType(clicked) { + const clickBehavior = getClickBehavior(clicked); + if (clickBehavior == null) { + return null; + } + + const { type, linkType, targetId, extraData } = getClickBehaviorData( + clicked, + clickBehavior, + ); + if (!hasLinkTargetData(clickBehavior, extraData)) { + return null; + } + + if (type === "crossfilter") { + return "dashboard-filter"; + } else if (type === "link") { + if (linkType === "url") { + return "link-url"; + } else if (linkType === "dashboard") { + if (extraData.dashboard.id === targetId) { + return "dashboard-reset"; + } else { + return "dashboard-url"; + } + } else if (linkType === "page") { + const { location, routerParams } = extraData; + + const isInDataApp = + Urls.isDataAppPagePath(location.pathname) || + Urls.isDataAppPath(location.pathname); + if (!isInDataApp) { + return null; + } + + const dataAppId = Urls.extractEntityId(routerParams.slug); + if (!dataAppId) { + return null; + } + + return "page-url"; + } else if (linkType === "question" && extraData && extraData.questions) { + return "question-url"; + } + } + + return null; +} + +export function getDashboardDrillParameters(clicked) { + const clickBehavior = getClickBehavior(clicked); + const { data, parameterMapping, extraData } = getClickBehaviorData( + clicked, + clickBehavior, + ); + + return getParameterIdValuePairs(parameterMapping, { + data, + extraData, + clickBehavior, + }); +} + +export function getDashboardDrillLinkUrl(clicked) { + const clickBehavior = getClickBehavior(clicked); + const { data } = getClickBehaviorData(clicked, clickBehavior); + + return renderLinkURLForClick(clickBehavior.linkTemplate || "", data); +} + +export function getDashboardDrillUrl(clicked) { + const clickBehavior = getClickBehavior(clicked); + const { data, extraData, parameterMapping, targetId } = getClickBehaviorData( + clicked, + clickBehavior, + ); + + const queryParams = getParameterValuesBySlug(parameterMapping, { + data, + extraData, + clickBehavior, + }); + + const path = Urls.dashboard({ id: targetId }); + return `${path}?${querystring.stringify(queryParams)}`; +} + +export function getDashboardDrillPageUrl(clicked) { + const clickBehavior = getClickBehavior(clicked); + const { data, extraData, parameterMapping, targetId } = getClickBehaviorData( + clicked, + clickBehavior, + ); + + const { routerParams } = extraData; + const dataAppId = Urls.extractEntityId(routerParams.slug); + const path = Urls.dataAppPage({ id: dataAppId }, { id: targetId }); + + const queryParams = getParameterValuesBySlug(parameterMapping, { + data, + extraData, + clickBehavior, + }); + + return `${path}?${querystring.stringify(queryParams)}`; +} + +export function getDashboardDrillQuestionUrl(question, clicked) { + const clickBehavior = getClickBehavior(clicked); + const { data, extraData, parameterMapping, targetId } = getClickBehaviorData( + clicked, + clickBehavior, + ); + + const targetQuestion = new Question( + extraData.questions[targetId], + question.metadata(), + ).lockDisplay(); + + const parameters = _.chain(parameterMapping) + .values() + .map(({ target, id, source }) => ({ + target: target.dimension, + id, + slug: id, + type: getTypeForSource(source, extraData), + })) + .value(); + + const queryParams = getParameterValuesBySlug(parameterMapping, { + data, + extraData, + clickBehavior, + }); + + return targetQuestion.isStructured() + ? targetQuestion.getUrlWithParameters(parameters, queryParams) + : `${targetQuestion.getUrl()}?${querystring.stringify(queryParams)}`; +} + +function getClickBehavior(clicked) { + const settings = (clicked && clicked.settings) || {}; + const columnSettings = + (clicked && + clicked.column && + settings.column && + settings.column(clicked.column)) || + {}; + + return columnSettings.click_behavior || settings.click_behavior; +} + +function getClickBehaviorData(clicked, clickBehavior) { + const data = getDataFromClicked(clicked); + const { type, linkType, parameterMapping, targetId } = clickBehavior; + const { extraData } = clicked || {}; + + return { type, linkType, data, extraData, parameterMapping, targetId }; +} + +function getParameterIdValuePairs( + parameterMapping, + { data, extraData, clickBehavior }, +) { + return _.values(parameterMapping).map(({ source, target, id }) => { + return [ + id, + formatSourceForTarget(source, target, { + data, + extraData, + clickBehavior, + }), + ]; + }); +} + +function getParameterValuesBySlug( + parameterMapping, + { data, extraData, clickBehavior }, +) { + return _.chain(parameterMapping) + .values() + .map(({ source, target }) => [ + getTargetForQueryParams(target, { extraData, clickBehavior }), + formatSourceForTarget(source, target, { data, extraData, clickBehavior }), + ]) + .filter(([key, value]) => value != null) + .object() + .value(); +} + +function getTypeForSource(source, extraData) { + if (source.type === "parameter") { + const parameters = getIn(extraData, ["dashboard", "parameters"]) || []; + const { type = "text" } = parameters.find(p => p.id === source.id) || {}; + return type; + } + return "text"; +} + +function hasLinkTargetData(clickBehavior, extraData) { + const { linkType, targetId } = clickBehavior; + if (linkType === "question") { + return getIn(extraData, ["questions", targetId]) != null; + } else if (linkType === "dashboard") { + return getIn(extraData, ["dashboards", targetId]) != null; + } + return true; +} diff --git a/frontend/src/metabase/dashboard/actions/save.js b/frontend/src/metabase/dashboard/actions/save.js index 0ad697f79f5c2eecfdab694131882ad86f032b35..68fa3a73fc799709ae9185448feaf1338bd44a53 100644 --- a/frontend/src/metabase/dashboard/actions/save.js +++ b/frontend/src/metabase/dashboard/actions/save.js @@ -5,9 +5,8 @@ import { createThunkAction } from "metabase/lib/redux"; import Dashboards from "metabase/entities/dashboards"; -import { clickBehaviorIsValid } from "metabase/lib/click-behavior"; - import { DashboardApi, CardApi } from "metabase/services"; +import { clickBehaviorIsValid } from "metabase-lib/lib/parameters/utils/click-behavior"; import { getDashboardBeforeEditing } from "../selectors"; diff --git a/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx b/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx index c5b8912db14c59ddb4fb406034e246d55faca8b2..7ff35aa71bd20137f3dd693c8c203f73d61081b6 100644 --- a/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx +++ b/frontend/src/metabase/dashboard/components/AddActionSidebar/ButtonOptions.tsx @@ -6,11 +6,11 @@ import type { CustomDestinationClickBehavior, DashboardOrderedCard, } from "metabase-types/api"; -import { clickBehaviorIsValid } from "metabase/lib/click-behavior"; import { BehaviorOption } from "metabase/dashboard/components/ClickBehaviorSidebar/TypeSelector/TypeSelector"; import LinkOptions from "metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions"; import Icon from "metabase/components/Icon"; +import { clickBehaviorIsValid } from "metabase-lib/lib/parameters/utils/click-behavior"; import { ClickBehaviorPickerText, diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebar.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebar.tsx index 129b1b42b4ba89f530e0d31d090ffb47553879b4..a81834c7f366ff49197b89c51445ebf8e896cbd0 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebar.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebar.tsx @@ -1,11 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getIn } from "icepick"; -import { - isTableDisplay, - clickBehaviorIsValid, -} from "metabase/lib/click-behavior"; - import { useOnMount } from "metabase/hooks/use-on-mount"; import { usePrevious } from "metabase/hooks/use-previous"; @@ -23,6 +18,8 @@ import type { DatasetData, } from "metabase-types/api"; import type { Column } from "metabase-types/types/Dataset"; +import { isTableDisplay } from "metabase/lib/click-behavior"; +import { clickBehaviorIsValid } from "metabase-lib/lib/parameters/utils/click-behavior"; import { keyForColumn } from "metabase-lib/lib/queries/utils/dataset"; import { getClickBehaviorForColumn } from "./utils"; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarContent.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarContent.tsx index 60a6bb4e4d311bf8b07e70abea3c2d24e4e74596..b1c835b72a0479179ff26f05c07b94ab53e63394 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarContent.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarContent.tsx @@ -1,8 +1,6 @@ import React, { useMemo } from "react"; import { getIn } from "icepick"; -import { isTableDisplay } from "metabase/lib/click-behavior"; - import { isMappedExplicitActionButton } from "metabase/writeback/utils"; import type { UiParameter } from "metabase/parameters/types"; @@ -16,6 +14,7 @@ import type { } from "metabase-types/api"; import type { Column } from "metabase-types/types/Dataset"; +import { isTableDisplay } from "metabase/lib/click-behavior"; import { getClickBehaviorForColumn } from "./utils"; import ClickBehaviorSidebarMainView from "./ClickBehaviorSidebarMainView"; import TableClickBehaviorView from "./TableClickBehaviorView"; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarHeader/ClickBehaviorSidebarHeader.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarHeader/ClickBehaviorSidebarHeader.tsx index a1a339f268bfa300c7f4464fcb7c31da4e3c5d3b..7947f01d54c65a37427e600bb33253f00f6c209b 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarHeader/ClickBehaviorSidebarHeader.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ClickBehaviorSidebarHeader/ClickBehaviorSidebarHeader.tsx @@ -3,7 +3,6 @@ import { t, jt } from "ttag"; import Icon from "metabase/components/Icon"; -import { isTableDisplay } from "metabase/lib/click-behavior"; import { isActionDashCard, getActionButtonLabel, @@ -12,6 +11,7 @@ import { import type { DashboardOrderedCard } from "metabase-types/api"; import type { Column } from "metabase-types/types/Dataset"; +import { isTableDisplay } from "metabase/lib/click-behavior"; import { Heading, SidebarHeader } from "../ClickBehaviorSidebar.styled"; import { ColumnClickBehaviorHeader, diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/CustomURLPicker.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/CustomURLPicker.tsx index c21364f3664ff486b9227c78ea613124a7da4870..c0e37ee57a3b2b2e0454c7d4444ee02246ff661f 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/CustomURLPicker.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/CustomURLPicker.tsx @@ -5,17 +5,14 @@ import InputBlurChange from "metabase/components/InputBlurChange"; import ModalContent from "metabase/components/ModalContent"; import ModalWithTrigger from "metabase/components/ModalWithTrigger"; -import { - isTableDisplay, - clickBehaviorIsValid, -} from "metabase/lib/click-behavior"; - import type { UiParameter } from "metabase/parameters/types"; import type { ArbitraryCustomDestinationClickBehavior, ClickBehavior, DashboardOrderedCard, } from "metabase-types/api"; +import { isTableDisplay } from "metabase/lib/click-behavior"; +import { clickBehaviorIsValid } from "metabase-lib/lib/parameters/utils/click-behavior"; import { SidebarItem } from "../SidebarItem"; import CustomLinkText from "./CustomLinkText"; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/LinkOptions.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/LinkOptions.tsx index 1cd9346cd3e776e239c9688fb218a222c1ebb78a..fe1a3f8216f47fbbc970650cca29697ca074266e 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/LinkOptions.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/LinkOptions/LinkOptions.tsx @@ -1,8 +1,6 @@ import React, { useCallback } from "react"; import { t } from "ttag"; -import { isTableDisplay } from "metabase/lib/click-behavior"; - import type { UiParameter } from "metabase/parameters/types"; import type { DashboardOrderedCard, @@ -11,6 +9,7 @@ import type { CustomDestinationClickBehavior, CustomDestinationClickBehaviorLinkType, } from "metabase-types/api"; +import { isTableDisplay } from "metabase/lib/click-behavior"; import { SidebarContent } from "../ClickBehaviorSidebar.styled"; import CustomLinkText from "./CustomLinkText"; import LinkedEntityPicker from "./LinkedEntityPicker"; diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TableClickBehaviorView/TableClickBehaviorView.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TableClickBehaviorView/TableClickBehaviorView.tsx index 751b3169756258520b5f2e5bd0de6ea99d82fa76..1a09ce97cd99940c6424bd7fac82430b777530b0 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TableClickBehaviorView/TableClickBehaviorView.tsx +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/TableClickBehaviorView/TableClickBehaviorView.tsx @@ -2,8 +2,6 @@ import React, { useMemo, useCallback } from "react"; import { t } from "ttag"; import _ from "underscore"; -import { hasActionsMenu } from "metabase/lib/click-behavior"; - import type { DashboardOrderedCard, ClickBehavior, @@ -11,6 +9,7 @@ import type { } from "metabase-types/api"; import type { Column as IColumn } from "metabase-types/types/Dataset"; +import { hasActionsMenu } from "metabase/lib/click-behavior"; import Column from "./Column"; const COLUMN_SORTING_ORDER_BY_CLICK_BEHAVIOR_TYPE = [ diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts index 27dd4af4ca85d1c2dac9ab76d27abd71fc0f72ef..9a35adba1bfb8f78a4a45cdf84d6c2cd53d1e7e3 100644 --- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts +++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/utils.ts @@ -1,13 +1,12 @@ import { t } from "ttag"; import { getIn } from "icepick"; -import { hasActionsMenu } from "metabase/lib/click-behavior"; - import type { ClickBehaviorType, DashboardOrderedCard, } from "metabase-types/api"; import type { Column } from "metabase-types/types/Dataset"; +import { hasActionsMenu } from "metabase/lib/click-behavior"; import { keyForColumn } from "metabase-lib/lib/queries/utils/dataset"; type ClickBehaviorOption = { diff --git a/frontend/src/metabase/dashboard/components/ClickMappings.jsx b/frontend/src/metabase/dashboard/components/ClickMappings.jsx index b278757d0cde9e078dffbbc2b3cb5943b62579e5..bb2025d8eecef92102f03b9ba985bec62e008913 100644 --- a/frontend/src/metabase/dashboard/components/ClickMappings.jsx +++ b/frontend/src/metabase/dashboard/components/ClickMappings.jsx @@ -10,12 +10,12 @@ import Select from "metabase/core/components/Select"; import MetabaseSettings from "metabase/lib/settings"; import { isPivotGroupColumn } from "metabase/lib/data_grid"; -import { getTargetsWithSourceFilters } from "metabase/lib/click-behavior"; import { GTAPApi } from "metabase/services"; import { loadMetadataForQuery } from "metabase/redux/metadata"; import { getMetadata } from "metabase/selectors/metadata"; import { getParameters } from "metabase/dashboard/selectors"; +import { getTargetsWithSourceFilters } from "metabase-lib/lib/parameters/utils/click-behavior"; import Question from "metabase-lib/lib/Question"; class ClickMappingsInner extends React.Component { diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index ef25af8905f2a9801431243b4c68861b62ef3325..0fe079b5cc4be0eab9c07a6a90764154321685a7 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -27,12 +27,12 @@ import Tooltip from "metabase/components/Tooltip"; import { isVirtualDashCard } from "metabase/dashboard/utils"; import { IS_EMBED_PREVIEW } from "metabase/lib/embed"; -import { getClickBehaviorDescription } from "metabase/lib/click-behavior"; import { isActionCard } from "metabase/writeback/utils"; import { getParameterValuesBySlug } from "metabase/parameters/utils/parameter-values"; import Utils from "metabase/lib/utils"; +import { getClickBehaviorDescription } from "metabase/lib/click-behavior"; import DashCardParameterMapper from "./DashCardParameterMapper"; import { DashCardRoot } from "./DashCard.styled"; diff --git a/frontend/src/metabase/lib/click-behavior.js b/frontend/src/metabase/lib/click-behavior.js index b53ca8252a2ea344190154d00304713b315746b8..debd4fc8766367b89374b95b9af5426512f653c4 100644 --- a/frontend/src/metabase/lib/click-behavior.js +++ b/frontend/src/metabase/lib/click-behavior.js @@ -1,202 +1,5 @@ -import _ from "underscore"; +import { msgid, ngettext, t } from "ttag"; import { getIn } from "icepick"; -import { t, ngettext, msgid } from "ttag"; - -import { parseTimestamp } from "metabase/lib/time"; -import { formatDateTimeForParameter } from "metabase/lib/formatting/date"; -import { isValidImplicitActionClickBehavior } from "metabase/writeback/utils"; -import { - dimensionFilterForParameter, - variableFilterForParameter, -} from "metabase-lib/lib/parameters/utils/filters"; -import { isa, isDate } from "metabase-lib/lib/types/utils/isa"; -import { TYPE } from "metabase-lib/lib/types/constants"; -import Question from "metabase-lib/lib/Question"; -import TemplateTagVariable from "metabase-lib/lib/variables/TemplateTagVariable"; -import { TemplateTagDimension } from "metabase-lib/lib/Dimension"; - -export function getDataFromClicked({ - extraData: { dashboard, parameterValuesBySlug, userAttributes } = {}, - dimensions = [], - data = [], -}) { - const column = [ - ...dimensions, - ...data.map(d => ({ - column: d.col, - // When the data is changed to a display value for use in tooltips, we can set clickBehaviorValue to the raw value for filtering. - value: d.clickBehaviorValue || d.value, - })), - ] - .filter(d => d.column != null) - .reduce( - (acc, { column, value }) => - acc[name] === undefined - ? { ...acc, [column.name.toLowerCase()]: { value, column } } - : acc, - {}, - ); - - const parameterByName = - dashboard == null - ? {} - : _.chain(dashboard.parameters) - .filter(p => parameterValuesBySlug[p.slug] != null) - .map(p => [ - p.name.toLowerCase(), - { value: parameterValuesBySlug[p.slug] }, - ]) - .object() - .value(); - - const parameterBySlug = _.mapObject(parameterValuesBySlug, value => ({ - value, - })); - - const parameter = - dashboard == null - ? {} - : _.chain(dashboard.parameters) - .filter(p => parameterValuesBySlug[p.slug] != null) - .map(p => [p.id, { value: parameterValuesBySlug[p.slug] }]) - .object() - .value(); - - const userAttribute = _.mapObject(userAttributes, value => ({ value })); - - return { column, parameter, parameterByName, parameterBySlug, userAttribute }; -} - -const { Text, Number, Temporal } = TYPE; - -function notRelativeDateOrRange({ type }) { - return type !== "date/range" && type !== "date/relative"; -} - -export function getTargetsWithSourceFilters({ - isDash, - isAction, - dashcard, - object, - metadata, -}) { - if (isAction) { - return getTargetsForAction(object); - } - return isDash - ? getTargetsForDashboard(object, dashcard) - : getTargetsForQuestion(object, metadata); -} - -function getTargetsForAction(action) { - const parameters = Object.values(action.parameters); - return parameters.map(parameter => { - const { id, name } = parameter; - return { - id, - name, - target: { type: "parameter", id }, - - // We probably don't want to allow everything - // and will need to add some filters eventually - sourceFilters: { - column: () => true, - parameter: () => true, - userAttribute: () => true, - }, - }; - }); -} - -function getTargetsForQuestion(question, metadata) { - const query = new Question(question, metadata).query(); - return query - .dimensionOptions() - .all() - .concat(query.variables()) - .map(o => { - let id, target; - if ( - o instanceof TemplateTagVariable || - o instanceof TemplateTagDimension - ) { - let name; - ({ id, name } = o.tag()); - target = { type: "variable", id: name }; - } else { - const dimension = ["dimension", o.mbql()]; - id = JSON.stringify(dimension); - target = { type: "dimension", id, dimension }; - } - let parentType; - let parameterSourceFilter = () => true; - const columnSourceFilter = c => isa(c.base_type, parentType); - if (o instanceof TemplateTagVariable) { - parentType = { text: Text, number: Number, date: Temporal }[ - o.tag().type - ]; - parameterSourceFilter = parameter => - variableFilterForParameter(parameter)(o); - } else if (o.field() != null) { - const { base_type } = o.field(); - parentType = - [Temporal, Number, Text].find(t => isa(base_type, t)) || base_type; - parameterSourceFilter = parameter => - dimensionFilterForParameter(parameter)(o); - } - - return { - id, - target, - name: o.displayName({ includeTable: true }), - sourceFilters: { - column: columnSourceFilter, - parameter: parameterSourceFilter, - userAttribute: () => parentType === Text, - }, - }; - }); -} - -function getTargetsForDashboard(dashboard, dashcard) { - return dashboard.parameters.map(parameter => { - const { type, id, name } = parameter; - const filter = baseTypeFilterForParameterType(type); - return { - id, - name, - target: { type: "parameter", id }, - sourceFilters: { - column: c => notRelativeDateOrRange(parameter) && filter(c.base_type), - parameter: sourceParam => { - // parameter IDs are generated client-side, so they might not be unique - // if dashboard is a clone, it will have identical parameter IDs to the original - const isSameParameter = - dashboard.id === dashcard.dashboard_id && - parameter.id === sourceParam.id; - return parameter.type === sourceParam.type && !isSameParameter; - }, - userAttribute: () => !parameter.type.startsWith("date"), - }, - }; - }); -} - -function baseTypeFilterForParameterType(parameterType) { - const [typePrefix] = parameterType.split("/"); - const allowedTypes = { - date: [TYPE.Temporal], - id: [TYPE.Integer, TYPE.UUID], - category: [TYPE.Text, TYPE.Integer], - location: [TYPE.Text], - }[typePrefix]; - if (allowedTypes === undefined) { - // default to showing everything - return () => true; - } - return baseType => - allowedTypes.some(allowedType => isa(baseType, allowedType)); -} export function getClickBehaviorDescription(dashcard) { const noBehaviorMessage = hasActionsMenu(dashcard) @@ -233,40 +36,6 @@ export function getClickBehaviorDescription(dashcard) { return t`Filter this dashboard`; } -export function clickBehaviorIsValid(clickBehavior) { - // opens action menu - if (clickBehavior == null) { - return true; - } - const { - type, - parameterMapping = {}, - linkType, - targetId, - linkTemplate, - } = clickBehavior; - if (type === "crossfilter") { - return Object.keys(parameterMapping).length > 0; - } - if (type === "action") { - return isValidImplicitActionClickBehavior(clickBehavior); - } - // if it's not a crossfilter/action, it's a link - if (linkType === "url") { - return (linkTemplate || "").length > 0; - } - // if we're linking to a Metabase entity we just need a targetId - if ( - linkType === "dashboard" || - linkType === "question" || - linkType === "page" - ) { - return targetId != null; - } - // we've picked "link" without picking a link type - return false; -} - export function hasActionsMenu(dashcard) { // This seems to work, but it isn't the right logic. // The right thing to do would be to check for any drills. However, we'd need a "clicked" object for that. @@ -276,63 +45,3 @@ export function hasActionsMenu(dashcard) { export function isTableDisplay(dashcard) { return dashcard?.card?.display === "table"; } - -export function formatSourceForTarget( - source, - target, - { data, extraData, clickBehavior }, -) { - const datum = data[source.type][source.id.toLowerCase()] || []; - if (datum.column && isDate(datum.column)) { - if (target.type === "parameter") { - // we should serialize differently based on the target parameter type - const parameter = getParameter(target, { extraData, clickBehavior }); - if (parameter) { - return formatDateForParameterType( - datum.value, - parameter.type, - datum.column.unit, - ); - } - } else { - // If the target is a dimension or variable,, we serialize as a date to remove the timestamp. - // TODO: provide better serialization for field filter widget types - return formatDateForParameterType(datum.value, "date/single"); - } - } - return datum.value; -} - -function formatDateForParameterType(value, parameterType, unit) { - const m = parseTimestamp(value); - if (!m.isValid()) { - return String(value); - } - if (parameterType === "date/month-year") { - return m.format("YYYY-MM"); - } else if (parameterType === "date/quarter-year") { - return m.format("[Q]Q-YYYY"); - } else if (parameterType === "date/single") { - return m.format("YYYY-MM-DD"); - } else if (parameterType === "date/all-options") { - return formatDateTimeForParameter(value, unit); - } - return value; -} - -export function getTargetForQueryParams(target, { extraData, clickBehavior }) { - if (target.type === "parameter") { - const parameter = getParameter(target, { extraData, clickBehavior }); - return parameter && parameter.slug; - } - return target.id; -} - -function getParameter(target, { extraData, clickBehavior }) { - const parameterPath = - clickBehavior.type === "crossfilter" - ? ["dashboard", "parameters"] - : ["dashboards", clickBehavior.targetId, "parameters"]; - const parameters = getIn(extraData, parameterPath) || []; - return parameters.find(p => p.id === target.id); -} diff --git a/frontend/src/metabase/lib/formatting/email.tsx b/frontend/src/metabase/lib/formatting/email.tsx index ad47f33e36c9528e64838965acce000b2676de7c..0f867956088eba5bda632c5b7811df2f7743597f 100644 --- a/frontend/src/metabase/lib/formatting/email.tsx +++ b/frontend/src/metabase/lib/formatting/email.tsx @@ -1,7 +1,7 @@ import React from "react"; import ExternalLink from "metabase/core/components/ExternalLink"; -import { getDataFromClicked } from "metabase/lib/click-behavior"; +import { getDataFromClicked } from "metabase-lib/lib/parameters/utils/click-behavior"; import { renderLinkTextForClick } from "./link"; import { OptionsType } from "./types"; diff --git a/frontend/src/metabase/lib/formatting/strings.ts b/frontend/src/metabase/lib/formatting/strings.ts index 15ab0d3740f424d1b39451ed07b6c718c6fb05e0..c84c3c7eac9e0e0b620a27b4bfd7ba7a4f63c99c 100644 --- a/frontend/src/metabase/lib/formatting/strings.ts +++ b/frontend/src/metabase/lib/formatting/strings.ts @@ -1,6 +1,6 @@ import inflection from "inflection"; -import { getDataFromClicked } from "metabase/lib/click-behavior"; +import { getDataFromClicked } from "metabase-lib/lib/parameters/utils/click-behavior"; import { formatUrl } from "./url"; import { renderLinkTextForClick } from "./link"; import { formatValue, getRemappedValue } from "./value"; diff --git a/frontend/src/metabase/lib/formatting/url.tsx b/frontend/src/metabase/lib/formatting/url.tsx index 0d67dc1e37812bde4154e490b5e66be531fb018a..2507bc757e3bbef2ebb95d3a20ce8b56234eaa30 100644 --- a/frontend/src/metabase/lib/formatting/url.tsx +++ b/frontend/src/metabase/lib/formatting/url.tsx @@ -1,7 +1,7 @@ import React from "react"; import ExternalLink from "metabase/core/components/ExternalLink"; -import { getDataFromClicked } from "metabase/lib/click-behavior"; +import { getDataFromClicked } from "metabase-lib/lib/parameters/utils/click-behavior"; import { isURL } from "metabase-lib/lib/types/utils/isa"; import { renderLinkTextForClick, renderLinkURLForClick } from "./link"; import { formatValue, getRemappedValue } from "./value"; diff --git a/frontend/src/metabase/lib/formatting/value.tsx b/frontend/src/metabase/lib/formatting/value.tsx index 2f281974f40cbb860ccdc398dd8b0dd8a93ad590..55b65138e39a768822f9b985dedf1d9a046c5718 100644 --- a/frontend/src/metabase/lib/formatting/value.tsx +++ b/frontend/src/metabase/lib/formatting/value.tsx @@ -4,12 +4,12 @@ import Mustache from "mustache"; import moment, { Moment } from "moment-timezone"; import ExternalLink from "metabase/core/components/ExternalLink"; +import { renderLinkTextForClick } from "metabase/lib/formatting/link"; +import { NULL_DISPLAY_VALUE, NULL_NUMERIC_VALUE } from "metabase/lib/constants"; import { clickBehaviorIsValid, getDataFromClicked, -} from "metabase/lib/click-behavior"; -import { renderLinkTextForClick } from "metabase/lib/formatting/link"; -import { NULL_DISPLAY_VALUE, NULL_NUMERIC_VALUE } from "metabase/lib/constants"; +} from "metabase-lib/lib/parameters/utils/click-behavior"; import { rangeForValue } from "metabase-lib/lib/queries/utils/dataset"; import { isBoolean, diff --git a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.tsx b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.tsx index 129a560d14863aebe94086773855a801897f31f6..18765a5ac4c4303a112d2304289be53d063ffb03 100644 --- a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.tsx +++ b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.tsx @@ -1,6 +1,5 @@ -import { getDataFromClicked } from "metabase/lib/click-behavior"; - import { openActionParametersModal } from "metabase/dashboard/actions"; +import { getDataFromClicked } from "metabase-lib/lib/parameters/utils/click-behavior"; import type { ActionClickObject } from "./types"; diff --git a/frontend/src/metabase/modes/components/drill/DashboardClickDrill.jsx b/frontend/src/metabase/modes/components/drill/DashboardClickDrill.jsx index d432d7fdff65534f9b4a24699dea429229234dcf..204e45f7270144fb51a7a0224edd0a18560ff0df 100644 --- a/frontend/src/metabase/modes/components/drill/DashboardClickDrill.jsx +++ b/frontend/src/metabase/modes/components/drill/DashboardClickDrill.jsx @@ -1,204 +1,62 @@ -/* eslint-disable react/prop-types */ -import { getIn } from "icepick"; -import _ from "underscore"; -import querystring from "querystring"; import { push } from "react-router-redux"; - import { setOrUnsetParameterValues, setParameterValue, } from "metabase/dashboard/actions"; import { - getDataFromClicked, - getTargetForQueryParams, - formatSourceForTarget, -} from "metabase/lib/click-behavior"; -import { renderLinkURLForClick } from "metabase/lib/formatting/link"; -import * as Urls from "metabase/lib/urls"; -import Question from "metabase-lib/lib/Question"; - -export default ({ question, clicked }) => { - const settings = (clicked && clicked.settings) || {}; - const columnSettings = - (clicked && - clicked.column && - settings.column && - settings.column(clicked.column)) || - {}; - - const clickBehavior = - columnSettings.click_behavior || settings.click_behavior; - - if (clickBehavior == null) { - return []; - } - const { extraData } = clicked || {}; - const data = getDataFromClicked(clicked); - const { type, linkType, parameterMapping, targetId } = clickBehavior; - - let behavior; - - if (!hasLinkTargetData(clickBehavior, extraData)) { - return []; - } - - if (type === "crossfilter") { - const parameterIdValuePairs = getParameterIdValuePairs(parameterMapping, { - data, - extraData, - clickBehavior, - }); - - behavior = { - action: () => setOrUnsetParameterValues(parameterIdValuePairs), - }; - } else if (type === "link") { - if (linkType === "url") { - behavior = { + getDashboardDrillLinkUrl, + getDashboardDrillPageUrl, + getDashboardDrillParameters, + getDashboardDrillQuestionUrl, + getDashboardDrillType, + getDashboardDrillUrl, +} from "metabase-lib/lib/queries/drills/dashboard-click-drill"; + +function getAction(type, question, clicked) { + switch (type) { + case "link-url": + return { ignoreSiteUrl: true, - url: () => - renderLinkURLForClick(clickBehavior.linkTemplate || "", data), + url: () => getDashboardDrillLinkUrl(clicked), }; - } else if (linkType === "dashboard") { - if (extraData.dashboard.id === targetId) { - const parameterIdValuePairs = getParameterIdValuePairs( - parameterMapping, - { data, extraData, clickBehavior }, - ); - - behavior = { - action: () => { - return dispatch => - parameterIdValuePairs.forEach(([id, value]) => { - setParameterValue(id, value)(dispatch); - }); - }, - }; - } else { - const queryParams = getParameterValuesBySlug(parameterMapping, { - data, - extraData, - clickBehavior, - }); - - const path = Urls.dashboard({ id: targetId }); - const url = `${path}?${querystring.stringify(queryParams)}`; - - behavior = { url: () => url }; - } - } else if (linkType === "page") { - const { location, routerParams } = extraData; - - const isInDataApp = - Urls.isDataAppPagePath(location.pathname) || - Urls.isDataAppPath(location.pathname); - - if (!isInDataApp) { - return []; - } - - const dataAppId = Urls.extractEntityId(routerParams.slug); - if (!dataAppId) { - return []; - } - - const queryParams = getParameterValuesBySlug(parameterMapping, { - data, - extraData, - clickBehavior, - }); - - const path = Urls.dataAppPage({ id: dataAppId }, { id: targetId }); - const url = `${path}?${querystring.stringify(queryParams)}`; - - behavior = { action: () => push(url) }; - } else if (linkType === "question" && extraData && extraData.questions) { - const queryParams = getParameterValuesBySlug(parameterMapping, { - data, - extraData, - clickBehavior, - }); - - const targetQuestion = new Question( - extraData.questions[targetId], - question.metadata(), - ).lockDisplay(); - - const parameters = _.chain(parameterMapping) - .values() - .map(({ target, id, source }) => ({ - target: target.dimension, - id, - slug: id, - type: getTypeForSource(source, extraData), - })) - .value(); - - const url = targetQuestion.isStructured() - ? targetQuestion.getUrlWithParameters(parameters, queryParams) - : `${targetQuestion.getUrl()}?${querystring.stringify(queryParams)}`; + case "question-url": + return { + url: () => getDashboardDrillQuestionUrl(question, clicked), + }; + case "page-url": + return { action: () => push(getDashboardDrillPageUrl(clicked)) }; + case "dashboard-url": + return { url: () => getDashboardDrillUrl(clicked) }; + case "dashboard-filter": + return { + action: () => { + const parameterIdValuePairs = getDashboardDrillParameters(clicked); + return setOrUnsetParameterValues(parameterIdValuePairs); + }, + }; + case "dashboard-reset": + return { + action: () => dispatch => { + const parameterIdValuePairs = getDashboardDrillParameters(clicked); + parameterIdValuePairs + .map(([id, value]) => setParameterValue(id, value)) + .forEach(action => dispatch(action)); + }, + }; + } +} - behavior = { url: () => url }; - } +export default ({ question, clicked }) => { + const type = getDashboardDrillType(clicked); + if (!type) { + return []; } return [ { name: "click_behavior", defaultAlways: true, - ...behavior, + ...getAction(type, question, clicked), }, ]; }; - -function getParameterIdValuePairs( - parameterMapping, - { data, extraData, clickBehavior }, -) { - const value = _.values(parameterMapping).map(({ source, target, id }) => { - return [ - id, - formatSourceForTarget(source, target, { - data, - extraData, - clickBehavior, - }), - ]; - }); - - return value; -} - -function getParameterValuesBySlug( - parameterMapping, - { data, extraData, clickBehavior }, -) { - return _.chain(parameterMapping) - .values() - .map(({ source, target }) => [ - getTargetForQueryParams(target, { extraData, clickBehavior }), - formatSourceForTarget(source, target, { data, extraData, clickBehavior }), - ]) - .filter(([key, value]) => value != null) - .object() - .value(); -} - -function getTypeForSource(source, extraData) { - if (source.type === "parameter") { - const parameters = getIn(extraData, ["dashboard", "parameters"]) || []; - const { type = "text" } = parameters.find(p => p.id === source.id) || {}; - return type; - } - return "text"; -} - -function hasLinkTargetData(clickBehavior, extraData) { - const { linkType, targetId } = clickBehavior; - if (linkType === "question") { - return getIn(extraData, ["questions", targetId]) != null; - } else if (linkType === "dashboard") { - return getIn(extraData, ["dashboards", targetId]) != null; - } - return true; -} diff --git a/frontend/test/metabase/lib/click-behavior.unit.spec.js b/frontend/test/metabase/lib/click-behavior.unit.spec.js index 1031fe865cad3cca13b72e5871068f69a119361c..6874477096c0d3db30c494a6333cb09704743bd7 100644 --- a/frontend/test/metabase/lib/click-behavior.unit.spec.js +++ b/frontend/test/metabase/lib/click-behavior.unit.spec.js @@ -1,11 +1,11 @@ import _ from "underscore"; +import { metadata, PRODUCTS } from "__support__/sample_database_fixture"; +import * as dateFormatUtils from "metabase/lib/formatting/date"; import { getDataFromClicked, getTargetsWithSourceFilters, formatSourceForTarget, -} from "metabase/lib/click-behavior"; -import { metadata, PRODUCTS } from "__support__/sample_database_fixture"; -import * as dateFormatUtils from "metabase/lib/formatting/date"; +} from "metabase-lib/lib/parameters/utils/click-behavior"; describe("metabase/lib/click-behavior", () => { describe("getDataFromClicked", () => {