Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
click-behavior.js 9.04 KiB
import _ from "underscore";
import { getIn } from "icepick";

import { parseTimestamp } from "metabase/lib/time";
import { formatDateTimeForParameter } from "metabase/lib/formatting/date";
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;
}

function isValidImplicitActionClickBehavior(clickBehavior) {
  if (
    !clickBehavior ||
    clickBehavior.type !== "action" ||
    !("actionType" in clickBehavior)
  ) {
    return false;
  }
  if (clickBehavior.actionType === "insert") {
    return clickBehavior.tableId != null;
  }
  if (
    clickBehavior.actionType === "update" ||
    clickBehavior.actionType === "delete"
  ) {
    return typeof clickBehavior.objectDetailDashCardId === "number";
  }
  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);
}