[Lens] collapse by for partition charts (#140336)

This commit is contained in:
Andrew Tate 2022-09-12 12:17:04 -05:00 committed by GitHub
parent 11b6ca8160
commit 1a30682fce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 386 additions and 50 deletions

View file

@ -66,6 +66,7 @@ export interface SharedPieLayerState {
primaryGroups: string[];
secondaryGroups?: string[];
metric?: string;
collapseFns?: Record<string, string>;
numberDisplay: NumberDisplayType;
categoryDisplay: CategoryDisplayType;
legendDisplay: LegendDisplayType;

View file

@ -249,6 +249,52 @@ describe('LayerPanel', () => {
expect(group).toHaveLength(1);
});
it('should tell the user to remove the correct number of dimensions', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'x' }],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
dimensionsTooMany: 1,
},
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'x' }],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
dimensionsTooMany: -1,
},
{
groupLabel: 'B',
groupId: 'b',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
dimensionsTooMany: 3,
},
],
});
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
const groups = instance.find(EuiFormRow);
expect(groups.findWhere((e) => e.prop('error') === 'Please remove a dimension')).toHaveLength(
1
);
expect(
groups.findWhere((e) => e.prop('error') === 'Please remove 3 dimensions')
).toHaveLength(1);
expect(groups.findWhere((e) => e.prop('error') === '')).toHaveLength(1);
});
it('should render the required warning when only one group is configured (with requiredMinDimensionCount)', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [

View file

@ -370,26 +370,36 @@ export function LayerPanel(
</header>
{groups.map((group, groupIndex) => {
let isMissing = false;
let errorText: string = '';
if (!isEmptyLayer) {
if (group.requiredMinDimensionCount) {
isMissing = group.accessors.length < group.requiredMinDimensionCount;
} else if (group.required) {
isMissing = group.accessors.length === 0;
}
}
const isMissingError = group.requiredMinDimensionCount
? i18n.translate('xpack.lens.editorFrame.requiresTwoOrMoreFieldsWarningLabel', {
defaultMessage: 'Requires {requiredMinDimensionCount} fields',
values: {
requiredMinDimensionCount: group.requiredMinDimensionCount,
},
})
: i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', {
errorText = i18n.translate(
'xpack.lens.editorFrame.requiresTwoOrMoreFieldsWarningLabel',
{
defaultMessage: 'Requires {requiredMinDimensionCount} fields',
values: {
requiredMinDimensionCount: group.requiredMinDimensionCount,
},
}
);
} else if (group.required && group.accessors.length === 0) {
errorText = i18n.translate('xpack.lens.editorFrame.requiresFieldWarningLabel', {
defaultMessage: 'Requires field',
});
} else if (group.dimensionsTooMany && group.dimensionsTooMany > 0) {
errorText = i18n.translate(
'xpack.lens.editorFrame.tooManyDimensionsSingularWarningLabel',
{
defaultMessage:
'Please remove {dimensionsTooMany, plural, one {a dimension} other {{dimensionsTooMany} dimensions}}',
values: {
dimensionsTooMany: group.dimensionsTooMany,
},
}
);
}
}
const isOptional = !group.required && !group.suggestedValue;
return (
<EuiFormRow
@ -425,8 +435,8 @@ export function LayerPanel(
}
labelType="legend"
key={group.groupId}
isInvalid={isMissing}
error={isMissing ? isMissingError : []}
isInvalid={Boolean(errorText)}
error={errorText}
>
<>
{group.accessors.length ? (

View file

@ -712,6 +712,7 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
groupId: string;
accessors: AccessorConfig[];
supportsMoreColumns: boolean;
dimensionsTooMany?: number;
/** If required, a warning will appear if accessors are empty */
required?: boolean;
requiredMinDimensionCount?: number;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Ast } from '@kbn/interpreter';
import type { Ast, AstFunction } from '@kbn/interpreter';
import { Position } from '@elastic/charts';
import type { PaletteOutput, PaletteRegistry } from '@kbn/coloring';
@ -23,6 +23,7 @@ import {
LegendDisplay,
} from '../../../common';
import { getDefaultVisualValuesForLayer } from '../../shared_components/datasource_default_values';
import { isCollapsed } from './visualization';
interface Attributes {
isPreview: boolean;
@ -142,7 +143,10 @@ const generateCommonArguments: GenerateExpressionAstArguments = (
) => {
return {
labels: generateCommonLabelsAstArgs(state, attributes, layer),
buckets: operations.map((o) => o.columnId).map(prepareDimension),
buckets: operations
.filter(({ columnId }) => !isCollapsed(columnId, layer))
.map(({ columnId }) => columnId)
.map(prepareDimension),
metric: layer.metric ? [prepareDimension(layer.metric)] : [],
legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay],
legendPosition: [layer.legendPosition || Position.Right],
@ -218,6 +222,7 @@ const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => ({
...generateCommonArguments(...rest),
// flip order of bucket dimensions so the rows are fetched before the columns to keep them stable
buckets: rest[2]
.filter(({ columnId }) => !isCollapsed(columnId, rest[3]))
.reverse()
.map((o) => o.columnId)
.map(prepareDimension),
@ -298,6 +303,19 @@ function expressionHelper(
type: 'expression',
chain: [
...(datasourceAst ? datasourceAst.chain : []),
...groups
.filter((columnId) => layer.collapseFns?.[columnId])
.map((columnId) => {
return {
type: 'function',
function: 'lens_collapse',
arguments: {
by: groups.filter((chk) => chk !== columnId),
metric: [layer.metric],
fn: [layer.collapseFns![columnId]!],
},
} as AstFunction;
}),
...(visualizationAst ? visualizationAst.chain : []),
],
};

View file

@ -31,6 +31,8 @@ import {
} from '../../shared_components';
import { getDefaultVisualValuesForLayer } from '../../shared_components/datasource_default_values';
import { shouldShowValuesInLegend } from './render_helpers';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { isCollapsed } from './visualization';
const legendOptions: Array<{
value: SharedPieLayerState['legendDisplay'];
@ -306,14 +308,46 @@ export function DimensionEditor(
paletteService: PaletteRegistry;
}
) {
if (props.accessor !== Object.values(props.state.layers)[0].primaryGroups[0]) return null;
const currentLayer = props.state.layers.find((layer) => layer.layerId === props.layerId);
if (!currentLayer) {
return null;
}
const firstNonCollapsedColumnId = currentLayer.primaryGroups.find(
(columnId) => !isCollapsed(columnId, currentLayer)
);
return (
<PalettePicker
palettes={props.paletteService}
activePalette={props.state.palette}
setPalette={(newPalette) => {
props.setState({ ...props.state, palette: newPalette });
}}
/>
<>
{props.accessor === firstNonCollapsedColumnId && (
<PalettePicker
palettes={props.paletteService}
activePalette={props.state.palette}
setPalette={(newPalette) => {
props.setState({ ...props.state, palette: newPalette });
}}
/>
)}
<CollapseSetting
value={currentLayer?.collapseFns?.[props.accessor] || ''}
onChange={(collapseFn: string) => {
props.setState({
...props.state,
layers: props.state.layers.map((layer) =>
layer.layerId !== props.layerId
? layer
: {
...layer,
collapseFns: {
...layer.collapseFns,
[props.accessor]: collapseFn,
},
}
),
});
}}
/>
</>
);
}

View file

@ -18,6 +18,8 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { createMockDatasource, createMockFramePublicAPI } from '../../mocks';
import { FramePublicAPI } from '../../types';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { cloneDeep } from 'lodash';
import { PartitionChartsMeta } from './partition_charts_meta';
jest.mock('../../id_generator');
@ -59,10 +61,26 @@ function mockFrame(): FramePublicAPI {
// Just a basic bootstrap here to kickstart the tests
describe('pie_visualization', () => {
describe('#getErrorMessages', () => {
it('returns undefined if no error is raised', () => {
const error = pieVisualization.getErrorMessages(getExampleState());
describe('too many dimensions', () => {
const state = { ...getExampleState(), shape: PieChartTypes.MOSAIC };
const colIds = new Array(PartitionChartsMeta.mosaic.maxBuckets + 1)
.fill(undefined)
.map((_, i) => String(i + 1));
expect(error).not.toBeDefined();
state.layers[0].primaryGroups = colIds.slice(0, 2);
state.layers[0].secondaryGroups = colIds.slice(2);
it('returns error', () => {
expect(pieVisualization.getErrorMessages(state)).toHaveLength(1);
});
it("doesn't count collapsed dimensions", () => {
state.layers[0].collapseFns = {
[colIds[0]]: 'some-fn',
};
expect(pieVisualization.getErrorMessages(state)).toHaveLength(0);
});
});
});
@ -111,4 +129,147 @@ describe('pie_visualization', () => {
);
});
});
describe('#removeDimension', () => {
it('removes corresponding collapse function if exists', () => {
const state = getExampleState();
const colIds = ['1', '2', '3', '4'];
state.layers[0].primaryGroups = colIds;
state.layers[0].collapseFns = {
'1': 'sum',
'3': 'max',
};
const newState = pieVisualization.removeDimension({
layerId: LAYER_ID,
columnId: '3',
prevState: state,
frame: mockFrame(),
});
expect(newState.layers[0].collapseFns).not.toHaveProperty('3');
});
});
describe('#getConfiguration', () => {
it('assigns correct icons to accessors', () => {
const colIds = ['1', '2', '3', '4'];
const frame = mockFrame();
frame.datasourceLayers[LAYER_ID]!.getTableSpec = () =>
colIds.map((id) => ({ columnId: id, fields: [] }));
const state = getExampleState();
state.layers[0].primaryGroups = colIds;
state.layers[0].collapseFns = {
'1': 'sum',
'3': 'max',
};
const configuration = pieVisualization.getConfiguration({
state,
frame,
layerId: state.layers[0].layerId,
});
// palette should be assigned to the first non-collapsed dimension
expect(configuration.groups[0].accessors).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "1",
"triggerIcon": "aggregate",
},
Object {
"columnId": "2",
"palette": Array [
"red",
"black",
],
"triggerIcon": "colorBy",
},
Object {
"columnId": "3",
"triggerIcon": "aggregate",
},
Object {
"columnId": "4",
"triggerIcon": undefined,
},
]
`);
const mosaicState = getExampleState();
mosaicState.shape = PieChartTypes.MOSAIC;
mosaicState.layers[0].primaryGroups = colIds.slice(0, 2);
mosaicState.layers[0].secondaryGroups = colIds.slice(2);
mosaicState.layers[0].collapseFns = {
'1': 'sum',
'3': 'max',
};
const mosaicConfiguration = pieVisualization.getConfiguration({
state: mosaicState,
frame,
layerId: mosaicState.layers[0].layerId,
});
expect(mosaicConfiguration.groups.map(({ accessors }) => accessors)).toMatchInlineSnapshot(`
Array [
Array [
Object {
"columnId": "1",
"triggerIcon": "aggregate",
},
Object {
"columnId": "2",
"palette": Array [
"red",
"black",
],
"triggerIcon": "colorBy",
},
],
Array [
Object {
"columnId": "3",
"triggerIcon": "aggregate",
},
Object {
"columnId": "4",
"triggerIcon": undefined,
},
],
Array [],
]
`);
});
it("doesn't count collapsed columns toward the dimension limits", () => {
const colIds = new Array(PartitionChartsMeta.pie.maxBuckets)
.fill(undefined)
.map((_, i) => String(i + 1));
const frame = mockFrame();
frame.datasourceLayers[LAYER_ID]!.getTableSpec = () =>
colIds.map((id) => ({ columnId: id, fields: [] }));
const state = getExampleState();
state.layers[0].primaryGroups = colIds;
const getConfig = (_state: PieVisualizationState) =>
pieVisualization.getConfiguration({
state: _state,
frame,
layerId: state.layers[0].layerId,
});
expect(getConfig(state).groups[0].supportsMoreColumns).toBeFalsy();
const stateWithCollapsed = cloneDeep(state);
stateWithCollapsed.layers[0].collapseFns = { '1': 'sum' };
expect(getConfig(stateWithCollapsed).groups[0].supportsMoreColumns).toBeTruthy();
});
});
});

View file

@ -13,6 +13,7 @@ import type { PaletteRegistry } from '@kbn/coloring';
import { ThemeServiceStart } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { EuiSpacer } from '@elastic/eui';
import type {
Visualization,
OperationMetadata,
@ -45,18 +46,28 @@ const bucketedOperations = (op: OperationMetadata) => op.isBucketed;
const numberMetricOperations = (op: OperationMetadata) =>
!op.isBucketed && op.dataType === 'number' && !op.isStaticValue;
export const isCollapsed = (columnId: string, layer: PieLayerState) =>
Boolean(layer.collapseFns?.[columnId]);
const applyPaletteToColumnConfig = (
columns: AccessorConfig[],
{ palette }: PieVisualizationState,
layer: PieLayerState,
palette: PieVisualizationState['palette'],
paletteService: PaletteRegistry
) => {
columns[0] = {
columnId: columns[0].columnId,
triggerIcon: 'colorBy',
palette: paletteService
.get(palette?.name || 'default')
.getCategoricalColors(10, palette?.params),
};
const firstNonCollapsedColumnIdx = columns.findIndex(
(column) => !isCollapsed(column.columnId, layer)
);
if (firstNonCollapsedColumnIdx > -1) {
columns[firstNonCollapsedColumnIdx] = {
columnId: columns[firstNonCollapsedColumnIdx].columnId,
triggerIcon: 'colorBy',
palette: paletteService
.get(palette?.name || 'default')
.getCategoricalColors(10, palette?.params),
};
}
};
export const getPieVisualization = ({
@ -129,10 +140,11 @@ export const getPieVisualization = ({
// When we add a column it could be empty, and therefore have no order
const accessors: AccessorConfig[] = originalOrder.map((accessor) => ({
columnId: accessor,
triggerIcon: isCollapsed(accessor, layer) ? ('aggregate' as const) : undefined,
}));
if (accessors.length) {
applyPaletteToColumnConfig(accessors, state, paletteService);
applyPaletteToColumnConfig(accessors, layer, state.palette, paletteService);
}
const primaryGroupConfigBaseProps = {
@ -143,6 +155,11 @@ export const getPieVisualization = ({
filterOperations: bucketedOperations,
};
const totalNonCollapsedAccessors = accessors.reduce(
(total, { columnId }) => total + (isCollapsed(columnId, layer) ? 0 : 1),
0
);
switch (state.shape) {
case 'donut':
case 'pie':
@ -154,7 +171,8 @@ export const getPieVisualization = ({
dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.sliceDimensionGroupLabel', {
defaultMessage: 'Slice',
}),
supportsMoreColumns: accessors.length < PartitionChartsMeta.pie.maxBuckets,
supportsMoreColumns: totalNonCollapsedAccessors < PartitionChartsMeta.pie.maxBuckets,
dimensionsTooMany: totalNonCollapsedAccessors - PartitionChartsMeta.pie.maxBuckets,
dataTestSubj: 'lnsPie_sliceByDimensionPanel',
};
case 'mosaic':
@ -166,7 +184,8 @@ export const getPieVisualization = ({
dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.verticalAxisDimensionLabel', {
defaultMessage: 'Vertical axis',
}),
supportsMoreColumns: accessors.length === 0,
supportsMoreColumns: totalNonCollapsedAccessors === 0,
dimensionsTooMany: totalNonCollapsedAccessors - 1,
dataTestSubj: 'lnsPie_verticalAxisDimensionPanel',
};
default:
@ -178,7 +197,10 @@ export const getPieVisualization = ({
dimensionEditorGroupLabel: i18n.translate('xpack.lens.pie.treemapDimensionGroupLabel', {
defaultMessage: 'Group',
}),
supportsMoreColumns: accessors.length < PartitionChartsMeta[state.shape].maxBuckets,
supportsMoreColumns:
totalNonCollapsedAccessors < PartitionChartsMeta[state.shape].maxBuckets,
dimensionsTooMany:
totalNonCollapsedAccessors - PartitionChartsMeta[state.shape].maxBuckets,
dataTestSubj: 'lnsPie_groupByDimensionPanel',
};
}
@ -188,6 +210,7 @@ export const getPieVisualization = ({
const originalSecondaryOrder = getSortedGroups(datasource, layer, 'secondaryGroups');
const accessors = originalSecondaryOrder.map((accessor) => ({
columnId: accessor,
triggerIcon: isCollapsed(accessor, layer) ? ('aggregate' as const) : undefined,
}));
const secondaryGroupConfigBaseProps = {
@ -198,6 +221,11 @@ export const getPieVisualization = ({
filterOperations: bucketedOperations,
};
const totalNonCollapsedAccessors = accessors.reduce(
(total, { columnId }) => total + (isCollapsed(columnId, layer) ? 0 : 1),
0
);
switch (state.shape) {
case 'mosaic':
return {
@ -211,7 +239,8 @@ export const getPieVisualization = ({
defaultMessage: 'Horizontal axis',
}
),
supportsMoreColumns: accessors.length === 0,
supportsMoreColumns: totalNonCollapsedAccessors === 0,
dimensionsTooMany: totalNonCollapsedAccessors - 1,
dataTestSubj: 'lnsPie_horizontalAxisDimensionPanel',
};
default:
@ -280,13 +309,21 @@ export const getPieVisualization = ({
return l;
}
if (l.metric === columnId) {
return { ...l, metric: undefined };
const newLayer = { ...l };
if (l.collapseFns?.[columnId]) {
const newCollapseFns = { ...l.collapseFns };
delete newCollapseFns[columnId];
newLayer.collapseFns = newCollapseFns;
}
if (newLayer.metric === columnId) {
return { ...newLayer, metric: undefined };
}
return {
...l,
primaryGroups: l.primaryGroups.filter((c) => c !== columnId),
secondaryGroups: l.secondaryGroups?.filter((c) => c !== columnId) ?? undefined,
...newLayer,
primaryGroups: newLayer.primaryGroups.filter((c) => c !== columnId),
secondaryGroups: newLayer.secondaryGroups?.filter((c) => c !== columnId) ?? undefined,
};
}),
};
@ -386,7 +423,35 @@ export const getPieVisualization = ({
},
getErrorMessages(state) {
// not possible to break it?
return undefined;
const hasTooManyBucketDimensions = state.layers
.map(
(layer) =>
Array.from(new Set([...layer.primaryGroups, ...(layer.secondaryGroups ?? [])])).filter(
(columnId) => !isCollapsed(columnId, layer)
).length > PartitionChartsMeta[state.shape].maxBuckets
)
.some(Boolean);
return hasTooManyBucketDimensions
? [
{
shortMessage: i18n.translate('xpack.lens.pie.tooManyDimensions', {
defaultMessage: 'Your visualization has too many dimensions.',
}),
longMessage: (
<span>
{i18n.translate('xpack.lens.pie.tooManyDimensionsLong', {
defaultMessage:
'Your visualization has too many dimensions. Please follow the instructions in the layer panel.',
})}
<EuiSpacer size="s" />
{i18n.translate('xpack.lens.pie.collapsedDimensionsDontCount', {
defaultMessage: "(Collapsed dimensions don't count toward this limit.)",
})}
</span>
),
},
]
: [];
},
});