/* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ import { DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; import { IndexPatternPrivateState } from './types'; import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, } from './indexpattern_suggestions'; import { documentField } from './document_field'; import { getFieldByNameFactory } from './pure_helpers'; jest.mock('./loader'); jest.mock('../id_generator'); const fieldsOne = [ { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, }, { name: 'start_date', displayName: 'start_date', type: 'date', aggregatable: true, searchable: true, }, { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }, { name: 'memory', displayName: 'memory', type: 'number', aggregatable: true, searchable: true, }, { name: 'source', displayName: 'source', type: 'string', aggregatable: true, searchable: true, }, { name: 'dest', displayName: 'dest', type: 'string', aggregatable: true, searchable: true, }, documentField, ]; const fieldsTwo = [ { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, aggregationRestrictions: { date_histogram: { agg: 'date_histogram', fixed_interval: '1d', delay: '7d', time_zone: 'UTC', }, }, }, { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, aggregationRestrictions: { // Ignored in the UI histogram: { agg: 'histogram', interval: 1000, }, avg: { agg: 'avg', }, max: { agg: 'max', }, min: { agg: 'min', }, sum: { agg: 'sum', }, }, }, { name: 'source', displayName: 'source', type: 'string', aggregatable: true, searchable: true, aggregationRestrictions: { terms: { agg: 'terms', }, }, }, documentField, ]; const expectedIndexPatterns = { 1: { id: '1', title: 'my-fake-index-pattern', timeFieldName: 'timestamp', hasRestrictions: false, fields: fieldsOne, getFieldByName: getFieldByNameFactory(fieldsOne), }, 2: { id: '2', title: 'my-fake-restricted-pattern', hasRestrictions: true, timeFieldName: 'timestamp', fields: fieldsTwo, getFieldByName: getFieldByNameFactory(fieldsTwo), }, }; function testInitialState(): IndexPatternPrivateState { return { currentIndexPatternId: '1', indexPatternRefs: [], existingFields: {}, indexPatterns: expectedIndexPatterns, layers: { first: { indexPatternId: '1', columnOrder: ['col1'], columns: { col1: { label: 'My Op', dataType: 'string', isBucketed: true, // Private operationType: 'terms', sourceField: 'dest', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', }, }, }, }, }, isFirstExistenceFetch: false, }; } describe('IndexPattern Data Source suggestions', () => { beforeEach(async () => { let count = 0; jest.resetAllMocks(); (generateId as jest.Mock).mockImplementation(() => `id${++count}`); }); describe('#getDatasourceSuggestionsForField', () => { describe('with no layer', () => { function stateWithoutLayer() { return { ...testInitialState(), layers: {}, }; } it('should apply a bucketed aggregation for a string field, using metric for sorting', () => { const suggestions = getDatasourceSuggestionsForField(stateWithoutLayer(), '1', { name: 'source', displayName: 'source', type: 'string', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { id1: expect.objectContaining({ columnOrder: ['id3', 'id2'], columns: { id3: expect.objectContaining({ operationType: 'terms', sourceField: 'source', params: expect.objectContaining({ size: 5, orderBy: { columnId: 'id2', type: 'column' }, }), }), id2: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id3', }), expect.objectContaining({ columnId: 'id2', }), ], layerId: 'id1', }, }) ); }); it('should apply a bucketed aggregation for a date field', () => { const suggestions = getDatasourceSuggestionsForField(stateWithoutLayer(), '1', { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { id1: expect.objectContaining({ columnOrder: ['id3', 'id2'], columns: { id3: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id2: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id3', }), expect.objectContaining({ columnId: 'id2', }), ], layerId: 'id1', }, }) ); }); it('should select a metric for a number field', () => { const suggestions = getDatasourceSuggestionsForField(stateWithoutLayer(), '1', { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { id1: expect.objectContaining({ columnOrder: ['id3', 'id2'], columns: { id3: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id2: expect.objectContaining({ operationType: 'avg', sourceField: 'bytes', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id3', }), expect.objectContaining({ columnId: 'id2', }), ], layerId: 'id1', }, }) ); }); it('should make a metric suggestion for a number field if there is no time field', async () => { const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', title: 'no timefield', hasRestrictions: false, fields: [ { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }, ], getFieldByName: getFieldByNameFactory([ { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }, ]), }, }, layers: { first: { indexPatternId: '1', columnOrder: [], columns: {}, }, }, }; const suggestions = getDatasourceSuggestionsForField(state, '1', { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { first: expect.objectContaining({ columnOrder: ['id1'], columns: { id1: expect.objectContaining({ operationType: 'avg', sourceField: 'bytes', }), }, }), }, }), }) ); }); }); describe('with a previous empty layer', () => { function stateWithEmptyLayer() { const state = testInitialState(); return { ...state, layers: { previousLayer: { indexPatternId: '1', columns: {}, columnOrder: [], }, }, }; } it('should apply a bucketed aggregation for a string field, using metric for sorting', () => { const suggestions = getDatasourceSuggestionsForField(stateWithEmptyLayer(), '1', { name: 'source', displayName: 'source', type: 'string', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: expect.objectContaining({ columnOrder: ['id2', 'id1'], columns: { id2: expect.objectContaining({ operationType: 'terms', sourceField: 'source', params: expect.objectContaining({ size: 5, orderBy: { columnId: 'id1', type: 'column' }, }), }), id1: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id2', }), expect.objectContaining({ columnId: 'id1', }), ], layerId: 'previousLayer', }, }) ); }); it('should apply a bucketed aggregation for a date field', () => { const suggestions = getDatasourceSuggestionsForField(stateWithEmptyLayer(), '1', { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: expect.objectContaining({ columnOrder: ['id2', 'id1'], columns: { id2: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id1: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id2', }), expect.objectContaining({ columnId: 'id1', }), ], layerId: 'previousLayer', }, }) ); }); it('should select a metric for a number field', () => { const suggestions = getDatasourceSuggestionsForField(stateWithEmptyLayer(), '1', { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: expect.objectContaining({ columnOrder: ['id2', 'id1'], columns: { id2: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id1: expect.objectContaining({ operationType: 'avg', sourceField: 'bytes', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id2', }), expect.objectContaining({ columnId: 'id1', }), ], layerId: 'previousLayer', }, }) ); }); it('should make a metric suggestion for a number field if there is no time field', async () => { const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', title: 'no timefield', hasRestrictions: false, fields: [ { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }, ], getFieldByName: getFieldByNameFactory([ { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }, ]), }, }, layers: { previousLayer: { indexPatternId: '1', columnOrder: [], columns: {}, }, }, }; const suggestions = getDatasourceSuggestionsForField(state, '1', { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: expect.objectContaining({ columnOrder: ['id1'], columns: { id1: expect.objectContaining({ operationType: 'avg', sourceField: 'bytes', }), }, }), }, }), }) ); }); it('creates a new layer and replaces layer if no match is found', () => { const suggestions = getDatasourceSuggestionsForField(stateWithEmptyLayer(), '2', { name: 'source', displayName: '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', () => { function stateWithNonEmptyTables(): IndexPatternPrivateState { const state = testInitialState(); return { ...state, currentIndexPatternId: '1', layers: { previousLayer: { indexPatternId: '2', columns: {}, columnOrder: [], }, currentLayer: { indexPatternId: '1', columns: { cola: { dataType: 'string', isBucketed: true, sourceField: 'source', label: 'values of source', operationType: 'terms', params: { orderBy: { type: 'column', columnId: 'colb' }, orderDirection: 'asc', size: 5, }, }, colb: { dataType: 'number', isBucketed: false, sourceField: 'bytes', label: 'Avg of bytes', operationType: 'avg', }, }, columnOrder: ['cola', 'colb'], }, }, }; } it('replaces an existing date histogram column on date field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField( { ...initialState, layers: { previousLayer: initialState.layers.previousLayer, currentLayer: { ...initialState.layers.currentLayer, columns: { cola: { dataType: 'date', isBucketed: true, sourceField: 'timestamp', label: 'date histogram of timestamp', operationType: 'date_histogram', params: { interval: 'w', }, }, colb: { dataType: 'number', isBucketed: false, sourceField: 'bytes', label: 'Avg of bytes', operationType: 'avg', }, }, }, }, }, '1', { name: 'start_date', displayName: 'start_date', type: 'date', aggregatable: true, searchable: true, } ); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ columnOrder: ['cola', 'colb'], columns: { cola: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'start_date', }), colb: initialState.layers.currentLayer.columns.colb, }, }), }, }), }) ); }); it('puts a date histogram column after the last bucket column on date field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ columnOrder: ['cola', 'id1', 'colb'], columns: { ...initialState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), }, }), }, }), table: { changeType: 'extended', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'cola', }), expect.objectContaining({ columnId: 'id1', }), expect.objectContaining({ columnId: 'colb', }), ], layerId: 'currentLayer', }, }) ); }); it('does not use the same field for bucketing multiple times', () => { const suggestions = getDatasourceSuggestionsForField(stateWithNonEmptyTables(), '1', { name: 'source', displayName: 'source', type: 'string', aggregatable: true, searchable: true, }); expect(suggestions).toHaveLength(1); // Check that the suggestion is a single metric expect(suggestions[0].table.columns).toHaveLength(1); expect(suggestions[0].table.columns[0].operation.isBucketed).toBeFalsy(); }); it('appends a terms column with default size on string field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'dest', displayName: 'dest', type: 'string', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ columnOrder: ['cola', 'id1', 'colb'], columns: { ...initialState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'terms', sourceField: 'dest', params: expect.objectContaining({ size: 3 }), }), }, }), }, }), }) ); }); it('suggests both replacing and adding metric if only one other metric is set', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'memory', displayName: 'memory', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: expect.objectContaining({ currentLayer: expect.objectContaining({ columnOrder: ['cola', 'colb'], columns: { cola: initialState.layers.currentLayer.columns.cola, colb: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', }), }, }), }), }), }) ); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: expect.objectContaining({ currentLayer: expect.objectContaining({ columnOrder: ['cola', 'colb', 'id1'], columns: { cola: initialState.layers.currentLayer.columns.cola, colb: initialState.layers.currentLayer.columns.colb, id1: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', }), }, }), }), }), }) ); }); it('adds a metric column on a number field if no other metrics set', () => { const initialState = stateWithNonEmptyTables(); const modifiedState: IndexPatternPrivateState = { ...initialState, layers: { ...initialState.layers, currentLayer: { ...initialState.layers.currentLayer, columns: { cola: initialState.layers.currentLayer.columns.cola, }, columnOrder: ['cola'], }, }, }; const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { name: 'memory', displayName: 'memory', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ columnOrder: ['cola', 'id1'], columns: { ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', }), }, }), }, }), }) ); }); it('skips duplicates when the field is already in use', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'bytes', displayName: 'bytes', type: 'number', aggregatable: true, searchable: true, }); expect(suggestions).not.toContain(expect.objectContaining({ changeType: 'extended' })); }); it('skips duplicates when the document-specific field is already in use', () => { const initialState = stateWithNonEmptyTables(); const modifiedState: IndexPatternPrivateState = { ...initialState, layers: { ...initialState.layers, currentLayer: { ...initialState.layers.currentLayer, columns: { ...initialState.layers.currentLayer.columns, colb: { label: 'Count of records', dataType: 'document', isBucketed: false, operationType: 'count', sourceField: 'Records', }, }, }, }, }; const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', documentField); expect(suggestions).not.toContain(expect.objectContaining({ changeType: 'extended' })); }); }); describe('finding the layer that is using the current index pattern', () => { function stateWithCurrentIndexPattern(): IndexPatternPrivateState { const state = testInitialState(); return { ...state, currentIndexPatternId: '1', layers: { previousLayer: { indexPatternId: '1', columns: {}, columnOrder: [], }, currentLayer: { indexPatternId: '2', columns: {}, columnOrder: [], }, }, }; } it('suggests on the layer that matches by indexPatternId', () => { const initialState = stateWithCurrentIndexPattern(); const suggestions = getDatasourceSuggestionsForField(initialState, '2', { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, aggregationRestrictions: { date_histogram: { agg: 'date_histogram', fixed_interval: '1d', delay: '7d', time_zone: 'UTC', }, }, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ columnOrder: ['id2', 'id1'], columns: { id2: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id1: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id2', }), expect.objectContaining({ columnId: 'id1', }), ], layerId: 'currentLayer', }, }) ); }); it('suggests on the layer with the fewest columns that matches by indexPatternId', () => { const initialState = stateWithCurrentIndexPattern(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'timestamp', displayName: 'timestampLabel', type: 'date', aggregatable: true, searchable: true, }); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { currentLayer: initialState.layers.currentLayer, previousLayer: expect.objectContaining({ columnOrder: ['id2', 'id1'], columns: { id2: expect.objectContaining({ operationType: 'date_histogram', sourceField: 'timestamp', }), id1: expect.objectContaining({ operationType: 'count', }), }, }), }, }), }) ); }); }); }); describe('#getDatasourceSuggestionsForVisualizeField', () => { describe('with no layer', () => { function stateWithoutLayer() { return { ...testInitialState(), layers: {}, }; } it('should return an empty array if the field does not exist', () => { const suggestions = getDatasourceSuggestionsForVisualizeField( stateWithoutLayer(), '1', 'field_not_exist' ); expect(suggestions).toEqual([]); }); it('should apply a bucketed aggregation for a string field', () => { const suggestions = getDatasourceSuggestionsForVisualizeField( stateWithoutLayer(), '1', 'source' ); expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { id1: expect.objectContaining({ columnOrder: ['id3', 'id2'], columns: { id3: expect.objectContaining({ operationType: 'terms', sourceField: 'source', params: expect.objectContaining({ size: 5 }), }), id2: expect.objectContaining({ operationType: 'count', }), }, }), }, }), table: { changeType: 'initial', label: undefined, isMultiRow: true, columns: [ expect.objectContaining({ columnId: 'id3', }), expect.objectContaining({ columnId: 'id2', }), ], layerId: 'id1', }, }) ); }); }); }); describe('#getDatasourceSuggestionsFromCurrentState', () => { it('returns no suggestions if there are no columns', () => { expect( getDatasourceSuggestionsFromCurrentState({ isFirstExistenceFetch: false, indexPatternRefs: [], existingFields: {}, indexPatterns: expectedIndexPatterns, layers: { first: { indexPatternId: '1', columnOrder: [], columns: {}, }, }, currentIndexPatternId: '1', }) ).toEqual([]); }); it('returns a single suggestion containing the current columns for each layer', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { ...initialState.layers, second: { ...initialState.layers.first, columnOrder: ['cola'], columns: { cola: { label: 'My Op 2', dataType: 'string', isBucketed: true, // Private operationType: 'terms', sourceField: 'dest', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', }, }, }, }, }, }; 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: 'layers', label: 'Show only layer 1', columns: [ { columnId: 'col1', operation: { label: 'My Op', dataType: 'string', isBucketed: true, scale: undefined, }, }, ], layerId: 'first', }, }) ); expect(result).toContainEqual( expect.objectContaining({ table: { isMultiRow: true, changeType: 'layers', label: 'Show only layer 2', columns: [ { columnId: 'cola', operation: { label: 'My Op 2', dataType: 'string', isBucketed: true, scale: undefined, }, }, ], layerId: 'second', }, }) ); }); it('returns a metric over time for single metric tables', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { first: { indexPatternId: '1', columnOrder: ['cola'], columns: { cola: { label: 'My Op', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'bytes', scale: 'ratio', }, }, }, }, }; expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual( expect.objectContaining({ table: { isMultiRow: true, changeType: 'extended', label: 'Over time', columns: [ { columnId: 'id1', operation: { label: 'timestampLabel', dataType: 'date', isBucketed: true, scale: 'interval', }, }, { columnId: 'cola', operation: { label: 'My Op', dataType: 'number', isBucketed: false, scale: 'ratio', }, }, ], layerId: 'first', }, }) ); }); it('adds date histogram over default time field for tables without time dimension', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { first: { indexPatternId: '1', columnOrder: ['cola', 'colb'], columns: { cola: { label: 'My Terms', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'source', scale: 'ordinal', params: { orderBy: { type: 'alphabetical' }, orderDirection: 'asc', size: 5, }, }, colb: { label: 'My Op', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'bytes', scale: 'ratio', }, }, }, }, }; expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual( expect.objectContaining({ table: { isMultiRow: true, changeType: 'extended', label: 'Over time', columns: [ { columnId: 'cola', operation: { label: 'My Terms', dataType: 'string', isBucketed: true, scale: 'ordinal', }, }, { columnId: 'id1', operation: { label: 'timestampLabel', dataType: 'date', isBucketed: true, scale: 'interval', }, }, { columnId: 'colb', operation: { label: 'My Op', dataType: 'number', isBucketed: false, scale: 'ratio', }, }, ], layerId: 'first', }, }) ); }); it('does not create an over time suggestion if tables with numeric buckets with time dimension', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { first: { indexPatternId: '1', columnOrder: ['colb', 'cola'], columns: { cola: { dataType: 'number', isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', operationType: 'cardinality', }, colb: { label: 'My Op', dataType: 'number', isBucketed: true, operationType: 'range', sourceField: 'bytes', scale: 'interval', params: { type: 'histogram', maxBars: 100, ranges: [], }, }, }, }, }, }; expect(getDatasourceSuggestionsFromCurrentState(state)).not.toContainEqual( expect.objectContaining({ table: { isMultiRow: true, label: 'Over time', layerId: 'first', }, }) ); }); it('adds date histogram over default time field for custom range intervals', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { first: { indexPatternId: '1', columnOrder: ['colb', 'cola'], columns: { cola: { dataType: 'number', isBucketed: false, sourceField: 'dest', label: 'Unique count of dest', operationType: 'cardinality', }, colb: { label: 'My Custom Range', dataType: 'string', isBucketed: true, operationType: 'range', sourceField: 'bytes', scale: 'ordinal', params: { type: 'range', maxBars: 100, ranges: [{ from: 1, to: 2, label: '' }], }, }, }, }, }, }; expect(getDatasourceSuggestionsFromCurrentState(state)).toContainEqual( expect.objectContaining({ table: { changeType: 'extended', columns: [ { columnId: 'colb', operation: { dataType: 'string', isBucketed: true, label: 'My Custom Range', scale: 'ordinal', }, }, { columnId: 'id1', operation: { dataType: 'date', isBucketed: true, label: 'timestampLabel', scale: 'interval', }, }, { columnId: 'cola', operation: { dataType: 'number', isBucketed: false, label: 'Unique count of dest', scale: undefined, }, }, ], isMultiRow: true, label: 'Over time', layerId: 'first', }, }) ); }); it('does not create an over time suggestion if there is no default time field', async () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { ...initialState, layers: { first: { indexPatternId: '1', columnOrder: ['id1'], columns: { id1: { label: 'My Op', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'bytes', scale: 'ratio', }, }, }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState({ ...state, indexPatterns: { 1: { ...state.indexPatterns['1'], timeFieldName: undefined } }, }); suggestions.forEach((suggestion) => expect(suggestion.table.columns.length).toBe(1)); }); it('returns simplified versions of table with more than 2 columns', () => { const initialState = testInitialState(); const fields = [ { name: 'field1', displayName: 'field1', type: 'string', aggregatable: true, searchable: true, }, { name: 'field2', displayName: 'field2', type: 'string', aggregatable: true, searchable: true, }, { name: 'field3', displayName: 'field3Label', type: 'string', aggregatable: true, searchable: true, }, { name: 'field4', displayName: 'field4', type: 'number', aggregatable: true, searchable: true, }, { name: 'field5', displayName: 'field5', type: 'number', aggregatable: true, searchable: true, }, ]; const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', indexPatterns: { 1: { id: '1', title: 'my-fake-index-pattern', hasRestrictions: false, fields, getFieldByName: getFieldByNameFactory(fields), }, }, isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, columns: { col1: { label: 'My Op', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'field1', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', }, }, col2: { label: 'My Op', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'field2', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', }, }, col3: { label: 'My Op', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'field3', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc', }, }, col4: { label: 'My Op', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'field4', }, col5: { label: 'My Op', dataType: 'number', isBucketed: false, operationType: 'min', sourceField: 'field5', }, }, columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5'], }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState(state); // 1 bucket col, 2 metric cols isTableWithBucketColumns(suggestions[0], ['col1', 'col4', 'col5'], 1); // 1 bucket col, 1 metric col isTableWithBucketColumns(suggestions[1], ['col1', 'col4'], 1); // 2 bucket cols, 2 metric cols isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2); // 2 bucket cols, 1 metric col isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col4'], 2); // 3 bucket cols, 2 metric cols isTableWithBucketColumns(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3); // 3 bucket cols, 1 metric col isTableWithBucketColumns(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3); // first metric col isTableWithMetricColumns(suggestions[6], ['col4']); // second metric col isTableWithMetricColumns(suggestions[7], ['col5']); expect(suggestions.length).toBe(8); }); it('returns an only metric version of a given table', () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', indexPatterns: { 1: { id: '1', title: 'my-fake-index-pattern', hasRestrictions: false, fields: [ { name: 'field1', displayName: 'field1', type: 'number', aggregatable: true, searchable: true, }, { name: 'field2', displayName: 'field2', type: 'date', aggregatable: true, searchable: true, }, ], getFieldByName: getFieldByNameFactory([ { name: 'field1', displayName: 'field1', type: 'number', aggregatable: true, searchable: true, }, { name: 'field2', displayName: 'field2', type: 'date', aggregatable: true, searchable: true, }, ]), }, }, isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, columns: { id1: { label: 'Date histogram', dataType: 'date', isBucketed: true, operationType: 'date_histogram', sourceField: 'field2', params: { interval: 'd', }, }, id2: { label: 'Average of field1', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'field1', }, }, columnOrder: ['id1', 'id2'], }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState(state); expect(suggestions[1].table.columns[0].operation.label).toBe('Average of field1'); }); it('returns an alternative metric for an only-metric table', () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', indexPatterns: { 1: { id: '1', title: 'my-fake-index-pattern', hasRestrictions: false, fields: [ { name: 'field1', displayName: 'field1', type: 'number', aggregatable: true, searchable: true, }, ], getFieldByName: getFieldByNameFactory([ { name: 'field1', displayName: 'field1', type: 'number', aggregatable: true, searchable: true, }, ]), }, }, isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, columns: { id1: { label: 'Average of field1', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'field1', }, }, columnOrder: ['id1'], }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState(state); expect(suggestions[0].table.columns.length).toBe(1); expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1'); }); it('contains a reordering suggestion when there are exactly 2 buckets', () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, columns: { id1: { label: 'Date histogram', dataType: 'date', isBucketed: true, operationType: 'date_histogram', sourceField: 'timestamp', params: { interval: 'd', }, }, id2: { label: 'Top 5', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'dest', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' }, }, id3: { label: 'Average of field1', dataType: 'number', isBucketed: false, operationType: 'avg', sourceField: 'bytes', }, }, columnOrder: ['id1', 'id2', 'id3'], }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState(state); expect(suggestions).toContainEqual( expect.objectContaining({ table: expect.objectContaining({ changeType: 'reorder', }), }) ); }); it('does not generate suggestions if invalid fields are referenced', () => { const initialState = testInitialState(); const state: IndexPatternPrivateState = { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, columns: { ...initialState.layers.first.columns, col2: { label: 'Top 5', dataType: 'string', isBucketed: true, operationType: 'terms', sourceField: 'nonExistingField', params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' }, }, }, columnOrder: ['col1', 'col2'], }, }, }; const suggestions = getDatasourceSuggestionsFromCurrentState(state); expect(suggestions).toEqual([]); }); }); }); function isTableWithBucketColumns( suggestion: DatasourceSuggestion, columnIds: string[], numBuckets: number ) { expect(suggestion.table.columns.map((column) => column.columnId)).toEqual(columnIds); expect( suggestion.table.columns.slice(0, numBuckets).every((column) => column.operation.isBucketed) ).toBeTruthy(); } function isTableWithMetricColumns( suggestion: DatasourceSuggestion, columnIds: string[] ) { expect(suggestion.table.isMultiRow).toEqual(false); expect(suggestion.table.columns.map((column) => column.columnId)).toEqual(columnIds); expect(suggestion.table.columns.every((column) => !column.operation.isBucketed)).toBeTruthy(); }