in x-pack/platform/plugins/shared/lens/public/visualizations/datatable/visualization.tsx [180:838]
stops: applyPaletteParams(paletteService, newPalette, dataBounds),
},
},
};
}
}
return column;
});
return {
...state,
columns,
};
},
getSuggestions({
table,
state,
keptLayerIds,
}: SuggestionRequest<DatatableVisualizationState>): Array<
VisualizationSuggestion<DatatableVisualizationState>
> {
if (
keptLayerIds.length > 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
(state && table.changeType === 'unchanged') ||
table.columns.some((col) => col.operation.isStaticValue)
) {
return [];
}
const oldColumnSettings: Record<string, ColumnState> = {};
if (state) {
state.columns.forEach((column) => {
oldColumnSettings[column.columnId] = column;
});
}
const lastTransposedColumnIndex = table.columns.findIndex((c) =>
!oldColumnSettings[c.columnId] ? false : !oldColumnSettings[c.columnId]?.isTransposed
);
const usesTransposing = state?.columns.some((c) => c.isTransposed);
const title =
table.changeType === 'unchanged'
? i18n.translate('xpack.lens.datatable.suggestionLabel', {
defaultMessage: 'As table',
})
: i18n.translate('xpack.lens.datatable.visualizationOf', {
defaultMessage: 'Table {operations}',
values: {
operations:
table.label ||
table.columns
.map((col) => col.operation.label)
.join(
i18n.translate('xpack.lens.datatable.conjunctionSign', {
defaultMessage: ' & ',
description:
'A character that can be used for conjunction of multiple enumarated items. Make sure to include spaces around it if needed.',
})
),
},
});
const changeType = table.changeType;
const changeFactor =
changeType === 'reduced' || changeType === 'layers'
? 0.3
: changeType === 'unchanged'
? 0.5
: 1;
// forcing datatable as a suggestion when there are no metrics (number fields)
const forceSuggestion = Boolean(table?.notAssignedMetrics);
return [
{
title,
// table with >= 10 columns will have a score of 0.4, fewer columns reduce score
score: forceSuggestion ? 1 : (Math.min(table.columns.length, 10) / 10) * 0.4 * changeFactor,
state: {
...(state || {}),
layerId: table.layerId,
layerType: LayerTypes.DATA,
columns: table.columns.map((col, columnIndex) => ({
...(oldColumnSettings[col.columnId] || {}),
isTransposed: usesTransposing && columnIndex < lastTransposedColumnIndex,
columnId: col.columnId,
})),
},
previewIcon: IconChartDatatable,
// tables are hidden from suggestion bar, but used for drag & drop and chart switching
hide: true,
},
];
},
/*
Datatable works differently on text-based datasource and form-based
- Form-based: It relies on the isBucketed flag to identify groups. It allows only numeric fields
on the Metrics dimension
- Text-based: It relies on the isMetric flag to identify groups. It allows all type of fields
on the Metric dimension in cases where there are no numeric columns
**/
getConfiguration({ state, frame }) {
const theme = kibanaTheme.getTheme();
const palettes = getKbnPalettes(theme);
const { sortedColumns, datasource } = getDatasourceAndSortedColumns(
state,
frame.datasourceLayers
);
const columnMap: Record<string, ColumnState> = {};
state.columns.forEach((column) => {
columnMap[column.columnId] = column;
});
if (!sortedColumns) {
return { groups: [] };
}
const isTextBasedLanguage = datasource?.isTextBasedLanguage();
return {
groups: [
// In this group we get columns that are not transposed and are not on the metric dimension
{
groupId: 'rows',
groupLabel: i18n.translate('xpack.lens.datatable.breakdownRows', {
defaultMessage: 'Rows',
}),
dimensionEditorGroupLabel: i18n.translate('xpack.lens.datatable.breakdownRow', {
defaultMessage: 'Row',
}),
groupTooltip: i18n.translate('xpack.lens.datatable.breakdownRows.description', {
defaultMessage:
'Split table rows by field. This is recommended for high cardinality breakdowns.',
}),
layerId: state.layerId,
accessors: sortedColumns
.filter((c) => {
const column = state.columns.find((col) => col.columnId === c);
if (isTextBasedLanguage) {
return (
!datasource!.getOperationForColumnId(c)?.inMetricDimension &&
!column?.isMetric &&
!column?.isTransposed
);
}
return datasource!.getOperationForColumnId(c)?.isBucketed && !column?.isTransposed;
})
.map((accessor) => {
const {
colorMode = 'none',
palette,
colorMapping,
hidden,
collapseFn,
} = columnMap[accessor] ?? {};
const stops = getPaletteDisplayColors(
paletteService,
palettes,
theme.darkMode,
palette,
colorMapping
);
const hasColoring = colorMode !== 'none' && stops.length > 0;
return {
columnId: accessor,
triggerIconType: hidden
? 'invisible'
: hasColoring
? 'colorBy'
: collapseFn
? 'aggregate'
: undefined,
palette: hasColoring ? stops : undefined,
};
}),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
dataTestSubj: 'lnsDatatable_rows',
enableDimensionEditor: true,
hideGrouping: true,
nestingOrder: 1,
},
// In this group we get columns that are transposed and are not on the metric dimension
{
groupId: 'columns',
groupLabel: i18n.translate('xpack.lens.datatable.breakdownColumns', {
defaultMessage: 'Split metrics by',
}),
dimensionEditorGroupLabel: i18n.translate('xpack.lens.datatable.breakdownColumn', {
defaultMessage: 'Split metrics by',
}),
groupTooltip: i18n.translate('xpack.lens.datatable.breakdownColumns.description', {
defaultMessage:
"Split metric columns by field. It's recommended to keep the number of columns low to avoid horizontal scrolling.",
}),
layerId: state.layerId,
accessors: sortedColumns
.filter((c) => {
if (isTextBasedLanguage) {
return state.columns.find((col) => col.columnId === c)?.isTransposed;
}
return (
datasource!.getOperationForColumnId(c)?.isBucketed &&
state.columns.find((col) => col.columnId === c)?.isTransposed
);
})
.map((accessor) => ({ columnId: accessor })),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
dataTestSubj: 'lnsDatatable_columns',
enableDimensionEditor: true,
hideGrouping: true,
nestingOrder: 0,
},
// In this group we get columns are on the metric dimension
{
groupId: 'metrics',
groupLabel: i18n.translate('xpack.lens.datatable.metrics', {
defaultMessage: 'Metrics',
}),
dimensionEditorGroupLabel: i18n.translate('xpack.lens.datatable.metric', {
defaultMessage: 'Metric',
}),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.datatable.headingLabel', {
defaultMessage: 'Value',
}),
},
layerId: state.layerId,
accessors: sortedColumns
.filter((c) => {
const operation = datasource!.getOperationForColumnId(c);
if (isTextBasedLanguage) {
return (
operation?.inMetricDimension ||
state.columns.find((col) => col.columnId === c)?.isMetric
);
}
return !operation?.isBucketed;
})
.map((accessor) => {
const {
colorMode = 'none',
palette,
colorMapping,
hidden,
} = columnMap[accessor] ?? {};
const stops = getPaletteDisplayColors(
paletteService,
palettes,
theme.darkMode,
palette,
colorMapping
);
const hasColoring = colorMode !== 'none' && stops.length > 0;
return {
columnId: accessor,
triggerIconType: hidden ? 'invisible' : hasColoring ? 'colorBy' : undefined,
palette: hasColoring ? stops : undefined,
};
}),
supportsMoreColumns: true,
filterOperations: (op) => !op.isBucketed,
isMetricDimension: true,
requiredMinDimensionCount: 1,
dataTestSubj: 'lnsDatatable_metrics',
enableDimensionEditor: true,
},
],
};
},
setDimension({ prevState, columnId, groupId, previousColumn }) {
if (
prevState.columns.some(
(column) =>
column.columnId === columnId || (previousColumn && column.columnId === previousColumn)
)
) {
return {
...prevState,
columns: prevState.columns.map((column) => {
if (column.columnId === columnId || column.columnId === previousColumn) {
return {
...column,
columnId,
isTransposed: groupId === 'columns',
isMetric: groupId === 'metrics',
};
}
return column;
}),
};
}
return {
...prevState,
columns: [
...prevState.columns,
{ columnId, isTransposed: groupId === 'columns', isMetric: groupId === 'metrics' },
],
};
},
removeDimension({ prevState, columnId }) {
return {
...prevState,
columns: prevState.columns.filter((column) => column.columnId !== columnId),
sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting,
};
},
DimensionEditorComponent(props) {
const theme = useObservable<CoreTheme>(kibanaTheme.theme$, {
darkMode: false,
name: 'amsterdam',
});
const palettes = getKbnPalettes(theme);
return (
<TableDimensionEditor
{...props}
isDarkMode={theme.darkMode}
palettes={palettes}
paletteService={paletteService}
formatFactory={formatFactory}
/>
);
},
DimensionEditorAdditionalSectionComponent(props) {
return <TableDimensionEditorAdditionalSection {...props} paletteService={paletteService} />;
},
DimensionEditorDataExtraComponent(props) {
return <TableDimensionDataExtraEditor {...props} paletteService={paletteService} />;
},
getSupportedLayers() {
return [
{
type: LayerTypes.DATA,
label: i18n.translate('xpack.lens.datatable.addLayer', {
defaultMessage: 'Visualization',
}),
},
];
},
getLayerType(layerId, state) {
if (state?.layerId === layerId) {
return state.layerType;
}
},
toExpression(
state,
datasourceLayers,
{ title, description } = {},
datasourceExpressionsByLayers = {}
): Ast | null {
const { sortedColumns, datasource } = getDatasourceAndSortedColumns(state, datasourceLayers);
const isTextBasedLanguage = datasource?.isTextBasedLanguage();
if (
sortedColumns?.length &&
!isTextBasedLanguage &&
sortedColumns.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed).length === 0
) {
return null;
}
if (!datasourceExpressionsByLayers || Object.keys(datasourceExpressionsByLayers).length === 0) {
return null;
}
const columnMap: Record<string, ColumnState> = {};
state.columns.forEach((column) => {
columnMap[column.columnId] = column;
});
const columns = sortedColumns!
.filter((columnId) => datasource!.getOperationForColumnId(columnId))
.map((columnId) => columnMap[columnId]);
const datasourceExpression = datasourceExpressionsByLayers[state.layerId];
const lensCollapseFnAsts = columns
.filter((c) => c.collapseFn)
.map((c) =>
buildExpressionFunction<CollapseExpressionFunction>('lens_collapse', {
by: columns
.filter(
(col) =>
col.columnId !== c.columnId &&
datasource!.getOperationForColumnId(col.columnId)?.isBucketed
)
.map((col) => col.columnId),
metric: columns
.filter((col) => !datasource!.getOperationForColumnId(col.columnId)?.isBucketed)
.map((col) => col.columnId),
fn: [c.collapseFn!],
}).toAst()
);
const datatableFnAst = buildExpressionFunction<DatatableExpressionFunction>('lens_datatable', {
title: title || '',
description: description || '',
columns: columns
.filter((c) => !c.collapseFn)
.map((column) => {
const { isNumeric, isCategory: isBucketable } = getAccessorType(
datasource,
column.columnId
);
const stops = getOverridePaletteStops(paletteService, column.palette);
const paletteParams = {
...column.palette?.params,
// rewrite colors and stops as two distinct arguments
colors: stops?.map(({ color }) => color),
stops:
column.palette?.params?.name === RowHeightMode.custom
? stops?.map(({ stop }) => stop)
: [],
reverse: false, // managed at UI level
};
const { sortingHint, inMetricDimension } =
datasource?.getOperationForColumnId(column.columnId) ?? {};
const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
const canColor = isNumeric || isBucketable;
const colorByTerms = isBucketable;
let isTransposable =
!isTextBasedLanguage &&
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed;
if (isTextBasedLanguage) {
isTransposable = Boolean(column?.isMetric || inMetricDimension);
}
const datatableColumnFn = buildExpressionFunction<DatatableColumnFn>(
'lens_datatable_column',
{
columnId: column.columnId,
hidden: column.hidden,
oneClickFilter: column.oneClickFilter,
width: column.width,
isTransposed: column.isTransposed,
transposable: isTransposable,
alignment: column.alignment,
colorMode: canColor ? column.colorMode ?? 'none' : 'none',
palette: !canColor
? undefined
: paletteService
// The by value palette is a pseudo custom palette that is only custom from params level
.get(colorByTerms ? column.palette?.name || CUSTOM_PALETTE : CUSTOM_PALETTE)
.toExpression(paletteParams),
colorMapping:
canColor && column.colorMapping ? JSON.stringify(column.colorMapping) : undefined,
summaryRow: hasNoSummaryRow ? undefined : column.summaryRow!,
summaryLabel: hasNoSummaryRow
? undefined
: column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!),
sortingHint,
}
);
return buildExpression([datatableColumnFn]).toAst();
}),
sortingColumnId: state.sorting?.columnId || '',
sortingDirection: state.sorting?.direction || 'none',
fitRowToContent: state.rowHeight === RowHeightMode.auto,
headerRowHeight: state.headerRowHeight ?? DEFAULT_HEADER_ROW_HEIGHT,
rowHeightLines: state.rowHeightLines ?? DEFAULT_ROW_HEIGHT_LINES,
headerRowHeightLines: state.headerRowHeightLines ?? DEFAULT_HEADER_ROW_HEIGHT_LINES,
pageSize: state.paging?.enabled ? state.paging.size : undefined,
}).toAst();
return {
type: 'expression',
chain: [...(datasourceExpression?.chain ?? []), ...lensCollapseFnAsts, datatableFnAst],
};
},
getTelemetryEventsOnSave(state, prevState) {
const colorMappingEvents = state.columns.flatMap((col) => {
const prevColumn = prevState?.columns?.find((prevCol) => prevCol.columnId === col.columnId);
return getColorMappingTelemetryEvents(col.colorMapping, prevColumn?.colorMapping);
});
return colorMappingEvents;
},
getRenderEventCounters(state) {
const events = {
color_by_value: false,
summary_row: false,
};
state.columns.forEach((column) => {
if (column.summaryRow && column.summaryRow !== 'none') {
events.summary_row = true;
}
if (column.colorMode && column.colorMode !== 'none') {
events.color_by_value = true;
}
});
return Object.entries(events).reduce<string[]>((acc, [key, isActive]) => {
if (isActive) {
acc.push(`dimension_${key}`);
}
return acc;
}, []);
},
ToolbarComponent(props) {
return <DataTableToolbar {...props} />;
},
onEditAction(state, event) {
switch (event.data.action) {
case 'sort':
return {
...state,
sorting: {
columnId: event.data.columnId,
direction: event.data.direction,
},
};
case 'toggle':
const toggleColumnId = event.data.columnId;
return {
...state,
columns: state.columns.map((column) => {
if (column.columnId === toggleColumnId) {
return {
...column,
hidden: !column.hidden,
};
} else {
return column;
}
}),
};
case 'resize':
const targetWidth = event.data.width;
const resizeColumnId = event.data.columnId;
return {
...state,
columns: state.columns.map((column) => {
if (column.columnId === resizeColumnId) {
return {
...column,
width: targetWidth,
};
} else {
return column;
}
}),
};
case 'pagesize':
return {
...state,
paging: {
enabled: state.paging?.enabled || false,
size: event.data.size,
},
};
default:
return state;
}
},
getSuggestionFromConvertToLensContext({ suggestions, context }) {
const allSuggestions = suggestions as Array<
Suggestion<DatatableVisualizationState, FormBasedPersistedState>
>;
const suggestion: Suggestion<DatatableVisualizationState, FormBasedPersistedState> = {
...allSuggestions[0],
datasourceState: {
...allSuggestions[0].datasourceState,
layers: allSuggestions.reduce(
(acc, s) => ({
...acc,
...s.datasourceState?.layers,
}),
{}
),
},
visualizationState: {
...allSuggestions[0].visualizationState,
...context.configuration,
},
};
return suggestion;
},
getExportDatatables(state, datasourceLayers = {}, activeData) {
const columnMap = new Map(state.columns.map((c) => [c.columnId, c]));
const datatable =
activeData?.[DatatableInspectorTables.Transpose] ??
activeData?.[DatatableInspectorTables.Default];
if (!datatable) return [];
const columns = datatable.columns.filter(({ id }) => !columnMap.get(getOriginalId(id))?.hidden);
let rows = datatable.rows;
const sortColumn =
state.sorting?.columnId && columns.find(({ id }) => id === state.sorting?.columnId);
const sortDirection = state.sorting?.direction;
if (sortColumn && sortDirection && sortDirection !== 'none') {
const datasource = datasourceLayers[state.layerId];
const schemaType =
datasource?.getOperationForColumnId?.(sortColumn.id)?.sortingHint ??
getSimpleColumnType(sortColumn.meta);
const sortingCriteria = getSortingCriteria(
schemaType,
sortColumn.id,
formatFactory(sortColumn.meta?.params)
);
rows = [...rows].sort((rA, rB) => sortingCriteria(rA, rB, sortDirection));
}
return [
{
...datatable,
columns,
rows,
},
];
},
getVisualizationInfo(state) {
const visibleMetricColumns = state.columns.filter(
(c) => !c.hidden && c.colorMode && c.colorMode !== 'none'
);
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: 'table',
...this.getDescription(state),
palette:
// if multiple columns have color by value, do not show the palette for now: see #154349
visibleMetricColumns.length > 1
? undefined
: visibleMetricColumns[0]?.palette?.params?.stops?.map(({ color }) => color),
dimensions: state.columns.map((column) => {
let name = i18n.translate('xpack.lens.datatable.metric', {
defaultMessage: 'Metric',
});
let dimensionType = 'Metric';
if (!column.transposable) {
if (column.isTransposed) {
name = i18n.translate('xpack.lens.datatable.breakdownColumns', {