[data views] Default field formatters based on field meta values (#174973)

## Summary

Default field formatters based on field meta units data.

Note: the smallest unit our formatter will show is milliseconds which
means micro and nanoseconds may round down to zero for smaller values.
https://github.com/elastic/kibana/issues/176112


Closes: https://github.com/elastic/kibana/issues/82318

Mapping and doc setup for testing - 

```
PUT my-index-000001

PUT my-index-000001/_mapping
{
    "properties": {
            "nanos": {
        "type": "long",
        "meta": {
          "unit": "nanos"
        }
      },
            "micros": {
        "type": "long",
        "meta": {
          "unit": "micros"
        }
      },
      "ms": {
        "type": "long",
        "meta": {
          "unit": "ms"
        }
      },
                  "second": {
        "type": "long",
        "meta": {
          "unit": "s"
        }
      },
                  "minute": {
        "type": "long",
        "meta": {
          "unit": "m"
        }
      },
                  "hour": {
        "type": "long",
        "meta": {
          "unit": "h"
        }
      },
                  "day": {
        "type": "long",
        "meta": {
          "unit": "d"
        }
      },
      "percent": {
        "type": "long",
        "meta": {
          "unit": "percent"
        }
      },
            "bytes": {
        "type": "long",
        "meta": {
          "unit": "byte"
        }
      }
    }
}

POST my-index-000001/_doc
{
  "nanos" : 1234.5,
  "micros" : 1234.5,
  "ms" : 1234.5,
  "second" : 1234.5,
  "minute" : 1234.5,
  "hour" : 1234.5,
  "day" : 1234.5,
  "percent" : 1234.5,
  "bytes" : 1234.5
}

```
This commit is contained in:
Matthew Kime 2024-02-06 15:33:27 -06:00 committed by GitHub
parent a50f0ae1a6
commit cde8f2be67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 128 additions and 1 deletions

View file

@ -7,6 +7,7 @@ FldList [
"conflictDescriptions": undefined,
"count": 5,
"customLabel": "A Runtime Field",
"defaultFormatter": undefined,
"esTypes": Array [
"keyword",
],

View file

@ -24,6 +24,7 @@ import type {
RuntimeField,
} from '../types';
import { removeFieldAttrs } from './utils';
import { metaUnitsToFormatter } from './meta_units_to_formatter';
import type { DataViewAttributes, FieldAttrs, FieldAttrSet } from '..';
@ -251,6 +252,11 @@ export abstract class AbstractDataView {
return fieldFormat;
}
const fmt = field.defaultFormatter ? metaUnitsToFormatter[field.defaultFormatter] : undefined;
if (fmt) {
return this.fieldFormats.getInstance(fmt.id, fmt.params);
}
return this.fieldFormats.getDefaultInstance(
field.type as KBN_FIELD_TYPES,
field.esTypes as ES_FIELD_TYPES[]

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FieldFormatParams } from '@kbn/field-formats-plugin/common';
const timeUnitToDurationFmt = (inputFormat = 'milliseconds') => {
return {
id: 'duration',
params: {
inputFormat,
outputFormat: 'humanizePrecise',
outputPrecision: 2,
includeSpaceWithSuffix: true,
useShortSuffix: true,
},
};
};
export const metaUnitsToFormatter: Record<string, { id: string; params?: FieldFormatParams }> = {
percent: { id: 'percent' },
byte: { id: 'bytes' },
nanos: timeUnitToDurationFmt('nanoseconds'),
micros: timeUnitToDurationFmt('microseconds'),
ms: timeUnitToDurationFmt('milliseconds'),
s: timeUnitToDurationFmt('seconds'),
m: timeUnitToDurationFmt('minutes'),
h: timeUnitToDurationFmt('hours'),
d: timeUnitToDurationFmt('days'),
};

View file

@ -69,6 +69,10 @@ export class DataViewField implements DataViewFieldBase {
this.spec.count = count;
}
public get defaultFormatter() {
return this.spec.defaultFormatter;
}
/**
* Returns runtime field definition or undefined if field is not runtime field.
*/
@ -370,6 +374,7 @@ export class DataViewField implements DataViewFieldBase {
readFromDocValues: this.readFromDocValues,
subType: this.subType,
customLabel: this.customLabel,
defaultFormatter: this.defaultFormatter,
};
}
@ -403,6 +408,7 @@ export class DataViewField implements DataViewFieldBase {
timeSeriesMetric: this.spec.timeSeriesMetric,
timeZone: this.spec.timeZone,
fixedInterval: this.spec.fixedInterval,
defaultFormatter: this.defaultFormatter,
};
// Filter undefined values from the spec

View file

@ -462,6 +462,8 @@ export type FieldSpec = DataViewFieldBase & {
* Name of parent field for composite runtime field subfields.
*/
parentName?: string;
defaultFormatter?: string;
};
export type DataViewFieldMap = Record<string, FieldSpec>;

View file

@ -32,6 +32,7 @@ export interface FieldDescriptor {
timeZone?: string[];
timeSeriesMetric?: estypes.MappingTimeSeriesMetricType;
timeSeriesDimension?: boolean;
defaultFormatter?: string;
}
interface FieldSubType {

View file

@ -165,5 +165,22 @@ describe('index_patterns/field_capabilities/field_caps_response', () => {
expect(child).not.toHaveProperty('subType');
});
});
it('sets default field formatter', () => {
const fields = readFieldCapsResponse({
fields: {
seconds: {
long: {
searchable: true,
aggregatable: true,
meta: {
unit: ['s'],
},
},
},
},
});
expect(fields[0].defaultFormatter).toEqual('s');
});
});
});

