[Lens] Enables text-based languages (SQL) (#140469)

* [Lens] Enable text-based languages-sql

* Display data

* Chart switcher and further fixes

* Drag and drop fixes

* Small fix

* Multiple improvements

* Errors implementation and save and exit

* Some cleanup

* Fix types failures

* Revert change

* Fix underlying data error

* Fix jest test

* Fixes app test

* Rename datasource to textBased

* display the dataview picker component but disabled

* Fix functional test

* Refactoring

* Populate the dataview to theembeddable

* Load sync

* sync load of the new dtsource

* Fix

* Fix bug when the dtaview is not found

* Refactoring

* Add some unit tests

* Add some unit tests

* Add more unit tests

* Add a functional test

* Add all files

* Update lens limit

* Fixes bug

* Bump lens size

* Fix flakiness

* Further fixes

* Fix check

* More fixes

* Fix test

* Wait for query to run

* More changes

* Fix

* Fix the function input to fetch from variable

* Remove the colorFullBackground check

* Fix dashboard bug when timeRange changes

* Remove the clear on query update, show column error instead

* Render a field missing label in case the label is not defined

* Fix

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Stratoula Kalafateli 2022-09-27 14:32:52 +03:00 committed by GitHub
parent 3edba25c2d
commit 4c3cd034aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 3321 additions and 276 deletions

View file

@ -21,7 +21,8 @@ export {
getTime,
isQuery,
isTimeRange,
queryStateToExpressionAst,
textBasedQueryStateToAstWithValidation,
textBasedQueryStateToExpressionAst,
} from './query';
export type { QueryState } from './query';
export * from './search';

View file

@ -10,4 +10,5 @@ export * from './timefilter';
export * from './types';
export * from './is_query';
export * from './query_state';
export { queryStateToExpressionAst } from './to_expression_ast';
export { textBasedQueryStateToAstWithValidation } from './text_based_query_state_to_ast_with_validation';
export { textBasedQueryStateToExpressionAst } from './text_based_query_state_to_ast';

View file

@ -5,20 +5,17 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { queryStateToExpressionAst } from './to_expression_ast';
import { textBasedQueryStateToExpressionAst } from './text_based_query_state_to_ast';
describe('queryStateToExpressionAst', () => {
describe('textBasedQueryStateToExpressionAst', () => {
it('returns an object with the correct structure', async () => {
const dataViewsService = {} as unknown as DataViewsContract;
const actual = await queryStateToExpressionAst({
const actual = await textBasedQueryStateToExpressionAst({
filters: [],
query: { language: 'lucene', query: '' },
time: {
from: 'now',
to: 'now+7d',
},
dataViewsService,
});
expect(actual).toHaveProperty(
@ -33,31 +30,13 @@ describe('queryStateToExpressionAst', () => {
});
it('returns an object with the correct structure for an SQL query', async () => {
const dataViewsService = {
getIdsWithTitle: jest.fn(() => {
return [
{
title: 'foo',
id: 'bar',
},
];
}),
get: jest.fn(() => {
return {
title: 'foo',
id: 'bar',
timeFieldName: 'baz',
};
}),
} as unknown as DataViewsContract;
const actual = await queryStateToExpressionAst({
const actual = await textBasedQueryStateToExpressionAst({
filters: [],
query: { sql: 'SELECT * FROM foo' },
time: {
from: 'now',
to: 'now+7d',
},
dataViewsService,
});
expect(actual).toHaveProperty(

View file

@ -5,14 +5,8 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
isOfAggregateQueryType,
getAggregateQueryMode,
getIndexPatternFromSQLQuery,
Query,
} from '@kbn/es-query';
import { isOfAggregateQueryType, getAggregateQueryMode, Query } from '@kbn/es-query';
import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/common';
import type { DataViewsContract } from '@kbn/data-views-plugin/common';
import {
ExpressionFunctionKibana,
ExpressionFunctionKibanaContext,
@ -24,7 +18,7 @@ import {
} from '..';
interface Args extends QueryState {
dataViewsService: DataViewsContract;
timeFieldName?: string;
inputQuery?: Query;
}
@ -34,12 +28,12 @@ interface Args extends QueryState {
* @param query kibana query or aggregate query
* @param time kibana time range
*/
export async function queryStateToExpressionAst({
export function textBasedQueryStateToExpressionAst({
filters,
query,
inputQuery,
time,
dataViewsService,
timeFieldName,
}: Args) {
const kibana = buildExpressionFunction<ExpressionFunctionKibana>('kibana', {});
let q;
@ -52,24 +46,15 @@ export async function queryStateToExpressionAst({
filters: filters && filtersToAst(filters),
});
const ast = buildExpression([kibana, kibanaContext]).toAst();
if (query && isOfAggregateQueryType(query)) {
const mode = getAggregateQueryMode(query);
// sql query
if (mode === 'sql' && 'sql' in query) {
const idxPattern = getIndexPatternFromSQLQuery(query.sql);
const idsTitles = await dataViewsService.getIdsWithTitle();
const dataViewIdTitle = idsTitles.find(({ title }) => title === idxPattern);
const essql = aggregateQueryToAst(query, timeFieldName);
if (dataViewIdTitle) {
const dataView = await dataViewsService.get(dataViewIdTitle.id);
const timeFieldName = dataView.timeFieldName;
const essql = aggregateQueryToAst(query, timeFieldName);
if (essql) {
ast.chain.push(essql);
}
} else {
throw new Error(`No data view found for index pattern ${idxPattern}`);
if (essql) {
ast.chain.push(essql);
}
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { textBasedQueryStateToAstWithValidation } from './text_based_query_state_to_ast_with_validation';
describe('textBasedQueryStateToAstWithValidation', () => {
it('returns undefined for a non text based query', async () => {
const dataViewsService = {} as unknown as DataViewsContract;
const actual = await textBasedQueryStateToAstWithValidation({
filters: [],
query: { language: 'lucene', query: '' },
time: {
from: 'now',
to: 'now+7d',
},
dataViewsService,
});
expect(actual).toBeUndefined();
});
it('returns an object with the correct structure for an SQL query with existing dataview', async () => {
const dataViewsService = {
getIdsWithTitle: jest.fn(() => {
return [
{
title: 'foo',
id: 'bar',
},
];
}),
get: jest.fn(() => {
return {
title: 'foo',
id: 'bar',
timeFieldName: 'baz',
};
}),
} as unknown as DataViewsContract;
const actual = await textBasedQueryStateToAstWithValidation({
filters: [],
query: { sql: 'SELECT * FROM foo' },
time: {
from: 'now',
to: 'now+7d',
},
dataViewsService,
});
expect(actual).toHaveProperty(
'chain.1.arguments.timeRange.0.chain.0.arguments',
expect.objectContaining({
from: ['now'],
to: ['now+7d'],
})
);
expect(actual).toHaveProperty(
'chain.2.arguments',
expect.objectContaining({
query: ['SELECT * FROM foo'],
})
);
});
it('returns an error for text based language with non existing dataview', async () => {
const dataViewsService = {
getIdsWithTitle: jest.fn(() => {
return [
{
title: 'foo',
id: 'bar',
},
];
}),
get: jest.fn(() => {
return {
title: 'foo',
id: 'bar',
timeFieldName: 'baz',
};
}),
} as unknown as DataViewsContract;
await expect(
textBasedQueryStateToAstWithValidation({
filters: [],
query: { sql: 'SELECT * FROM another_dataview' },
time: {
from: 'now',
to: 'now+7d',
},
dataViewsService,
})
).rejects.toThrow('No data view found for index pattern another_dataview');
});
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
isOfAggregateQueryType,
getIndexPatternFromSQLQuery,
Query,
AggregateQuery,
} from '@kbn/es-query';
import type { DataViewsContract } from '@kbn/data-views-plugin/common';
import type { QueryState } from '..';
import { textBasedQueryStateToExpressionAst } from './text_based_query_state_to_ast';
interface Args extends QueryState {
dataViewsService: DataViewsContract;
inputQuery?: Query;
}
const getIndexPatternFromAggregateQuery = (query: AggregateQuery) => {
if ('sql' in query) {
return getIndexPatternFromSQLQuery(query.sql);
}
};
/**
* Converts QueryState to expression AST
* @param filters array of kibana filters
* @param query kibana query or aggregate query
* @param time kibana time range
*/
export async function textBasedQueryStateToAstWithValidation({
filters,
query,
inputQuery,
time,
dataViewsService,
}: Args) {
let ast;
if (query && isOfAggregateQueryType(query)) {
// sql query
const idxPattern = getIndexPatternFromAggregateQuery(query);
const idsTitles = await dataViewsService.getIdsWithTitle();
const dataViewIdTitle = idsTitles.find(({ title }) => title === idxPattern);
if (dataViewIdTitle) {
const dataView = await dataViewsService.get(dataViewIdTitle.id);
const timeFieldName = dataView.timeFieldName;
ast = textBasedQueryStateToExpressionAst({
filters,
query,
inputQuery,
time,
timeFieldName,
});
} else {
throw new Error(`No data view found for index pattern ${idxPattern}`);
}
}
return ast;
}

View file

@ -12,7 +12,7 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/public';
import type { DataViewsContract } from '@kbn/data-views-plugin/common';
import { queryStateToExpressionAst } from '@kbn/data-plugin/common';
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
import { DataTableRecord } from '../../../types';
interface SQLErrorResponse {
@ -31,7 +31,7 @@ export function fetchSql(
inputQuery?: Query
) {
const timeRange = data.query.timefilter.timefilter.getTime();
return queryStateToExpressionAst({
return textBasedQueryStateToAstWithValidation({
filters,
query,
time: timeRange,

View file

@ -329,7 +329,7 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record<string, U
value: false,
description: i18n.translate('discover.advancedSettings.enableSQLDescription', {
defaultMessage:
'{technicalPreviewLabel} This tech preview feature is highly experimental--do not rely on this for production saved searches or dashboards. This setting enables SQL as a text-based query language in Discover. If you have feedback on this experience please reach out to us on {link}',
'{technicalPreviewLabel} This tech preview feature is highly experimental--do not rely on this for production saved searches, visualizations or dashboards. This setting enables SQL as a text-based query language in Discover and Lens. If you have feedback on this experience please reach out to us on {link}',
values: {
link:
`<a href="https://discuss.elastic.co/c/elastic-stack/kibana" target="_blank" rel="noopener">` +

View file

@ -105,7 +105,7 @@ describe('DataView component', () => {
expect(addFieldSpy).toHaveBeenCalled();
});
it('should not render the add datavuew menu if onDataViewCreated is not given', async () => {
it('should not render the add dataview menu if onDataViewCreated is not given', async () => {
await act(async () => {
const component = mount(wrapDataViewComponentInContext(props, true));
findTestSubject(component, 'dataview-trigger').simulate('click');
@ -113,7 +113,7 @@ describe('DataView component', () => {
});
});
it('should render the add datavuew menu if onDataViewCreated is given', async () => {
it('should render the add dataview menu if onDataViewCreated is given', async () => {
const addDataViewSpy = jest.fn();
const component = mount(
wrapDataViewComponentInContext({ ...props, onDataViewCreated: addDataViewSpy }, false)
@ -141,4 +141,21 @@ describe('DataView component', () => {
const text = component.find('[data-test-subj="select-text-based-language-panel"]');
expect(text.length).not.toBe(0);
});
it('should cleanup the query is on text based mode and add new dataview', async () => {
const component = mount(
wrapDataViewComponentInContext(
{
...props,
onDataViewCreated: jest.fn(),
textBasedLanguages: [TextBasedLanguages.ESQL, TextBasedLanguages.SQL],
textBasedLanguage: TextBasedLanguages.SQL,
},
false
)
);
findTestSubject(component, 'dataview-trigger').simulate('click');
component.find('[data-test-subj="dataview-create-new"]').first().simulate('click');
expect(props.onTextLangQuerySubmit).toHaveBeenCalled();
});
});

View file

@ -235,6 +235,16 @@ export function ChangeDataView({
onClick={() => {
setPopoverIsOpen(false);
onDataViewCreated();
// go to dataview mode
if (isTextBasedLangSelected) {
setIsTextBasedLangSelected(false);
// clean up the Text based language query
onTextLangQuerySubmit?.({
language: 'kuery',
query: '',
});
setTriggerLabel(trigger.label);
}
}}
size="xs"
iconType="plusInCircleFilled"
@ -262,7 +272,7 @@ export function ChangeDataView({
setIsTextBasedLangSelected(false);
// clean up the Text based language query
onTextLangQuerySubmit?.({
language: 'kql',
language: 'kuery',
query: '',
});
onChangeDataView(newId);
@ -335,7 +345,7 @@ export function ChangeDataView({
setIsTextBasedLangSelected(false);
// clean up the Text based language query
onTextLangQuerySubmit?.({
language: 'kql',
language: 'kuery',
query: '',
});
if (selectedDataViewId) {

View file

@ -86,6 +86,7 @@ export default function TextBasedLanguagesTransitionModal({
onClick={() => closeModal(dismissModalChecked)}
color="warning"
iconType="merge"
data-test-subj="unifiedSearch_switch_noSave"
>
{i18n.translate(
'unifiedSearch.query.queryBar.indexPattern.textBasedLanguagesTransitionModalCloseButton',
@ -101,6 +102,7 @@ export default function TextBasedLanguagesTransitionModal({
fill
color="success"
iconType="save"
data-test-subj="unifiedSearch_switch_andSave"
>
{i18n.translate(
'unifiedSearch.query.queryBar.indexPattern.textBasedLanguagesTransitionModalSaveButton',

View file

@ -353,7 +353,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
() => {
if (this.props.onQuerySubmit) {
this.props.onQuerySubmit({
query: this.state.query,
query: query as QT,
dateRange: {
from: this.state.dateRangeFrom,
to: this.state.dateRangeTo,

View file

@ -13,13 +13,21 @@ export class UnifiedSearchPageObject extends FtrService {
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly find = this.ctx.getService('find');
public async switchDataView(switchButtonSelector: string, dataViewTitle: string) {
public async switchDataView(
switchButtonSelector: string,
dataViewTitle: string,
transitionFromTextBasedLanguages?: boolean
) {
await this.testSubjects.click(switchButtonSelector);
const indexPatternSwitcher = await this.testSubjects.find('indexPattern-switcher', 500);
await this.testSubjects.setValue('indexPattern-switcher--input', dataViewTitle);
await (await indexPatternSwitcher.findByCssSelector(`[title="${dataViewTitle}"]`)).click();
if (Boolean(transitionFromTextBasedLanguages)) {
await this.testSubjects.click('unifiedSearch_switch_noSave');
}
await this.retry.waitFor(
'wait for updating switcher',
async () => (await this.getSelectedDataView(switchButtonSelector)) === dataViewTitle
@ -66,4 +74,10 @@ export class UnifiedSearchPageObject extends FtrService {
});
await this.testSubjects.click(adHoc ? 'exploreIndexPatternButton' : 'saveIndexPatternButton');
}
public async selectTextBasedLanguage(language: string) {
await this.find.clickByCssSelector(
`[data-test-subj="text-based-languages-switcher"] [title="${language}"]`
);
}
}

View file

@ -16,6 +16,8 @@ export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations';
export const BASE_API_URL = '/api/lens';
export const LENS_EDIT_BY_VALUE = 'edit_by_value';
export const ENABLE_SQL = 'discover:enableSql';
export const PieChartTypes = {
PIE: 'pie',
DONUT: 'donut',

View file

@ -6,3 +6,4 @@
*/
export { mapToColumns } from './map_to_columns';
export type { OriginalColumn } from './types';

View file

@ -20,6 +20,8 @@ import {
PieChartTypes,
} from './constants';
export type { OriginalColumn } from './expressions/map_to_columns';
export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat;
export interface ExistingFields {

View file

@ -202,7 +202,7 @@ describe('Lens App', () => {
},
});
const navigationComponent = services.navigation.ui
.TopNavMenu as unknown as React.ReactElement;
.AggregateQueryTopNavMenu as unknown as React.ReactElement;
const extraEntry = instance.find(navigationComponent).prop('config')[0];
expect(extraEntry.label).toEqual('My entry');
expect(extraEntry.run).toBe(runFn);
@ -367,7 +367,7 @@ describe('Lens App', () => {
Promise.resolve({ id, isTimeBased: () => true, isPersisted: () => true } as DataView)
);
const { services } = await mountWith({ services: customServices });
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ showDatePicker: true }),
{}
);
@ -382,7 +382,7 @@ describe('Lens App', () => {
const customProps = makeDefaultProps();
customProps.datasourceMap.testDatasource.isTimeBased = () => true;
const { services } = await mountWith({ props: customProps, services: customServices });
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ showDatePicker: true }),
{}
);
@ -397,7 +397,7 @@ describe('Lens App', () => {
const customProps = makeDefaultProps();
customProps.datasourceMap.testDatasource.isTimeBased = () => false;
const { services } = await mountWith({ props: customProps, services: customServices });
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ showDatePicker: false }),
{}
);
@ -495,7 +495,7 @@ describe('Lens App', () => {
});
instance.update();
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: 'fake query',
indexPatterns: [
@ -518,7 +518,7 @@ describe('Lens App', () => {
.mockImplementation((id) => Promise.reject({ reason: 'Could not locate that data view' }));
const customProps = makeDefaultProps();
const { services } = await mountWith({ props: customProps, services: customServices });
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ indexPatterns: [] }),
{}
);
@ -633,7 +633,9 @@ describe('Lens App', () => {
expect(getButton(instance).disableButton).toEqual(false);
await act(async () => {
const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
const topNavMenuConfig = instance
.find(services.navigation.ui.AggregateQueryTopNavMenu)
.prop('config');
expect(topNavMenuConfig).not.toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@ -671,7 +673,9 @@ describe('Lens App', () => {
});
await act(async () => {
const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
const topNavMenuConfig = instance
.find(services.navigation.ui.AggregateQueryTopNavMenu)
.prop('config');
expect(topNavMenuConfig).toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@ -699,7 +703,9 @@ describe('Lens App', () => {
});
await act(async () => {
const topNavMenuConfig = instance.find(services.navigation.ui.TopNavMenu).prop('config');
const topNavMenuConfig = instance
.find(services.navigation.ui.AggregateQueryTopNavMenu)
.prop('config');
expect(topNavMenuConfig).toContainEqual(
expect.objectContaining(navMenuItems.expectedSaveAndReturnButton)
);
@ -1002,7 +1008,7 @@ describe('Lens App', () => {
describe('query bar state management', () => {
it('uses the default time and query language settings', async () => {
const { lensStore, services } = await mountWith({});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: { query: '', language: 'lucene' },
dateRangeFrom: 'now-7d',
@ -1029,13 +1035,13 @@ describe('Lens App', () => {
max: moment('2021-01-09T08:00:00.000Z'),
});
await act(async () =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
);
instance.update();
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: { query: 'new', language: 'lucene' },
dateRangeFrom: 'now-14d',
@ -1089,7 +1095,7 @@ describe('Lens App', () => {
});
act(() =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: '', language: 'lucene' },
})
@ -1103,7 +1109,7 @@ describe('Lens App', () => {
});
// trigger again, this time changing just the query
act(() =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
@ -1139,7 +1145,7 @@ describe('Lens App', () => {
},
};
await mountWith({ services });
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({ showSaveQuery: false }),
{}
);
@ -1147,7 +1153,7 @@ describe('Lens App', () => {
it('persists the saved query ID when the query is saved', async () => {
const { instance, services } = await mountWith({});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
showSaveQuery: true,
savedQuery: undefined,
@ -1158,7 +1164,7 @@ describe('Lens App', () => {
{}
);
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@ -1167,7 +1173,7 @@ describe('Lens App', () => {
},
});
});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
savedQuery: {
id: '1',
@ -1185,7 +1191,7 @@ describe('Lens App', () => {
it('changes the saved query ID when the query is updated', async () => {
const { instance, services } = await mountWith({});
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@ -1195,16 +1201,18 @@ describe('Lens App', () => {
});
});
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: '', language: 'lucene' },
},
});
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSavedQueryUpdated')!(
{
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: '', language: 'lucene' },
},
}
);
});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
savedQuery: {
id: '2',
@ -1222,16 +1230,18 @@ describe('Lens App', () => {
it('updates the query if saved query is selected', async () => {
const { instance, services } = await mountWith({});
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: 'abc:def', language: 'lucene' },
},
});
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSavedQueryUpdated')!(
{
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: 'abc:def', language: 'lucene' },
},
}
);
});
expect(services.navigation.ui.TopNavMenu).toHaveBeenCalledWith(
expect(services.navigation.ui.AggregateQueryTopNavMenu).toHaveBeenCalledWith(
expect.objectContaining({
query: { query: 'abc:def', language: 'lucene' },
}),
@ -1242,7 +1252,7 @@ describe('Lens App', () => {
it('clears all existing unpinned filters when the active saved query is cleared', async () => {
const { instance, services, lensStore } = await mountWith({});
act(() =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
@ -1255,7 +1265,9 @@ describe('Lens App', () => {
FilterManager.setFiltersStore([pinned], FilterStateStore.GLOBAL_STATE);
act(() => services.data.query.filterManager.setFilters([pinned, unpinned]));
instance.update();
act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
act(() =>
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onClearSavedQuery')!()
);
instance.update();
expect(lensStore.getState()).toEqual({
lens: expect.objectContaining({
@ -1269,7 +1281,7 @@ describe('Lens App', () => {
it('updates the searchSessionId when the query is updated', async () => {
const { instance, lensStore, services } = await mountWith({});
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSaved')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSaved')!({
id: '1',
attributes: {
title: '',
@ -1279,14 +1291,16 @@ describe('Lens App', () => {
});
});
act(() => {
instance.find(services.navigation.ui.TopNavMenu).prop('onSavedQueryUpdated')!({
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: '', language: 'lucene' },
},
});
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onSavedQueryUpdated')!(
{
id: '2',
attributes: {
title: 'new title',
description: '',
query: { query: '', language: 'lucene' },
},
}
);
});
instance.update();
expect(lensStore.getState()).toEqual({
@ -1299,7 +1313,7 @@ describe('Lens App', () => {
it('updates the searchSessionId when the active saved query is cleared', async () => {
const { instance, services, lensStore } = await mountWith({});
act(() =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-14d', to: 'now-7d' },
query: { query: 'new', language: 'lucene' },
})
@ -1312,7 +1326,9 @@ describe('Lens App', () => {
FilterManager.setFiltersStore([pinned], FilterStateStore.GLOBAL_STATE);
act(() => services.data.query.filterManager.setFilters([pinned, unpinned]));
instance.update();
act(() => instance.find(services.navigation.ui.TopNavMenu).prop('onClearSavedQuery')!());
act(() =>
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onClearSavedQuery')!()
);
instance.update();
expect(lensStore.getState()).toEqual({
lens: expect.objectContaining({
@ -1324,7 +1340,7 @@ describe('Lens App', () => {
it('dispatches update to searchSessionId and dateRange when the user hits refresh', async () => {
const { instance, services, lensStore } = await mountWith({});
act(() =>
instance.find(services.navigation.ui.TopNavMenu).prop('onQuerySubmit')!({
instance.find(services.navigation.ui.AggregateQueryTopNavMenu).prop('onQuerySubmit')!({
dateRange: { from: 'now-7d', to: 'now' },
})
);

View file

@ -6,7 +6,7 @@
*/
import './app.scss';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBreadcrumb, EuiConfirmModal } from '@elastic/eui';
import { useExecutionContext, useKibana } from '@kbn/kibana-react-plugin/public';
@ -79,6 +79,8 @@ export function App({
dashboardFeatureFlag,
} = lensAppServices;
const saveAndExit = useRef<() => void>();
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = useCallback(
(state: Partial<LensAppState>) => dispatch(setState(state)),
@ -115,6 +117,7 @@ export function App({
undefined
);
const [isGoBackToVizEditorModalVisible, setIsGoBackToVizEditorModalVisible] = useState(false);
const [shouldCloseAndSaveTextBasedQuery, setShouldCloseAndSaveTextBasedQuery] = useState(false);
const savedObjectId = (initialInput as LensByReferenceInput)?.savedObjectId;
useEffect(() => {
@ -261,6 +264,12 @@ export function App({
initialContext,
]);
const switchDatasource = useCallback(() => {
if (saveAndExit && saveAndExit.current) {
saveAndExit.current();
}
}, []);
const runSave = useCallback(
(saveProps: SaveProps, options: { saveToLibrary: boolean }) => {
dispatch(applyChanges());
@ -274,7 +283,9 @@ export function App({
persistedDoc,
onAppLeave,
redirectTo,
switchDatasource,
originatingApp: incomingState?.originatingApp,
textBasedLanguageSave: shouldCloseAndSaveTextBasedQuery,
...lensAppServices,
},
saveProps,
@ -284,6 +295,7 @@ export function App({
if (newState) {
dispatchSetState(newState);
setIsSaveModalVisible(false);
setShouldCloseAndSaveTextBasedQuery(false);
}
},
() => {
@ -293,19 +305,20 @@ export function App({
);
},
[
incomingState?.originatingApp,
dispatch,
lastKnownDoc,
persistedDoc,
getIsByValueMode,
savedObjectsTagging,
initialInput,
redirectToOrigin,
persistedDoc,
onAppLeave,
redirectTo,
switchDatasource,
incomingState?.originatingApp,
shouldCloseAndSaveTextBasedQuery,
lensAppServices,
dispatchSetState,
dispatch,
setIsSaveModalVisible,
]
);
@ -392,6 +405,14 @@ export function App({
[dataViews, uiActions, http, notifications, uiSettings, data, initialContext, dispatch]
);
const onTextBasedSavedAndExit = useCallback(async ({ onSave, onCancel }) => {
setIsSaveModalVisible(true);
setShouldCloseAndSaveTextBasedQuery(true);
saveAndExit.current = () => {
onSave();
};
}, []);
return (
<>
<div className="lnsApp" data-test-subj="lnsApp">
@ -416,6 +437,7 @@ export function App({
initialContext={initialContext}
theme$={theme$}
indexPatternService={indexPatternService}
onTextBasedSavedAndExit={onTextBasedSavedAndExit}
/>
{getLegacyUrlConflictCallout()}
{(!isLoading || persistedDoc) && (

View file

@ -8,13 +8,16 @@
import { isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { useStore } from 'react-redux';
import { TopNavMenuData } from '@kbn/navigation-plugin/public';
import { downloadMultipleAs } from '@kbn/share-plugin/public';
import { tableHasFormulas } from '@kbn/data-plugin/common';
import { exporters, getEsQueryConfig } from '@kbn/data-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ENABLE_SQL } from '../../common';
import {
LensAppServices,
LensTopNavActions,
@ -28,6 +31,7 @@ import {
useLensDispatch,
LensAppState,
DispatchSetState,
switchAndCleanDatasource,
} from '../state_management';
import {
getIndexPatternsObjects,
@ -220,6 +224,7 @@ export const LensTopNavMenu = ({
theme$,
indexPatternService,
currentDoc,
onTextBasedSavedAndExit,
}: LensTopNavMenuProps) => {
const {
data,
@ -254,7 +259,9 @@ export const LensTopNavMenu = ({
[dispatch]
);
const [indexPatterns, setIndexPatterns] = useState<DataView[]>([]);
const [dataViewsList, setDataViewsList] = useState<DataView[]>([]);
const [currentIndexPattern, setCurrentIndexPattern] = useState<DataView>();
const [isOnTextBasedMode, setIsOnTextBasedMode] = useState(false);
const [rejectedIndexPatterns, setRejectedIndexPatterns] = useState<string[]>([]);
const dispatchChangeIndexPattern = React.useCallback(
@ -355,6 +362,25 @@ export const LensTopNavMenu = ({
}
}, [indexPatterns]);
useEffect(() => {
const fetchDataViews = async () => {
const totalDataViewsList = [];
const dataViewsIds = await data.dataViews.getIds();
for (let i = 0; i < dataViewsIds.length; i++) {
const d = await data.dataViews.get(dataViewsIds[i]);
totalDataViewsList.push(d);
}
setDataViewsList(totalDataViewsList);
};
fetchDataViews();
}, [data]);
useEffect(() => {
if (typeof query === 'object' && query !== null && isOfAggregateQueryType(query)) {
setIsOnTextBasedMode(true);
}
}, [query]);
useEffect(() => {
return () => {
// Make sure to close the editors when unmounting
@ -363,7 +389,7 @@ export const LensTopNavMenu = ({
};
}, []);
const { TopNavMenu } = navigation.ui;
const { AggregateQueryTopNavMenu } = navigation.ui;
const { from, to } = data.query.timefilter.timefilter.getTime();
const savingToLibraryPermitted = Boolean(isSaveable && application.capabilities.visualize.save);
@ -550,7 +576,7 @@ export const LensTopNavMenu = ({
);
return discover.locator!.getRedirectUrl({
dataViewSpec: dataViews.indexPatterns[meta.id].spec,
dataViewSpec: dataViews.indexPatterns[meta.id]?.spec,
timeRange: data.query.timefilter.timefilter.getTime(),
filters: newFilters,
query: newQuery,
@ -617,10 +643,30 @@ export const LensTopNavMenu = ({
if (newQuery) {
if (!isEqual(newQuery, query)) {
dispatchSetState({ query: newQuery });
// check if query is text-based (sql, essql etc) and switchAndCleanDatasource
if (isOfAggregateQueryType(newQuery) && !isOnTextBasedMode) {
setIsOnTextBasedMode(true);
dispatch(
switchAndCleanDatasource({
newDatasourceId: 'textBasedLanguages',
visualizationId: visualization?.activeId,
currentIndexPatternId: currentIndexPattern?.id,
})
);
}
}
}
},
[data.query.timefilter.timefilter, data.search.session, dispatchSetState, query]
[
currentIndexPattern?.id,
data.query.timefilter.timefilter,
data.search.session,
dispatch,
dispatchSetState,
isOnTextBasedMode,
query,
visualization?.activeId,
]
);
const onSavedWrapped = useCallback(
@ -722,6 +768,13 @@ export const LensTopNavMenu = ({
closeDataViewEditor.current = dataViewEditor.openEditor({
onSave: async (dataView) => {
if (dataView.id) {
dispatch(
switchAndCleanDatasource({
newDatasourceId: 'indexpattern',
visualizationId: visualization?.activeId,
currentIndexPatternId: dataView?.id,
})
);
dispatchChangeIndexPattern(dataView);
setCurrentIndexPattern(dataView);
}
@ -730,9 +783,16 @@ export const LensTopNavMenu = ({
});
}
: undefined,
[canEditDataView, dataViewEditor, dispatchChangeIndexPattern]
[canEditDataView, dataViewEditor, dispatch, dispatchChangeIndexPattern, visualization?.activeId]
);
// setting that enables/disables SQL
const isSQLModeEnabled = uiSettings.get(ENABLE_SQL);
const supportedTextBasedLanguages = [];
if (isSQLModeEnabled) {
supportedTextBasedLanguages.push('SQL');
}
const dataViewPickerProps = {
trigger: {
label: currentIndexPattern?.getName?.() || '',
@ -744,16 +804,42 @@ export const LensTopNavMenu = ({
onDataViewCreated: createNewDataView,
adHocDataViews: indexPatterns.filter((pattern) => !pattern.isPersisted()),
onChangeDataView: (newIndexPatternId: string) => {
const currentDataView = indexPatterns.find(
const currentDataView = dataViewsList.find(
(indexPattern) => indexPattern.id === newIndexPatternId
);
setCurrentIndexPattern(currentDataView);
dispatchChangeIndexPattern(newIndexPatternId);
if (isOnTextBasedMode) {
dispatch(
switchAndCleanDatasource({
newDatasourceId: 'indexpattern',
visualizationId: visualization?.activeId,
currentIndexPatternId: newIndexPatternId,
})
);
setIsOnTextBasedMode(false);
}
},
textBasedLanguages: supportedTextBasedLanguages as DataViewPickerProps['textBasedLanguages'],
};
// text based languages errors should also appear to the unified search bar
const textBasedLanguageModeErrors: Error[] = [];
if (activeDatasourceId && allLoaded) {
if (
datasourceMap[activeDatasourceId] &&
datasourceMap[activeDatasourceId].getUnifiedSearchErrors
) {
const errors = datasourceMap[activeDatasourceId].getUnifiedSearchErrors?.(
datasourceStates[activeDatasourceId].state
);
if (errors) {
textBasedLanguageModeErrors.push(...errors);
}
}
}
return (
<TopNavMenu
<AggregateQueryTopNavMenu
setMenuMountPoint={setHeaderActionMenu}
config={topNavConfig}
showSaveQuery={Boolean(application.capabilities.visualize.saveQuery)}
@ -780,6 +866,8 @@ export const LensTopNavMenu = ({
)
)
}
textBasedLanguageModeErrors={textBasedLanguageModeErrors}
onTextBasedSavedAndExit={onTextBasedSavedAndExit}
showQueryBar={true}
showFilterBar={true}
data-test-subj="lnsApp_topNav"

View file

@ -190,6 +190,8 @@ export const runSaveLensVisualization = async (
getIsByValueMode: () => boolean;
persistedDoc?: Document;
originatingApp?: string;
textBasedLanguageSave?: boolean;
switchDatasource?: () => void;
} & ExtraProps &
LensAppServices,
saveProps: SaveProps,
@ -211,6 +213,9 @@ export const runSaveLensVisualization = async (
onAppLeave,
redirectTo,
dashboardFeatureFlag,
textBasedLanguageSave,
switchDatasource,
application,
} = props;
if (!lastKnownDoc) {
@ -318,8 +323,12 @@ export const runSaveLensVisualization = async (
// remove editor state so the connection is still broken after reload
stateTransfer.clearEditorState?.(APP_ID);
redirectTo?.(newInput.savedObjectId);
if (textBasedLanguageSave) {
switchDatasource?.();
application.navigateToApp('lens', { path: '/' });
} else {
redirectTo?.(newInput.savedObjectId);
}
return { isLinkedToOriginatingApp: false };
}

View file

@ -81,6 +81,7 @@ describe('getLayerMetaInfo', () => {
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
expect(
@ -99,6 +100,7 @@ describe('getLayerMetaInfo', () => {
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(() => ({ error: 'filters error' })),
isTextBasedLanguage: jest.fn(() => false),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
expect(
@ -158,6 +160,7 @@ describe('getLayerMetaInfo', () => {
getVisualDefaults: jest.fn(),
getSourceId: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
getFilters: jest.fn(() => ({
enabled: {
kuery: [[{ language: 'kuery', query: 'memory > 40000' }]],

View file

@ -14,6 +14,7 @@ import {
FilterStateStore,
TimeRange,
EsQueryConfig,
isOfQueryType,
} from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { RecursiveReadonly } from '@kbn/utility-types';
@ -183,7 +184,7 @@ export function combineQueryAndFilters(
lucene: [],
};
const allQueries = Array.isArray(query) ? query : query ? [query] : [];
const allQueries = Array.isArray(query) ? query : query && isOfQueryType(query) ? [query] : [];
const nonEmptyQueries = allQueries.filter((q) => Boolean(q.query.trim()));
[queries.lucene, queries.kuery] = partition(nonEmptyQueries, (q) => q.language === 'lucene');

View file

@ -119,6 +119,7 @@ export interface LensTopNavMenuProps {
currentDoc: Document | undefined;
theme$: Observable<CoreTheme>;
indexPatternService: IndexPatternServiceAPI;
onTextBasedSavedAndExit: ({ onSave }: { onSave: () => void }) => Promise<void>;
}
export interface HistoryLocationState {

View file

@ -30,8 +30,10 @@ export * from './visualizations/gauge/gauge_visualization';
export * from './visualizations/gauge';
export * from './indexpattern_datasource/indexpattern';
export { getTextBasedLanguagesDatasource } from './text_based_languages_datasource/text_based_languages';
export { createFormulaPublicApi } from './indexpattern_datasource/operations/definitions/formula/formula_public_api';
export * from './text_based_languages_datasource';
export * from './indexpattern_datasource';
export * from './lens_ui_telemetry';
export * from './lens_ui_errors';

View file

@ -7,6 +7,8 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import type { Query, AggregateQuery } from '@kbn/es-query';
import {
createMockFramePublicAPI,
mockVisualizationMap,
@ -25,6 +27,7 @@ import { mountWithProvider } from '../../../mocks';
import { LayerType, layerTypes } from '../../../../common';
import { ReactWrapper } from 'enzyme';
import { addLayer } from '../../../state_management';
import { AddLayerButton } from './add_layer';
import { createIndexPatternServiceMock } from '../../../mocks/data_views_service_mock';
jest.mock('../../../id_generator');
@ -68,7 +71,8 @@ describe('ConfigPanel', () => {
function prepareAndMountComponent(
props: ReturnType<typeof getDefaultProps>,
customStoreProps?: Partial<MountStoreProps>
customStoreProps?: Partial<MountStoreProps>,
query?: Query | AggregateQuery
) {
(generateId as jest.Mock).mockReturnValue(`newId`);
return mountWithProvider(
@ -82,6 +86,7 @@ describe('ConfigPanel', () => {
},
},
activeDatasourceId: 'testDatasource',
query: query as Query,
},
storeDeps: mockStoreDeps({
datasourceMap: props.datasourceMap,
@ -466,4 +471,28 @@ describe('ConfigPanel', () => {
expect(datasourceMap.testDatasource.initializeDimension).not.toHaveBeenCalled();
});
});
describe('text based languages', () => {
it('should not allow to add a new layer', async () => {
const datasourceMap = mockDatasourceMap();
const visualizationMap = mockVisualizationMap();
visualizationMap.testVis.getSupportedLayers = jest.fn(() => [
{ type: layerTypes.DATA, label: 'Data Layer' },
{
type: layerTypes.REFERENCELINE,
label: 'Reference layer',
},
]);
datasourceMap.testDatasource.initializeDimension = jest.fn();
const props = getDefaultProps({ datasourceMap, visualizationMap });
const { instance } = await prepareAndMountComponent(
props,
{},
{ sql: 'SELECT * from "foo"' }
);
expect(instance.find(AddLayerButton).exists()).toBe(false);
});
});
});

View file

@ -8,6 +8,7 @@
import React, { useMemo, memo, useCallback } from 'react';
import { EuiForm } from '@elastic/eui';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { isOfAggregateQueryType } from '@kbn/es-query';
import {
UPDATE_FILTER_REFERENCES_ACTION,
UPDATE_FILTER_REFERENCES_TRIGGER,
@ -52,7 +53,7 @@ export function LayerPanels(
}
) {
const { activeVisualization, datasourceMap, indexPatternService } = props;
const { activeDatasourceId, visualization, datasourceStates } = useLensSelector(
const { activeDatasourceId, visualization, datasourceStates, query } = useLensSelector(
(state) => state.lens
);
@ -185,6 +186,8 @@ export function LayerPanels(
[dispatchLens, props.framePublicAPI.dataViews, props.indexPatternService]
);
const hideAddLayerButton = query && isOfAggregateQueryType(query);
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map((layerId, layerIndex) => (
@ -264,16 +267,18 @@ export function LayerPanels(
indexPatternService={indexPatternService}
/>
))}
<AddLayerButton
visualization={activeVisualization}
visualizationState={visualization.state}
layersMeta={props.framePublicAPI}
onAddLayerClick={(layerType) => {
const layerId = generateId();
dispatchLens(addLayer({ layerId, layerType }));
setNextFocusedLayerId(layerId);
}}
/>
{!hideAddLayerButton && (
<AddLayerButton
visualization={activeVisualization}
visualizationState={visualization.state}
layersMeta={props.framePublicAPI}
onAddLayerClick={(layerType) => {
const layerId = generateId();
dispatchLens(addLayer({ layerId, layerType }));
setNextFocusedLayerId(layerId);
}}
/>
)}
</EuiForm>
);
}

View file

@ -13,6 +13,7 @@ interface CloneLayerAction {
execute: () => void;
layerIndex: number;
activeVisualization: Visualization;
isTextBasedLanguage?: boolean;
}
export const getCloneLayerAction = (props: CloneLayerAction): LayerAction => {
@ -23,7 +24,7 @@ export const getCloneLayerAction = (props: CloneLayerAction): LayerAction => {
return {
execute: props.execute,
displayName,
isCompatible: Boolean(props.activeVisualization.cloneLayer),
isCompatible: Boolean(props.activeVisualization.cloneLayer && !props.isTextBasedLanguage),
icon: 'copy',
'data-test-subj': `lnsLayerClone--${props.layerIndex}`,
};

View file

@ -31,6 +31,7 @@ export interface LayerActionsProps {
isOnlyLayer: boolean;
activeVisualization: Visualization;
layerType?: LayerType;
isTextBasedLanguage?: boolean;
core: Pick<CoreStart, 'overlays' | 'theme'>;
}
@ -111,6 +112,7 @@ export const LayerActions = (props: LayerActionsProps) => {
execute: props.onCloneLayer,
layerIndex: props.layerIndex,
activeVisualization: props.activeVisualization,
isTextBasedLanguage: props.isTextBasedLanguage,
}),
getRemoveLayerAction({
execute: props.onRemoveLayer,

View file

@ -307,6 +307,8 @@ export function LayerPanel(
);
const { dataViews } = props.framePublicAPI;
const [datasource] = Object.values(framePublicAPI.datasourceLayers);
const isTextBasedLanguage = Boolean(datasource?.isTextBasedLanguage());
return (
<>
@ -337,6 +339,7 @@ export function LayerPanel(
layerType={activeVisualization.getLayerType(layerId, visualizationState)}
onRemoveLayer={onRemoveLayer}
onCloneLayer={onCloneLayer}
isTextBasedLanguage={isTextBasedLanguage}
core={core}
/>
</EuiFlexItem>

View file

@ -7,9 +7,7 @@
import './data_panel_wrapper.scss';
import React, { useMemo, memo, useContext, useState, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import React, { useMemo, memo, useContext, useEffect, useCallback } from 'react';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
@ -24,7 +22,6 @@ import {
VisualizationMap,
} from '../../types';
import {
switchDatasource,
useLensDispatch,
updateDatasourceState,
useLensSelector,
@ -180,53 +177,9 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
],
};
const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false);
return (
<>
<Easteregg query={externalContext?.query} />
{Object.keys(props.datasourceMap).length > 1 && (
<EuiPopover
id="datasource-switch"
className="lnsDataPanelWrapper__switchSource"
button={
<EuiButtonIcon
aria-label={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
defaultMessage: 'Switch to datasource',
})}
title={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
defaultMessage: 'Switch to datasource',
})}
data-test-subj="datasource-switch"
onClick={() => setDatasourceSwitcher(true)}
iconType="gear"
/>
}
isOpen={showDatasourceSwitcher}
closePopover={() => setDatasourceSwitcher(false)}
panelPaddingSize="none"
anchorPosition="rightUp"
>
<EuiContextMenuPanel
title={i18n.translate('xpack.lens.dataPanelWrapper.switchDatasource', {
defaultMessage: 'Switch to datasource',
})}
items={Object.keys(props.datasourceMap).map((datasourceId) => (
<EuiContextMenuItem
key={datasourceId}
data-test-subj={`datasource-switch-${datasourceId}`}
icon={activeDatasourceId === datasourceId ? 'check' : 'empty'}
onClick={() => {
setDatasourceSwitcher(false);
dispatchLens(switchDatasource({ newDatasourceId: datasourceId }));
}}
>
{datasourceId}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
)}
{activeDatasourceId && !datasourceIsLoading && (
<NativeRenderer
className="lnsDataPanelWrapper"

View file

@ -376,6 +376,7 @@ describe('editor_frame', () => {
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
@ -498,41 +499,6 @@ describe('editor_frame', () => {
instance.unmount();
});
it('should initialize other datasource on switch', async () => {
await act(async () => {
instance.find('button[data-test-subj="datasource-switch"]').simulate('click');
});
await act(async () => {
(
document.querySelector(
'[data-test-subj="datasource-switch-testDatasource2"]'
) as HTMLButtonElement
).click();
});
instance.update();
expect(mockDatasource2.initialize).toHaveBeenCalled();
});
it('should call datasource render with new state on switch', async () => {
const initialState = {};
mockDatasource2.initialize.mockReturnValue(initialState);
instance.find('button[data-test-subj="datasource-switch"]').simulate('click');
await act(async () => {
(
document.querySelector(
'[data-test-subj="datasource-switch-testDatasource2"]'
) as HTMLButtonElement
).click();
});
expect(mockDatasource2.renderDataPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({ state: initialState })
);
});
it('should initialize other visualization on switch', async () => {
switchTo('testVis2');
expect(mockVisualization2.initialize).toHaveBeenCalled();

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Ast, fromExpression } from '@kbn/interpreter';
import { DatasourceStates } from '../../state_management';
import { Visualization, DatasourceMap, DatasourceLayers, IndexPatternMap } from '../../types';

View file

@ -570,6 +570,7 @@ describe('suggestion helpers', () => {
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
},
},
{ activeId: 'testVis', state: {} },
@ -607,6 +608,7 @@ describe('suggestion helpers', () => {
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
},
};
defaultParams[3] = {
@ -669,6 +671,7 @@ describe('suggestion helpers', () => {
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
},
};
mockVisualization1.getSuggestions.mockReturnValue([]);

View file

@ -196,12 +196,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
const onRender$ = useCallback(() => {
if (renderDeps.current) {
const datasourceEvents = Object.values(renderDeps.current.datasourceMap).reduce<string[]>(
(acc, datasource) => [
...acc,
...(datasource.getRenderEventCounters?.(
renderDeps.current!.datasourceStates[datasource.id].state
) ?? []),
],
(acc, datasource) => {
if (!renderDeps.current!.datasourceStates[datasource.id]) return [];
return [
...acc,
...(datasource.getRenderEventCounters?.(
renderDeps.current!.datasourceStates[datasource.id]?.state
) ?? []),
];
},
[]
);
let visualizationEvents: string[] = [];

View file

@ -310,15 +310,15 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
const currentIndexPattern = indexPatterns[currentIndexPatternId];
const existingFieldsForIndexPattern = existingFields[currentIndexPattern?.title];
const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER);
const allFields = useMemo(
() =>
visualizeGeoFieldTrigger
? currentIndexPattern.fields
: currentIndexPattern.fields.filter(
({ type }) => type !== 'geo_point' && type !== 'geo_shape'
),
[currentIndexPattern.fields, visualizeGeoFieldTrigger]
);
const allFields = useMemo(() => {
if (!currentIndexPattern) return [];
return visualizeGeoFieldTrigger
? currentIndexPattern.fields
: currentIndexPattern.fields.filter(
({ type }) => type !== 'geo_point' && type !== 'geo_shape'
);
}, [currentIndexPattern, visualizeGeoFieldTrigger]);
const clearLocalState = () => setLocalState((s) => ({ ...s, nameFilter: '', typeFilter: [] }));
const availableFieldTypes = uniq([
...uniq(allFields.map(getFieldType)).filter((type) => type in fieldTypeNames),
@ -327,13 +327,13 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
]);
const fieldInfoUnavailable =
existenceFetchFailed || existenceFetchTimeout || currentIndexPattern.hasRestrictions;
existenceFetchFailed || existenceFetchTimeout || currentIndexPattern?.hasRestrictions;
const editPermission = indexPatternFieldEditor.userPermissions.editIndexPattern();
const unfilteredFieldGroups: FieldGroups = useMemo(() => {
const containsData = (field: IndexPatternField) => {
const overallField = currentIndexPattern.getFieldByName(field.name);
const overallField = currentIndexPattern?.getFieldByName(field.name);
return (
overallField &&
existingFieldsForIndexPattern &&
@ -503,22 +503,24 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
}, []);
const refreshFieldList = useCallback(async () => {
const newlyMappedIndexPattern = await indexPatternService.loadIndexPatterns({
patterns: [currentIndexPattern.id],
cache: {},
onIndexPatternRefresh,
});
indexPatternService.updateDataViewsState({
indexPatterns: {
...frame.dataViews.indexPatterns,
[currentIndexPattern.id]: newlyMappedIndexPattern[currentIndexPattern.id],
},
});
if (currentIndexPattern) {
const newlyMappedIndexPattern = await indexPatternService.loadIndexPatterns({
patterns: [currentIndexPattern.id],
cache: {},
onIndexPatternRefresh,
});
indexPatternService.updateDataViewsState({
indexPatterns: {
...frame.dataViews.indexPatterns,
[currentIndexPattern.id]: newlyMappedIndexPattern[currentIndexPattern.id],
},
});
}
// start a new session so all charts are refreshed
data.search.session.start();
}, [
indexPatternService,
currentIndexPattern.id,
currentIndexPattern,
onIndexPatternRefresh,
frame.dataViews.indexPatterns,
data.search.session,
@ -528,7 +530,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
() =>
editPermission
? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => {
const indexPatternInstance = await dataViews.get(currentIndexPattern.id);
const indexPatternInstance = await dataViews.get(currentIndexPattern?.id);
closeFieldEditor.current = indexPatternFieldEditor.openEditor({
ctx: {
dataView: indexPatternInstance,
@ -547,7 +549,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
[
editPermission,
dataViews,
currentIndexPattern.id,
currentIndexPattern?.id,
indexPatternFieldEditor,
refreshFieldList,
indexPatternService,
@ -558,7 +560,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
() =>
editPermission
? async (fieldName: string) => {
const indexPatternInstance = await dataViews.get(currentIndexPattern.id);
const indexPatternInstance = await dataViews.get(currentIndexPattern?.id);
closeFieldEditor.current = indexPatternFieldEditor.openDeleteModal({
ctx: {
dataView: indexPatternInstance,
@ -575,7 +577,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
}
: undefined,
[
currentIndexPattern.id,
currentIndexPattern?.id,
dataViews,
editPermission,
indexPatternFieldEditor,

View file

@ -1718,6 +1718,15 @@ describe('IndexPattern Data Source', () => {
});
});
describe('#createEmptyLayer', () => {
it('creates state with empty layers', () => {
expect(indexPatternDatasource.createEmptyLayer('index-pattern-id')).toEqual({
currentIndexPatternId: 'index-pattern-id',
layers: {},
});
});
});
describe('#getLayers', () => {
it('should list the current layers', () => {
expect(

View file

@ -190,6 +190,13 @@ export function getIndexPatternDatasource({
};
},
createEmptyLayer(indexPatternId: string) {
return {
currentIndexPatternId: indexPatternId,
layers: {},
};
},
cloneLayer(state, layerId, newLayerId, getNewId) {
return {
...state,
@ -218,7 +225,7 @@ export function getIndexPatternDatasource({
},
getLayers(state: IndexPatternPrivateState) {
return Object.keys(state.layers);
return Object.keys(state?.layers);
},
removeColumn({ prevState, layerId, columnId, indexPatterns }) {
@ -314,7 +321,6 @@ export function getIndexPatternDatasource({
counts[uniqueLabel] = 0;
return uniqueLabel;
};
Object.values(layers).forEach((layer) => {
if (!layer.columns) {
return;
@ -500,7 +506,7 @@ export function getIndexPatternDatasource({
filter: false,
};
const operations = flatten(
Object.values(state.layers ?? {}).map((l) =>
Object.values(state?.layers ?? {}).map((l) =>
Object.values(l.columns).map((c) => {
if (c.timeShift) {
additionalEvents.time_shift = true;
@ -568,6 +574,7 @@ export function getIndexPatternDatasource({
fields: [...new Set(fieldsPerColumn[colId] || [])],
}));
},
isTextBasedLanguage: () => false,
getOperationForColumnId: (columnId: string) => {
if (layer && layer.columns[columnId]) {
if (!isReferenced(layer, columnId)) {

View file

@ -129,6 +129,7 @@ export function mockDataPlugin(
get: jest.fn().mockImplementation((id) => Promise.resolve({ id, isTimeBased: () => true })),
},
dataViews: {
getIds: jest.fn().mockImplementation(jest.fn(async () => [])),
get: jest.fn().mockImplementation((id) =>
Promise.resolve({
id,

View file

@ -20,6 +20,7 @@ export function createMockDatasource(id: string): DatasourceMock {
getSourceId: jest.fn(),
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
};
return {
@ -52,6 +53,7 @@ export function createMockDatasource(id: string): DatasourceMock {
renderDimensionEditor: jest.fn(),
getDropProps: jest.fn(),
onDrop: jest.fn(),
createEmptyLayer: jest.fn(),
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called

View file

@ -104,9 +104,11 @@ export function makeDefaultServices(
const navigationStartMock = navigationPluginMock.createStartContract();
jest.spyOn(navigationStartMock.ui.TopNavMenu.prototype, 'constructor').mockImplementation(() => {
return <div className="topNavMenu" />;
});
jest
.spyOn(navigationStartMock.ui.AggregateQueryTopNavMenu.prototype, 'constructor')
.mockImplementation(() => {
return <div className="topNavMenu" />;
});
function makeAttributeService(): LensAttributeService {
const attributeServiceMock = mockAttributeService<

View file

@ -56,6 +56,8 @@ import type {
IndexPatternDatasourceSetupPlugins,
FormulaPublicApi,
} from './indexpattern_datasource';
import type { TextBasedLanguagesDatasource as TextBasedLanguagesDatasourceType } from './text_based_languages_datasource';
import type {
XyVisualization as XyVisualizationType,
XyVisualizationPluginSetupPlugins,
@ -230,6 +232,7 @@ export class LensPlugin {
private editorFrameSetup: EditorFrameSetup | undefined;
private queuedVisualizations: Array<Visualization | (() => Promise<Visualization>)> = [];
private indexpatternDatasource: IndexPatternDatasourceType | undefined;
private textBasedLanguagesDatasource: TextBasedLanguagesDatasourceType | undefined;
private xyVisualization: XyVisualizationType | undefined;
private legacyMetricVisualization: LegacyMetricVisualizationType | undefined;
private metricVisualization: MetricVisualizationType | undefined;
@ -426,10 +429,12 @@ export class LensPlugin {
PieVisualization,
HeatmapVisualization,
GaugeVisualization,
TextBasedLanguagesDatasource,
} = await import('./async_services');
this.datatableVisualization = new DatatableVisualization();
this.editorFrameService = new EditorFrameService();
this.indexpatternDatasource = new IndexPatternDatasource();
this.textBasedLanguagesDatasource = new TextBasedLanguagesDatasource();
this.xyVisualization = new XyVisualization();
this.legacyMetricVisualization = new LegacyMetricVisualization();
this.metricVisualization = new MetricVisualization();
@ -453,6 +458,7 @@ export class LensPlugin {
eventAnnotation,
};
this.indexpatternDatasource.setup(core, dependencies);
this.textBasedLanguagesDatasource.setup(core, dependencies);
this.xyVisualization.setup(core, dependencies);
this.datatableVisualization.setup(core, dependencies);
this.legacyMetricVisualization.setup(core, dependencies);

View file

@ -15,6 +15,7 @@ import { IndexPatternRef } from '../../types';
export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & {
label: string;
title?: string;
isDisabled?: boolean;
};
export function ChangeIndexPattern({

View file

@ -34,6 +34,7 @@ export const {
rollbackSuggestion,
submitSuggestion,
switchDatasource,
switchAndCleanDatasource,
updateIndexPatterns,
setToggleFullscreen,
initEmpty,

View file

@ -9,6 +9,7 @@ import { EnhancedStore } from '@reduxjs/toolkit';
import type { Query } from '@kbn/es-query';
import {
switchDatasource,
switchAndCleanDatasource,
switchVisualization,
setState,
updateState,
@ -210,6 +211,57 @@ describe('lensSlice', () => {
);
});
describe('switching to a new datasource and modify the state', () => {
it('should switch active datasource and initialize new state', () => {
store.dispatch(
switchAndCleanDatasource({
newDatasourceId: 'testDatasource2',
visualizationId: 'testVis',
currentIndexPatternId: 'testIndexPatternId',
})
);
expect(store.getState().lens.activeDatasourceId).toEqual('testDatasource2');
expect(store.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(false);
expect(store.getState().lens.visualization.activeId).toEqual('testVis');
});
it('should should switch active datasource and clean the datasource state', () => {
const datasource2State = {
layers: {},
};
const { store: customStore } = makeLensStore({
preloadedState: {
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
testDatasource2: {
state: datasource2State,
isLoading: false,
},
},
},
});
customStore.dispatch(
switchAndCleanDatasource({
newDatasourceId: 'testDatasource2',
visualizationId: 'testVis',
currentIndexPatternId: 'testIndexPatternId',
})
);
expect(customStore.getState().lens.activeDatasourceId).toEqual('testDatasource2');
expect(customStore.getState().lens.datasourceStates.testDatasource2.isLoading).toEqual(
false
);
expect(customStore.getState().lens.datasourceStates.testDatasource2.state).toStrictEqual(
{}
);
});
});
describe('adding or removing layer', () => {
const testDatasource = (datasourceId: string) => {
return {

View file

@ -57,10 +57,12 @@ export const getPreloadedState = ({
const initialDatasourceId = getInitialDatasourceId(datasourceMap);
const datasourceStates: LensAppState['datasourceStates'] = {};
if (initialDatasourceId) {
datasourceStates[initialDatasourceId] = {
state: null,
isLoading: true,
};
Object.keys(datasourceMap).forEach((datasourceId) => {
datasourceStates[datasourceId] = {
state: null,
isLoading: true,
};
});
}
const state = {
@ -130,6 +132,11 @@ export const submitSuggestion = createAction<void>('lens/submitSuggestion');
export const switchDatasource = createAction<{
newDatasourceId: string;
}>('lens/switchDatasource');
export const switchAndCleanDatasource = createAction<{
newDatasourceId: string;
visualizationId: string | null;
currentIndexPatternId?: string;
}>('lens/switchAndCleanDatasource');
export const navigateAway = createAction<void>('lens/navigateAway');
export const loadInitial = createAction<{
initialInput?: LensEmbeddableInput;
@ -211,6 +218,7 @@ export const lensActions = {
setToggleFullscreen,
submitSuggestion,
switchDatasource,
switchAndCleanDatasource,
navigateAway,
loadInitial,
initEmpty,
@ -359,9 +367,11 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
const datasource = datasourceMap[datasourceId!];
return {
...datasourceState,
state: isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId),
...(datasourceId === state.activeDatasourceId && {
state: isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId),
}),
};
}
);
@ -663,6 +673,55 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
activeDatasourceId: payload.newDatasourceId,
};
},
[switchAndCleanDatasource.type]: (
state,
{
payload,
}: {
payload: {
newDatasourceId: string;
visualizationId?: string;
currentIndexPatternId?: string;
};
}
) => {
const activeVisualization =
payload.visualizationId && visualizationMap[payload.visualizationId];
const visualization = state.visualization;
let newVizState = visualization.state;
const ids: string[] = [];
if (activeVisualization && activeVisualization.getLayerIds) {
const layerIds = activeVisualization.getLayerIds(visualization.state);
ids.push(...Object.values(layerIds));
newVizState = activeVisualization.initialize(() => ids[0]);
}
const currentVizId = ids[0];
const datasourceState = current(state).datasourceStates[payload.newDatasourceId]
? current(state).datasourceStates[payload.newDatasourceId]?.state
: datasourceMap[payload.newDatasourceId].createEmptyLayer(
payload.currentIndexPatternId ?? ''
);
const updatedState = datasourceMap[payload.newDatasourceId].insertLayer(
datasourceState,
currentVizId
);
return {
...state,
datasourceStates: {
[payload.newDatasourceId]: {
state: updatedState,
isLoading: false,
},
},
activeDatasourceId: payload.newDatasourceId,
visualization: {
...visualization,
state: newVizState,
},
};
},
[navigateAway.type]: (state) => state,
[loadInitial.type]: (
state,

View file

@ -7,7 +7,7 @@
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { EmbeddableEditorState } from '@kbn/embeddable-plugin/public';
import { Filter, Query } from '@kbn/es-query';
import type { Filter, Query } from '@kbn/es-query';
import { SavedQuery } from '@kbn/data-plugin/public';
import { Document } from '../persistence';

View file

@ -0,0 +1,237 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { act } from 'react-dom/test-utils';
import type { Query } from '@kbn/es-query';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import {
dataViewPluginMocks,
Start as DataViewPublicStart,
} from '@kbn/data-views-plugin/public/mocks';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { FieldButton } from '@kbn/react-field';
import { type TextBasedLanguagesDataPanelProps, TextBasedLanguagesDataPanel } from './datapanel';
import { coreMock } from '@kbn/core/public/mocks';
import type { TextBasedLanguagesPrivateState } from './types';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { createIndexPatternServiceMock } from '../mocks/data_views_service_mock';
import { createMockFramePublicAPI } from '../mocks';
import { createMockedDragDropContext } from './mocks';
import { DataViewsState } from '../state_management';
import { ExistingFieldsMap, IndexPattern } from '../types';
const fieldsFromQuery = [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
] as DatatableColumn[];
const fieldsOne = [
{
name: 'timestamp',
displayName: 'timestampLabel',
type: 'date',
aggregatable: true,
searchable: true,
},
{
name: 'bytes',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
},
{
name: 'memory',
displayName: 'amemory',
type: 'number',
aggregatable: true,
searchable: true,
},
{
name: 'unsupported',
displayName: 'unsupported',
type: 'geo',
aggregatable: true,
searchable: true,
},
{
name: 'source',
displayName: 'source',
type: 'string',
aggregatable: true,
searchable: true,
},
{
name: 'client',
displayName: 'client',
type: 'ip',
aggregatable: true,
searchable: true,
},
];
function getExistingFields(indexPatterns: Record<string, IndexPattern>) {
const existingFields: ExistingFieldsMap = {};
for (const { title, fields } of Object.values(indexPatterns)) {
const fieldsMap: Record<string, boolean> = {};
for (const { displayName, name } of fields) {
fieldsMap[displayName ?? name] = true;
}
existingFields[title] = fieldsMap;
}
return existingFields;
}
const initialState: TextBasedLanguagesPrivateState = {
layers: {
first: {
index: '1',
columns: [],
allColumns: [],
query: { sql: 'SELECT * FROM foo' },
},
},
indexPatternRefs: [
{ id: '1', title: 'my-fake-index-pattern' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
],
fieldList: fieldsFromQuery,
};
function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial<DataViewsState> = {}) {
const frameAPI = createMockFramePublicAPI();
const defaultIndexPatterns = {
'1': {
id: '1',
title: 'idx1',
timeFieldName: 'timestamp',
hasRestrictions: false,
fields: fieldsOne,
getFieldByName: jest.fn(),
isPersisted: true,
spec: {},
},
};
return {
...frameAPI,
dataViews: {
...frameAPI.dataViews,
indexPatterns: indexPatterns ?? defaultIndexPatterns,
existingFields: existingFields ?? getExistingFields(indexPatterns ?? defaultIndexPatterns),
isFirstExistenceFetch: false,
...rest,
},
};
}
// @ts-expect-error Portal mocks are notoriously difficult to type
ReactDOM.createPortal = jest.fn((element) => element);
describe('TextBased Query Languages Data Panel', () => {
let core: ReturnType<typeof coreMock['createStart']>;
let dataViews: DataViewPublicStart;
let defaultProps: TextBasedLanguagesDataPanelProps;
const dataViewsMock = dataViewPluginMocks.createStartContract();
beforeEach(() => {
core = coreMock.createStart();
dataViews = dataViewPluginMocks.createStartContract();
defaultProps = {
data: dataPluginMock.createStartContract(),
expressions: expressionsPluginMock.createStartContract(),
dataViews: {
...dataViewsMock,
getIdsWithTitle: jest.fn().mockReturnValue(
Promise.resolve([
{ id: '1', title: 'my-fake-index-pattern' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
])
),
},
dragDropContext: createMockedDragDropContext(),
core,
dateRange: {
fromDate: 'now-7d',
toDate: 'now',
},
query: { sql: 'SELECT * FROM my-fake-index-pattern' } as unknown as Query,
filters: [],
showNoDataPopover: jest.fn(),
dropOntoWorkspace: jest.fn(),
hasSuggestionForField: jest.fn(() => false),
uiActions: uiActionsPluginMock.createStartContract(),
indexPatternService: createIndexPatternServiceMock({ core, dataViews }),
frame: getFrameAPIMock(),
state: initialState,
setState: jest.fn(),
onChangeIndexPattern: jest.fn(),
};
});
it('should render a search box', async () => {
const wrapper = mountWithIntl(<TextBasedLanguagesDataPanel {...defaultProps} />);
expect(wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]').length).toEqual(1);
});
it('should list all supported fields in the pattern', async () => {
const wrapper = mountWithIntl(<TextBasedLanguagesDataPanel {...defaultProps} />);
expect(
wrapper
.find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]')
.find(FieldButton)
.map((fieldItem) => fieldItem.prop('fieldName'))
).toEqual(['timestamp', 'bytes', 'memory']);
});
it('should list all supported fields in the pattern that match the search input', async () => {
const wrapper = mountWithIntl(<TextBasedLanguagesDataPanel {...defaultProps} />);
const searchBox = wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]');
act(() => {
searchBox.prop('onChange')!({
target: { value: 'mem' },
} as React.ChangeEvent<HTMLInputElement>);
});
wrapper.update();
expect(
wrapper
.find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]')
.find(FieldButton)
.map((fieldItem) => fieldItem.prop('fieldName'))
).toEqual(['memory']);
});
});

View file

@ -0,0 +1,167 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormControlLayout, htmlIdGenerator } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import usePrevious from 'react-use/lib/usePrevious';
import { isEqual } from 'lodash';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { FieldButton } from '@kbn/react-field';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { DatasourceDataPanelProps, DataType } from '../types';
import type { TextBasedLanguagesPrivateState } from './types';
import { getStateFromAggregateQuery } from './utils';
import { DragDrop } from '../drag_drop';
import { LensFieldIcon } from '../shared_components';
import { ChildDragDropProvider } from '../drag_drop';
export type TextBasedLanguagesDataPanelProps =
DatasourceDataPanelProps<TextBasedLanguagesPrivateState> & {
data: DataPublicPluginStart;
expressions: ExpressionsStart;
dataViews: DataViewsPublicPluginStart;
};
const htmlId = htmlIdGenerator('datapanel-text-based-languages');
const fieldSearchDescriptionId = htmlId();
export function TextBasedLanguagesDataPanel({
setState,
state,
dragDropContext,
core,
data,
query,
filters,
dateRange,
expressions,
dataViews,
}: TextBasedLanguagesDataPanelProps) {
const prevQuery = usePrevious(query);
const [localState, setLocalState] = useState({ nameFilter: '' });
const clearLocalState = () => setLocalState((s) => ({ ...s, nameFilter: '' }));
useEffect(() => {
async function fetchData() {
if (query && isOfAggregateQueryType(query) && !isEqual(query, prevQuery)) {
const stateFromQuery = await getStateFromAggregateQuery(
state,
query,
dataViews,
data,
expressions
);
setState(stateFromQuery);
}
}
fetchData();
}, [data, dataViews, expressions, prevQuery, query, setState, state]);
const { fieldList } = state;
const filteredFields = useMemo(() => {
return fieldList.filter((field) => {
if (
localState.nameFilter &&
!field.name.toLowerCase().includes(localState.nameFilter.toLowerCase())
) {
return false;
}
return true;
});
}, [fieldList, localState.nameFilter]);
return (
<KibanaContextProvider
services={{
...core,
}}
>
<ChildDragDropProvider {...dragDropContext}>
<EuiFlexGroup
gutterSize="none"
className="lnsInnerIndexPatternDataPanel"
direction="column"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiFormControlLayout
icon="search"
fullWidth
clear={{
title: i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', {
defaultMessage: 'Clear name and type filters',
}),
'aria-label': i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', {
defaultMessage: 'Clear name and type filters',
}),
onClick: () => {
clearLocalState();
},
}}
>
<input
className="euiFieldText euiFieldText--fullWidth lnsInnerIndexPatternDataPanel__textField"
data-test-subj="lnsTextBasedLangugesFieldSearch"
placeholder={i18n.translate('xpack.lens.indexPatterns.filterByNameLabel', {
defaultMessage: 'Search field names',
description: 'Search the list of fields in the data view for the provided text',
})}
value={localState.nameFilter}
onChange={(e) => {
setLocalState({ ...localState, nameFilter: e.target.value });
}}
aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameLabel', {
defaultMessage: 'Search field names',
description: 'Search the list of fields in the data view for the provided text',
})}
aria-describedby={fieldSearchDescriptionId}
/>
</EuiFormControlLayout>
</EuiFlexItem>
<EuiFlexItem>
<div className="lnsIndexPatternFieldList">
<div className="lnsIndexPatternFieldList__accordionContainer">
<ul
className="lnsInnerIndexPatternDataPanel__fieldItems"
data-test-subj="lnsTextBasedLanguagesPanelFields"
>
{filteredFields.length > 0 &&
filteredFields.map((field, index) => (
<li key={field?.name}>
<DragDrop
draggable
order={[index]}
value={{
field: field?.name,
id: field.id,
humanData: { label: field?.name },
}}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
>
<FieldButton
className={`lnsFieldItem lnsFieldItem--${field?.meta?.type}`}
isActive={false}
onClick={() => {}}
fieldIcon={<LensFieldIcon type={field?.meta.type as DataType} />}
fieldName={field?.name}
/>
</DragDrop>
</li>
))}
</ul>
</div>
</div>
</EuiFlexItem>
</EuiFlexGroup>
</ChildDragDropProvider>
</KibanaContextProvider>
);
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { pluck } from 'rxjs/operators';
import { lastValueFrom } from 'rxjs';
import { Query, AggregateQuery, Filter } from '@kbn/es-query';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/public';
import type { DataViewsContract } from '@kbn/data-views-plugin/common';
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
interface TextBasedLanguagesErrorResponse {
error: {
message: string;
};
type: 'error';
}
export function fetchDataFromAggregateQuery(
query: Query | AggregateQuery,
dataViewsService: DataViewsContract,
data: DataPublicPluginStart,
expressions: ExpressionsStart,
filters?: Filter[],
inputQuery?: Query
) {
const timeRange = data.query.timefilter.timefilter.getTime();
return textBasedQueryStateToAstWithValidation({
filters,
query,
time: timeRange,
dataViewsService,
inputQuery,
})
.then((ast) => {
if (ast) {
const execution = expressions.run(ast, null);
let finalData: Datatable;
let error: string | undefined;
execution.pipe(pluck('result')).subscribe((resp) => {
const response = resp as Datatable | TextBasedLanguagesErrorResponse;
if (response.type === 'error') {
error = response.error.message;
} else {
finalData = response;
}
});
return lastValueFrom(execution).then(() => {
if (error) {
throw new Error(error);
} else {
return finalData;
}
});
}
return undefined;
})
.catch((err) => {
throw new Error(err.message);
});
}

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { FieldPicker, FieldOptionValue } from '../shared_components/field_picker';
import { FieldSelect, FieldSelectProps } from './field_select';
import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers';
const fields = [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
] as DatatableColumn[];
describe('Layer Data Panel', () => {
let defaultProps: FieldSelectProps;
beforeEach(() => {
defaultProps = {
selectedField: {
fieldName: 'bytes',
columnId: 'bytes',
meta: {
type: 'number',
},
},
existingFields: fields,
onChoose: jest.fn(),
};
});
it('should display the selected field if given', () => {
const instance = shallow(<FieldSelect {...defaultProps} />);
expect(instance.find(FieldPicker).prop('selectedOptions')).toStrictEqual([
{
label: 'bytes',
value: {
type: 'field',
field: 'bytes',
dataType: 'number',
},
},
]);
});
it('should pass the fields with the correct format', () => {
const instance = shallow(<FieldSelect {...defaultProps} />);
expect(instance.find(FieldPicker).prop('options')).toStrictEqual([
{
label: 'Available fields',
options: [
{
compatible: true,
exists: true,
label: 'timestamp',
value: {
type: 'field' as FieldOptionValue['type'],
field: 'timestamp',
dataType: 'date',
},
},
{
compatible: true,
exists: true,
label: 'bytes',
value: {
type: 'field' as FieldOptionValue['type'],
field: 'bytes',
dataType: 'number',
},
},
{
compatible: true,
exists: true,
label: 'memory',
value: {
type: 'field' as FieldOptionValue['type'],
field: 'memory',
dataType: 'number',
},
},
],
},
]);
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiComboBoxOptionOption, EuiComboBoxProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { FieldPicker, FieldOptionValue, FieldOption } from '../shared_components/field_picker';
import type { TextBasedLanguagesLayerColumn } from './types';
import type { DataType } from '../types';
export interface FieldSelectProps extends EuiComboBoxProps<EuiComboBoxOptionOption['value']> {
selectedField?: TextBasedLanguagesLayerColumn;
onChoose: (choice: FieldOptionValue) => void;
existingFields: DatatableColumn[];
}
export function FieldSelect({
selectedField,
onChoose,
existingFields,
['data-test-subj']: dataTestSub,
}: FieldSelectProps) {
const memoizedFieldOptions = useMemo(() => {
const availableFields = existingFields.map((field) => {
const dataType = field?.meta?.type as DataType;
return {
compatible: true,
exists: true,
label: field.name,
value: {
type: 'field' as FieldOptionValue['type'],
field: field.name,
dataType,
},
};
});
return [
{
label: i18n.translate('xpack.lens.indexPattern.availableFieldsLabel', {
defaultMessage: 'Available fields',
}),
options: availableFields,
},
];
}, [existingFields]);
return (
<FieldPicker<FieldOptionValue>
selectedOptions={
selectedField
? ([
{
label: selectedField.fieldName,
value: {
type: 'field',
field: selectedField.fieldName,
dataType: selectedField?.meta?.type,
},
},
] as unknown as Array<FieldOption<FieldOptionValue>>)
: []
}
options={memoizedFieldOptions as Array<FieldOption<FieldOptionValue>>}
onChoose={(choice) => {
if (choice && choice.field !== selectedField?.fieldName) {
onChoose(choice);
}
}}
fieldIsInvalid={false}
data-test-subj={dataTestSub ?? 'text-based-dimension-field'}
/>
);
}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup } from '@kbn/core/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { EditorFrameSetup } from '../types';
export interface TextBasedLanguageSetupPlugins {
data: DataPublicPluginSetup;
editorFrame: EditorFrameSetup;
}
export interface TextBasedLanguageStartPlugins {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
expressions: ExpressionsStart;
}
export class TextBasedLanguagesDatasource {
constructor() {}
setup(
core: CoreSetup<TextBasedLanguageStartPlugins>,
{ editorFrame }: TextBasedLanguageSetupPlugins
) {
editorFrame.registerDatasource(async () => {
const { getTextBasedLanguagesDatasource } = await import('../async_services');
const [coreStart, { data, dataViews, expressions }] = await core.getStartServices();
return getTextBasedLanguagesDatasource({
core: coreStart,
storage: new Storage(localStorage),
data,
dataViews,
expressions,
});
});
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { TextBasedLanguagesPrivateState } from './types';
import type { DataViewsState } from '../state_management/types';
import { TextBasedLanguageLayerPanelProps, LayerPanel } from './layerpanel';
import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers';
import { ChangeIndexPattern } from '../shared_components/dataview_picker/dataview_picker';
const fields = [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
] as DatatableColumn[];
const initialState: TextBasedLanguagesPrivateState = {
layers: {
first: {
index: '1',
columns: [],
allColumns: [],
query: { sql: 'SELECT * FROM foo' },
},
},
indexPatternRefs: [
{ id: '1', title: 'my-fake-index-pattern' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
],
fieldList: fields,
};
describe('Layer Data Panel', () => {
let defaultProps: TextBasedLanguageLayerPanelProps;
beforeEach(() => {
defaultProps = {
layerId: 'first',
state: initialState,
onChangeIndexPattern: jest.fn(),
dataViews: {
indexPatternRefs: [
{ id: '1', title: 'my-fake-index-pattern', name: 'My fake index pattern' },
{ id: '2', title: 'my-fake-restricted-pattern', name: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern', name: 'my-compatible-pattern' },
],
existingFields: {},
isFirstExistenceFetch: false,
indexPatterns: {},
} as DataViewsState,
};
});
it('should display the selected dataview but disabled', () => {
const instance = shallow(<LayerPanel {...defaultProps} />);
expect(instance.find(ChangeIndexPattern).prop('trigger')).toStrictEqual({
fontWeight: 'normal',
isDisabled: true,
label: 'My fake index pattern',
size: 's',
title: 'my-fake-index-pattern',
});
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { DatasourceLayerPanelProps } from '../types';
import { TextBasedLanguagesPrivateState } from './types';
import { ChangeIndexPattern } from '../shared_components/dataview_picker/dataview_picker';
export interface TextBasedLanguageLayerPanelProps
extends DatasourceLayerPanelProps<TextBasedLanguagesPrivateState> {
state: TextBasedLanguagesPrivateState;
}
export function LayerPanel({ state, layerId, dataViews }: TextBasedLanguageLayerPanelProps) {
const layer = state.layers[layerId];
const dataView = dataViews.indexPatternRefs.find((ref) => ref.id === layer.index);
const notFoundTitleLabel = i18n.translate('xpack.lens.layerPanel.missingDataView', {
defaultMessage: 'Data view not found',
});
return (
<I18nProvider>
<ChangeIndexPattern
data-test-subj="textBasedLanguages-switcher"
trigger={{
label: dataView?.name || dataView?.title || notFoundTitleLabel,
title: dataView?.title || notFoundTitleLabel,
size: 's',
fontWeight: 'normal',
isDisabled: true,
}}
indexPatternId={layer.index}
indexPatternRefs={dataViews.indexPatternRefs}
isMissingCurrent={!dataView}
onChangeIndexPattern={() => {}}
/>
</I18nProvider>
);
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DragContextState } from '../drag_drop';
export function createMockedDragDropContext(): jest.Mocked<DragContextState> {
return {
dragging: undefined,
setDragging: jest.fn(),
activeDropTarget: undefined,
setActiveDropTarget: jest.fn(),
keyboardMode: false,
setKeyboardMode: jest.fn(),
setA11yMessage: jest.fn(),
dropTargetsByOrder: undefined,
registerDropTarget: jest.fn(),
};
}

View file

@ -0,0 +1,595 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/public/mocks';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { TextBasedLanguagesPersistedState, TextBasedLanguagesPrivateState } from './types';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { getTextBasedLanguagesDatasource } from './text_based_languages';
import { DatasourcePublicAPI, Datasource } from '../types';
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,
},
];
const expectedIndexPatterns = {
1: {
id: '1',
title: 'foo',
timeFieldName: 'timestamp',
hasRestrictions: false,
fields: fieldsOne,
getFieldByName: jest.fn(),
spec: {},
isPersisted: true,
},
};
const indexPatterns = expectedIndexPatterns;
describe('IndexPattern Data Source', () => {
let baseState: TextBasedLanguagesPrivateState;
let textBasedLanguagesDatasource: Datasource<
TextBasedLanguagesPrivateState,
TextBasedLanguagesPersistedState
>;
beforeEach(() => {
textBasedLanguagesDatasource = getTextBasedLanguagesDatasource({
storage: {} as IStorageWrapper,
core: coreMock.createStart(),
data: dataPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
expressions: expressionsPluginMock.createStartContract(),
});
baseState = {
layers: {
a: {
columns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
],
index: 'foo',
query: { sql: 'SELECT * FROM foo' },
},
},
} as unknown as TextBasedLanguagesPrivateState;
});
describe('uniqueLabels', () => {
it('appends a suffix to duplicates', () => {
const map = textBasedLanguagesDatasource.uniqueLabels({
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Foo',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Foo',
meta: {
type: 'number',
},
},
],
index: 'foo',
},
},
} as unknown as TextBasedLanguagesPrivateState);
expect(map).toMatchInlineSnapshot(`
Object {
"a": "Foo",
"b": "Foo [1]",
}
`);
});
});
describe('#getPersistedState', () => {
it('should persist from saved state', async () => {
expect(textBasedLanguagesDatasource.getPersistableState(baseState)).toEqual({
state: baseState,
savedObjectReferences: [
{ name: 'textBasedLanguages-datasource-layer-a', type: 'index-pattern', id: 'foo' },
],
});
});
});
describe('#insertLayer', () => {
it('should insert an empty layer into the previous state', () => {
expect(textBasedLanguagesDatasource.insertLayer(baseState, 'newLayer')).toEqual({
...baseState,
layers: {
...baseState.layers,
newLayer: {
index: 'foo',
query: { sql: 'SELECT * FROM foo' },
allColumns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
],
columns: [],
},
},
});
});
});
describe('#removeLayer', () => {
it('should remove a layer', () => {
expect(textBasedLanguagesDatasource.removeLayer(baseState, 'a')).toEqual({
...baseState,
layers: {
a: {
columns: [],
allColumns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: 'foo',
},
},
});
});
});
describe('#createEmptyLayer', () => {
it('creates state with empty layers', () => {
expect(textBasedLanguagesDatasource.createEmptyLayer('index-pattern-id')).toEqual({
fieldList: [],
layers: {},
indexPatternRefs: [],
});
});
});
describe('#getLayers', () => {
it('should list the current layers', () => {
expect(
textBasedLanguagesDatasource.getLayers({
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: 'foo',
},
},
} as unknown as TextBasedLanguagesPrivateState)
).toEqual(['a']);
});
});
describe('#getErrorMessages', () => {
it('should use the results of getErrorMessages directly when single layer', () => {
const state = {
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
errors: [new Error('error 1'), new Error('error 2')],
query: { sql: 'SELECT * FROM foo' },
index: 'foo',
},
},
} as unknown as TextBasedLanguagesPrivateState;
expect(textBasedLanguagesDatasource.getErrorMessages(state, indexPatterns)).toEqual([
{ longMessage: 'error 1', shortMessage: 'error 1' },
{ longMessage: 'error 2', shortMessage: 'error 2' },
]);
});
});
describe('#isTimeBased', () => {
it('should return true if timefield name exists on the dataview', () => {
const state = {
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: '1',
},
},
} as unknown as TextBasedLanguagesPrivateState;
expect(
textBasedLanguagesDatasource.isTimeBased(state, {
...indexPatterns,
})
).toEqual(true);
});
it('should return false if timefield name not exists on the selected dataview', () => {
const state = {
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: '1',
},
},
} as unknown as TextBasedLanguagesPrivateState;
expect(
textBasedLanguagesDatasource.isTimeBased(state, {
...indexPatterns,
'1': { ...indexPatterns['1'], timeFieldName: undefined },
})
).toEqual(false);
});
});
describe('#toExpression', () => {
it('should generate an empty expression when no columns are selected', async () => {
const state = textBasedLanguagesDatasource.initialize();
expect(textBasedLanguagesDatasource.toExpression(state, 'first', indexPatterns)).toEqual(
null
);
});
it('should generate an expression for an SQL query', async () => {
const queryBaseState = {
layers: {
a: {
columns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
allColumns: [
{
columnId: 'a',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'b',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
query: { sql: 'SELECT * FROM foo' },
index: '1',
},
},
indexPatternRefs: [
{ id: '1', title: 'foo' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
],
} as unknown as TextBasedLanguagesPrivateState;
expect(textBasedLanguagesDatasource.toExpression(queryBaseState, 'a', indexPatterns))
.toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {},
"function": "kibana",
"type": "function",
},
Object {
"arguments": Object {},
"function": "kibana_context",
"type": "function",
},
Object {
"arguments": Object {
"query": Array [
"SELECT * FROM foo",
],
},
"function": "essql",
"type": "function",
},
Object {
"arguments": Object {
"idMap": Array [
"{\\"Test 1\\":[{\\"id\\":\\"a\\",\\"label\\":\\"Test 1\\"}],\\"Test 2\\":[{\\"id\\":\\"b\\",\\"label\\":\\"Test 2\\"}]}",
],
},
"function": "lens_map_to_columns",
"type": "function",
},
],
"type": "expression",
}
`);
});
});
describe('#getPublicAPI', () => {
let publicAPI: DatasourcePublicAPI;
beforeEach(async () => {
publicAPI = textBasedLanguagesDatasource.getPublicAPI({
state: baseState,
layerId: 'a',
indexPatterns,
});
});
describe('getTableSpec', () => {
it('should include col1', () => {
expect(publicAPI.getTableSpec()).toEqual([expect.objectContaining({ columnId: 'col1' })]);
});
it('should include fields prop for each column', () => {
expect(publicAPI.getTableSpec()).toEqual([expect.objectContaining({ fields: ['Test 1'] })]);
});
it('should collect all fields ', () => {
const state = {
layers: {
a: {
columns: [
{
columnId: 'col1',
fieldName: 'Test 1',
meta: {
type: 'number',
},
},
{
columnId: 'col2',
fieldName: 'Test 2',
meta: {
type: 'number',
},
},
],
index: 'foo',
},
},
} as unknown as TextBasedLanguagesPrivateState;
publicAPI = textBasedLanguagesDatasource.getPublicAPI({
state,
layerId: 'a',
indexPatterns,
});
expect(publicAPI.getTableSpec()).toEqual([
{ columnId: 'col1', fields: ['Test 1'] },
{ columnId: 'col2', fields: ['Test 2'] },
]);
});
});
describe('getOperationForColumnId', () => {
it('should get an operation for col1', () => {
expect(publicAPI.getOperationForColumnId('col1')).toEqual({
label: 'Test 1',
dataType: 'number',
isBucketed: false,
hasTimeShift: false,
});
});
it('should return null for non-existant columns', () => {
expect(publicAPI.getOperationForColumnId('col2')).toBe(null);
});
});
describe('getSourceId', () => {
it('should basically return the datasource internal id', () => {
expect(publicAPI.getSourceId()).toEqual('foo');
});
});
});
});

View file

@ -0,0 +1,587 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from 'react-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { AggregateQuery } from '@kbn/es-query';
import type { SavedObjectReference } from '@kbn/core/public';
import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import {
DatasourceDimensionEditorProps,
DatasourceDataPanelProps,
DatasourceLayerPanelProps,
PublicAPIProps,
DataType,
TableChangeType,
DatasourceDimensionTriggerProps,
} from '../types';
import { generateId } from '../id_generator';
import { toExpression } from './to_expression';
import { TextBasedLanguagesDataPanel } from './datapanel';
import type {
TextBasedLanguagesPrivateState,
TextBasedLanguagesPersistedState,
TextBasedLanguagesLayerColumn,
TextBasedLanguageField,
} from './types';
import { FieldSelect } from './field_select';
import { Datasource } from '../types';
import { LayerPanel } from './layerpanel';
function getLayerReferenceName(layerId: string) {
return `textBasedLanguages-datasource-layer-${layerId}`;
}
export function getTextBasedLanguagesDatasource({
core,
storage,
data,
expressions,
dataViews,
}: {
core: CoreStart;
storage: IStorageWrapper;
data: DataPublicPluginStart;
expressions: ExpressionsStart;
dataViews: DataViewsPublicPluginStart;
}) {
const getSuggestionsForState = (state: TextBasedLanguagesPrivateState) => {
return Object.entries(state.layers)?.map(([id, layer]) => {
return {
state: {
...state,
},
table: {
changeType: 'unchanged' as TableChangeType,
isMultiRow: false,
layerId: id,
columns:
layer.columns?.map((f) => {
return {
columnId: f.columnId,
operation: {
dataType: f?.meta?.type as DataType,
label: f.fieldName,
isBucketed: Boolean(f?.meta?.type !== 'number'),
},
};
}) ?? [],
},
keptLayerIds: [id],
};
});
};
const TextBasedLanguagesDatasource: Datasource<
TextBasedLanguagesPrivateState,
TextBasedLanguagesPersistedState
> = {
id: 'textBasedLanguages',
checkIntegrity: () => {
return [];
},
getErrorMessages: (state) => {
const errors: Error[] = [];
Object.values(state.layers).forEach((layer) => {
if (layer.errors && layer.errors.length > 0) {
errors.push(...layer.errors);
}
});
return errors.map((err) => {
return {
shortMessage: err.message,
longMessage: err.message,
};
});
},
getUnifiedSearchErrors: (state) => {
const errors: Error[] = [];
Object.values(state.layers).forEach((layer) => {
if (layer.errors && layer.errors.length > 0) {
errors.push(...layer.errors);
}
});
return errors;
},
initialize(
state?: TextBasedLanguagesPersistedState,
savedObjectReferences?,
context?,
indexPatternRefs?,
indexPatterns?
) {
const patterns = indexPatterns ? Object.values(indexPatterns) : [];
const refs = patterns.map((p) => {
return {
id: p.id,
title: p.title,
timeField: p.timeFieldName,
};
});
const initState = state || { layers: {} };
return {
...initState,
fieldList: [],
indexPatternRefs: refs,
};
},
onRefreshIndexPattern() {},
getUsedDataViews: (state) => {
return Object.values(state.layers).map(({ index }) => index);
},
getPersistableState({ layers }: TextBasedLanguagesPrivateState) {
const savedObjectReferences: SavedObjectReference[] = [];
Object.entries(layers).forEach(([layerId, { index, ...persistableLayer }]) => {
if (index) {
savedObjectReferences.push({
type: 'index-pattern',
id: index,
name: getLayerReferenceName(layerId),
});
}
});
return { state: { layers }, savedObjectReferences };
},
isValidColumn(state, indexPatterns, layerId, columnId) {
const layer = state.layers[layerId];
const column = layer.columns.find((c) => c.columnId === columnId);
const indexPattern = indexPatterns[layer.index];
if (!column || !indexPattern) return false;
return true;
},
insertLayer(state: TextBasedLanguagesPrivateState, newLayerId: string) {
const layer = Object.values(state?.layers)?.[0];
const query = layer?.query;
const columns = layer?.allColumns ?? [];
const index =
layer?.index ??
(JSON.parse(localStorage.getItem('lens-settings') || '{}').indexPatternId ||
state.indexPatternRefs[0].id);
return {
...state,
layers: {
...state.layers,
[newLayerId]: blankLayer(index, query, columns),
},
};
},
createEmptyLayer() {
return {
indexPatternRefs: [],
layers: {},
fieldList: [],
};
},
cloneLayer(state, layerId, newLayerId, getNewId) {
return {
...state,
};
},
removeLayer(state: TextBasedLanguagesPrivateState, layerId: string) {
const newLayers = {
...state.layers,
[layerId]: {
...state.layers[layerId],
columns: [],
},
};
return {
...state,
layers: newLayers,
fieldList: state.fieldList,
};
},
clearLayer(state: TextBasedLanguagesPrivateState, layerId: string) {
return {
...state,
layers: {
...state.layers,
[layerId]: { ...state.layers[layerId], columns: [] },
},
};
},
getLayers(state: TextBasedLanguagesPrivateState) {
return state && state.layers ? Object.keys(state?.layers) : [];
},
getCurrentIndexPatternId(state: TextBasedLanguagesPrivateState) {
const layers = Object.values(state.layers);
return layers?.[0]?.index;
},
isTimeBased: (state, indexPatterns) => {
if (!state) return false;
const { layers } = state;
return (
Boolean(layers) &&
Object.values(layers).some((layer) => {
return Boolean(indexPatterns[layer.index]?.timeFieldName);
})
);
},
getUsedDataView: (state: TextBasedLanguagesPrivateState, layerId: string) => {
return state.layers[layerId].index;
},
removeColumn({ prevState, layerId, columnId }) {
return {
...prevState,
layers: {
...prevState.layers,
[layerId]: {
...prevState.layers[layerId],
columns: prevState.layers[layerId].columns.filter((col) => col.columnId !== columnId),
},
},
};
},
toExpression: (state, layerId, indexPatterns) => {
return toExpression(state, layerId);
},
renderDataPanel(
domElement: Element,
props: DatasourceDataPanelProps<TextBasedLanguagesPrivateState>
) {
render(
<I18nProvider>
<TextBasedLanguagesDataPanel
data={data}
dataViews={dataViews}
expressions={expressions}
{...props}
/>
</I18nProvider>,
domElement
);
},
renderDimensionTrigger: (
domElement: Element,
props: DatasourceDimensionTriggerProps<TextBasedLanguagesPrivateState>
) => {
const columnLabelMap = TextBasedLanguagesDatasource.uniqueLabels(props.state);
const layer = props.state.layers[props.layerId];
const selectedField = layer?.allColumns?.find((column) => column.columnId === props.columnId);
let customLabel: string | undefined = columnLabelMap[props.columnId];
if (!customLabel) {
customLabel = selectedField?.fieldName;
}
const columnExists = props.state.fieldList.some((f) => f.name === selectedField?.fieldName);
render(
<EuiButtonEmpty color={columnExists ? 'primary' : 'danger'} onClick={() => {}}>
{customLabel ??
i18n.translate('xpack.lens.textBasedLanguages.missingField', {
defaultMessage: 'Missing field',
})}
</EuiButtonEmpty>,
domElement
);
},
getRenderEventCounters(state: TextBasedLanguagesPrivateState): string[] {
return [];
},
renderDimensionEditor: (
domElement: Element,
props: DatasourceDimensionEditorProps<TextBasedLanguagesPrivateState>
) => {
const fields = props.state.fieldList;
const selectedField = props.state.layers[props.layerId]?.allColumns?.find(
(column) => column.columnId === props.columnId
);
render(
<EuiFormRow
data-test-subj="text-based-languages-field-selection-row"
label={i18n.translate('xpack.lens.textBasedLanguages.chooseField', {
defaultMessage: 'Field',
})}
fullWidth
className="lnsIndexPatternDimensionEditor--padded"
>
<FieldSelect
existingFields={fields}
selectedField={selectedField}
onChoose={(choice) => {
const meta = fields.find((f) => f.name === choice.field)?.meta;
const newColumn = {
columnId: props.columnId,
fieldName: choice.field,
meta,
};
return props.setState(
!selectedField
? {
...props.state,
layers: {
...props.state.layers,
[props.layerId]: {
...props.state.layers[props.layerId],
columns: [...props.state.layers[props.layerId].columns, newColumn],
allColumns: [...props.state.layers[props.layerId].allColumns, newColumn],
},
},
}
: {
...props.state,
layers: {
...props.state.layers,
[props.layerId]: {
...props.state.layers[props.layerId],
columns: props.state.layers[props.layerId].columns.map((col) =>
col.columnId !== props.columnId
? col
: { ...col, fieldName: choice.field }
),
allColumns: props.state.layers[props.layerId].allColumns.map((col) =>
col.columnId !== props.columnId
? col
: { ...col, fieldName: choice.field }
),
},
},
}
);
}}
/>
</EuiFormRow>,
domElement
);
},
renderLayerPanel: (
domElement: Element,
props: DatasourceLayerPanelProps<TextBasedLanguagesPrivateState>
) => {
render(
<I18nProvider>
<LayerPanel {...props} />
</I18nProvider>,
domElement
);
},
uniqueLabels(state: TextBasedLanguagesPrivateState) {
const layers = state.layers;
const columnLabelMap = {} as Record<string, string>;
const counts = {} as Record<string, number>;
const makeUnique = (label: string) => {
let uniqueLabel = label;
while (counts[uniqueLabel] >= 0) {
const num = ++counts[uniqueLabel];
uniqueLabel = i18n.translate('xpack.lens.indexPattern.uniqueLabel', {
defaultMessage: '{label} [{num}]',
values: { label, num },
});
}
counts[uniqueLabel] = 0;
return uniqueLabel;
};
Object.values(layers).forEach((layer) => {
if (!layer.columns) {
return;
}
Object.values(layer.columns).forEach((column) => {
columnLabelMap[column.columnId] = makeUnique(column.fieldName);
});
});
return columnLabelMap;
},
getDropProps: (props) => {
const { source } = props;
if (!source) {
return;
}
const label = source.field as string;
return { dropTypes: ['field_add'], nextLabel: label };
},
onDrop: (props) => {
const { dropType, state, source, target } = props;
const { layers } = state;
if (dropType === 'field_add') {
Object.keys(layers).forEach((layerId) => {
const currentLayer = layers[layerId];
const field = currentLayer.allColumns.find((f) => f.columnId === source.id);
const newColumn = {
columnId: target.columnId,
fieldName: field?.fieldName ?? '',
meta: field?.meta,
};
const columns = currentLayer.columns.filter((c) => c.columnId !== target.columnId);
columns.push(newColumn);
const allColumns = currentLayer.allColumns.filter((c) => c.columnId !== target.columnId);
allColumns.push(newColumn);
props.setState({
...props.state,
layers: {
...props.state.layers,
[layerId]: {
...props.state.layers[layerId],
columns,
allColumns,
},
},
});
});
return true;
}
return false;
},
getPublicAPI({ state, layerId }: PublicAPIProps<TextBasedLanguagesPrivateState>) {
return {
datasourceId: 'textBasedLanguages',
getTableSpec: () => {
return (
state.layers[layerId]?.columns?.map((column) => ({
columnId: column.columnId,
fields: [column.fieldName],
})) || []
);
},
getOperationForColumnId: (columnId: string) => {
const layer = state.layers[layerId];
const column = layer?.allColumns?.find((c) => c.columnId === columnId);
const columnLabelMap = TextBasedLanguagesDatasource.uniqueLabels(state);
if (column) {
return {
dataType: column?.meta?.type as DataType,
label: columnLabelMap[columnId] ?? column?.fieldName,
isBucketed: Boolean(column?.meta?.type !== 'number'),
hasTimeShift: false,
};
}
return null;
},
getVisualDefaults: () => ({}),
isTextBasedLanguage: () => true,
getMaxPossibleNumValues: (columnId) => {
return null;
},
getSourceId: () => {
const layer = state.layers[layerId];
return layer.index;
},
getFilters: () => {
return {
enabled: {
kuery: [],
lucene: [],
},
disabled: {
kuery: [],
lucene: [],
},
};
},
};
},
getDatasourceSuggestionsForField(state, draggedField) {
const field = state.fieldList.find(
(f) => f.id === (draggedField as TextBasedLanguageField).id
);
if (!field) return [];
return Object.entries(state.layers)?.map(([id, layer]) => {
const newId = generateId();
const newColumn = {
columnId: newId,
fieldName: field?.name ?? '',
meta: field?.meta,
};
return {
state: {
...state,
layers: {
...state.layers,
[id]: {
...state.layers[id],
columns: [...layer.columns, newColumn],
allColumns: [...layer.allColumns, newColumn],
},
},
},
table: {
changeType: 'initial' as TableChangeType,
isMultiRow: false,
layerId: id,
columns: [
...layer.columns?.map((f) => {
return {
columnId: f.columnId,
operation: {
dataType: f?.meta?.type as DataType,
label: f.fieldName,
isBucketed: Boolean(f?.meta?.type !== 'number'),
},
};
}),
{
columnId: newId,
operation: {
dataType: field?.meta?.type as DataType,
label: field?.name ?? '',
isBucketed: Boolean(field?.meta?.type !== 'number'),
},
},
],
},
keptLayerIds: [id],
};
});
return [];
},
getDatasourceSuggestionsForVisualizeField: getSuggestionsForState,
getDatasourceSuggestionsFromCurrentState: getSuggestionsForState,
getDatasourceSuggestionsForVisualizeCharts: getSuggestionsForState,
isEqual: () => true,
};
return TextBasedLanguagesDatasource;
}
function blankLayer(
index: string,
query?: AggregateQuery,
columns?: TextBasedLanguagesLayerColumn[]
) {
return {
index,
query,
columns: [],
allColumns: columns ?? [],
};
}

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Ast } from '@kbn/interpreter';
import { textBasedQueryStateToExpressionAst } from '@kbn/data-plugin/common';
import type { OriginalColumn } from '../../common/types';
import { TextBasedLanguagesPrivateState, TextBasedLanguagesLayer, IndexPatternRef } from './types';
function getExpressionForLayer(
layer: TextBasedLanguagesLayer,
refs: IndexPatternRef[]
): Ast | null {
if (!layer.columns || layer.columns?.length === 0) {
return null;
}
let idMapper: Record<string, OriginalColumn[]> = {};
layer.columns.forEach((col) => {
if (idMapper[col.fieldName]) {
idMapper[col.fieldName].push({
id: col.columnId,
label: col.fieldName,
} as OriginalColumn);
} else {
idMapper = {
...idMapper,
[col.fieldName]: [
{
id: col.columnId,
label: col.fieldName,
} as OriginalColumn,
],
};
}
});
const timeFieldName = refs.find((r) => r.id === layer.index)?.timeField;
const textBasedQueryToAst = textBasedQueryStateToExpressionAst({
query: layer.query,
timeFieldName,
});
textBasedQueryToAst.chain.push({
type: 'function',
function: 'lens_map_to_columns',
arguments: {
idMap: [JSON.stringify(idMapper)],
},
});
return textBasedQueryToAst;
}
export function toExpression(state: TextBasedLanguagesPrivateState, layerId: string) {
if (state.layers[layerId]) {
return getExpressionForLayer(state.layers[layerId], state.indexPatternRefs);
}
return null;
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import type { AggregateQuery } from '@kbn/es-query';
export interface TextBasedLanguagesLayerColumn {
columnId: string;
fieldName: string;
meta?: DatatableColumn['meta'];
}
export interface TextBasedLanguageField {
id: string;
field: string;
}
export interface TextBasedLanguagesLayer {
index: string;
query: AggregateQuery | undefined;
columns: TextBasedLanguagesLayerColumn[];
allColumns: TextBasedLanguagesLayerColumn[];
timeField?: string;
errors?: Error[];
}
export interface TextBasedLanguagesPersistedState {
layers: Record<string, TextBasedLanguagesLayer>;
}
export type TextBasedLanguagesPrivateState = TextBasedLanguagesPersistedState & {
indexPatternRefs: IndexPatternRef[];
fieldList: DatatableColumn[];
};
export interface IndexPatternRef {
id: string;
title: string;
timeField?: string;
}

View file

@ -0,0 +1,193 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { mockDataViewsService } from '../data_views_service/mocks';
import {
getIndexPatternFromTextBasedQuery,
loadIndexPatternRefs,
getStateFromAggregateQuery,
} from './utils';
import { type AggregateQuery } from '@kbn/es-query';
jest.mock('./fetch_data_from_aggregate_query', () => ({
fetchDataFromAggregateQuery: jest.fn(() => {
return {
columns: [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
],
};
}),
}));
describe('Text based languages utils', () => {
describe('getIndexPatternFromTextBasedQuery', () => {
it('should return the index pattern for sql query', () => {
const indexPattern = getIndexPatternFromTextBasedQuery({
sql: 'SELECT bytes, memory from foo',
});
expect(indexPattern).toBe('foo');
});
it('should return empty index pattern for non sql query', () => {
const indexPattern = getIndexPatternFromTextBasedQuery({
lang1: 'SELECT bytes, memory from foo',
} as unknown as AggregateQuery);
expect(indexPattern).toBe('');
});
});
describe('loadIndexPatternRefs', () => {
it('should return a list of sorted indexpattern refs', async () => {
const refs = await loadIndexPatternRefs(mockDataViewsService() as DataViewsPublicPluginStart);
expect(refs[0].title < refs[1].title).toBeTruthy();
});
});
describe('getStateFromAggregateQuery', () => {
it('should return the correct state', async () => {
const state = {
layers: {
first: {
allColumns: [],
columns: [],
query: undefined,
index: '',
},
},
};
const dataViewsMock = dataViewPluginMocks.createStartContract();
const dataMock = dataPluginMock.createStartContract();
const expressionsMock = expressionsPluginMock.createStartContract();
const updatedState = await getStateFromAggregateQuery(
state,
{ sql: 'SELECT * FROM my-fake-index-pattern' },
{
...dataViewsMock,
getIdsWithTitle: jest.fn().mockReturnValue(
Promise.resolve([
{ id: '1', title: 'my-fake-index-pattern' },
{ id: '2', title: 'my-fake-restricted-pattern' },
{ id: '3', title: 'my-compatible-pattern' },
])
),
get: jest.fn().mockReturnValue(
Promise.resolve({
id: '1',
title: 'my-fake-index-pattern',
timeFieldName: 'timeField',
})
),
},
dataMock,
expressionsMock
);
expect(updatedState).toStrictEqual({
fieldList: [
{
name: 'timestamp',
id: 'timestamp',
meta: {
type: 'date',
},
},
{
name: 'bytes',
id: 'bytes',
meta: {
type: 'number',
},
},
{
name: 'memory',
id: 'memory',
meta: {
type: 'number',
},
},
],
indexPatternRefs: [
{
id: '3',
timeField: 'timeField',
title: 'my-compatible-pattern',
},
{
id: '1',
timeField: 'timeField',
title: 'my-fake-index-pattern',
},
{
id: '2',
timeField: 'timeField',
title: 'my-fake-restricted-pattern',
},
],
layers: {
first: {
allColumns: [
{
fieldName: 'timestamp',
columnId: 'timestamp',
meta: {
type: 'date',
},
},
{
fieldName: 'bytes',
columnId: 'bytes',
meta: {
type: 'number',
},
},
{
fieldName: 'memory',
columnId: 'memory',
meta: {
type: 'number',
},
},
],
columns: [],
errors: [],
index: '1',
query: {
sql: 'SELECT * FROM my-fake-index-pattern',
},
timeField: 'timeField',
},
},
});
});
});
});

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { type AggregateQuery, getIndexPatternFromSQLQuery } from '@kbn/es-query';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { generateId } from '../id_generator';
import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query';
import type {
IndexPatternRef,
TextBasedLanguagesPersistedState,
TextBasedLanguagesLayerColumn,
} from './types';
export async function loadIndexPatternRefs(
indexPatternsService: DataViewsPublicPluginStart
): Promise<IndexPatternRef[]> {
const indexPatterns = await indexPatternsService.getIdsWithTitle();
const timefields = await Promise.all(
indexPatterns.map((p) => indexPatternsService.get(p.id).then((pat) => pat.timeFieldName))
);
return indexPatterns
.map((p, i) => ({ ...p, timeField: timefields[i] }))
.sort((a, b) => {
return a.title.localeCompare(b.title);
});
}
export async function getStateFromAggregateQuery(
state: TextBasedLanguagesPersistedState,
query: AggregateQuery,
dataViews: DataViewsPublicPluginStart,
data: DataPublicPluginStart,
expressions: ExpressionsStart
) {
const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(dataViews);
const errors: Error[] = [];
const layerIds = Object.keys(state.layers);
const newLayerId = layerIds.length > 0 ? layerIds[0] : generateId();
// fetch the pattern from the query
const indexPattern = getIndexPatternFromTextBasedQuery(query);
// get the id of the dataview
const index = indexPatternRefs.find((r) => r.title === indexPattern)?.id ?? '';
let columnsFromQuery: DatatableColumn[] = [];
let columns: TextBasedLanguagesLayerColumn[] = [];
let timeFieldName;
try {
const table = await fetchDataFromAggregateQuery(query, dataViews, data, expressions);
const dataView = await dataViews.get(index);
timeFieldName = dataView.timeFieldName;
columnsFromQuery = table?.columns ?? [];
const existingColumns = state.layers[newLayerId].allColumns;
columns = [
...existingColumns,
...columnsFromQuery.map((c) => ({ columnId: c.id, fieldName: c.id, meta: c.meta })),
];
} catch (e) {
errors.push(e);
}
const tempState = {
layers: {
[newLayerId]: {
index,
query,
columns: state.layers[newLayerId].columns ?? [],
allColumns: columns,
timeField: timeFieldName,
errors,
},
},
};
return {
...tempState,
fieldList: columnsFromQuery ?? [],
indexPatternRefs,
};
}
export function getIndexPatternFromTextBasedQuery(query: AggregateQuery): string {
let indexPattern = '';
// sql queries
if ('sql' in query) {
indexPattern = getIndexPatternFromSQLQuery(query.sql);
}
// other textbased queries....
return indexPattern;
}

View file

@ -259,8 +259,10 @@ export interface Datasource<T = unknown, P = unknown> {
// Given the current state, which parts should be saved?
getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
getCurrentIndexPatternId: (state: T) => string;
getUnifiedSearchErrors?: (state: T) => Error[];
insertLayer: (state: T, newLayerId: string) => T;
createEmptyLayer: (indexPatternId: string) => T;
removeLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string) => T;
cloneLayer: (
@ -466,6 +468,10 @@ export interface DatasourcePublicAPI {
* Retrieve the specific source id for the current state
*/
getSourceId: () => string | undefined;
/**
* Returns true if this is a text based language datasource
*/
isTextBasedLanguage: () => boolean;
/**
* Collect all defined filters from all the operations in the layer. If it returns undefined, this means that filters can't be constructed for the current layer
*/

View file

@ -277,12 +277,12 @@ export const getDatatableVisualization = ({
.filter((c) => !datasource!.getOperationForColumnId(c)?.isBucketed)
.map((accessor) => {
const columnConfig = columnMap[accessor];
const stops = columnConfig.palette?.params?.stops;
const hasColoring = Boolean(columnConfig.colorMode !== 'none' && stops);
const stops = columnConfig?.palette?.params?.stops;
const hasColoring = Boolean(columnConfig?.colorMode !== 'none' && stops);
return {
columnId: accessor,
triggerIcon: columnConfig.hidden
triggerIcon: columnConfig?.hidden
? 'invisible'
: hasColoring
? 'colorBy'

View file

@ -17119,7 +17119,6 @@
"xpack.lens.configure.invalidReferenceLineDimension": "La ligne de référence est affectée à un axe qui nexiste plus. Vous pouvez déplacer cette ligne de référence vers un autre axe disponible ou la supprimer.",
"xpack.lens.confirmModal.cancelButtonLabel": "Annuler",
"xpack.lens.customBucketContainer.dragToReorder": "Faire glisser pour réorganiser",
"xpack.lens.dataPanelWrapper.switchDatasource": "Basculer vers la source de données",
"xpack.lens.datatable.addLayer": "Visualisation",
"xpack.lens.datatable.breakdownColumns": "Colonnes",
"xpack.lens.datatable.breakdownColumns.description": "Divisez les colonnes d'indicateurs par champ. Il est recommandé de conserver un faible nombre de colonnes pour éviter le défilement horizontal.",

View file

@ -17102,7 +17102,6 @@
"xpack.lens.configure.invalidReferenceLineDimension": "この基準線は存在しない軸に割り当てられています。この基準線を別の使用可能な軸に移動するか、削除することができます。",
"xpack.lens.confirmModal.cancelButtonLabel": "キャンセル",
"xpack.lens.customBucketContainer.dragToReorder": "ドラッグして並べ替え",
"xpack.lens.dataPanelWrapper.switchDatasource": "データソースに切り替える",
"xpack.lens.datatable.addLayer": "ビジュアライゼーション",
"xpack.lens.datatable.breakdownColumns": "列",
"xpack.lens.datatable.breakdownColumns.description": "フィールドでメトリックを列に分割します。列数を少なくし、横スクロールを避けることをお勧めします。",

View file

@ -17124,7 +17124,6 @@
"xpack.lens.configure.invalidReferenceLineDimension": "此参考线分配给了不再存在的轴。您可以将此参考线移到其他可用的轴,或将其移除。",
"xpack.lens.confirmModal.cancelButtonLabel": "取消",
"xpack.lens.customBucketContainer.dragToReorder": "拖动以重新排序",
"xpack.lens.dataPanelWrapper.switchDatasource": "切换到数据源",
"xpack.lens.datatable.addLayer": "可视化",
"xpack.lens.datatable.breakdownColumns": "列",
"xpack.lens.datatable.breakdownColumns.description": "按字段拆分指标列。建议减少列数目以避免水平滚动。",

View file

@ -77,6 +77,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext
loadTestFile(require.resolve('./persistent_context'));
loadTestFile(require.resolve('./table_dashboard'));
loadTestFile(require.resolve('./table'));
loadTestFile(require.resolve('./text_based_languages'));
}
});
};

View file

@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DebugState } from '@elastic/charts';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects([
'visualize',
'lens',
'header',
'unifiedSearch',
'dashboard',
'common',
]);
const elasticChart = getService('elasticChart');
const queryBar = getService('queryBar');
const testSubjects = getService('testSubjects');
const kibanaServer = getService('kibanaServer');
const monacoEditor = getService('monacoEditor');
function assertMatchesExpectedData(state: DebugState) {
expect(state.axes?.x![0].labels.sort()).to.eql(['css', 'gif', 'jpg', 'php', 'png']);
}
const defaultSettings = {
'discover:enableSql': true,
};
async function switchToTextBasedLanguage(language: string) {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await elasticChart.setNewChartUiDebugFlag(true);
await PageObjects.lens.switchToTextBasedLanguage(language);
await PageObjects.header.waitUntilLoadingHasFinished();
}
describe('lens text based language tests', () => {
before(async () => {
await kibanaServer.uiSettings.replace(defaultSettings);
});
it('should navigate to text based languages mode correctly', async () => {
await switchToTextBasedLanguage('SQL');
expect(await testSubjects.exists('showQueryBarMenu')).to.be(false);
expect(await testSubjects.exists('addFilter')).to.be(false);
const textBasedQuery = await monacoEditor.getCodeEditorValue();
expect(textBasedQuery).to.be('SELECT * FROM "log*"');
});
it('should allow adding and using a field', async () => {
await monacoEditor.setCodeEditorValue(
'SELECT extension, AVG("bytes") as average FROM "logstash-*" GROUP BY extension'
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.switchToVisualization('lnsMetric');
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsMetric_primaryMetricDimensionPanel > lns-empty-dimension',
field: 'average',
});
await PageObjects.lens.waitForVisualization('mtrVis');
const metricData = await PageObjects.lens.getMetricVisualizationData();
expect(metricData[0].title).to.eql('average');
});
it('should allow switching to another chart', async () => {
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.switchToVisualization('bar');
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
field: 'extension',
});
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
field: 'average',
});
await PageObjects.lens.waitForVisualization('xyVisChart');
const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart');
assertMatchesExpectedData(data!);
});
it('should allow adding an text based languages chart to a dashboard', async () => {
await PageObjects.lens.switchToVisualization('lnsMetric');
await PageObjects.lens.waitForVisualization('mtrVis');
await PageObjects.lens.removeDimension('lnsMetric_breakdownByDimensionPanel');
await PageObjects.lens.waitForVisualization('mtrVis');
const metricData = await PageObjects.lens.getMetricVisualizationData();
expect(metricData[0].value).to.eql('5.7K');
expect(metricData[0].title).to.eql('average');
await PageObjects.lens.save('New text based languages viz', false, false, false, 'new');
await PageObjects.dashboard.waitForRenderComplete();
expect(metricData[0].value).to.eql('5.7K');
const panelCount = await PageObjects.dashboard.getPanelCount();
expect(panelCount).to.eql(1);
});
it('should allow saving the text based languages chart into a saved object', async () => {
await switchToTextBasedLanguage('SQL');
await monacoEditor.setCodeEditorValue(
'SELECT extension, AVG("bytes") as average FROM "logstash-*" GROUP BY extension'
);
await testSubjects.click('querySubmitButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension',
field: 'extension',
});
await PageObjects.lens.configureTextBasedLanguagesDimension({
dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension',
field: 'average',
});
await PageObjects.lens.waitForVisualization('xyVisChart');
await PageObjects.lens.save('Lens with text based language');
await PageObjects.lens.waitForVisualization('xyVisChart');
const data = await PageObjects.lens.getCurrentChartDebugState('xyVisChart');
assertMatchesExpectedData(data!);
});
it('should allow to return to the dataview mode', async () => {
await PageObjects.lens.switchDataPanelIndexPattern('logstash-*', true);
expect(await testSubjects.exists('addFilter')).to.be(true);
expect(await queryBar.getQueryString()).to.be('');
});
});
}

View file

@ -880,8 +880,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
/**
* Changes the index pattern in the data panel
*/
async switchDataPanelIndexPattern(dataViewTitle: string) {
await PageObjects.unifiedSearch.switchDataView('lns-dataView-switch-link', dataViewTitle);
async switchDataPanelIndexPattern(
dataViewTitle: string,
transitionFromTextBasedLanguages?: boolean
) {
await PageObjects.unifiedSearch.switchDataView(
'lns-dataView-switch-link',
dataViewTitle,
transitionFromTextBasedLanguages
);
await PageObjects.header.waitUntilLoadingHasFinished();
},
@ -1297,6 +1304,35 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await PageObjects.unifiedSearch.createNewDataView(name, true);
},
async switchToTextBasedLanguage(language: string) {
await testSubjects.click('lns-dataView-switch-link');
await PageObjects.unifiedSearch.selectTextBasedLanguage(language);
},
async configureTextBasedLanguagesDimension(opts: {
dimension: string;
field: string;
keepOpen?: boolean;
palette?: string;
}) {
await retry.try(async () => {
if (!(await testSubjects.exists('lns-indexPattern-dimensionContainerClose'))) {
await testSubjects.click(opts.dimension);
}
await testSubjects.existOrFail('lns-indexPattern-dimensionContainerClose');
});
await this.selectOptionFromComboBox('text-based-dimension-field', opts.field);
if (opts.palette) {
await this.setPalette(opts.palette);
}
if (!opts.keepOpen) {
await this.closeDimensionEditor();
}
},
/** resets visualization/layer or removes a layer */
async removeLayer(index: number = 0) {
await retry.try(async () => {