diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx index d32b0ccfadf6f..209608c260eca 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx @@ -1417,6 +1417,18 @@ describe('MetricVisComponent', function () { }); }); + it('does not override duration configuration at visualization level when set', () => { + getFormattedMetrics(394.2393, 983123.984, { + id: 'duration', + params: { formatOverride: true, outputFormat: 'asSeconds' }, + }); + expect(mockDeserialize).toHaveBeenCalledTimes(2); + expect(mockDeserialize).toHaveBeenCalledWith({ + id: 'duration', + params: { formatOverride: true, outputFormat: 'asSeconds' }, + }); + }); + it('does not tweak bytes format when passed', () => { getFormattedMetrics(394.2393, 983123.984, { id: 'bytes', diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx index 87154cd3dba73..f0aa3c3e1dede 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -49,7 +49,7 @@ export const defaultColor = euiThemeVars.euiColorLightestShade; function enhanceFieldFormat(serializedFieldFormat: SerializedFieldFormat | undefined) { const formatId = serializedFieldFormat?.id || 'number'; - if (formatId === 'duration') { + if (formatId === 'duration' && !serializedFieldFormat?.params?.formatOverride) { return { ...serializedFieldFormat, params: { diff --git a/src/plugins/field_formats/common/constants/duration_formats.ts b/src/plugins/field_formats/common/constants/duration_formats.ts new file mode 100644 index 0000000000000..ab83b71c276b1 --- /dev/null +++ b/src/plugins/field_formats/common/constants/duration_formats.ts @@ -0,0 +1,171 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +const DEFAULT_INPUT_FORMAT = { + text: i18n.translate('fieldFormats.duration.inputFormats.seconds', { + defaultMessage: 'Seconds', + }), + kind: 'seconds', +}; +const inputFormats = [ + { + text: i18n.translate('fieldFormats.duration.inputFormats.picoseconds', { + defaultMessage: 'Picoseconds', + }), + kind: 'picoseconds', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.nanoseconds', { + defaultMessage: 'Nanoseconds', + }), + kind: 'nanoseconds', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.microseconds', { + defaultMessage: 'Microseconds', + }), + kind: 'microseconds', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.milliseconds', { + defaultMessage: 'Milliseconds', + }), + kind: 'milliseconds', + }, + { ...DEFAULT_INPUT_FORMAT }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.minutes', { + defaultMessage: 'Minutes', + }), + kind: 'minutes', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.hours', { + defaultMessage: 'Hours', + }), + kind: 'hours', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.days', { + defaultMessage: 'Days', + }), + kind: 'days', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.weeks', { + defaultMessage: 'Weeks', + }), + kind: 'weeks', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.months', { + defaultMessage: 'Months', + }), + kind: 'months', + }, + { + text: i18n.translate('fieldFormats.duration.inputFormats.years', { + defaultMessage: 'Years', + }), + kind: 'years', + }, +]; +const DEFAULT_OUTPUT_FORMAT = { + text: i18n.translate('fieldFormats.duration.outputFormats.humanize.approximate', { + defaultMessage: 'Human-readable (approximate)', + }), + method: 'humanize', +}; +const outputFormats = [ + { ...DEFAULT_OUTPUT_FORMAT }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.humanize.precise', { + defaultMessage: 'Human-readable (precise)', + }), + method: 'humanizePrecise', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asMilliseconds', { + defaultMessage: 'Milliseconds', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asMilliseconds.short', { + defaultMessage: 'ms', + }), + method: 'asMilliseconds', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asSeconds', { + defaultMessage: 'Seconds', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asSeconds.short', { + defaultMessage: 's', + }), + method: 'asSeconds', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asMinutes', { + defaultMessage: 'Minutes', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asMinutes.short', { + defaultMessage: 'min', + }), + method: 'asMinutes', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asHours', { + defaultMessage: 'Hours', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asHours.short', { + defaultMessage: 'h', + }), + method: 'asHours', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asDays', { + defaultMessage: 'Days', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asDays.short', { + defaultMessage: 'd', + }), + method: 'asDays', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asWeeks', { + defaultMessage: 'Weeks', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asWeeks.short', { + defaultMessage: 'w', + }), + method: 'asWeeks', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asMonths', { + defaultMessage: 'Months', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asMonths.short', { + defaultMessage: 'mon', + }), + method: 'asMonths', + }, + { + text: i18n.translate('fieldFormats.duration.outputFormats.asYears', { + defaultMessage: 'Years', + }), + shortText: i18n.translate('fieldFormats.duration.outputFormats.asYears.short', { + defaultMessage: 'y', + }), + method: 'asYears', + }, +]; + +export const DEFAULT_DURATION_INPUT_FORMAT = DEFAULT_INPUT_FORMAT; +export const DEFAULT_DURATION_OUTPUT_FORMAT = DEFAULT_OUTPUT_FORMAT; +export const DURATION_INPUT_FORMATS = inputFormats; +export const DURATION_OUTPUT_FORMATS = outputFormats; diff --git a/src/plugins/field_formats/common/converters/duration.ts b/src/plugins/field_formats/common/converters/duration.ts index 72f893b59ef4a..1579d6058e98c 100644 --- a/src/plugins/field_formats/common/converters/duration.ts +++ b/src/plugins/field_formats/common/converters/duration.ts @@ -11,171 +11,22 @@ import moment, { unitOfTime, Duration } from 'moment'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { FieldFormat } from '../field_format'; import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; +import { + DEFAULT_DURATION_INPUT_FORMAT, + DEFAULT_DURATION_OUTPUT_FORMAT, + DURATION_INPUT_FORMATS, + DURATION_OUTPUT_FORMATS, +} from '../constants/duration_formats'; const ratioToSeconds: Record = { picoseconds: 0.000000000001, nanoseconds: 0.000000001, microseconds: 0.000001, }; + const HUMAN_FRIENDLY = 'humanize'; const HUMAN_FRIENDLY_PRECISE = 'humanizePrecise'; const DEFAULT_OUTPUT_PRECISION = 2; -const DEFAULT_INPUT_FORMAT = { - text: i18n.translate('fieldFormats.duration.inputFormats.seconds', { - defaultMessage: 'Seconds', - }), - kind: 'seconds', -}; -const inputFormats = [ - { - text: i18n.translate('fieldFormats.duration.inputFormats.picoseconds', { - defaultMessage: 'Picoseconds', - }), - kind: 'picoseconds', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.nanoseconds', { - defaultMessage: 'Nanoseconds', - }), - kind: 'nanoseconds', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.microseconds', { - defaultMessage: 'Microseconds', - }), - kind: 'microseconds', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.milliseconds', { - defaultMessage: 'Milliseconds', - }), - kind: 'milliseconds', - }, - { ...DEFAULT_INPUT_FORMAT }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.minutes', { - defaultMessage: 'Minutes', - }), - kind: 'minutes', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.hours', { - defaultMessage: 'Hours', - }), - kind: 'hours', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.days', { - defaultMessage: 'Days', - }), - kind: 'days', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.weeks', { - defaultMessage: 'Weeks', - }), - kind: 'weeks', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.months', { - defaultMessage: 'Months', - }), - kind: 'months', - }, - { - text: i18n.translate('fieldFormats.duration.inputFormats.years', { - defaultMessage: 'Years', - }), - kind: 'years', - }, -]; -const DEFAULT_OUTPUT_FORMAT = { - text: i18n.translate('fieldFormats.duration.outputFormats.humanize.approximate', { - defaultMessage: 'Human-readable (approximate)', - }), - method: 'humanize', -}; -const outputFormats = [ - { ...DEFAULT_OUTPUT_FORMAT }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.humanize.precise', { - defaultMessage: 'Human-readable (precise)', - }), - method: 'humanizePrecise', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asMilliseconds', { - defaultMessage: 'Milliseconds', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asMilliseconds.short', { - defaultMessage: 'ms', - }), - method: 'asMilliseconds', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asSeconds', { - defaultMessage: 'Seconds', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asSeconds.short', { - defaultMessage: 's', - }), - method: 'asSeconds', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asMinutes', { - defaultMessage: 'Minutes', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asMinutes.short', { - defaultMessage: 'min', - }), - method: 'asMinutes', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asHours', { - defaultMessage: 'Hours', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asHours.short', { - defaultMessage: 'h', - }), - method: 'asHours', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asDays', { - defaultMessage: 'Days', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asDays.short', { - defaultMessage: 'd', - }), - method: 'asDays', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asWeeks', { - defaultMessage: 'Weeks', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asWeeks.short', { - defaultMessage: 'w', - }), - method: 'asWeeks', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asMonths', { - defaultMessage: 'Months', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asMonths.short', { - defaultMessage: 'mon', - }), - method: 'asMonths', - }, - { - text: i18n.translate('fieldFormats.duration.outputFormats.asYears', { - defaultMessage: 'Years', - }), - shortText: i18n.translate('fieldFormats.duration.outputFormats.asYears.short', { - defaultMessage: 'y', - }), - method: 'asYears', - }, -]; function parseInputAsDuration(val: number, inputFormat: string) { const ratio = ratioToSeconds[inputFormat] || 1; @@ -214,8 +65,8 @@ export class DurationFormat extends FieldFormat { defaultMessage: 'Duration', }); static fieldType = KBN_FIELD_TYPES.NUMBER; - static inputFormats = inputFormats; - static outputFormats = outputFormats; + static inputFormats = DURATION_INPUT_FORMATS; + static outputFormats = DURATION_OUTPUT_FORMATS; allowsNumericalAggregations = true; isHuman() { @@ -228,8 +79,8 @@ export class DurationFormat extends FieldFormat { getParamDefaults() { return { - inputFormat: DEFAULT_INPUT_FORMAT.kind, - outputFormat: DEFAULT_OUTPUT_FORMAT.method, + inputFormat: DEFAULT_DURATION_INPUT_FORMAT.kind, + outputFormat: DEFAULT_DURATION_OUTPUT_FORMAT.method, outputPrecision: DEFAULT_OUTPUT_PRECISION, includeSpaceWithSuffix: true, }; @@ -261,7 +112,7 @@ export class DurationFormat extends FieldFormat { : duration[outputFormat](); const precise = human || humanPrecise ? formatted : formatted.toFixed(outputPrecision); - const type = outputFormats.find(({ method }) => method === outputFormat); + const type = DURATION_OUTPUT_FORMATS.find(({ method }) => method === outputFormat); const unitText = useShortSuffix ? type?.shortText : type?.text.toLowerCase(); @@ -293,7 +144,7 @@ function formatDuration( ]; const getUnitText = (method: string) => { - const type = outputFormats.find(({ method: methodT }) => method === methodT); + const type = DURATION_OUTPUT_FORMATS.find(({ method: methodT }) => method === methodT); return useShortSuffix ? type?.shortText : type?.text.toLowerCase(); }; diff --git a/src/plugins/field_formats/common/index.ts b/src/plugins/field_formats/common/index.ts index 9f3a037d5f5e8..23c6f51b04823 100644 --- a/src/plugins/field_formats/common/index.ts +++ b/src/plugins/field_formats/common/index.ts @@ -37,6 +37,12 @@ export { getHighlightRequest, geoUtils } from './utils'; export { DEFAULT_CONVERTER_COLOR } from './constants/color_default'; export { FORMATS_UI_SETTINGS } from './constants/ui_settings'; +export { + DEFAULT_DURATION_INPUT_FORMAT, + DEFAULT_DURATION_OUTPUT_FORMAT, + DURATION_INPUT_FORMATS, + DURATION_OUTPUT_FORMATS, +} from './constants/duration_formats'; export { FIELD_FORMAT_IDS } from './types'; export { HTML_CONTEXT_TYPE, TEXT_CONTEXT_TYPE } from './content_types'; diff --git a/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts b/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts index ff90e1d40d30c..ac1cf6ef1f940 100644 --- a/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts +++ b/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts @@ -326,4 +326,65 @@ describe('format_column', () => { }, }); }); + + it('does translate the duration params into native parameters', async () => { + const result = await fn(datatable, { + columnId: 'test', + format: 'duration', + fromUnit: 'seconds', + toUnit: 'asHours', + compact: true, + decimals: 2, + }); + + expect(result.columns[0].meta).toEqual({ + type: 'number', + params: { + id: 'duration', + params: { + pattern: '', + formatOverride: true, + inputFormat: 'seconds', + outputFormat: 'asHours', + outputPrecision: 2, + useShortSuffix: true, + showSuffix: true, + includeSpaceWithSuffix: true, + }, + }, + }); + }); + + it('should apply custom suffix to duration format when configured', async () => { + const result = await fn(datatable, { + columnId: 'test', + format: 'duration', + fromUnit: 'seconds', + toUnit: 'asHours', + compact: true, + decimals: 2, + suffix: ' on Earth', + }); + expect(result.columns[0].meta).toEqual({ + type: 'number', + params: { + id: 'suffix', + params: { + suffixString: ' on Earth', + id: 'duration', + formatOverride: true, + params: { + pattern: '', + formatOverride: true, + inputFormat: 'seconds', + outputFormat: 'asHours', + outputPrecision: 2, + useShortSuffix: true, + showSuffix: true, + includeSpaceWithSuffix: true, + }, + }, + }, + }); + }); }); diff --git a/x-pack/plugins/lens/common/expressions/format_column/format_column_fn.ts b/x-pack/plugins/lens/common/expressions/format_column/format_column_fn.ts index 84e83aa2a9d18..e99aaa7577024 100644 --- a/x-pack/plugins/lens/common/expressions/format_column/format_column_fn.ts +++ b/x-pack/plugins/lens/common/expressions/format_column/format_column_fn.ts @@ -42,7 +42,16 @@ function getPatternFromFormat( export const formatColumnFn: FormatColumnExpressionFunction['fn'] = ( input, - { format, columnId, decimals, compact, suffix, pattern, parentFormat }: FormatColumnArgs + { + format, + columnId, + decimals, + compact, + suffix, + pattern, + parentFormat, + ...otherArgs + }: FormatColumnArgs ) => ({ ...input, columns: input.columns @@ -56,6 +65,12 @@ export const formatColumnFn: FormatColumnExpressionFunction['fn'] = ( params: { pattern: getPatternFromFormat(format, decimals, compact, pattern), formatOverride: true, + ...supportedFormats[format].translateToFormatParams?.({ + decimals, + compact, + suffix, + ...otherArgs, + }), }, }; return withParams(col, serializedFormat as Record); @@ -80,6 +95,12 @@ export const formatColumnFn: FormatColumnExpressionFunction['fn'] = ( const customParams = { pattern: getPatternFromFormat(format, decimals, compact, pattern), formatOverride: true, + ...supportedFormats[format].translateToFormatParams?.({ + decimals, + compact, + suffix, + ...otherArgs, + }), }; // Some parent formatters are multi-fields and wrap the custom format into a "paramsPerField" // property. Here the format is passed to this property to make it work properly diff --git a/x-pack/plugins/lens/common/expressions/format_column/index.ts b/x-pack/plugins/lens/common/expressions/format_column/index.ts index 7acbe3237c0e8..7cfb5e8afed92 100644 --- a/x-pack/plugins/lens/common/expressions/format_column/index.ts +++ b/x-pack/plugins/lens/common/expressions/format_column/index.ts @@ -15,6 +15,8 @@ export interface FormatColumnArgs { compact?: boolean; pattern?: string; parentFormat?: string; + fromUnit?: string; + toUnit?: string; } export const formatColumn: FormatColumnExpressionFunction = { @@ -52,6 +54,14 @@ export const formatColumn: FormatColumnExpressionFunction = { types: ['string'], help: '', }, + fromUnit: { + types: ['string'], + help: '', + }, + toUnit: { + types: ['string'], + help: '', + }, }, inputTypes: ['datatable'], async fn(...args) { diff --git a/x-pack/plugins/lens/common/expressions/format_column/supported_formats.ts b/x-pack/plugins/lens/common/expressions/format_column/supported_formats.ts index 9c1ef439bad31..b13b9348577d2 100644 --- a/x-pack/plugins/lens/common/expressions/format_column/supported_formats.ts +++ b/x-pack/plugins/lens/common/expressions/format_column/supported_formats.ts @@ -5,9 +5,21 @@ * 2.0. */ +import { + DEFAULT_DURATION_INPUT_FORMAT, + DEFAULT_DURATION_OUTPUT_FORMAT, +} from '@kbn/field-formats-plugin/common'; +import type { FormatColumnArgs } from '.'; + export const supportedFormats: Record< string, - { decimalsToPattern: (decimals?: number, compact?: boolean) => string; formatId: string } + { + formatId: string; + decimalsToPattern: (decimals?: number, compact?: boolean) => string; + translateToFormatParams?: ( + params: Omit + ) => Record; + } > = { number: { formatId: 'number', @@ -45,6 +57,20 @@ export const supportedFormats: Record< return `0,0.${'0'.repeat(decimals)}bitd`; }, }, + duration: { + formatId: 'duration', + decimalsToPattern: () => '', + translateToFormatParams: (params) => { + return { + inputFormat: params.fromUnit || DEFAULT_DURATION_INPUT_FORMAT.kind, + outputFormat: params.toUnit || DEFAULT_DURATION_OUTPUT_FORMAT.method, + outputPrecision: params.decimals, + useShortSuffix: Boolean(params.compact), + showSuffix: true, + includeSpaceWithSuffix: true, + }; + }, + }, custom: { formatId: 'custom', decimalsToPattern: () => '', diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.test.tsx index 81d6040abefda..af45608665b0c 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.test.tsx @@ -14,7 +14,7 @@ import { LensAppServices } from '../../../app_plugin/types'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { coreMock } from '@kbn/core/public/mocks'; -import { EuiFieldNumber } from '@elastic/eui'; +import { EuiComboBox, EuiFieldNumber } from '@elastic/eui'; jest.mock('lodash', () => { const original = jest.requireActual('lodash'); @@ -106,10 +106,10 @@ describe('FormatSelector', () => { }); expect(props.onChange).toBeCalledWith({ id: 'bytes', params: { decimals: 0 } }); }); - it('updates the suffix', async () => { + it('updates the suffix', () => { const props = getDefaultProps(); const component = mountWithServices(); - await act(async () => { + act(() => { component .find('[data-test-subj="indexPattern-dimension-formatSuffix"]') .last() @@ -120,4 +120,55 @@ describe('FormatSelector', () => { component.update(); expect(props.onChange).toBeCalledWith({ id: 'bytes', params: { suffix: 'GB' } }); }); + + describe('Duration', () => { + it('disables the decimals and compact controls for humanize approximate output', () => { + const originalProps = getDefaultProps(); + let component = mountWithServices( + + ); + + expect( + component + .find('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .last() + .prop('disabled') + ).toBe(true); + expect( + component + .find('[data-test-subj="lns-indexpattern-dimension-formatCompact"]') + .first() + .prop('disabled') + ).toBe(true); + + act(() => { + component + .find('[data-test-subj="indexPattern-dimension-duration-end"]') + .find(EuiComboBox) + .prop('onChange')!([{ label: 'Hours', value: 'asHours' }]); + }); + component = component.update(); + + expect( + component + .find('[data-test-subj="indexPattern-dimension-formatDecimals"]') + .last() + .prop('disabled') + ).toBe(false); + expect( + component + .find('[data-test-subj="lns-indexpattern-dimension-formatCompact"]') + .first() + .prop('disabled') + ).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.tsx index af0645a0c4c29..e233cb3cf7f21 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/format_selector.tsx @@ -16,35 +16,52 @@ import { EuiSwitch, EuiCode, } from '@elastic/eui'; -import { useDebouncedValue } from '@kbn/visualization-ui-components'; +import { useDebouncedValue, TooltipWrapper } from '@kbn/visualization-ui-components'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common'; +import { + DEFAULT_DURATION_INPUT_FORMAT, + DEFAULT_DURATION_OUTPUT_FORMAT, + FORMATS_UI_SETTINGS, +} from '@kbn/field-formats-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { LensAppServices } from '../../../app_plugin/types'; import { GenericIndexPatternColumn } from '../form_based'; import { isColumnFormatted } from '../operations/definitions/helpers'; import { ValueFormatConfig } from '../operations/definitions/column_types'; +import { DurationRowInputs } from './formatting/duration_input'; const supportedFormats: Record< string, - { title: string; defaultDecimals?: number; supportsCompact: boolean } + { + title: string; + defaultDecimals?: number; + supportsCompact: boolean; + supportsDecimals: boolean; + supportsSuffix: boolean; + } > = { number: { title: i18n.translate('xpack.lens.indexPattern.numberFormatLabel', { defaultMessage: 'Number', }), + supportsDecimals: true, + supportsSuffix: true, supportsCompact: true, }, percent: { title: i18n.translate('xpack.lens.indexPattern.percentFormatLabel', { defaultMessage: 'Percent', }), + supportsDecimals: true, + supportsSuffix: true, supportsCompact: true, }, bytes: { title: i18n.translate('xpack.lens.indexPattern.bytesFormatLabel', { defaultMessage: 'Bytes (1024)', }), + supportsDecimals: true, + supportsSuffix: true, supportsCompact: false, }, bits: { @@ -52,13 +69,26 @@ const supportedFormats: Record< defaultMessage: 'Bits (1000)', }), defaultDecimals: 0, + supportsDecimals: true, + supportsSuffix: true, supportsCompact: false, }, + duration: { + title: i18n.translate('xpack.lens.indexPattern.durationLabel', { + defaultMessage: 'Duration', + }), + defaultDecimals: 0, + supportsDecimals: true, + supportsSuffix: true, + supportsCompact: true, + }, custom: { title: i18n.translate('xpack.lens.indexPattern.customFormatLabel', { defaultMessage: 'Custom format', }), defaultDecimals: 0, + supportsDecimals: false, + supportsSuffix: false, supportsCompact: false, }, }; @@ -164,6 +194,20 @@ export function FormatSelector(props: FormatSelectorProps) { onChange ); + const { setter: setDurationFrom, value: durationFrom } = useDebouncedInputforParam( + 'fromUnit' as const, + DEFAULT_DURATION_INPUT_FORMAT.kind, + currentFormat, + onChange + ); + + const { setter: setDurationTo, value: durationTo } = useDebouncedInputforParam( + 'toUnit' as const, + DEFAULT_DURATION_OUTPUT_FORMAT.method, + currentFormat, + onChange + ); + const selectedFormat = currentFormat?.id ? supportedFormats[currentFormat.id] : undefined; const stableOptions = useMemo( () => [ @@ -210,6 +254,8 @@ export function FormatSelector(props: FormatSelectorProps) { [currentFormat, selectedFormat?.title] ); + const approximatedFormat = currentFormat?.id === 'duration' && durationTo === 'humanize'; + return ( <> - {currentFormat && currentFormat.id !== 'custom' ? ( - <> - - { - const value = Number(e.currentTarget.value); - setDecimals(value); - const validatedValue = Math.min(RANGE_MAX, Math.max(RANGE_MIN, value)); - onChange({ - id: currentFormat.id, - params: { - ...currentFormat.params, - decimals: validatedValue, - }, - }); - }} - data-test-subj="indexPattern-dimension-formatDecimals" - compressed - fullWidth - prepend={decimalsLabel} - aria-label={decimalsLabel} - /> - - - { - setSuffix(e.currentTarget.value); - }} - data-test-subj="indexPattern-dimension-formatSuffix" - compressed - fullWidth - prepend={suffixLabel} - aria-label={suffixLabel} - /> - - ) : null} - {selectedFormat?.supportsCompact ? ( + {currentFormat && selectedFormat ? ( <> - - setCompact(!compact)} - data-test-subj="lns-indexpattern-dimension-formatCompact" - /> + {currentFormat?.id === 'duration' ? ( + <> + + + + ) : null} + {selectedFormat.supportsDecimals ? ( + <> + + + { + const value = Number(e.currentTarget.value); + setDecimals(value); + const validatedValue = Math.min(RANGE_MAX, Math.max(RANGE_MIN, value)); + onChange({ + id: currentFormat.id, + params: { + ...currentFormat.params, + decimals: validatedValue, + }, + }); + }} + data-test-subj="indexPattern-dimension-formatDecimals" + compressed + fullWidth + prepend={decimalsLabel} + aria-label={decimalsLabel} + disabled={approximatedFormat} + /> + + + ) : null} + {selectedFormat.supportsSuffix ? ( + <> + + { + setSuffix(e.currentTarget.value); + }} + data-test-subj="indexPattern-dimension-formatSuffix" + compressed + fullWidth + prepend={suffixLabel} + aria-label={suffixLabel} + /> + + ) : null} + {selectedFormat.supportsCompact ? ( + <> + + + setCompact(!compact)} + data-test-subj="lns-indexpattern-dimension-formatCompact" + disabled={approximatedFormat} + /> + + + ) : null} ) : null} diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/formatting/duration_input.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/formatting/duration_input.tsx new file mode 100644 index 0000000000000..e5414af51f45a --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/formatting/duration_input.tsx @@ -0,0 +1,78 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBox, EuiSpacer } from '@elastic/eui'; +import { DURATION_INPUT_FORMATS, DURATION_OUTPUT_FORMATS } from '@kbn/field-formats-plugin/common'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const durationOutputOptions = DURATION_OUTPUT_FORMATS.map(({ text, method }) => ({ + label: text, + value: method, +})); +export const durationInputOptions = DURATION_INPUT_FORMATS.map(({ text, kind }) => ({ + label: text, + value: kind, +})); + +interface DurationInputProps { + testSubjLayout?: string; + testSubjStart?: string; + testSubjEnd?: string; + onStartChange: (newStartValue: string) => void; + onEndChange: (newEndValue: string) => void; + startValue: string | undefined; + endValue: string | undefined; +} + +function getSelectedOption( + inputValue: string, + list: Array<{ label: string; value: string }> +): Array<{ label: string; value: string }> { + const option = list.find(({ value }) => inputValue === value); + return option ? [option] : []; +} + +export const DurationRowInputs = ({ + testSubjLayout, + testSubjStart, + testSubjEnd, + startValue = 'milliseconds', + endValue = 'seconds', + onStartChange, + onEndChange, +}: DurationInputProps) => { + return ( + <> + onStartChange(newStartValue.value!)} + singleSelection={{ asPlainText: true }} + data-test-subj={testSubjStart} + compressed + /> + + onEndChange(newEndChange.value!)} + singleSelection={{ asPlainText: true }} + data-test-subj={testSubjEnd} + compressed + /> + + ); +}; diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/column_types.ts index e10955b3c54ba..971f952c73dc6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/column_types.ts @@ -27,6 +27,8 @@ export interface ValueFormatConfig { suffix?: string; compact?: boolean; pattern?: string; + fromUnit?: string; + toUnit?: string; }; } diff --git a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts index d28df1c3e5f9a..33bec4c23a1bf 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts @@ -27,7 +27,7 @@ import { GenericIndexPatternColumn } from './form_based'; import { operationDefinitionMap } from './operations'; import { FormBasedPrivateState, FormBasedLayer } from './types'; import { DateHistogramIndexPatternColumn, RangeIndexPatternColumn } from './operations/definitions'; -import { FormattedIndexPatternColumn } from './operations/definitions/column_types'; +import type { FormattedIndexPatternColumn } from './operations/definitions/column_types'; import { isColumnFormatted, isColumnOfType } from './operations/definitions/helpers'; import type { IndexPattern, IndexPatternMap } from '../../types'; import { dedupeAggs } from './dedupe_aggs'; @@ -352,6 +352,14 @@ function getExpressionForLayer( format?.params && 'pattern' in format.params && format.params.pattern ? [format.params.pattern] : [], + fromUnit: + format?.params && 'fromUnit' in format.params && format.params.fromUnit + ? [format.params.fromUnit] + : [], + toUnit: + format?.params && 'toUnit' in format.params && format.params.toUnit + ? [format.params.toUnit] + : [], parentFormat: parentFormat ? [JSON.stringify(parentFormat)] : [], }, }; diff --git a/x-pack/test/functional/apps/lens/group2/field_formatters.ts b/x-pack/test/functional/apps/lens/group2/field_formatters.ts index 4116de53e4985..4b66436fc1c2c 100644 --- a/x-pack/test/functional/apps/lens/group2/field_formatters.ts +++ b/x-pack/test/functional/apps/lens/group2/field_formatters.ts @@ -36,7 +36,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await fieldEditor.confirmDelete(); await PageObjects.lens.waitForFieldMissing('runtimefield'); }); - it('should display url formatter correctly', async () => { await retry.try(async () => { await PageObjects.lens.clickAddField(); @@ -196,5 +195,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('572,732.21%'); }); }); + describe('formatter order', () => { + before(async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + }); + + after(async () => { + await PageObjects.lens.clickField('runtimefield'); + await PageObjects.lens.removeField('runtimefield'); + await fieldEditor.confirmDelete(); + await PageObjects.lens.waitForFieldMissing('runtimefield'); + }); + it('should be overridden by Lens formatter', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.setFieldType('long'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['bytes'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.BYTES); + await fieldEditor.save(); + await fieldEditor.waitUntilClosed(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsDatatable_metrics > lns-empty-dimension', + operation: 'average', + field: 'runtimefield', + keepOpen: true, + }); + await PageObjects.lens.editDimensionFormat('Bits (1000)', { decimals: 3, prefix: 'blah' }); + await PageObjects.lens.closeDimensionEditor(); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('5.727kbitblah'); + }); + }); }); }