[Lens] Metric trendlines design changes (#143781)

This commit is contained in:
Andrew Tate 2022-10-24 14:54:15 -05:00 committed by GitHub
parent 670fe25673
commit 15ef4a0bcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 686 additions and 399 deletions

View file

@ -1748,12 +1748,15 @@ describe('IndexPattern Data Source', () => {
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.removeLayer(state, 'first')).toEqual({
...state,
layers: {
second: {
indexPatternId: '2',
columnOrder: [],
columns: {},
removedLayerIds: ['first'],
newState: {
...state,
layers: {
second: {
indexPatternId: '2',
columnOrder: [],
columns: {},
},
},
},
});
@ -1777,8 +1780,72 @@ describe('IndexPattern Data Source', () => {
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.removeLayer(state, 'first')).toEqual({
...state,
layers: {},
removedLayerIds: ['first', 'second'],
newState: {
...state,
layers: {},
},
});
});
});
describe('#clearLayer', () => {
it('should clear a layer', () => {
const state = {
layers: {
first: {
indexPatternId: '1',
columnOrder: ['some', 'order'],
columns: {
some: {} as GenericIndexPatternColumn,
columns: {} as GenericIndexPatternColumn,
},
linkToLayers: ['some-layer'],
},
},
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.clearLayer(state, 'first')).toEqual({
removedLayerIds: [],
newState: {
...state,
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
linkToLayers: ['some-layer'],
},
},
},
});
});
it('should remove linked layers', () => {
const state = {
layers: {
first: {
indexPatternId: '1',
columnOrder: [],
columns: {},
},
second: {
indexPatternId: '2',
columnOrder: [],
columns: {},
linkToLayers: ['first'],
},
},
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.clearLayer(state, 'first')).toEqual({
removedLayerIds: ['second'],
newState: {
...state,
layers: {
first: state.layers.first,
},
},
});
});
});

View file

