mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
3edba25c2d
commit
4c3cd034aa
69 changed files with 3321 additions and 276 deletions
|
@ -21,7 +21,8 @@ export {
|
|||
getTime,
|
||||
isQuery,
|
||||
isTimeRange,
|
||||
queryStateToExpressionAst,
|
||||
textBasedQueryStateToAstWithValidation,
|
||||
textBasedQueryStateToExpressionAst,
|
||||
} from './query';
|
||||
export type { QueryState } from './query';
|
||||
export * from './search';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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">` +
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}"]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { mapToColumns } from './map_to_columns';
|
||||
export type { OriginalColumn } from './types';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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' },
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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) && (
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
@ -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' }]],
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -119,6 +119,7 @@ export interface LensTopNavMenuProps {
|
|||
currentDoc: Document | undefined;
|
||||
theme$: Observable<CoreTheme>;
|
||||
indexPatternService: IndexPatternServiceAPI;
|
||||
onTextBasedSavedAndExit: ({ onSave }: { onSave: () => void }) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface HistoryLocationState {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}`,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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[] = [];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -15,6 +15,7 @@ import { IndexPatternRef } from '../../types';
|
|||
export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & {
|
||||
label: string;
|
||||
title?: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function ChangeIndexPattern({
|
||||
|
|
|
@ -34,6 +34,7 @@ export const {
|
|||
rollbackSuggestion,
|
||||
submitSuggestion,
|
||||
switchDatasource,
|
||||
switchAndCleanDatasource,
|
||||
updateIndexPatterns,
|
||||
setToggleFullscreen,
|
||||
initEmpty,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
};
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -17119,7 +17119,6 @@
|
|||
"xpack.lens.configure.invalidReferenceLineDimension": "La ligne de référence est affectée à un axe qui n’existe 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.",
|
||||
|
|
|
@ -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": "フィールドでメトリックを列に分割します。列数を少なくし、横スクロールを避けることをお勧めします。",
|
||||
|
|
|
@ -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": "按字段拆分指标列。建议减少列数目以避免水平滚动。",
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
139
x-pack/test/functional/apps/lens/group1/text_based_languages.ts
Normal file
139
x-pack/test/functional/apps/lens/group1/text_based_languages.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue