From 3dc60cd2d769d1a99abc891e527f2ddf06a4be9b Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Wed, 26 Jul 2023 17:08:36 -0500 Subject: [PATCH] Transforms: Add Format Time Transform (Alpha) (#72319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stub transform editor * Mostly working * Get things working 💪 * Add tests * Add alpha flag * Timezone support * Remove debug statement * Fix tests * Prettier fix * Fix linter error * One more linter fix --- .../src/transformations/transformers.ts | 2 + .../transformers/formatTime.test.ts | 89 ++++++++++++++++ .../transformers/formatTime.ts | 76 +++++++++++++ .../src/transformations/transformers/ids.ts | 1 + .../editors/FormatTimeTransformerEditor.tsx | 100 ++++++++++++++++++ .../transformers/standardTransformers.ts | 2 + 6 files changed, 270 insertions(+) create mode 100644 packages/grafana-data/src/transformations/transformers/formatTime.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/formatTime.ts create mode 100644 public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index 218e5f8e9c0..a087bd9a467 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -6,6 +6,7 @@ import { filterFieldsTransformer, filterFramesTransformer } from './transformers import { filterFieldsByNameTransformer } from './transformers/filterByName'; import { filterFramesByRefIdTransformer } from './transformers/filterByRefId'; import { filterByValueTransformer } from './transformers/filterByValue'; +import { formatTimeTransformer } from './transformers/formatTime'; import { groupByTransformer } from './transformers/groupBy'; import { groupingToMatrixTransformer } from './transformers/groupingToMatrix'; import { histogramTransformer } from './transformers/histogram'; @@ -29,6 +30,7 @@ export const standardTransformers = { filterFramesTransformer, filterFramesByRefIdTransformer, filterByValueTransformer, + formatTimeTransformer, orderFieldsTransformer, organizeFieldsTransformer, reduceTransformer, diff --git a/packages/grafana-data/src/transformations/transformers/formatTime.test.ts b/packages/grafana-data/src/transformations/transformers/formatTime.test.ts new file mode 100644 index 00000000000..5a63b53bfe7 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/formatTime.test.ts @@ -0,0 +1,89 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; + +import { createTimeFormatter, formatTimeTransformer } from './formatTime'; + +describe('Format Time Transformer', () => { + beforeAll(() => { + mockTransformationsRegistry([formatTimeTransformer]); + }); + + it('will convert time to formatted string', () => { + const options = { + timeField: 'time', + outputFormat: 'YYYY-MM', + useTimezone: false, + }; + + const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.useTimezone); + const frame = toDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1612939600000, 1689192000000, 1682025600000, 1690328089000, 1691011200000], + }, + ], + }); + + const newFrame = formatter(frame.fields); + expect(newFrame[0].values).toEqual(['2021-02', '2023-07', '2023-04', '2023-07', '2023-08']); + }); + + it('will handle formats with times', () => { + const options = { + timeField: 'time', + outputFormat: 'YYYY-MM h:mm:ss a', + useTimezone: false, + }; + + const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.useTimezone); + const frame = toDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1612939600000, 1689192000000, 1682025600000, 1690328089000, 1691011200000], + }, + ], + }); + + const newFrame = formatter(frame.fields); + expect(newFrame[0].values).toEqual([ + '2021-02 1:46:40 am', + '2023-07 2:00:00 pm', + '2023-04 3:20:00 pm', + '2023-07 5:34:49 pm', + '2023-08 3:20:00 pm', + ]); + }); + + it('will handle null times', () => { + const options = { + timeField: 'time', + outputFormat: 'YYYY-MM h:mm:ss a', + useTimezone: false, + }; + + const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.useTimezone); + const frame = toDataFrame({ + fields: [ + { + name: 'time', + type: FieldType.time, + values: [1612939600000, 1689192000000, 1682025600000, 1690328089000, null], + }, + ], + }); + + const newFrame = formatter(frame.fields); + expect(newFrame[0].values).toEqual([ + '2021-02 1:46:40 am', + '2023-07 2:00:00 pm', + '2023-04 3:20:00 pm', + '2023-07 5:34:49 pm', + 'Invalid date', + ]); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/formatTime.ts b/packages/grafana-data/src/transformations/transformers/formatTime.ts new file mode 100644 index 00000000000..66406f0ef48 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/formatTime.ts @@ -0,0 +1,76 @@ +import moment from 'moment-timezone'; +import { map } from 'rxjs/operators'; + +import { getTimeZone, getTimeZoneInfo } from '../../datetime'; +import { Field, FieldType } from '../../types'; +import { DataTransformerInfo } from '../../types/transformations'; + +import { DataTransformerID } from './ids'; + +export interface FormatTimeTransformerOptions { + timeField: string; + outputFormat: string; + useTimezone: boolean; +} + +export const formatTimeTransformer: DataTransformerInfo = { + id: DataTransformerID.formatTime, + name: 'Format Time', + description: 'Set the output format of a time field', + defaultOptions: { timeField: '', outputFormat: '', useTimezone: true }, + operator: (options) => (source) => + source.pipe( + map((data) => { + // If a field and a format are configured + // then format the time output + const formatter = createTimeFormatter(options.timeField, options.outputFormat, options.useTimezone); + + if (!Array.isArray(data) || data.length === 0) { + return data; + } + + return data.map((frame) => ({ + ...frame, + fields: formatter(frame.fields), + })); + }) + ), +}; + +/** + * @internal + */ +export const createTimeFormatter = + (timeField: string, outputFormat: string, useTimezone: boolean) => (fields: Field[]) => { + const tz = getTimeZone(); + + return fields.map((field) => { + // Find the configured field + if (field.name === timeField) { + // Update values to use the configured format + const newVals = field.values.map((value) => { + const date = moment(value); + + // Apply configured timezone if the + // option has been set. Otherwise + // use the date directly + if (useTimezone) { + const info = getTimeZoneInfo(tz, value); + const realTz = info !== undefined ? info.ianaName : 'UTC'; + + return date.tz(realTz).format(outputFormat); + } else { + return date.format(outputFormat); + } + }); + + return { + ...field, + type: FieldType.string, + values: newVals, + }; + } + + return field; + }); + }; diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index bce099ea1ca..b3cb08dce24 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -37,4 +37,5 @@ export enum DataTransformerID { limit = 'limit', partitionByValues = 'partitionByValues', timeSeriesTable = 'timeSeriesTable', + formatTime = 'formatTime', } diff --git a/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx b/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx new file mode 100644 index 00000000000..4a39104b6fd --- /dev/null +++ b/public/app/features/transformers/editors/FormatTimeTransformerEditor.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, ChangeEvent } from 'react'; + +import { + DataTransformerID, + SelectableValue, + standardTransformers, + TransformerRegistryItem, + TransformerUIProps, + getFieldDisplayName, + PluginState, +} from '@grafana/data'; +import { FormatTimeTransformerOptions } from '@grafana/data/src/transformations/transformers/formatTime'; +import { Select, InlineFieldRow, InlineField, Input, InlineSwitch } from '@grafana/ui'; + +export function FormatTimeTransfomerEditor({ + input, + options, + onChange, +}: TransformerUIProps) { + const timeFields: Array> = []; + + // Get time fields + for (const frame of input) { + for (const field of frame.fields) { + if (field.type === 'time') { + const name = getFieldDisplayName(field, frame, input); + timeFields.push({ label: name, value: name }); + } + } + } + + const onSelectField = useCallback( + (value: SelectableValue) => { + const val = value?.value !== undefined ? value.value : ''; + onChange({ + ...options, + timeField: val, + }); + }, + [onChange, options] + ); + + const onFormatChange = useCallback( + (e: ChangeEvent) => { + const val = e.target.value; + onChange({ + ...options, + outputFormat: val, + }); + }, + [onChange, options] + ); + + const onUseTzChange = useCallback(() => { + onChange({ + ...options, + useTimezone: !options.useTimezone, + }); + }, [onChange, options]); + + return ( + <> + + + + + + + + + + ); +} + +export const formatTimeTransformerRegistryItem: TransformerRegistryItem = { + id: DataTransformerID.formatTime, + editor: FormatTimeTransfomerEditor, + transformation: standardTransformers.formatTimeTransformer, + name: standardTransformers.formatTimeTransformer.name, + state: PluginState.alpha, + description: standardTransformers.formatTimeTransformer.description, +}; diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts index 8716a48a20a..5a188e091e0 100644 --- a/public/app/features/transformers/standardTransformers.ts +++ b/public/app/features/transformers/standardTransformers.ts @@ -9,6 +9,7 @@ import { concatenateTransformRegistryItem } from './editors/ConcatenateTransform import { convertFieldTypeTransformRegistryItem } from './editors/ConvertFieldTypeTransformerEditor'; import { filterFieldsByNameTransformRegistryItem } from './editors/FilterByNameTransformerEditor'; import { filterFramesByRefIdTransformRegistryItem } from './editors/FilterByRefIdTransformerEditor'; +import { formatTimeTransformerRegistryItem } from './editors/FormatTimeTransformerEditor'; import { groupByTransformRegistryItem } from './editors/GroupByTransformerEditor'; import { groupingToMatrixTransformRegistryItem } from './editors/GroupingToMatrixTransformerEditor'; import { histogramTransformRegistryItem } from './editors/HistogramTransformerEditor'; @@ -59,6 +60,7 @@ export const getStandardTransformers = (): Array> = limitTransformRegistryItem, joinByLabelsTransformRegistryItem, partitionByValuesTransformRegistryItem, + formatTimeTransformerRegistryItem, ...(config.featureToggles.timeSeriesTable ? [timeSeriesTableTransformRegistryItem] : []), ]; };