[Lens] Improve layer suggestions and fix layer crash in metric (#49389) (#49694)

* Add loading indicator to Lens workspace panel

* [Expressions] [Lens] Handle loading and errors in ExpressionRenderer

* Using loading$ observable and improve tests

* [Lens] Fix layer crash and improve layer suggestions

* Using CSS and to handle layout of expression renderer

Added TODO for using chart loader when area is completely empty

* Improve error handling and simplify code

* Fix cleanup behavior

* Fix double render and prevent error cases in xy chart

* Fix context for use in dashboards

* Remove className from expression rendere component

* Improve handling of additional interpreter args

* More layout fixes

- Hide chart if Empty not Loading
- Fix relative positioning for progress bar since className is no longer passed (super hacky)

* Build suggestions that remove layers

* Update tests and add keptLayerIds everywhere

* Fix bug where datatable would accept multi-layer suggestions

* Build more suggestions that work with metric/datatable

* Fix issue with chart switching from empty

* Fix datatable multiple layer issue
This commit is contained in:
Wylie Conlon 2019-10-29 20:48:54 -04:00 committed by GitHub
parent 398beccd4d
commit 2562c5866b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 411 additions and 190 deletions

View file

@ -12,7 +12,7 @@ import {
DataTableLayer,
} from './visualization';
import { mount } from 'enzyme';
import { Operation, DataType, FramePublicAPI } from '../types';
import { Operation, DataType, FramePublicAPI, TableSuggestionColumn } from '../types';
import { generateId } from '../id_generator';
jest.mock('../id_generator');
@ -72,6 +72,112 @@ describe('Datatable Visualization', () => {
});
});
describe('#getSuggestions', () => {
function numCol(columnId: string): TableSuggestionColumn {
return {
columnId,
operation: {
dataType: 'number',
label: `Avg ${columnId}`,
isBucketed: false,
},
};
}
function strCol(columnId: string): TableSuggestionColumn {
return {
columnId,
operation: {
dataType: 'string',
label: `Top 5 ${columnId}`,
isBucketed: true,
},
};
}
it('should accept a single-layer suggestion', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
layers: [{ layerId: 'first', columns: ['col1'] }],
},
table: {
isMultiRow: true,
layerId: 'first',
changeType: 'initial',
columns: [numCol('col1'), strCol('col2')],
},
keptLayerIds: [],
});
expect(suggestions.length).toBeGreaterThan(0);
});
it('should not make suggestions when the table is unchanged', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
layers: [{ layerId: 'first', columns: ['col1'] }],
},
table: {
isMultiRow: true,
layerId: 'first',
changeType: 'unchanged',
columns: [numCol('col1')],
},
keptLayerIds: ['first'],
});
expect(suggestions).toEqual([]);
});
it('should not make suggestions when multiple layers are involved', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
layers: [{ layerId: 'first', columns: ['col1'] }],
},
table: {
isMultiRow: true,
layerId: 'first',
changeType: 'unchanged',
columns: [numCol('col1')],
},
keptLayerIds: ['first', 'second'],
});
expect(suggestions).toEqual([]);
});
it('should not make suggestions when the suggestion keeps a different layer', () => {
const suggestions = datatableVisualization.getSuggestions({
state: {
layers: [{ layerId: 'older', columns: ['col1'] }],
},
table: {
isMultiRow: true,
layerId: 'newer',
changeType: 'initial',
columns: [numCol('col1'), strCol('col2')],
},
keptLayerIds: ['older'],
});
expect(suggestions).toEqual([]);
});
it('should suggest unchanged tables when the state is not passed in', () => {
const suggestions = datatableVisualization.getSuggestions({
table: {
isMultiRow: true,
layerId: 'first',
changeType: 'unchanged',
columns: [numCol('col1')],
},
keptLayerIds: ['first'],
});
expect(suggestions.length).toBeGreaterThan(0);
});
});
describe('DataTableLayer', () => {
it('allows all kinds of operations', () => {
const setState = jest.fn();

View file

@ -134,10 +134,15 @@ export const datatableVisualization: Visualization<
getSuggestions({
table,
state,
keptLayerIds,
}: SuggestionRequest<DatatableVisualizationState>): Array<
VisualizationSuggestion<DatatableVisualizationState>
> {
if (state && table.changeType === 'unchanged') {
if (
keptLayerIds.length > 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
(state && table.changeType === 'unchanged')
) {
return [];
}
const title =

View file

@ -94,6 +94,7 @@ describe('chart_switch', () => {
layerId: 'a',
changeType: 'unchanged',
},
keptLayerIds: ['a'],
},
]);
return {
@ -180,6 +181,8 @@ describe('chart_switch', () => {
switchTo('subvisB', component);
expect(frame.removeLayers).toHaveBeenCalledWith(['a']);
expect(dispatch).toHaveBeenCalledWith({
initialState: 'visB initial state',
newVisualizationId: 'visB',
@ -219,6 +222,7 @@ describe('chart_switch', () => {
isMultiRow: true,
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
datasourceMap.testDatasource.publicAPIMock.getTableSpec.mockReturnValue([
@ -307,11 +311,7 @@ describe('chart_switch', () => {
it('should not indicate data loss if visualization is not changed', () => {
const dispatch = jest.fn();
const removeLayers = jest.fn();
const frame = {
...mockFrame(['a', 'b', 'c']),
removeLayers,
};
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
const switchVisualizationType = jest.fn(() => 'therebedragons');
@ -332,30 +332,6 @@ describe('chart_switch', () => {
expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined();
});
it('should remove unused layers', () => {
const removeLayers = jest.fn();
const frame = {
...mockFrame(['a', 'b', 'c']),
removeLayers,
};
const component = mount(
<ChartSwitch
visualizationId="visA"
visualizationState={{}}
visualizationMap={mockVisualizations()}
dispatch={jest.fn()}
framePublicAPI={frame}
datasourceMap={mockDatasourceMap()}
datasourceStates={mockDatasourceStates()}
/>
);
switchTo('subvisB', component);
expect(removeLayers).toHaveBeenCalledTimes(1);
expect(removeLayers).toHaveBeenCalledWith(['b', 'c']);
});
it('should remove all layers if there is no suggestion', () => {
const dispatch = jest.fn();
const visualizations = mockVisualizations();
@ -378,15 +354,24 @@ describe('chart_switch', () => {
expect(frame.removeLayers).toHaveBeenCalledTimes(1);
expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(visualizations.visB.getSuggestions).toHaveBeenCalledWith(
expect.objectContaining({
keptLayerIds: ['a'],
})
);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SWITCH_VISUALIZATION',
initialState: 'visB initial state',
})
);
});
it('should not remove layers if the visualization is not changing', () => {
const dispatch = jest.fn();
const removeLayers = jest.fn();
const frame = {
...mockFrame(['a', 'b', 'c']),
removeLayers,
};
const frame = mockFrame(['a', 'b', 'c']);
const visualizations = mockVisualizations();
const switchVisualizationType = jest.fn(() => 'therebedragons');
@ -405,7 +390,6 @@ describe('chart_switch', () => {
);
switchTo('subvisC2', component);
expect(removeLayers).not.toHaveBeenCalled();
expect(switchVisualizationType).toHaveBeenCalledWith('subvisC2', 'therebegriffins');
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
@ -447,6 +431,7 @@ describe('chart_switch', () => {
isMultiRow: true,
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);

View file

@ -88,6 +88,10 @@ export function ChartSwitch(props: Props) {
},
'SWITCH_VISUALIZATION'
);
if (!selection.datasourceId || selection.dataLoss === 'everything') {
props.framePublicAPI.removeLayers(Object.keys(props.framePublicAPI.datasourceLayers));
}
};
function getSelection(

View file

@ -35,6 +35,7 @@ function generateSuggestion(state = {}): DatasourceSuggestion {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: ['first'],
};
}
@ -928,6 +929,7 @@ describe('editor_frame', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
@ -1073,6 +1075,7 @@ describe('editor_frame', () => {
isMultiRow: true,
layerId: 'first',
},
keptLayerIds: [],
},
]);
mount(

View file

@ -16,6 +16,7 @@ const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSu
layerId,
changeType: 'unchanged',
},
keptLayerIds: [layerId],
});
let datasourceMap: Record<string, DatasourceMock>;
@ -235,8 +236,8 @@ describe('suggestion helpers', () => {
changeType: 'unchanged',
};
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
{ state: {}, table: table1 },
{ state: {}, table: table2 },
{ state: {}, table: table1, keptLayerIds: ['first'] },
{ state: {}, table: table2, keptLayerIds: ['first'] },
]);
getSuggestions({
visualizationMap: {
@ -343,45 +344,4 @@ describe('suggestion helpers', () => {
})
);
});
it('should drop other layers only on visualization switch', () => {
const mockVisualization1 = createMockVisualization();
const mockVisualization2 = createMockVisualization();
datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
generateSuggestion(),
]);
datasourceMap.mock.getLayers.mockReturnValue(['first', 'second']);
const suggestions = getSuggestions({
visualizationMap: {
vis1: {
...mockVisualization1,
getSuggestions: () => [
{
score: 0.8,
title: 'Test2',
state: {},
previewIcon: 'empty',
},
],
},
vis2: {
...mockVisualization2,
getSuggestions: () => [
{
score: 0.6,
title: 'Test3',
state: {},
previewIcon: 'empty',
},
],
},
},
activeVisualizationId: 'vis1',
visualizationState: {},
datasourceMap,
datasourceStates,
});
expect(suggestions[0].keptLayerIds).toEqual(['first', 'second']);
expect(suggestions[1].keptLayerIds).toEqual(['first']);
});
});

View file

@ -13,6 +13,7 @@ import {
FramePublicAPI,
TableChangeType,
TableSuggestion,
DatasourceSuggestion,
} from '../../types';
import { Action } from './state_management';
@ -20,7 +21,6 @@ export interface Suggestion {
visualizationId: string;
datasourceState?: unknown;
datasourceId?: string;
keptLayerIds: string[];
columns: number;
score: number;
title: string;
@ -29,6 +29,7 @@ export interface Suggestion {
previewIcon: IconType;
hide?: boolean;
changeType: TableChangeType;
keptLayerIds: string[];
}
/**
@ -64,12 +65,6 @@ export function getSuggestions({
([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading
);
const allLayerIds = _.flatten(
datasources.map(([datasourceId, datasource]) =>
datasource.getLayers(datasourceStates[datasourceId].state)
)
);
// Collect all table suggestions from available datasources
const datasourceTableSuggestions = _.flatten(
datasources.map(([datasourceId, datasource]) => {
@ -90,17 +85,12 @@ export function getSuggestions({
const table = datasourceSuggestion.table;
const currentVisualizationState =
visualizationId === activeVisualizationId ? visualizationState : undefined;
const keptLayerIds =
visualizationId !== activeVisualizationId
? [datasourceSuggestion.table.layerId]
: allLayerIds;
return getVisualizationSuggestions(
visualization,
table,
visualizationId,
datasourceSuggestion,
currentVisualizationState,
keptLayerIds
currentVisualizationState
);
})
)
@ -118,20 +108,20 @@ function getVisualizationSuggestions(
visualization: Visualization<unknown, unknown>,
table: TableSuggestion,
visualizationId: string,
datasourceSuggestion: { datasourceId: string; state: unknown; table: TableSuggestion },
currentVisualizationState: unknown,
keptLayerIds: string[]
datasourceSuggestion: DatasourceSuggestion & { datasourceId: string },
currentVisualizationState: unknown
) {
return visualization
.getSuggestions({
table,
state: currentVisualizationState,
keptLayerIds: datasourceSuggestion.keptLayerIds,
})
.map(({ state, ...visualizationSuggestion }) => ({
...visualizationSuggestion,
visualizationId,
visualizationState: state,
keptLayerIds,
keptLayerIds: datasourceSuggestion.keptLayerIds,
datasourceState: datasourceSuggestion.state,
datasourceId: datasourceSuggestion.datasourceId,
columns: table.columns.length,
@ -144,7 +134,7 @@ export function switchToSuggestion(
dispatch: (action: Action) => void,
suggestion: Pick<
Suggestion,
'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds'
'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId'
>,
type: 'SWITCH_VISUALIZATION' | 'SELECT_SUGGESTION' = 'SELECT_SUGGESTION'
) {
@ -156,10 +146,4 @@ export function switchToSuggestion(
datasourceId: suggestion.datasourceId!,
};
dispatch(action);
const layerIds = Object.keys(frame.datasourceLayers).filter(id => {
return !suggestion.keptLayerIds.includes(id);
});
if (layerIds.length > 0) {
frame.removeLayers(layerIds);
}
}

View file

@ -218,36 +218,6 @@ describe('suggestion_panel', () => {
);
});
it('should remove unused layers if suggestion is clicked', () => {
defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock;
defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock;
const wrapper = mount(
<SuggestionPanel
{...defaultProps}
stagedPreview={{ visualization: { state: {}, activeId: 'vis' }, datasourceStates: {} }}
activeVisualizationId="vis2"
/>
);
act(() => {
wrapper
.find('button[data-test-subj="lnsSuggestion"]')
.at(1)
.simulate('click');
});
wrapper.update();
act(() => {
wrapper
.find('[data-test-subj="lensSubmitSuggestion"]')
.first()
.simulate('click');
});
expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']);
});
it('should render preview expression if there is one', () => {
mockDatasource.getLayers.mockReturnValue(['first']);
(getSuggestions as jest.Mock).mockReturnValue([

View file

@ -574,6 +574,7 @@ describe('workspace_panel', () => {
{
state: {},
table: expectedTable,
keptLayerIds: [],
},
]);
mockVisualization.getSuggestions.mockReturnValueOnce([
@ -613,6 +614,7 @@ describe('workspace_panel', () => {
columns: [],
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
mockVisualization.getSuggestions.mockReturnValueOnce([
@ -639,6 +641,7 @@ describe('workspace_panel', () => {
columns: [],
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
mockVisualization2.getSuggestions.mockReturnValueOnce([
@ -665,6 +668,7 @@ describe('workspace_panel', () => {
columns: [],
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
mockVisualization.getSuggestions.mockReturnValueOnce([
@ -694,6 +698,7 @@ describe('workspace_panel', () => {
layerId: '1',
changeType: 'unchanged',
},
keptLayerIds: [],
},
{
state: {},
@ -703,6 +708,7 @@ describe('workspace_panel', () => {
layerId: '1',
changeType: 'unchanged',
},
keptLayerIds: [],
},
]);
mockVisualization.getSuggestions.mockReturnValueOnce([

View file

@ -260,6 +260,7 @@ export function getIndexPatternDatasource({
state,
layerId: props.layerId,
onError: onIndexPatternLoadError,
replaceIfPossible: true,
});
}}
{...props}

View file

@ -562,6 +562,62 @@ describe('IndexPattern Data Source suggestions', () => {
})
);
});
it('creates a new layer and replaces layer if no match is found', () => {
const suggestions = getDatasourceSuggestionsForField(stateWithEmptyLayer(), '2', {
name: 'source',
type: 'string',
aggregatable: true,
searchable: true,
});
expect(suggestions).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({
layers: {
previousLayer: expect.objectContaining({
indexPatternId: '1',
}),
id1: expect.objectContaining({
indexPatternId: '2',
}),
},
}),
table: {
changeType: 'initial',
label: undefined,
isMultiRow: true,
columns: expect.arrayContaining([]),
layerId: 'id1',
},
keptLayerIds: ['previousLayer'],
})
);
expect(suggestions).toContainEqual(
expect.objectContaining({
state: expect.objectContaining({
layers: {
id1: expect.objectContaining({
indexPatternId: '2',
}),
},
}),
table: {
changeType: 'initial',
label: undefined,
isMultiRow: false,
columns: expect.arrayContaining([
expect.objectContaining({
columnId: expect.any(String),
}),
]),
layerId: 'id1',
},
keptLayerIds: [],
})
);
});
});
describe('suggesting extensions to non-empty tables', () => {
@ -979,12 +1035,25 @@ describe('IndexPattern Data Source suggestions', () => {
};
const result = getDatasourceSuggestionsFromCurrentState(state);
expect(result).toContainEqual(
expect.objectContaining({
table: expect.objectContaining({
isMultiRow: true,
changeType: 'unchanged',
label: undefined,
layerId: 'first',
}),
keptLayerIds: ['first', 'second'],
})
);
expect(result).toContainEqual(
expect.objectContaining({
table: {
isMultiRow: true,
changeType: 'unchanged',
label: undefined,
changeType: 'layers',
label: 'Show only layer 1',
columns: [
{
columnId: 'col1',
@ -1005,8 +1074,8 @@ describe('IndexPattern Data Source suggestions', () => {
expect.objectContaining({
table: {
isMultiRow: true,
changeType: 'unchanged',
label: undefined,
changeType: 'layers',
label: 'Show only layer 2',
columns: [
{
columnId: 'cola',

View file

@ -81,6 +81,8 @@ function buildSuggestion({
changeType,
label,
},
keptLayerIds: Object.keys(state.layers),
};
}
@ -93,10 +95,13 @@ export function getDatasourceSuggestionsForField(
const layerIds = layers.filter(id => state.layers[id].indexPatternId === indexPatternId);
if (layerIds.length === 0) {
// The field we're suggesting on does not match any existing layer. This will always add
// a new layer if possible, but that might not be desirable if the layers are too complicated
// already
return getEmptyLayerSuggestionsForField(state, generateId(), indexPatternId, field);
// The field we're suggesting on does not match any existing layer.
// This generates a set of suggestions where we add a layer.
// A second set of suggestions is generated for visualizations that don't work with layers
const newId = generateId();
return getEmptyLayerSuggestionsForField(state, newId, indexPatternId, field).concat(
getEmptyLayerSuggestionsForField({ ...state, layers: {} }, newId, indexPatternId, field)
);
} else {
// The field we're suggesting on matches an existing layer. In this case we find the layer with
// the fewest configured columns and try to add the field to this table. If this layer does not
@ -166,7 +171,7 @@ function addFieldAsMetricOperation(
layerId: string,
indexPattern: IndexPattern,
field: IndexPatternField
) {
): IndexPatternLayer | undefined {
const operations = getOperationTypesForField(field);
const operationsAlreadyAppliedToThisField = Object.values(layer.columns)
.filter(column => hasField(column) && column.sourceField === field.name)
@ -176,7 +181,7 @@ function addFieldAsMetricOperation(
);
if (!operationCandidate) {
return undefined;
return;
}
const newColumn = buildColumn({
@ -206,7 +211,7 @@ function addFieldAsBucketOperation(
layerId: string,
indexPattern: IndexPattern,
field: IndexPatternField
) {
): IndexPatternLayer {
const applicableBucketOperation = getBucketOperation(field);
const newColumn = buildColumn({
op: applicableBucketOperation,
@ -250,7 +255,7 @@ function getEmptyLayerSuggestionsForField(
layerId: string,
indexPatternId: string,
field: IndexPatternField
) {
): IndexPatternSugestion[] {
const indexPattern = state.indexPatterns[indexPatternId];
let newLayer: IndexPatternLayer | undefined;
if (getBucketOperation(field)) {
@ -279,7 +284,7 @@ function createNewLayerWithBucketAggregation(
layerId: string,
indexPattern: IndexPattern,
field: IndexPatternField
) {
): IndexPatternLayer {
const countColumn = buildColumn({
op: 'count',
columns: {},
@ -318,7 +323,7 @@ function createNewLayerWithMetricAggregation(
layerId: string,
indexPattern: IndexPattern,
field: IndexPatternField
) {
): IndexPatternLayer {
const dateField = indexPattern.fields.find(f => f.name === indexPattern.timeFieldName)!;
const operations = getOperationTypesForField(field);
@ -356,10 +361,50 @@ function createNewLayerWithMetricAggregation(
export function getDatasourceSuggestionsFromCurrentState(
state: IndexPatternPrivateState
): Array<DatasourceSuggestion<IndexPatternPrivateState>> {
const layers = Object.entries(state.layers || {});
if (layers.length > 1) {
// Return suggestions that reduce the data to each layer individually
return layers
.map(([layerId, layer], index) => {
const hasMatchingLayer = layers.some(
([otherLayerId, otherLayer]) =>
otherLayerId !== layerId && otherLayer.indexPatternId === layer.indexPatternId
);
const suggestionTitle = hasMatchingLayer
? i18n.translate('xpack.lens.indexPatternSuggestion.removeLayerPositionLabel', {
defaultMessage: 'Show only layer {layerNumber}',
values: { layerNumber: index + 1 },
})
: i18n.translate('xpack.lens.indexPatternSuggestion.removeLayerLabel', {
defaultMessage: 'Show only {indexPatternTitle}',
values: { indexPatternTitle: state.indexPatterns[layer.indexPatternId].title },
});
return buildSuggestion({
state: {
...state,
layers: {
[layerId]: layer,
},
},
layerId,
changeType: 'layers',
label: suggestionTitle,
});
})
.concat([
buildSuggestion({
state,
layerId: layers[0][0],
changeType: 'unchanged',
}),
]);
}
return _.flatten(
Object.entries(state.layers || {})
.filter(([_id, layer]) => layer.columnOrder.length)
.map(([layerId, layer], index) => {
.filter(([_id, layer]) => layer.columnOrder.length && layer.indexPatternId)
.map(([layerId, layer]) => {
const indexPattern = state.indexPatterns[layer.indexPatternId];
const [buckets, metrics] = separateBucketColumns(layer);
const timeDimension = layer.columnOrder.find(
@ -432,7 +477,6 @@ function createMetricSuggestion(
state: IndexPatternPrivateState,
field: IndexPatternField
) {
const layer = state.layers[layerId];
const operationDefinitionsMap = _.indexBy(operationDefinitions, 'type');
const [column] = getOperationTypesForField(field)
.map(type =>
@ -457,7 +501,7 @@ function createMetricSuggestion(
state,
changeType: 'initial',
updatedLayer: {
...layer,
indexPatternId: indexPattern.id,
columns: {
[newId]:
column.dataType !== 'document'
@ -515,7 +559,7 @@ function createAlternativeMetricSuggestions(
suggestedPriority: undefined,
});
const updatedLayer = {
...layer,
indexPatternId: indexPattern.id,
columns: { [newId]: newColumn },
columnOrder: [newId],
};
@ -549,7 +593,7 @@ function createSuggestionWithDefaultDateHistogram(
suggestedPriority: undefined,
});
const updatedLayer = {
...layer,
indexPatternId: layer.indexPatternId,
columns: { ...layer.columns, [newId]: timeColumn },
columnOrder: [...buckets, newId, ...metrics],
};

View file

@ -172,6 +172,7 @@ export async function changeLayerIndexPattern({
state,
setState,
onError,
replaceIfPossible,
}: {
indexPatternId: string;
layerId: string;
@ -179,6 +180,7 @@ export async function changeLayerIndexPattern({
state: IndexPatternPrivateState;
setState: SetState;
onError: ErrorHandler;
replaceIfPossible?: boolean;
}) {
try {
const indexPatterns = await loadIndexPatterns({
@ -197,6 +199,7 @@ export async function changeLayerIndexPattern({
...s.indexPatterns,
[indexPatternId]: indexPatterns[indexPatternId],
},
currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId,
}));
} catch (err) {
onError(err);
@ -212,10 +215,14 @@ async function loadIndexPatternRefs(
perPage: 10000,
});
return result.savedObjects.map(o => ({
id: String(o.id),
title: (o.attributes as { title: string }).title,
}));
return result.savedObjects
.map(o => ({
id: String(o.id),
title: (o.attributes as { title: string }).title,
}))
.sort((a, b) => {
return a.title.localeCompare(b.title);
});
}
export async function syncExistingFields({

View file

@ -80,7 +80,9 @@ describe('metric_suggestions', () => {
layerId: 'l1',
changeType: 'unchanged',
},
] as TableSuggestion[]).map(table => expect(getSuggestions({ table })).toEqual([]))
] as TableSuggestion[]).map(table =>
expect(getSuggestions({ table, keptLayerIds: ['l1'] })).toEqual([])
)
);
});
@ -92,6 +94,7 @@ describe('metric_suggestions', () => {
layerId: 'l1',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -107,4 +110,32 @@ describe('metric_suggestions', () => {
}
`);
});
test('does not suggest for multiple layers', () => {
const suggestions = getSuggestions({
table: {
columns: [numCol('bytes')],
isMultiRow: false,
layerId: 'l1',
changeType: 'unchanged',
},
keptLayerIds: ['l1', 'l2'],
});
expect(suggestions).toHaveLength(0);
});
test('does not suggest when the suggestion keeps a different layer', () => {
const suggestions = getSuggestions({
table: {
columns: [numCol('bytes')],
isMultiRow: false,
layerId: 'newer',
changeType: 'initial',
},
keptLayerIds: ['older'],
});
expect(suggestions).toHaveLength(0);
});
});

View file

@ -16,11 +16,14 @@ import chartMetricSVG from '../assets/chart_metric.svg';
export function getSuggestions({
table,
state,
keptLayerIds,
}: SuggestionRequest<State>): Array<VisualizationSuggestion<State>> {
// We only render metric charts for single-row queries. We require a single, numeric column.
if (
table.isMultiRow ||
table.columns.length > 1 ||
keptLayerIds.length > 1 ||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
table.columns.length !== 1 ||
table.columns[0].operation.dataType !== 'number'
) {
return [];

View file

@ -104,12 +104,14 @@ export interface TableSuggestion {
* * `unchanged` means the table is the same in the currently active configuration
* * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them)
* * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns)
* * `layers` means the change is a change to the layer structure, not to the table
*/
export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended';
export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'layers';
export interface DatasourceSuggestion<T = unknown> {
state: T;
table: TableSuggestion;
keptLayerIds: string[];
}
export interface DatasourceMetaData {
@ -262,6 +264,10 @@ export interface SuggestionRequest<T = unknown> {
* State is only passed if the visualization is active.
*/
state?: T;
/**
* The visualization needs to know which table is being suggested
*/
keptLayerIds: string[];
}
/**

View file

@ -100,7 +100,9 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
] as TableSuggestion[]).map(table => expect(getSuggestions({ table })).toEqual([]))
] as TableSuggestion[]).map(table =>
expect(getSuggestions({ table, keptLayerIds: [] })).toEqual([])
)
);
});
@ -113,6 +115,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -144,6 +147,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(suggestions).toHaveLength(0);
@ -157,6 +161,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -184,6 +189,7 @@ describe('xy_suggestions', () => {
changeType: 'unchanged',
label: 'Datasource title',
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -211,6 +217,7 @@ describe('xy_suggestions', () => {
},
],
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -225,6 +232,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'reduced',
},
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -254,6 +262,7 @@ describe('xy_suggestions', () => {
changeType: 'unchanged',
},
state: currentState,
keptLayerIds: ['first'],
});
expect(suggestions).toHaveLength(1);
@ -288,6 +297,7 @@ describe('xy_suggestions', () => {
changeType: 'unchanged',
},
state: currentState,
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -328,6 +338,7 @@ describe('xy_suggestions', () => {
changeType: 'unchanged',
},
state: currentState,
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -358,6 +369,7 @@ describe('xy_suggestions', () => {
changeType: 'unchanged',
},
state: currentState,
keptLayerIds: [],
});
const suggestion = suggestions[suggestions.length - 1];
@ -392,6 +404,7 @@ describe('xy_suggestions', () => {
changeType: 'extended',
},
state: currentState,
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -430,6 +443,7 @@ describe('xy_suggestions', () => {
changeType: 'extended',
},
state: currentState,
keptLayerIds: [],
});
expect(rest).toHaveLength(0);
@ -454,6 +468,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
@ -490,6 +505,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`
@ -525,6 +541,7 @@ describe('xy_suggestions', () => {
layerId: 'first',
changeType: 'unchanged',
},
keptLayerIds: [],
});
expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(`

View file

@ -35,6 +35,7 @@ const columnSortOrder = {
export function getSuggestions({
table,
state,
keptLayerIds,
}: SuggestionRequest<State>): Array<VisualizationSuggestion<State>> {
if (
// We only render line charts for multi-row queries. We require at least
@ -48,7 +49,7 @@ export function getSuggestions({
return [];
}
const suggestions = getSuggestionForColumns(table, state);
const suggestions = getSuggestionForColumns(table, keptLayerIds, state);
if (suggestions && suggestions instanceof Array) {
return suggestions;
@ -59,32 +60,35 @@ export function getSuggestions({
function getSuggestionForColumns(
table: TableSuggestion,
keptLayerIds: string[],
currentState?: State
): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> | undefined {
const [buckets, values] = partition(table.columns, col => col.operation.isBucketed);
if (buckets.length === 1 || buckets.length === 2) {
const [x, splitBy] = getBucketMappings(table, currentState);
return getSuggestionsForLayer(
table.layerId,
table.changeType,
x,
values,
return getSuggestionsForLayer({
layerId: table.layerId,
changeType: table.changeType,
xValue: x,
yValues: values,
splitBy,
currentState,
table.label
);
tableLabel: table.label,
keptLayerIds,
});
} else if (buckets.length === 0) {
const [x, ...yValues] = prioritizeColumns(values);
return getSuggestionsForLayer(
table.layerId,
table.changeType,
x,
return getSuggestionsForLayer({
layerId: table.layerId,
changeType: table.changeType,
xValue: x,
yValues,
undefined,
splitBy: undefined,
currentState,
table.label
);
tableLabel: table.label,
keptLayerIds,
});
}
}
@ -138,15 +142,25 @@ function prioritizeColumns(columns: TableSuggestionColumn[]) {
);
}
function getSuggestionsForLayer(
layerId: string,
changeType: TableChangeType,
xValue: TableSuggestionColumn,
yValues: TableSuggestionColumn[],
splitBy?: TableSuggestionColumn,
currentState?: State,
tableLabel?: string
): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> {
function getSuggestionsForLayer({
layerId,
changeType,
xValue,
yValues,
splitBy,
currentState,
tableLabel,
keptLayerIds,
}: {
layerId: string;
changeType: TableChangeType;
xValue: TableSuggestionColumn;
yValues: TableSuggestionColumn[];
splitBy?: TableSuggestionColumn;
currentState?: State;
tableLabel?: string;
keptLayerIds: string[];
}): VisualizationSuggestion<State> | Array<VisualizationSuggestion<State>> {
const title = getSuggestionTitle(yValues, xValue, tableLabel);
const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType);
@ -159,6 +173,7 @@ function getSuggestionsForLayer(
splitBy,
changeType,
xValue,
keptLayerIds,
};
const isSameState = currentState && changeType === 'unchanged';
@ -324,6 +339,7 @@ function buildSuggestion({
splitBy,
changeType,
xValue,
keptLayerIds,
}: {
currentState: XYState | undefined;
seriesType: SeriesType;
@ -333,6 +349,7 @@ function buildSuggestion({
splitBy: TableSuggestionColumn | undefined;
layerId: string;
changeType: TableChangeType;
keptLayerIds: string[];
}) {
const newLayer = {
...(getExistingLayer(currentState, layerId) || {}),
@ -343,13 +360,16 @@ function buildSuggestion({
accessors: yValues.map(col => col.columnId),
};
const keptLayers = currentState
? currentState.layers.filter(
layer => layer.layerId !== layerId && keptLayerIds.includes(layer.layerId)
)
: [];
const state: State = {
legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right },
preferredSeriesType: seriesType,
layers: [
...(currentState ? currentState.layers.filter(layer => layer.layerId !== layerId) : []),
newLayer,
],
layers: [...keptLayers, newLayer],
};
return {