diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9d65857bb5d90b6..d9a9f60eef539f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.i18nrc.json b/.i18nrc.json index 7854a7855351cb9..4d71e47159e1b4d 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -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"], diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index d861a4e72c572f6..e6b980a459f158d 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -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. diff --git a/package.json b/package.json index 4188137c88ce069..dab6cc9b57d748c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index c3616140cabe8db..29de4a26abcc96e 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -42,6 +42,7 @@ pageLoadAssetSize: embeddable: 87309 embeddableEnhanced: 22107 enterpriseSearch: 50858 + esqlDataGrid: 24598 esUiShared: 326654 eventAnnotation: 30000 eventAnnotationListing: 25841 diff --git a/packages/kbn-unified-data-table/index.ts b/packages/kbn-unified-data-table/index.ts index 26095d948cb8ae0..0929c33208fa094 100644 --- a/packages/kbn-unified-data-table/index.ts +++ b/packages/kbn-unified-data-table/index.ts @@ -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, diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index a6496151521ec26..3a64947fe39cad0 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -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, diff --git a/src/plugins/esql_datagrid/.i18nrc.json b/src/plugins/esql_datagrid/.i18nrc.json new file mode 100755 index 000000000000000..b56ac6e79d88ce0 --- /dev/null +++ b/src/plugins/esql_datagrid/.i18nrc.json @@ -0,0 +1,6 @@ +{ + "prefix": "esqlDataGrid", + "paths": { + "esqlDataGrid": "." + } +} diff --git a/src/plugins/esql_datagrid/README.md b/src/plugins/esql_datagrid/README.md new file mode 100644 index 000000000000000..89848b34b5f4dbc --- /dev/null +++ b/src/plugins/esql_datagrid/README.md @@ -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: + } + ``` + * 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); + + +``` diff --git a/src/plugins/esql_datagrid/jest.config.js b/src/plugins/esql_datagrid/jest.config.js new file mode 100644 index 000000000000000..6def95236d27c17 --- /dev/null +++ b/src/plugins/esql_datagrid/jest.config.js @@ -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: ['/src/plugins/esql_datagrid'], + coverageDirectory: '/target/kibana-coverage/jest/src/plugins/esql_datagrid', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/src/plugins/esql_datagrid/{common,public,server}/**/*.{js,ts,tsx}', + ], + setupFiles: ['jest-canvas-mock'], +}; diff --git a/src/plugins/esql_datagrid/kibana.jsonc b/src/plugins/esql_datagrid/kibana.jsonc new file mode 100644 index 000000000000000..ed589432578f38e --- /dev/null +++ b/src/plugins/esql_datagrid/kibana.jsonc @@ -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" + ] + } +} diff --git a/src/plugins/esql_datagrid/package.json b/src/plugins/esql_datagrid/package.json new file mode 100644 index 000000000000000..3c5482db0f13f69 --- /dev/null +++ b/src/plugins/esql_datagrid/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/esql-datagrid", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/src/plugins/esql_datagrid/public/create_datagrid.tsx b/src/plugins/esql_datagrid/public/create_datagrid.tsx new file mode 100644 index 000000000000000..04e5693c96c38eb --- /dev/null +++ b/src/plugins/esql_datagrid/public/create_datagrid.tsx @@ -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 ; + + return ( + + +
+ +
+
+
+ ); +}; diff --git a/src/plugins/esql_datagrid/public/data_grid.tsx b/src/plugins/esql_datagrid/public/data_grid.tsx new file mode 100644 index 000000000000000..00d1ef2f541b6ef --- /dev/null +++ b/src/plugins/esql_datagrid/public/data_grid.tsx @@ -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 = (props) => { + const [expandedDoc, setExpandedDoc] = useState(undefined); + const [activeColumns, setActiveColumns] = useState( + props.isTableView ? props.columns.map((c) => c.name) : [] + ); + const [rowHeight, setRowHeight] = useState(5); + + const onSetColumns = useCallback((columns) => { + setActiveColumns(columns); + }, []); + + const renderDocumentView = useCallback( + ( + hit: DataTableRecord, + displayedRows: DataTableRecord[], + displayedColumns: string[], + customColumnsMeta?: DataTableColumnsMeta + ) => ( + { + 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 ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default DataGrid; diff --git a/src/plugins/esql_datagrid/public/index.ts b/src/plugins/esql_datagrid/public/index.ts new file mode 100644 index 000000000000000..14a92068f02cc8e --- /dev/null +++ b/src/plugins/esql_datagrid/public/index.ts @@ -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(); +} diff --git a/src/plugins/esql_datagrid/public/kibana_services.ts b/src/plugins/esql_datagrid/public/kibana_services.ts new file mode 100644 index 000000000000000..df52136ce021b01 --- /dev/null +++ b/src/plugins/esql_datagrid/public/kibana_services.ts @@ -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(undefined); +export const untilPluginStartServicesReady = () => { + if (servicesReady$.value) return Promise.resolve(servicesReady$.value); + return new Promise((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, + }); +}; diff --git a/src/plugins/esql_datagrid/public/plugin.ts b/src/plugins/esql_datagrid/public/plugin.ts new file mode 100755 index 000000000000000..662d23c5190b80b --- /dev/null +++ b/src/plugins/esql_datagrid/public/plugin.ts @@ -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() {} +} diff --git a/src/plugins/esql_datagrid/public/row_viewer.test.tsx b/src/plugins/esql_datagrid/public/row_viewer.test.tsx new file mode 100644 index 000000000000000..712e23953fae9de --- /dev/null +++ b/src/plugins/esql_datagrid/public/row_viewer.test.tsx @@ -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( + + + + ); + } + + 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(); + }); + }); +}); diff --git a/src/plugins/esql_datagrid/public/row_viewer.tsx b/src/plugins/esql_datagrid/public/row_viewer.tsx new file mode 100644 index 000000000000000..35c2b807fbbb3fb --- /dev/null +++ b/src/plugins/esql_datagrid/public/row_viewer.tsx @@ -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(() => (hits ? hits.length : 0), [hits]); + const activePage = useMemo(() => { + 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( + () => ( + + ), + [actualHit, addColumn, columns, columnsMeta, dataView, hits, removeColumn] + ); + + const bodyContent = renderDefaultContent(); + + return ( + + + + + + +

+ {i18n.translate('esqlDataGrid.grid.tableRow.docViewerEsqlDetailHeading', { + defaultMessage: 'Result', + })} +

+
+
+ {activePage !== -1 && ( + + + + )} +
+
+ {bodyContent} + + + {i18n.translate('esqlDataGrid.grid.flyout.close', { + defaultMessage: 'Close', + })} + + +
+
+ ); +} + +// eslint-disable-next-line import/no-default-export +export default RowViewer; diff --git a/src/plugins/esql_datagrid/public/row_viewer_lazy.tsx b/src/plugins/esql_datagrid/public/row_viewer_lazy.tsx new file mode 100644 index 000000000000000..a4fea13dffab225 --- /dev/null +++ b/src/plugins/esql_datagrid/public/row_viewer_lazy.tsx @@ -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'))); diff --git a/src/plugins/esql_datagrid/tsconfig.json b/src/plugins/esql_datagrid/tsconfig.json new file mode 100644 index 000000000000000..5db30eb35fd20d8 --- /dev/null +++ b/src/plugins/esql_datagrid/tsconfig.json @@ -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/**/*", + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 912a135068d8828..a3cb9e15a770d25 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc index 17a9812631e398a..7408f5e5dd1c90f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc @@ -24,9 +24,9 @@ "licensing", "ml", "alerting", - "features" + "features", ], - "requiredBundles": ["kibanaReact"], + "requiredBundles": ["kibanaReact", "esqlDataGrid"], "optionalPlugins": ["cloud"], "extraPublicDirs": [] } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.test.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.test.tsx index 6ac300fa7cb3136..836b8f6ef7f9395 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.test.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.test.tsx @@ -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={[]} /> ); @@ -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')); + }); }); }); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx index 334db28a8b89ac3..fe850b18f4d5d30 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/functions/visualize_esql.tsx @@ -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( userOverrides as TypedLensByValueInput ); @@ -238,88 +250,159 @@ export function VisualizeESQL({ if (!lensHelpersAsync.value || !dataViewAsync.value || !lensInput) { return ; } + // 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 ( <> - - {Boolean(errorMessages?.length) && ( - <> - - {i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.errorMessage', { - defaultMessage: 'There were some errors in the generated query', - })} - - - {errorMessages?.map((error, index) => { - return ( - - - - - - {error} - - - ); - })} - - - )} - - - - - { - 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 && ( + + {Boolean(errorMessages?.length) && ( + <> + + {i18n.translate('xpack.observabilityAiAssistant.lensESQLFunction.errorMessage', { + defaultMessage: 'There were some errors in the generated query', })} - /> - - + + + {errorMessages?.map((error, index) => { + return ( + + + + + + {error} + + + ); + })} + + + )} + + + + + + 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', + } + ) + } + /> + + 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', } )} /> - - - - - + + setIsSaveModalOpen(true)} + data-test-subj="observabilityAiAssistantLensESQLSaveButton" + aria-label={i18n.translate( + 'xpack.observabilityAiAssistant.lensESQLFunction.save', + { + defaultMessage: 'Save visualization', + } + )} + /> + + + + + + {isTableVisible ? ( + + ) : ( + + )} + + + )} + {isLensInputTable && ( +
+ - - +
+ )} {isSaveModalOpen ? ( { 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, + }, }; } ); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts index 268b1f5fb5fa6f0..154fb858342e4bd 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/query/validate_esql_query.ts @@ -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 { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/visualize_esql.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/visualize_esql.ts index 1523ca510238a25..1979a0535083a9a 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/visualize_esql.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/functions/visualize_esql.ts @@ -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, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json index ce98c24be2a256b..282363e50ec3fd3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/tsconfig.json @@ -69,6 +69,7 @@ "@kbn/task-manager-plugin", "@kbn/cloud-plugin", "@kbn/observability-plugin", + "@kbn/esql-datagrid", "@kbn/alerting-comparators" ], "exclude": ["target/**/*"] diff --git a/yarn.lock b/yarn.lock index 66c3d86bcbbe7da..c2a5e924f770f0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""