mirror of
https://github.com/apache/superset.git
synced 2024-09-17 11:09:47 -04:00
Add @superset-ui/number-format package (#31)
* feat: Add number-format package
This commit is contained in:
parent
af725ce874
commit
1edfdcf1b5
@ -15,6 +15,8 @@ applications that leverage a Superset backend :chart_with_upwards_trend:
|
||||
| [@superset-ui/color](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-color) | [![Version](https://img.shields.io/npm/v/@superset-ui/color.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/color.svg?style=flat-square) |
|
||||
| [@superset-ui/connection](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-connection) | [![Version](https://img.shields.io/npm/v/@superset-ui/connection.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/connection.svg?style=flat-square) |
|
||||
| [@superset-ui/core](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-core) | [![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat-square) |
|
||||
| [@superset-ui/generator-superset](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-generator-superset) | [![Version](https://img.shields.io/npm/v/@superset-ui/generator-superset.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/generator-superset.svg?style=flat-square) |
|
||||
| [@superset-ui/number-format](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-number-format) | [![Version](https://img.shields.io/npm/v/@superset-ui/number-format.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/number-format.svg?style=flat-square) |
|
||||
| [@superset-ui/translation](https://github.com/apache-superset/superset-ui/tree/master/packages/superset-ui-translation) | [![Version](https://img.shields.io/npm/v/@superset-ui/translation.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/translation.svg?style=flat-square) |
|
||||
|
||||
#### Coming :soon:
|
||||
@ -22,7 +24,6 @@ applications that leverage a Superset backend :chart_with_upwards_trend:
|
||||
- Data providers
|
||||
- Embeddable charts
|
||||
- Chart collections
|
||||
- Demo storybook package
|
||||
|
||||
### Development
|
||||
|
||||
|
@ -0,0 +1,57 @@
|
||||
## @superset-ui/number-format
|
||||
|
||||
[![Version](https://img.shields.io/npm/v/@superset-ui/number-format.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/number-format.svg?style=flat)
|
||||
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-number-format&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-number-format)
|
||||
|
||||
Description
|
||||
|
||||
#### Example usage
|
||||
|
||||
Functions `getNumberFormatter` and `formatNumber` should be used instead of calling `d3.format` directly.
|
||||
|
||||
```js
|
||||
import { getNumberFormatter } from '@superset-ui/number-format';
|
||||
const formatter = getNumberFormatter('.2f');
|
||||
console.log(formatter(1000));
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```js
|
||||
import { formatNumber } from '@superset-ui/number-format';
|
||||
console.log(formatNumber('.2f', 1000));
|
||||
```
|
||||
|
||||
It is powered by a registry to support registration of custom formatting, with fallback to `d3.format` and handle error for invalid format string.
|
||||
|
||||
```js
|
||||
import { getNumberFormatterRegistry, formatNumber, NumberFormatter } from '@superset-ui/number-format';
|
||||
|
||||
getNumberFormatterRegistry().registerValue('my_format', new NumberFormatter({
|
||||
id: 'my_format',
|
||||
formatFunc: v => `my special format of ${v}`
|
||||
});
|
||||
|
||||
console.log(formatNumber('my_format', 1000));
|
||||
// prints 'my special format of 1000'
|
||||
```
|
||||
|
||||
It also define constants for common d3 formats. See the full list of formats in [NumberFormats.js](https://github.com/apache-superset/superset-ui/blob/master/packages/superset-ui-number-format/src/NumberFormats.js).
|
||||
|
||||
```js
|
||||
import { NumberFormats } from '@superset-ui-number-format';
|
||||
|
||||
NumberFormats.PERCENT // ,.2%
|
||||
NumberFormats.PERCENT_3_POINT // ,.3%
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
`fn(args)`
|
||||
|
||||
- Do something
|
||||
|
||||
### Development
|
||||
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
||||
builds, jest testing, eslint, and prettier.
|
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@superset-ui/number-format",
|
||||
"version": "0.0.0",
|
||||
"description": "Superset UI number format",
|
||||
"sideEffects": false,
|
||||
"main": "lib/index.js",
|
||||
"module": "esm/index.js",
|
||||
"files": [
|
||||
"esm",
|
||||
"lib"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/apache-superset/superset-ui.git"
|
||||
},
|
||||
"keywords": [
|
||||
"superset"
|
||||
],
|
||||
"author": "Superset",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/apache-superset/superset-ui/issues"
|
||||
},
|
||||
"homepage": "https://github.com/apache-superset/superset-ui#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@superset-ui/core": "^0.6.0",
|
||||
"d3-format": "^1.3.2",
|
||||
"lodash": "^4.17.11"
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
export const DOLLAR = '$,.2f';
|
||||
export const DOLLAR_CHANGE = '+$,.2f';
|
||||
export const DOLLAR_ROUND = '$,d';
|
||||
export const DOLLAR_ROUND_CHANGE = '+$,d';
|
||||
|
||||
export const FLOAT_1_POINT = ',.1f';
|
||||
export const FLOAT_2_POINT = ',.2f';
|
||||
export const FLOAT_3_POINT = ',.3f';
|
||||
export const FLOAT = FLOAT_2_POINT;
|
||||
|
||||
export const FLOAT_CHANGE_1_POINT = '+,.1f';
|
||||
export const FLOAT_CHANGE_2_POINT = '+,.2f';
|
||||
export const FLOAT_CHANGE_3_POINT = '+,.3f';
|
||||
export const FLOAT_CHANGE = FLOAT_CHANGE_2_POINT;
|
||||
|
||||
export const INTEGER = ',d';
|
||||
export const INTEGER_CHANGE = '+,d';
|
||||
|
||||
export const PERCENT_1_POINT = ',.1%';
|
||||
export const PERCENT_2_POINT = ',.2%';
|
||||
export const PERCENT_3_POINT = ',.3%';
|
||||
export const PERCENT = PERCENT_2_POINT;
|
||||
|
||||
export const PERCENT_CHANGE_1_POINT = '+,.1%';
|
||||
export const PERCENT_CHANGE_2_POINT = '+,.2%';
|
||||
export const PERCENT_CHANGE_3_POINT = '+,.3%';
|
||||
export const PERCENT_CHANGE = PERCENT_CHANGE_2_POINT;
|
||||
|
||||
export const SI_1_DIGIT = '.1s';
|
||||
export const SI_2_DIGIT = '.2s';
|
||||
export const SI_3_DIGIT = '.3s';
|
@ -0,0 +1,35 @@
|
||||
import { ExtensibleFunction, isRequired } from '@superset-ui/core';
|
||||
|
||||
export const PREVIEW_VALUE = 12345.432;
|
||||
|
||||
export default class NumberFormatter extends ExtensibleFunction {
|
||||
constructor({
|
||||
id = isRequired('config.id'),
|
||||
label,
|
||||
description = '',
|
||||
formatFunc = isRequired('config.formatFunc'),
|
||||
} = {}) {
|
||||
super((...args) => this.format(...args));
|
||||
|
||||
this.id = id;
|
||||
this.label = label || id;
|
||||
this.description = description;
|
||||
this.formatFunc = formatFunc;
|
||||
}
|
||||
|
||||
format(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) {
|
||||
return value;
|
||||
} else if (value === Number.POSITIVE_INFINITY) {
|
||||
return '∞';
|
||||
} else if (value === Number.NEGATIVE_INFINITY) {
|
||||
return '-∞';
|
||||
}
|
||||
|
||||
return this.formatFunc(value);
|
||||
}
|
||||
|
||||
preview(value = PREVIEW_VALUE) {
|
||||
return `${value} => ${this.format(value)}`;
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { RegistryWithDefaultKey } from '@superset-ui/core';
|
||||
import D3Formatter from './formatters/D3Formatter';
|
||||
import { SI_3_DIGIT } from './NumberFormats';
|
||||
|
||||
const DEFAULT_FORMAT = SI_3_DIGIT;
|
||||
|
||||
export default class NumberFormatterRegistry extends RegistryWithDefaultKey {
|
||||
constructor() {
|
||||
super({
|
||||
initialDefaultKey: DEFAULT_FORMAT,
|
||||
name: 'NumberFormatter',
|
||||
});
|
||||
}
|
||||
|
||||
get(formatterId) {
|
||||
const targetFormat = formatterId || this.defaultKey;
|
||||
|
||||
if (this.has(targetFormat)) {
|
||||
return super.get(targetFormat);
|
||||
}
|
||||
|
||||
// Create new formatter if does not exist
|
||||
const formatter = new D3Formatter(targetFormat);
|
||||
this.registerValue(targetFormat, formatter);
|
||||
|
||||
return formatter;
|
||||
}
|
||||
|
||||
format(formatterId, value) {
|
||||
return this.get(formatterId)(value);
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import { makeSingleton } from '@superset-ui/core';
|
||||
import NumberFormatterRegistry from './NumberFormatterRegistry';
|
||||
|
||||
const getInstance = makeSingleton(NumberFormatterRegistry);
|
||||
|
||||
export default getInstance;
|
||||
|
||||
export function getNumberFormatter(format) {
|
||||
return getInstance().get(format);
|
||||
}
|
||||
|
||||
export function formatNumber(format, value) {
|
||||
return getInstance().format(format, value);
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import isString from 'lodash/isString';
|
||||
import { format as d3Format } from 'd3-format';
|
||||
import { isRequired } from '@superset-ui/core';
|
||||
import NumberFormatter from '../NumberFormatter';
|
||||
|
||||
export default class D3Formatter extends NumberFormatter {
|
||||
/**
|
||||
* Pass only the D3 format string to constructor
|
||||
*
|
||||
* new D3Formatter('.2f');
|
||||
*
|
||||
* or accompany it with human-readable label and description
|
||||
*
|
||||
* new D3Formatter({
|
||||
* id: '.2f',
|
||||
* label: 'Float with 2 decimal points',
|
||||
* description: 'lorem ipsum dolor sit amet',
|
||||
* });
|
||||
*
|
||||
* @param {String|Object} configOrFormatString
|
||||
*/
|
||||
constructor(configOrFormatString = isRequired('configOrFormatString')) {
|
||||
const config = isString(configOrFormatString)
|
||||
? { id: configOrFormatString }
|
||||
: configOrFormatString;
|
||||
|
||||
let formatFunc;
|
||||
let isInvalid = false;
|
||||
|
||||
try {
|
||||
formatFunc = d3Format(config.id);
|
||||
} catch (e) {
|
||||
formatFunc = () => `Invalid format: ${config.id}`;
|
||||
isInvalid = true;
|
||||
}
|
||||
|
||||
super({ ...config, formatFunc });
|
||||
this.isInvalid = isInvalid;
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { format as d3Format } from 'd3-format';
|
||||
import NumberFormatter from '../NumberFormatter';
|
||||
|
||||
export default class SiAtMostNDigitFormatter extends NumberFormatter {
|
||||
constructor(n = 3) {
|
||||
const siFormatter = d3Format(`.${n}s`);
|
||||
|
||||
super({
|
||||
formatFunc: value => {
|
||||
const si = siFormatter(value);
|
||||
|
||||
// Removing trailing `.00` if any
|
||||
return si.slice(-1) < 'A' ? parseFloat(si).toString() : si;
|
||||
},
|
||||
id: `si_at_most_${n}_digit`,
|
||||
label: `SI with at most ${n} significant digits`,
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import * as NumberFormats from './NumberFormats';
|
||||
|
||||
export {
|
||||
default as getNumberFormatterRegistry,
|
||||
formatNumber,
|
||||
getNumberFormatter,
|
||||
} from './NumberFormatterRegistrySingleton';
|
||||
|
||||
export { default as NumberFormatter, PREVIEW_VALUE } from './NumberFormatter';
|
||||
export { NumberFormats };
|
@ -0,0 +1,64 @@
|
||||
import NumberFormatter from '../src/NumberFormatter';
|
||||
|
||||
describe('NumberFormatter', () => {
|
||||
describe('new NumberFormatter(config)', () => {
|
||||
it('requires config.id', () => {
|
||||
expect(() => new NumberFormatter()).toThrow();
|
||||
});
|
||||
it('requires config.formatFunc', () => {
|
||||
expect(
|
||||
() =>
|
||||
new NumberFormatter({
|
||||
id: 'my_format',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
describe('formatter is also a format function itself', () => {
|
||||
const formatter = new NumberFormatter({
|
||||
id: 'fixed_3',
|
||||
formatFunc: value => value.toFixed(3),
|
||||
});
|
||||
it('returns formatted value', () => {
|
||||
expect(formatter(12345.67)).toEqual('12345.670');
|
||||
});
|
||||
it('formatter(value) is the same with formatter.format(value)', () => {
|
||||
const value = 12345.67;
|
||||
expect(formatter(value)).toEqual(formatter.format(value));
|
||||
});
|
||||
});
|
||||
describe('.format(value)', () => {
|
||||
const formatter = new NumberFormatter({
|
||||
id: 'fixed_3',
|
||||
formatFunc: value => value.toFixed(3),
|
||||
});
|
||||
it('handles null', () => {
|
||||
expect(formatter.format(null)).toBeNull();
|
||||
});
|
||||
it('handles undefined', () => {
|
||||
expect(formatter.format(undefined)).toBeUndefined();
|
||||
});
|
||||
it('handles NaN', () => {
|
||||
expect(formatter.format(NaN)).toBeNaN();
|
||||
});
|
||||
it('handles positive and negative infinity', () => {
|
||||
expect(formatter.format(Number.POSITIVE_INFINITY)).toEqual('∞');
|
||||
expect(formatter.format(Number.NEGATIVE_INFINITY)).toEqual('-∞');
|
||||
});
|
||||
it('otherwise returns formatted value', () => {
|
||||
expect(formatter.format(12345.67)).toEqual('12345.670');
|
||||
});
|
||||
});
|
||||
describe('.preview(value)', () => {
|
||||
const formatter = new NumberFormatter({
|
||||
id: 'fixed_2',
|
||||
formatFunc: value => value.toFixed(2),
|
||||
});
|
||||
it('returns string comparing value before and after formatting', () => {
|
||||
expect(formatter.preview(100)).toEqual('100 => 100.00');
|
||||
});
|
||||
it('uses the default preview value if not specified', () => {
|
||||
expect(formatter.preview()).toEqual('12345.432 => 12345.43');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,32 @@
|
||||
import NumberFormatterRegistry from '../src/NumberFormatterRegistry';
|
||||
import NumberFormatter from '../src/NumberFormatter';
|
||||
|
||||
describe('NumberFormatterRegistry', () => {
|
||||
let registry;
|
||||
beforeEach(() => {
|
||||
registry = new NumberFormatterRegistry();
|
||||
});
|
||||
describe('.get(format)', () => {
|
||||
it('creates and returns a new formatter if does not exist', () => {
|
||||
const formatter = registry.get('.2f');
|
||||
expect(formatter).toBeInstanceOf(NumberFormatter);
|
||||
expect(formatter.format(100)).toEqual('100.00');
|
||||
});
|
||||
it('returns an existing formatter if already exists', () => {
|
||||
const formatter = registry.get('.2f');
|
||||
const formatter2 = registry.get('.2f');
|
||||
expect(formatter).toBe(formatter2);
|
||||
});
|
||||
it('falls back to default format if format is not specified', () => {
|
||||
registry.setDefaultKey('.1f');
|
||||
const formatter = registry.get();
|
||||
expect(formatter.format(100)).toEqual('100.0');
|
||||
});
|
||||
});
|
||||
describe('.format(format, value)', () => {
|
||||
it('return the value with the specified format', () => {
|
||||
expect(registry.format('.2f', 100)).toEqual('100.00');
|
||||
expect(registry.format(',d', 100)).toEqual('100');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,29 @@
|
||||
import getNumberFormatterRegistry, {
|
||||
getNumberFormatter,
|
||||
formatNumber,
|
||||
} from '../src/NumberFormatterRegistrySingleton';
|
||||
import NumberFormatterRegistry from '../src/NumberFormatterRegistry';
|
||||
|
||||
describe('NumberFormatterRegistrySingleton', () => {
|
||||
describe('getNumberFormatterRegistry()', () => {
|
||||
it('returns a NumberFormatterRegisry', () => {
|
||||
expect(getNumberFormatterRegistry()).toBeInstanceOf(NumberFormatterRegistry);
|
||||
});
|
||||
});
|
||||
describe('getNumberFormatter(format)', () => {
|
||||
it('returns a format function', () => {
|
||||
const format = getNumberFormatter('.3s');
|
||||
expect(format(12345)).toEqual('12.3k');
|
||||
});
|
||||
it('returns a format function even given invalid format', () => {
|
||||
const format = getNumberFormatter('xkcd');
|
||||
expect(format(12345)).toEqual('Invalid format: xkcd');
|
||||
});
|
||||
});
|
||||
describe('formatNumber(format, value)', () => {
|
||||
it('format the given number using the specified format', () => {
|
||||
const output = formatNumber('.3s', 12345);
|
||||
expect(output).toEqual('12.3k');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,28 @@
|
||||
import D3Formatter from '../../src/formatters/D3Formatter';
|
||||
|
||||
describe('D3Formatter', () => {
|
||||
describe('new D3Formatter(config)', () => {
|
||||
it('requires configOrFormatString', () => {
|
||||
expect(() => new D3Formatter()).toThrow();
|
||||
});
|
||||
describe('if configOrFormatString is string', () => {
|
||||
it('uses the input as d3.format string', () => {
|
||||
const formatter = new D3Formatter('.2f');
|
||||
expect(formatter.format(100)).toEqual('100.00');
|
||||
});
|
||||
});
|
||||
describe('if configOrFormatString is not string', () => {
|
||||
it('requires field config.id', () => {
|
||||
expect(() => new D3Formatter({})).toThrow();
|
||||
});
|
||||
it('uses d3.format(config.id) as format function', () => {
|
||||
const formatter = new D3Formatter({ id: ',.4f' });
|
||||
expect(formatter.format(12345.67)).toEqual('12,345.6700');
|
||||
});
|
||||
it('if it is an invalid d3 format, the format function displays error message', () => {
|
||||
const formatter = new D3Formatter({ id: 'i-am-groot' });
|
||||
expect(formatter.format(12345.67)).toEqual('Invalid format: i-am-groot');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,51 @@
|
||||
import NumberFormatter from '../../src/NumberFormatter';
|
||||
import SiAtMostNDigitFormatter from '../../src/formatters/SiAtMostNDigitFormatter';
|
||||
|
||||
describe('SiAtMostNDigitFormatter', () => {
|
||||
describe('new SiAtMostNDigitFormatter(n)', () => {
|
||||
it('creates an instance of NumberFormatter', () => {
|
||||
const formatter = new SiAtMostNDigitFormatter(3);
|
||||
expect(formatter).toBeInstanceOf(NumberFormatter);
|
||||
});
|
||||
it('when n is specified, it formats number in SI format with at most n significant digits', () => {
|
||||
const formatter = new SiAtMostNDigitFormatter(2);
|
||||
expect(formatter(10)).toBe('10');
|
||||
expect(formatter(1)).toBe('1');
|
||||
expect(formatter(1.0)).toBe('1');
|
||||
expect(formatter(10.0)).toBe('10');
|
||||
expect(formatter(10001)).toBe('10k');
|
||||
expect(formatter(10100)).toBe('10k');
|
||||
expect(formatter(111000000)).toBe('110M');
|
||||
expect(formatter(0.23)).toBe('230m');
|
||||
expect(formatter(0)).toBe('0');
|
||||
expect(formatter(-10)).toBe('-10');
|
||||
expect(formatter(-1)).toBe('-1');
|
||||
expect(formatter(-1.0)).toBe('-1');
|
||||
expect(formatter(-10.0)).toBe('-10');
|
||||
expect(formatter(-10001)).toBe('-10k');
|
||||
expect(formatter(-10101)).toBe('-10k');
|
||||
expect(formatter(-111000000)).toBe('-110M');
|
||||
expect(formatter(-0.23)).toBe('-230m');
|
||||
});
|
||||
it('when n is not specified, it defaults to n=3', () => {
|
||||
const formatter = new SiAtMostNDigitFormatter(3);
|
||||
expect(formatter(10)).toBe('10');
|
||||
expect(formatter(1)).toBe('1');
|
||||
expect(formatter(1.0)).toBe('1');
|
||||
expect(formatter(10.0)).toBe('10');
|
||||
expect(formatter(10001)).toBe('10.0k');
|
||||
expect(formatter(10100)).toBe('10.1k');
|
||||
expect(formatter(111000000)).toBe('111M');
|
||||
expect(formatter(0.23)).toBe('230m');
|
||||
expect(formatter(0)).toBe('0');
|
||||
expect(formatter(-10)).toBe('-10');
|
||||
expect(formatter(-1)).toBe('-1');
|
||||
expect(formatter(-1.0)).toBe('-1');
|
||||
expect(formatter(-10.0)).toBe('-10');
|
||||
expect(formatter(-10001)).toBe('-10.0k');
|
||||
expect(formatter(-10101)).toBe('-10.1k');
|
||||
expect(formatter(-111000000)).toBe('-111M');
|
||||
expect(formatter(-0.23)).toBe('-230m');
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,21 @@
|
||||
import {
|
||||
formatNumber,
|
||||
NumberFormats,
|
||||
getNumberFormatter,
|
||||
getNumberFormatterRegistry,
|
||||
NumberFormatter,
|
||||
PREVIEW_VALUE,
|
||||
} from '../src/index';
|
||||
|
||||
describe('index', () => {
|
||||
it('exports modules', () => {
|
||||
[
|
||||
formatNumber,
|
||||
NumberFormats,
|
||||
getNumberFormatter,
|
||||
getNumberFormatterRegistry,
|
||||
NumberFormatter,
|
||||
PREVIEW_VALUE,
|
||||
].forEach(x => expect(x).toBeDefined());
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user