View file

@ -12,6 +12,11 @@ import { castEsToKbnFieldTypeName } from '@kbn/field-types';
import { shouldReadFieldFromDocValues } from './should_read_field_from_doc_values';
import { FieldDescriptor } from '../..';
// The array will have different values if values vary across indices
const unitsArrayToFormatter = (unitArr: string[]) => {
return unitArr.find((unit) => unitArr[0] !== unit) ? undefined : unitArr[0];
};
/**
* Read the response from the _field_caps API to determine the type and
* "aggregatable"/"searchable" status of each field.
@ -134,7 +139,11 @@ export function readFieldCapsResponse(
timeSeriesMetricType = 'position';
}
const esType = types[0];
const field = {
const defaultFormatter =
capsByType[types[0]].meta?.unit && unitsArrayToFormatter(capsByType[types[0]].meta?.unit);
const field: FieldDescriptor = {
name: fieldName,
type: castEsToKbnFieldTypeName(esType),
esTypes: types,
@ -147,6 +156,11 @@ export function readFieldCapsResponse(
timeSeriesMetric: timeSeriesMetricType,
timeSeriesDimension: capsByType[types[0]].time_series_dimension,
};
if (defaultFormatter) {
field.defaultFormatter = defaultFormatter;
}
// This is intentionally using a "hash" and a "push" to be highly optimized with very large indexes
agg.array.push(field);
agg.hash[fieldName] = field;

View file

@ -99,6 +99,7 @@ const FieldDescriptorSchema = schema.object({
conflictDescriptions: schema.maybe(
schema.recordOf(schema.string(), schema.arrayOf(schema.string()))
),
defaultFormatter: schema.maybe(schema.string()),
});
export const validate: FullValidationConfig<any, any, any> = {

View file

@ -413,6 +413,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
]);
});
});
describe('default formatter by field meta value', () => {
const indexTitle = 'field_formats_management_functional_tests';
before(async () => {
if (await es.indices.exists({ index: indexTitle })) {
await es.indices.delete({ index: indexTitle });
}
});
it('should apply default formatter by field meta value', async () => {
await es.indices.create({
index: indexTitle,
body: {
mappings: {
properties: {
seconds: { type: 'long', meta: { unit: 's' } },
},
},
},
});
const docResult = await es.index({
index: indexTitle,
body: { seconds: 1234 },
refresh: 'wait_for',
});
const testDocumentId = docResult._id;
const indexPatternResult = await indexPatterns.create(
{ title: `${indexTitle}*` }, // sidesteps field caching when index pattern is reused
{ override: true }
);
await PageObjects.common.navigateToApp('discover', {
hash: `/doc/${indexPatternResult.id}/${indexTitle}?id=${testDocumentId}`,
});
await testSubjects.exists('doc-hit');
const renderedValue = await testSubjects.find(`tableDocViewRow-seconds-value`);
const text = await renderedValue.getVisibleText();
expect(text).to.be('20.57 min');
});
});
});
/**