@ -220,27 +220,47 @@ export function getFormBasedDatasource({
removeLayer(state: FormBasedPrivateState, layerId: string) {
const newLayers = { ...state.layers };
delete newLayers[layerId];
const removedLayerIds: string[] = [layerId];
// delete layers linked to this layer
Object.keys(newLayers).forEach((id) => {
const linkedLayers = newLayers[id]?.linkToLayers;
if (linkedLayers && linkedLayers.includes(layerId)) {
delete newLayers[id];
removedLayerIds.push(id);
}
});
return {
...state,
layers: newLayers,
removedLayerIds,
newState: {
...state,
layers: newLayers,
},
};
},
clearLayer(state: FormBasedPrivateState, layerId: string) {
const newLayers = { ...state.layers };
const removedLayerIds: string[] = [];
// delete layers linked to this layer
Object.keys(newLayers).forEach((id) => {
const linkedLayers = newLayers[id]?.linkToLayers;
if (linkedLayers && linkedLayers.includes(layerId)) {
delete newLayers[id];
removedLayerIds.push(id);
}
});
return {
...state,
layers: {
...state.layers,
[layerId]: blankLayer(state.currentIndexPatternId, state.layers[layerId].linkToLayers),
removedLayerIds,
newState: {
...state,
layers: {
...newLayers,
[layerId]: blankLayer(state.currentIndexPatternId, state.layers[layerId].linkToLayers),
},
},
};
},

View file

@ -217,21 +217,24 @@ describe('IndexPattern Data Source', () => {
describe('#removeLayer', () => {
it('should remove a layer', () => {
expect(TextBasedDatasource.removeLayer(baseState, 'a')).toEqual({
...baseState,
layers: {
a: {
columns: [],
allColumns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
removedLayerIds: ['a'],
newState: {
...baseState,
layers: {
a: {
columns: [],
allColumns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: 'foo',
],
query: { sql: 'SELECT * FROM foo' },
index: 'foo',
},
},
},
});

View file

@ -281,18 +281,24 @@ export function getTextBasedDatasource({
};
return {
...state,
layers: newLayers,
fieldList: state.fieldList,
removedLayerIds: [layerId],
newState: {
...state,
layers: newLayers,
fieldList: state.fieldList,
},
};
},
clearLayer(state: TextBasedPrivateState, layerId: string) {
return {
...state,
layers: {
...state.layers,
[layerId]: { ...state.layers[layerId], columns: [] },
removedLayerIds: [],
newState: {
...state,
layers: {
...state.layers,
[layerId]: { ...state.layers[layerId], columns: [] },
},
},
};
},

View file

@ -494,8 +494,8 @@ describe('chart_switch', () => {
switchTo('visB', instance);
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'a');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'b');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith(undefined, 'c');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'b');
expect(datasourceMap.testDatasource.removeLayer).toHaveBeenCalledWith({}, 'c');
expect(visualizationMap.visB.getSuggestions).toHaveBeenCalledWith(
expect.objectContaining({
keptLayerIds: ['a'],

View file

@ -26,7 +26,7 @@ export function createMockDatasource(id: string): DatasourceMock {
return {
id: 'testDatasource',
clearLayer: jest.fn((state, _layerId) => state),
clearLayer: jest.fn((state, _layerId) => ({ newState: state, removedLayerIds: [] })),
getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn, _indexPatterns) => []),
getDatasourceSuggestionsForVisualizeField: jest.fn(
(_state, _indexpatternId, _fieldName, _indexPatterns) => []
@ -44,7 +44,7 @@ export function createMockDatasource(id: string): DatasourceMock {
renderLayerPanel: jest.fn(),
toExpression: jest.fn((_frame, _state, _indexPatterns) => null),
insertLayer: jest.fn((_state, _newLayerId) => ({})),
removeLayer: jest.fn((_state, _layerId) => {}),
removeLayer: jest.fn((state, layerId) => ({ newState: state, removedLayerIds: [layerId] })),
cloneLayer: jest.fn((_state, _layerId, _newLayerId, getNewId) => {}),
removeColumn: jest.fn((props) => {}),
getLayers: jest.fn((_state) => []),

View file

@ -39,6 +39,7 @@ describe('lensSlice', () => {
let store: EnhancedStore<{ lens: LensAppState }>;
beforeEach(() => {
store = makeLensStore({}).store;
jest.clearAllMocks();
});
const customQuery = { query: 'custom' } as Query;
@ -275,17 +276,21 @@ describe('lensSlice', () => {
return {
id: datasourceId,
getPublicAPI: () => ({
datasourceId: 'testDatasource',
datasourceId,
getOperationForColumnId: jest.fn(),
getTableSpec: jest.fn(),
}),
getLayers: () => ['layer1'],
clearLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).map((id: string) =>
clearLayer: (layerIds: unknown, layerId: string) => ({
removedLayerIds: [],
newState: (layerIds as string[]).map((id: string) =>
id === layerId ? `${datasourceId}_clear_${layerId}` : id
),
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
}),
removeLayer: (layerIds: unknown, layerId: string) => ({
newState: (layerIds as string[]).filter((id: string) => id !== layerId),
removedLayerIds: [layerId],
}),
insertLayer: (layerIds: unknown, layerId: string, layersToLinkTo: string[]) => [
...(layerIds as string[]),
layerId,
@ -317,8 +322,9 @@ describe('lensSlice', () => {
(layerIds as string[]).map((id: string) =>
id === layerId ? `vis_clear_${layerId}` : id
),
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
removeLayer: jest.fn((layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId)
),
getLayerIds: (layerIds: unknown) => layerIds as string[],
getLayersToLinkTo: (state, newLayerId) => ['linked-layer-id'],
appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
@ -482,6 +488,54 @@ describe('lensSlice', () => {
expect(state.datasourceStates.testDatasource2.state).toEqual(['layer2']);
expect(state.stagedPreview).not.toBeDefined();
});
it('removeLayer: should remove all layers from visualization that were removed by datasource', () => {
const removedLayerId = 'other-removed-layer';
const testDatasource3 = testDatasource('testDatasource3');
testDatasource3.removeLayer = (layerIds: unknown, layerId: string) => ({
newState: (layerIds as string[]).filter((id: string) => id !== layerId),
removedLayerIds: [layerId, removedLayerId],
});
const localStore = makeLensStore({
preloadedState: {
activeDatasourceId: 'testDatasource',
datasourceStates: {
...datasourceStates,
testDatasource3: {
isLoading: false,
state: [],
},
},
visualization: {
activeId: activeVisId,
state: ['layer1', 'layer2'],
},
stagedPreview: {
visualization: {
activeId: activeVisId,
state: ['layer1', 'layer2'],
},
datasourceStates,
},
},
storeDeps: mockStoreDeps({
visualizationMap: visualizationMap as unknown as VisualizationMap,
datasourceMap: { ...datasourceMap, testDatasource3 } as unknown as DatasourceMap,
}),
}).store;
localStore.dispatch(
removeOrClearLayer({
visualizationId: 'testVis',
layerId: 'layer1',
layerIds: ['layer1', 'layer2'],
})
);
expect(visualizationMap[activeVisId].removeLayer).toHaveBeenCalledTimes(2);
});
});
describe('removing a dimension', () => {
@ -546,8 +600,6 @@ describe('lensSlice', () => {
datasourceMap: datasourceMap as unknown as DatasourceMap,
}),
}).store;
jest.clearAllMocks();
});
it('removes a dimension', () => {

View file

@ -7,7 +7,7 @@
import { createAction, createReducer, current, PayloadAction } from '@reduxjs/toolkit';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { mapValues } from 'lodash';
import { mapValues, uniq } from 'lodash';
import { Query } from '@kbn/es-query';
import { History } from 'history';
import { LensEmbeddableInput } from '..';
@ -400,16 +400,23 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
layerIds.length
) === 'clear';
let removedLayerIds: string[] = [];
state.datasourceStates = mapValues(
state.datasourceStates,
(datasourceState, datasourceId) => {
const datasource = datasourceMap[datasourceId!];
const { newState, removedLayerIds: removedLayerIdsForThisDatasource } = isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId);
removedLayerIds = [...removedLayerIds, ...removedLayerIdsForThisDatasource];
return {
...datasourceState,
...(datasourceId === state.activeDatasourceId && {
state: isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId),
state: newState,
}),
};
}
@ -419,10 +426,22 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
const currentDataViewsId = activeDataSource.getUsedDataView(
state.datasourceStates[state.activeDatasourceId!].state
);
state.visualization.state =
isOnlyLayer || !activeVisualization.removeLayer
? activeVisualization.clearLayer(state.visualization.state, layerId, currentDataViewsId)
: activeVisualization.removeLayer(state.visualization.state, layerId);
if (isOnlyLayer || !activeVisualization.removeLayer) {
state.visualization.state = activeVisualization.clearLayer(
state.visualization.state,
layerId,
currentDataViewsId
);
}
uniq(removedLayerIds).forEach(
(removedId) =>
(state.visualization.state = activeVisualization.removeLayer?.(
state.visualization.state,
removedId
))
);
},
[changeIndexPattern.type]: (
state,
@ -977,9 +996,12 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
);
}) ?? [];
if (layerDatasourceId) {
state.datasourceStates[layerDatasourceId].state = datasourceMap[
layerDatasourceId
].removeLayer(current(state).datasourceStates[layerDatasourceId].state, layerId);
const { newState } = datasourceMap[layerDatasourceId].removeLayer(
current(state).datasourceStates[layerDatasourceId].state,
layerId
);
state.datasourceStates[layerDatasourceId].state = newState;
// TODO - call removeLayer for any extra (linked) layers removed by the datasource
}
});
},

View file

@ -265,8 +265,8 @@ export interface Datasource<T = unknown, P = unknown> {
insertLayer: (state: T, newLayerId: string, linkToLayers?: string[]) => T;
createEmptyLayer: (indexPatternId: string) => T;
removeLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string) => T;
removeLayer: (state: T, layerId: string) => { newState: T; removedLayerIds: string[] };
clearLayer: (state: T, layerId: string) => { newState: T; removedLayerIds: string[] };
cloneLayer: (
state: T,
layerId: string,

View file

@ -12,7 +12,11 @@ import { OperationDescriptor, VisualizationDimensionEditorProps } from '../../ty
import { CustomPaletteParams, PaletteOutput, PaletteRegistry } from '@kbn/coloring';
import { MetricVisualizationState } from './visualization';
import { DimensionEditor, SupportingVisType } from './dimension_editor';
import {
DimensionEditor,
DimensionEditorAdditionalSection,
SupportingVisType,
} from './dimension_editor';
import { HTMLAttributes, mount, ReactWrapper, shallow } from 'enzyme';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { EuiButtonGroup, EuiColorPicker, PropsOf } from '@elastic/eui';
@ -154,42 +158,6 @@ describe('dimension editor', () => {
this.colorPicker.props().onChange!(color, {} as EuiColorPickerOutput);
});
}
private get supportingVisButtonGroup() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_supporting_visualization_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get currentSupportingVis() {
return this.supportingVisButtonGroup
.props()
.idSelected?.split('--')[1] as SupportingVisType;
}
public isDisabled(type: SupportingVisType) {
return this.supportingVisButtonGroup.props().options.find(({ id }) => id.includes(type))
?.isDisabled;
}
public setSupportingVis(type: SupportingVisType) {
this.supportingVisButtonGroup.props().onChange(`some-id--${type}`);
}
private get progressDirectionControl() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_progress_direction_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get progressDirectionShowing() {
return this.progressDirectionControl.exists();
}
public setProgressDirection(direction: LayoutDirection) {
this.progressDirectionControl.props().onChange(direction);
this._wrapper.update();
}
}
const mockSetState = jest.fn();
@ -266,144 +234,6 @@ describe('dimension editor', () => {
`);
});
});
describe('supporting visualizations', () => {
const stateWOTrend = {
...metricAccessorState,
trendlineLayerId: undefined,
};
describe('reflecting visualization state', () => {
it('should select the correct button', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false, maxAccessor: undefined })
.currentSupportingVis
).toBe<SupportingVisType>('none');
expect(
getHarnessWithState({ ...stateWOTrend, showBar: true }).currentSupportingVis
).toBe<SupportingVisType>('bar');
expect(
getHarnessWithState(metricAccessorState).currentSupportingVis
).toBe<SupportingVisType>('trendline');
});
it('should disable bar when no max dimension', () => {
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: 'something',
}).isDisabled('bar')
).toBeFalsy();
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: undefined,
}).isDisabled('bar')
).toBeTruthy();
});
it('should disable trendline when no default time field', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => false,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeFalsy();
});
});
it('should disable trendline when a metric dimension has a reduced time range', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({ hasReducedTimeRange: id === stateWOTrend.metricAccessor } as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({
hasReducedTimeRange: id === stateWOTrend.secondaryMetricAccessor,
} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
});
describe('responding to buttons', () => {
it('enables trendline', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('trendline');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.addLayer).toHaveBeenCalledWith('metricTrendline');
expectCalledBefore(mockSetState, props.addLayer as jest.Mock);
});
it('enables bar', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('bar');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: true });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
it('selects none from bar', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.removeLayer).not.toHaveBeenCalled();
});
it('selects none from trendline', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: false });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
});
describe('progress bar direction controls', () => {
it('hides direction controls if bar not showing', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false }).progressDirectionShowing
).toBeFalsy();
});
it('toggles progress direction', () => {
const harness = getHarnessWithState(metricAccessorState);
expect(harness.progressDirectionShowing).toBeTruthy();
expect(harness.currentState.progressDirection).toBe('vertical');
harness.setProgressDirection('horizontal');
harness.setProgressDirection('vertical');
harness.setProgressDirection('horizontal');
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].progressDirection))
.toMatchInlineSnapshot(`
Array [
"horizontal",
"vertical",
"horizontal",
]
`);
});
});
});
});
describe('secondary metric dimension', () => {
@ -628,4 +458,235 @@ describe('dimension editor', () => {
`);
});
});
describe('additional section', () => {
const accessor = 'primary-metric-col-id';
const metricAccessorState = { ...fullState, metricAccessor: accessor };
class Harness {
public _wrapper;
constructor(
wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>
) {
this._wrapper = wrapper;
}
private get rootComponent() {
return this._wrapper.find(DimensionEditorAdditionalSection);
}
public get currentState() {
return this.rootComponent.props().state;
}
private get supportingVisButtonGroup() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_supporting_visualization_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get currentSupportingVis() {
return this.supportingVisButtonGroup
.props()
.idSelected?.split('--')[1] as SupportingVisType;
}
public isDisabled(type: SupportingVisType) {
return this.supportingVisButtonGroup.props().options.find(({ id }) => id.includes(type))
?.isDisabled;
}
public setSupportingVis(type: SupportingVisType) {
this.supportingVisButtonGroup.props().onChange(`some-id--${type}`);
}
private get progressDirectionControl() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_progress_direction_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get progressDirectionShowing() {
return this.progressDirectionControl.exists();
}
public setProgressDirection(direction: LayoutDirection) {
this.progressDirectionControl.props().onChange(direction);
this._wrapper.update();
}
}
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState, datasource = props.datasource) =>
new Harness(
mountWithIntl(
<DimensionEditorAdditionalSection
{...props}
datasource={datasource}
state={state}
setState={mockSetState}
accessor={accessor}
/>
)
);
it.each([
{ name: 'secondary metric', accessor: metricAccessorState.secondaryMetricAccessor },
{ name: 'max', accessor: metricAccessorState.maxAccessor },
{ name: 'break down by', accessor: metricAccessorState.breakdownByAccessor },
])('doesnt show for the following dimension: %s', ({ accessor: testAccessor }) => {
expect(
shallow(
<DimensionEditorAdditionalSection
{...props}
state={metricAccessorState}
setState={mockSetState}
accessor={testAccessor}
/>
).isEmptyRender()
).toBeTruthy();
});
describe('supporting visualizations', () => {
const stateWOTrend = {
...metricAccessorState,
trendlineLayerId: undefined,
};
describe('reflecting visualization state', () => {
it('should select the correct button', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false, maxAccessor: undefined })
.currentSupportingVis
).toBe<SupportingVisType>('none');
expect(
getHarnessWithState({ ...stateWOTrend, showBar: true }).currentSupportingVis
).toBe<SupportingVisType>('bar');
expect(
getHarnessWithState(metricAccessorState).currentSupportingVis
).toBe<SupportingVisType>('trendline');
});
it('should disable bar when no max dimension', () => {
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: 'something',
}).isDisabled('bar')
).toBeFalsy();
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: undefined,
}).isDisabled('bar')
).toBeTruthy();
});
it('should disable trendline when no default time field', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => false,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeFalsy();
});
});
it('should disable trendline when a metric dimension has a reduced time range', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({
hasReducedTimeRange: id === stateWOTrend.metricAccessor,
} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({
hasReducedTimeRange: id === stateWOTrend.secondaryMetricAccessor,
} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
});
describe('responding to buttons', () => {
it('enables trendline', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('trendline');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.addLayer).toHaveBeenCalledWith('metricTrendline');
expectCalledBefore(mockSetState, props.addLayer as jest.Mock);
});
it('enables bar', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('bar');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: true });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
it('selects none from bar', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.removeLayer).not.toHaveBeenCalled();
});
it('selects none from trendline', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: false });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
});
describe('progress bar direction controls', () => {
it('hides direction controls if bar not showing', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false }).progressDirectionShowing
).toBeFalsy();
});
it('toggles progress direction', () => {
const harness = getHarnessWithState(metricAccessorState);
expect(harness.progressDirectionShowing).toBeTruthy();
expect(harness.currentState.progressDirection).toBe('vertical');
harness.setProgressDirection('horizontal');
harness.setProgressDirection('vertical');
harness.setProgressDirection('horizontal');
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].progressDirection))
.toMatchInlineSnapshot(`
Array [
"horizontal",
"vertical",
"horizontal",
]
`);
});
});
});
});
});

