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"