[Index Patterns Field Formatter] Added human readable precise formatter for duration (#100540) (#101046)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Shahzad <shahzad.muhammad@elastic.co>
This commit is contained in:
Kibana Machine 2021-06-01 09:59:27 -04:00 committed by GitHub
parent 3295128346
commit c0fe7d0834
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 630 additions and 16 deletions

View file

@ -139,17 +139,182 @@ describe('Duration Format', () => {
],
});
testCase({
inputFormat: 'nanoseconds',
outputFormat: 'humanizePrecise',
outputPrecision: 2,
showSuffix: true,
fixtures: [
{
input: 1988,
output: '0.00 Milliseconds',
},
{
input: 658,
output: '0.00 Milliseconds',
},
{
input: 3857,
output: '0.00 Milliseconds',
},
],
});
testCase({
inputFormat: 'microseconds',
outputFormat: 'humanizePrecise',
outputPrecision: 2,
showSuffix: true,
fixtures: [
{
input: 1988,
output: '1.99 Milliseconds',
},
{
input: 658,
output: '0.66 Milliseconds',
},
{
input: 3857,
output: '3.86 Milliseconds',
},
],
});
testCase({
inputFormat: 'microseconds',
outputFormat: 'humanizePrecise',
outputPrecision: 1,
showSuffix: true,
fixtures: [
{
input: 1988,
output: '2.0 Milliseconds',
},
{
input: 0,
output: '0.0 Milliseconds',
},
{
input: 658,
output: '0.7 Milliseconds',
},
{
input: 3857,
output: '3.9 Milliseconds',
},
],
});
testCase({
inputFormat: 'seconds',
outputFormat: 'humanizePrecise',
outputPrecision: 0,
showSuffix: true,
fixtures: [
{
input: 600,
output: '10 Minutes',
},
{
input: 30,
output: '30 Seconds',
},
{
input: 3000,
output: '50 Minutes',
},
],
});
testCase({
inputFormat: 'milliseconds',
outputFormat: 'humanizePrecise',
outputPrecision: 0,
showSuffix: true,
useShortSuffix: true,
fixtures: [
{
input: -123,
output: '-123 ms',
},
{
input: 1,
output: '1 ms',
},
{
input: 600,
output: '600 ms',
},
{
input: 30,
output: '30 ms',
},
{
input: 3000,
output: '3 s',
},
{
input: 300000,
output: '5 min',
},
{
input: 30000000,
output: '8 h',
},
{
input: 90000000,
output: '1 d',
},
{
input: 9000000000,
output: '3 mon',
},
{
input: 99999999999,
output: '3 y',
},
],
});
testCase({
inputFormat: 'milliseconds',
outputFormat: 'humanizePrecise',
outputPrecision: 0,
showSuffix: true,
useShortSuffix: true,
includeSpaceWithSuffix: false,
fixtures: [
{
input: -123,
output: '-123ms',
},
{
input: 1,
output: '1ms',
},
{
input: 600,
output: '600ms',
},
],
});
function testCase({
inputFormat,
outputFormat,
outputPrecision,
showSuffix,
useShortSuffix,
includeSpaceWithSuffix,
fixtures,
}: {
inputFormat: string;
outputFormat: string;
outputPrecision: number | undefined;
showSuffix: boolean | undefined;
useShortSuffix?: boolean;
includeSpaceWithSuffix?: boolean;
fixtures: any[];
}) {
fixtures.forEach((fixture: Record<string, any>) => {
@ -160,7 +325,14 @@ describe('Duration Format', () => {
outputPrecision ? `, ${outputPrecision} decimals` : ''
}`, () => {
const duration = new DurationFormat(
{ inputFormat, outputFormat, outputPrecision, showSuffix },
{
inputFormat,
outputFormat,
outputPrecision,
showSuffix,
useShortSuffix,
includeSpaceWithSuffix,
},
jest.fn()
);
expect(duration.convert(input)).toBe(output);

View file

@ -18,6 +18,7 @@ const ratioToSeconds: Record<string, number> = {
microseconds: 0.000001,
};
const HUMAN_FRIENDLY = 'humanize';
const HUMAN_FRIENDLY_PRECISE = 'humanizePrecise';
const DEFAULT_OUTPUT_PRECISION = 2;
const DEFAULT_INPUT_FORMAT = {
text: i18n.translate('data.fieldFormats.duration.inputFormats.seconds', {
@ -89,59 +90,89 @@ const inputFormats = [
},
];
const DEFAULT_OUTPUT_FORMAT = {
text: i18n.translate('data.fieldFormats.duration.outputFormats.humanize', {
defaultMessage: 'Human Readable',
text: i18n.translate('data.fieldFormats.duration.outputFormats.humanize.approximate', {
defaultMessage: 'Human-readable (approximate)',
}),
method: 'humanize',
};
const outputFormats = [
{ ...DEFAULT_OUTPUT_FORMAT },
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.humanize.precise', {
defaultMessage: 'Human-readable (precise)',
}),
method: 'humanizePrecise',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asMilliseconds', {
defaultMessage: 'Milliseconds',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asMilliseconds.short', {
defaultMessage: 'ms',
}),
method: 'asMilliseconds',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asSeconds', {
defaultMessage: 'Seconds',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asSeconds.short', {
defaultMessage: 's',
}),
method: 'asSeconds',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asMinutes', {
defaultMessage: 'Minutes',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asMinutes.short', {
defaultMessage: 'min',
}),
method: 'asMinutes',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asHours', {
defaultMessage: 'Hours',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asHours.short', {
defaultMessage: 'h',
}),
method: 'asHours',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asDays', {
defaultMessage: 'Days',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asDays.short', {
defaultMessage: 'd',
}),
method: 'asDays',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asWeeks', {
defaultMessage: 'Weeks',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asWeeks.short', {
defaultMessage: 'w',
}),
method: 'asWeeks',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asMonths', {
defaultMessage: 'Months',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asMonths.short', {
defaultMessage: 'mon',
}),
method: 'asMonths',
},
{
text: i18n.translate('data.fieldFormats.duration.outputFormats.asYears', {
defaultMessage: 'Years',
}),
shortText: i18n.translate('data.fieldFormats.duration.outputFormats.asYears.short', {
defaultMessage: 'y',
}),
method: 'asYears',
},
];
@ -154,6 +185,29 @@ function parseInputAsDuration(val: number, inputFormat: string) {
return moment.duration(val * ratio, kind);
}
function formatInputHumanPrecise(
val: number,
inputFormat: string,
outputPrecision: number,
useShortSuffix: boolean,
includeSpace: string
) {
const ratio = ratioToSeconds[inputFormat] || 1;
const kind = (inputFormat in ratioToSeconds
? 'seconds'
: inputFormat) as unitOfTime.DurationConstructor;
const valueInDuration = moment.duration(val * ratio, kind);
return formatDuration(
val,
valueInDuration,
inputFormat,
outputPrecision,
useShortSuffix,
includeSpace
);
}
export class DurationFormat extends FieldFormat {
static id = FIELD_FORMAT_IDS.DURATION;
static title = i18n.translate('data.fieldFormats.duration.title', {
@ -167,11 +221,17 @@ export class DurationFormat extends FieldFormat {
isHuman() {
return this.param('outputFormat') === HUMAN_FRIENDLY;
}
isHumanPrecise() {
return this.param('outputFormat') === HUMAN_FRIENDLY_PRECISE;
}
getParamDefaults() {
return {
inputFormat: DEFAULT_INPUT_FORMAT.kind,
outputFormat: DEFAULT_OUTPUT_FORMAT.method,
outputPrecision: DEFAULT_OUTPUT_PRECISION,
includeSpaceWithSuffix: true,
};
}
@ -180,19 +240,84 @@ export class DurationFormat extends FieldFormat {
const outputFormat = this.param('outputFormat') as keyof Duration;
const outputPrecision = this.param('outputPrecision');
const showSuffix = Boolean(this.param('showSuffix'));
const useShortSuffix = Boolean(this.param('useShortSuffix'));
const includeSpaceWithSuffix = this.param('includeSpaceWithSuffix');
const includeSpace = includeSpaceWithSuffix ? ' ' : '';
const human = this.isHuman();
const humanPrecise = this.isHumanPrecise();
const prefix =
val < 0 && human
? i18n.translate('data.fieldFormats.duration.negativeLabel', {
defaultMessage: 'minus',
}) + ' '
: '';
const duration = parseInputAsDuration(val, inputFormat) as Record<keyof Duration, Function>;
const formatted = duration[outputFormat]();
const precise = human ? formatted : formatted.toFixed(outputPrecision);
const type = outputFormats.find(({ method }) => method === outputFormat);
const suffix = showSuffix && type ? ` ${type.text}` : '';
return prefix + precise + suffix;
const duration = parseInputAsDuration(val, inputFormat) as Record<keyof Duration, Function>;
const formatted = humanPrecise
? formatInputHumanPrecise(val, inputFormat, outputPrecision, useShortSuffix, includeSpace)
: duration[outputFormat]();
const precise = human || humanPrecise ? formatted : formatted.toFixed(outputPrecision);
const type = outputFormats.find(({ method }) => method === outputFormat);
const unitText = useShortSuffix ? type?.shortText : type?.text;
const suffix = showSuffix && unitText && !human ? `${includeSpace}${unitText}` : '';
return humanPrecise ? precise : prefix + precise + suffix;
};
}
function formatDuration(
val: number,
duration: moment.Duration,
inputFormat: string,
outputPrecision: number,
useShortSuffix: boolean,
includeSpace: string
) {
// return nothing when the duration is falsy or not correctly parsed (P0D)
if (!duration || !duration.isValid()) return;
const units = [
{ unit: duration.years(), nextUnitRate: 12, method: 'asYears' },
{ unit: duration.months(), nextUnitRate: 4, method: 'asMonths' },
{ unit: duration.weeks(), nextUnitRate: 7, method: 'asWeeks' },
{ unit: duration.days(), nextUnitRate: 24, method: 'asDays' },
{ unit: duration.hours(), nextUnitRate: 60, method: 'asHours' },
{ unit: duration.minutes(), nextUnitRate: 60, method: 'asMinutes' },
{ unit: duration.seconds(), nextUnitRate: 1000, method: 'asSeconds' },
{ unit: duration.milliseconds(), nextUnitRate: 1000, method: 'asMilliseconds' },
];
const getUnitText = (method: string) => {
const type = outputFormats.find(({ method: methodT }) => method === methodT);
return useShortSuffix ? type?.shortText : type?.text;
};
for (let i = 0; i < units.length; i++) {
const unitValue = units[i].unit;
if (unitValue >= 1) {
const unitText = getUnitText(units[i].method);
const value = Math.floor(unitValue);
if (units?.[i + 1]) {
const decimalPointValue = Math.floor(units[i + 1].unit);
return (
(value + decimalPointValue / units[i].nextUnitRate).toFixed(outputPrecision) +
includeSpace +
unitText
);
} else {
return unitValue.toFixed(outputPrecision) + includeSpace + unitText;
}
}
}
const unitValue = units[units.length - 1].unit;
const unitText = getUnitText(units[units.length - 1].method);
return unitValue.toFixed(outputPrecision) + includeSpace + unitText;
}

View file

@ -1,5 +1,184 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DurationFormatEditor should not render show suffix on dynamic output 1`] = `
<Fragment>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Input format"
id="indexPatternFieldEditor.duration.inputFormatLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiSelect
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"text": "Seconds",
"value": "seconds",
},
]
}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Output format"
id="indexPatternFieldEditor.duration.outputFormatLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiSelect
isInvalid={false}
onChange={[Function]}
options={
Array [
Object {
"text": "Human Readable",
"value": "humanize",
},
Object {
"text": "Minutes",
"value": "asMinutes",
},
]
}
value="dynamic"
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
error={null}
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
isInvalid={false}
label={
<FormattedMessage
defaultMessage="Decimal places"
id="indexPatternFieldEditor.duration.decimalPlacesLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldNumber
isInvalid={false}
max={20}
min={0}
onChange={[Function]}
value={2}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={false}
disabled={false}
label={
<FormattedMessage
defaultMessage="Use short suffix"
id="indexPatternFieldEditor.duration.showSuffixLabel.short"
values={Object {}}
/>
}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={true}
disabled={false}
label={
<FormattedMessage
defaultMessage="Include space between suffix and value"
id="indexPatternFieldEditor.duration.includeSpace"
values={Object {}}
/>
}
onChange={[Function]}
/>
</EuiFormRow>
<FormatEditorSamples
sampleType="text"
samples={
Array [
Object {
"input": -123,
"output": "converted duration for -123",
},
Object {
"input": 1,
"output": "converted duration for 1",
},
Object {
"input": 12,
"output": "converted duration for 12",
},
Object {
"input": 123,
"output": "converted duration for 123",
},
Object {
"input": 658,
"output": "converted duration for 658",
},
Object {
"input": 1988,
"output": "converted duration for 1988",
},
Object {
"input": 3857,
"output": "converted duration for 3857",
},
Object {
"input": 123292,
"output": "converted duration for 123292",
},
Object {
"input": 923528271,
"output": "converted duration for 923528271",
},
]
}
/>
</Fragment>
`;
exports[`DurationFormatEditor should render human readable output normally 1`] = `
<Fragment>
<EuiFormRow
@ -223,6 +402,48 @@ exports[`DurationFormatEditor should render non-human readable output normally 1
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={false}
disabled={true}
label={
<FormattedMessage
defaultMessage="Use short suffix"
id="indexPatternFieldEditor.duration.showSuffixLabel.short"
values={Object {}}
/>
}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
labelType="label"
>
<EuiSwitch
checked={true}
disabled={true}
label={
<FormattedMessage
defaultMessage="Include space between suffix and value"
id="indexPatternFieldEditor.duration.includeSpace"
values={Object {}}
/>
}
onChange={[Function]}
/>
</EuiFormRow>
<FormatEditorSamples
sampleType="text"
samples={

View file

@ -11,6 +11,7 @@ import { shallow } from 'enzyme';
import { DurationFormatEditor } from './duration';
import { FieldFormat } from 'src/plugins/data/public';
import { EuiSwitch } from '@elastic/eui';
const fieldType = 'number';
const format = {
@ -22,9 +23,11 @@ const format = {
inputFormat: 'seconds',
outputFormat: 'humanize',
outputPrecision: 10,
includeSpaceWithSuffix: true,
};
}),
isHuman: () => true,
isHumanPrecise: () => false,
type: {
inputFormats: [
{
@ -78,6 +81,7 @@ describe('DurationFormatEditor', () => {
inputFormat: 'seconds',
outputFormat: 'asMinutes',
outputPrecision: 10,
includeSpaceWithSuffix: true,
};
}),
isHuman: () => false,
@ -91,6 +95,55 @@ describe('DurationFormatEditor', () => {
onError={onError}
/>
);
const labels = component.find(EuiSwitch);
expect(labels.length).toEqual(3);
expect(labels.get(0).props.label.props.defaultMessage).toEqual('Show suffix');
expect(labels.get(1).props.label.props.defaultMessage).toEqual('Use short suffix');
expect(labels.get(2).props.label.props.defaultMessage).toEqual(
'Include space between suffix and value'
);
expect(component).toMatchSnapshot();
});
it('should not render show suffix on dynamic output', async () => {
const newFormat = {
...format,
getParamDefaults: jest.fn().mockImplementation(() => {
return {
inputFormat: 'seconds',
outputFormat: 'dynamic',
outputPrecision: 2,
includeSpaceWithSuffix: true,
};
}),
isHuman: () => false,
isHumanPrecise: () => true,
};
const component = shallow(
<DurationFormatEditor
fieldType={fieldType}
format={(newFormat as unknown) as FieldFormat}
formatParams={{ ...formatParams, outputFormat: 'dynamic' }}
onChange={onChange}
onError={onError}
/>
);
const labels = component.find(EuiSwitch);
expect(labels.length).toEqual(2);
const useShortSuffixSwitch = labels.get(0);
expect(useShortSuffixSwitch.props.label.props.defaultMessage).toEqual('Use short suffix');
expect(useShortSuffixSwitch.props.disabled).toEqual(false);
const includeSpaceSwitch = labels.get(1);
expect(includeSpaceSwitch.props.disabled).toEqual(false);
expect(includeSpaceSwitch.props.label.props.defaultMessage).toEqual(
'Include space between suffix and value'
);
expect(component).toMatchSnapshot();
});
});

View file

@ -41,6 +41,8 @@ interface DurationFormatEditorFormatParams {
inputFormat: string;
outputFormat: string;
showSuffix?: boolean;
useShortSuffix?: boolean;
includeSpaceWithSuffix?: boolean;
}
export class DurationFormatEditor extends DefaultFormatEditor<
@ -83,9 +85,14 @@ export class DurationFormatEditor extends DefaultFormatEditor<
}
render() {
const { format, formatParams } = this.props;
const { format } = this.props;
const { error, samples, hasDecimalError } = this.state;
const formatParams: DurationFormatEditorFormatParams = {
includeSpaceWithSuffix: format.getParamDefaults().includeSpaceWithSuffix,
...this.props.formatParams,
};
return (
<Fragment>
<EuiFormRow
@ -159,17 +166,55 @@ export class DurationFormatEditor extends DefaultFormatEditor<
isInvalid={!!error}
/>
</EuiFormRow>
{!(format as DurationFormat).isHumanPrecise() && (
<EuiFormRow>
<EuiSwitch
label={
<FormattedMessage
id="indexPatternFieldEditor.duration.showSuffixLabel"
defaultMessage="Show suffix"
/>
}
checked={Boolean(formatParams.showSuffix)}
onChange={(e) => {
this.onChange({
showSuffix: !formatParams.showSuffix,
});
}}
/>
</EuiFormRow>
)}
<EuiFormRow>
<EuiSwitch
disabled={
!Boolean(formatParams.showSuffix) && !(format as DurationFormat).isHumanPrecise()
}
label={
<FormattedMessage
id="indexPatternFieldEditor.duration.showSuffixLabel"
defaultMessage="Show suffix"
id="indexPatternFieldEditor.duration.showSuffixLabel.short"
defaultMessage="Use short suffix"
/>
}
checked={Boolean(formatParams.showSuffix)}
checked={Boolean(formatParams.useShortSuffix)}
onChange={(e) => {
this.onChange({ showSuffix: !formatParams.showSuffix });
this.onChange({ useShortSuffix: !formatParams.useShortSuffix });
}}
/>
</EuiFormRow>
<EuiFormRow>
<EuiSwitch
disabled={
!Boolean(formatParams.showSuffix) && !(format as DurationFormat).isHumanPrecise()
}
label={
<FormattedMessage
id="indexPatternFieldEditor.duration.includeSpace"
defaultMessage="Include space between suffix and value"
/>
}
checked={Boolean(formatParams.includeSpaceWithSuffix)}
onChange={(e) => {
this.onChange({ includeSpaceWithSuffix: !formatParams.includeSpaceWithSuffix });
}}
/>
</EuiFormRow>

View file

@ -821,7 +821,6 @@
"data.fieldFormats.duration.outputFormats.asSeconds": "秒",
"data.fieldFormats.duration.outputFormats.asWeeks": "週間",
"data.fieldFormats.duration.outputFormats.asYears": "年",
"data.fieldFormats.duration.outputFormats.humanize": "人間に読解可能",
"data.fieldFormats.duration.title": "期間",
"data.fieldFormats.histogram.title": "ヒストグラム",
"data.fieldFormats.ip.title": "IP アドレス",

View file

@ -824,7 +824,6 @@
"data.fieldFormats.duration.outputFormats.asSeconds": "秒",
"data.fieldFormats.duration.outputFormats.asWeeks": "周",
"data.fieldFormats.duration.outputFormats.asYears": "年",
"data.fieldFormats.duration.outputFormats.humanize": "可人工读取",
"data.fieldFormats.duration.title": "持续时间",
"data.fieldFormats.histogram.title": "直方图",
"data.fieldFormats.ip.title": "IP 地址",