View file

@ -17,6 +17,8 @@ import {
EuiColorPicker,
euiPaletteColorBlind,
EuiSpacer,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { LayoutDirection } from '@elastic/charts';
import React, { useCallback, useState } from 'react';
@ -30,6 +32,7 @@ import {
} from '@kbn/coloring';
import { getDataBoundsForPalette } from '@kbn/expression-metric-vis-plugin/public';
import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { css } from '@emotion/react';
import { isNumericFieldForDatatable } from '../../../common/expressions/datatable/utils';
import {
applyPaletteParams,
@ -263,170 +266,8 @@ function PrimaryMetricEditor(props: SubProps) {
const togglePalette = () => setIsPaletteOpen(!isPaletteOpen);
const supportingVisLabel = i18n.translate('xpack.lens.metric.supportingVis.label', {
defaultMessage: 'Supporting visualization',
});
const hasDefaultTimeField = props.datasource?.hasDefaultTimeField();
const metricHasReducedTimeRange = Boolean(
state.metricAccessor &&
props.datasource?.getOperationForColumnId(state.metricAccessor)?.hasReducedTimeRange
);
const secondaryMetricHasReducedTimeRange = Boolean(
state.secondaryMetricAccessor &&
props.datasource?.getOperationForColumnId(state.secondaryMetricAccessor)?.hasReducedTimeRange
);
const supportingVisHelpTexts: string[] = [];
const supportsTrendline =
hasDefaultTimeField && !metricHasReducedTimeRange && !secondaryMetricHasReducedTimeRange;
if (!supportsTrendline) {
supportingVisHelpTexts.push(
!hasDefaultTimeField
? i18n.translate('xpack.lens.metric.supportingVis.needDefaultTimeField', {
defaultMessage: 'Use a data view with a default time field to enable trend lines.',
})
: metricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.metricHasReducedTimeRange', {
defaultMessage:
'Remove the reduced time range on this dimension to enable trend lines.',
})
: secondaryMetricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.secondaryMetricHasReducedTimeRange', {
defaultMessage:
'Remove the reduced time range on the secondary metric dimension to enable trend lines.',
})
: ''
);
}
if (!state.maxAccessor) {
supportingVisHelpTexts.push(
i18n.translate('xpack.lens.metric.summportingVis.needMaxDimension', {
defaultMessage: 'Add a maximum dimension to enable the progress bar.',
})
);
}
const buttonIdPrefix = `${idPrefix}--`;
return (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={supportingVisLabel}
helpText={supportingVisHelpTexts.map((text) => (
<div>{text}</div>
))}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={supportingVisLabel}
data-test-subj="lnsMetric_supporting_visualization_buttons"
options={[
{
id: `${buttonIdPrefix}none`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_supporting_visualization_none',
},
{
id: `${buttonIdPrefix}trendline`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.trendline', {
defaultMessage: 'Trend line',
}),
isDisabled: !supportsTrendline,
'data-test-subj': 'lnsMetric_supporting_visualization_trendline',
},
{
id: `${buttonIdPrefix}bar`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.bar', {
defaultMessage: 'Bar',
}),
isDisabled: !state.maxAccessor,
'data-test-subj': 'lnsMetric_supporting_visualization_bar',
},
]}
idSelected={`${buttonIdPrefix}${
state.trendlineLayerId ? 'trendline' : showingBar(state) ? 'bar' : 'none'
}`}
onChange={(id) => {
const supportingVisualizationType = id.split('--')[1] as SupportingVisType;
switch (supportingVisualizationType) {
case 'trendline':
setState({
...state,
showBar: false,
});
props.addLayer('metricTrendline');
break;
case 'bar':
setState({
...state,
showBar: true,
});
if (state.trendlineLayerId) props.removeLayer(state.trendlineLayerId);
break;
case 'none':
setState({
...state,
showBar: false,
});
if (state.trendlineLayerId) props.removeLayer(state.trendlineLayerId);
break;
}
}}
/>
</EuiFormRow>
{showingBar(state) && (
<EuiFormRow
label={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
fullWidth
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
data-test-subj="lnsMetric_progress_direction_buttons"
name="alignment"
options={[
{
id: `${idPrefix}vertical`,
label: i18n.translate('xpack.lens.metric.progressDirection.vertical', {
defaultMessage: 'Vertical',
}),
'data-test-subj': 'lnsMetric_progress_bar_vertical',
},
{
id: `${idPrefix}horizontal`,
label: i18n.translate('xpack.lens.metric.progressDirection.horizontal', {
defaultMessage: 'Horizontal',
}),
'data-test-subj': 'lnsMetric_progress_bar_horizontal',
},
]}
idSelected={`${idPrefix}${state.progressDirection ?? 'vertical'}`}
onChange={(id) => {
const newDirection = id.replace(idPrefix, '') as LayoutDirection;
setState({
...state,
progressDirection: newDirection,
});
}}
/>
</EuiFormRow>
)}
<EuiFormRow
display="columnCompressed"
fullWidth
@ -580,3 +421,203 @@ function StaticColorControls({ state, setState }: Pick<Props, 'state' | 'setStat
</EuiFormRow>
);
}
export function DimensionEditorAdditionalSection({
state,
datasource,
setState,
addLayer,
removeLayer,
accessor,
}: VisualizationDimensionEditorProps<MetricVisualizationState>) {
const { euiTheme } = useEuiTheme();
if (accessor !== state.metricAccessor) {
return null;
}
const idPrefix = htmlIdGenerator()();
const hasDefaultTimeField = datasource?.hasDefaultTimeField();
const metricHasReducedTimeRange = Boolean(
state.metricAccessor &&
datasource?.getOperationForColumnId(state.metricAccessor)?.hasReducedTimeRange
);
const secondaryMetricHasReducedTimeRange = Boolean(
state.secondaryMetricAccessor &&
datasource?.getOperationForColumnId(state.secondaryMetricAccessor)?.hasReducedTimeRange
);
const supportingVisHelpTexts: string[] = [];
const supportsTrendline =
hasDefaultTimeField && !metricHasReducedTimeRange && !secondaryMetricHasReducedTimeRange;
if (!supportsTrendline) {
supportingVisHelpTexts.push(
!hasDefaultTimeField
? i18n.translate('xpack.lens.metric.supportingVis.needDefaultTimeField', {
defaultMessage:
'Line visualizations require use of a data view with a default time field.',
})
: metricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.metricHasReducedTimeRange', {
defaultMessage:
'Line visualizations cannot be used when a reduced time range is applied to the primary metric.',
})
: secondaryMetricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.secondaryMetricHasReducedTimeRange', {
defaultMessage:
'Line visualizations cannot be used when a reduced time range is applied to the secondary metric.',
})
: ''
);
}
if (!state.maxAccessor) {
supportingVisHelpTexts.push(
i18n.translate('xpack.lens.metric.summportingVis.needMaxDimension', {
defaultMessage: 'Bar visualizations require a maximum value to be defined.',
})
);
}
const buttonIdPrefix = `${idPrefix}--`;
return (
<div className="lnsIndexPatternDimensionEditor--padded lnsIndexPatternDimensionEditor--collapseNext">
<EuiText
size="s"
css={css`
margin-bottom: ${euiTheme.size.base};
`}
>
<h4>
{i18n.translate('xpack.lens.metric.supportingVis.label', {
defaultMessage: 'Supporting visualization',
})}
</h4>
</EuiText>
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.supportingVis.type', {
defaultMessage: 'Type',
})}
helpText={supportingVisHelpTexts.map((text) => (
<p>{text}</p>
))}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.supportingVis.type', {
defaultMessage: 'Type',
})}
data-test-subj="lnsMetric_supporting_visualization_buttons"
options={[
{
id: `${buttonIdPrefix}none`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_supporting_visualization_none',
},
{
id: `${buttonIdPrefix}trendline`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.trendline', {
defaultMessage: 'Line',
}),
isDisabled: !supportsTrendline,
'data-test-subj': 'lnsMetric_supporting_visualization_trendline',
},
{
id: `${buttonIdPrefix}bar`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.bar', {
defaultMessage: 'Bar',
}),
isDisabled: !state.maxAccessor,
'data-test-subj': 'lnsMetric_supporting_visualization_bar',
},
]}
idSelected={`${buttonIdPrefix}${
state.trendlineLayerId ? 'trendline' : showingBar(state) ? 'bar' : 'none'
}`}
onChange={(id) => {
const supportingVisualizationType = id.split('--')[1] as SupportingVisType;
switch (supportingVisualizationType) {
case 'trendline':
setState({
...state,
showBar: false,
});
addLayer('metricTrendline');
break;
case 'bar':
setState({
...state,
showBar: true,
});
if (state.trendlineLayerId) removeLayer(state.trendlineLayerId);
break;
case 'none':
setState({
...state,
showBar: false,
});
if (state.trendlineLayerId) removeLayer(state.trendlineLayerId);
break;
}
}}
/>
</EuiFormRow>
{showingBar(state) && (
<EuiFormRow
label={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar orientation',
})}
fullWidth
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar orientation',
})}
data-test-subj="lnsMetric_progress_direction_buttons"
name="alignment"
options={[
{
id: `${idPrefix}vertical`,
label: i18n.translate('xpack.lens.metric.progressDirection.vertical', {
defaultMessage: 'Vertical',
}),
'data-test-subj': 'lnsMetric_progress_bar_vertical',
},
{
id: `${idPrefix}horizontal`,
label: i18n.translate('xpack.lens.metric.progressDirection.horizontal', {
defaultMessage: 'Horizontal',
}),
'data-test-subj': 'lnsMetric_progress_bar_horizontal',
},
]}
idSelected={`${idPrefix}${state.progressDirection ?? 'vertical'}`}
onChange={(id) => {
const newDirection = id.replace(idPrefix, '') as LayoutDirection;
setState({
...state,
progressDirection: newDirection,
});
}}
/>
</EuiFormRow>
)}
</>
</div>
);
}

View file

@ -30,7 +30,7 @@ import {
Suggestion,
} from '../../types';
import { GROUP_ID, LENS_METRIC_ID } from './constants';
import { DimensionEditor } from './dimension_editor';
import { DimensionEditor, DimensionEditorAdditionalSection } from './dimension_editor';
import { Toolbar } from './toolbar';
import { generateId } from '../../id_generator';
import { FormatSelectorOptions } from '../../datasources/form_based/dimension_panel/format_selector';
@ -454,6 +454,10 @@ export const getMetricVisualization = ({
return newState;
},
getRemoveOperation(state, layerId) {
return layerId === state.trendlineLayerId ? 'remove' : 'clear';
},
getLayersToLinkTo(state, newLayerId: string): string[] {
return newLayerId === state.trendlineLayerId ? [state.layerId] : [];
},
@ -617,6 +621,17 @@ export const getMetricVisualization = ({
);
},
renderDimensionEditorAdditionalSection(domElement, props) {
render(
<KibanaThemeProvider theme$={theme.theme$}>
<I18nProvider>
<DimensionEditorAdditionalSection {...props} />
</I18nProvider>
</KibanaThemeProvider>,
domElement
);
},
getErrorMessages(state) {
// Is it possible to break it?
return undefined;