From a170ae4368298207b5a5c9a698889c865fd73421 Mon Sep 17 00:00:00 2001 From: Etienne Baratte Date: Tue, 2 May 2023 20:29:33 +0200 Subject: [PATCH] feat: custom d3 number locale (#20075) --- superset-frontend/package-lock.json | 33 +++++++++++++---- superset-frontend/package.json | 1 + .../src/utils/D3Formatting.ts | 35 ++++++++++++------- .../src/number-format/D3FormatConfig.ts | 26 ++++++++++++++ .../number-format/NumberFormatterRegistry.ts | 11 ++++++ .../NumberFormatterRegistrySingleton.ts | 5 +++ .../src/number-format/index.ts | 2 ++ .../NumberFormatterRegistrySingleton.test.ts | 20 +++++++++++ superset-frontend/src/constants.ts | 3 +- superset-frontend/src/preamble.ts | 2 +- .../src/setup/setupFormatters.ts | 6 +++- superset-frontend/src/types/bootstrapTypes.ts | 2 ++ superset/config.py | 19 ++++++++++ superset/views/base.py | 1 + 14 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-core/src/number-format/D3FormatConfig.ts diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index cd0f4417d..738d691b5 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -50,6 +50,7 @@ "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/preset-chart-xy": "file:./plugins/preset-chart-xy", "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", + "@types/d3-format": "^3.0.1", "@visx/axis": "^3.0.1", "@visx/grid": "^3.0.1", "@visx/responsive": "^3.0.0", @@ -5613,6 +5614,11 @@ "d3-time-format": "^3.0.0" } }, + "node_modules/@encodable/format/node_modules/@types/d3-format": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", + "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + }, "node_modules/@encodable/format/node_modules/d3-array": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", @@ -20465,9 +20471,9 @@ "integrity": "sha512-fYtiVLBYy7VQX+Kx7wU/uOIkGQn8aAEY8oWMoyja3N4dLd8Yf6XgSIR/4yWvMuveNOH5VShnqCgRqqh/UNanBA==" }, "node_modules/@types/d3-format": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", - "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==" }, "node_modules/@types/d3-interpolate": { "version": "1.4.2", @@ -60389,6 +60395,11 @@ "react": ">=16.13.1" } }, + "packages/superset-ui-core/node_modules/@types/d3-format": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", + "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + }, "packages/superset-ui-core/node_modules/@types/d3-time": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", @@ -66037,6 +66048,11 @@ "d3-time-format": "^3.0.0" }, "dependencies": { + "@types/d3-format": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", + "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + }, "d3-array": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", @@ -76715,6 +76731,11 @@ } } }, + "@types/d3-format": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", + "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + }, "@types/d3-time": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", @@ -78303,9 +78324,9 @@ "integrity": "sha512-fYtiVLBYy7VQX+Kx7wU/uOIkGQn8aAEY8oWMoyja3N4dLd8Yf6XgSIR/4yWvMuveNOH5VShnqCgRqqh/UNanBA==" }, "@types/d3-format": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.2.tgz", - "integrity": "sha512-WeGCHAs7PHdZYq6lwl/+jsl+Nfc1J2W1kNcMeIMYzQsT6mtBDBgtJ/rcdjZ0k0rVIvqEZqhhuD5TK/v3P2gFHQ==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==" }, "@types/d3-interpolate": { "version": "1.4.2", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 9d4755926..8079a607b 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -115,6 +115,7 @@ "@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud", "@superset-ui/preset-chart-xy": "file:./plugins/preset-chart-xy", "@superset-ui/switchboard": "file:./packages/superset-ui-switchboard", + "@types/d3-format": "^3.0.1", "@visx/axis": "^3.0.1", "@visx/grid": "^3.0.1", "@visx/responsive": "^3.0.0", diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts index be703f973..3ea4ee80a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/D3Formatting.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { t, smartDateFormatter, NumberFormats } from '@superset-ui/core'; +import { + t, + smartDateFormatter, + NumberFormats, + getNumberFormatter, +} from '@superset-ui/core'; // D3 specific formatting config export const D3_FORMAT_DOCS = t( @@ -30,22 +35,26 @@ export const D3_NUMBER_FORMAT_DESCRIPTION_PERCENTAGE_TEXT = t( 'Only applies when "Label Type" is not set to a percentage.', ); +const d3Formatted: [string, string][] = [ + ',d', + '.1s', + '.3s', + ',.1%', + '.2%', + '.3%', + '.4r', + ',.1f', + ',.2f', + ',.3f', + '+,', + '$,.2f', +].map(fmt => [fmt, `${fmt} (${getNumberFormatter(fmt).preview()})`]); + // input choices & options export const D3_FORMAT_OPTIONS: [string, string][] = [ [NumberFormats.SMART_NUMBER, t('Adaptive formatting')], ['~g', t('Original value')], - [',d', ',d (12345.432 => 12,345)'], - ['.1s', '.1s (12345.432 => 10k)'], - ['.3s', '.3s (12345.432 => 12.3k)'], - [',.1%', ',.1% (12345.432 => 1,234,543.2%)'], - ['.2%', '.2% (12345.432 => 1234543.20%)'], - ['.3%', '.3% (12345.432 => 1234543.200%)'], - ['.4r', '.4r (12345.432 => 12350)'], - [',.1f', ',.1f (12345.432 => 12,345.4)'], - [',.2f', ',.2f (12345.432 => 12,345.43)'], - [',.3f', ',.3f (12345.432 => 12,345.432)'], - ['+,', '+, (12345.432 => +12,345.432)'], - ['$,.2f', '$,.2f (12345.432 => $12,345.43)'], + ...d3Formatted, ['DURATION', t('Duration in ms (66000 => 1m 6s)')], ['DURATION_SUB', t('Duration in ms (1.40008 => 1ms 400µs 80ns)')], ]; diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/D3FormatConfig.ts b/superset-frontend/packages/superset-ui-core/src/number-format/D3FormatConfig.ts new file mode 100644 index 000000000..4dc84f8d8 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/number-format/D3FormatConfig.ts @@ -0,0 +1,26 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { FormatLocaleDefinition } from 'd3-format'; + +export const DEFAULT_D3_FORMAT: FormatLocaleDefinition = { + decimal: '.', + thousands: ',', + grouping: [3], + currency: ['$', ''], +}; diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistry.ts b/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistry.ts index 6afbad13c..e64796ad3 100644 --- a/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistry.ts +++ b/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistry.ts @@ -16,7 +16,9 @@ * specific language governing permissions and limitations * under the License. */ +import { FormatLocaleDefinition } from 'd3-format'; import { RegistryWithDefaultKey, OverwritePolicy } from '../models'; +import { DEFAULT_D3_FORMAT } from './D3FormatConfig'; import createD3NumberFormatter from './factories/createD3NumberFormatter'; import createSmartNumberFormatter from './factories/createSmartNumberFormatter'; import NumberFormats from './NumberFormats'; @@ -26,6 +28,8 @@ export default class NumberFormatterRegistry extends RegistryWithDefaultKey< NumberFormatter, NumberFormatter > { + d3Format: FormatLocaleDefinition; + constructor() { super({ name: 'NumberFormatter', @@ -41,6 +45,12 @@ export default class NumberFormatterRegistry extends RegistryWithDefaultKey< createSmartNumberFormatter({ signed: true }), ); this.setDefaultKey(NumberFormats.SMART_NUMBER); + this.d3Format = DEFAULT_D3_FORMAT; + } + + setD3Format(d3Format: Partial) { + this.d3Format = { ...DEFAULT_D3_FORMAT, ...d3Format }; + return this; } get(formatterId?: string) { @@ -59,6 +69,7 @@ export default class NumberFormatterRegistry extends RegistryWithDefaultKey< // Create new formatter if does not exist const formatter = createD3NumberFormatter({ formatString: targetFormat, + locale: this.d3Format, }); this.registerValue(targetFormat, formatter); diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistrySingleton.ts b/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistrySingleton.ts index de3e76a97..a4ccbe9ca 100644 --- a/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistrySingleton.ts +++ b/superset-frontend/packages/superset-ui-core/src/number-format/NumberFormatterRegistrySingleton.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { FormatLocaleDefinition } from 'd3-format'; import { makeSingleton } from '../utils'; import NumberFormatterRegistry from './NumberFormatterRegistry'; @@ -27,6 +28,10 @@ export function getNumberFormatter(format?: string) { return getInstance().get(format); } +export function setD3Format(d3Format: Partial) { + getInstance().setD3Format(d3Format); +} + export function formatNumber( format: string | undefined, value: number | null | undefined, diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/index.ts b/superset-frontend/packages/superset-ui-core/src/number-format/index.ts index 0c501b685..c65537552 100644 --- a/superset-frontend/packages/superset-ui-core/src/number-format/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/number-format/index.ts @@ -19,10 +19,12 @@ export { default as NumberFormats } from './NumberFormats'; export { default as NumberFormatter, PREVIEW_VALUE } from './NumberFormatter'; +export { DEFAULT_D3_FORMAT } from './D3FormatConfig'; export { default as getNumberFormatterRegistry, formatNumber, + setD3Format, getNumberFormatter, } from './NumberFormatterRegistrySingleton'; diff --git a/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts b/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts index dbb7eac6d..9b653ba70 100644 --- a/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/number-format/NumberFormatterRegistrySingleton.test.ts @@ -20,6 +20,7 @@ import { NumberFormatterRegistry, getNumberFormatterRegistry, + setD3Format, getNumberFormatter, formatNumber, } from '@superset-ui/core'; @@ -55,4 +56,23 @@ describe('NumberFormatterRegistrySingleton', () => { expect(formatNumber(undefined, 1000)).toEqual('1k'); }); }); + describe('setD3Format()', () => { + it('sets a specific FormatLocaleDefinition', () => { + setD3Format({ + decimal: ';', + thousands: '-', + currency: ['€', ''], + grouping: [2], + }); + const formatter = getNumberFormatter('$,.2f'); + expect(formatter.format(12345.67)).toEqual('€1-23-45;67'); + }); + it('falls back to default value for unspecified locale format parameters', () => { + setD3Format({ + currency: ['€', ''], + }); + const formatter = getNumberFormatter('$,.1f'); + expect(formatter.format(12345.67)).toEqual('€12,345.7'); + }); + }); }); diff --git a/superset-frontend/src/constants.ts b/superset-frontend/src/constants.ts index 40d0b22ae..bc2b0be84 100644 --- a/superset-frontend/src/constants.ts +++ b/superset-frontend/src/constants.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { t } from '@superset-ui/core'; +import { t, DEFAULT_D3_FORMAT } from '@superset-ui/core'; import { BootstrapData, CommonBootstrapData } from './types/bootstrapTypes'; @@ -184,6 +184,7 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = { color: '', }, }, + d3_format: DEFAULT_D3_FORMAT, }; export const DEFAULT_BOOTSTRAP_DATA: BootstrapData = { diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index 1af94eedc..d1d84b777 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -57,7 +57,7 @@ setupColors( ); // Setup number formatters -setupFormatters(); +setupFormatters(bootstrapData.common.d3_format); setupDashboardComponents(); diff --git a/superset-frontend/src/setup/setupFormatters.ts b/superset-frontend/src/setup/setupFormatters.ts index bc5efc1ed..964c94b79 100644 --- a/superset-frontend/src/setup/setupFormatters.ts +++ b/superset-frontend/src/setup/setupFormatters.ts @@ -25,9 +25,13 @@ import { smartDateFormatter, smartDateVerboseFormatter, } from '@superset-ui/core'; +import { FormatLocaleDefinition } from 'd3-format'; -export default function setupFormatters() { +export default function setupFormatters( + d3Format: Partial, +) { getNumberFormatterRegistry() + .setD3Format(d3Format) // Add shims for format strings that are deprecated or common typos. // Temporary solution until performing a db migration to fix this. .registerValue(',0', getNumberFormatter(',.4~f')) diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index 971012f2f..2f54c4b42 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -6,6 +6,7 @@ import { Locale, SequentialSchemeConfig, } from '@superset-ui/core'; +import { FormatLocaleDefinition } from 'd3-format'; import { isPlainObject } from 'lodash'; import { Languages } from 'src/features/home/LanguagePicker'; import { FlashMessage } from '../components/FlashProvider'; @@ -150,6 +151,7 @@ export interface CommonBootstrapData { extra_sequential_color_schemes: SequentialSchemeConfig[]; theme_overrides: JsonObject; menu_data: MenuData; + d3_format: Partial; } export interface BootstrapData { diff --git a/superset/config.py b/superset/config.py index f3ad488a7..267ad6439 100644 --- a/superset/config.py +++ b/superset/config.py @@ -378,6 +378,25 @@ LANGUAGES = { # incomplete and not well maintained. LANGUAGES = {} + +# Override the default d3 locale format +# Default values are equivalent to +# D3_FORMAT = { +# "decimal": ".", # - decimal place string (e.g., "."). +# "thousands": ",", # - group separator string (e.g., ","). +# "grouping": [3], # - array of group sizes (e.g., [3]), cycled as needed. +# "currency": ["$", ""] # - currency prefix/suffix strings (e.g., ["$", ""]) +# } +# https://github.com/d3/d3-format/blob/main/README.md#formatLocale +class D3Format(TypedDict, total=False): + decimal: str + thousands: str + grouping: List[int] + currency: List[str] + + +D3_FORMAT: D3Format = {} + # --------------------------------------------------- # Feature flags # --------------------------------------------------- diff --git a/superset/views/base.py b/superset/views/base.py index 0da0be065..cc405adcf 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -428,6 +428,7 @@ def cached_common_bootstrap_data(user: User) -> Dict[str, Any]: "conf": frontend_config, "locale": locale, "language_pack": get_language_pack(locale), + "d3_format": conf.get("D3_FORMAT"), "feature_flags": get_feature_flags(), "extra_sequential_color_schemes": conf["EXTRA_SEQUENTIAL_COLOR_SCHEMES"], "extra_categorical_color_schemes": conf["EXTRA_CATEGORICAL_COLOR_SCHEMES"],