Skip to content
Snippets Groups Projects
Unverified Commit 1694be29 authored by Tom Robinson's avatar Tom Robinson Committed by GitHub
Browse files

Merge pull request #8479 from metabase/formatting-refactor

Column settings, mini bar charts, and more
parents 9464e002 b94d06ce
No related branches found
No related tags found
No related merge requests found
Showing
with 704 additions and 187 deletions
/* @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;
......@@ -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}
/>
......
......@@ -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
......
......@@ -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
......
/* @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;
}
}
......@@ -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 {
......
......@@ -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) {
......
......@@ -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,
};
......
/* @flow */
// "Flux standard action" style redux action
export type ReduxAction = { type: string, payload: any, error?: boolean };
/* @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) },
},
}),
},
];
};
......@@ -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,
];
......@@ -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;
......
......@@ -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 {
......
......@@ -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,
},
);
......
......@@ -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),
);
/* @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",
......
......@@ -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">
......
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;
......@@ -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,
......
......@@ -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)} %`;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment