mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ES|QL] Render a Discover-like table in the assistant instead of a Lens chart (#184106)
## Summary This PR does 2 things: - Creates a new plugin that is a wrapper of the unified datatable and is only for rendering as a table ES|QL results. The UnifiedDatatable package is good but the consumers need to know all the properties to understand how to use it and the necessity of displaying in a table the results of an ES|QL query comes a lot lately. This plugin has only 3 required properties (rows, columns, query) which make it very easy for the consumers to use it. It also integrates the Row Viewer flyout - It changes the implementation of the obs ai assistant to render a Discover like table instead of a Lens table. The Discover-like table is much better on rendering a table with thousands of columns and is going to be much more helpful for our users. The same plugin can be used later for the inline ediitng flyout too in a dashboard if we want to also display the results of an ES|QL query. Some screenshots of the new possibilities in the assistant: - I can see the results of an ES|QL query in a visualization  - I can render my results as a Document view <img width="880" alt="image" src="e8034e10
-325d-4d9e-b8a5-34d01b0dbd9d"> <img width="1095" alt="image" src="c8236e65
-96aa-4fcb-b7c3-835e2a5665bd"> <img width="955" alt="image" src="78b1d664
-6863-42bf-a337-659143b7683d"> ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
eaed27c14b
commit
5860259222
30 changed files with 1066 additions and 105 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -411,6 +411,7 @@ examples/eso_model_version_example @elastic/kibana-security
|
|||
x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security
|
||||
packages/kbn-esql-ast @elastic/kibana-esql
|
||||
examples/esql_ast_inspector @elastic/kibana-esql
|
||||
src/plugins/esql_datagrid @elastic/kibana-esql
|
||||
packages/kbn-esql-utils @elastic/kibana-esql
|
||||
packages/kbn-esql-validation-autocomplete @elastic/kibana-esql
|
||||
examples/esql_validation_example @elastic/kibana-esql
|
||||
|
|
|
@ -116,6 +116,7 @@
|
|||
"coloring": "packages/kbn-coloring/src",
|
||||
"languageDocumentationPopover": "packages/kbn-language-documentation-popover/src",
|
||||
"textBasedLanguages": "src/plugins/text_based_languages",
|
||||
"esqlDataGrid": "src/plugins/esql_datagrid",
|
||||
"statusPage": "src/legacy/core_plugins/status_page",
|
||||
"telemetry": ["src/plugins/telemetry", "src/plugins/telemetry_management_section"],
|
||||
"timelion": ["src/plugins/vis_types/timelion"],
|
||||
|
|
|
@ -102,6 +102,10 @@ This API doesn't support angular, for registering angular dev tools, bootstrap a
|
|||
|Embeddables are React components that manage their own state, can be serialized and deserialized, and return an API that can be used to interact with them imperatively.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/esql_datagrid/README.md[esqlDataGrid]
|
||||
|Contains a Discover-like table specifically for ES|QL queries:
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/src/plugins/es_ui_shared/README.md[esUiShared]
|
||||
|This plugin contains reusable code in the form of self-contained modules (or libraries). Each of these modules exports a set of functionality relevant to the domain of the module.
|
||||
|
||||
|
|
|
@ -457,6 +457,7 @@
|
|||
"@kbn/eso-plugin": "link:x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin",
|
||||
"@kbn/esql-ast": "link:packages/kbn-esql-ast",
|
||||
"@kbn/esql-ast-inspector-plugin": "link:examples/esql_ast_inspector",
|
||||
"@kbn/esql-datagrid": "link:src/plugins/esql_datagrid",
|
||||
"@kbn/esql-utils": "link:packages/kbn-esql-utils",
|
||||
"@kbn/esql-validation-autocomplete": "link:packages/kbn-esql-validation-autocomplete",
|
||||
"@kbn/esql-validation-example-plugin": "link:examples/esql_validation_example",
|
||||
|
|
|
@ -42,6 +42,7 @@ pageLoadAssetSize:
|
|||
embeddable: 87309
|
||||
embeddableEnhanced: 22107
|
||||
enterpriseSearch: 50858
|
||||
esqlDataGrid: 24598
|
||||
esUiShared: 326654
|
||||
eventAnnotation: 30000
|
||||
eventAnnotationListing: 25841
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
export { UnifiedDataTable, DataLoadingState } from './src/components/data_table';
|
||||
export type { UnifiedDataTableProps } from './src/components/data_table';
|
||||
export type { UnifiedDataTableProps, SortOrder } from './src/components/data_table';
|
||||
export {
|
||||
RowHeightSettings,
|
||||
type RowHeightSettingsProps,
|
||||
|
|
|
@ -267,7 +267,7 @@ export interface UnifiedDataTableProps {
|
|||
theme: ThemeServiceStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
uiSettings: IUiSettingsClient;
|
||||
dataViewFieldEditor: DataViewFieldEditorStart;
|
||||
dataViewFieldEditor?: DataViewFieldEditorStart;
|
||||
toastNotifications: ToastsStart;
|
||||
storage: Storage;
|
||||
data: DataPublicPluginStart;
|
||||
|
@ -611,7 +611,7 @@ export const UnifiedDataTable = ({
|
|||
useNewFieldsApi,
|
||||
shouldShowFieldHandler,
|
||||
closePopover: () => dataGridRef.current?.closeCellPopover(),
|
||||
fieldFormats: services.fieldFormats,
|
||||
fieldFormats,
|
||||
maxEntries: maxDocFieldsDisplayed,
|
||||
externalCustomRenderers,
|
||||
isPlainRecord,
|
||||
|
@ -622,7 +622,7 @@ export const UnifiedDataTable = ({
|
|||
useNewFieldsApi,
|
||||
shouldShowFieldHandler,
|
||||
maxDocFieldsDisplayed,
|
||||
services.fieldFormats,
|
||||
fieldFormats,
|
||||
externalCustomRenderers,
|
||||
isPlainRecord,
|
||||
]
|
||||
|
@ -651,18 +651,20 @@ export const UnifiedDataTable = ({
|
|||
() =>
|
||||
onFieldEdited
|
||||
? (fieldName: string) => {
|
||||
closeFieldEditor.current = services.dataViewFieldEditor.openEditor({
|
||||
ctx: {
|
||||
dataView,
|
||||
},
|
||||
fieldName,
|
||||
onSave: async () => {
|
||||
await onFieldEdited();
|
||||
},
|
||||
});
|
||||
closeFieldEditor.current =
|
||||
onFieldEdited &&
|
||||
services?.dataViewFieldEditor?.openEditor({
|
||||
ctx: {
|
||||
dataView,
|
||||
},
|
||||
fieldName,
|
||||
onSave: async () => {
|
||||
await onFieldEdited();
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
[dataView, onFieldEdited, services.dataViewFieldEditor]
|
||||
[dataView, onFieldEdited, services?.dataViewFieldEditor]
|
||||
);
|
||||
|
||||
const timeFieldName = dataView.timeFieldName;
|
||||
|
@ -756,7 +758,8 @@ export const UnifiedDataTable = ({
|
|||
uiSettings,
|
||||
toastNotifications,
|
||||
},
|
||||
hasEditDataViewPermission: () => dataViewFieldEditor.userPermissions.editIndexPattern(),
|
||||
hasEditDataViewPermission: () =>
|
||||
Boolean(dataViewFieldEditor?.userPermissions?.editIndexPattern()),
|
||||
valueToStringConverter,
|
||||
onFilter,
|
||||
editField,
|
||||
|
|
6
src/plugins/esql_datagrid/.i18nrc.json
Executable file
6
src/plugins/esql_datagrid/.i18nrc.json
Executable file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"prefix": "esqlDataGrid",
|
||||
"paths": {
|
||||
"esqlDataGrid": "."
|
||||
}
|
||||
}
|
47
src/plugins/esql_datagrid/README.md
Normal file
47
src/plugins/esql_datagrid/README.md
Normal file
|
@ -0,0 +1,47 @@
|
|||
# @kbn/esql-datagrid
|
||||
|
||||
Contains a Discover-like table specifically for ES|QL queries:
|
||||
- You have to run the esql query on your application, this is just a UI component
|
||||
- You pass the columns and rows of the _query response to the table
|
||||
- The table operates in both Document view and table view mode, define this with the `isTableView` property
|
||||
- The table offers a built in Row Viewer flyout
|
||||
- The table offers a rows comparison mode, exactly as Discover
|
||||
|
||||
---
|
||||
|
||||
### Properties
|
||||
* rows: ESQLRow[], is the array of values returned by the _query api
|
||||
* columns: DatatableColumn[], is the array of columns in a kibana compatible format. You can sue the `formatESQLColumns` helper function from the `@kbn/esql-utils` package
|
||||
* query: AggregateQuery, the ES|QL query in the format of
|
||||
```json
|
||||
{
|
||||
esql: <queryString>
|
||||
}
|
||||
```
|
||||
* flyoutType?: "overlay" | "push", defines the type of flyout for the Row Viewer
|
||||
* isTableView?: boolean, defines if the table will render as a Document Viewer or a Table View
|
||||
|
||||
|
||||
### How to use it
|
||||
```tsx
|
||||
import { getIndexPatternFromESQLQuery, getESQLAdHocDataview, formatESQLColumns } from '@kbn/esql-utils';
|
||||
import { ESQLDataGrid } from '@kbn/esql-datagrid/public';
|
||||
|
||||
/**
|
||||
Run the _query api to get the datatable with the ES|QL query you want.
|
||||
This will return a response with columns and values
|
||||
**/
|
||||
|
||||
const indexPattern = getIndexPatternFromESQLQuery(query);
|
||||
const adHocDataView = getESQLAdHocDataview(indexPattern, dataViewService);
|
||||
const formattedColumns = formatESQLColumns(columns);
|
||||
|
||||
<ESQLDataGrid
|
||||
rows={values}
|
||||
columns={formattedColumns}
|
||||
dataView={adHocDataView}
|
||||
query={{ esql: query }}
|
||||
flyoutType="overlay"
|
||||
isTableView
|
||||
/>
|
||||
```
|
19
src/plugins/esql_datagrid/jest.config.js
Normal file
19
src/plugins/esql_datagrid/jest.config.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../..',
|
||||
roots: ['<rootDir>/src/plugins/esql_datagrid'],
|
||||
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/esql_datagrid',
|
||||
coverageReporters: ['text', 'html'],
|
||||
collectCoverageFrom: [
|
||||
'<rootDir>/src/plugins/esql_datagrid/{common,public,server}/**/*.{js,ts,tsx}',
|
||||
],
|
||||
setupFiles: ['jest-canvas-mock'],
|
||||
};
|
21
src/plugins/esql_datagrid/kibana.jsonc
Normal file
21
src/plugins/esql_datagrid/kibana.jsonc
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"type": "plugin",
|
||||
"id": "@kbn/esql-datagrid",
|
||||
"owner": "@elastic/kibana-esql",
|
||||
"plugin": {
|
||||
"id": "esqlDataGrid",
|
||||
"server": false,
|
||||
"browser": true,
|
||||
"requiredPlugins": [
|
||||
"data",
|
||||
"uiActions",
|
||||
"fieldFormats"
|
||||
],
|
||||
"requiredBundles": [
|
||||
"kibanaReact",
|
||||
"kibanaUtils",
|
||||
"dataViews",
|
||||
"unifiedDocViewer"
|
||||
]
|
||||
}
|
||||
}
|
6
src/plugins/esql_datagrid/package.json
Normal file
6
src/plugins/esql_datagrid/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/esql-datagrid",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
58
src/plugins/esql_datagrid/public/create_datagrid.tsx
Normal file
58
src/plugins/esql_datagrid/public/create_datagrid.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 React, { lazy } from 'react';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import type { ESQLRow } from '@kbn/es-types';
|
||||
import type { AggregateQuery } from '@kbn/es-query';
|
||||
import { withSuspense } from '@kbn/shared-ux-utility';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
|
||||
import { CellActionsProvider } from '@kbn/cell-actions';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { untilPluginStartServicesReady } from './kibana_services';
|
||||
|
||||
interface ESQLDataGridProps {
|
||||
rows: ESQLRow[];
|
||||
dataView: DataView;
|
||||
columns: DatatableColumn[];
|
||||
query: AggregateQuery;
|
||||
flyoutType?: 'overlay' | 'push';
|
||||
isTableView?: boolean;
|
||||
}
|
||||
|
||||
const DataGridLazy = withSuspense(lazy(() => import('./data_grid')));
|
||||
|
||||
export const ESQLDataGrid = (props: ESQLDataGridProps) => {
|
||||
const { loading, value } = useAsync(() => {
|
||||
const startServicesPromise = untilPluginStartServicesReady();
|
||||
return Promise.all([startServicesPromise]);
|
||||
}, []);
|
||||
|
||||
const deps = value?.[0];
|
||||
if (loading || !deps) return <EuiLoadingSpinner />;
|
||||
|
||||
return (
|
||||
<KibanaContextProvider
|
||||
services={{
|
||||
...deps,
|
||||
}}
|
||||
>
|
||||
<CellActionsProvider getTriggerCompatibleActions={deps.uiActions.getTriggerCompatibleActions}>
|
||||
<div style={{ height: 500 }}>
|
||||
<DataGridLazy
|
||||
data={deps.data}
|
||||
fieldFormats={deps.fieldFormats}
|
||||
core={deps.core}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</CellActionsProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
};
|
156
src/plugins/esql_datagrid/public/data_grid.tsx
Normal file
156
src/plugins/esql_datagrid/public/data_grid.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* 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 React, { useState, useCallback, useMemo } from 'react';
|
||||
import { zipObject } from 'lodash';
|
||||
import { UnifiedDataTable, DataLoadingState, type SortOrder } from '@kbn/unified-data-table';
|
||||
import { Storage } from '@kbn/kibana-utils-plugin/public';
|
||||
import type { ESQLRow } from '@kbn/es-types';
|
||||
import type { DatatableColumn, DatatableColumnMeta } from '@kbn/expressions-plugin/common';
|
||||
import type { AggregateQuery } from '@kbn/es-query';
|
||||
import type { DataTableRecord } from '@kbn/discover-utils/types';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import { RowViewer } from './row_viewer_lazy';
|
||||
|
||||
interface ESQLDataGridProps {
|
||||
core: CoreStart;
|
||||
data: DataPublicPluginStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
rows: ESQLRow[];
|
||||
dataView: DataView;
|
||||
columns: DatatableColumn[];
|
||||
query: AggregateQuery;
|
||||
flyoutType?: 'overlay' | 'push';
|
||||
isTableView?: boolean;
|
||||
}
|
||||
type DataTableColumnsMeta = Record<
|
||||
string,
|
||||
{
|
||||
type: DatatableColumnMeta['type'];
|
||||
esType?: DatatableColumnMeta['esType'];
|
||||
}
|
||||
>;
|
||||
|
||||
const sortOrder: SortOrder[] = [];
|
||||
|
||||
const DataGrid: React.FC<ESQLDataGridProps> = (props) => {
|
||||
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>(undefined);
|
||||
const [activeColumns, setActiveColumns] = useState<string[]>(
|
||||
props.isTableView ? props.columns.map((c) => c.name) : []
|
||||
);
|
||||
const [rowHeight, setRowHeight] = useState<number>(5);
|
||||
|
||||
const onSetColumns = useCallback((columns) => {
|
||||
setActiveColumns(columns);
|
||||
}, []);
|
||||
|
||||
const renderDocumentView = useCallback(
|
||||
(
|
||||
hit: DataTableRecord,
|
||||
displayedRows: DataTableRecord[],
|
||||
displayedColumns: string[],
|
||||
customColumnsMeta?: DataTableColumnsMeta
|
||||
) => (
|
||||
<RowViewer
|
||||
dataView={props.dataView}
|
||||
toastNotifications={props.core.notifications}
|
||||
hit={hit}
|
||||
hits={displayedRows}
|
||||
columns={displayedColumns}
|
||||
columnsMeta={customColumnsMeta}
|
||||
flyoutType={props.flyoutType ?? 'push'}
|
||||
onRemoveColumn={(column) => {
|
||||
setActiveColumns(activeColumns.filter((c) => c !== column));
|
||||
}}
|
||||
onAddColumn={(column) => {
|
||||
setActiveColumns([...activeColumns, column]);
|
||||
}}
|
||||
onClose={() => setExpandedDoc(undefined)}
|
||||
setExpandedDoc={setExpandedDoc}
|
||||
/>
|
||||
),
|
||||
[activeColumns, props.core.notifications, props.dataView, props.flyoutType]
|
||||
);
|
||||
|
||||
const columnsMeta = useMemo(() => {
|
||||
return props.columns.reduce((acc, column) => {
|
||||
acc[column.id] = {
|
||||
type: column.meta?.type,
|
||||
esType: column.meta?.esType ?? column.meta?.type,
|
||||
};
|
||||
return acc;
|
||||
}, {} as DataTableColumnsMeta);
|
||||
}, [props.columns]);
|
||||
|
||||
const rows: DataTableRecord[] = useMemo(() => {
|
||||
const columnNames = props.columns?.map(({ name }) => name);
|
||||
return props.rows
|
||||
.map((row) => zipObject(columnNames, row))
|
||||
.map((row, idx: number) => {
|
||||
return {
|
||||
id: String(idx),
|
||||
raw: row,
|
||||
flattened: row,
|
||||
} as unknown as DataTableRecord;
|
||||
});
|
||||
}, [props.columns, props.rows]);
|
||||
|
||||
const services = useMemo(() => {
|
||||
const storage = new Storage(localStorage);
|
||||
|
||||
return {
|
||||
data: props.data,
|
||||
theme: props.core.theme,
|
||||
uiSettings: props.core.uiSettings,
|
||||
toastNotifications: props.core.notifications.toasts,
|
||||
fieldFormats: props.fieldFormats,
|
||||
storage,
|
||||
};
|
||||
}, [
|
||||
props.core.notifications.toasts,
|
||||
props.core.theme,
|
||||
props.core.uiSettings,
|
||||
props.data,
|
||||
props.fieldFormats,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UnifiedDataTable
|
||||
columns={activeColumns}
|
||||
rows={rows}
|
||||
columnsMeta={columnsMeta}
|
||||
services={services}
|
||||
isPlainRecord
|
||||
isSortEnabled={false}
|
||||
loadingState={DataLoadingState.loaded}
|
||||
dataView={props.dataView}
|
||||
sampleSizeState={rows.length}
|
||||
rowsPerPageState={10}
|
||||
onSetColumns={onSetColumns}
|
||||
expandedDoc={expandedDoc}
|
||||
setExpandedDoc={setExpandedDoc}
|
||||
showTimeCol
|
||||
useNewFieldsApi
|
||||
enableComparisonMode
|
||||
sort={sortOrder}
|
||||
ariaLabelledBy="esqlDataGrid"
|
||||
maxDocFieldsDisplayed={100}
|
||||
renderDocumentView={renderDocumentView}
|
||||
showFullScreenButton={false}
|
||||
configRowHeight={5}
|
||||
rowHeightState={rowHeight}
|
||||
onUpdateRowHeight={setRowHeight}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default DataGrid;
|
14
src/plugins/esql_datagrid/public/index.ts
Normal file
14
src/plugins/esql_datagrid/public/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* 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 { ESQLDataGridPlugin } from './plugin';
|
||||
export { ESQLDataGrid } from './create_datagrid';
|
||||
|
||||
export function plugin() {
|
||||
return new ESQLDataGridPlugin();
|
||||
}
|
50
src/plugins/esql_datagrid/public/kibana_services.ts
Normal file
50
src/plugins/esql_datagrid/public/kibana_services.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { BehaviorSubject } from 'rxjs';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
|
||||
export let core: CoreStart;
|
||||
|
||||
interface ServiceDeps {
|
||||
core: CoreStart;
|
||||
data: DataPublicPluginStart;
|
||||
uiActions: UiActionsStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
}
|
||||
|
||||
const servicesReady$ = new BehaviorSubject<ServiceDeps | undefined>(undefined);
|
||||
export const untilPluginStartServicesReady = () => {
|
||||
if (servicesReady$.value) return Promise.resolve(servicesReady$.value);
|
||||
return new Promise<ServiceDeps>((resolve) => {
|
||||
const subscription = servicesReady$.subscribe((deps) => {
|
||||
if (deps) {
|
||||
subscription.unsubscribe();
|
||||
resolve(deps);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const setKibanaServices = (
|
||||
kibanaCore: CoreStart,
|
||||
data: DataPublicPluginStart,
|
||||
uiActions: UiActionsStart,
|
||||
fieldFormats: FieldFormatsStart
|
||||
) => {
|
||||
core = kibanaCore;
|
||||
servicesReady$.next({
|
||||
core,
|
||||
data,
|
||||
uiActions,
|
||||
fieldFormats,
|
||||
});
|
||||
};
|
30
src/plugins/esql_datagrid/public/plugin.ts
Executable file
30
src/plugins/esql_datagrid/public/plugin.ts
Executable file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 type { Plugin, CoreStart, CoreSetup } from '@kbn/core/public';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import { setKibanaServices } from './kibana_services';
|
||||
|
||||
interface ESQLDataGridPluginStart {
|
||||
data: DataPublicPluginStart;
|
||||
uiActions: UiActionsStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
}
|
||||
export class ESQLDataGridPlugin implements Plugin<{}, void> {
|
||||
public setup(_: CoreSetup, {}: {}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, { data, uiActions, fieldFormats }: ESQLDataGridPluginStart): void {
|
||||
setKibanaServices(core, data, uiActions, fieldFormats);
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
119
src/plugins/esql_datagrid/public/row_viewer.test.tsx
Normal file
119
src/plugins/esql_datagrid/public/row_viewer.test.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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 React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import type { DataTableRecord } from '@kbn/discover-utils/types';
|
||||
import type { DataView } from '@kbn/data-views-plugin/common';
|
||||
import { setUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/plugin';
|
||||
import { mockUnifiedDocViewerServices } from '@kbn/unified-doc-viewer-plugin/public/__mocks__';
|
||||
import { RowViewer } from './row_viewer';
|
||||
|
||||
describe('RowViewer', () => {
|
||||
function renderComponent(closeFlyoutSpy?: jest.Mock, extraHit?: DataTableRecord) {
|
||||
const dataView = {
|
||||
title: 'foo',
|
||||
id: 'foo',
|
||||
name: 'foo',
|
||||
toSpec: jest.fn(),
|
||||
toMinimalSpec: jest.fn(),
|
||||
isPersisted: jest.fn().mockReturnValue(false),
|
||||
fields: {
|
||||
getByName: jest.fn(),
|
||||
},
|
||||
timeFieldName: 'timestamp',
|
||||
};
|
||||
const columns = ['bytes', 'destination'];
|
||||
const hit = {
|
||||
flattened: {
|
||||
bytes: 123,
|
||||
destination: 'Amsterdam',
|
||||
},
|
||||
id: '1',
|
||||
raw: {
|
||||
bytes: 123,
|
||||
destination: 'Amsterdam',
|
||||
},
|
||||
} as unknown as DataTableRecord;
|
||||
|
||||
const hits = [hit];
|
||||
if (extraHit) {
|
||||
hits.push(extraHit);
|
||||
}
|
||||
const services = {
|
||||
toastNotifications: {
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
setUnifiedDocViewerServices(mockUnifiedDocViewerServices);
|
||||
|
||||
render(
|
||||
<KibanaContextProvider services={services}>
|
||||
<RowViewer
|
||||
dataView={dataView as unknown as DataView}
|
||||
toastNotifications={
|
||||
{
|
||||
toasts: {
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
} as unknown as CoreStart['notifications']
|
||||
}
|
||||
hit={hit}
|
||||
hits={hits}
|
||||
columns={columns}
|
||||
flyoutType={'push'}
|
||||
onRemoveColumn={jest.fn()}
|
||||
onAddColumn={jest.fn()}
|
||||
onClose={closeFlyoutSpy ?? jest.fn()}
|
||||
setExpandedDoc={jest.fn()}
|
||||
/>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
it('should render a flyout', async () => {
|
||||
renderComponent();
|
||||
await waitFor(() => expect(screen.getByTestId('esqlRowDetailsFlyout')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should run the onClose prop when the close button is clicked', async () => {
|
||||
const closeFlyoutSpy = jest.fn();
|
||||
renderComponent(closeFlyoutSpy);
|
||||
await waitFor(() => {
|
||||
userEvent.click(screen.getByTestId('esqlRowDetailsFlyoutCloseBtn'));
|
||||
expect(closeFlyoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays row navigation when there is more than 1 row available', async () => {
|
||||
renderComponent(undefined, {
|
||||
flattened: {
|
||||
bytes: 456,
|
||||
destination: 'Athens',
|
||||
},
|
||||
id: '3',
|
||||
raw: {
|
||||
bytes: 456,
|
||||
destination: 'Athens',
|
||||
},
|
||||
} as unknown as DataTableRecord);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('esqlTableRowNavigation')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('doesnt display row navigation when there is only 1 row available', async () => {
|
||||
renderComponent();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('esqlTableRowNavigation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
235
src/plugins/esql_datagrid/public/row_viewer.tsx
Normal file
235
src/plugins/esql_datagrid/public/row_viewer.tsx
Normal file
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
* 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 React, { useMemo, useCallback } from 'react';
|
||||
import { get } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { css } from '@emotion/react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFlyoutResizable,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiPortal,
|
||||
EuiPagination,
|
||||
keys,
|
||||
EuiButtonEmpty,
|
||||
useEuiTheme,
|
||||
useIsWithinMinBreakpoint,
|
||||
} from '@elastic/eui';
|
||||
import type { DataTableRecord } from '@kbn/discover-utils/types';
|
||||
import type { DataTableColumnsMeta } from '@kbn/unified-data-table';
|
||||
import { UnifiedDocViewer } from '@kbn/unified-doc-viewer-plugin/public';
|
||||
import useLocalStorage from 'react-use/lib/useLocalStorage';
|
||||
import { NotificationsStart } from '@kbn/core-notifications-browser';
|
||||
|
||||
export interface RowViewerProps {
|
||||
toastNotifications?: NotificationsStart;
|
||||
columns: string[];
|
||||
columnsMeta?: DataTableColumnsMeta;
|
||||
hit: DataTableRecord;
|
||||
hits?: DataTableRecord[];
|
||||
flyoutType?: 'push' | 'overlay';
|
||||
dataView: DataView;
|
||||
onAddColumn: (column: string) => void;
|
||||
onClose: () => void;
|
||||
onRemoveColumn: (column: string) => void;
|
||||
setExpandedDoc: (doc?: DataTableRecord) => void;
|
||||
}
|
||||
|
||||
function getIndexByDocId(hits: DataTableRecord[], id: string) {
|
||||
return hits.findIndex((h) => {
|
||||
return h.id === id;
|
||||
});
|
||||
}
|
||||
|
||||
export const FLYOUT_WIDTH_KEY = 'esqlTable:flyoutWidth';
|
||||
/**
|
||||
* Flyout displaying an expanded ES|QL row
|
||||
*/
|
||||
export function RowViewer({
|
||||
hit,
|
||||
hits,
|
||||
dataView,
|
||||
columns,
|
||||
columnsMeta,
|
||||
toastNotifications,
|
||||
flyoutType = 'push',
|
||||
onClose,
|
||||
onRemoveColumn,
|
||||
onAddColumn,
|
||||
setExpandedDoc,
|
||||
}: RowViewerProps) {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const isXlScreen = useIsWithinMinBreakpoint('xl');
|
||||
const DEFAULT_WIDTH = euiTheme.base * 34;
|
||||
const defaultWidth = DEFAULT_WIDTH;
|
||||
const [flyoutWidth, setFlyoutWidth] = useLocalStorage(FLYOUT_WIDTH_KEY, defaultWidth);
|
||||
const minWidth = euiTheme.base * 24;
|
||||
const maxWidth = euiTheme.breakpoint.xl;
|
||||
|
||||
const actualHit = useMemo(() => hits?.find(({ id }) => id === hit?.id) || hit, [hit, hits]);
|
||||
const pageCount = useMemo<number>(() => (hits ? hits.length : 0), [hits]);
|
||||
const activePage = useMemo<number>(() => {
|
||||
const id = hit.id;
|
||||
if (!hits || pageCount <= 1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return getIndexByDocId(hits, id);
|
||||
}, [hits, hit, pageCount]);
|
||||
|
||||
const setPage = useCallback(
|
||||
(index: number) => {
|
||||
if (hits && hits[index]) {
|
||||
setExpandedDoc(hits[index]);
|
||||
}
|
||||
},
|
||||
[hits, setExpandedDoc]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
const nodeName = get(ev, 'target.nodeName', null);
|
||||
if (typeof nodeName === 'string' && nodeName.toLowerCase() === 'input') {
|
||||
return;
|
||||
}
|
||||
if (ev.key === keys.ARROW_LEFT || ev.key === keys.ARROW_RIGHT) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setPage(activePage + (ev.key === keys.ARROW_RIGHT ? 1 : -1));
|
||||
}
|
||||
},
|
||||
[activePage, setPage]
|
||||
);
|
||||
|
||||
const addColumn = useCallback(
|
||||
(columnName: string) => {
|
||||
onAddColumn(columnName);
|
||||
toastNotifications?.toasts?.addSuccess?.(
|
||||
i18n.translate('esqlDataGrid.grid.flyout.toastColumnAdded', {
|
||||
defaultMessage: `Column '{columnName}' was added`,
|
||||
values: { columnName },
|
||||
})
|
||||
);
|
||||
},
|
||||
[onAddColumn, toastNotifications]
|
||||
);
|
||||
|
||||
const removeColumn = useCallback(
|
||||
(columnName: string) => {
|
||||
onRemoveColumn(columnName);
|
||||
toastNotifications?.toasts?.addSuccess?.(
|
||||
i18n.translate('esqlDataGrid.grid.flyout.toastColumnRemoved', {
|
||||
defaultMessage: `Column '{columnName}' was removed`,
|
||||
values: { columnName },
|
||||
})
|
||||
);
|
||||
},
|
||||
[onRemoveColumn, toastNotifications]
|
||||
);
|
||||
|
||||
const renderDefaultContent = useCallback(
|
||||
() => (
|
||||
<UnifiedDocViewer
|
||||
columns={columns}
|
||||
columnsMeta={columnsMeta}
|
||||
dataView={dataView}
|
||||
hit={actualHit}
|
||||
onAddColumn={addColumn}
|
||||
onRemoveColumn={removeColumn}
|
||||
textBasedHits={hits}
|
||||
/>
|
||||
),
|
||||
[actualHit, addColumn, columns, columnsMeta, dataView, hits, removeColumn]
|
||||
);
|
||||
|
||||
const bodyContent = renderDefaultContent();
|
||||
|
||||
return (
|
||||
<EuiPortal>
|
||||
<EuiFlyoutResizable
|
||||
onClose={onClose}
|
||||
type={flyoutType}
|
||||
size={flyoutWidth}
|
||||
pushMinBreakpoint="xl"
|
||||
data-test-subj="esqlRowDetailsFlyout"
|
||||
onKeyDown={onKeyDown}
|
||||
ownFocus={true}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
onResize={setFlyoutWidth}
|
||||
css={{
|
||||
maxWidth: `${isXlScreen ? `calc(100vw - ${DEFAULT_WIDTH}px)` : '90vw'} !important`,
|
||||
}}
|
||||
paddingSize="m"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gutterSize="m"
|
||||
responsive={false}
|
||||
wrap={true}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle
|
||||
size="xs"
|
||||
data-test-subj="docTableRowDetailsTitle"
|
||||
css={css`
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
<h2>
|
||||
{i18n.translate('esqlDataGrid.grid.tableRow.docViewerEsqlDetailHeading', {
|
||||
defaultMessage: 'Result',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{activePage !== -1 && (
|
||||
<EuiFlexItem data-test-subj={`esqlTableRowNavigation-${activePage}`}>
|
||||
<EuiPagination
|
||||
aria-label={i18n.translate('esqlDataGrid.grid.flyout.rowNavigation', {
|
||||
defaultMessage: 'Row navigation',
|
||||
})}
|
||||
pageCount={pageCount}
|
||||
activePage={activePage}
|
||||
onPageClick={setPage}
|
||||
compressed
|
||||
data-test-subj="esqlTableRowNavigation"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>{bodyContent}</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
onClick={onClose}
|
||||
flush="left"
|
||||
data-test-subj="esqlRowDetailsFlyoutCloseBtn"
|
||||
>
|
||||
{i18n.translate('esqlDataGrid.grid.flyout.close', {
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyoutResizable>
|
||||
</EuiPortal>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default RowViewer;
|
13
src/plugins/esql_datagrid/public/row_viewer_lazy.tsx
Normal file
13
src/plugins/esql_datagrid/public/row_viewer_lazy.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 { withSuspense } from '@kbn/shared-ux-utility';
|
||||
import { lazy } from 'react';
|
||||
export type { RowViewerProps } from './row_viewer';
|
||||
|
||||
export const RowViewer = withSuspense(lazy(() => import('./row_viewer')));
|
33
src/plugins/esql_datagrid/tsconfig.json
Normal file
33
src/plugins/esql_datagrid/tsconfig.json
Normal file
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
},
|
||||
"include": [
|
||||
"../../typings/**/*",
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/es-types",
|
||||
"@kbn/es-query",
|
||||
"@kbn/discover-utils",
|
||||
"@kbn/data-views-plugin",
|
||||
"@kbn/expressions-plugin",
|
||||
"@kbn/cell-actions",
|
||||
"@kbn/unified-data-table",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/kibana-react-plugin",
|
||||
"@kbn/core",
|
||||
"@kbn/ui-actions-plugin",
|
||||
"@kbn/field-formats-plugin",
|
||||
"@kbn/i18n",
|
||||
"@kbn/unified-doc-viewer-plugin",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/shared-ux-utility"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
]
|
||||
}
|
|
@ -816,6 +816,8 @@
|
|||
"@kbn/esql-ast/*": ["packages/kbn-esql-ast/*"],
|
||||
"@kbn/esql-ast-inspector-plugin": ["examples/esql_ast_inspector"],
|
||||
"@kbn/esql-ast-inspector-plugin/*": ["examples/esql_ast_inspector/*"],
|
||||
"@kbn/esql-datagrid": ["src/plugins/esql_datagrid"],
|
||||
"@kbn/esql-datagrid/*": ["src/plugins/esql_datagrid/*"],
|
||||
"@kbn/esql-utils": ["packages/kbn-esql-utils"],
|
||||
"@kbn/esql-utils/*": ["packages/kbn-esql-utils/*"],
|
||||
"@kbn/esql-validation-autocomplete": ["packages/kbn-esql-validation-autocomplete"],
|
||||
|
|
|
@ -24,9 +24,9 @@
|
|||
"licensing",
|
||||
"ml",
|
||||
"alerting",
|
||||
"features"
|
||||
"features",
|
||||
],
|
||||
"requiredBundles": ["kibanaReact"],
|
||||
"requiredBundles": ["kibanaReact", "esqlDataGrid"],
|
||||
"optionalPlugins": ["cloud"],
|
||||
"extraPublicDirs": []
|
||||
}
|
||||
|
|
|
@ -31,6 +31,9 @@ describe('VisualizeESQL', () => {
|
|||
toSpec: jest.fn(),
|
||||
toMinimalSpec: jest.fn(),
|
||||
isPersisted: jest.fn().mockReturnValue(false),
|
||||
fields: {
|
||||
getByName: jest.fn(),
|
||||
},
|
||||
})
|
||||
),
|
||||
};
|
||||
|
@ -73,6 +76,7 @@ describe('VisualizeESQL', () => {
|
|||
ObservabilityAIAssistantMultipaneFlyoutContext={
|
||||
ObservabilityAIAssistantMultipaneFlyoutContext
|
||||
}
|
||||
rows={[]}
|
||||
/>
|
||||
</ObservabilityAIAssistantMultipaneFlyoutContext.Provider>
|
||||
);
|
||||
|
@ -138,8 +142,61 @@ describe('VisualizeESQL', () => {
|
|||
}),
|
||||
};
|
||||
renderComponent({}, lensService, undefined, ['There is an error mate']);
|
||||
await waitFor(() => expect(screen.findByTestId('observabilityAiAssistantErrorsList')));
|
||||
});
|
||||
|
||||
it('should not display the table on first render', async () => {
|
||||
const lensService = {
|
||||
...lensPluginMock.createStartContract(),
|
||||
stateHelperApi: jest.fn().mockResolvedValue({
|
||||
formula: jest.fn(),
|
||||
suggestions: jest.fn(),
|
||||
}),
|
||||
};
|
||||
renderComponent({}, lensService);
|
||||
// the button to render a table should be present
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('observabilityAiAssistantErrorsList')).toBeInTheDocument()
|
||||
expect(screen.findByTestId('observabilityAiAssistantLensESQLDisplayTableButton'))
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('observabilityAiAssistantESQLDataGrid')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the table when user clicks the table button', async () => {
|
||||
const lensService = {
|
||||
...lensPluginMock.createStartContract(),
|
||||
stateHelperApi: jest.fn().mockResolvedValue({
|
||||
formula: jest.fn(),
|
||||
suggestions: jest.fn(),
|
||||
}),
|
||||
};
|
||||
renderComponent({}, lensService);
|
||||
await waitFor(() => {
|
||||
userEvent.click(screen.getByTestId('observabilityAiAssistantLensESQLDisplayTableButton'));
|
||||
expect(screen.findByTestId('observabilityAiAssistantESQLDataGrid'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the ESQLDataGrid if Lens returns a table', async () => {
|
||||
const lensService = {
|
||||
...lensPluginMock.createStartContract(),
|
||||
stateHelperApi: jest.fn().mockResolvedValue({
|
||||
formula: jest.fn(),
|
||||
suggestions: jest.fn(),
|
||||
}),
|
||||
};
|
||||
renderComponent(
|
||||
{
|
||||
attributes: {
|
||||
visualizationType: 'lnsDatatable',
|
||||
},
|
||||
},
|
||||
lensService
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.findByTestId('observabilityAiAssistantESQLDataGrid'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,6 +15,8 @@ import {
|
|||
EuiText,
|
||||
EuiDescriptionList,
|
||||
} from '@elastic/eui';
|
||||
import type { ESQLRow } from '@kbn/es-types';
|
||||
import { ESQLDataGrid } from '@kbn/esql-datagrid/public';
|
||||
import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types';
|
||||
import { getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
|
||||
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
|
||||
|
@ -63,6 +65,7 @@ interface VisualizeQueryResponsev0 {
|
|||
interface VisualizeQueryResponsev1 {
|
||||
data: {
|
||||
columns: DatatableColumn[];
|
||||
rows: ESQLRow[];
|
||||
userOverrides?: unknown;
|
||||
};
|
||||
content: {
|
||||
|
@ -82,6 +85,8 @@ interface VisualizeESQLProps {
|
|||
uiActions: UiActionsStart;
|
||||
/** Datatable columns as returned from the ES|QL _query api, slightly processed to be kibana compliant */
|
||||
columns: DatatableColumn[];
|
||||
/** Datatable rows as returned from the ES|QL _query api */
|
||||
rows: ESQLRow[];
|
||||
/** The ES|QL query */
|
||||
query: string;
|
||||
/** Actions handler */
|
||||
|
@ -106,6 +111,7 @@ export function VisualizeESQL({
|
|||
dataViews,
|
||||
uiActions,
|
||||
columns,
|
||||
rows,
|
||||
query,
|
||||
onActionClick,
|
||||
userOverrides,
|
||||
|
@ -120,11 +126,17 @@ export function VisualizeESQL({
|
|||
}, [lens]);
|
||||
|
||||
const dataViewAsync = useAsync(() => {
|
||||
return getESQLAdHocDataview(indexPattern, dataViews);
|
||||
return getESQLAdHocDataview(indexPattern, dataViews).then((dataView) => {
|
||||
if (dataView.fields.getByName('@timestamp')?.type === 'date') {
|
||||
dataView.timeFieldName = '@timestamp';
|
||||
}
|
||||
return dataView;
|
||||
});
|
||||
}, [indexPattern]);
|
||||
const chatFlyoutSecondSlotHandler = useContext(ObservabilityAIAssistantMultipaneFlyoutContext);
|
||||
|
||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||
const [isTableVisible, setIsTableVisible] = useState(false);
|
||||
const [lensInput, setLensInput] = useState<TypedLensByValueInput | undefined>(
|
||||
userOverrides as TypedLensByValueInput
|
||||
);
|
||||
|
@ -238,88 +250,159 @@ export function VisualizeESQL({
|
|||
if (!lensHelpersAsync.value || !dataViewAsync.value || !lensInput) {
|
||||
return <EuiLoadingSpinner />;
|
||||
}
|
||||
// if the Lens suggestions api suggests a table then we want to render a Discover table instead
|
||||
const isLensInputTable = lensInput?.attributes?.visualizationType === 'lnsDatatable';
|
||||
|
||||
const visualizationComponentDataTestSubj = isTableVisible
|
||||
? 'observabilityAiAssistantESQLDataGrid'
|
||||
: 'observabilityAiAssistantESQLLensChart';
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup direction="column">
|
||||
{Boolean(errorMessages?.length) && (
|
||||
<>
|
||||
<EuiText size="s">
|
||||
{i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.errorMessage', {
|
||||
defaultMessage: 'There were some errors in the generated query',
|
||||
})}
|
||||
</EuiText>
|
||||
<EuiDescriptionList data-test-subj="observabilityAiAssistantErrorsList">
|
||||
{errorMessages?.map((error, index) => {
|
||||
return (
|
||||
<EuiDescriptionListDescription key={index}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="error" color="danger" size="s" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{error}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListDescription>
|
||||
);
|
||||
})}
|
||||
</EuiDescriptionList>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="flexEnd">
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.edit', {
|
||||
defaultMessage: 'Edit visualization',
|
||||
})}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
iconType="pencil"
|
||||
onClick={() => {
|
||||
chatFlyoutSecondSlotHandler?.setVisibility?.(true);
|
||||
if (triggerOptions) {
|
||||
uiActions.getTrigger('IN_APP_EMBEDDABLE_EDIT_TRIGGER').exec(triggerOptions);
|
||||
}
|
||||
}}
|
||||
data-test-subj="observabilityAiAssistantLensESQLEditButton"
|
||||
aria-label={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.edit', {
|
||||
defaultMessage: 'Edit visualization',
|
||||
{!isLensInputTable && (
|
||||
<EuiFlexGroup direction="column">
|
||||
{Boolean(errorMessages?.length) && (
|
||||
<>
|
||||
<EuiText size="s">
|
||||
{i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.errorMessage', {
|
||||
defaultMessage: 'There were some errors in the generated query',
|
||||
})}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
<EuiFlexItem grow={false}>
|
||||
</EuiText>
|
||||
<EuiDescriptionList data-test-subj="observabilityAiAssistantErrorsList">
|
||||
{errorMessages?.map((error, index) => {
|
||||
return (
|
||||
<EuiDescriptionListDescription key={index}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="error" color="danger" size="s" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{error}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListDescription>
|
||||
);
|
||||
})}
|
||||
</EuiDescriptionList>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup direction="row" gutterSize="s" justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={
|
||||
isTableVisible
|
||||
? i18n.translate(
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.visualization',
|
||||
{
|
||||
defaultMessage: 'Visualization',
|
||||
}
|
||||
)
|
||||
: i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.table', {
|
||||
defaultMessage: 'Table of results',
|
||||
})
|
||||
}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
iconType={isTableVisible ? 'visBarVerticalStacked' : 'tableDensityExpanded'}
|
||||
onClick={() => setIsTableVisible(!isTableVisible)}
|
||||
data-test-subj="observabilityAiAssistantLensESQLDisplayTableButton"
|
||||
aria-label={
|
||||
isTableVisible
|
||||
? i18n.translate(
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.displayChart',
|
||||
{
|
||||
defaultMessage: 'Display chart',
|
||||
}
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.displayTable',
|
||||
{
|
||||
defaultMessage: 'Display results',
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.save', {
|
||||
defaultMessage: 'Save visualization',
|
||||
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.edit', {
|
||||
defaultMessage: 'Edit visualization',
|
||||
})}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
iconType="save"
|
||||
onClick={() => setIsSaveModalOpen(true)}
|
||||
data-test-subj="observabilityAiAssistantLensESQLSaveButton"
|
||||
iconType="pencil"
|
||||
onClick={() => {
|
||||
chatFlyoutSecondSlotHandler?.setVisibility?.(true);
|
||||
if (triggerOptions) {
|
||||
uiActions.getTrigger('IN_APP_EMBEDDABLE_EDIT_TRIGGER').exec(triggerOptions);
|
||||
}
|
||||
}}
|
||||
data-test-subj="observabilityAiAssistantLensESQLEditButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.save',
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.edit',
|
||||
{
|
||||
defaultMessage: 'Save visualization',
|
||||
defaultMessage: 'Edit visualization',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="observabilityAiAssistantESQLLensChart">
|
||||
<lens.EmbeddableComponent
|
||||
{...lensInput}
|
||||
style={{
|
||||
height: 240,
|
||||
}}
|
||||
onLoad={onLoad}
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.save', {
|
||||
defaultMessage: 'Save visualization',
|
||||
})}
|
||||
>
|
||||
<EuiButtonIcon
|
||||
size="xs"
|
||||
iconType="save"
|
||||
onClick={() => setIsSaveModalOpen(true)}
|
||||
data-test-subj="observabilityAiAssistantLensESQLSaveButton"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.observabilityAiAssistant.lensESQLFunction.save',
|
||||
{
|
||||
defaultMessage: 'Save visualization',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj={visualizationComponentDataTestSubj}>
|
||||
{isTableVisible ? (
|
||||
<ESQLDataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
dataView={dataViewAsync.value}
|
||||
query={{ esql: query }}
|
||||
flyoutType="overlay"
|
||||
isTableView
|
||||
/>
|
||||
) : (
|
||||
<lens.EmbeddableComponent
|
||||
{...lensInput}
|
||||
style={{
|
||||
height: 240,
|
||||
}}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{isLensInputTable && (
|
||||
<div data-test-subj="observabilityAiAssistantESQLDataGrid">
|
||||
<ESQLDataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
dataView={dataViewAsync.value}
|
||||
query={{ esql: query }}
|
||||
flyoutType="overlay"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</div>
|
||||
)}
|
||||
{isSaveModalOpen ? (
|
||||
<lens.SaveModalComponent
|
||||
initialInput={lensInput}
|
||||
|
@ -351,6 +434,7 @@ export function registerVisualizeQueryRenderFunction({
|
|||
const typedResponse = response as VisualizeQueryResponse;
|
||||
|
||||
const columns = 'data' in typedResponse ? typedResponse.data.columns : typedResponse.content;
|
||||
const rows = 'data' in typedResponse ? typedResponse.data.rows : [];
|
||||
const errorMessages =
|
||||
'content' in typedResponse && 'errorMessages' in typedResponse.content
|
||||
? typedResponse.content.errorMessages
|
||||
|
@ -420,6 +504,7 @@ export function registerVisualizeQueryRenderFunction({
|
|||
dataViews={pluginsStart.dataViews}
|
||||
uiActions={pluginsStart.uiActions}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
query={trimmedQuery}
|
||||
onActionClick={onActionClick}
|
||||
userOverrides={userOverrides}
|
||||
|
|
|
@ -22,10 +22,9 @@ import {
|
|||
} from '@kbn/observability-ai-assistant-plugin/common/utils/concatenate_chat_completion_chunks';
|
||||
import { emitWithConcatenatedMessage } from '@kbn/observability-ai-assistant-plugin/common/utils/emit_with_concatenated_message';
|
||||
import { createFunctionResponseMessage } from '@kbn/observability-ai-assistant-plugin/common/utils/create_function_response_message';
|
||||
import { ESQLSearchReponse } from '@kbn/es-types';
|
||||
import type { FunctionRegistrationParameters } from '..';
|
||||
import { correctCommonEsqlMistakes } from './correct_common_esql_mistakes';
|
||||
import { validateEsqlQuery } from './validate_esql_query';
|
||||
import { runAndValidateEsqlQuery } from './validate_esql_query';
|
||||
|
||||
const readFile = promisify(Fs.readFile);
|
||||
const readdir = promisify(Fs.readdir);
|
||||
|
@ -108,7 +107,7 @@ export function registerQueryFunction({ functions, resources }: FunctionRegistra
|
|||
},
|
||||
async ({ arguments: { query } }) => {
|
||||
const client = (await resources.context.core).elasticsearch.client.asCurrentUser;
|
||||
const { error, errorMessages } = await validateEsqlQuery({
|
||||
const { error, errorMessages, rows, columns } = await runAndValidateEsqlQuery({
|
||||
query,
|
||||
client,
|
||||
});
|
||||
|
@ -122,16 +121,12 @@ export function registerQueryFunction({ functions, resources }: FunctionRegistra
|
|||
},
|
||||
};
|
||||
}
|
||||
const response = (await client.transport.request({
|
||||
method: 'POST',
|
||||
path: '_query',
|
||||
body: {
|
||||
query,
|
||||
},
|
||||
})) as ESQLSearchReponse;
|
||||
|
||||
return {
|
||||
content: response,
|
||||
content: {
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
import { validateQuery } from '@kbn/esql-validation-autocomplete';
|
||||
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { ESQLSearchReponse } from '@kbn/es-types';
|
||||
import { ESQLSearchReponse, ESQLRow } from '@kbn/es-types';
|
||||
import { esFieldTypeToKibanaFieldType, type KBN_FIELD_TYPES } from '@kbn/field-types';
|
||||
import { splitIntoCommands } from './correct_common_esql_mistakes';
|
||||
|
||||
export async function validateEsqlQuery({
|
||||
export async function runAndValidateEsqlQuery({
|
||||
query,
|
||||
client,
|
||||
}: {
|
||||
|
@ -26,6 +26,7 @@ export async function validateEsqlQuery({
|
|||
type: KBN_FIELD_TYPES;
|
||||
};
|
||||
}>;
|
||||
rows?: ESQLRow[];
|
||||
error?: Error;
|
||||
errorMessages?: string[];
|
||||
}> {
|
||||
|
@ -47,15 +48,12 @@ export async function validateEsqlQuery({
|
|||
return 'text' in error ? error.text : error.message;
|
||||
});
|
||||
|
||||
// With limit 0 I get only the columns, it is much more performant
|
||||
const performantQuery = `${query} | limit 0`;
|
||||
|
||||
return client.transport
|
||||
.request({
|
||||
method: 'POST',
|
||||
path: '_query',
|
||||
body: {
|
||||
query: performantQuery,
|
||||
query,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
|
@ -68,7 +66,7 @@ export async function validateEsqlQuery({
|
|||
meta: { type: esFieldTypeToKibanaFieldType(type) },
|
||||
})) ?? [];
|
||||
|
||||
return { columns };
|
||||
return { columns, rows: esqlResponse.values };
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import { VisualizeESQLUserIntention } from '@kbn/observability-ai-assistant-plugin/common/functions/visualize_esql';
|
||||
import { visualizeESQLFunction } from '../../common/functions/visualize_esql';
|
||||
import { FunctionRegistrationParameters } from '.';
|
||||
import { validateEsqlQuery } from './query/validate_esql_query';
|
||||
import { runAndValidateEsqlQuery } from './query/validate_esql_query';
|
||||
|
||||
const getMessageForLLM = (
|
||||
intention: VisualizeESQLUserIntention,
|
||||
|
@ -28,7 +28,7 @@ export function registerVisualizeESQLFunction({
|
|||
resources,
|
||||
}: FunctionRegistrationParameters) {
|
||||
functions.registerFunction(visualizeESQLFunction, async ({ arguments: { query, intention } }) => {
|
||||
const { columns, errorMessages } = await validateEsqlQuery({
|
||||
const { columns, errorMessages, rows } = await runAndValidateEsqlQuery({
|
||||
query,
|
||||
client: (await resources.context.core).elasticsearch.client.asCurrentUser,
|
||||
});
|
||||
|
@ -38,6 +38,7 @@ export function registerVisualizeESQLFunction({
|
|||
return {
|
||||
data: {
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
content: {
|
||||
message,
|
||||
|
|
|
@ -69,6 +69,7 @@
|
|||
"@kbn/task-manager-plugin",
|
||||
"@kbn/cloud-plugin",
|
||||
"@kbn/observability-plugin",
|
||||
"@kbn/esql-datagrid",
|
||||
"@kbn/alerting-comparators"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
|
|
|
@ -4800,6 +4800,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/esql-datagrid@link:src/plugins/esql_datagrid":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/esql-utils@link:packages/kbn-esql-utils":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue