[ES|QL] Supports custom formatters in charts (#201540)

This commit is contained in:
Peter Pisljar 2024-12-11 05:45:05 +01:00 committed by GitHub
parent e3ec4771d1
commit 168e67d50d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 275 additions and 56 deletions

View file

@ -12,7 +12,7 @@ import { lastValueFrom } from 'rxjs';
import { Query, AggregateQuery, TimeRange } from '@kbn/es-query';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/public';
import { type DataView, textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
interface TextBasedLanguagesErrorResponse {
error: {
@ -26,16 +26,20 @@ export function fetchFieldsFromESQL(
expressions: ExpressionsStart,
time?: TimeRange,
abortController?: AbortController,
dataView?: DataView
timeFieldName?: string
) {
return textBasedQueryStateToAstWithValidation({
query,
time,
dataView,
timeFieldName,
})
.then((ast) => {
if (ast) {
const executionContract = expressions.execute(ast, null);
const executionContract = expressions.execute(ast, null, {
searchContext: {
timeRange: time,
},
});
if (abortController) {
abortController.signal.onabort = () => {

View file

@ -74,10 +74,7 @@ export class DatatableUtilitiesService {
timeZone: string;
}> = {}
): DateHistogramMeta | undefined {
if (column.meta.source !== 'esaggs') {
return;
}
if (column.meta.sourceParams?.type !== BUCKET_TYPES.DATE_HISTOGRAM) {
if (!column.meta.sourceParams || !column.meta.sourceParams.params) {
return;
}

View file

@ -7,7 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createStubDataView } from '@kbn/data-views-plugin/common/mocks';
import { textBasedQueryStateToAstWithValidation } from './text_based_query_state_to_ast_with_validation';
describe('textBasedQueryStateToAstWithValidation', () => {
@ -25,13 +24,6 @@ describe('textBasedQueryStateToAstWithValidation', () => {
});
it('returns an object with the correct structure for an SQL query with existing dataview', async () => {
const dataView = createStubDataView({
spec: {
id: 'foo',
title: 'foo',
timeFieldName: '@timestamp',
},
});
const actual = await textBasedQueryStateToAstWithValidation({
filters: [],
query: { esql: 'FROM foo' },
@ -39,7 +31,7 @@ describe('textBasedQueryStateToAstWithValidation', () => {
from: 'now',
to: 'now+7d',
},
dataView,
timeFieldName: '@timestamp',
});
expect(actual).toHaveProperty(
@ -76,13 +68,6 @@ describe('textBasedQueryStateToAstWithValidation', () => {
});
it('returns an object with the correct structure for ES|QL', async () => {
const dataView = createStubDataView({
spec: {
id: 'foo',
title: 'foo',
timeFieldName: '@timestamp',
},
});
const actual = await textBasedQueryStateToAstWithValidation({
filters: [],
query: { esql: 'from logs*' },
@ -90,7 +75,7 @@ describe('textBasedQueryStateToAstWithValidation', () => {
from: 'now',
to: 'now+7d',
},
dataView,
timeFieldName: '@timestamp',
titleForInspector: 'Custom title',
descriptionForInspector: 'Custom desc',
});

View file

@ -8,12 +8,10 @@
*/
import { isOfAggregateQueryType, Query } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/common';
import type { QueryState } from '..';
import { textBasedQueryStateToExpressionAst } from './text_based_query_state_to_ast';
interface Args extends QueryState {
dataView?: DataView;
inputQuery?: Query;
timeFieldName?: string;
titleForInspector?: string;
@ -26,7 +24,7 @@ interface Args extends QueryState {
* @param query kibana query or aggregate query
* @param inputQuery
* @param time kibana time range
* @param dataView
* @param timeFieldName
* @param titleForInspector
* @param descriptionForInspector
*/
@ -35,7 +33,7 @@ export async function textBasedQueryStateToAstWithValidation({
query,
inputQuery,
time,
dataView,
timeFieldName,
titleForInspector,
descriptionForInspector,
}: Args) {
@ -46,7 +44,7 @@ export async function textBasedQueryStateToAstWithValidation({
query,
inputQuery,
time,
timeFieldName: dataView?.timeFieldName,
timeFieldName,
titleForInspector,
descriptionForInspector,
});

View file

@ -315,7 +315,17 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
(body.all_columns ?? body.columns)?.map(({ name, type }) => ({
id: name,
name,
meta: { type: esFieldTypeToKibanaFieldType(type), esType: type },
meta: {
type: esFieldTypeToKibanaFieldType(type),
esType: type,
sourceParams:
type === 'date'
? {
appliedTimeRange: input?.timeRange,
params: {},
}
: {},
},
isNull: hasEmptyColumns ? !lookup.has(name) : false,
})) ?? [];

View file

@ -56,7 +56,7 @@ export function fetchEsql({
filters,
query,
time: timeRange,
dataView,
timeFieldName: dataView.timeFieldName,
inputQuery,
titleForInspector: i18n.translate('discover.inspectorEsqlRequestTitle', {
defaultMessage: 'Table',

View file

@ -319,8 +319,8 @@ describe('map_to_columns', () => {
);
expect(result.columns).toStrictEqual([
{ id: 'a', name: 'A', meta: { type: 'number' } },
{ id: 'b', name: 'B', meta: { type: 'number' } },
{ id: 'a', name: 'A', meta: { type: 'number', field: undefined, params: undefined } },
{ id: 'b', name: 'B', meta: { type: 'number', field: undefined, params: undefined } },
]);
expect(result.rows).toStrictEqual([

View file

@ -32,7 +32,16 @@ export const mapToOriginalColumnsTextBased: MapToColumnsExpressionFunction['fn']
if (!(column.id in idMap)) {
return [];
}
return idMap[column.id].map((originalColumn) => ({ ...column, id: originalColumn.id }));
return idMap[column.id].map((originalColumn) => ({
...column,
id: originalColumn.id,
name: originalColumn.label,
meta: {
...column.meta,
field: originalColumn.sourceField,
params: originalColumn.format,
},
}));
}),
};
};

View file

@ -6,8 +6,9 @@
*/
import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
export type OriginalColumn = { id: string; label: string } & (
export type OriginalColumn = { id: string; label: string; format?: SerializedFieldFormat } & (
| { operationType: 'date_histogram'; sourceField: string }
| { operationType: string; sourceField: never }
);

View file

@ -28,6 +28,7 @@ import {
} from '@kbn/field-formats-plugin/common';
import { css } from '@emotion/react';
import type { DocLinksStart } from '@kbn/core/public';
import { TextBasedLayerColumn } from '../../text_based/types';
import { LensAppServices } from '../../../app_plugin/types';
import { GenericIndexPatternColumn } from '../form_based';
import { isColumnFormatted } from '../operations/definitions/helpers';
@ -127,7 +128,7 @@ type FormatParams = NonNullable<ValueFormatConfig['params']>;
type FormatParamsKeys = keyof FormatParams;
export interface FormatSelectorProps {
selectedColumn: GenericIndexPatternColumn;
selectedColumn: GenericIndexPatternColumn | TextBasedLayerColumn;
onChange: (newFormat?: { id: string; params?: FormatParams }) => void;
docLinks: DocLinksStart;
}

View file

@ -10,6 +10,7 @@ import React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { isEqual } from 'lodash';
import { Query } from '@kbn/es-query';
import { TextBasedLayerColumn } from '../../../text_based/types';
import type { IndexPattern, IndexPatternField } from '../../../../types';
import {
type FieldBasedOperationErrorMessage,
@ -178,8 +179,8 @@ export const isColumn = (
};
export function isColumnFormatted(
column: GenericIndexPatternColumn
): column is FormattedIndexPatternColumn {
column: GenericIndexPatternColumn | TextBasedLayerColumn
): column is FormattedIndexPatternColumn | TextBasedLayerColumn {
return Boolean(
'params' in column &&
(column as FormattedIndexPatternColumn).params &&

View file

@ -5,16 +5,24 @@
* 2.0.
*/
import React, { useEffect, useState, useMemo } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow } from '@elastic/eui';
import { EuiFormRow, useEuiTheme, EuiText } from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { fetchFieldsFromESQL } from '@kbn/esql-editor';
import { NameInput } from '@kbn/visualization-ui-components';
import { css } from '@emotion/react';
import { mergeLayer, updateColumnFormat, updateColumnLabel } from '../utils';
import {
FormatSelector,
FormatSelectorProps,
} from '../../form_based/dimension_panel/format_selector';
import type { DatasourceDimensionEditorProps, DataType } from '../../../types';
import { FieldSelect, type FieldOptionCompatible } from './field_select';
import type { TextBasedPrivateState } from '../types';
import { isNotNumeric, isNumeric } from '../utils';
import { TextBasedLayer } from '../types';
export type TextBasedDimensionEditorProps =
DatasourceDimensionEditorProps<TextBasedPrivateState> & {
@ -24,6 +32,17 @@ export type TextBasedDimensionEditorProps =
export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) {
const [allColumns, setAllColumns] = useState<FieldOptionCompatible[]>([]);
const query = props.state.layers[props.layerId]?.query;
const { euiTheme } = useEuiTheme();
const {
isFullscreen,
columnId,
layerId,
state,
setState,
indexPatterns,
dateRange,
expressions,
} = props;
useEffect(() => {
// in case the columns are not in the cache, I refetch them
@ -31,7 +50,12 @@ export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) {
if (query) {
const table = await fetchFieldsFromESQL(
{ esql: `${query.esql} | limit 0` },
props.expressions
expressions,
{ from: dateRange.fromDate, to: dateRange.toDate },
undefined,
Object.values(indexPatterns).length
? Object.values(indexPatterns)[0].timeFieldName
: undefined
);
if (table) {
const hasNumberTypeColumns = table.columns?.some(isNumeric);
@ -55,13 +79,40 @@ export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) {
}
}
fetchColumns();
}, [props, props.expressions, query]);
}, [
dateRange.fromDate,
dateRange.toDate,
expressions,
indexPatterns,
props,
props.expressions,
query,
]);
const selectedField = useMemo(() => {
const layerColumns = props.state.layers[props.layerId].columns;
return layerColumns?.find((column) => column.columnId === props.columnId);
}, [props.columnId, props.layerId, props.state.layers]);
const updateLayer = useCallback(
(newLayer: Partial<TextBasedLayer>) =>
setState((prevState) => mergeLayer({ state: prevState, layerId, newLayer })),
[layerId, setState]
);
const onFormatChange = useCallback<FormatSelectorProps['onChange']>(
(newFormat) => {
updateLayer(
updateColumnFormat({
layer: state.layers[layerId],
columnId,
value: newFormat,
})
);
},
[columnId, layerId, state.layers, updateLayer]
);
return (
<>
<EuiFormRow
@ -80,6 +131,7 @@ export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) {
const newColumn = {
columnId: props.columnId,
fieldName: choice.field,
label: choice.field,
meta,
};
return props.setState(
@ -122,6 +174,44 @@ export function TextBasedDimensionEditor(props: TextBasedDimensionEditorProps) {
{props.dataSectionExtra}
</div>
)}
{!isFullscreen && selectedField && (
<div className="lnsIndexPatternDimensionEditor--padded lnsIndexPatternDimensionEditor--collapseNext">
<EuiText
size="s"
css={css`
margin-bottom: ${euiTheme.size.base};
`}
>
<h4>
{i18n.translate('xpack.lens.indexPattern.dimensionEditor.headingAppearance', {
defaultMessage: 'Appearance',
})}
</h4>
</EuiText>
<NameInput
value={selectedField.label || ''}
defaultValue={''}
onChange={(value) => {
updateLayer(
updateColumnLabel({
layer: state.layers[layerId],
columnId,
value,
})
);
}}
/>
{selectedField.meta?.type === 'number' ? (
<FormatSelector
selectedColumn={selectedField}
onChange={onFormatChange}
docLinks={props.core.docLinks}
/>
) : null}
</div>
)}
</>
);
}

View file

@ -33,7 +33,7 @@ export function fetchDataFromAggregateQuery(
filters,
query,
time: timeRange,
dataView,
timeFieldName: dataView.timeFieldName,
inputQuery,
})
.then((ast) => {

View file

@ -8,8 +8,9 @@
import { i18n } from '@kbn/i18n';
import { Ast } from '@kbn/interpreter';
import { textBasedQueryStateToExpressionAst } from '@kbn/data-plugin/common';
import type { OriginalColumn } from '../../../common/types';
import { ExpressionAstFunction } from '@kbn/expressions-plugin/common';
import { TextBasedPrivateState, TextBasedLayer, IndexPatternRef } from './types';
import type { OriginalColumn } from '../../../common/types';
function getExpressionForLayer(
layer: TextBasedLayer,
@ -25,7 +26,7 @@ function getExpressionForLayer(
if (idMapper[col.fieldName]) {
idMapper[col.fieldName].push({
id: col.columnId,
label: col.fieldName,
label: col.customLabel ? col.label : col.fieldName,
} as OriginalColumn);
} else {
idMapper = {
@ -33,7 +34,7 @@ function getExpressionForLayer(
[col.fieldName]: [
{
id: col.columnId,
label: col.fieldName,
label: col.customLabel ? col.label : col.fieldName,
} as OriginalColumn,
],
};
@ -41,6 +42,45 @@ function getExpressionForLayer(
});
const timeFieldName = layer.timeField ?? undefined;
const formatterOverrides: ExpressionAstFunction[] = layer.columns
.filter((col) => col.params?.format)
.map((col) => {
const format = col.params!.format!;
const base: ExpressionAstFunction = {
type: 'function',
function: 'lens_format_column',
arguments: {
format: format ? [format.id] : [''],
columnId: [col.columnId],
decimals: typeof format?.params?.decimals === 'number' ? [format.params.decimals] : [],
suffix:
format?.params && 'suffix' in format.params && format.params.suffix
? [format.params.suffix]
: [],
compact:
format?.params && 'compact' in format.params && format.params.compact
? [format.params.compact]
: [],
pattern:
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: [],
},
};
return base;
});
if (!layer.table) {
const textBasedQueryToAst = textBasedQueryStateToExpressionAst({
query: layer.query,
@ -62,6 +102,7 @@ function getExpressionForLayer(
isTextBased: [true],
},
});
textBasedQueryToAst.chain.push(...formatterOverrides);
return textBasedQueryToAst;
} else {
return {
@ -81,6 +122,7 @@ function getExpressionForLayer(
idMap: [JSON.stringify(idMapper)],
},
},
...formatterOverrides,
],
};
}

View file

@ -7,11 +7,17 @@
import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/public';
import type { AggregateQuery } from '@kbn/es-query';
import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { ValueFormatConfig } from '../form_based/operations/definitions/column_types';
import type { VisualizeEditorContext } from '../../types';
export interface TextBasedLayerColumn {
columnId: string;
fieldName: string;
label?: string;
customLabel?: boolean;
params?: {
format?: ValueFormatConfig;
};
meta?: DatatableColumn['meta'];
inMetricDimension?: boolean;
}

View file

@ -129,6 +129,7 @@ describe('Text based languages utils', () => {
{
fieldName: 'timestamp',
columnId: 'timestamp',
label: 'timestamp',
meta: {
type: 'date',
},
@ -136,6 +137,7 @@ describe('Text based languages utils', () => {
{
fieldName: 'memory',
columnId: 'memory',
label: 'memory',
meta: {
type: 'number',
},

View file

@ -11,9 +11,15 @@ import { getESQLAdHocDataview } from '@kbn/esql-utils';
import type { AggregateQuery } from '@kbn/es-query';
import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { ValueFormatConfig } from '../form_based/operations/definitions/column_types';
import { generateId } from '../../id_generator';
import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query';
import type { IndexPatternRef, TextBasedPrivateState, TextBasedLayerColumn } from './types';
import type {
IndexPatternRef,
TextBasedPrivateState,
TextBasedLayerColumn,
TextBasedLayer,
} from './types';
import type { DataViewsState } from '../../state_management';
import { addColumnsToCache } from './fieldlist_cache';
@ -46,7 +52,12 @@ export const getAllColumns = (
});
const allCols = [
...columns,
...columnsFromQuery.map((c) => ({ columnId: c.id, fieldName: c.id, meta: c.meta })),
...columnsFromQuery.map((c) => ({
columnId: c.id,
fieldName: c.id,
label: c.name,
meta: c.meta,
})),
];
const uniqueIds: string[] = [];
@ -154,3 +165,70 @@ export function canColumnBeUsedBeInMetricDimension(
(hasNumberTypeColumns && selectedColumnType === 'number')
);
}
export function mergeLayer({
state,
layerId,
newLayer,
}: {
state: TextBasedPrivateState;
layerId: string;
newLayer: Partial<TextBasedLayer>;
}) {
return {
...state,
layers: {
...state.layers,
[layerId]: { ...state.layers[layerId], ...newLayer },
},
};
}
export function updateColumnLabel({
layer,
columnId,
value,
}: {
layer: TextBasedLayer;
columnId: string;
value: string;
}): TextBasedLayer {
const currentColumnIndex = layer.columns.findIndex((c) => c.columnId === columnId);
const currentColumn = layer.columns[currentColumnIndex];
return {
...layer,
columns: [
...layer.columns.slice(0, currentColumnIndex),
{
...currentColumn,
label: value,
customLabel: !!value,
},
...layer.columns.slice(currentColumnIndex + 1),
],
};
}
export function updateColumnFormat({
layer,
columnId,
value,
}: {
layer: TextBasedLayer;
columnId: string;
value: ValueFormatConfig | undefined;
}): TextBasedLayer {
const currentColumnIndex = layer.columns.findIndex((c) => c.columnId === columnId);
const currentColumn = layer.columns[currentColumnIndex];
return {
...layer,
columns: [
...layer.columns.slice(0, currentColumnIndex),
{
...currentColumn,
params: { format: value },
},
...layer.columns.slice(currentColumnIndex + 1),
],
};
}

View file

@ -27,7 +27,6 @@ import {
getTimeOptions,
parseAggregationResults,
} from '@kbn/triggers-actions-ui-plugin/public/common';
import { DataView } from '@kbn/data-views-plugin/common';
import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
import { DEFAULT_VALUES, SERVERLESS_DEFAULT_VALUES } from '../constants';
import { useTriggerUiActionServices } from '../util';
@ -38,7 +37,7 @@ import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from '../
export const EsqlQueryExpression: React.FC<
RuleTypeParamsExpressionProps<EsQueryRuleParams<SearchType.esqlQuery>, EsQueryRuleMetaData>
> = ({ ruleParams, setRuleParams, setRuleProperty, errors }) => {
const { expressions, http, fieldFormats, isServerless, dataViews } = useTriggerUiActionServices();
const { expressions, http, isServerless, dataViews } = useTriggerUiActionServices();
const { esqlQuery, timeWindowSize, timeWindowUnit, timeField } = ruleParams;
const [currentRuleParams, setCurrentRuleParams] = useState<
@ -116,10 +115,7 @@ export const EsqlQueryExpression: React.FC<
},
undefined,
// create a data view with the timefield to pass into the query
new DataView({
spec: { timeFieldName: timeField },
fieldFormats,
})
timeField
);
if (table) {
const esqlTable = transformDatatableToEsqlTable(table);
@ -154,7 +150,6 @@ export const EsqlQueryExpression: React.FC<
currentRuleParams,
esqlQuery,
expressions,
fieldFormats,
timeField,
isServerless,
]);