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

Merge pull request #8544 from metabase/currency-metadata

Currency metadata
parents d9d94ddb 7531be5f
No related branches found
No related tags found
No related merge requests found
Showing
with 2164 additions and 621 deletions
import React from "react";
import { t } from "c-3po";
import _ from "underscore";
import cx from "classnames";
import SelectButton from "metabase/components/SelectButton";
import Select from "metabase/components/Select";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
import FieldList from "metabase/query_builder/components/FieldList";
import InputBlurChange from "metabase/components/InputBlurChange";
import ButtonWithStatus from "metabase/components/ButtonWithStatus";
import SelectSeparator from "../components/SelectSeparator";
import MetabaseAnalytics from "metabase/lib/analytics";
import { DatetimeFieldDimension } from "metabase-lib/lib/Dimension";
import Question from "metabase-lib/lib/Question";
const MAP_OPTIONS = {
original: { type: "original", name: t`Use original value` },
foreign: { type: "foreign", name: t`Use foreign key` },
custom: { type: "custom", name: t`Custom mapping` },
};
export default class FieldRemapping extends React.Component {
state = {
isChoosingInitialFkTarget: false,
dismissedInitialFkTargetPopover: false,
};
constructor(props, context) {
super(props, context);
}
getMappingTypeForField = field => {
if (this.state.isChoosingInitialFkTarget) {
return MAP_OPTIONS.foreign;
}
if (_.isEmpty(field.dimensions)) {
return MAP_OPTIONS.original;
}
if (field.dimensions.type === "external") {
return MAP_OPTIONS.foreign;
}
if (field.dimensions.type === "internal") {
return MAP_OPTIONS.custom;
}
throw new Error(t`Unrecognized mapping type`);
};
getAvailableMappingTypes = () => {
const { field } = this.props;
const hasForeignKeys =
field.special_type === "type/FK" && this.getForeignKeys().length > 0;
// Only show the "custom" option if we have some values that can be mapped to user-defined custom values
// (for a field without user-defined remappings, every key of `field.remappings` has value `undefined`)
const hasMappableNumeralValues =
field.remapping.size > 0 &&
[...field.remapping.keys()].every(key => typeof key === "number");
return [
MAP_OPTIONS.original,
...(hasForeignKeys ? [MAP_OPTIONS.foreign] : []),
...(hasMappableNumeralValues > 0 ? [MAP_OPTIONS.custom] : []),
];
};
getFKTargetTableEntityNameOrNull = () => {
const fks = this.getForeignKeys();
const fkTargetFields = fks[0] && fks[0].dimensions.map(dim => dim.field());
if (fkTargetFields) {
// TODO Atte Keinänen 7/11/17: Should there be `isName(field)` in Field.js?
const nameField = fkTargetFields.find(
field => field.special_type === "type/Name",
);
return nameField ? nameField.id : null;
} else {
throw new Error(
t`Current field isn't a foreign key or FK target table metadata is missing`,
);
}
};
clearEditingStates = () => {
this.setState({
isChoosingInitialFkTarget: false,
dismissedInitialFkTargetPopover: false,
});
};
onSetMappingType = async mappingType => {
const {
table,
field,
fetchTableMetadata,
updateFieldDimension,
deleteFieldDimension,
} = this.props;
this.clearEditingStates();
if (mappingType.type === "original") {
MetabaseAnalytics.trackEvent(
"Data Model",
"Change Remapping Type",
"No Remapping",
);
await deleteFieldDimension(field.id);
this.setState({ hasChanged: false });
} else if (mappingType.type === "foreign") {
// Try to find a entity name field from target table and choose it as remapping target field if it exists
const entityNameFieldId = this.getFKTargetTableEntityNameOrNull();
if (entityNameFieldId) {
MetabaseAnalytics.trackEvent(
"Data Model",
"Change Remapping Type",
"Foreign Key",
);
await updateFieldDimension(field.id, {
type: "external",
name: field.display_name,
human_readable_field_id: entityNameFieldId,
});
} else {
// Enter a special state where we are choosing an initial value for FK target
this.setState({
hasChanged: true,
isChoosingInitialFkTarget: true,
});
}
} else if (mappingType.type === "custom") {
MetabaseAnalytics.trackEvent(
"Data Model",
"Change Remapping Type",
"Custom Remappings",
);
await updateFieldDimension(field.id, {
type: "internal",
name: field.display_name,
human_readable_field_id: null,
});
this.setState({ hasChanged: true });
} else {
throw new Error(t`Unrecognized mapping type`);
}
// TODO Atte Keinänen 7/11/17: It's a pretty heavy approach to reload the whole table after a single field
// has been updated; would be nicer to just fetch a single field. MetabaseApi.field_get seems to exist for that
await fetchTableMetadata(table.id, true);
};
onForeignKeyFieldChange = async foreignKeyClause => {
const {
table,
field,
fetchTableMetadata,
updateFieldDimension,
} = this.props;
this.clearEditingStates();
// TODO Atte Keinänen 7/10/17: Use Dimension class when migrating to metabase-lib
if (foreignKeyClause.length === 3 && foreignKeyClause[0] === "fk->") {
MetabaseAnalytics.trackEvent("Data Model", "Update FK Remapping Target");
await updateFieldDimension(field.id, {
type: "external",
name: field.display_name,
human_readable_field_id: foreignKeyClause[2],
});
await fetchTableMetadata(table.id, true);
this.refs.fkPopover.close();
} else {
throw new Error(t`The selected field isn't a foreign key`);
}
};
onUpdateRemappings = remappings => {
const { field, updateFieldValues } = this.props;
return updateFieldValues(field.id, Array.from(remappings));
};
// TODO Atte Keinänen 7/11/17: Should we have stricter criteria for valid remapping targets?
isValidFKRemappingTarget = dimension =>
!(dimension.defaultDimension() instanceof DatetimeFieldDimension);
getForeignKeys = () => {
const { table, field } = this.props;
// this method has a little odd structure due to using fieldOptions(); basically filteredFKs should
// always be an array with a single value
const metadata = table.metadata;
const fieldOptions = Question.create({
metadata,
databaseId: table.db.id,
tableId: table.id,
})
.query()
.fieldOptions();
const unfilteredFks = fieldOptions.fks;
const filteredFKs = unfilteredFks.filter(fk => fk.field.id === field.id);
return filteredFKs.map(filteredFK => ({
field: filteredFK.field,
dimension: filteredFK.dimension,
dimensions: filteredFK.dimensions.filter(this.isValidFKRemappingTarget),
}));
};
onFkPopoverDismiss = () => {
const { isChoosingInitialFkTarget } = this.state;
if (isChoosingInitialFkTarget) {
this.setState({ dismissedInitialFkTargetPopover: true });
}
};
render() {
const { field, table, fields } = this.props;
const {
isChoosingInitialFkTarget,
hasChanged,
dismissedInitialFkTargetPopover,
} = this.state;
const mappingType = this.getMappingTypeForField(field);
const isFKMapping = mappingType === MAP_OPTIONS.foreign;
const hasFKMappingValue =
isFKMapping && field.dimensions.human_readable_field_id !== null;
const fkMappingField =
hasFKMappingValue && fields[field.dimensions.human_readable_field_id];
return (
<div>
<Select
value={mappingType}
onChange={this.onSetMappingType}
options={this.getAvailableMappingTypes()}
/>
{mappingType === MAP_OPTIONS.foreign && [
<SelectSeparator key="foreignKeySeparator" />,
<PopoverWithTrigger
ref="fkPopover"
triggerElement={
<SelectButton
hasValue={hasFKMappingValue}
className={cx("flex inline-block no-decoration", {
"border-error": dismissedInitialFkTargetPopover,
"border-dark": !dismissedInitialFkTargetPopover,
})}
>
{fkMappingField ? (
fkMappingField.display_name
) : (
<span className="text-medium">{t`Choose a field`}</span>
)}
</SelectButton>
}
isInitiallyOpen={isChoosingInitialFkTarget}
onClose={this.onFkPopoverDismiss}
>
<FieldList
className="text-purple"
field={fkMappingField}
fieldOptions={{
count: 0,
dimensions: [],
fks: this.getForeignKeys(),
}}
tableMetadata={table}
onFieldChange={this.onForeignKeyFieldChange}
hideSectionHeader
/>
</PopoverWithTrigger>,
dismissedInitialFkTargetPopover && (
<div className="text-error my2">{t`Please select a column to use for display.`}</div>
),
hasChanged && hasFKMappingValue && <RemappingNamingTip />,
]}
{mappingType === MAP_OPTIONS.custom && (
<div className="mt3">
{hasChanged && <RemappingNamingTip />}
<ValueRemappings
remappings={field && field.remapping}
updateRemappings={this.onUpdateRemappings}
/>
</div>
)}
</div>
);
}
}
// consider renaming this component to something more descriptive
export class ValueRemappings extends React.Component {
state = {
editingRemappings: new Map(),
};
componentWillMount() {
this._updateEditingRemappings(this.props.remappings);
}
componentWillReceiveProps(nextProps) {
if (nextProps.remappings !== this.props.remappings) {
this._updateEditingRemappings(nextProps.remappings);
}
}
_updateEditingRemappings(remappings) {
const editingRemappings = new Map(
[...remappings].map(([original, mappedOrUndefined]) => {
// Use currently the original value as the "default custom mapping" as the current backend implementation
// requires that all original values must have corresponding mappings
// Additionally, the defensive `.toString` ensures that the mapped value definitely will be string
const mappedString =
mappedOrUndefined !== undefined
? mappedOrUndefined.toString()
: original.toString();
return [original, mappedString];
}),
);
const containsUnsetMappings = [...remappings].some(
([_, mappedOrUndefined]) => {
return mappedOrUndefined === undefined;
},
);
if (containsUnsetMappings) {
// Save the initial values to make sure that we aren't left in a potentially broken state where
// the dimension type is "internal" but we don't have any values in metabase_fieldvalues
this.props.updateRemappings(editingRemappings);
}
this.setState({ editingRemappings });
}
onSetRemapping(original, newMapped) {
this.setState({
editingRemappings: new Map([
...this.state.editingRemappings,
[original, newMapped],
]),
});
}
onSaveClick = () => {
MetabaseAnalytics.trackEvent("Data Model", "Update Custom Remappings");
// Returns the promise so that ButtonWithStatus can show the saving status
return this.props.updateRemappings(this.state.editingRemappings);
};
customValuesAreNonEmpty = () => {
return Array.from(this.state.editingRemappings.values()).every(
value => value !== "",
);
};
render() {
const { editingRemappings } = this.state;
return (
<div className="bordered rounded py2 px4 border-dark">
<div className="flex align-center my1 pb2 border-bottom">
<h3>{t`Original value`}</h3>
<h3 className="ml-auto">{t`Mapped value`}</h3>
</div>
<ol>
{[...editingRemappings].map(([original, mapped]) => (
<li className="mb1">
<FieldValueMapping
original={original}
mapped={mapped}
setMapping={newMapped =>
this.onSetRemapping(original, newMapped)
}
/>
</li>
))}
</ol>
<div className="flex align-center">
<ButtonWithStatus
className="ml-auto"
disabled={!this.customValuesAreNonEmpty()}
onClickOperation={this.onSaveClick}
>
{t`Save`}
</ButtonWithStatus>
</div>
</div>
);
}
}
export class FieldValueMapping extends React.Component {
onInputChange = e => {
this.props.setMapping(e.target.value);
};
render() {
const { original, mapped } = this.props;
return (
<div className="flex align-center">
<h3>{original}</h3>
<InputBlurChange
className="AdminInput input ml-auto"
value={mapped}
onChange={this.onInputChange}
placeholder={t`Enter value`}
/>
</div>
);
}
}
export const RemappingNamingTip = () => (
<div className="bordered rounded p1 mt1 mb2 border-brand">
<span className="text-brand text-bold">{t`Tip: `}</span>
{t`You might want to update the field name to make sure it still makes sense based on your remapping choices.`}
</div>
);
import React from "react";
import cx from "classnames";
const Section = ({ children, first, last }) => (
<section
className={cx("pb4", first ? "mb4" : "my4", { "border-bottom": !last })}
>
{children}
</section>
);
export const SectionHeader = ({ title, description }) => (
<div className="mb2">
<h4>{title}</h4>
{description && <p className="mb0 text-medium mt1">{description}</p>}
</div>
);
export default Section;
import React from "react";
import Icon from "metabase/components/Icon";
const SelectSeparator = () => (
<Icon name="chevronright" size={12} className="mx2 text-medium" />
);
export default SelectSeparator;
import React from "react";
import { t } from "c-3po";
import ActionButton from "metabase/components/ActionButton.jsx";
export default class UpdateCachedFieldValues extends React.Component {
render() {
return (
<div>
<ActionButton
className="Button mr2"
actionFn={this.props.rescanFieldValues}
normalText={t`Re-scan this field`}
activeText={t`Starting…`}
failedText={t`Failed to start scan`}
successText={t`Scan triggered!`}
/>
<ActionButton
className="Button Button--danger"
actionFn={this.props.discardFieldValues}
normalText={t`Discard cached field values`}
activeText={t`Starting…`}
failedText={t`Failed to discard values`}
successText={t`Discard triggered!`}
/>
</div>
);
}
}
...@@ -3,13 +3,15 @@ import PropTypes from "prop-types"; ...@@ -3,13 +3,15 @@ import PropTypes from "prop-types";
import { Link, withRouter } from "react-router"; import { Link, withRouter } from "react-router";
import InputBlurChange from "metabase/components/InputBlurChange.jsx"; import InputBlurChange from "metabase/components/InputBlurChange.jsx";
import Select from "metabase/components/Select.jsx"; import Select, { Option } from "metabase/components/Select.jsx";
import Icon from "metabase/components/Icon"; import Icon from "metabase/components/Icon";
import { t } from "c-3po"; import { t } from "c-3po";
import * as MetabaseCore from "metabase/lib/core"; import * as MetabaseCore from "metabase/lib/core";
import { titleize, humanize } from "metabase/lib/formatting"; import { titleize, humanize } from "metabase/lib/formatting";
import { isNumericBaseType } from "metabase/lib/schema_metadata"; import { isNumericBaseType, isCurrency } from "metabase/lib/schema_metadata";
import { TYPE, isa, isFK } from "metabase/lib/types"; import { TYPE, isa, isFK } from "metabase/lib/types";
import currency from "metabase/lib/currency";
import { getGlobalSettingsForColumn } from "metabase/visualizations/lib/settings/column";
import _ from "underscore"; import _ from "underscore";
import cx from "classnames"; import cx from "classnames";
...@@ -152,6 +154,8 @@ export class SpecialTypeAndTargetPicker extends Component { ...@@ -152,6 +154,8 @@ export class SpecialTypeAndTargetPicker extends Component {
onSpecialTypeChange = async special_type => { onSpecialTypeChange = async special_type => {
const { field, updateField } = this.props; const { field, updateField } = this.props;
// FIXME: mutation
field.special_type = special_type.id; field.special_type = special_type.id;
// If we are changing the field from a FK to something else, we should delete any FKs present // If we are changing the field from a FK to something else, we should delete any FKs present
...@@ -171,6 +175,23 @@ export class SpecialTypeAndTargetPicker extends Component { ...@@ -171,6 +175,23 @@ export class SpecialTypeAndTargetPicker extends Component {
); );
}; };
onCurrencyTypeChange = async currency => {
const { field, updateField } = this.props;
// FIXME: mutation
field.settings = {
...(field.settings || {}),
currency,
};
await updateField(field);
MetabaseAnalytics.trackEvent(
"Data Model",
"Update Currency Type",
currency,
);
};
onTargetChange = async target_field => { onTargetChange = async target_field => {
const { field, updateField } = this.props; const { field, updateField } = this.props;
field.fk_target_field_id = target_field.id; field.fk_target_field_id = target_field.id;
...@@ -196,6 +217,8 @@ export class SpecialTypeAndTargetPicker extends Component { ...@@ -196,6 +217,8 @@ export class SpecialTypeAndTargetPicker extends Component {
const showFKTargetSelect = isFK(field.special_type); const showFKTargetSelect = isFK(field.special_type);
const showCurrencyTypeSelect = isCurrency(field);
// If all FK target fields are in the same schema (like `PUBLIC` for sample dataset) // If all FK target fields are in the same schema (like `PUBLIC` for sample dataset)
// or if there are no schemas at all, omit the schema name // or if there are no schemas at all, omit the schema name
const includeSchemaName = const includeSchemaName =
...@@ -214,6 +237,38 @@ export class SpecialTypeAndTargetPicker extends Component { ...@@ -214,6 +237,38 @@ export class SpecialTypeAndTargetPicker extends Component {
onChange={this.onSpecialTypeChange} onChange={this.onSpecialTypeChange}
triggerClasses={this.props.triggerClasses} triggerClasses={this.props.triggerClasses}
/> />
{showCurrencyTypeSelect && selectSeparator}
{// TODO - now that we have multiple "nested" options like choosing a
// FK table and a currency type we should make this more generic and
// handle a "secondary" input more elegantly
showCurrencyTypeSelect && (
<Select
className={cx(
"TableEditor-field-target",
"inline-block",
className,
)}
triggerClasses={this.props.triggerClasses}
value={
(field.settings && field.settings.currency) ||
getGlobalSettingsForColumn(field).currency ||
"USD"
}
onChange={({ target }) => this.onCurrencyTypeChange(target.value)}
placeholder={t`Select a currency type`}
searchProp="name"
searchCaseSensitive={false}
>
{Object.values(currency).map(c => (
<Option name={c.name} value={c.code}>
<span className="flex full align-center">
<span>{c.name}</span>
<span className="text-bold text-light ml1">{c.symbol}</span>
</span>
</Option>
))}
</Select>
)}
{showFKTargetSelect && selectSeparator} {showFKTargetSelect && selectSeparator}
{showFKTargetSelect && ( {showFKTargetSelect && (
<Select <Select
......
...@@ -101,7 +101,7 @@ export default class MetadataTable extends Component { ...@@ -101,7 +101,7 @@ export default class MetadataTable extends Component {
} }
return ( return (
<div className="MetadataTable px3 full"> <div className="MetadataTable full px3">
<div className="MetadataTable-title flex flex-column bordered rounded"> <div className="MetadataTable-title flex flex-column bordered rounded">
<InputBlurChange <InputBlurChange
className="AdminInput TableEditor-table-name text-bold border-bottom rounded-top" className="AdminInput TableEditor-table-name text-bold border-bottom rounded-top"
......
...@@ -55,10 +55,10 @@ const getRoutes = (store, IsAdmin) => ( ...@@ -55,10 +55,10 @@ const getRoutes = (store, IsAdmin) => (
path="database/:databaseId/:mode/:tableId/settings" path="database/:databaseId/:mode/:tableId/settings"
component={TableSettingsApp} component={TableSettingsApp}
/> />
<Route <Route path="database/:databaseId/:mode/:tableId/:fieldId">
path="database/:databaseId/:mode/:tableId/:fieldId" <IndexRedirect to="general" />
component={FieldApp} <Route path=":section" component={FieldApp} />
/> </Route>
<Route path="metric/create" component={MetricApp} /> <Route path="metric/create" component={MetricApp} />
<Route path="metric/:id" component={MetricApp} /> <Route path="metric/:id" component={MetricApp} />
<Route path="segment/create" component={SegmentApp} /> <Route path="segment/create" component={SegmentApp} />
......
import React from "react";
import { TYPE } from "metabase/lib/types";
import ColumnSettings from "metabase/visualizations/components/ColumnSettings";
const SETTING_TYPES = [
{
name: "Dates and Times",
type: TYPE.DateTime,
settings: [
"date_style",
"date_separator",
"date_abbreviate",
// "time_enabled",
"time_style",
],
column: {
special_type: TYPE.DateTime,
unit: "second",
},
},
{
name: "Numbers",
type: TYPE.Number,
settings: ["number_separators"],
column: {
base_type: TYPE.Number,
special_type: TYPE.Number,
},
},
{
name: "Currency",
type: TYPE.Currency,
settings: ["currency_style", "currency", "currency_in_header"],
column: {
base_type: TYPE.Number,
special_type: TYPE.Currency,
},
},
];
class FormattingWidget extends React.Component {
render() {
const { setting, onChange } = this.props;
const value = setting.value || setting.default;
return (
<div className="mt2">
{SETTING_TYPES.map(({ type, name, column, settings }) => (
<div
className="border-bottom pb2 mb4 flex-full"
style={{ minWidth: 400 }}
>
<h3 className="mb3">{name}</h3>
<ColumnSettings
value={value[type]}
onChange={settings => onChange({ ...value, [type]: settings })}
column={column}
whitelist={new Set(settings)}
noReset
/>
</div>
))}
</div>
);
}
}
export default FormattingWidget;
...@@ -13,6 +13,7 @@ import SecretKeyWidget from "./components/widgets/SecretKeyWidget.jsx"; ...@@ -13,6 +13,7 @@ import SecretKeyWidget from "./components/widgets/SecretKeyWidget.jsx";
import EmbeddingLegalese from "./components/widgets/EmbeddingLegalese"; import EmbeddingLegalese from "./components/widgets/EmbeddingLegalese";
import EmbeddingLevel from "./components/widgets/EmbeddingLevel"; import EmbeddingLevel from "./components/widgets/EmbeddingLevel";
import LdapGroupMappingsWidget from "./components/widgets/LdapGroupMappingsWidget"; import LdapGroupMappingsWidget from "./components/widgets/LdapGroupMappingsWidget";
import FormattingWidget from "./components/widgets/FormattingWidget";
import { UtilApi } from "metabase/services"; import { UtilApi } from "metabase/services";
...@@ -309,6 +310,18 @@ const SECTIONS = [ ...@@ -309,6 +310,18 @@ const SECTIONS = [
}, },
], ],
}, },
{
name: t`Formatting`,
slug: "formatting",
settings: [
{
display_name: t`Formatting Options`,
description: "",
key: "custom-formatting",
widget: FormattingWidget,
},
],
},
{ {
name: t`Public Sharing`, name: t`Public Sharing`,
slug: "public_sharing", slug: "public_sharing",
......
...@@ -40,6 +40,11 @@ export const field_special_types = [ ...@@ -40,6 +40,11 @@ export const field_special_types = [
name: t`Country`, name: t`Country`,
section: t`Common`, section: t`Common`,
}, },
{
id: TYPE.Currency,
name: t`Currency`,
section: t`Common`,
},
{ {
id: TYPE.Description, id: TYPE.Description,
name: t`Description`, name: t`Description`,
......
This diff is collapsed.
...@@ -44,6 +44,12 @@ import type { ...@@ -44,6 +44,12 @@ import type {
TimeEnabled, TimeEnabled,
} from "metabase/lib/formatting/date"; } from "metabase/lib/formatting/date";
// a one or two character string specifying the decimal and grouping separator characters
export type NumberSeparators = ".," | ", " | ",." | ".";
// single character string specifying date separators
export type DateSeparator = "/" | "-" | ".";
export type FormattingOptions = { export type FormattingOptions = {
// GENERIC // GENERIC
column?: Column | Field, column?: Column | Field,
...@@ -63,11 +69,9 @@ export type FormattingOptions = { ...@@ -63,11 +69,9 @@ export type FormattingOptions = {
scale?: number, scale?: number,
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
scale?: number, scale?: number,
locale?: string, number_separators?: NumberSeparators,
minimumFractionDigits?: number, minimumFractionDigits?: number,
maximumFractionDigits?: number, maximumFractionDigits?: number,
// use thousand separators, defualt to false if locale === null
useGrouping?: boolean,
// decimals sets both minimumFractionDigits and maximumFractionDigits // decimals sets both minimumFractionDigits and maximumFractionDigits
decimals?: number, decimals?: number,
// STRING // STRING
...@@ -76,6 +80,7 @@ export type FormattingOptions = { ...@@ -76,6 +80,7 @@ export type FormattingOptions = {
// DATE/TIME // 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/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_style?: DateStyle,
date_separator?: DateSeparator,
date_abbreviate?: boolean, date_abbreviate?: boolean,
date_format?: string, date_format?: string,
time_style?: TimeStyle, time_style?: TimeStyle,
...@@ -88,7 +93,6 @@ type FormattedString = string | React$Element<any>; ...@@ -88,7 +93,6 @@ type FormattedString = string | React$Element<any>;
const DEFAULT_NUMBER_OPTIONS: FormattingOptions = { const DEFAULT_NUMBER_OPTIONS: FormattingOptions = {
compact: false, compact: false,
maximumFractionDigits: 2, maximumFractionDigits: 2,
useGrouping: true,
}; };
function getDefaultNumberOptions(options) { function getDefaultNumberOptions(options) {
...@@ -100,11 +104,6 @@ function getDefaultNumberOptions(options) { ...@@ -100,11 +104,6 @@ function getDefaultNumberOptions(options) {
defaults.maximumFractionDigits = 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; return defaults;
} }
...@@ -124,15 +123,22 @@ const getDayFormat = options => ...@@ -124,15 +123,22 @@ const getDayFormat = options =>
// use en dashes, for Maz // use en dashes, for Maz
const RANGE_SEPARATOR = ` – `; const RANGE_SEPARATOR = ` – `;
// for extracting number portion from a formatted currency string
const NUMBER_REGEX = /[\+\-]?[0-9\., ]+/;
const DEFAULT_NUMBER_SEPARATORS = ".,";
export function numberFormatterForOptions(options: FormattingOptions) { export function numberFormatterForOptions(options: FormattingOptions) {
options = { ...getDefaultNumberOptions(options), ...options }; options = { ...getDefaultNumberOptions(options), ...options };
// if we don't provide a locale much of the formatting doens't work // always use "en" locale so we have known number separators we can replace depending on number_separators option
// TODO: if we do that how can we get localized currency names?
// $FlowFixMe: doesn't know about Intl.NumberFormat // $FlowFixMe: doesn't know about Intl.NumberFormat
return new Intl.NumberFormat(options.locale || "en", { return new Intl.NumberFormat("en", {
style: options.number_style, style: options.number_style,
currency: options.currency, currency: options.currency,
currencyDisplay: options.currency_style, currencyDisplay: options.currency_style,
useGrouping: options.useGrouping, // always use grouping separators, but we may replace/remove them depending on number_separators option
useGrouping: true,
minimumIntegerDigits: options.minimumIntegerDigits, minimumIntegerDigits: options.minimumIntegerDigits,
minimumFractionDigits: options.minimumFractionDigits, minimumFractionDigits: options.minimumFractionDigits,
maximumFractionDigits: options.maximumFractionDigits, maximumFractionDigits: options.maximumFractionDigits,
...@@ -170,7 +176,28 @@ export function formatNumber(number: number, options: FormattingOptions = {}) { ...@@ -170,7 +176,28 @@ export function formatNumber(number: number, options: FormattingOptions = {}) {
} else { } else {
nf = numberFormatterForOptions(options); nf = numberFormatterForOptions(options);
} }
return nf.format(number);
let formatted = nf.format(number);
// extract number portion of currency if we're formatting a cell
if (
options["type"] === "cell" &&
options["currency_in_header"] &&
options["number_style"] === "currency"
) {
const match = formatted.match(NUMBER_REGEX);
if (match) {
formatted = match[0].trim();
}
}
// replace the separators if not default
const separators = options["number_separators"];
if (separators && separators !== DEFAULT_NUMBER_SEPARATORS) {
formatted = replaceNumberSeparators(formatted, separators);
}
return formatted;
} catch (e) { } catch (e) {
console.warn("Error formatting number", e); console.warn("Error formatting number", e);
// fall back to old, less capable formatter // fall back to old, less capable formatter
...@@ -182,6 +209,21 @@ export function formatNumber(number: number, options: FormattingOptions = {}) { ...@@ -182,6 +209,21 @@ export function formatNumber(number: number, options: FormattingOptions = {}) {
} }
} }
// replaces the decimale and grouping separators with those specified by a NumberSeparators option
function replaceNumberSeparators(
formatted: string,
separators: NumberSeparators,
) {
const [decimalSeparator, groupingSeparator] = (separators || ".,").split("");
const separatorMap = {
",": groupingSeparator || "",
".": decimalSeparator,
};
return formatted.replace(/,|\./g, separator => separatorMap[separator]);
}
function formatNumberScientific( function formatNumberScientific(
value: number, value: number,
options: FormattingOptions, options: FormattingOptions,
...@@ -413,8 +455,12 @@ export function formatDateTimeWithUnit( ...@@ -413,8 +455,12 @@ export function formatDateTimeWithUnit(
let timeFormat = options.time_format; let timeFormat = options.time_format;
if (!dateFormat) { if (!dateFormat) {
// $FlowFixMe: date_style default set above dateFormat = getDateFormatFromStyle(
dateFormat = getDateFormatFromStyle(options.date_style, unit); // $FlowFixMe: date_style default set above
options["date_style"],
unit,
options["date_separator"],
);
} }
if (!timeFormat) { if (!timeFormat) {
......
/* @flow */ /* @flow */
import type { DateSeparator } from "metabase/lib/formatting";
import type { DatetimeUnit } from "metabase/meta/types/Query"; import type { DatetimeUnit } from "metabase/meta/types/Query";
export type DateStyle = export type DateStyle =
...@@ -63,21 +65,25 @@ export const DEFAULT_DATE_STYLE: DateStyle = "MMMM D, YYYY"; ...@@ -63,21 +65,25 @@ export const DEFAULT_DATE_STYLE: DateStyle = "MMMM D, YYYY";
export function getDateFormatFromStyle( export function getDateFormatFromStyle(
style: DateStyle, style: DateStyle,
unit: ?DatetimeUnit, unit: ?DatetimeUnit,
separator?: DateSeparator,
): DateFormat { ): DateFormat {
const replaceSeparators = format =>
separator && format ? format.replace(/\//g, separator) : format;
if (!unit) { if (!unit) {
unit = "default"; unit = "default";
} }
if (DATE_STYLE_TO_FORMAT[style]) { if (DATE_STYLE_TO_FORMAT[style]) {
if (DATE_STYLE_TO_FORMAT[style][unit]) { if (DATE_STYLE_TO_FORMAT[style][unit]) {
return DATE_STYLE_TO_FORMAT[style][unit]; return replaceSeparators(DATE_STYLE_TO_FORMAT[style][unit]);
} }
} else { } else {
console.warn("Unknown date style", style); console.warn("Unknown date style", style);
} }
if (DEFAULT_DATE_FORMATS[unit]) { if (DEFAULT_DATE_FORMATS[unit]) {
return DEFAULT_DATE_FORMATS[unit]; return replaceSeparators(DEFAULT_DATE_FORMATS[unit]);
} }
return style; return replaceSeparators(style);
} }
const UNITS_WITH_HOUR: DatetimeUnit[] = ["default", "minute", "hour"]; const UNITS_WITH_HOUR: DatetimeUnit[] = ["default", "minute", "hour"];
......
...@@ -170,6 +170,9 @@ export const isLatitude = field => ...@@ -170,6 +170,9 @@ export const isLatitude = field =>
export const isLongitude = field => export const isLongitude = field =>
isa(field && field.special_type, TYPE.Longitude); isa(field && field.special_type, TYPE.Longitude);
export const isCurrency = field =>
isa(field && field.special_type, TYPE.Currency);
export const isID = field => isFK(field) || isPK(field); export const isID = field => isFK(field) || isPK(field);
export const isURL = field => isa(field && field.special_type, TYPE.URL); export const isURL = field => isa(field && field.special_type, TYPE.URL);
......
...@@ -7,6 +7,8 @@ import type { DatetimeUnit, FieldLiteral } from "./Query"; ...@@ -7,6 +7,8 @@ import type { DatetimeUnit, FieldLiteral } from "./Query";
export type ColumnName = string; export type ColumnName = string;
export type ColumnSettings = { [id: string]: any };
export type BinningInfo = { export type BinningInfo = {
bin_width: number, bin_width: number,
}; };
...@@ -23,6 +25,7 @@ export type Column = { ...@@ -23,6 +25,7 @@ export type Column = {
binning_info?: BinningInfo, binning_info?: BinningInfo,
fk_field_id?: FieldId, fk_field_id?: FieldId,
"expression-name"?: any, "expression-name"?: any,
settings?: ColumnSettings,
}; };
export type Value = string | number | ISO8601Time | boolean | null | {}; export type Value = string | number | ISO8601Time | boolean | null | {};
......
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
getVisualizationTransformed, getVisualizationTransformed,
extractRemappings, extractRemappings,
} from "metabase/visualizations"; } from "metabase/visualizations";
import { updateSettings } from "metabase/visualizations/lib/settings";
const DEFAULT_TAB_PRIORITY = ["Display"]; const DEFAULT_TAB_PRIORITY = ["Display"];
...@@ -52,7 +53,7 @@ class ChartSettings extends Component { ...@@ -52,7 +53,7 @@ class ChartSettings extends Component {
} }
handleSelectTab = tab => { handleSelectTab = tab => {
this.setState({ currentTab: tab }); this.setState({ currentTab: tab, showWidget: null });
}; };
handleResetSettings = () => { handleResetSettings = () => {
...@@ -63,17 +64,11 @@ class ChartSettings extends Component { ...@@ -63,17 +64,11 @@ class ChartSettings extends Component {
}); });
}; };
handleChangeSettings = newSettings => { handleChangeSettings = changedSettings => {
for (const key of Object.keys(newSettings)) { const newSettings = updateSettings(this.state.settings, changedSettings);
MetabaseAnalytics.trackEvent("Chart Settings", "Change Setting", key);
}
const settings = {
...this.state.settings,
...newSettings,
};
this.setState({ this.setState({
settings: settings, settings: newSettings,
series: this._getSeries(this.props.series, settings), series: this._getSeries(this.props.series, newSettings),
}); });
}; };
......
import React from "react"; import React from "react";
import Icon from "metabase/components/Icon";
import cx from "classnames"; import cx from "classnames";
const ChartSettingsWidget = ({ const ChartSettingsWidget = ({
title, title,
hidden, hidden,
disabled, disabled,
set,
widget: Widget, widget: Widget,
value, value,
onChange, onChange,
props, props,
// disables X padding for certain widgets so divider line extends to edge // disables X padding for certain widgets so divider line extends to edge
noPadding, noPadding,
// disable reset button
noReset,
// NOTE: pass along special props to support: // NOTE: pass along special props to support:
// * adding additional fields // * adding additional fields
// * substituting widgets // * substituting widgets
...@@ -20,13 +25,27 @@ const ChartSettingsWidget = ({ ...@@ -20,13 +25,27 @@ const ChartSettingsWidget = ({
return ( return (
<div <div
className={cx({ className={cx({
mb2: !hidden, mb3: !hidden,
mx4: !noPadding, mx4: !noPadding,
hide: hidden, hide: hidden,
disable: disabled, disable: disabled,
})} })}
> >
{title && <h4 className="mb1">{title}</h4>} {title && (
<h4 className="mb1 flex align-center">
{title}
<Icon
size={12}
className={cx("ml1 text-light text-medium-hover cursor-pointer", {
hidden: !set || noReset,
})}
name="refresh"
tooltip="Reset to default"
onClick={() => onChange(undefined)}
/>
</h4>
)}
{Widget && ( {Widget && (
<Widget <Widget
value={value} value={value}
......
...@@ -2,9 +2,9 @@ import React, { Component } from "react"; ...@@ -2,9 +2,9 @@ import React, { Component } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import TooltipPopover from "metabase/components/TooltipPopover.jsx"; import TooltipPopover from "metabase/components/TooltipPopover.jsx";
import Value from "metabase/components/Value.jsx";
import { getFriendlyName } from "metabase/visualizations/lib/utils"; import { getFriendlyName } from "metabase/visualizations/lib/utils";
import { formatValue } from "metabase/lib/formatting";
export default class ChartTooltip extends Component { export default class ChartTooltip extends Component {
static propTypes = { static propTypes = {
...@@ -58,7 +58,7 @@ export default class ChartTooltip extends Component { ...@@ -58,7 +58,7 @@ export default class ChartTooltip extends Component {
} }
render() { render() {
const { hovered } = this.props; const { hovered, settings } = this.props;
const rows = this._getRows(); const rows = this._getRows();
const hasEventOrElement = const hasEventOrElement =
hovered && hovered &&
...@@ -75,7 +75,13 @@ export default class ChartTooltip extends Component { ...@@ -75,7 +75,13 @@ export default class ChartTooltip extends Component {
<table className="py1 px2"> <table className="py1 px2">
<tbody> <tbody>
{rows.map(({ key, value, col }, index) => ( {rows.map(({ key, value, col }, index) => (
<TooltipRow key={index} name={key} value={value} column={col} /> <TooltipRow
key={index}
name={key}
value={value}
column={col}
settings={settings}
/>
))} ))}
</tbody> </tbody>
</table> </table>
...@@ -84,15 +90,19 @@ export default class ChartTooltip extends Component { ...@@ -84,15 +90,19 @@ export default class ChartTooltip extends Component {
} }
} }
const TooltipRow = ({ name, value, column }) => ( const TooltipRow = ({ name, value, column, settings }) => (
<tr> <tr>
<td className="text-light text-right">{name}:</td> <td className="text-light text-right">{name}:</td>
<td className="pl1 text-bold text-left"> <td className="pl1 text-bold text-left">
{React.isValidElement(value) ? ( {React.isValidElement(value)
value ? value
) : ( : formatValue(value, {
<Value type="tooltip" value={value} column={column} majorWidth={0} /> ...(settings && settings.column && column
)} ? settings.column(column)
: { column }),
type: "tooltip",
majorWidth: 0,
})}
</td> </td>
</tr> </tr>
); );
/* @flow */
import React from "react";
import { t } from "c-3po";
import EmptyState from "metabase/components/EmptyState";
import { getSettingDefintionsForColumn } from "metabase/visualizations/lib/settings/column";
import {
getSettingsWidgets,
getComputedSettings,
} from "metabase/visualizations/lib/settings";
import ChartSettingsWidget from "metabase/visualizations/components/ChartSettingsWidget";
type SettingId = string;
type Settings = { [id: SettingId]: any };
type Props = {
value: Settings,
onChange: (settings: Settings) => void,
column: any,
whitelist?: Set<SettingId>,
blacklist?: Set<SettingId>,
inheritedSettings?: Settings,
noReset?: boolean,
};
const ColumnSettings = ({
value,
onChange,
column,
whitelist,
blacklist,
inheritedSettings = {},
noReset = false,
}: Props) => {
const storedSettings = value || {};
// fake series
const series = [{ card: {}, data: { rows: [], cols: [] } }];
// add a "unit" to make certain settings work
if (column.unit == null) {
column = { ...column, unit: "default" };
}
// $FlowFixMe
const settingsDefs = getSettingDefintionsForColumn(series, column);
const computedSettings = getComputedSettings(
settingsDefs,
column,
{ ...inheritedSettings, ...storedSettings },
{ series },
);
const widgets = getSettingsWidgets(
settingsDefs,
storedSettings,
computedSettings,
column,
changedSettings => {
onChange({ ...storedSettings, ...changedSettings });
},
{ series },
).filter(
widget =>
(!whitelist || whitelist.has(widget.id)) &&
(!blacklist || !blacklist.has(widget.id)),
);
return (
<div style={{ maxWidth: 300 }}>
{widgets.length > 0 ? (
widgets.map(widget => (
<ChartSettingsWidget
key={widget.id}
{...widget}
// FIXME: this is to force all settings to be visible but causes irrelevant settings to be shown
hidden={false}
unset={storedSettings[widget.id] === undefined}
noPadding
noReset={noReset || widget.noReset}
/>
))
) : (
<EmptyState
message={t`No formatting settings`}
illustrationElement={<img src="../app/assets/img/no_results.svg" />}
/>
)}
</div>
);
};
export default ColumnSettings;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment