diff --git a/frontend/src/metabase/components/ButtonGroup.jsx b/frontend/src/metabase/components/ButtonGroup.jsx new file mode 100644 index 0000000000000000000000000000000000000000..286af5c1a4c193874566f12842a180ce93c4cb02 --- /dev/null +++ b/frontend/src/metabase/components/ButtonGroup.jsx @@ -0,0 +1,48 @@ +/* @flow */ + +import React from "react"; + +import cx from "classnames"; + +type Value = any; +type Option = any; + +type Props = { + value: Value, + onChange: (value: Value) => void, + options: Option[], + optionNameFn?: (o: Option) => string | React$Element<any>, + optionValueFn?: (o: Option) => Value, + optionKeyFn?: (o: Option) => string, + className?: string, +}; + +const ButtonGroup = ({ + value, + onChange, + options, + optionNameFn = o => o.name, + optionValueFn = o => o.value, + optionKeyFn = optionValueFn, + className, +}: Props) => { + return ( + <div className={cx(className, "rounded bordered flex")}> + {options.map((o, index) => ( + <div + key={optionKeyFn(o)} + className={cx( + "flex flex-full layout-centered text-bold text-brand-hover p1 cursor-pointer", + { "border-left": index > 0 }, + optionValueFn(o) === value ? "text-brand" : "text-medium", + )} + onClick={() => onChange(optionValueFn(o))} + > + {optionNameFn(o)} + </div> + ))} + </div> + ); +}; + +export default ButtonGroup; diff --git a/frontend/src/metabase/components/FieldValuesWidget.jsx b/frontend/src/metabase/components/FieldValuesWidget.jsx index 1c8b08adeddd4d9b4199cd0296a88c25a7dd7e0e..54b4ed45cd431af91824c1453ab8720ee2aa7a8a 100644 --- a/frontend/src/metabase/components/FieldValuesWidget.jsx +++ b/frontend/src/metabase/components/FieldValuesWidget.jsx @@ -287,7 +287,7 @@ export class FieldValuesWidget extends Component { value={value} column={field} {...formatOptions} - round={false} + maximumFractionDigits={20} compact={false} autoLoad={true} /> @@ -296,7 +296,7 @@ export class FieldValuesWidget extends Component { <RemappedValue value={option[0]} column={field} - round={false} + maximumFractionDigits={20} autoLoad={false} {...formatOptions} /> diff --git a/frontend/src/metabase/lib/dataset.js b/frontend/src/metabase/lib/dataset.js index 51ae58f0c5d83d763161a5f21c21109f6e13ba7d..59203fd46585d233ee57e0153f69e2305cf3fe7a 100644 --- a/frontend/src/metabase/lib/dataset.js +++ b/frontend/src/metabase/lib/dataset.js @@ -60,6 +60,11 @@ export function fieldRefForColumn(column: Column): ?ConcreteField { } } +export const keyForColumn = (column: Column): string => { + const ref = fieldRefForColumn(column); + return JSON.stringify(ref ? ["ref", ref] : ["name", column.name]); +}; + /** * Finds the column object from the dataset results for the given `table.columns` column setting * @param {Column[]} columns Dataset results columns diff --git a/frontend/src/metabase/lib/formatting.js b/frontend/src/metabase/lib/formatting.js index 97215f5fe77a661bcbe0ab9afd6a03e07d34ebcb..b88dedbbe072b1e2df90ff923e5dff7eee0893f9 100644 --- a/frontend/src/metabase/lib/formatting.js +++ b/frontend/src/metabase/lib/formatting.js @@ -7,6 +7,9 @@ import Humanize from "humanize-plus"; import React from "react"; import { ngettext, msgid } from "c-3po"; +import Mustache from "mustache"; +import ReactMarkdown from "react-markdown"; + import ExternalLink from "metabase/components/ExternalLink.jsx"; import { @@ -22,80 +25,200 @@ import { rangeForValue } from "metabase/lib/dataset"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; import { decimalCount } from "metabase/visualizations/lib/numeric"; +import { + DEFAULT_DATE_STYLE, + getDateFormatFromStyle, + DEFAULT_TIME_STYLE, + getTimeFormatFromStyle, + hasHour, +} from "metabase/lib/formatting/date"; + import Field from "metabase-lib/lib/metadata/Field"; import type { Column, Value } from "metabase/meta/types/Dataset"; import type { DatetimeUnit } from "metabase/meta/types/Query"; import type { Moment } from "metabase/meta/types"; +import type { + DateStyle, + TimeStyle, + TimeEnabled, +} from "metabase/lib/formatting/date"; + export type FormattingOptions = { + // GENERIC column?: Column | Field, majorWidth?: number, type?: "axis" | "cell" | "tooltip", jsx?: boolean, // render links for type/URLs, type/Email, etc rich?: boolean, - // number options: - comma?: boolean, compact?: boolean, - round?: boolean, // always format as the start value rather than the range, e.x. for bar histogram noRange?: boolean, + // NUMBER + // TODO: docoument these: + number_style?: null | "decimal" | "percent" | "scientific" | "currency", + prefix?: string, + suffix?: string, + scale?: number, + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString + scale?: number, + locale?: string, + minimumFractionDigits?: number, + maximumFractionDigits?: number, + // use thousand separators, defualt to false if locale === null + useGrouping?: boolean, + // decimals sets both minimumFractionDigits and maximumFractionDigits + decimals?: number, + // STRING + view_as?: "link" | "email_link" | "image", + link_text?: string, + // DATE/TIME + // date/timeout style string that is used to derive a date_format or time_format for different units, see metabase/lib/formatting/date + date_style?: DateStyle, + date_abbreviate?: boolean, + date_format?: string, + time_style?: TimeStyle, + time_enabled?: TimeEnabled, + time_format?: string, }; +type FormattedString = string | React$Element<any>; + const DEFAULT_NUMBER_OPTIONS: FormattingOptions = { - comma: true, compact: false, - round: true, + maximumFractionDigits: 2, + useGrouping: true, }; +function getDefaultNumberOptions(options) { + const defaults = { ...DEFAULT_NUMBER_OPTIONS }; + + // decimals sets the exact number of digits after the decimal place + if (typeof options.decimals === "number" && !isNaN(options.decimals)) { + defaults.minimumFractionDigits = options.decimals; + defaults.maximumFractionDigits = options.decimals; + } + + // previously we used locale === null to signify that we should turn off thousand separators + if (options.locale === null) { + defaults.useGrouping = false; + } + + return defaults; +} + const PRECISION_NUMBER_FORMATTER = d3.format(".2r"); const FIXED_NUMBER_FORMATTER = d3.format(",.f"); -const FIXED_NUMBER_FORMATTER_NO_COMMA = d3.format(".f"); const DECIMAL_DEGREES_FORMATTER = d3.format(".08f"); const DECIMAL_DEGREES_FORMATTER_COMPACT = d3.format(".02f"); const BINNING_DEGREES_FORMATTER = (value, binWidth) => { return d3.format(`.0${decimalCount(binWidth)}f`)(value); }; -const getMonthFormat = options => (options.compact ? "MMM" : "MMMM"); -const getDayFormat = options => (options.compact ? "ddd" : "dddd"); +const getMonthFormat = options => + options.compact || options.date_abbreviate ? "MMM" : "MMMM"; +const getDayFormat = options => + options.compact || options.date_abbreviate ? "ddd" : "dddd"; // use en dashes, for Maz const RANGE_SEPARATOR = ` – `; +export function numberFormatterForOptions(options: FormattingOptions) { + options = { ...getDefaultNumberOptions(options), ...options }; + // if we don't provide a locale much of the formatting doens't work + // $FlowFixMe: doesn't know about Intl.NumberFormat + return new Intl.NumberFormat(options.locale || "en", { + style: options.number_style, + currency: options.currency, + currencyDisplay: options.currency_style, + useGrouping: options.useGrouping, + minimumIntegerDigits: options.minimumIntegerDigits, + minimumFractionDigits: options.minimumFractionDigits, + maximumFractionDigits: options.maximumFractionDigits, + minimumSignificantDigits: options.minimumSignificantDigits, + maximumSignificantDigits: options.maximumSignificantDigits, + }); +} + export function formatNumber(number: number, options: FormattingOptions = {}) { - options = { ...DEFAULT_NUMBER_OPTIONS, ...options }; + options = { ...getDefaultNumberOptions(options), ...options }; + + if (typeof options.scale === "number" && !isNaN(options.scale)) { + number = options.scale * number; + } + if (options.compact) { - if (number === 0) { - // 0 => 0 - return "0"; - } else if (number >= -0.01 && number <= 0.01) { - // 0.01 => ~0 - return "~ 0"; - } else if (number > -1 && number < 1) { - // 0.1 => 0.1 - return PRECISION_NUMBER_FORMATTER(number).replace(/\.?0+$/, ""); - } else { - // 1 => 1 - // 1000 => 1K - return Humanize.compactInteger(number, 1); - } - } else if (number > -1 && number < 1) { - // numbers between 1 and -1 round to 2 significant digits with extra 0s stripped off - return PRECISION_NUMBER_FORMATTER(number).replace(/\.?0+$/, ""); + return formatNumberCompact(number); + } else if (options.number_style === "scientific") { + return formatNumberScientific(number, options); } else { - // anything else rounds to at most 2 decimal points, unless disabled - if (options.round) { - number = d3.round(number, 2); - } - if (options.comma) { - return FIXED_NUMBER_FORMATTER(number); - } else { - return FIXED_NUMBER_FORMATTER_NO_COMMA(number); + try { + let nf; + if (number < 1 && number > -1 && options.decimals == null) { + // NOTE: special case to match existing behavior for small numbers, use + // max significant digits instead of max fraction digits + nf = numberFormatterForOptions({ + ...options, + maximumSignificantDigits: 2, + maximumFractionDigits: undefined, + }); + } else if (options._numberFormatter) { + // NOTE: options._numberFormatter allows you to provide a predefined + // Intl.NumberFormat object for increased performance + nf = options._numberFormatter; + } else { + nf = numberFormatterForOptions(options); + } + return nf.format(number); + } catch (e) { + console.warn("Error formatting number", e); + // fall back to old, less capable formatter + // NOTE: does not handle things like currency, percent + return FIXED_NUMBER_FORMATTER( + d3.round(number, options.maximumFractionDigits), + ); } } } +function formatNumberScientific( + value: number, + options: FormattingOptions, +): FormattedString { + if (options.maximumFractionDigits) { + value = d3.round(value, options.maximumFractionDigits); + } + const exp = value.toExponential(options.minimumFractionDigits); + if (options.jsx) { + const [m, n] = exp.split("e"); + return ( + <span> + {m}×10<sup>{n.replace(/^\+/, "")}</sup> + </span> + ); + } else { + return exp; + } +} + +function formatNumberCompact(value: number) { + if (value === 0) { + // 0 => 0 + return "0"; + } else if (value >= -0.01 && value <= 0.01) { + // 0.01 => ~0 + return "~ 0"; + } else if (value > -1 && value < 1) { + // 0.1 => 0.1 + return PRECISION_NUMBER_FORMATTER(value).replace(/\.?0+$/, ""); + } else { + // 1 => 1 + // 1000 => 1K + return Humanize.compactInteger(value, 1); + } +} + export function formatCoordinate( value: number, options: FormattingOptions = {}, @@ -123,12 +246,19 @@ export function formatCoordinate( export function formatRange( range: [number, number], - formatter: (value: number) => string, + formatter: (value: number) => any, options: FormattingOptions = {}, ) { - return range - .map(value => formatter(value, options)) - .join(` ${RANGE_SEPARATOR} `); + const [start, end] = range.map(value => formatter(value, options)); + if ((options.jsx && typeof start !== "string") || typeof end !== "string") { + return ( + <span> + {start} {RANGE_SEPARATOR} {end} + </span> + ); + } else { + return `${start} ${RANGE_SEPARATOR} ${end}`; + } } function formatMajorMinor(major, minor, options = {}) { @@ -156,7 +286,7 @@ function formatMajorMinor(major, minor, options = {}) { } /** This formats a time with unit as a date range */ -export function formatTimeRangeWithUnit( +export function formatDateTimeRangeWithUnit( value: Value, unit: DatetimeUnit, options: FormattingOptions = {}, @@ -207,7 +337,51 @@ function formatWeek(m: Moment, options: FormattingOptions = {}) { return formatMajorMinor(m.format("wo"), m.format("gggg"), options); } -export function formatTimeWithUnit( +function replaceDateFormatNames(format, options) { + return format + .replace(/\bMMMM\b/g, getMonthFormat(options)) + .replace(/\bdddd\b/g, getDayFormat(options)); +} + +function formatDateTimeWithFormats(value, dateFormat, timeFormat, options) { + let m = parseTimestamp(value, options.column && options.column.unit); + if (!m.isValid()) { + return String(value); + } + + const format = []; + if (dateFormat) { + format.push(replaceDateFormatNames(dateFormat, options)); + } + if (timeFormat && options.time_enabled) { + format.push(timeFormat); + } + return m.format(format.join(", ")); +} + +function formatDateTime(value, options) { + let m = parseTimestamp(value, options.column && options.column.unit); + if (!m.isValid()) { + return String(value); + } + + if (options.date_format || options.time_format) { + formatDateTimeWithFormats( + value, + options.date_format, + options.time_format, + options, + ); + } else { + if (options.time_enabled === false) { + return m.format(options.date_abbreviate ? "ll" : "LL"); + } else { + return m.format(options.date_abbreviate ? "llll" : "LLLL"); + } + } +} + +export function formatDateTimeWithUnit( value: Value, unit: DatetimeUnit, options: FormattingOptions = {}, @@ -217,69 +391,45 @@ export function formatTimeWithUnit( return String(value); } - switch (unit) { - case "hour": // 12 AM - January 1, 2015 - return formatMajorMinor( - m.format("h A"), - m.format(`${getMonthFormat(options)} D, YYYY`), - options, - ); - case "day": // January 1, 2015 - return m.format(`${getMonthFormat(options)} D, YYYY`); - case "week": // 1st - 2015 - if (options.type === "tooltip" && !options.noRange) { - // tooltip show range like "January 1 - 7, 2017" - return formatTimeRangeWithUnit(value, unit, options); - } else if (options.type === "cell" && !options.noRange) { - // table cells show range like "Jan 1, 2017 - Jan 7, 2017" - return formatTimeRangeWithUnit(value, unit, options); - } else if (options.type === "axis") { - // axis ticks show start of the week as "Jan 1" - return m - .clone() - .startOf(unit) - .format(`MMM D`); - } else { - return formatWeek(m, options); - } - case "month": // January 2015 - return options.jsx ? ( - <div> - <span className="text-bold">{m.format(getMonthFormat(options))}</span>{" "} - {m.format("YYYY")} - </div> - ) : ( - m.format(`${getMonthFormat(options)} YYYY`) - ); - case "year": // 2015 - return m.format("YYYY"); - case "quarter": // Q1 - 2015 - return formatMajorMinor(m.format("[Q]Q"), m.format("YYYY"), { - ...options, - majorWidth: 0, - }); - case "minute-of-hour": - return m.format("m"); - case "hour-of-day": // 12 AM - return m.format("h A"); - case "day-of-week": // Sunday - return m.format(getDayFormat(options)); - case "day-of-month": - return m.format("D"); - case "day-of-year": - return m.format("DDD"); - case "week-of-year": // 1st - return m.format("wo"); - case "month-of-year": // January - return m.format(getMonthFormat(options)); - case "quarter-of-year": // January - return m.format("[Q]Q"); - default: - return m.format("LLLL"); - } -} - -export function formatTimeValue(value: Value) { + // expand "week" into a range in specific contexts + if (unit === "week") { + if ( + (options.type === "tooltip" || options.type === "cell") && + !options.noRange + ) { + // tooltip show range like "January 1 - 7, 2017" + return formatDateTimeRangeWithUnit(value, unit, options); + } + } + + options = { + date_style: DEFAULT_DATE_STYLE, + time_style: DEFAULT_TIME_STYLE, + time_enabled: hasHour(unit) ? "minutes" : null, + ...options, + }; + + let dateFormat = options.date_format; + let timeFormat = options.time_format; + + if (!dateFormat) { + // $FlowFixMe: date_style default set above + dateFormat = getDateFormatFromStyle(options.date_style, unit); + } + + if (!timeFormat) { + timeFormat = getTimeFormatFromStyle( + // $FlowFixMe: time_style default set above + options.time_style, + unit, + options.time_enabled, + ); + } + + return formatDateTimeWithFormats(value, dateFormat, timeFormat, options); +} + +export function formatTime(value: Value) { let m = parseTime(value); if (!m.isValid()) { return String(value); @@ -293,11 +443,18 @@ const EMAIL_WHITELIST_REGEX = /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a export function formatEmail( value: Value, - { jsx, rich }: FormattingOptions = {}, + { jsx, rich, view_as = "auto", link_text }: FormattingOptions = {}, ) { const email = String(value); - if (jsx && rich && EMAIL_WHITELIST_REGEX.test(email)) { - return <ExternalLink href={"mailto:" + email}>{email}</ExternalLink>; + if ( + jsx && + rich && + (view_as === "email_link" || view_as === "auto") && + EMAIL_WHITELIST_REGEX.test(email) + ) { + return ( + <ExternalLink href={"mailto:" + email}>{link_text || email}</ExternalLink> + ); } else { return email; } @@ -306,12 +463,20 @@ export function formatEmail( // based on https://github.com/angular/angular.js/blob/v1.6.3/src/ng/directive/input.js#L25 const URL_WHITELIST_REGEX = /^(https?|mailto):\/*(?:[^:@]+(?::[^@]+)?@)?(?:[^\s:/?#]+|\[[a-f\d:]+])(?::\d+)?(?:\/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$/i; -export function formatUrl(value: Value, { jsx, rich }: FormattingOptions = {}) { +export function formatUrl( + value: Value, + { jsx, rich, view_as = "auto", link_text }: FormattingOptions = {}, +) { const url = String(value); - if (jsx && rich && URL_WHITELIST_REGEX.test(url)) { + if ( + jsx && + rich && + (view_as === "link" || view_as === "auto") && + URL_WHITELIST_REGEX.test(url) + ) { return ( <ExternalLink className="link link--wrappable" href={url}> - {url} + {link_text || url} </ExternalLink> ); } else { @@ -319,22 +484,79 @@ export function formatUrl(value: Value, { jsx, rich }: FormattingOptions = {}) { } } +export function formatImage( + value: Value, + { jsx, rich, view_as = "auto", link_text }: FormattingOptions = {}, +) { + const url = String(value); + if (jsx && rich && view_as === "image" && URL_WHITELIST_REGEX.test(url)) { + return <img src={url} style={{ height: 30 }} />; + } else { + return url; + } +} + // fallback for formatting a string without a column special_type function formatStringFallback(value: Value, options: FormattingOptions = {}) { value = formatUrl(value, options); if (typeof value === "string") { value = formatEmail(value, options); } + if (typeof value === "string") { + value = formatImage(value, options); + } return value; } +const MARKDOWN_RENDERERS = { + // eslint-disable-next-line react/display-name + link: ({ href, children }) => ( + <ExternalLink href={href}>{children}</ExternalLink> + ), +}; + export function formatValue(value: Value, options: FormattingOptions = {}) { + const formatted = formatValueRaw(value, options); + if (options.markdown_template) { + if (options.jsx) { + // inject the formatted value as "value" and the unformatted value as "raw" + const markdown = Mustache.render(options.markdown_template, { + value: formatted, + raw: value, + }); + return <ReactMarkdown source={markdown} renderers={MARKDOWN_RENDERERS} />; + } else { + // FIXME: render and get the innerText? + console.warn( + "formatValue: options.markdown_template not supported when options.jsx = false", + ); + return formatted; + } + } + if (options.prefix || options.suffix) { + if (options.jsx && typeof formatted !== "string") { + return ( + <span> + {options.prefix || ""} + {formatted} + {options.suffix || ""} + </span> + ); + } else { + // $FlowFixMe: doesn't understand formatted is a string + return `${options.prefix || ""}${formatted}${options.suffix || ""}`; + } + } else { + return formatted; + } +} + +export function formatValueRaw(value: Value, options: FormattingOptions = {}) { let column = options.column; options = { jsx: false, remap: true, - comma: isNumber(column), ...options, }; @@ -358,25 +580,31 @@ export function formatValue(value: Value, options: FormattingOptions = {}) { } else if (column && isa(column.special_type, TYPE.Email)) { return formatEmail(value, options); } else if (column && isa(column.base_type, TYPE.Time)) { - return formatTimeValue(value); + return formatTime(value); } else if (column && column.unit != null) { - return formatTimeWithUnit(value, column.unit, options); + return formatDateTimeWithUnit(value, column.unit, options); } else if ( isDate(column) || moment.isDate(value) || moment.isMoment(value) || moment(value, ["YYYY-MM-DD'T'HH:mm:ss.SSSZ"], true).isValid() ) { - return parseTimestamp(value, column && column.unit).format("LLLL"); + return formatDateTime(value, options); } else if (typeof value === "string") { return formatStringFallback(value, options); - } else if (typeof value === "number") { - const formatter = isCoordinate(column) ? formatCoordinate : formatNumber; + } else if (typeof value === "number" && isCoordinate(column)) { + const range = rangeForValue(value, options.column); + if (range && !options.noRange) { + return formatRange(range, formatCoordinate, options); + } else { + return formatCoordinate(value, options); + } + } else if (typeof value === "number" && isNumber(column)) { const range = rangeForValue(value, options.column); if (range && !options.noRange) { - return formatRange(range, formatter, options); + return formatRange(range, formatNumber, options); } else { - return formatter(value, options); + return formatNumber(value, options); } } else if (typeof value === "object") { // no extra whitespace for table cells diff --git a/frontend/src/metabase/lib/formatting/date.js b/frontend/src/metabase/lib/formatting/date.js new file mode 100644 index 0000000000000000000000000000000000000000..cce023e95eb805f78fe1b07ed2714533d38bd59a --- /dev/null +++ b/frontend/src/metabase/lib/formatting/date.js @@ -0,0 +1,113 @@ +/* @flow */ + +import type { DatetimeUnit } from "metabase/meta/types/Query"; + +export type DateStyle = + | "M/D/YYYY" + | "D/M/YYYY" + | "YYYY/M/D" + | "MMMM D, YYYY" + | "MMMM D, YYYY" + | "D MMMM, YYYY" + | "dddd, MMMM D, YYYY"; + +export type TimeStyle = "h:mm A" | "k:mm"; + +export type MomentFormat = string; // moment.js format strings +export type DateFormat = MomentFormat; +export type TimeFormat = MomentFormat; + +export type TimeEnabled = null | "minutes" | "seconds" | "milliseconds"; + +const DEFAULT_DATE_FORMATS: { [unit: DatetimeUnit]: MomentFormat } = { + year: "YYYY", + quarter: "[Q]Q - YYYY", + "minute-of-hour": "m", + "hour-of-day": "h A", + "day-of-week": "dddd", + "day-of-month": "D", + "day-of-year": "DDD", + "week-of-year": "wo", + "month-of-year": "MMMM", + "quarter-of-year": "[Q]Q", +}; + +// a "date style" is essentially a "day" format with overrides for larger units +const DATE_STYLE_TO_FORMAT: { + [style: DateStyle]: { [unit: DatetimeUnit]: MomentFormat }, +} = { + "M/D/YYYY": { + month: "M/YYYY", + }, + "D/M/YYYY": { + month: "M/YYYY", + }, + "YYYY/M/D": { + month: "YYYY/M", + quarter: "YYYY - [Q]Q", + }, + "MMMM D, YYYY": { + month: "MMMM, YYYY", + }, + "D MMMM, YYYY": { + month: "MMMM, YYYY", + }, + "dddd, MMMM D, YYYY": { + week: "MMMM D, YYYY", + month: "MMMM, YYYY", + }, +}; + +export const DEFAULT_DATE_STYLE: DateStyle = "MMMM D, YYYY"; + +export function getDateFormatFromStyle( + style: DateStyle, + unit: ?DatetimeUnit, +): DateFormat { + if (!unit) { + unit = "default"; + } + if (DATE_STYLE_TO_FORMAT[style]) { + if (DATE_STYLE_TO_FORMAT[style][unit]) { + return DATE_STYLE_TO_FORMAT[style][unit]; + } + } else { + console.warn("Unknown date style", style); + } + if (DEFAULT_DATE_FORMATS[unit]) { + return DEFAULT_DATE_FORMATS[unit]; + } + return style; +} + +const UNITS_WITH_HOUR: DatetimeUnit[] = ["default", "minute", "hour"]; +const UNITS_WITH_DAY: DatetimeUnit[] = [ + "default", + "minute", + "hour", + "day", + "week", +]; + +const UNITS_WITH_HOUR_SET = new Set(UNITS_WITH_HOUR); +const UNITS_WITH_DAY_SET = new Set(UNITS_WITH_DAY); + +export const hasHour = (unit: ?DatetimeUnit) => UNITS_WITH_HOUR_SET.has(unit); +export const hasDay = (unit: ?DatetimeUnit) => UNITS_WITH_DAY_SET.has(unit); + +export const DEFAULT_TIME_STYLE: TimeStyle = "h:mm A"; + +export function getTimeFormatFromStyle( + style: TimeStyle, + unit: DatetimeUnit, + timeEnabled: ?TimeEnabled, +): TimeFormat { + let format = style; + if (!timeEnabled || timeEnabled === "milliseconds") { + return format.replace(/mm/, "mm:ss.SSS"); + } else if (timeEnabled === "seconds") { + return format.replace(/mm/, "mm:ss"); + } else { + return format; + } +} diff --git a/frontend/src/metabase/lib/query_time.js b/frontend/src/metabase/lib/query_time.js index 31aad3bb9a62c8190ec2550567fca0528e548334..37fb77cd1f80c33a89fd09462593ae2908e5bbf8 100644 --- a/frontend/src/metabase/lib/query_time.js +++ b/frontend/src/metabase/lib/query_time.js @@ -2,7 +2,7 @@ import moment from "moment"; import inflection from "inflection"; import { mbqlEq } from "metabase/lib/query/util"; -import { formatTimeWithUnit } from "metabase/lib/formatting"; +import { formatDateTimeWithUnit } from "metabase/lib/formatting"; import { parseTimestamp } from "metabase/lib/time"; export const DATETIME_UNITS = [ @@ -144,7 +144,7 @@ export function generateTimeValueDescription(value, bucketing) { if (typeof value === "string") { const m = parseTimestamp(value, bucketing); if (bucketing) { - return formatTimeWithUnit(value, bucketing); + return formatDateTimeWithUnit(value, bucketing); } else if (m.hours() || m.minutes()) { return m.format("MMMM D, YYYY hh:mm a"); } else { diff --git a/frontend/src/metabase/lib/schema_metadata.js b/frontend/src/metabase/lib/schema_metadata.js index a97776048ff7300caa389d6fa4f37e4bd1a155a2..31faf5a696bf2551dd288075e0f16bd7b1132031 100644 --- a/frontend/src/metabase/lib/schema_metadata.js +++ b/frontend/src/metabase/lib/schema_metadata.js @@ -172,6 +172,13 @@ export const isLongitude = field => export const isID = field => isFK(field) || isPK(field); +export const isURL = field => isa(field && field.special_type, TYPE.URL); +export const isEmail = field => isa(field && field.special_type, TYPE.Email); +export const isAvatarURL = field => + isa(field && field.special_type, TYPE.AvatarURL); +export const isImageURL = field => + isa(field && field.special_type, TYPE.ImageURL); + // operator argument constructors: function freeformArgument(field, table) { diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index d01c82bfdfc96ae101fde2996eba554991688dba..83cc1ad63cb8e7bbba1b002d4514c93b02ed32b1 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -4,6 +4,7 @@ import type { DatasetData, Column } from "metabase/meta/types/Dataset"; import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; import type { TableMetadata } from "metabase/meta/types/Metadata"; import type { Field, FieldId } from "metabase/meta/types/Field"; +import type { ReduxAction } from "metabase/meta/types/redux"; import Question from "metabase-lib/lib/Question"; export type ActionCreator = (props: ClickActionProps) => ClickAction[]; @@ -44,6 +45,7 @@ export type ClickAction = { popover?: (props: ClickActionPopoverProps) => any, // React Element question?: () => ?Question, url?: () => string, + action?: () => ?ReduxAction, section?: string, name?: string, }; diff --git a/frontend/src/metabase/meta/types/redux.js b/frontend/src/metabase/meta/types/redux.js new file mode 100644 index 0000000000000000000000000000000000000000..a9784849d71c0c59c03b720bc2bb6f30f27892b2 --- /dev/null +++ b/frontend/src/metabase/meta/types/redux.js @@ -0,0 +1,4 @@ +/* @flow */ + +// "Flux standard action" style redux action +export type ReduxAction = { type: string, payload: any, error?: boolean }; diff --git a/frontend/src/metabase/qb/components/drill/FormatAction.jsx b/frontend/src/metabase/qb/components/drill/FormatAction.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ba622f436bbfb1773e4a00922aa181c87dc9adb1 --- /dev/null +++ b/frontend/src/metabase/qb/components/drill/FormatAction.jsx @@ -0,0 +1,37 @@ +/* @flow */ + +import { t } from "c-3po"; +import type { + ClickAction, + ClickActionProps, +} from "metabase/meta/types/Visualization"; + +// NOTE: cyclical dependency +// import { showChartSettings } from "metabase/query_builder/actions"; +function showChartSettings(...args) { + return require("metabase/query_builder/actions").showChartSettings(...args); +} + +import { keyForColumn } from "metabase/lib/dataset"; + +export default ({ question, clicked }: ClickActionProps): ClickAction[] => { + if (!clicked || clicked.value !== undefined || !clicked.column) { + return []; + } + const { column } = clicked; + + return [ + { + name: "formatting", + section: "Formatting", + title: t`Formatting`, + action: () => + showChartSettings({ + widget: { + id: "column_settings", + props: { initialKey: keyForColumn(column) }, + }, + }), + }, + ]; +}; diff --git a/frontend/src/metabase/qb/components/drill/index.js b/frontend/src/metabase/qb/components/drill/index.js index 8df6dc2bb20ed281852e2cfc5624dbe8963052e4..9f40d4589f53804e0430d5d39d04c5c704a563cb 100644 --- a/frontend/src/metabase/qb/components/drill/index.js +++ b/frontend/src/metabase/qb/components/drill/index.js @@ -7,6 +7,7 @@ import UnderlyingRecordsDrill from "./UnderlyingRecordsDrill"; import AutomaticDashboardDrill from "./AutomaticDashboardDrill"; import CompareToRestDrill from "./CompareToRestDrill"; import ZoomDrill from "./ZoomDrill"; +import FormatAction from "./FormatAction"; export const DEFAULT_DRILLS = [ ZoomDrill, @@ -16,4 +17,5 @@ export const DEFAULT_DRILLS = [ UnderlyingRecordsDrill, AutomaticDashboardDrill, CompareToRestDrill, + FormatAction, ]; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 9550bd2228b359dd404732e3dfdba6ab047b6dc5..d13c2c97d688d0b5ce010b863af1c9e33eba2665 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -62,7 +62,7 @@ import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/util import type { Card } from "metabase/meta/types/Card"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; -import { getPersistableDefaultSettings } from "metabase/visualizations/lib/settings"; +import { getPersistableDefaultSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { clearRequestState } from "metabase/redux/requests"; import Questions from "metabase/entities/questions"; @@ -1299,7 +1299,7 @@ const getQuestionWithDefaultVisualizationSettings = (question, series) => { const oldVizSettings = question.visualizationSettings(); const newVizSettings = { ...oldVizSettings, - ...getPersistableDefaultSettings(series), + ...getPersistableDefaultSettingsForSeries(series), }; // Don't update the question unnecessarily @@ -1534,6 +1534,9 @@ export const viewPreviousObjectDetail = () => { }; }; +export const SHOW_CHART_SETTINGS = "metabase/query_builder/SHOW_CHART_SETTINGS"; +export const showChartSettings = createAction(SHOW_CHART_SETTINGS); + // these are just temporary mappings to appease the existing QB code and it's naming prefs export const toggleDataReferenceFn = toggleDataReference; export const onBeginEditing = beginEditing; diff --git a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx index 0ec788905f07b75aa02dc0e8c1017ae9389ca673..24e0771cc07c4989f3ec91e9c667b22f0124c33f 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationSettings.jsx @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import { t } from "c-3po"; import Icon from "metabase/components/Icon.jsx"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; -import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx"; +import Modal from "metabase/components/Modal.jsx"; import ChartSettings from "metabase/visualizations/components/ChartSettings.jsx"; @@ -91,25 +91,27 @@ export default class VisualizationSettings extends React.Component { } open = () => { - this.refs.popover.open(); + this.props.showChartSettings({}); + }; + + close = () => { + this.props.showChartSettings(null); }; render() { if (this.props.result && this.props.result.error === undefined) { + const { chartSettings } = this.props.uiControls; return ( <div className="VisualizationSettings flex align-center"> {this.renderChartTypePicker()} - <ModalWithTrigger - wide - tall - triggerElement={ - <span data-metabase-event="Query Builder;Chart Settings"> - <Icon name="gear" /> - </span> - } - triggerClasses="text-brand-hover" - ref="popover" + <span + className="text-brand-hover" + data-metabase-event="Query Builder;Chart Settings" + onClick={this.open} > + <Icon name="gear" /> + </span> + <Modal wide tall isOpen={chartSettings} onClose={this.close}> <ChartSettings question={this.props.question} addField={this.props.addField} @@ -120,8 +122,10 @@ export default class VisualizationSettings extends React.Component { }, ]} onChange={this.props.onReplaceAllVisualizationSettings} + onClose={this.close} + initialWidget={chartSettings && chartSettings.widget} /> - </ModalWithTrigger> + </Modal> </div> ); } else { diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 0ca6aca936c91b77073f6ea84f35b3cecf5dadf6..69d8c2c200460e474da8568b31e1052d6b1002b7 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -40,6 +40,7 @@ import { DELETE_PUBLIC_LINK, UPDATE_ENABLE_EMBEDDING, UPDATE_EMBEDDING_PARAMS, + SHOW_CHART_SETTINGS, } from "./actions"; // various ui state options @@ -106,6 +107,10 @@ export const uiControls = handleActions( [QUERY_ERRORED]: { next: (state, { payload }) => ({ ...state, isRunning: false }), }, + + [SHOW_CHART_SETTINGS]: { + next: (state, { payload }) => ({ ...state, chartSettings: payload }), + }, }, { isShowingDataReference: false, @@ -114,6 +119,7 @@ export const uiControls = handleActions( isShowingNewbModal: false, isEditing: false, isRunning: false, + chartSettings: null, }, ); diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index cbdffeb83e8feb948c61e9c8d5b1adbab26825c8..686a4cf675c7e39889d31c6bdd998ed3cd1e8aa7 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -5,7 +5,7 @@ import _ from "underscore"; // eslint-disable-next-line no-unused-vars import Visualization from "metabase/visualizations/components/Visualization"; -import { getSettings as _getVisualizationSettings } from "metabase/visualizations/lib/settings"; +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { getParametersWithExtras } from "metabase/meta/Card"; @@ -250,5 +250,5 @@ export const getTransformedSeries = createSelector( */ export const getVisualizationSettings = createSelector( [getTransformedSeries], - series => series && _getVisualizationSettings(series), + series => series && getComputedSettingsForSeries(series), ); diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index 7aab5791b0c88d6975b8c1d42d23ef5afe3bc351..002ea85ac18fe57547104b95570c5a3f1f3e24f0 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -1,6 +1,7 @@ /* @flow */ import React, { Component } from "react"; +import { connect } from "react-redux"; import cx from "classnames"; import Icon from "metabase/components/Icon"; @@ -71,6 +72,7 @@ type State = { popoverAction: ?ClickAction, }; +@connect() export default class ChartClickActions extends Component { props: Props; state: State = { @@ -86,7 +88,14 @@ export default class ChartClickActions extends Component { handleClickAction = (action: ClickAction) => { const { onChangeCardAndRun } = this.props; - if (action.popover) { + if (action.action) { + const reduxAction = action.action(); + if (reduxAction) { + // $FlowFixMe: dispatch provided by @connect + this.props.dispatch(reduxAction); + } + this.props.onClose(); + } else if (action.popover) { MetabaseAnalytics.trackEvent( "Actions", "Open Click Action Popover", diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx index 5d7d19bcf4088037ce6cc6195820ef4c8accbbd0..80f6ada5b7e0474ba07db02a3baa670f6f59b3a0 100644 --- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx +++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx @@ -9,42 +9,15 @@ import Button from "metabase/components/Button"; import Radio from "metabase/components/Radio"; import Visualization from "metabase/visualizations/components/Visualization.jsx"; -import { getSettingsWidgets } from "metabase/visualizations/lib/settings"; +import ChartSettingsWidget from "./ChartSettingsWidget"; + +import { getSettingsWidgetsForSeries } from "metabase/visualizations/lib/settings/visualization"; import MetabaseAnalytics from "metabase/lib/analytics"; import { getVisualizationTransformed, extractRemappings, } from "metabase/visualizations"; -const Widget = ({ - title, - hidden, - disabled, - widget, - value, - onChange, - props, - // NOTE: special props to support adding additional fields - question, - addField, -}) => { - const W = widget; - return ( - <div className={cx("mb2", { hide: hidden, disable: disabled })}> - {title && <h4 className="mb1">{title}</h4>} - {W && ( - <W - value={value} - onChange={onChange} - question={question} - addField={addField} - {...props} - /> - )} - </div> - ); -}; - class ChartSettings extends Component { constructor(props) { super(props); @@ -53,6 +26,7 @@ class ChartSettings extends Component { currentTab: null, settings: initialSettings, series: this._getSeries(props.series, initialSettings), + showWidget: props.initialWidget, }; } @@ -110,18 +84,31 @@ class ChartSettings extends Component { this.props.onClose(); }; + // allows a widget to temporarily replace itself with a different widget + handleShowWidget = widget => { + this.setState({ showWidget: widget }); + }; + handleEndShowWidget = () => { + this.setState({ showWidget: null }); + }; + render() { const { isDashboard, question, addField } = this.props; - const { series } = this.state; + const { series, showWidget } = this.state; + + const widgetsById = {}; const tabs = {}; - for (const widget of getSettingsWidgets( + for (const widget of getSettingsWidgetsForSeries( series, this.handleChangeSettings, isDashboard, )) { - tabs[widget.section] = tabs[widget.section] || []; - tabs[widget.section].push(widget); + widgetsById[widget.id] = widget; + if (widget.widget && !widget.hidden) { + tabs[widget.section] = tabs[widget.section] || []; + tabs[widget.section].push(widget); + } } // Move settings from the "undefined" section in the first tab @@ -133,7 +120,30 @@ class ChartSettings extends Component { const tabNames = Object.keys(tabs); const currentTab = this.state.currentTab || tabNames[0]; - const widgets = tabs[currentTab]; + + let widgets; + let widget = showWidget && widgetsById[showWidget.id]; + if (widget) { + widget = { + ...widget, + hidden: false, + props: { + ...(widget.props || {}), + ...(showWidget.props || {}), + }, + }; + widgets = [widget]; + } else { + widgets = tabs[currentTab]; + } + + const extraWidgetProps = { + // NOTE: special props to support adding additional fields + question: question, + addField: addField, + onShowWidget: this.handleShowWidget, + onEndShowWidget: this.handleEndShowWidget, + }; return ( <div className="flex flex-column spread"> @@ -151,16 +161,14 @@ class ChartSettings extends Component { )} <div className="full-height relative"> <div className="Grid spread"> - <div className="Grid-cell Cell--1of3 scroll-y scroll-show border-right p4"> - {widgets && - widgets.map(widget => ( - <Widget - key={`${widget.id}`} - question={question} - addField={addField} - {...widget} - /> - ))} + <div className="Grid-cell Cell--1of3 scroll-y scroll-show border-right py4"> + {widgets.map(widget => ( + <ChartSettingsWidget + key={`${widget.id}`} + {...widget} + {...extraWidgetProps} + /> + ))} </div> <div className="Grid-cell flex flex-column pt2"> <div className="mx4 flex flex-column"> diff --git a/frontend/src/metabase/visualizations/components/ChartSettingsWidget.jsx b/frontend/src/metabase/visualizations/components/ChartSettingsWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3563f9688ea42b654e0d2ca4051e88a79df6dd2a --- /dev/null +++ b/frontend/src/metabase/visualizations/components/ChartSettingsWidget.jsx @@ -0,0 +1,42 @@ +import React from "react"; + +import cx from "classnames"; + +const ChartSettingsWidget = ({ + title, + hidden, + disabled, + widget: Widget, + value, + onChange, + props, + // disables X padding for certain widgets so divider line extends to edge + noPadding, + // NOTE: pass along special props to support: + // * adding additional fields + // * substituting widgets + ...additionalProps +}) => { + return ( + <div + className={cx({ + mb2: !hidden, + mx4: !noPadding, + hide: hidden, + disable: disabled, + })} + > + {title && <h4 className="mb1">{title}</h4>} + {Widget && ( + <Widget + value={value} + onChange={onChange} + {...additionalProps} + {...props} + /> + )} + </div> + ); +}; + +export default ChartSettingsWidget; diff --git a/frontend/src/metabase/visualizations/components/FunnelBar.jsx b/frontend/src/metabase/visualizations/components/FunnelBar.jsx index cecd21533f28d89cc4e99ee0c3e1f42c67365c6f..ca39063609bb465b3cd9702eb7640c27c686e81f 100644 --- a/frontend/src/metabase/visualizations/components/FunnelBar.jsx +++ b/frontend/src/metabase/visualizations/components/FunnelBar.jsx @@ -4,7 +4,7 @@ import React, { Component } from "react"; import BarChart from "metabase/visualizations/visualizations/BarChart.jsx"; -import { getSettings } from "metabase/visualizations/lib/settings"; +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { assocIn } from "icepick"; import type { VisualizationProps } from "metabase/meta/types/Visualization"; @@ -19,7 +19,7 @@ export default class BarFunnel extends Component { isScalarSeries={true} settings={{ ...this.props.settings, - ...getSettings( + ...getComputedSettingsForSeries( assocIn(this.props.series, [0, "card", "display"], "bar"), ), "bar.scalar_series": true, diff --git a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx index 23f8ffc4d03c780219f5d3b104eac877c69e3150..7674fcd81a94d51f7bdb354368dc20a451f91ddb 100644 --- a/frontend/src/metabase/visualizations/components/FunnelNormal.jsx +++ b/frontend/src/metabase/visualizations/components/FunnelNormal.jsx @@ -65,7 +65,6 @@ export default class Funnel extends Component { column: cols[metricIndex], jsx, majorWidth: 0, - comma: true, }); const formatPercent = percent => `${(100 * percent).toFixed(2)} %`; diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index b6c22eb1bc38a81f74f57abf277e286428a24dc6..ed4730b6715f45d77605fde4b389e7afba4410e5 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -17,7 +17,7 @@ import { import { addCSSRule } from "metabase/lib/dom"; import { formatValue } from "metabase/lib/formatting"; -import { getSettings } from "metabase/visualizations/lib/settings"; +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { MinRowsError, @@ -106,8 +106,8 @@ export default class LineAreaBarChart extends Component { } static seriesAreCompatible(initialSeries, newSeries) { - let initialSettings = getSettings([initialSeries]); - let newSettings = getSettings([newSeries]); + let initialSettings = getComputedSettingsForSeries([initialSeries]); + let newSettings = getComputedSettingsForSeries([newSeries]); let initialDimensions = getColumnsFromNames( initialSeries.data.cols, @@ -305,7 +305,7 @@ function transformSingleSeries(s, series, seriesIndex) { } const { cols, rows } = data; - const settings = getSettings([s]); + const settings = getComputedSettingsForSeries([s]); const dimensions = settings["graph.dimensions"].filter(d => d != null); const metrics = settings["graph.metrics"].filter(d => d != null); diff --git a/frontend/src/metabase/visualizations/components/MiniBar.jsx b/frontend/src/metabase/visualizations/components/MiniBar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..491945caab349ed02f1bc110b91eab95e8023bf7 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/MiniBar.jsx @@ -0,0 +1,90 @@ +import React from "react"; + +import colors, { alpha } from "metabase/lib/colors"; +import { formatValue } from "metabase/lib/formatting"; + +const BAR_HEIGHT = 11; +const BAR_WIDTH = 70; +const BORDER_RADIUS = 3; + +const LABEL_MIN_WIDTH = 30; + +const MiniBar = ({ value, extent: [min, max], options, cellHeight }) => { + const hasNegative = min < 0; + const isNegative = value < 0; + const barPercent = + Math.abs(value) / Math.max(Math.abs(min), Math.abs(max)) * 100; + const barColor = isNegative ? colors["error"] : colors["brand"]; + + const barStyle = !hasNegative + ? { + width: barPercent + "%", + left: 0, + borderRadius: BORDER_RADIUS, + } + : isNegative + ? { + width: barPercent / 2 + "%", + right: "50%", + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + borderTopLeftRadius: BORDER_RADIUS, + borderBottomLeftRadius: BORDER_RADIUS, + } + : { + width: barPercent / 2 + "%", + left: "50%", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderTopRightRadius: BORDER_RADIUS, + borderBottomRightRadius: BORDER_RADIUS, + }; + + return ( + <div className="flex align-center justify-end relative"> + {/* TEXT VALUE */} + <div + className="text-ellipsis text-small text-bold text-medium text-right" + style={{ minWidth: LABEL_MIN_WIDTH }} + > + {formatValue(value, { ...options, jsx: true })} + </div> + {/* OUTER CONTAINER BAR */} + <div + className="ml1" + style={{ + position: "relative", + width: BAR_WIDTH, + height: BAR_HEIGHT, + backgroundColor: alpha(barColor, 0.2), + borderRadius: BORDER_RADIUS, + }} + > + {/* INNER PROGRESS BAR */} + <div + style={{ + position: "absolute", + top: 0, + bottom: 0, + backgroundColor: barColor, + ...barStyle, + }} + /> + {/* CENTER LINE */} + {hasNegative && ( + <div + style={{ + position: "absolute", + left: "50%", + top: 0, + bottom: 0, + borderLeft: `1px solid white`, + }} + /> + )} + </div> + </div> + ); +}; + +export default MiniBar; diff --git a/frontend/src/metabase/visualizations/components/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive.jsx index b4aafe6270a01942cca226b853bbb508e8cde41e..323e8e763ec487cb523e1d30e4fb7c5983faac0d 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive.jsx @@ -14,11 +14,13 @@ import { getTableCellClickedObject, isColumnRightAligned, } from "metabase/visualizations/lib/table"; +import { getColumnExtent } from "metabase/visualizations/lib/utils"; import _ from "underscore"; import cx from "classnames"; import ExplicitSize from "metabase/components/ExplicitSize.jsx"; +import MiniBar from "./MiniBar"; // $FlowFixMe: had to ignore react-virtualized in flow, probably due to different version import { Grid, ScrollSync } from "react-virtualized"; @@ -143,11 +145,23 @@ export default class TableInteractive extends Component { componentWillReceiveProps(newProps: Props) { if ( - JSON.stringify(this.props.data && this.props.data.cols) !== - JSON.stringify(newProps.data && newProps.data.cols) + this.props.data && + newProps.data && + !_.isEqual(this.props.data.cols, newProps.data.cols) ) { this.resetColumnWidths(); } + + // remeasure columns if the column settings change, e.x. turning on/off mini bar charts + const oldColSettings = this._getColumnSettings(this.props); + const newColSettings = this._getColumnSettings(newProps); + if (!_.isEqual(oldColSettings, newColSettings)) { + this.remeasureColumnWidths(); + } + } + + _getColumnSettings(props: Props) { + return props.data && props.data.cols.map(col => props.settings.column(col)); } shouldComponentUpdate(nextProps: Props, nextState: State) { @@ -167,12 +181,16 @@ export default class TableInteractive extends Component { } } - resetColumnWidths() { + remeasureColumnWidths() { this.setState({ columnWidths: [], contentWidths: null, }); this.columnHasResized = {}; + } + + resetColumnWidths() { + this.remeasureColumnWidths(); this.props.onUpdateVisualizationSettings({ "table.column_widths": undefined, }); @@ -314,6 +332,8 @@ export default class TableInteractive extends Component { getCellBackgroundColor && getCellBackgroundColor(value, rowIndex, column.name); + const columnSettings = settings.column(column); + return ( <div key={key} @@ -342,13 +362,22 @@ export default class TableInteractive extends Component { } > <div className="cellData"> - {/* using formatValue instead of <Value> here for performance. The later wraps in an extra <span> */} - {formatValue(value, { - column: column, - type: "cell", - jsx: true, - rich: true, - })} + {columnSettings["show_mini_bar"] ? ( + <MiniBar + value={value} + options={columnSettings} + extent={getColumnExtent(data.cols, data.rows, columnIndex)} + cellHeight={ROW_HEIGHT} + /> + ) : ( + /* using formatValue instead of <Value> here for performance. The later wraps in an extra <span> */ + formatValue(value, { + ...columnSettings, + type: "cell", + jsx: true, + rich: true, + }) + )} </div> </div> ); @@ -414,11 +443,12 @@ export default class TableInteractive extends Component { } tableHeaderRenderer = ({ key, style, columnIndex }: CellRendererProps) => { - const { sort, isPivoted } = this.props; + const { sort, isPivoted, settings } = this.props; const { cols } = this.props.data; const column = cols[columnIndex]; - let columnTitle = formatColumn(column); + let columnTitle = + settings.column(column).column_title || formatColumn(column); if (!columnTitle && this.props.isPivoted && columnIndex !== 0) { columnTitle = t`Unset`; } diff --git a/frontend/src/metabase/visualizations/components/TableSimple.jsx b/frontend/src/metabase/visualizations/components/TableSimple.jsx index 1863401520363104c2a5c7fd6c70ea794f3ec9b4..8cb55a7018a0be0ab79ac5d72e428d80b3d6d819 100644 --- a/frontend/src/metabase/visualizations/components/TableSimple.jsx +++ b/frontend/src/metabase/visualizations/components/TableSimple.jsx @@ -3,18 +3,22 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import ReactDOM from "react-dom"; + import styles from "./Table.css"; -import { t } from "c-3po"; + import ExplicitSize from "metabase/components/ExplicitSize.jsx"; import Ellipsified from "metabase/components/Ellipsified.jsx"; import Icon from "metabase/components/Icon.jsx"; +import MiniBar from "./MiniBar"; -import { formatColumn, formatValue } from "metabase/lib/formatting"; +import { formatValue, formatColumn } from "metabase/lib/formatting"; import { getTableCellClickedObject, isColumnRightAligned, } from "metabase/visualizations/lib/table"; +import { getColumnExtent } from "metabase/visualizations/lib/utils"; +import { t } from "c-3po"; import cx from "classnames"; import _ from "underscore"; @@ -149,7 +153,10 @@ export default class TableSimple extends Component { marginRight: 3, }} /> - <Ellipsified>{formatColumn(col)}</Ellipsified> + <Ellipsified> + {settings.column(col).column_title || + formatColumn(col)} + </Ellipsified> </div> </th> ))} @@ -158,7 +165,7 @@ export default class TableSimple extends Component { <tbody> {rowIndexes.slice(start, end + 1).map((rowIndex, index) => ( <tr key={rowIndex} ref={index === 0 ? "firstRow" : null}> - {rows[rowIndex].map((cell, columnIndex) => { + {rows[rowIndex].map((value, columnIndex) => { const clicked = getTableCellClickedObject( data, rowIndex, @@ -168,6 +175,7 @@ export default class TableSimple extends Component { const isClickable = onVisualizationClick && visualizationIsClickable(clicked); + const columnSettings = settings.column(cols[columnIndex]); return ( <td key={columnIndex} @@ -176,7 +184,7 @@ export default class TableSimple extends Component { backgroundColor: getCellBackgroundColor && getCellBackgroundColor( - cell, + value, rowIndex, cols[columnIndex].name, ), @@ -202,13 +210,25 @@ export default class TableSimple extends Component { : undefined } > - {cell == null - ? "-" - : formatValue(cell, { - column: cols[columnIndex], - jsx: true, - rich: true, - })} + {value == null ? ( + "-" + ) : columnSettings["show_mini_bar"] ? ( + <MiniBar + value={value} + options={columnSettings} + extent={getColumnExtent( + cols, + rows, + columnIndex, + )} + /> + ) : ( + formatValue(value, { + ...columnSettings, + jsx: true, + rich: true, + }) + )} </span> </td> ); diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index f603cdcd19b8236e24b4a445464dbefbb464649f..33700555ce5227fa4d1f8c8ec5fdeb08985727b3 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -17,7 +17,7 @@ import { getVisualizationTransformed, extractRemappings, } from "metabase/visualizations"; -import { getSettings } from "metabase/visualizations/lib/settings"; +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { isSameSeries } from "metabase/visualizations/lib/utils"; import Utils from "metabase/lib/utils"; @@ -347,7 +347,7 @@ export default class Visualization extends Component { let settings = this.props.settings || {}; if (!loading && !error) { - settings = this.props.settings || getSettings(series); + settings = this.props.settings || getComputedSettingsForSeries(series); if (!CardVisualization) { error = t`Could not find visualization`; } else { diff --git a/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9034001f4bf6a0cbf98e5464c89f59f76f77bc23 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx @@ -0,0 +1,58 @@ +/* @flow */ + +import React from "react"; + +import Icon from "metabase/components/Icon"; + +import ColumnItem from "./ColumnItem"; + +const displayNameForColumn = column => + column ? column.display_name || column.name : "[Unknown]"; + +import type { NestedSettingComponentProps } from "./ChartSettingNestedSettings"; + +// various props injected by chartSettingNestedSettings HOC +export default class ChartNestedSettingSeries extends React.Component { + props: NestedSettingComponentProps; + + render() { + const { + objects, + onChangeEditingObject, + objectSettingsWidgets, + object, + } = this.props; + + if (object) { + return ( + <div> + {/* only show the back button if we have more than one column */} + {objects.length > 1 && ( + <div + className="flex align-center mb2 cursor-pointer" + onClick={() => onChangeEditingObject()} + > + <Icon name="chevronleft" className="text-light" /> + <span className="ml1 text-bold text-brand"> + {displayNameForColumn(object)} + </span> + </div> + )} + {objectSettingsWidgets} + </div> + ); + } else { + return ( + <div> + {objects.map(column => ( + <ColumnItem + title={displayNameForColumn(column)} + onEdit={() => onChangeEditingObject(column)} + onClick={() => onChangeEditingObject(column)} + /> + ))} + </div> + ); + } + } +} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingButtonGroup.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingButtonGroup.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fb6324c86eb80a9f652400b313a28f067a746d71 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingButtonGroup.jsx @@ -0,0 +1,16 @@ +import React from "react"; + +import Icon from "metabase/components/Icon"; +import ButtonGroup from "metabase/components/ButtonGroup"; + +const ChartSettingButtonGroup = ({ value, onChange, options, ...props }) => ( + <ButtonGroup + {...props} + value={value} + onChange={onChange} + options={options} + optionNameFn={o => (o.icon ? <Icon name={o.icon} /> : o.name)} + /> +); + +export default ChartSettingButtonGroup; diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx index d1601c329e584dc882f1212b0db72bff1672f922..41ee106fa9b492fb74af416015fb572b611e8776 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx @@ -5,8 +5,14 @@ import cx from "classnames"; import ChartSettingSelect from "./ChartSettingSelect.jsx"; -const ChartSettingFieldPicker = ({ value, options, onChange, onRemove }) => ( - <div className="flex align-center"> +const ChartSettingFieldPicker = ({ + value, + options, + onChange, + onRemove, + className, +}) => ( + <div className={cx(className, "flex align-center")}> <ChartSettingSelect value={value} options={options} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx index 9234a3f31e35cf89928ba1b9b9130faf77c8cf68..86ee8cd40ac492df9f42c912972abc3f42a87aba 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx @@ -12,6 +12,7 @@ const ChartSettingFieldsPicker = ({ {Array.isArray(value) ? ( value.map((v, index) => ( <ChartSettingFieldPicker + className={index > 0 ? "mt1" : null} key={index} value={v} options={options} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx index c5bdbe249f26fae3a0c7afecdfe260369715918e..f7705f1931a3e60143ba55ed0e22a2150dbbf9d1 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInput.jsx @@ -1,7 +1,8 @@ import React from "react"; -const ChartSettingInput = ({ value, onChange }) => ( +const ChartSettingInput = ({ value, onChange, ...props }) => ( <input + {...props} className="input block full" value={value} onChange={e => onChange(e.target.value)} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx index e99bb42099b2740b5d0a9f1758a1509bfe47c8fb..efd9a9e927e9371db12d61112a7965d11cca1bb3 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.jsx @@ -17,9 +17,10 @@ export default class ChartSettingInputNumeric extends Component { } render() { - const { onChange } = this.props; + const { onChange, ...props } = this.props; return ( <input + {...props} className={cx("input block full", { "border-error": this.state.value !== "" && isNaN(parseFloat(this.state.value)), diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingNestedSettings.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingNestedSettings.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c750c60457795b71933f89cb1c36ece3b5d1d90d --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingNestedSettings.jsx @@ -0,0 +1,171 @@ +/* @flow */ + +import React from "react"; + +import ChartSettingsWidget from "../ChartSettingsWidget"; + +import _ from "underscore"; + +import type { + Settings, + ExtraProps, + WidgetDef, +} from "metabase/visualizations/lib/settings"; +import type { + NestedObject, + NestedObjectKey, + SettingsWidgetsForObjectGetter, + NestedObjectKeyGetter, +} from "metabase/visualizations/lib/settings/nested"; +import type { Series } from "metabase/meta/types/Visualization"; + +export type NestedSettingComponentProps = { + objects: NestedObject[], + object: ?NestedObject, + objectSettingsWidgets: ?(WidgetDef[]), + onChangeEditingObject: (editingObject: ?NestedObject) => void, +}; +type NestedSettingComponent = Class< + React$Component<NestedSettingComponentProps, *, *>, +>; + +type SettingsByObjectKey = { [key: NestedObjectKey]: Settings }; + +type Props = { + value: SettingsByObjectKey, + onChange: (newSettings: SettingsByObjectKey) => void, + onEndShowWidget?: () => void, + series: Series, + extra: ExtraProps, + objects: NestedObject[], + initialKey?: NestedObjectKey, +}; + +type State = { + editingObjectKey: ?NestedObjectKey, +}; + +type ChartSettingsNestedSettingHOCProps = { + getObjectKey: NestedObjectKeyGetter, + getSettingsWidgetsForObject: SettingsWidgetsForObjectGetter, +}; + +const chartSettingNestedSettings = ({ + getObjectKey, + getSettingsWidgetsForObject, +}: ChartSettingsNestedSettingHOCProps) => ( + ComposedComponent: NestedSettingComponent, +) => + class extends React.Component { + props: Props; + state: State; + + constructor(props: Props) { + super(props); + this.state = { + editingObjectKey: + props.initialKey || + (props.objects.length === 1 ? getObjectKey(props.objects[0]) : null), + }; + } + + componentWillReceiveProps(nextProps: Props) { + // reset editingObjectKey if there's only one object + if ( + nextProps.objects.length === 1 && + this.state.editingObjectKey !== getObjectKey(nextProps.objects[0]) + ) { + this.setState({ + editingObjectKey: getObjectKey(nextProps.objects[0]), + }); + } + } + + handleChangeEditingObject = (editingObject: ?NestedObject) => { + this.setState({ + editingObjectKey: editingObject ? getObjectKey(editingObject) : null, + }); + // special prop to notify ChartSettings it should unswap replaced widget + if (!editingObject && this.props.onEndShowWidget) { + this.props.onEndShowWidget(); + } + }; + + handleChangeSettingsForEditingObject = (newSettings: Settings) => { + const { editingObjectKey } = this.state; + if (editingObjectKey) { + this.handleChangeSettingsForObjectKey(editingObjectKey, newSettings); + } + }; + + handleChangeSettingsForObject = ( + object: NestedObject, + newSettings: Settings, + ) => { + const objectKey = getObjectKey(object); + if (objectKey) { + this.handleChangeSettingsForObjectKey(objectKey, newSettings); + } + }; + + handleChangeSettingsForObjectKey = ( + objectKey: NestedObjectKey, + newSettings: Settings, + ) => { + const { onChange } = this.props; + const objectsSettings = this.props.value || {}; + const objectSettings = objectsSettings[objectKey] || {}; + onChange({ + ...objectsSettings, + [objectKey]: { + ...objectSettings, + ...newSettings, + }, + }); + }; + + render() { + const { series, objects, extra } = this.props; + const { editingObjectKey } = this.state; + + if (editingObjectKey) { + const editingObject = _.find( + objects, + o => getObjectKey(o) === editingObjectKey, + ); + if (editingObject) { + const objectsSettings = this.props.value || {}; + const objectSettings = objectsSettings[editingObjectKey] || {}; + const objectSettingsWidgets = getSettingsWidgetsForObject( + series, + editingObject, + objectSettings, + this.handleChangeSettingsForEditingObject, + extra, + ); + return ( + <ComposedComponent + {...this.props} + getObjectKey={getObjectKey} + onChangeEditingObject={this.handleChangeEditingObject} + onChangeObjectSettings={this.handleChangeSettingsForObject} + object={editingObject} + objectSettingsWidgets={objectSettingsWidgets.map(widget => ( + <ChartSettingsWidget key={widget.id} {...widget} /> + ))} + /> + ); + } + } + return ( + <ComposedComponent + {...this.props} + getObjectKey={getObjectKey} + onChangeEditingObject={this.handleChangeEditingObject} + onChangeObjectSettings={this.handleChangeSettingsForObject} + /> + ); + } + }; + +export default chartSettingNestedSettings; diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedColumns.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedColumns.jsx index 014cbb6c6281d76e0d4280a1104e40a3d20bb080..7b0807d5f82d6e2cca954b3ab513d364ae3b1da2 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedColumns.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedColumns.jsx @@ -1,12 +1,13 @@ import React, { Component } from "react"; import { t } from "c-3po"; -import Icon from "metabase/components/Icon.jsx"; +import ColumnItem from "./ColumnItem"; import { SortableContainer, SortableElement } from "react-sortable-hoc"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; import { + keyForColumn, fieldRefForColumn, findColumnForColumnSetting, } from "metabase/lib/dataset"; @@ -15,16 +16,18 @@ import { getFriendlyName } from "metabase/visualizations/lib/utils"; import _ from "underscore"; const SortableColumn = SortableElement( - ({ columnSetting, getColumnName, onRemove }) => ( + ({ columnSetting, getColumnName, onEdit, onRemove }) => ( <ColumnItem title={getColumnName(columnSetting)} - onRemove={() => onRemove(columnSetting)} + onEdit={onEdit ? () => onEdit(columnSetting) : null} + onRemove={onRemove ? () => onRemove(columnSetting) : null} + draggable /> ), ); const SortableColumnList = SortableContainer( - ({ columnSettings, getColumnName, onRemove }) => { + ({ columnSettings, getColumnName, onEdit, onRemove }) => { return ( <div> {columnSettings.map((columnSetting, index) => ( @@ -33,6 +36,7 @@ const SortableColumnList = SortableContainer( index={columnSetting.index} columnSetting={columnSetting} getColumnName={getColumnName} + onEdit={onEdit} onRemove={onRemove} /> ))} @@ -62,6 +66,21 @@ export default class ChartSettingOrderedColumns extends Component { this.props.onChange(fields); }; + handleEdit = columnSetting => { + const column = findColumnForColumnSetting( + this.props.columns, + columnSetting, + ); + if (column) { + this.props.onShowWidget({ + id: "column_settings", + props: { + initialKey: keyForColumn(column), + }, + }); + } + }; + handleAddNewField = fieldRef => { const { value, onChange, addField } = this.props; onChange([ @@ -109,6 +128,7 @@ export default class ChartSettingOrderedColumns extends Component { <SortableColumnList columnSettings={enabledColumns} getColumnName={this.getColumnName} + onEdit={this.handleEdit} onRemove={this.handleDisable} onSortEnd={this.handleSortEnd} distance={5} @@ -120,13 +140,14 @@ export default class ChartSettingOrderedColumns extends Component { </div> )} {disabledColumns.length > 0 || additionalFieldOptions.count > 0 ? ( - <h4 className="mb2 mt4 pt4 border-top">{`More fields`}</h4> + <h4 className="mb2 mt4 pt4 border-top">{`More columns`}</h4> ) : null} {disabledColumns.map((columnSetting, index) => ( <ColumnItem key={index} title={this.getColumnName(columnSetting)} onAdd={() => this.handleEnable(columnSetting)} + onClick={() => this.handleEnable(columnSetting)} /> ))} {additionalFieldOptions.count > 0 && ( @@ -158,36 +179,3 @@ export default class ChartSettingOrderedColumns extends Component { ); } } - -const ColumnItem = ({ title, onAdd, onRemove }) => ( - <div - className="my1 bordered rounded shadowed cursor-pointer overflow-hidden bg-white" - onClick={onAdd} - > - <div className="p1 border-bottom relative"> - <div className="px1 flex align-center relative"> - <span className="h4 flex-full text-dark">{title}</span> - {onAdd && ( - <Icon - name="add" - className="cursor-pointer text-light text-medium-hover" - onClick={e => { - e.stopPropagation(); - onAdd(); - }} - /> - )} - {onRemove && ( - <Icon - name="close" - className="cursor-pointer text-light text-medium-hover" - onClick={e => { - e.stopPropagation(); - onRemove(); - }} - /> - )} - </div> - </div> - </div> -); diff --git a/frontend/src/metabase/visualizations/components/settings/ColumnItem.jsx b/frontend/src/metabase/visualizations/components/settings/ColumnItem.jsx new file mode 100644 index 0000000000000000000000000000000000000000..59ca2cda0400a9ab2baf4ed0e2ba17762ea05e05 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ColumnItem.jsx @@ -0,0 +1,37 @@ +import React from "react"; + +import Icon from "metabase/components/Icon"; + +import cx from "classnames"; + +const ActionIcon = ({ icon, onClick }) => ( + <Icon + name={icon} + className="cursor-pointer text-light text-medium-hover ml1" + onClick={e => { + e.stopPropagation(); + onClick(); + }} + /> +); + +const ColumnItem = ({ title, onAdd, onRemove, onClick, onEdit, draggable }) => ( + <div + className={cx("my1 bordered rounded overflow-hidden bg-white", { + "cursor-grab shadowed": draggable, + "cursor-pointer": onClick, + })} + onClick={onClick} + > + <div className="p1 border-bottom relative"> + <div className="px1 flex align-center relative"> + <span className="h4 flex-full text-dark">{title}</span> + {onEdit && <ActionIcon icon="gear" onClick={onEdit} />} + {onAdd && <ActionIcon icon="add" onClick={onAdd} />} + {onRemove && <ActionIcon icon="close" onClick={onRemove} />} + </div> + </div> + </div> +); + +export default ColumnItem; diff --git a/frontend/src/metabase/visualizations/lib/apply_tooltips.js b/frontend/src/metabase/visualizations/lib/apply_tooltips.js index 3c7aef85d6f88dc8ff6faae36c5c20386334ad9b..0fe9817f9a8eea9ced65a1c98ad27c6fba72e33d 100644 --- a/frontend/src/metabase/visualizations/lib/apply_tooltips.js +++ b/frontend/src/metabase/visualizations/lib/apply_tooltips.js @@ -64,9 +64,12 @@ function applyChartTooltips( { key: getFriendlyName(cols[1]), value: isNormalized - ? `${formatValue(d.data.value) * 100}%` + ? formatValue(d.data.value, { + number_style: "percent", + column: cols[1], + }) : d.data.value, - col: cols[1], + col: { ...cols[1] }, }, ]; diff --git a/frontend/src/metabase/visualizations/lib/settings.js b/frontend/src/metabase/visualizations/lib/settings.js index 2331906775dcb80b1668d0a8dbfa04391d646d69..8551a99a3e47d14aef073edc486ed9608c2af479 100644 --- a/frontend/src/metabase/visualizations/lib/settings.js +++ b/frontend/src/metabase/visualizations/lib/settings.js @@ -1,16 +1,4 @@ -import { getVisualizationRaw } from "metabase/visualizations"; -import { t } from "c-3po"; -import { - columnsAreValid, - getChartTypeFromData, - DIMENSION_DIMENSION_METRIC, - DIMENSION_METRIC, - DIMENSION_METRIC_METRIC, - getColumnCardinality, - getFriendlyName, -} from "./utils"; - -import { isDate, isMetric, isDimension } from "metabase/lib/schema_metadata"; +/* @flow */ import ChartSettingInput from "metabase/visualizations/components/settings/ChartSettingInput.jsx"; import ChartSettingInputGroup from "metabase/visualizations/components/settings/ChartSettingInputGroup.jsx"; @@ -18,11 +6,58 @@ import ChartSettingInputNumeric from "metabase/visualizations/components/setting import ChartSettingRadio from "metabase/visualizations/components/settings/ChartSettingRadio.jsx"; import ChartSettingSelect from "metabase/visualizations/components/settings/ChartSettingSelect.jsx"; import ChartSettingToggle from "metabase/visualizations/components/settings/ChartSettingToggle.jsx"; +import ChartSettingButtonGroup from "metabase/visualizations/components/settings/ChartSettingButtonGroup.jsx"; import ChartSettingFieldPicker from "metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx"; import ChartSettingFieldsPicker from "metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx"; import ChartSettingColorPicker from "metabase/visualizations/components/settings/ChartSettingColorPicker.jsx"; import ChartSettingColorsPicker from "metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx"; +export type SettingId = string; + +export type Settings = { + [settingId: SettingId]: any, +}; + +export type SettingDefs = { + [settingId: SettingId]: SettingDef, +}; + +export type SettingDef = { + title?: string, + props?: { [key: string]: any }, + default?: any, + hidden?: boolean, + disabled?: boolean, + getTitle?: (object: any, settings: Settings) => ?string, + getHidden?: (object: any, settings: Settings) => boolean, + getDisabled?: (object: any, settings: Settings) => boolean, + getProps?: ( + object: any, + settings: Settings, + onChange: Function, + ) => { [key: string]: any }, + getDefault?: (object: any, settings: Settings) => any, + getValue?: (object: any, settings: Settings) => any, + isValid?: (object: any, settings: Settings) => boolean, + widget?: string | React$Component<any, any, any>, + writeDependencies?: SettingId[], + readDependencies?: SettingId[], +}; + +export type WidgetDef = { + id: SettingId, + value: any, + title: ?string, + hidden: boolean, + disabled: boolean, + props: { [key: string]: any }, + // $FlowFixMe + widget?: React$Component<any, any, any>, + onChange: (value: any) => void, +}; + +export type ExtraProps = { [key: string]: any }; + const WIDGETS = { input: ChartSettingInput, inputGroup: ChartSettingInputGroup, @@ -30,257 +65,131 @@ const WIDGETS = { radio: ChartSettingRadio, select: ChartSettingSelect, toggle: ChartSettingToggle, + buttonGroup: ChartSettingButtonGroup, field: ChartSettingFieldPicker, fields: ChartSettingFieldsPicker, color: ChartSettingColorPicker, colors: ChartSettingColorsPicker, }; -export function getDefaultColumns(series) { - if (series[0].card.display === "scatter") { - return getDefaultScatterColumns(series); - } else { - return getDefaultLineAreaBarColumns(series); - } -} - -function getDefaultScatterColumns([{ data: { cols, rows } }]) { - let dimensions = cols.filter(isDimension); - let metrics = cols.filter(isMetric); - if (dimensions.length === 2 && metrics.length < 2) { - return { - dimensions: [dimensions[0].name], - metrics: [dimensions[1].name], - bubble: metrics.length === 1 ? metrics[0].name : null, - }; - } else { - return { - dimensions: [null], - metrics: [null], - bubble: null, - }; - } -} - -function getDefaultLineAreaBarColumns([{ data: { cols, rows } }]) { - let type = getChartTypeFromData(cols, rows, false); - if (type === DIMENSION_DIMENSION_METRIC) { - let dimensions = [cols[0], cols[1]]; - if (isDate(dimensions[1]) && !isDate(dimensions[0])) { - // if the series dimension is a date but the axis dimension is not then swap them - dimensions.reverse(); - } else if ( - getColumnCardinality(cols, rows, 1) > getColumnCardinality(cols, rows, 0) - ) { - // if the series dimension is higher cardinality than the axis dimension then swap them - dimensions.reverse(); - } - return { - dimensions: dimensions.map(col => col.name), - metrics: [cols[2].name], - }; - } else if (type === DIMENSION_METRIC) { - return { - dimensions: [cols[0].name], - metrics: [cols[1].name], - }; - } else if (type === DIMENSION_METRIC_METRIC) { - return { - dimensions: [cols[0].name], - metrics: cols.slice(1).map(col => col.name), - }; - } - return { - dimensions: [null], - metrics: [null], - }; -} - -export function getDefaultDimensionAndMetric([{ data }]) { - const type = data && getChartTypeFromData(data.cols, data.rows, false); - if (type === DIMENSION_METRIC) { - return { - dimension: data.cols[0].name, - metric: data.cols[1].name, - }; - } else if (type === DIMENSION_DIMENSION_METRIC) { - return { - dimension: null, - metric: data.cols[2].name, - }; - } else { - return { - dimension: null, - metric: null, - }; +export function getComputedSettings( + settingsDefs: SettingDefs, + object: any, + storedSettings: Settings, + extra?: ExtraProps = {}, +) { + const computedSettings = {}; + for (let settingId in settingsDefs) { + getComputedSetting( + computedSettings, + settingsDefs, + settingId, + object, + storedSettings, + extra, + ); } + return computedSettings; } -export function getOptionFromColumn(col) { - return { - name: getFriendlyName(col), - value: col.name, - }; -} - -export function metricSetting(id) { - return fieldSetting( - id, - isMetric, - series => getDefaultDimensionAndMetric(series).metric, - ); -} - -export function dimensionSetting(id) { - return fieldSetting( - id, - isDimension, - series => getDefaultDimensionAndMetric(series).dimension, - ); -} - -export function fieldSetting(id, filter, getDefault) { - return { - widget: "select", - isValid: ([{ card, data }], vizSettings) => - columnsAreValid(card.visualization_settings[id], data, filter), - getDefault: getDefault, - getProps: ([{ card, data: { cols } }]) => ({ - options: cols.filter(filter).map(getOptionFromColumn), - }), - }; -} - -const COMMON_SETTINGS = { - "card.title": { - title: t`Title`, - widget: "input", - getDefault: series => (series.length === 1 ? series[0].card.name : null), - dashboard: true, - useRawSeries: true, - }, - "card.description": { - title: t`Description`, - widget: "input", - getDefault: series => - series.length === 1 ? series[0].card.description : null, - dashboard: true, - useRawSeries: true, - }, -}; - -function getSetting(settingDefs, id, vizSettings, series) { - if (id in vizSettings) { +function getComputedSetting( + computedSettings: Settings, // MUTATED! + settingDefs: SettingDefs, + settingId: SettingId, + object: any, + storedSettings: Settings, + extra?: ExtraProps = {}, +): any { + if (settingId in computedSettings) { return; } - const settingDef = settingDefs[id] || {}; - const [{ card }] = series; - const visualization_settings = card.visualization_settings || {}; + const settingDef = settingDefs[settingId] || {}; for (let dependentId of settingDef.readDependencies || []) { - getSetting(settingDefs, dependentId, vizSettings, series); + getComputedSetting( + computedSettings, + settingDefs, + dependentId, + object, + storedSettings, + extra, + ); } - if (settingDef.useRawSeries && series._raw) { - series = series._raw; + if (settingDef.useRawSeries && object._raw) { + object = object._raw; } + const settings = { ...storedSettings, ...computedSettings }; + try { if (settingDef.getValue) { - return (vizSettings[id] = settingDef.getValue(series, vizSettings)); + return (computedSettings[settingId] = settingDef.getValue( + object, + settings, + extra, + )); } - if (visualization_settings[id] !== undefined) { - if (!settingDef.isValid || settingDef.isValid(series, vizSettings)) { - return (vizSettings[id] = visualization_settings[id]); + if (storedSettings[settingId] !== undefined) { + if (!settingDef.isValid || settingDef.isValid(object, settings, extra)) { + return (computedSettings[settingId] = storedSettings[settingId]); } } if (settingDef.getDefault) { - const defaultValue = settingDef.getDefault(series, vizSettings); + const defaultValue = settingDef.getDefault(object, settings, extra); - return (vizSettings[id] = defaultValue); + return (computedSettings[settingId] = defaultValue); } if ("default" in settingDef) { - return (vizSettings[id] = settingDef.default); + return (computedSettings[settingId] = settingDef.default); } } catch (e) { - console.warn("Error getting setting", id, e); + console.warn("Error getting setting", settingId, e); } - return (vizSettings[id] = undefined); + return (computedSettings[settingId] = undefined); } -function getSettingDefintionsForSeries(series) { - const { CardVisualization } = getVisualizationRaw(series); - const definitions = { - ...COMMON_SETTINGS, - ...(CardVisualization.settings || {}), - }; - for (const id in definitions) { - definitions[id].id = id; - } - return definitions; -} - -export function getPersistableDefaultSettings(series) { - // A complete set of settings (not only defaults) is loaded because - // some persistable default settings need other settings as dependency for calculating the default value - const completeSettings = getSettings(series); - - let persistableDefaultSettings = {}; - let settingsDefs = getSettingDefintionsForSeries(series); - - for (let id in settingsDefs) { - const settingDef = settingsDefs[id]; - if (settingDef.persistDefault) { - persistableDefaultSettings[id] = completeSettings[id]; - } - } - - return persistableDefaultSettings; -} - -export function getSettings(series) { - let vizSettings = {}; - let settingsDefs = getSettingDefintionsForSeries(series); - for (let id in settingsDefs) { - getSetting(settingsDefs, id, vizSettings, series); - } - return vizSettings; -} - -function getSettingWidget(settingDef, vizSettings, series, onChangeSettings) { - const id = settingDef.id; - const value = vizSettings[id]; +function getSettingWidget( + settingDefs: SettingDefs, + settingId: SettingId, + settings: Settings, + object: any, + onChangeSettings: (settings: Settings) => void, + extra?: ExtraProps = {}, +): WidgetDef { + const settingDef = settingDefs[settingId]; + const value = settings[settingId]; const onChange = value => { - const newSettings = { [id]: value }; - for (const id of settingDef.writeDependencies || []) { - newSettings[id] = vizSettings[id]; + const newSettings = { [settingId]: value }; + for (const settingId of settingDef.writeDependencies || []) { + newSettings[settingId] = settings[settingId]; } onChangeSettings(newSettings); }; - if (settingDef.useRawSeries && series._raw) { - series = series._raw; + if (settingDef.useRawSeries && object._raw) { + object = object._raw; } return { ...settingDef, - id: id, + id: settingId, value: value, title: settingDef.getTitle - ? settingDef.getTitle(series, vizSettings) + ? settingDef.getTitle(object, settings, extra) : settingDef.title, hidden: settingDef.getHidden - ? settingDef.getHidden(series, vizSettings) - : false, + ? settingDef.getHidden(object, settings, extra) + : settingDef.hidden || false, disabled: settingDef.getDisabled - ? settingDef.getDisabled(series, vizSettings) - : false, + ? settingDef.getDisabled(object, settings, extra) + : settingDef.disabled || false, props: { ...(settingDef.props ? settingDef.props : {}), ...(settingDef.getProps - ? settingDef.getProps(series, vizSettings, onChange) + ? settingDef.getProps(object, settings, onChange, extra) : {}), }, widget: @@ -292,19 +201,36 @@ function getSettingWidget(settingDef, vizSettings, series, onChangeSettings) { } export function getSettingsWidgets( - series, - onChangeSettings, - isDashboard = false, + settingDefs: SettingDefs, + settings: Settings, + object: any, + onChangeSettings: (settings: Settings) => void, + extra?: ExtraProps = {}, ) { - const vizSettings = getSettings(series); - return Object.values(getSettingDefintionsForSeries(series)) - .map(settingDef => - getSettingWidget(settingDef, vizSettings, series, onChangeSettings), + return Object.keys(settingDefs) + .map(settingId => + getSettingWidget( + settingDefs, + settingId, + settings, + object, + onChangeSettings, + extra, + ), ) - .filter( - widget => - widget.widget && - !widget.hidden && - (widget.dashboard === undefined || widget.dashboard === isDashboard), - ); + .filter(widget => widget.widget); +} + +export function getPersistableDefaultSettings( + settingsDefs: SettingDefs, + completeSettings: Settings, +): Settings { + let persistableDefaultSettings = {}; + for (let settingId in settingsDefs) { + const settingDef = settingsDefs[settingId]; + if (settingDef.persistDefault) { + persistableDefaultSettings[settingId] = completeSettings[settingId]; + } + } + return persistableDefaultSettings; } diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js new file mode 100644 index 0000000000000000000000000000000000000000..46baaae3e5882e4fa3c9aeaabb0f64beb7491af1 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/column.js @@ -0,0 +1,292 @@ +/* @flow */ + +import { t } from "c-3po"; +import moment from "moment"; + +import { nestedSettings } from "./nested"; +import ChartNestedSettingColumns from "metabase/visualizations/components/settings/ChartNestedSettingColumns.jsx"; + +import { keyForColumn } from "metabase/lib/dataset"; +import { isDate, isNumber, isCoordinate } from "metabase/lib/schema_metadata"; + +// HACK: cyclical dependency causing errors in unit tests +// import { getVisualizationRaw } from "metabase/visualizations"; +function getVisualizationRaw(...args) { + return require("metabase/visualizations").getVisualizationRaw(...args); +} + +import { numberFormatterForOptions } from "metabase/lib/formatting"; +import { + DEFAULT_DATE_STYLE, + getDateFormatFromStyle, + hasDay, + hasHour, +} from "metabase/lib/formatting/date"; + +import type { Settings, SettingDef } from "../settings"; +import type { DateStyle, TimeStyle } from "metabase/lib/formatting/date"; +import type { DatetimeUnit } from "metabase/meta/types/Query"; +import type { Column } from "metabase/meta/types/Dataset"; +import type { Series } from "metabase/meta/types/Visualization"; +import type { VisualizationSettings } from "metabase/meta/types/Card"; + +type ColumnSettings = Settings; + +type ColumnGetter = ( + series: Series, + vizSettings: VisualizationSettings, +) => Column[]; + +const DEFAULT_GET_COLUMNS: ColumnGetter = (series, vizSettings) => + [].concat(...series.map(s => s.data.cols)); + +type ColumnSettingDef = SettingDef & { + getColumns?: ColumnGetter, +}; + +export function columnSettings({ + getColumns = DEFAULT_GET_COLUMNS, + ...def +}: ColumnSettingDef) { + return nestedSettings("column_settings", { + section: t`Formatting`, + objectName: "column", + getObjects: getColumns, + getObjectKey: keyForColumn, + getSettingDefintionsForObject: getSettingDefintionsForColumn, + getObjectSettingsExtra: (series, settings, object) => ({ column: object }), + component: ChartNestedSettingColumns, + useRawSeries: true, + ...def, + }); +} + +const EXAMPLE_DATE = moment("2018-01-07 17:24"); + +function getDateStyleOptionsForUnit(unit: ?DatetimeUnit) { + const options = [ + dateStyleOption("MMMM D, YYYY", unit), + dateStyleOption("D MMMM, YYYY", unit), + dateStyleOption("dddd, MMMM D, YYYY", unit), + dateStyleOption("M/D/YYYY", unit, hasDay(unit) ? "month, day, year" : null), + dateStyleOption("D/M/YYYY", unit, hasDay(unit) ? "day, month, year" : null), + dateStyleOption("YYYY/M/D", unit, hasDay(unit) ? "year, month, day" : null), + ]; + const seen = new Set(); + return options.filter(option => { + const format = getDateFormatFromStyle(option.value, unit); + if (seen.has(format)) { + return false; + } else { + seen.add(format); + return true; + } + }); +} + +function dateStyleOption( + style: DateStyle, + unit: ?DatetimeUnit, + description?: ?string, +) { + const format = getDateFormatFromStyle(style, unit); + return { + name: + EXAMPLE_DATE.format(format) + (description ? ` (${description})` : ``), + value: style, + }; +} + +function timeStyleOption(style: TimeStyle, description?: ?string) { + const format = style; + return { + name: + EXAMPLE_DATE.format(format) + (description ? ` (${description})` : ``), + value: style, + }; +} + +export const DATE_COLUMN_SETTINGS = { + date_style: { + title: t`Date style`, + widget: "radio", + default: DEFAULT_DATE_STYLE, + getProps: ({ unit }: Column) => ({ + options: getDateStyleOptionsForUnit(unit), + }), + getHidden: ({ unit }: Column) => + getDateStyleOptionsForUnit(unit).length < 2, + }, + date_abbreviate: { + title: t`Abbreviate names of days and months`, + widget: "toggle", + default: false, + getHidden: ({ unit }: Column, settings: ColumnSettings) => { + const format = getDateFormatFromStyle(settings["date_style"], unit); + return !format.match(/MMMM|dddd/); + }, + readDependencies: ["date_style"], + }, + time_enabled: { + title: t`Show the time`, + widget: "buttonGroup", + isValid: ({ unit }: Column, settings: ColumnSettings) => + !settings["time_enabled"] || hasHour(unit), + getProps: ({ unit }: Column, settings: ColumnSettings) => { + const options = [ + { name: t`Off`, value: null }, + { name: t`Minutes`, value: "minutes" }, + ]; + if (!unit || unit === "default" || unit === "second") { + options.push({ name: t`Seconds`, value: "seconds" }); + } + if (!unit || unit === "default") { + options.push({ name: t`Milliseconds`, value: "milliseconds" }); + } + if (options.length === 2) { + options[1].name = t`On`; + } + return { options }; + }, + getHidden: ({ unit }: Column, settings: ColumnSettings) => !hasHour(unit), + getDefault: ({ unit }: Column) => (hasHour(unit) ? "minutes" : null), + }, + time_style: { + title: t`Time style`, + widget: "radio", + default: "h:mm A", + getProps: (column: Column, settings: ColumnSettings) => ({ + options: [ + timeStyleOption("h:mm A", "12-hour clock"), + timeStyleOption("k:mm", "24-hour clock"), + ], + }), + getHidden: (column: Column, settings: ColumnSettings) => + !settings["time_enabled"], + readDependencies: ["time_enabled"], + }, +}; + +export const NUMBER_COLUMN_SETTINGS = { + number_style: { + title: t`Style`, + widget: "radio", + props: { + options: [ + { name: "Normal", value: "decimal" }, + { name: "Percent", value: "percent" }, + { name: "Scientific", value: "scientific" }, + // { name: "Currency", value: "currency" }, + ], + }, + // TODO: default to currency for fields that are a currency type + default: "decimal", + }, + currency: { + title: t`Currency`, + widget: "select", + props: { + // FIXME: rest of these options + options: [{ name: "USD", value: "USD" }, { name: "EUR", value: "EUR" }], + }, + default: "USD", + getHidden: (column: Column, settings: ColumnSettings) => + settings["number_style"] !== "currency", + }, + currency_style: { + title: t`Currency Style`, + widget: "radio", + props: { + options: [ + { name: "Symbol ($)", value: "symbol" }, + { name: "Code (USD)", value: "code" }, + { name: "Name (US dollars)", value: "name" }, + ], + }, + default: "symbol", + getHidden: (column: Column, settings: ColumnSettings) => + settings["number_style"] !== "currency", + }, + locale: { + title: t`Separator style`, + widget: "radio", + props: { + options: [ + { name: "100000.00", value: null }, + { name: "100,000.00", value: "en" }, + { name: "100 000,00", value: "fr" }, + { name: "100.000,00", value: "de" }, + ], + }, + default: "en", + }, + decimals: { + title: t`Number of decimal places`, + widget: "number", + }, + scale: { + title: t`Multiply by a number`, + widget: "number", + props: { + placeholder: "1", + }, + }, + prefix: { + title: t`Add a prefix`, + widget: "input", + }, + suffix: { + title: t`Add a suffix`, + widget: "input", + }, + // Optimization: build a single NumberFormat object that is used by formatting.js + _numberFormatter: { + getValue: (column: Column, settings: ColumnSettings) => + numberFormatterForOptions(settings), + // NOTE: make sure to include every setting that affects the number formatter here + readDependencies: [ + "number_style", + "currency_style", + "currency", + "locale", + "decimals", + ], + }, +}; + +const COMMON_COLUMN_SETTINGS = { + // markdown_template: { + // title: t`Markdown template`, + // widget: "input", + // props: { + // placeholder: "{{value}}", + // }, + // }, +}; + +export function getSettingDefintionsForColumn(series: Series, column: Column) { + const { CardVisualization } = getVisualizationRaw(series); + const extraColumnSettings = + typeof CardVisualization.columnSettings === "function" + ? CardVisualization.columnSettings(column) + : CardVisualization.columnSettings || {}; + + if (isDate(column)) { + return { + ...extraColumnSettings, + ...DATE_COLUMN_SETTINGS, + ...COMMON_COLUMN_SETTINGS, + }; + } else if (isNumber(column) && !isCoordinate(column)) { + return { + ...extraColumnSettings, + ...NUMBER_COLUMN_SETTINGS, + ...COMMON_COLUMN_SETTINGS, + }; + } else { + return { + ...extraColumnSettings, + ...COMMON_COLUMN_SETTINGS, + }; + } +} diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index 93e865edf94453eaafaa5ace550ccace6fac580d..2a4c953693be69068bd2b87712b7846bb2ebbc0e 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -4,17 +4,20 @@ import { isMetric, isNumeric, isAny, + isDate, } from "metabase/lib/schema_metadata"; import { t } from "c-3po"; -import { - getDefaultColumns, - getOptionFromColumn, -} from "metabase/visualizations/lib/settings"; import { columnsAreValid, getCardColors, getFriendlyName, + getChartTypeFromData, + getColumnCardinality, + DIMENSION_DIMENSION_METRIC, + DIMENSION_METRIC, + DIMENSION_METRIC_METRIC, } from "metabase/visualizations/lib/utils"; +import { getOptionFromColumn } from "metabase/visualizations/lib/settings/utils"; import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; @@ -40,6 +43,66 @@ function getSeriesTitles(series, vizSettings) { ); } +export function getDefaultColumns(series) { + if (series[0].card.display === "scatter") { + return getDefaultScatterColumns(series); + } else { + return getDefaultLineAreaBarColumns(series); + } +} + +function getDefaultScatterColumns([{ data: { cols, rows } }]) { + let dimensions = cols.filter(isDimension); + let metrics = cols.filter(isMetric); + if (dimensions.length === 2 && metrics.length < 2) { + return { + dimensions: [dimensions[0].name], + metrics: [dimensions[1].name], + bubble: metrics.length === 1 ? metrics[0].name : null, + }; + } else { + return { + dimensions: [null], + metrics: [null], + bubble: null, + }; + } +} + +function getDefaultLineAreaBarColumns([{ data: { cols, rows } }]) { + let type = getChartTypeFromData(cols, rows, false); + if (type === DIMENSION_DIMENSION_METRIC) { + let dimensions = [cols[0], cols[1]]; + if (isDate(dimensions[1]) && !isDate(dimensions[0])) { + // if the series dimension is a date but the axis dimension is not then swap them + dimensions.reverse(); + } else if ( + getColumnCardinality(cols, rows, 1) > getColumnCardinality(cols, rows, 0) + ) { + // if the series dimension is higher cardinality than the axis dimension then swap them + dimensions.reverse(); + } + return { + dimensions: dimensions.map(col => col.name), + metrics: [cols[2].name], + }; + } else if (type === DIMENSION_METRIC) { + return { + dimensions: [cols[0].name], + metrics: [cols[1].name], + }; + } else if (type === DIMENSION_METRIC_METRIC) { + return { + dimensions: [cols[0].name], + metrics: cols.slice(1).map(col => col.name), + }; + } + return { + dimensions: [null], + metrics: [null], + }; +} + export const GRAPH_DATA_SETTINGS = { "graph._dimension_filter": { getDefault: ([{ card }]) => diff --git a/frontend/src/metabase/visualizations/lib/settings/nested.js b/frontend/src/metabase/visualizations/lib/settings/nested.js new file mode 100644 index 0000000000000000000000000000000000000000..c28403e9af2f02eaae94428048749d8e283de059 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/nested.js @@ -0,0 +1,169 @@ +/* @flow */ + +import _ from "underscore"; +import { t } from "c-3po"; + +import { getComputedSettings, getSettingsWidgets } from "../settings"; + +import chartSettingNestedSettings from "metabase/visualizations/components/settings/ChartSettingNestedSettings"; + +import type { + SettingId, + SettingDef, + SettingDefs, + Settings, + WidgetDef, + ExtraProps, +} from "metabase/visualizations/lib/settings"; + +import type { Series } from "metabase/meta/types/Visualization"; + +export type NestedObject = any; +export type NestedObjectKey = string; + +type NestedSettingDef = SettingDef & { + objectName: string, + getObjects: (series: Series, settings: Settings) => NestedObject[], + getObjectKey: (object: NestedObject) => string, + getSettingDefintionsForObject: ( + series: Series, + object: NestedObject, + ) => SettingDefs, + getObjectSettingsExtra?: ( + series: Series, + settings: Settings, + object: NestedObject, + ) => { [key: string]: any }, + component: React$Component<any, any, any>, + id?: SettingId, +}; + +export type SettingsWidgetsForObjectGetter = ( + series: Series, + object: NestedObject, + storedSettings: Settings, + onChangeSettings: (newSettings: Settings) => void, + extra: ExtraProps, +) => WidgetDef[]; + +export type NestedObjectKeyGetter = (object: NestedObject) => NestedObjectKey; + +export function nestedSettings( + id: SettingId, + { + objectName = "object", + getObjects, + getObjectKey, + getSettingDefintionsForObject, + getObjectSettingsExtra = () => ({}), + component, + ...def + }: NestedSettingDef = {}, +) { + function getComputedSettingsForObject(series, object, storedSettings, extra) { + const settingsDefs = getSettingDefintionsForObject(series, object); + const computedSettings = getComputedSettings( + settingsDefs, + object, + storedSettings, + extra, + ); + // remove undefined settings since they override other settings when merging object + return _.pick(computedSettings, value => value !== undefined); + } + + function getComputedSettingsForAllObjects( + series, + objects, + allStoredSettings, + extra, + ) { + const allComputedSettings = {}; + for (const object of objects) { + const key = getObjectKey(object); + allComputedSettings[key] = getComputedSettingsForObject( + series, + object, + allStoredSettings[key] || {}, + extra, + ); + } + return allComputedSettings; + } + + function getSettingsWidgetsForObject( + series, + object, + storedSettings, + onChangeSettings, + extra, + ) { + const settingsDefs = getSettingDefintionsForObject(series, object); + const computedSettings = getComputedSettingsForObject( + series, + object, + storedSettings, + extra, + ); + const widgets = getSettingsWidgets( + settingsDefs, + computedSettings, + object, + onChangeSettings, + extra, + ); + return widgets.map(widget => ({ ...widget, noPadding: true })); + } + + // decorate with nested settings HOC + const widget = chartSettingNestedSettings({ + getObjectKey, + getSettingsWidgetsForObject, + })(component); + + return { + [id]: { + section: t`Display`, + default: {}, + getProps: (series: Series, settings: Settings) => { + const objects = getObjects(series, settings); + const allComputedSettings = getComputedSettingsForAllObjects( + series, + objects, + settings[id], + { series, settings }, + ); + return { + series, + settings, + objects, + allComputedSettings, + extra: { series, settings }, + }; + }, + widget, + ...def, + }, + [objectName]: { + getDefault(series: Series, settings: Settings) { + const cache = new Map(); + return (object: NestedObject) => { + const key = getObjectKey(object); + if (!cache.has(key)) { + cache.set(key, { + ...getComputedSettingsForObject( + series, + object, + settings[id][key] || {}, + { series, settings }, + ), + ...getObjectSettingsExtra(series, settings, object), + }); + } + return cache.get(key); + }; + }, + readDependencies: [id], + }, + }; +} diff --git a/frontend/src/metabase/visualizations/lib/settings/utils.js b/frontend/src/metabase/visualizations/lib/settings/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..92bb49d11751e5edfac7b77d4646e79c9f11b0f7 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/utils.js @@ -0,0 +1,120 @@ +import { isDimension, isMetric } from "metabase/lib/schema_metadata"; +import { getFriendlyName } from "metabase/visualizations/lib/utils"; + +export const DIMENSION_METRIC = "DIMENSION_METRIC"; +export const DIMENSION_METRIC_METRIC = "DIMENSION_METRIC_METRIC"; +export const DIMENSION_DIMENSION_METRIC = "DIMENSION_DIMENSION_METRIC"; + +// NOTE Atte Keinänen 7/31/17 Commented MAX_SERIES out as it wasn't being used +// const MAX_SERIES = 10; + +export const isDimensionMetric = (cols, strict = true) => + (!strict || cols.length === 2) && isDimension(cols[0]) && isMetric(cols[1]); + +export const isDimensionDimensionMetric = (cols, strict = true) => + (!strict || cols.length === 3) && + isDimension(cols[0]) && + isDimension(cols[1]) && + isMetric(cols[2]); + +export const isDimensionMetricMetric = (cols, strict = true) => + cols.length >= 3 && + isDimension(cols[0]) && + cols.slice(1).reduce((acc, col) => acc && isMetric(col), true); + +export function getChartTypeFromData(cols, rows, strict = true) { + // this should take precendence for backwards compatibilty + if (isDimensionMetricMetric(cols, strict)) { + return DIMENSION_METRIC_METRIC; + } else if (isDimensionDimensionMetric(cols, strict)) { + // if (getColumnCardinality(cols, rows, 0) < MAX_SERIES || getColumnCardinality(cols, rows, 1) < MAX_SERIES) { + return DIMENSION_DIMENSION_METRIC; + // } + } else if (isDimensionMetric(cols, strict)) { + return DIMENSION_METRIC; + } + return null; +} + +// NOTE Atte Keinänen 8/3/17: Moved from settings.js because this way we +// are able to avoid circular dependency errors in integrated tests +export function columnsAreValid(colNames, data, filter = () => true) { + if (typeof colNames === "string") { + colNames = [colNames]; + } + if (!data || !Array.isArray(colNames)) { + return false; + } + const colsByName = {}; + for (const col of data.cols) { + colsByName[col.name] = col; + } + return colNames.reduce( + (acc, name) => + acc && + (name == undefined || (colsByName[name] && filter(colsByName[name]))), + true, + ); +} + +export function getDefaultDimensionAndMetric([{ data }]) { + const type = data && getChartTypeFromData(data.cols, data.rows, false); + if (type === DIMENSION_METRIC) { + return { + dimension: data.cols[0].name, + metric: data.cols[1].name, + }; + } else if (type === DIMENSION_DIMENSION_METRIC) { + return { + dimension: null, + metric: data.cols[2].name, + }; + } else { + return { + dimension: null, + metric: null, + }; + } +} + +export function getOptionFromColumn(col) { + return { + name: getFriendlyName(col), + value: col.name, + }; +} + +export function metricSetting(id, def = {}) { + return fieldSetting(id, { + fieldFilter: isMetric, + getDefault: series => getDefaultDimensionAndMetric(series).metric, + ...def, + }); +} + +export function dimensionSetting(id, def = {}) { + return fieldSetting(id, { + fieldFilter: isDimension, + getDefault: series => getDefaultDimensionAndMetric(series).dimension, + ...def, + }); +} + +const DEFAULT_FIELD_FILTER = () => true; + +export function fieldSetting( + id, + { fieldFilter = DEFAULT_FIELD_FILTER, ...def } = {}, +) { + return { + [id]: { + widget: "select", + isValid: ([{ card, data }], vizSettings) => + columnsAreValid(card.visualization_settings[id], data, fieldFilter), + getProps: ([{ card, data: { cols } }]) => ({ + options: cols.filter(fieldFilter).map(getOptionFromColumn), + }), + ...def, + }, + }; +} diff --git a/frontend/src/metabase/visualizations/lib/settings/visualization.js b/frontend/src/metabase/visualizations/lib/settings/visualization.js new file mode 100644 index 0000000000000000000000000000000000000000..76a35cde6dfc0e1fbff4d39ad8bfdb0a148e479d --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/visualization.js @@ -0,0 +1,84 @@ +/* @flow */ + +import { + getComputedSettings, + getSettingsWidgets, + getPersistableDefaultSettings, +} from "../settings"; + +import { getVisualizationRaw } from "metabase/visualizations"; +import { t } from "c-3po"; + +import type { Settings, SettingDefs, WidgetDef } from "../settings"; +import type { Series } from "metabase/meta/types/Visualization"; + +const COMMON_SETTINGS = { + "card.title": { + title: t`Title`, + widget: "input", + getDefault: series => (series.length === 1 ? series[0].card.name : null), + dashboard: true, + useRawSeries: true, + }, + "card.description": { + title: t`Description`, + widget: "input", + getDefault: series => + series.length === 1 ? series[0].card.description : null, + dashboard: true, + useRawSeries: true, + }, +}; + +function getSettingDefintionsForSeries(series: ?Series): SettingDefs { + if (!series) { + return {}; + } + const { CardVisualization } = getVisualizationRaw(series); + const definitions = { + ...COMMON_SETTINGS, + ...(CardVisualization.settings || {}), + }; + for (const id in definitions) { + definitions[id].id = id; + } + return definitions; +} + +export function getComputedSettingsForSeries(series: ?Series): Settings { + if (!series) { + return {}; + } + const settingsDefs = getSettingDefintionsForSeries(series); + const [{ card }] = series; + const storedSettings = card.visualization_settings || {}; + return getComputedSettings(settingsDefs, series, storedSettings); +} + +export function getPersistableDefaultSettingsForSeries( + series: ?Series, +): Settings { + // A complete set of settings (not only defaults) is loaded because + // some persistable default settings need other settings as dependency for calculating the default value + const settingsDefs = getSettingDefintionsForSeries(series); + const computedSettings = getComputedSettingsForSeries(series); + return getPersistableDefaultSettings(settingsDefs, computedSettings); +} + +export function getSettingsWidgetsForSeries( + series: ?Series, + onChangeSettings: (settings: Settings) => void, + isDashboard: boolean = false, +): WidgetDef[] { + const settingsDefs = getSettingDefintionsForSeries(series); + const computedSettings = getComputedSettingsForSeries(series); + return getSettingsWidgets( + settingsDefs, + computedSettings, + series, + onChangeSettings, + ).filter( + widget => + widget.dashboard === undefined || widget.dashboard === isDashboard, + ); +} diff --git a/frontend/src/metabase/visualizations/lib/utils.js b/frontend/src/metabase/visualizations/lib/utils.js index bac3243557d2e537daaa35edfead60a6eccb7eba..21107e6aaecf05bcd27aeedadf08e6e90e827c1b 100644 --- a/frontend/src/metabase/visualizations/lib/utils.js +++ b/frontend/src/metabase/visualizations/lib/utils.js @@ -1,6 +1,5 @@ /* @flow weak */ -import React from "react"; import _ from "underscore"; import d3 from "d3"; import { t } from "c-3po"; @@ -259,6 +258,16 @@ export function getColumnCardinality(cols, rows, index) { return cardinalityCache.get(col); } +const extentCache = new WeakMap(); + +export function getColumnExtent(cols, rows, index) { + const col = cols[index]; + if (!extentCache.has(col)) { + extentCache.set(col, d3.extent(rows, row => row[index])); + } + return extentCache.get(col); +} + export function getChartTypeFromData(cols, rows, strict = true) { // this should take precendence for backwards compatibilty if (isDimensionMetricMetric(cols, strict)) { @@ -273,60 +282,6 @@ export function getChartTypeFromData(cols, rows, strict = true) { return null; } -export function enableVisualizationEasterEgg( - code, - OriginalVisualization, - EasterEggVisualization, -) { - if (!code) { - code = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; - } else if (typeof code === "string") { - code = code.split("").map(c => c.charCodeAt(0)); - } - wrapMethod( - OriginalVisualization.prototype, - "componentWillMount", - function easterEgg() { - let keypresses = []; - let enabled = false; - let render_original = this.render; - let render_egg = function() { - return <EasterEggVisualization {...this.props} />; - }; - this._keyListener = e => { - keypresses = keypresses.concat(e.keyCode).slice(-code.length); - if ( - code.reduce( - (ok, value, index) => ok && value === keypresses[index], - true, - ) - ) { - enabled = !enabled; - this.render = enabled ? render_egg : render_original; - this.forceUpdate(); - } - }; - window.addEventListener("keyup", this._keyListener, false); - }, - ); - wrapMethod( - OriginalVisualization.prototype, - "componentWillUnmount", - function cleanupEasterEgg() { - window.removeEventListener("keyup", this._keyListener, false); - }, - ); -} - -function wrapMethod(object, name, method) { - let method_original = object[name]; - object[name] = function() { - method.apply(this, arguments); - if (typeof method_original === "function") { - return method_original.apply(this, arguments); - } - }; -} // TODO Atte Keinänen 5/30/17 Extract to metabase-lib card/question logic export const cardHasBecomeDirty = (nextCard, previousCard) => !_.isEqual(previousCard.dataset_query, nextCard.dataset_query) || @@ -356,3 +311,23 @@ export function getCardAfterVisualizationClick(nextCard, previousCard) { }; } } + +export function getDefaultDimensionAndMetric([{ data }]) { + const type = data && getChartTypeFromData(data.cols, data.rows, false); + if (type === DIMENSION_METRIC) { + return { + dimension: data.cols[0].name, + metric: data.cols[1].name, + }; + } else if (type === DIMENSION_DIMENSION_METRIC) { + return { + dimension: null, + metric: data.cols[2].name, + }; + } else { + return { + dimension: null, + metric: null, + }; + } +} diff --git a/frontend/src/metabase/visualizations/visualizations/Funnel.jsx b/frontend/src/metabase/visualizations/visualizations/Funnel.jsx index 3c81e6b54e267254dbc2b7fe0c932187ae7a9c5c..6a5ed3fc8f792a211e341efdf33bd87fd924311f 100644 --- a/frontend/src/metabase/visualizations/visualizations/Funnel.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Funnel.jsx @@ -9,11 +9,11 @@ import { import { formatValue } from "metabase/lib/formatting"; +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; import { - getSettings, metricSetting, dimensionSetting, -} from "metabase/visualizations/lib/settings"; +} from "metabase/visualizations/lib/settings/utils"; import FunnelNormal from "../components/FunnelNormal"; import FunnelBar from "../components/FunnelBar"; @@ -62,20 +62,18 @@ export default class Funnel extends Component { } static settings = { - "funnel.dimension": { + ...dimensionSetting("funnel.dimension", { section: t`Data`, title: t`Step`, - ...dimensionSetting("funnel.dimension"), dashboard: false, useRawSeries: true, - }, - "funnel.metric": { + }), + ...metricSetting("funnel.metric", { section: t`Data`, title: t`Measure`, - ...metricSetting("funnel.metric"), dashboard: false, useRawSeries: true, - }, + }), "funnel.type": { title: t`Funnel type`, section: t`Display`, @@ -95,7 +93,7 @@ export default class Funnel extends Component { static transformSeries(series) { let [{ card, data: { rows, cols } }] = series; - const settings = getSettings(series); + const settings = getComputedSettingsForSeries(series); const dimensionIndex = _.findIndex( cols, diff --git a/frontend/src/metabase/visualizations/visualizations/Map.jsx b/frontend/src/metabase/visualizations/visualizations/Map.jsx index 054240146c320be91f4d4158f4a7bab9d2a3bfba..0e4e270e007e6291a153bb3cea95e3b2da66abc7 100644 --- a/frontend/src/metabase/visualizations/visualizations/Map.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Map.jsx @@ -14,15 +14,14 @@ import { isState, isCountry, } from "metabase/lib/schema_metadata"; +import { isSameSeries } from "metabase/visualizations/lib/utils"; import { metricSetting, dimensionSetting, fieldSetting, -} from "metabase/visualizations/lib/settings"; +} from "metabase/visualizations/lib/settings/utils"; import MetabaseSettings from "metabase/lib/settings"; -import { isSameSeries } from "metabase/visualizations/lib/utils"; - import _ from "underscore"; const PIN_MAP_TYPES = new Set(["pin", "heat", "grid"]); @@ -114,34 +113,29 @@ export default class Map extends Component { getHidden: (series, vizSettings) => !PIN_MAP_TYPES.has(vizSettings["map.type"]), }, - "map.latitude_column": { + ...fieldSetting("map.latitude_column", { title: t`Latitude field`, - ...fieldSetting( - "map.latitude_column", - isNumeric, - ([{ data: { cols } }]) => (_.find(cols, isLatitude) || {}).name, - ), + fieldFilter: isNumeric, + getDefault: ([{ data: { cols } }]) => + (_.find(cols, isLatitude) || {}).name, getHidden: (series, vizSettings) => !PIN_MAP_TYPES.has(vizSettings["map.type"]), - }, - "map.longitude_column": { + }), + ...fieldSetting("map.longitude_column", { title: t`Longitude field`, - ...fieldSetting( - "map.longitude_column", - isNumeric, - ([{ data: { cols } }]) => (_.find(cols, isLongitude) || {}).name, - ), + fieldFilter: isNumeric, + getDefault: ([{ data: { cols } }]) => + (_.find(cols, isLongitude) || {}).name, getHidden: (series, vizSettings) => !PIN_MAP_TYPES.has(vizSettings["map.type"]), - }, - "map.metric_column": { + }), + ...metricSetting("map.metric_column", { title: t`Metric field`, - ...metricSetting("map.metric_column"), getHidden: (series, vizSettings) => !PIN_MAP_TYPES.has(vizSettings["map.type"]) || (vizSettings["map.pin_type"] !== "heat" && vizSettings["map.pin_type"] !== "grid"), - }, + }), "map.region": { title: t`Region map`, widget: "select", @@ -161,17 +155,15 @@ export default class Map extends Component { }), getHidden: (series, vizSettings) => vizSettings["map.type"] !== "region", }, - "map.metric": { + ...metricSetting("map.metric", { title: t`Metric field`, - ...metricSetting("map.metric"), getHidden: (series, vizSettings) => vizSettings["map.type"] !== "region", - }, - "map.dimension": { + }), + ...dimensionSetting("map.dimension", { title: t`Region field`, widget: "select", - ...dimensionSetting("map.dimension"), getHidden: (series, vizSettings) => vizSettings["map.type"] !== "region", - }, + }), "map.zoom": {}, "map.center_latitude": {}, "map.center_longitude": {}, diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx index 0a25e0177c147897484ed04ca3f7d43fdf88232f..96d048ec39b4d6c65657679bf270107919080e2f 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart.jsx @@ -12,7 +12,7 @@ import { getFriendlyName } from "metabase/visualizations/lib/utils"; import { metricSetting, dimensionSetting, -} from "metabase/visualizations/lib/settings"; +} from "metabase/visualizations/lib/settings/utils"; import { formatValue } from "metabase/lib/formatting"; @@ -57,16 +57,14 @@ export default class PieChart extends Component { } static settings = { - "pie.dimension": { + ...dimensionSetting("pie.dimension", { section: t`Data`, title: t`Dimension`, - ...dimensionSetting("pie.dimension"), - }, - "pie.metric": { + }), + ...metricSetting("pie.metric", { section: t`Data`, title: t`Measure`, - ...metricSetting("pie.metric"), - }, + }), "pie.show_legend": { section: t`Display`, title: t`Show legend`, diff --git a/frontend/src/metabase/visualizations/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/visualizations/Progress.jsx index a93144d0012324dd08ab1d86e9bab2ae959405ca..39d48a26c53dffa772915d02e5181c9e7ca1d6c5 100644 --- a/frontend/src/metabase/visualizations/visualizations/Progress.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Progress.jsx @@ -112,6 +112,7 @@ export default class Progress extends Component { } = this.props; const value: number = rows[0] && typeof rows[0][0] === "number" ? rows[0][0] : 0; + const column = cols[0]; const goal = settings["progress.goal"] || 0; const mainColor = settings["progress.color"]; @@ -138,10 +139,7 @@ export default class Progress extends Component { barMessage = t`Goal exceeded`; } - const clicked = { - value: value, - column: cols[0], - }; + const clicked = { value, column }; const isClickable = visualizationIsClickable(clicked); return ( @@ -156,7 +154,7 @@ export default class Progress extends Component { style={{ height: 20 }} > <div ref="label" style={{ position: "absolute" }}> - {formatValue(value, { comma: true })} + {formatValue(value, { column })} </div> </div> <div className="relative" style={{ height: 10, marginBottom: 5 }}> @@ -206,7 +204,7 @@ export default class Progress extends Component { <div className="mt1"> <span className="float-left">0</span> <span className="float-right">{t`Goal ${formatValue(goal, { - comma: true, + column, })}`}</span> </div> </div> diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index 3547e6e98977e56e7a75b8d25ef52f700799e3ba..7e0ed33454a3ee4f87e6d8bbdfd5cc72a8eae54d 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -9,12 +9,26 @@ import Ellipsified from "metabase/components/Ellipsified.jsx"; import { formatValue } from "metabase/lib/formatting"; import { TYPE } from "metabase/lib/types"; -import { isNumber } from "metabase/lib/schema_metadata"; + +import { fieldSetting } from "metabase/visualizations/lib/settings/utils"; +import { columnSettings } from "metabase/visualizations/lib/settings/column"; import cx from "classnames"; -import d3 from "d3"; +import _ from "underscore"; import type { VisualizationProps } from "metabase/meta/types/Visualization"; +import type { Column } from "metabase/meta/types/Dataset"; +import type { VisualizationSettings } from "metabase/meta/types/Card"; + +// convert legacy `scalar.*` visualization settings to format options +function legacyScalarSettingsToFormatOptions(settings) { + return _.chain(settings) + .pairs() + .filter(([key, value]) => key.startsWith("scalar.") && value !== undefined) + .map(([key, value]) => [key.replace(/^scalar\./, ""), value]) + .object() + .value(); +} export default class Scalar extends Component { props: VisualizationProps; @@ -71,37 +85,57 @@ export default class Scalar extends Component { } static settings = { + ...fieldSetting("scalar.field", { + title: t`Field to show`, + getDefault: ([{ data: { cols } }]) => cols[0].name, + getHidden: ([{ data: { cols } }]) => cols.length < 2, + }), + ...columnSettings({ + getColumns: ([{ data: { cols } }], settings) => [ + _.find(cols, col => col.name === settings["scalar.field"]) || cols[0], + ], + readDependencies: ["scalar.field"], + }), + // LEGACY scalar settings, now handled by column level settings "scalar.locale": { - title: t`Separator style`, - widget: "select", - props: { - options: [ - { name: "100000.00", value: null }, - { name: "100,000.00", value: "en" }, - { name: "100 000,00", value: "fr" }, - { name: "100.000,00", value: "de" }, - ], - }, - default: "en", + // title: t`Separator style`, + // widget: "select", + // props: { + // options: [ + // { name: "100000.00", value: null }, + // { name: "100,000.00", value: "en" }, + // { name: "100 000,00", value: "fr" }, + // { name: "100.000,00", value: "de" }, + // ], + // }, + // default: "en", }, "scalar.decimals": { - title: t`Number of decimal places`, - widget: "number", + // title: t`Number of decimal places`, + // widget: "number", }, "scalar.prefix": { - title: t`Add a prefix`, - widget: "input", + // title: t`Add a prefix`, + // widget: "input", }, "scalar.suffix": { - title: t`Add a suffix`, - widget: "input", + // title: t`Add a suffix`, + // widget: "input", }, "scalar.scale": { - title: t`Multiply by a number`, - widget: "number", + // title: t`Multiply by a number`, + // widget: "number", }, }; + _getColumnIndex(cols: Column[], settings: VisualizationSettings) { + const columnIndex = _.findIndex( + cols, + col => col.name === settings["scalar.field"], + ); + return columnIndex < 0 ? 0 : columnIndex; + } + render() { let { series: [{ card, data: { cols, rows } }], @@ -116,78 +150,23 @@ export default class Scalar extends Component { let description = settings["card.description"]; let isSmall = gridSize && gridSize.width < 4; - const column = cols[0]; - - let scalarValue = rows[0] && rows[0][0]; - if (scalarValue == null) { - scalarValue = ""; - } - - let compactScalarValue, fullScalarValue; - - // TODO: some or all of these options should be part of formatValue - if (typeof scalarValue === "number" && isNumber(column)) { - // scale - const scale = parseFloat(settings["scalar.scale"]); - if (!isNaN(scale)) { - scalarValue *= scale; - } - - const localeStringOptions = {}; - // decimals - let decimals = parseFloat(settings["scalar.decimals"]); - if (!isNaN(decimals)) { - scalarValue = d3.round(scalarValue, decimals); - localeStringOptions.minimumFractionDigits = decimals; - } + const columnIndex = this._getColumnIndex(cols, settings); + const value = rows[0] && rows[0][columnIndex]; + const column = cols[columnIndex]; - let number = scalarValue; - - // currency - if (settings["scalar.currency"] != null) { - localeStringOptions.style = "currency"; - localeStringOptions.currency = settings["scalar.currency"]; - } - - try { - // format with separators and correct number of decimals - if (settings["scalar.locale"]) { - number = number.toLocaleString( - settings["scalar.locale"], - localeStringOptions, - ); - } else { - // HACK: no locales that don't thousands separators? - number = number - .toLocaleString("en", localeStringOptions) - .replace(/,/g, ""); - } - } catch (e) { - console.warn("error formatting scalar", e); - } - fullScalarValue = formatValue(number, { column: column }); - } else { - fullScalarValue = formatValue(scalarValue, { column: column }); - } + const formatOptions = { + ...legacyScalarSettingsToFormatOptions(settings), + ...settings.column(column), + jsx: true, + }; - compactScalarValue = isSmall - ? formatValue(scalarValue, { column: column, compact: true }) + const fullScalarValue = formatValue(value, formatOptions); + const compactScalarValue = isSmall + ? formatValue(value, { ...formatOptions, compact: true }) : fullScalarValue; - if (settings["scalar.prefix"]) { - compactScalarValue = settings["scalar.prefix"] + compactScalarValue; - fullScalarValue = settings["scalar.prefix"] + fullScalarValue; - } - if (settings["scalar.suffix"]) { - compactScalarValue = compactScalarValue + settings["scalar.suffix"]; - fullScalarValue = fullScalarValue + settings["scalar.suffix"]; - } - - const clicked = { - value: rows[0] && rows[0][0], - column: cols[0], - }; + const clicked = { value, column }; const isClickable = visualizationIsClickable(clicked); return ( diff --git a/frontend/src/metabase/visualizations/visualizations/Table.jsx b/frontend/src/metabase/visualizations/visualizations/Table.jsx index 48f2aa751795c54eb21b6a8923627b38337fd4c2..56c586f446031d314b29626c615962b66ca8b064 100644 --- a/frontend/src/metabase/visualizations/visualizations/Table.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Table.jsx @@ -7,9 +7,19 @@ import TableSimple from "../components/TableSimple.jsx"; import { t } from "c-3po"; import * as DataGrid from "metabase/lib/data_grid"; import { findColumnIndexForColumnSetting } from "metabase/lib/dataset"; +import { formatColumn } from "metabase/lib/formatting"; import Query from "metabase/lib/query"; -import { isMetric, isDimension } from "metabase/lib/schema_metadata"; +import { + isMetric, + isDimension, + isNumber, + isString, + isURL, + isEmail, + isImageURL, + isAvatarURL, +} from "metabase/lib/schema_metadata"; import { columnsAreValid } from "metabase/visualizations/lib/utils"; import ChartSettingOrderedColumns from "metabase/visualizations/components/settings/ChartSettingOrderedColumns.jsx"; import ChartSettingsTableFormatting, { @@ -17,6 +27,7 @@ import ChartSettingsTableFormatting, { } from "metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx"; import { makeCellBackgroundGetter } from "metabase/visualizations/lib/table_format"; +import { columnSettings } from "metabase/visualizations/lib/settings/column"; import _ from "underscore"; import cx from "classnames"; @@ -26,6 +37,7 @@ import { getIn } from "icepick"; import type { DatasetData } from "metabase/meta/types/Dataset"; import type { Card, VisualizationSettings } from "metabase/meta/types/Card"; +import type { SettingDefs } from "metabase/visualizations/lib/settings"; type Props = { card: Card, @@ -55,9 +67,10 @@ export default class Table extends Component { // scalar can always be rendered, nothing needed here } - static settings = { + static settings: SettingDefs = { + ...columnSettings({ hidden: true }), "table.pivot": { - section: t`Data`, + section: t`Columns`, title: t`Pivot the table`, widget: "toggle", getHidden: ([{ card, data }]) => data && data.cols.length !== 3, @@ -69,8 +82,8 @@ export default class Table extends Component { data.cols.filter(isDimension).length === 2, }, "table.columns": { - section: t`Data`, - title: t`Visible fields`, + section: t`Columns`, + title: t`Visible columns`, widget: ChartSettingOrderedColumns, getHidden: (series, vizSettings) => vizSettings["table.pivot"], isValid: ([{ card, data }]) => @@ -90,7 +103,7 @@ export default class Table extends Component { }, "table.column_widths": {}, "table.column_formatting": { - section: t`Formatting`, + section: t`Conditional Formatting`, widget: ChartSettingsTableFormatting, default: [], getProps: ([{ data: { cols } }], settings) => ({ @@ -109,6 +122,65 @@ export default class Table extends Component { }, }; + static columnSettings = column => { + const settings: SettingDefs = { + column_title: { + title: t`Column title`, + widget: "input", + getDefault: column => formatColumn(column), + }, + }; + if (isNumber(column)) { + settings["show_mini_bar"] = { + title: t`Show a mini bar chart`, + widget: "toggle", + }; + } + if (isString(column)) { + let defaultValue = null; + const options: { name: string, value: null | string }[] = [ + { name: t`Off`, value: null }, + ]; + if (!column.special_type || isURL(column)) { + defaultValue = "link"; + options.push({ name: t`Link`, value: "link" }); + } + if (!column.special_type || isEmail(column)) { + defaultValue = "email_link"; + options.push({ name: t`Email link`, value: "email_link" }); + } + if (!column.special_type || isImageURL(column) || isAvatarURL(column)) { + defaultValue = isAvatarURL(column) ? "image" : "link"; + options.push({ name: t`Image`, value: "image" }); + } + if (!column.special_type) { + defaultValue = "auto"; + options.push({ name: t`Automatic`, value: "auto" }); + } + + if (options.length > 1) { + settings["view_as"] = { + title: t`View as link or image`, + widget: "select", + default: defaultValue, + props: { + options, + }, + }; + } + + settings["link_text"] = { + title: t`Link text`, + widget: "input", + default: null, + getHidden: (column, settings) => + settings["view_as"] !== "link" && + settings["view_as"] !== "email_link", + }; + } + return settings; + }; + constructor(props: Props) { super(props); diff --git a/frontend/test/lib/formatting.unit.spec.js b/frontend/test/lib/formatting.unit.spec.js index 8c117322293e3a8e5cf8a170b8b1fa556f2644cd..d3084378341b116dd6ebbcbe64ec0d922bf1c7c6 100644 --- a/frontend/test/lib/formatting.unit.spec.js +++ b/frontend/test/lib/formatting.unit.spec.js @@ -40,6 +40,18 @@ describe("formatting", () => { expect(formatNumber(1111, { compact: true })).toEqual("1.1k"); }); }); + it("should format to correct number of decimal places", () => { + expect(formatNumber(0.1)).toEqual("0.1"); + expect(formatNumber(0.11)).toEqual("0.11"); + expect(formatNumber(0.111)).toEqual("0.11"); + expect(formatNumber(0.01)).toEqual("0.01"); + expect(formatNumber(0.011)).toEqual("0.011"); + expect(formatNumber(0.0111)).toEqual("0.011"); + expect(formatNumber(1.1)).toEqual("1.1"); + expect(formatNumber(1.11)).toEqual("1.11"); + expect(formatNumber(1.111)).toEqual("1.11"); + expect(formatNumber(111.111)).toEqual("111.11"); + }); }); describe("formatValue", () => { diff --git a/frontend/test/visualizations/components/LineAreaBarRenderer.unit.spec.js b/frontend/test/visualizations/components/LineAreaBarRenderer.unit.spec.js index ff003319c4c2c37164b5bc3e9fe5b362b985bc39..0fa682ab7f8be475ea50c7e23db555da9229ffb1 100644 --- a/frontend/test/visualizations/components/LineAreaBarRenderer.unit.spec.js +++ b/frontend/test/visualizations/components/LineAreaBarRenderer.unit.spec.js @@ -124,16 +124,16 @@ describe("LineAreaBarRenderer", () => { formatValue(rows[0][0], { column: DateTimeColumn({ unit: "hour" }), }), - ).toEqual("1 AM - January 1, 2016"); + ).toEqual("January 1, 2016, 1:00 AM"); expect( formatValue(hover.data[0].value, { column: hover.data[0].col }), - ).toEqual("1 AM - January 1, 2016"); + ).toEqual("January 1, 2016, 1:00 AM"); expect(qsa(".axis.x .tick text").map(e => e.textContent)).toEqual([ - "1 AM - January 1, 2016", - "2 AM - January 1, 2016", - "3 AM - January 1, 2016", - "4 AM - January 1, 2016", + "January 1, 2016, 1:00 AM", + "January 1, 2016, 2:00 AM", + "January 1, 2016, 3:00 AM", + "January 1, 2016, 4:00 AM", ]); resolve(); diff --git a/frontend/test/visualizations/lib/settings.unit.spec.js b/frontend/test/visualizations/lib/settings.unit.spec.js index f05ba1cc843b768545e4397542b11ce825fd773f..c61438e62348e7579627de36166592fe8f85ed41 100644 --- a/frontend/test/visualizations/lib/settings.unit.spec.js +++ b/frontend/test/visualizations/lib/settings.unit.spec.js @@ -1,84 +1,174 @@ // NOTE: need to load visualizations first for getSettings to work import "metabase/visualizations/index"; -import { getSettings } from "metabase/visualizations/lib/settings"; +import { + getComputedSettings, + getSettingsWidgets, +} from "metabase/visualizations/lib/settings"; -import { DateTimeColumn, NumberColumn } from "../__support__/visualizations"; +describe("settings framework", () => { + const mockObject = "[mockObject]"; -describe("visualization_settings", () => { - describe("getSettings", () => { - describe("stackable.stack_type", () => { - it("should default to unstacked stacked", () => { - const settings = getSettings( - cardWithTimeseriesBreakout({ unit: "month" }), - ); - expect(settings["stackable.stack_type"]).toBe(null); - }); - it("should default area chart to stacked for 1 dimensions and 2 metrics", () => { - const settings = getSettings( - cardWithTimeseriesBreakoutAndTwoMetrics({ - display: "area", - unit: "month", - }), - ); - expect(settings["stackable.stack_type"]).toBe("stacked"); - }); - }); - describe("graph.x_axis._is_histogram", () => { - // NOTE: currently datetimes with unit are never considered histograms - const HISTOGRAM_UNITS = []; - const NON_HISTOGRAM_UNITS = [ - // definitely not histogram - "day-of-week", - "month-of-year", - "quarter-of-year", - // arguably histogram but diabled for now - "minute-of-hour", - "hour-of-day", - "day-of-month", - "day-of-year", - "week-of-year", - ]; - describe("non-histgram units", () => - NON_HISTOGRAM_UNITS.map(unit => - it(`should default ${unit} to false`, () => { - const settings = getSettings(cardWithTimeseriesBreakout({ unit })); - expect(settings["graph.x_axis._is_histogram"]).toBe(false); - }), - )); - describe("histgram units", () => - HISTOGRAM_UNITS.map(unit => - it(`should default ${unit} to true`, () => { - const settings = getSettings(cardWithTimeseriesBreakout({ unit })); - expect(settings["graph.x_axis._is_histogram"]).toBe(true); - }), - )); + describe("getComputedSettings", () => { + it("should return stored settings for setting definitions", () => { + const defs = { foo: {} }; + const stored = { foo: "foo" }; + const expected = { foo: "foo" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should not return stored settings for settings without setting definition ", () => { + const defs = {}; + const stored = { foo: "foo" }; + const expected = {}; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should use `default` if no stored setting", () => { + const defs = { foo: { default: "foo" } }; + const stored = {}; + const expected = { foo: "foo" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should use `getDefault` if no stored setting", () => { + const defs = { foo: { getDefault: () => "foo" } }; + const stored = {}; + const expected = { foo: "foo" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should use `getValue` if provided", () => { + const defs = { foo: { getValue: () => "bar" } }; + const stored = { foo: "foo" }; + const expected = { foo: "bar" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should use default if `isValid` returns false", () => { + const defs = { foo: { default: "bar", isValid: () => false } }; + const stored = { foo: "foo" }; + const expected = { foo: "bar" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should use stored value if `isValid` returns true", () => { + const defs = { foo: { default: "bar", isValid: () => true } }; + const stored = { foo: "foo" }; + const expected = { foo: "foo" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + }); + it("should compute readDependencies first if provided", () => { + const getDefault = jest.fn().mockReturnValue("foo"); + const defs = { + foo: { getDefault, readDependencies: ["bar"] }, + bar: { default: "bar" }, + }; + const stored = {}; + const expected = { foo: "foo", bar: "bar" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + expect(getDefault.mock.calls[0]).toEqual([ + mockObject, + { bar: "bar" }, + {}, + ]); + }); + it("should pass the provided object to getDefault", () => { + const getDefault = jest.fn().mockReturnValue("foo"); + const defs = { foo: { getDefault } }; + const stored = {}; + const expected = { foo: "foo" }; + expect(getComputedSettings(defs, mockObject, stored)).toEqual(expected); + expect(getDefault.mock.calls[0]).toEqual([mockObject, {}, {}]); }); }); -}); -const cardWithTimeseriesBreakout = ({ unit, display = "bar" }) => [ - { - card: { - display: display, - visualization_settings: {}, - }, - data: { - cols: [DateTimeColumn({ unit }), NumberColumn()], - rows: [[0, 0]], - }, - }, -]; + describe("getSettingsWidgets", () => { + it("should return widget", () => { + const defs = { foo: { title: "Foo", widget: "input" } }; + const stored = { foo: "foo" }; + const widgets = getSettingsWidgets(defs, stored, mockObject, () => {}); + widgets.map(deleteFunctions); + expect(widgets).toEqual([ + { + id: "foo", + title: "Foo", + disabled: false, + hidden: false, + props: {}, + value: "foo", + }, + ]); + }); + it("should return disabled widget when `disabled` is true", () => { + const defs = { foo: { widget: "input", disabled: true } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].disabled).toEqual(true); + }); + it("should return disabled widget when `getDisabled` returns true", () => { + const getDisabled = jest.fn().mockReturnValue(true); + const defs = { foo: { widget: "input", getDisabled } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].disabled).toEqual(true); + expect(getDisabled.mock.calls).toEqual([[mockObject, {}, {}]]); + }); + it("should return hidden widget when `hidden` is true", () => { + const defs = { foo: { widget: "input", hidden: true } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].hidden).toEqual(true); + }); + it("should return hidden widget when `getHidden` returns true", () => { + const getHidden = jest.fn().mockReturnValue(true); + const defs = { foo: { widget: "input", getHidden } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].hidden).toEqual(true); + expect(getHidden.mock.calls).toEqual([[mockObject, {}, {}]]); + }); + it("should return props when `props` is provided", () => { + const defs = { foo: { widget: "input", props: { hello: "world" } } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].props).toEqual({ hello: "world" }); + }); + it("should compute props when `getProps` is provided", () => { + const getProps = jest.fn().mockReturnValue({ hello: "world" }); + const defs = { foo: { widget: "input", getProps } }; + const widgets = getSettingsWidgets(defs, {}, mockObject, () => {}); + expect(widgets[0].props).toEqual({ hello: "world" }); + // expect(getProps.mock.calls).toEqual([[null, {}, FIXME, {}]]); + }); + it("should call onChangeSettings with new value", () => { + const defs = { foo: { widget: "input" } }; + const stored = { foo: "foo" }; + const onChangeSettings = jest.fn(); + const widgets = getSettingsWidgets( + defs, + stored, + mockObject, + onChangeSettings, + ); + widgets[0].onChange("bar"); + expect(onChangeSettings.mock.calls).toEqual([[{ foo: "bar" }]]); + }); + // FIXME: is writeDependencies broken or is this test wrong? + xit("should include writeDependencies in onChangeSettings", () => { + const defs = { + foo: { widget: "input", writeDependencies: ["bar"] }, + bar: { default: "foo" }, + }; + const stored = { foo: "foo" }; + const onChangeSettings = jest.fn(); + const widgets = getSettingsWidgets( + defs, + stored, + mockObject, + onChangeSettings, + ); + widgets[0].onChange("bar"); + expect(onChangeSettings.mock.calls).toEqual([ + [{ foo: "bar", bar: "bar" }], + ]); + }); + }); +}); -const cardWithTimeseriesBreakoutAndTwoMetrics = ({ unit, display = "bar" }) => [ - { - card: { - display: display, - visualization_settings: {}, - }, - data: { - cols: [DateTimeColumn({ unit }), NumberColumn(), NumberColumn()], - rows: [[0, 0, 0]], - }, - }, -]; +function deleteFunctions(object) { + for (const property in object) { + if (typeof object[property] === "function") { + delete object[property]; + } + } +} diff --git a/frontend/test/visualizations/lib/settings/nested.unit.spec.js b/frontend/test/visualizations/lib/settings/nested.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..10693aa2a971aabce46952dc42da01b8b365caba --- /dev/null +++ b/frontend/test/visualizations/lib/settings/nested.unit.spec.js @@ -0,0 +1,27 @@ +import { nestedSettings } from "metabase/visualizations/lib/settings/nested"; +import { getComputedSettings } from "metabase/visualizations/lib/settings"; + +describe("nestedSettings", () => { + it("should add a nested setting function to settings", () => { + const defs = { + ...nestedSettings("nested_settings", { + objectName: "nested", + getObjects: () => [1, 2, 3], + getObjectKey: object => String(object), + getSettingDefintionsForObject: () => ({ + foo: { getDefault: object => `foo${object}` }, + }), + }), + }; + const stored = { nested_settings: { "1": { foo: "bar" } } }; + const settings = getComputedSettings(defs, null, stored); + expect(settings.nested(1)).toEqual({ foo: "bar" }); + expect(settings.nested(2)).toEqual({ foo: "foo2" }); + + delete settings.nested; + expect(settings).toEqual({ + nested: undefined, + nested_settings: { "1": { foo: "bar" } }, + }); + }); +}); diff --git a/frontend/test/visualizations/lib/settings/visualization.unit.spec.js b/frontend/test/visualizations/lib/settings/visualization.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f2740d5236a6602102b37c87ee1406ba596b3227 --- /dev/null +++ b/frontend/test/visualizations/lib/settings/visualization.unit.spec.js @@ -0,0 +1,88 @@ +// NOTE: need to load visualizations first for getSettings to work +import "metabase/visualizations/index"; + +import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; + +import { DateTimeColumn, NumberColumn } from "../../__support__/visualizations"; + +describe("visualization_settings", () => { + describe("getComputedSettingsForSeries", () => { + describe("stackable.stack_type", () => { + it("should default to unstacked stacked", () => { + const settings = getComputedSettingsForSeries( + cardWithTimeseriesBreakout({ unit: "month" }), + ); + expect(settings["stackable.stack_type"]).toBe(null); + }); + it("should default area chart to stacked for 1 dimensions and 2 metrics", () => { + const settings = getComputedSettingsForSeries( + cardWithTimeseriesBreakoutAndTwoMetrics({ + display: "area", + unit: "month", + }), + ); + expect(settings["stackable.stack_type"]).toBe("stacked"); + }); + }); + describe("graph.x_axis._is_histogram", () => { + // NOTE: currently datetimes with unit are never considered histograms + const HISTOGRAM_UNITS = []; + const NON_HISTOGRAM_UNITS = [ + // definitely not histogram + "day-of-week", + "month-of-year", + "quarter-of-year", + // arguably histogram but diabled for now + "minute-of-hour", + "hour-of-day", + "day-of-month", + "day-of-year", + "week-of-year", + ]; + describe("non-histgram units", () => + NON_HISTOGRAM_UNITS.map(unit => + it(`should default ${unit} to false`, () => { + const settings = getComputedSettingsForSeries( + cardWithTimeseriesBreakout({ unit }), + ); + expect(settings["graph.x_axis._is_histogram"]).toBe(false); + }), + )); + describe("histgram units", () => + HISTOGRAM_UNITS.map(unit => + it(`should default ${unit} to true`, () => { + const settings = getComputedSettingsForSeries( + cardWithTimeseriesBreakout({ unit }), + ); + expect(settings["graph.x_axis._is_histogram"]).toBe(true); + }), + )); + }); + }); +}); + +const cardWithTimeseriesBreakout = ({ unit, display = "bar" }) => [ + { + card: { + display: display, + visualization_settings: {}, + }, + data: { + cols: [DateTimeColumn({ unit }), NumberColumn()], + rows: [[0, 0]], + }, + }, +]; + +const cardWithTimeseriesBreakoutAndTwoMetrics = ({ unit, display = "bar" }) => [ + { + card: { + display: display, + visualization_settings: {}, + }, + data: { + cols: [DateTimeColumn({ unit }), NumberColumn(), NumberColumn()], + rows: [[0, 0, 0]], + }, + }, +]; diff --git a/package.json b/package.json index 454928b50a96867c30bb128df60e0d51caea3822..f7f30df6eed65e213693f209ff7e0fd683fb225a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "leaflet.heat": "^0.2.0", "lodash.memoize": "^4.1.2", "moment": "2.19.3", + "mustache": "^2.3.2", "node-libs-browser": "^2.0.0", "normalizr": "^3.0.2", "npm": "^5.8.0", diff --git a/src/metabase/types.clj b/src/metabase/types.clj index 8680cf16da7774286918d53907deab6b5edf468b..1708b089f2eadc49684665d45bba0d8797433ffe 100644 --- a/src/metabase/types.clj +++ b/src/metabase/types.clj @@ -57,8 +57,8 @@ (derive :type/UUID :type/Text) (derive :type/URL :type/Text) -(derive :type/AvatarURL :type/URL) (derive :type/ImageURL :type/URL) +(derive :type/AvatarURL :type/ImageURL) (derive :type/Email :type/Text) diff --git a/yarn.lock b/yarn.lock index 1a7e08ab4608cc626f858b2a9893cdde5da7323c..e3777a20bb1867f39961976ef5e77aec9010beaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7185,6 +7185,10 @@ multicast-dns@^6.0.1: dns-packet "^1.0.1" thunky "^0.1.0" +mustache@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-2.3.2.tgz#a6d4d9c3f91d13359ab889a812954f9230a3d0c5" + mute-stream@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"