diff --git a/.eslintrc.js b/.eslintrc.js index bd5d31596669a..9eed452827d6a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -636,6 +636,16 @@ module.exports = { }, }, + /** + * Lens overrides + */ + { + files: ['x-pack/legacy/plugins/lens/**/*.ts', 'x-pack/legacy/plugins/lens/**/*.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + }, + }, + /** * disable jsx-a11y for kbn-ui-framework */ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e7d6e8001f1de..0557435b7893b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,6 +2,9 @@ # Identify which groups will be pinged by changes to different parts of the codebase. # For more info, see https://help.github.com/articles/about-codeowners/ +# App +/x-pack/legacy/plugins/lens/ @elastic/kibana-app + # App Architecture /src/plugins/data/ @elastic/kibana-app-arch /src/plugins/kibana_utils/ @elastic/kibana-app-arch diff --git a/packages/kbn-interpreter/src/common/index.d.ts b/packages/kbn-interpreter/src/common/index.d.ts index 7201ccbb35635..bf03795d0a15c 100644 --- a/packages/kbn-interpreter/src/common/index.d.ts +++ b/packages/kbn-interpreter/src/common/index.d.ts @@ -19,4 +19,4 @@ export { Registry } from './lib/registry'; -export { fromExpression, toExpression, Ast } from './lib/ast'; +export { fromExpression, toExpression, Ast, ExpressionFunctionAST } from './lib/ast'; diff --git a/packages/kbn-interpreter/src/common/lib/ast.d.ts b/packages/kbn-interpreter/src/common/lib/ast.d.ts index a4ee235359463..bf073443c34e7 100644 --- a/packages/kbn-interpreter/src/common/lib/ast.d.ts +++ b/packages/kbn-interpreter/src/common/lib/ast.d.ts @@ -17,7 +17,20 @@ * under the License. */ -export type Ast = unknown; +export type ExpressionArgAST = string | boolean | number | Ast; + +export interface ExpressionFunctionAST { + type: 'function'; + function: string; + arguments: { + [key: string]: ExpressionArgAST[]; + }; +} + +export interface Ast { + type: 'expression'; + chain: ExpressionFunctionAST[]; +} export declare function fromExpression(expression: string): Ast; export declare function toExpression(astObj: Ast, type?: string): string; diff --git a/src/fixtures/logstash_fields.js b/src/fixtures/logstash_fields.js index 1bd2c050b4563..5771a01047c2e 100644 --- a/src/fixtures/logstash_fields.js +++ b/src/fixtures/logstash_fields.js @@ -39,7 +39,8 @@ function stubbedLogstashFields() { ['area', 'geo_shape', true, true ], ['hashed', 'murmur3', false, true ], ['geo.coordinates', 'geo_point', true, true ], - ['extension', 'keyword', true, true ], + ['extension', 'text', true, true], + ['extension.keyword', 'keyword', true, true, {}, 'extension', 'multi' ], ['machine.os', 'text', true, true ], ['machine.os.raw', 'keyword', true, true, {}, 'machine.os', 'multi' ], ['geo.src', 'keyword', true, true ], diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx index 5a1a7030b7119..b05201c32ee63 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_top_row.tsx @@ -359,5 +359,4 @@ export class QueryBarTopRowUI extends Component { } } -// @ts-ignore export const QueryBarTopRow = injectI18n(QueryBarTopRowUI); diff --git a/src/legacy/core_plugins/expressions/public/expressions/expression_renderer.tsx b/src/legacy/core_plugins/expressions/public/expressions/expression_renderer.tsx index e5358acc1c05c..11921ca9cf269 100644 --- a/src/legacy/core_plugins/expressions/public/expressions/expression_renderer.tsx +++ b/src/legacy/core_plugins/expressions/public/expressions/expression_renderer.tsx @@ -26,7 +26,7 @@ import { IExpressionLoader, ExpressionLoader } from './lib/loader'; // Accept all options of the runner as props except for the // dom element which is provided by the component itself export interface ExpressionRendererProps extends IExpressionLoaderParams { - className: 'string'; + className: string; expression: string | ExpressionAST; /** * If an element is specified, but the response of the expression run can't be rendered diff --git a/src/legacy/core_plugins/expressions/public/expressions/expressions_service.ts b/src/legacy/core_plugins/expressions/public/expressions/expressions_service.ts index 4b8124132a11f..7442eb47bee4b 100644 --- a/src/legacy/core_plugins/expressions/public/expressions/expressions_service.ts +++ b/src/legacy/core_plugins/expressions/public/expressions/expressions_service.ts @@ -24,6 +24,7 @@ import { setInspector, setInterpreter } from './services'; import { execute } from './lib/execute'; import { loader } from './lib/loader'; import { render } from './lib/render'; +import { IInterpreter } from './lib/_types'; import { createRenderer } from './expression_renderer'; import { Start as IInspector } from '../../../../../plugins/inspector/public'; @@ -40,7 +41,9 @@ export class ExpressionsService { // eslint-disable-next-line const { getInterpreter } = require('../../../interpreter/public/interpreter'); getInterpreter() - .then(setInterpreter) + .then(({ interpreter }: { interpreter: IInterpreter }) => { + setInterpreter(interpreter); + }) .catch((e: Error) => { throw new Error('interpreter is not initialized'); }); diff --git a/src/legacy/core_plugins/expressions/public/expressions/lib/_types.ts b/src/legacy/core_plugins/expressions/public/expressions/lib/_types.ts index 6345789a3b50b..91a20295a16b8 100644 --- a/src/legacy/core_plugins/expressions/public/expressions/lib/_types.ts +++ b/src/legacy/core_plugins/expressions/public/expressions/lib/_types.ts @@ -17,8 +17,8 @@ * under the License. */ -import { TimeRange } from 'src/plugins/data/public'; import { Filter } from '@kbn/es-query'; +import { TimeRange } from '../../../../../../plugins/data/public'; import { Adapters } from '../../../../../../plugins/inspector/public'; import { Query } from '../../../../../../plugins/data/public'; import { ExpressionAST } from '../../../../../../plugins/expressions/common'; @@ -68,13 +68,13 @@ export interface IInterpreterRenderHandlers { event: (event: event) => void; } -export interface IInterpreterRenderFunction { +export interface IInterpreterRenderFunction { name: string; displayName: string; help: string; validate: () => void; reuseDomNode: boolean; - render: (domNode: Element, data: unknown, handlers: IInterpreterRenderHandlers) => void; + render: (domNode: Element, data: T, handlers: IInterpreterRenderHandlers) => void | Promise; } export interface IInterpreter { diff --git a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js index 39f86c8ce7642..e6f79fd6a495a 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js @@ -50,6 +50,7 @@ class VisualizeListingTableUi extends Component { editItem={capabilities.get().visualize.save ? this.props.editItem : null} tableColumns={this.getTableColumns()} listingLimit={this.props.listingLimit} + selectable={item => item.canDelete} initialFilter={''} noItemsFragment={this.getNoItemsMessage()} entityName={ diff --git a/src/legacy/plugin_discovery/types.ts b/src/legacy/plugin_discovery/types.ts index 76b62b7eb693c..6d7c59893dfe6 100644 --- a/src/legacy/plugin_discovery/types.ts +++ b/src/legacy/plugin_discovery/types.ts @@ -58,6 +58,7 @@ export interface LegacyPluginOptions { icon: string; euiIconType: string; order: number; + listed: boolean; }>; apps: any; hacks: string[]; diff --git a/src/plugins/data/common/query/index.ts b/src/plugins/data/common/query/index.ts new file mode 100644 index 0000000000000..d8f7b5091eb8f --- /dev/null +++ b/src/plugins/data/common/query/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './types'; diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 26e72882a08a9..b92edfd06ffb1 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -18,6 +18,7 @@ "xpack.indexLifecycleMgmt": "legacy/plugins/index_lifecycle_management", "xpack.infra": "legacy/plugins/infra", "xpack.kueryAutocomplete": "legacy/plugins/kuery_autocomplete", + "xpack.lens": "legacy/plugins/lens", "xpack.licensing": "plugins/licensing", "xpack.licenseMgmt": "legacy/plugins/license_management", "xpack.maps": "legacy/plugins/maps", diff --git a/x-pack/index.js b/x-pack/index.js index e1c12e75e053c..c78a50273e324 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -44,6 +44,7 @@ import { snapshotRestore } from './legacy/plugins/snapshot_restore'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; import { advancedUiActions } from './legacy/plugins/advanced_ui_actions'; +import { lens } from './legacy/plugins/lens'; module.exports = function (kibana) { return [ @@ -83,6 +84,7 @@ module.exports = function (kibana) { ossTelemetry(kibana), fileUpload(kibana), encryptedSavedObjects(kibana), + lens(kibana), snapshotRestore(kibana), actions(kibana), alerting(kibana), diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx index 1c65ec8beab58..765a275dc99e8 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/dropdown_filter/index.tsx @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore - Interpreter not typed yet -import { fromExpression, toExpression } from '@kbn/interpreter/common'; +import { fromExpression, toExpression, Ast } from '@kbn/interpreter/common'; import { get } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -58,7 +57,7 @@ export const dropdownFilter: RendererFactory = () => ({ if (commitValue === '%%CANVAS_MATCH_ALL%%') { handlers.setFilter(''); } else { - const newFilterAST = { + const newFilterAST: Ast = { type: 'expression', chain: [ { diff --git a/x-pack/legacy/plugins/lens/common/constants.ts b/x-pack/legacy/plugins/lens/common/constants.ts new file mode 100644 index 0000000000000..787a348a788b8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/common/constants.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PLUGIN_ID = 'lens'; + +export const BASE_APP_URL = '/app/lens'; +export const BASE_API_URL = '/api/lens'; + +export function getEditPath(id: string) { + return `${BASE_APP_URL}#/edit/${encodeURIComponent(id)}`; +} diff --git a/x-pack/legacy/plugins/lens/common/index.ts b/x-pack/legacy/plugins/lens/common/index.ts new file mode 100644 index 0000000000000..358d0d5b7e076 --- /dev/null +++ b/x-pack/legacy/plugins/lens/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './constants'; diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts new file mode 100644 index 0000000000000..399a65041b664 --- /dev/null +++ b/x-pack/legacy/plugins/lens/index.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Joi from 'joi'; +import { resolve } from 'path'; +import { LegacyPluginInitializer } from 'src/legacy/types'; +import KbnServer, { Server } from 'src/legacy/server/kbn_server'; +import { CoreSetup } from 'src/core/server'; +import mappings from './mappings.json'; +import { PLUGIN_ID, getEditPath, BASE_API_URL } from './common'; +import { lensServerPlugin } from './server'; + +const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; + +export const lens: LegacyPluginInitializer = kibana => { + return new kibana.Plugin({ + id: PLUGIN_ID, + configPrefix: `xpack.${PLUGIN_ID}`, + require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter', 'data'], + publicDir: resolve(__dirname, 'public'), + + uiExports: { + app: { + title: NOT_INTERNATIONALIZED_PRODUCT_NAME, + description: 'Explore and visualize data.', + main: `plugins/${PLUGIN_ID}/index`, + listed: false, + }, + embeddableFactories: ['plugins/lens/register_embeddable'], + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + mappings, + visTypes: ['plugins/lens/register_vis_type_alias'], + savedObjectsManagement: { + lens: { + defaultSearchField: 'title', + isImportableAndExportable: true, + getTitle: (obj: { attributes: { title: string } }) => obj.attributes.title, + getInAppUrl: (obj: { id: string }) => ({ + path: getEditPath(obj.id), + uiCapabilitiesPath: 'lens.show', + }), + }, + }, + }, + + config: () => { + return Joi.object({ + enabled: Joi.boolean().default(true), + }).default(); + }, + + init(server: Server) { + const kbnServer = (server as unknown) as KbnServer; + + server.plugins.xpack_main.registerFeature({ + id: PLUGIN_ID, + name: NOT_INTERNATIONALIZED_PRODUCT_NAME, + app: [PLUGIN_ID, 'kibana'], + catalogue: [PLUGIN_ID], + privileges: { + all: { + api: [PLUGIN_ID], + catalogue: [PLUGIN_ID], + savedObject: { + all: [], + read: [], + }, + ui: ['save', 'show'], + }, + read: { + api: [PLUGIN_ID], + catalogue: [PLUGIN_ID], + savedObject: { + all: [], + read: [], + }, + ui: ['show'], + }, + }, + }); + + // Set up with the new platform plugin lifecycle API. + const plugin = lensServerPlugin(); + plugin.setup(({ + http: { + ...kbnServer.newPlatform.setup.core.http, + createRouter: () => kbnServer.newPlatform.setup.core.http.createRouter(BASE_API_URL), + }, + } as unknown) as CoreSetup); + + server.events.on('stop', () => { + plugin.stop(); + }); + }, + }); +}; diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json new file mode 100644 index 0000000000000..8eccf22eb2235 --- /dev/null +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -0,0 +1,20 @@ +{ + "lens": { + "properties": { + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + }, + "state": { + "enabled": false, + "type": "object" + }, + "expression": { + "index": false, + "type": "keyword" + } + } + } +} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx new file mode 100644 index 0000000000000..c2cd20f702f2b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -0,0 +1,436 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { App } from './app'; +import { EditorFrameInstance } from '../types'; +import { Storage } from 'ui/storage'; +import { Document, SavedObjectStore } from '../persistence'; +import { mount } from 'enzyme'; +import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { coreMock } from 'src/core/public/mocks'; + +jest.mock('../../../../../../src/legacy/core_plugins/data/public/query/query_bar', () => ({ + QueryBarTopRow: jest.fn(() => null), +})); + +jest.mock('ui/new_platform'); +jest.mock('../persistence'); +jest.mock('src/core/public'); + +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + +function createMockFrame(): jest.Mocked { + return { + mount: jest.fn((el, props) => {}), + unmount: jest.fn(() => {}), + }; +} + +describe('Lens App', () => { + let frame: jest.Mocked; + let core: ReturnType; + + function makeDefaultArgs(): jest.Mocked<{ + editorFrame: EditorFrameInstance; + core: typeof core; + store: Storage; + docId?: string; + docStorage: SavedObjectStore; + redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; + }> { + return ({ + editorFrame: createMockFrame(), + core, + store: { + get: jest.fn(), + }, + docStorage: { + load: jest.fn(), + save: jest.fn(), + }, + QueryBarTopRow: jest.fn(() =>
), + redirectTo: jest.fn(id => {}), + savedObjectsClient: jest.fn(), + } as unknown) as jest.Mocked<{ + editorFrame: EditorFrameInstance; + core: typeof core; + store: Storage; + docId?: string; + docStorage: SavedObjectStore; + redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; + }>; + } + + beforeEach(() => { + frame = createMockFrame(); + core = coreMock.createStart(); + + core.uiSettings.get.mockImplementation( + jest.fn(type => { + if (type === 'timepicker:timeDefaults') { + return { from: 'now-7d', to: 'now' }; + } else if (type === 'search:queryLanguage') { + return 'kuery'; + } else { + return []; + } + }) + ); + + (core.http.basePath.get as jest.Mock).mockReturnValue(`/testbasepath`); + (core.http.basePath.prepend as jest.Mock).mockImplementation(s => `/testbasepath${s}`); + }); + + it('renders the editor frame', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + mount(); + + expect(frame.mount.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ +
, + Object { + "dateRange": Object { + "fromDate": "now-7d", + "toDate": "now", + }, + "doc": undefined, + "onChange": [Function], + "onError": [Function], + "query": Object { + "language": "kuery", + "query": "", + }, + }, + ], + ] + `); + }); + + it('sets breadcrumbs when the document title changes', async () => { + const defaultArgs = makeDefaultArgs(); + const instance = mount(); + + expect(core.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + { text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' }, + { text: 'Create' }, + ]); + + (defaultArgs.docStorage.load as jest.Mock).mockResolvedValue({ + id: '1234', + title: 'Daaaaaaadaumching!', + state: { + query: 'fake query', + datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, + }, + }); + + instance.setProps({ docId: '1234' }); + await waitForPromises(); + + expect(defaultArgs.core.chrome.setBreadcrumbs).toHaveBeenCalledWith([ + { text: 'Visualize', href: '/testbasepath/app/kibana#/visualize' }, + { text: 'Daaaaaaadaumching!' }, + ]); + }); + + describe('persistence', () => { + it('does not load a document if there is no document id', () => { + const args = makeDefaultArgs(); + + mount(); + + expect(args.docStorage.load).not.toHaveBeenCalled(); + }); + + it('loads a document and uses query if there is a document id', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + (args.docStorage.load as jest.Mock).mockResolvedValue({ + id: '1234', + state: { + query: 'fake query', + datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, + }, + }); + + const instance = mount(); + + instance.setProps({ docId: '1234' }); + await waitForPromises(); + + expect(args.docStorage.load).toHaveBeenCalledWith('1234'); + expect(QueryBarTopRow).toHaveBeenCalledWith( + expect.objectContaining({ + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + query: 'fake query', + indexPatterns: ['saved'], + }), + {} + ); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + doc: { + id: '1234', + state: { + query: 'fake query', + datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'saved' }] }, + }, + }, + }) + ); + }); + + it('does not load documents on sequential renders unless the id changes', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234' }); + + const instance = mount(); + + instance.setProps({ docId: '1234' }); + await waitForPromises(); + instance.setProps({ docId: '1234' }); + await waitForPromises(); + + expect(args.docStorage.load).toHaveBeenCalledTimes(1); + + instance.setProps({ docId: '9876' }); + await waitForPromises(); + + expect(args.docStorage.load).toHaveBeenCalledTimes(2); + }); + + it('handles document load errors', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + (args.docStorage.load as jest.Mock).mockRejectedValue('failed to load'); + + const instance = mount(); + + instance.setProps({ docId: '1234' }); + await waitForPromises(); + + expect(args.docStorage.load).toHaveBeenCalledWith('1234'); + expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled(); + expect(args.redirectTo).toHaveBeenCalled(); + }); + + describe('save button', () => { + it('shows a save button that is enabled when the frame has provided its state', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + const instance = mount(); + + expect( + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('disabled') + ).toEqual(true); + + const onChange = frame.mount.mock.calls[0][1].onChange; + onChange({ indexPatternTitles: [], doc: ('will save this' as unknown) as Document }); + + instance.update(); + + expect( + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('disabled') + ).toEqual(false); + }); + + it('saves the latest doc and then prevents more saving', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + (args.docStorage.save as jest.Mock).mockResolvedValue({ id: '1234' }); + + const instance = mount(); + + expect(frame.mount).toHaveBeenCalledTimes(1); + + const onChange = frame.mount.mock.calls[0][1].onChange; + onChange({ indexPatternTitles: [], doc: ({ id: undefined } as unknown) as Document }); + + instance.update(); + + expect( + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('disabled') + ).toEqual(false); + + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('onClick')!({} as React.MouseEvent); + + expect(args.docStorage.save).toHaveBeenCalledWith({ id: undefined }); + + await waitForPromises(); + + expect(args.redirectTo).toHaveBeenCalledWith('1234'); + + instance.setProps({ docId: '1234' }); + + expect(args.docStorage.load).not.toHaveBeenCalled(); + + expect( + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('disabled') + ).toEqual(true); + }); + + it('handles save failure by showing a warning, but still allows another save', async () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + (args.docStorage.save as jest.Mock).mockRejectedValue({ message: 'failed' }); + + const instance = mount(); + + const onChange = frame.mount.mock.calls[0][1].onChange; + onChange({ indexPatternTitles: [], doc: ({ id: undefined } as unknown) as Document }); + + instance.update(); + + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('onClick')!({} as React.MouseEvent); + + await waitForPromises(); + + expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled(); + expect(args.redirectTo).not.toHaveBeenCalled(); + await waitForPromises(); + + expect( + instance + .find('[data-test-subj="lnsApp_saveButton"]') + .first() + .prop('disabled') + ).toEqual(false); + }); + }); + }); + + describe('query bar state management', () => { + it('uses the default time and query language settings', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + mount(); + + expect(QueryBarTopRow).toHaveBeenCalledWith( + expect.objectContaining({ + dateRangeFrom: 'now-7d', + dateRangeTo: 'now', + query: { query: '', language: 'kuery' }, + }), + {} + ); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'kuery' }, + }) + ); + }); + + it('updates the index patterns when the editor frame is changed', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + const instance = mount(); + + expect(QueryBarTopRow).toHaveBeenCalledWith( + expect.objectContaining({ + indexPatterns: [], + }), + {} + ); + + const onChange = frame.mount.mock.calls[0][1].onChange; + onChange({ + indexPatternTitles: ['newIndex'], + doc: ({ id: undefined } as unknown) as Document, + }); + + instance.update(); + + expect(QueryBarTopRow).toHaveBeenCalledWith( + expect.objectContaining({ + indexPatterns: ['newIndex'], + }), + {} + ); + }); + + it('updates the editor frame when the user changes query or time', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + const instance = mount(); + + instance + .find('[data-test-subj="lnsApp_queryBar"]') + .first() + .prop('onSubmit')!(({ + dateRange: { from: 'now-14d', to: 'now-7d' }, + query: { query: 'new', language: 'lucene' }, + } as unknown) as React.FormEvent); + + instance.update(); + + expect(QueryBarTopRow).toHaveBeenCalledWith( + expect.objectContaining({ + dateRangeFrom: 'now-14d', + dateRangeTo: 'now-7d', + query: { query: 'new', language: 'lucene' }, + }), + {} + ); + expect(frame.mount).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + dateRange: { fromDate: 'now-14d', toDate: 'now-7d' }, + query: { query: 'new', language: 'lucene' }, + }) + ); + }); + }); + + it('displays errors from the frame in a toast', () => { + const args = makeDefaultArgs(); + args.editorFrame = frame; + + const instance = mount(); + + const onError = frame.mount.mock.calls[0][1].onError; + onError({ message: 'error' }); + + instance.update(); + + expect(args.core.notifications.toasts.addDanger).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx new file mode 100644 index 0000000000000..acbb8c51bac12 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Storage } from 'ui/storage'; +import { CoreStart, SavedObjectsClientContract } from 'src/core/public'; +import { Query } from '../../../../../../src/legacy/core_plugins/data/public/query'; +import { QueryBarTopRow } from '../../../../../../src/legacy/core_plugins/data/public/query/query_bar'; +import { Document, SavedObjectStore } from '../persistence'; +import { EditorFrameInstance } from '../types'; +import { NativeRenderer } from '../native_renderer'; + +interface State { + isLoading: boolean; + isDirty: boolean; + dateRange: { + fromDate: string; + toDate: string; + }; + query: Query; + indexPatternTitles: string[]; + persistedDoc?: Document; + localQueryBarState: { + query?: Query; + dateRange?: { + from: string; + to: string; + }; + }; +} + +function isLocalStateDirty( + localState: State['localQueryBarState'], + query: Query, + dateRange: State['dateRange'] +) { + return Boolean( + (localState.query && query && localState.query.query !== query.query) || + (localState.dateRange && dateRange.fromDate !== localState.dateRange.from) || + (localState.dateRange && dateRange.toDate !== localState.dateRange.to) + ); +} + +export function App({ + editorFrame, + core, + store, + docId, + docStorage, + redirectTo, + savedObjectsClient, +}: { + editorFrame: EditorFrameInstance; + core: CoreStart; + store: Storage; + docId?: string; + docStorage: SavedObjectStore; + redirectTo: (id?: string) => void; + savedObjectsClient: SavedObjectsClientContract; +}) { + const timeDefaults = core.uiSettings.get('timepicker:timeDefaults'); + const language = + store.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); + + const [state, setState] = useState({ + isLoading: !!docId, + isDirty: false, + query: { query: '', language }, + dateRange: { + fromDate: timeDefaults.from, + toDate: timeDefaults.to, + }, + indexPatternTitles: [], + localQueryBarState: { + query: { query: '', language }, + dateRange: { + from: timeDefaults.from, + to: timeDefaults.to, + }, + }, + }); + + const lastKnownDocRef = useRef(undefined); + + // Sync Kibana breadcrumbs any time the saved document's title changes + useEffect(() => { + core.chrome.setBreadcrumbs([ + { + href: core.http.basePath.prepend(`/app/kibana#/visualize`), + text: i18n.translate('xpack.lens.breadcrumbsTitle', { + defaultMessage: 'Visualize', + }), + }, + { + text: state.persistedDoc + ? state.persistedDoc.title + : i18n.translate('xpack.lens.breadcrumbsCreate', { defaultMessage: 'Create' }), + }, + ]); + }, [state.persistedDoc && state.persistedDoc.title]); + + useEffect(() => { + if (docId && (!state.persistedDoc || state.persistedDoc.id !== docId)) { + setState({ ...state, isLoading: true }); + docStorage + .load(docId) + .then(doc => { + setState({ + ...state, + isLoading: false, + persistedDoc: doc, + query: doc.state.query, + localQueryBarState: { + ...state.localQueryBarState, + query: doc.state.query, + }, + indexPatternTitles: doc.state.datasourceMetaData.filterableIndexPatterns.map( + ({ title }) => title + ), + }); + }) + .catch(() => { + setState({ ...state, isLoading: false }); + + core.notifications.toasts.addDanger( + i18n.translate('xpack.lens.editorFrame.docLoadingError', { + defaultMessage: 'Error loading saved document', + }) + ); + + redirectTo(); + }); + } + }, [docId]); + + // Can save if the frame has told us what it has, and there is either: + // a) No saved doc + // b) A saved doc that differs from the frame state + const isSaveable = state.isDirty; + + const onError = useCallback( + (e: { message: string }) => + core.notifications.toasts.addDanger({ + title: e.message, + }), + [] + ); + + return ( + +
+
+ + { + const { dateRange, query } = payload; + setState({ + ...state, + dateRange: { + fromDate: dateRange.from, + toDate: dateRange.to, + }, + query: query || state.query, + localQueryBarState: payload, + }); + }} + onChange={localQueryBarState => { + setState({ ...state, localQueryBarState }); + }} + isDirty={isLocalStateDirty(state.localQueryBarState, state.query, state.dateRange)} + appName={'lens'} + indexPatterns={state.indexPatternTitles} + store={store} + showDatePicker={true} + showQueryInput={true} + query={state.localQueryBarState.query} + dateRangeFrom={ + state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.from + } + dateRangeTo={ + state.localQueryBarState.dateRange && state.localQueryBarState.dateRange.to + } + uiSettings={core.uiSettings} + savedObjectsClient={savedObjectsClient} + http={core.http} + /> +
+ + {(!state.isLoading || state.persistedDoc) && ( + { + const indexPatternChange = !_.isEqual(state.indexPatternTitles, indexPatternTitles); + const docChange = !_.isEqual(state.persistedDoc, doc); + if (indexPatternChange || docChange) { + setState({ + ...state, + indexPatternTitles, + isDirty: docChange, + }); + } + lastKnownDocRef.current = doc; + }, + }} + /> + )} +
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/index.scss b/x-pack/legacy/plugins/lens/public/app_plugin/index.scss new file mode 100644 index 0000000000000..16c3025711573 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/app_plugin/index.scss @@ -0,0 +1,23 @@ +.lnsApp { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.lnsAppHeader { + padding: $euiSize; + border-bottom: $euiBorderThin; +} + +.lnsAppFrame { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; +} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/index.ts b/x-pack/legacy/plugins/lens/public/app_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/app_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx new file mode 100644 index 0000000000000..a29356a084063 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; +import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; +import chrome from 'ui/chrome'; +import { Storage } from 'ui/storage'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { npSetup, npStart } from 'ui/new_platform'; +import { editorFrameSetup, editorFrameStart, editorFrameStop } from '../editor_frame_plugin'; +import { indexPatternDatasourceSetup, indexPatternDatasourceStop } from '../indexpattern_plugin'; +import { SavedObjectIndexStore } from '../persistence'; +import { xyVisualizationSetup, xyVisualizationStop } from '../xy_visualization_plugin'; +import { metricVisualizationSetup, metricVisualizationStop } from '../metric_visualization_plugin'; +import { + datatableVisualizationSetup, + datatableVisualizationStop, +} from '../datatable_visualization_plugin'; +import { App } from './app'; +import { EditorFrameInstance } from '../types'; + +export class AppPlugin { + private instance: EditorFrameInstance | null = null; + private store: SavedObjectIndexStore | null = null; + + constructor() {} + + setup(core: CoreSetup) { + // TODO: These plugins should not be called from the top level, but since this is the + // entry point to the app we have no choice until the new platform is ready + const indexPattern = indexPatternDatasourceSetup(); + const datatableVisualization = datatableVisualizationSetup(); + const xyVisualization = xyVisualizationSetup(); + const metricVisualization = metricVisualizationSetup(); + const editorFrameSetupInterface = editorFrameSetup(); + this.store = new SavedObjectIndexStore(chrome!.getSavedObjectsClient()); + + editorFrameSetupInterface.registerDatasource('indexpattern', indexPattern); + editorFrameSetupInterface.registerVisualization(xyVisualization); + editorFrameSetupInterface.registerVisualization(datatableVisualization); + editorFrameSetupInterface.registerVisualization(metricVisualization); + } + + start(core: CoreStart) { + if (this.store === null) { + throw new Error('Start lifecycle called before setup lifecycle'); + } + + const store = this.store; + + const editorFrameStartInterface = editorFrameStart(); + + this.instance = editorFrameStartInterface.createInstance({}); + + const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { + return ( + { + if (!id) { + routeProps.history.push('/'); + } else { + routeProps.history.push(`/edit/${id}`); + } + }} + /> + ); + }; + + function NotFound() { + return ; + } + + return ( + + + + + + + + + + ); + } + + stop() { + if (this.instance) { + this.instance.unmount(); + } + + // TODO this will be handled by the plugin platform itself + indexPatternDatasourceStop(); + xyVisualizationStop(); + metricVisualizationStop(); + datatableVisualizationStop(); + editorFrameStop(); + } +} + +const app = new AppPlugin(); + +export const appSetup = () => app.setup(npSetup.core); +export const appStart = () => app.start(npStart.core); +export const appStop = () => app.stop(); diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx new file mode 100644 index 0000000000000..5d4a534d5c262 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/expression.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable } from '@elastic/eui'; +import { ExpressionFunction } from '../../../../../../src/plugins/expressions/common'; +import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { LensMultiTable } from '../types'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; + +export interface DatatableColumns { + columnIds: string[]; + labels: string[]; +} + +interface Args { + columns: DatatableColumns; +} + +export interface DatatableProps { + data: LensMultiTable; + args: Args; +} + +export interface DatatableRender { + type: 'render'; + as: 'lens_datatable_renderer'; + value: DatatableProps; +} + +export const datatable: ExpressionFunction< + 'lens_datatable', + KibanaDatatable, + Args, + DatatableRender +> = ({ + name: 'lens_datatable', + type: 'render', + help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', { + defaultMessage: 'Datatable renderer', + }), + args: { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.datatable.titleLabel', { + defaultMessage: 'Title', + }), + }, + columns: { + types: ['lens_datatable_columns'], + help: '', + }, + }, + context: { + types: ['lens_multitable'], + }, + fn(data: KibanaDatatable, args: Args) { + return { + type: 'render', + as: 'lens_datatable_renderer', + value: { + data, + args, + }, + }; + }, + // TODO the typings currently don't support custom type args. As soon as they do, this can be removed +} as unknown) as ExpressionFunction<'lens_datatable', KibanaDatatable, Args, DatatableRender>; + +type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' }; + +export const datatableColumns: ExpressionFunction< + 'lens_datatable_columns', + null, + DatatableColumns, + DatatableColumnsResult +> = { + name: 'lens_datatable_columns', + aliases: [], + type: 'lens_datatable_columns', + help: '', + context: { + types: ['null'], + }, + args: { + columnIds: { + types: ['string'], + multi: true, + help: '', + }, + labels: { + types: ['string'], + multi: true, + help: '', + }, + }, + fn: function fn(_context: unknown, args: DatatableColumns) { + return { + type: 'lens_datatable_columns', + ...args, + }; + }, +}; + +export const getDatatableRenderer = ( + formatFactory: FormatFactory +): IInterpreterRenderFunction => ({ + name: 'lens_datatable_renderer', + displayName: i18n.translate('xpack.lens.datatable.visualizationName', { + defaultMessage: 'Datatable', + }), + help: '', + validate: () => {}, + reuseDomNode: true, + render: async (domNode: Element, config: DatatableProps, _handlers: unknown) => { + ReactDOM.render(, domNode); + }, +}); + +function DatatableComponent(props: DatatableProps & { formatFactory: FormatFactory }) { + const [firstTable] = Object.values(props.data.tables); + const formatters: Record> = {}; + + firstTable.columns.forEach(column => { + formatters[column.id] = props.formatFactory(column.formatHint); + }); + + return ( + { + return { + field, + name: props.args.columns.labels[index], + }; + }) + .filter(({ field }) => !!field)} + items={ + firstTable + ? firstTable.rows.map(row => { + const formattedRow: Record = {}; + Object.entries(formatters).forEach(([columnId, formatter]) => { + formattedRow[columnId] = formatter.convert(row[columnId]); + }); + return formattedRow; + }) + : [] + } + /> + ); +} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.scss b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.scss new file mode 100644 index 0000000000000..e36326d710f72 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.scss @@ -0,0 +1,3 @@ +.lnsDataTable { + align-self: flex-start; +} diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..88f029bd8b179 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/plugin.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { npSetup } from 'ui/new_platform'; +import { datatableVisualization } from './visualization'; +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { setup as expressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public/legacy'; +import { datatable, datatableColumns, getDatatableRenderer } from './expression'; + +export interface DatatableVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + // TODO this is a simulated NP plugin. + // Once field formatters are actually migrated, the actual shim can be used + fieldFormat: { + formatFactory: FormatFactory; + }; +} + +class DatatableVisualizationPlugin { + constructor() {} + + setup( + _core: CoreSetup | null, + { expressions, fieldFormat }: DatatableVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatable); + expressions.registerRenderer(() => getDatatableRenderer(fieldFormat.formatFactory)); + + return datatableVisualization; + } + + stop() {} +} + +const plugin = new DatatableVisualizationPlugin(); + +export const datatableVisualizationSetup = () => + plugin.setup(npSetup.core, { + expressions: expressionsSetup, + fieldFormat: { + formatFactory: getFormat, + }, + }); +export const datatableVisualizationStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx new file mode 100644 index 0000000000000..177dfc9577028 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { createMockDatasource } from '../editor_frame_plugin/mocks'; +import { + DatatableVisualizationState, + datatableVisualization, + DataTableLayer, +} from './visualization'; +import { mount } from 'enzyme'; +import { Operation, DataType, FramePublicAPI } from '../types'; +import { generateId } from '../id_generator'; + +jest.mock('../id_generator'); + +function mockFrame(): FramePublicAPI { + return { + addNewLayer: () => 'aaa', + removeLayers: () => {}, + datasourceLayers: {}, + query: { query: '', language: 'lucene' }, + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + }; +} + +describe('Datatable Visualization', () => { + describe('#initialize', () => { + it('should initialize from the empty state', () => { + (generateId as jest.Mock).mockReturnValueOnce('id'); + expect(datatableVisualization.initialize(mockFrame(), undefined)).toEqual({ + layers: [ + { + layerId: 'aaa', + columns: ['id'], + }, + ], + }); + }); + + it('should initialize from a persisted state', () => { + const expectedState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect(datatableVisualization.initialize(mockFrame(), expectedState)).toEqual(expectedState); + }); + }); + + describe('#getPersistableState', () => { + it('should persist the internal state', () => { + const expectedState: DatatableVisualizationState = { + layers: [ + { + layerId: 'baz', + columns: ['a', 'b', 'c'], + }, + ], + }; + expect(datatableVisualization.getPersistableState(expectedState)).toEqual(expectedState); + }); + }); + + describe('DataTableLayer', () => { + it('allows all kinds of operations', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + const layer = { layerId: 'a', columns: ['b', 'c'] }; + const frame = mockFrame(); + frame.datasourceLayers = { a: datasource.publicAPIMock }; + + mount( + {} }} + frame={frame} + layer={layer} + setState={setState} + state={{ layers: [layer] }} + /> + ); + + expect(datasource.publicAPIMock.renderDimensionPanel).toHaveBeenCalled(); + + const filterOperations = + datasource.publicAPIMock.renderDimensionPanel.mock.calls[0][1].filterOperations; + + const baseOperation: Operation = { + dataType: 'string', + isBucketed: true, + label: '', + }; + expect(filterOperations({ ...baseOperation })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'number' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'date' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(true); + expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + true + ); + }); + + it('allows columns to be removed', () => { + const setState = jest.fn(); + const datasource = createMockDatasource(); + const layer = { layerId: 'a', columns: ['b', 'c'] }; + const frame = mockFrame(); + frame.datasourceLayers = { a: datasource.publicAPIMock }; + const component = mount( + {} }} + frame={frame} + layer={layer} + setState={setState} + state={{ layers: [layer] }} + /> + ); + + const onRemove = component + .find('[data-test-subj="datatable_multicolumnEditor"]') + .first() + .prop('onRemove') as (k: string) => {}; + + onRemove('b'); + + expect(setState).toHaveBeenCalledWith({ + layers: [ + { + layerId: 'a', + columns: ['c'], + }, + ], + }); + }); + + it('allows columns to be added', () => { + (generateId as jest.Mock).mockReturnValueOnce('d'); + const setState = jest.fn(); + const datasource = createMockDatasource(); + const layer = { layerId: 'a', columns: ['b', 'c'] }; + const frame = mockFrame(); + frame.datasourceLayers = { a: datasource.publicAPIMock }; + const component = mount( + {} }} + frame={frame} + layer={layer} + setState={setState} + state={{ layers: [layer] }} + /> + ); + + const onAdd = component + .find('[data-test-subj="datatable_multicolumnEditor"]') + .first() + .prop('onAdd') as () => {}; + + onAdd(); + + expect(setState).toHaveBeenCalledWith({ + layers: [ + { + layerId: 'a', + columns: ['b', 'c', 'd'], + }, + ], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx new file mode 100644 index 0000000000000..b3f3a389c4490 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/datatable_visualization_plugin/visualization.tsx @@ -0,0 +1,231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { EuiForm, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { MultiColumnEditor } from '../multi_column_editor'; +import { + SuggestionRequest, + Visualization, + VisualizationProps, + VisualizationSuggestion, + Operation, +} from '../types'; +import { generateId } from '../id_generator'; +import { NativeRenderer } from '../native_renderer'; + +export interface LayerState { + layerId: string; + columns: string[]; +} + +export interface DatatableVisualizationState { + layers: LayerState[]; +} + +function newLayerState(layerId: string): LayerState { + return { + layerId, + columns: [generateId()], + }; +} + +function updateColumns( + state: DatatableVisualizationState, + layer: LayerState, + fn: (columns: string[]) => string[] +) { + const columns = fn(layer.columns); + const updatedLayer = { ...layer, columns }; + const layers = state.layers.map(l => (l.layerId === layer.layerId ? updatedLayer : l)); + return { ...state, layers }; +} + +const allOperations = () => true; + +export function DataTableLayer({ + layer, + frame, + state, + setState, + dragDropContext, +}: { layer: LayerState } & VisualizationProps) { + const datasource = frame.datasourceLayers[layer.layerId]; + return ( + + + + + + setState(updateColumns(state, layer, columns => [...columns, generateId()]))} + onRemove={column => + setState(updateColumns(state, layer, columns => columns.filter(c => c !== column))) + } + testSubj="datatable_columns" + data-test-subj="datatable_multicolumnEditor" + /> + + + ); +} + +export const datatableVisualization: Visualization< + DatatableVisualizationState, + DatatableVisualizationState +> = { + id: 'lnsDatatable', + + visualizationTypes: [ + { + id: 'lnsDatatable', + icon: 'visTable', + label: i18n.translate('xpack.lens.datatable.label', { + defaultMessage: 'Datatable', + }), + }, + ], + + getDescription(state) { + return { + icon: 'visTable', + label: i18n.translate('xpack.lens.datatable.label', { + defaultMessage: 'Datatable', + }), + }; + }, + + switchVisualizationType: (_, state) => state, + + initialize(frame, state) { + const layerId = Object.keys(frame.datasourceLayers)[0] || frame.addNewLayer(); + return ( + state || { + layers: [newLayerState(layerId)], + } + ); + }, + + getPersistableState: state => state, + + getSuggestions({ + table, + state, + }: SuggestionRequest): Array< + VisualizationSuggestion + > { + if (state && table.changeType === 'unchanged') { + return []; + } + const title = + table.changeType === 'unchanged' + ? i18n.translate('xpack.lens.datatable.suggestionLabel', { + defaultMessage: 'As table', + }) + : i18n.translate('xpack.lens.datatable.visualizationOf', { + defaultMessage: 'Table {operations}', + values: { + operations: + table.label || + table.columns + .map(col => col.operation.label) + .join( + i18n.translate('xpack.lens.datatable.conjunctionSign', { + defaultMessage: ' & ', + description: + 'A character that can be used for conjunction of multiple enumarated items. Make sure to include spaces around it if needed.', + }) + ), + }, + }); + + return [ + { + title, + // table with >= 10 columns will have a score of 0.6, fewer columns reduce score + score: (Math.min(table.columns.length, 10) / 10) * 0.6, + state: { + layers: [ + { + layerId: table.layerId, + columns: table.columns.map(col => col.columnId), + }, + ], + }, + previewIcon: 'visTable', + // dont show suggestions for reduced versions or single-line tables + hide: table.changeType === 'reduced' || !table.isMultiRow, + }, + ]; + }, + + renderConfigPanel: (domElement, props) => + render( + + + {props.state.layers.map(layer => ( + + ))} + + , + domElement + ), + + toExpression(state, frame) { + const layer = state.layers[0]; + const datasource = frame.datasourceLayers[layer.layerId]; + const operations = layer.columns + .map(columnId => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) + .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable', + arguments: { + columns: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_columns', + arguments: { + columnIds: operations.map(o => o.columnId), + labels: operations.map( + o => + o.operation.label || + i18n.translate('xpack.lens.datatable.na', { + defaultMessage: 'N/A', + }) + ), + }, + }, + ], + }, + ], + }, + }, + ], + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx new file mode 100644 index 0000000000000..26e9c18e00e9e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { debouncedComponent } from './debounced_component'; + +describe('debouncedComponent', () => { + test('immediately renders', () => { + const TestComponent = debouncedComponent(({ title }: { title: string }) => { + return

{title}

; + }); + expect(mount().html()).toMatchInlineSnapshot(`"

hoi

"`); + }); + + test('debounces changes', async () => { + const TestComponent = debouncedComponent(({ title }: { title: string }) => { + return

{title}

; + }, 1); + const component = mount(); + component.setProps({ title: 'yall' }); + expect(component.text()).toEqual('there'); + await new Promise(r => setTimeout(r, 1)); + expect(component.text()).toEqual('yall'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx new file mode 100644 index 0000000000000..be6830c115836 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/debounced_component.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo, memo, FunctionComponent } from 'react'; +import { debounce } from 'lodash'; + +/** + * debouncedComponent wraps the specified React component, returning a component which + * only renders once there is a pause in props changes for at least `delay` milliseconds. + * During the debounce phase, it will return the previously rendered value. + */ +export function debouncedComponent(component: FunctionComponent, delay = 256) { + const MemoizedComponent = (memo(component) as unknown) as FunctionComponent; + + return (props: TProps) => { + const [cachedProps, setCachedProps] = useState(props); + const delayRender = useMemo(() => debounce(setCachedProps, delay), []); + + delayRender(props); + + return React.createElement(MemoizedComponent, cachedProps); + }; +} diff --git a/x-pack/legacy/plugins/lens/public/debounced_component/index.ts b/x-pack/legacy/plugins/lens/public/debounced_component/index.ts new file mode 100644 index 0000000000000..ed940fed56112 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/debounced_component/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './debounced_component'; diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap new file mode 100644 index 0000000000000..d18a2db614f55 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DragDrop droppable is reflected in the className 1`] = ` +
+ Hello! +
+`; + +exports[`DragDrop renders if nothing is being dragged 1`] = ` +
+ Hello! +
+`; diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.scss new file mode 100644 index 0000000000000..f0b3238f76f2e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.scss @@ -0,0 +1,7 @@ +.lnsDragDrop-isDropTarget { + background-color: transparentize($euiColorSecondary, .9); +} + +.lnsDragDrop-isActiveDropTarget { + background-color: transparentize($euiColorSecondary, .75); +} diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx new file mode 100644 index 0000000000000..7471039a482bf --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, shallow, mount } from 'enzyme'; +import { DragDrop } from './drag_drop'; +import { ChildDragDropProvider } from './providers'; + +jest.useFakeTimers(); + +describe('DragDrop', () => { + test('renders if nothing is being dragged', () => { + const component = render( + + Hello! + + ); + + expect(component).toMatchSnapshot(); + }); + + test('dragover calls preventDefault if droppable is true', () => { + const preventDefault = jest.fn(); + const component = shallow(Hello!); + + component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + + expect(preventDefault).toBeCalled(); + }); + + test('dragover does not call preventDefault if droppable is false', () => { + const preventDefault = jest.fn(); + const component = shallow(Hello!); + + component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + + expect(preventDefault).not.toBeCalled(); + }); + + test('dragstart sets dragging in the context', async () => { + const setDragging = jest.fn(); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(), + }; + const value = {}; + + const component = mount( + + Hello! + + ); + + component.find('[data-test-subj="lnsDragDrop"]').simulate('dragstart', { dataTransfer }); + + jest.runAllTimers(); + + expect(dataTransfer.setData).toBeCalledWith('text', 'dragging'); + expect(setDragging).toBeCalledWith(value); + }); + + test('drop resets all the things', async () => { + const preventDefault = jest.fn(); + const stopPropagation = jest.fn(); + const setDragging = jest.fn(); + const onDrop = jest.fn(); + const value = {}; + + const component = mount( + + + Hello! + + + ); + + component + .find('[data-test-subj="lnsDragDrop"]') + .simulate('drop', { preventDefault, stopPropagation }); + + expect(preventDefault).toBeCalled(); + expect(stopPropagation).toBeCalled(); + expect(setDragging).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith('hola'); + }); + + test('droppable is reflected in the className', () => { + const component = render( + { + throw x; + }} + droppable + > + Hello! + + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx new file mode 100644 index 0000000000000..bf3f207a1d7d5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useContext } from 'react'; +import classNames from 'classnames'; +import { DragContext } from './providers'; + +type DroppableEvent = React.DragEvent; + +/** + * A function that handles a drop event. + */ +export type DropHandler = (item: unknown) => void; + +/** + * The argument to the DragDrop component. + */ +interface Props { + /** + * The CSS class(es) for the root element. + */ + className?: string; + + /** + * The event handler that fires when an item + * is dropped onto this DragDrop component. + */ + onDrop?: DropHandler; + + /** + * The value associated with this item, if it is draggable. + * If this component is dragged, this will be the value of + * "dragging" in the root drag/drop context. + */ + value?: unknown; + + /** + * The React children. + */ + children: React.ReactNode; + + /** + * Indicates whether or not the currently dragged item + * can be dropped onto this component. + */ + droppable?: boolean; + + /** + * Indicates whether or not this component is draggable. + */ + draggable?: boolean; + + /** + * The optional test subject associated with this DOM element. + */ + 'data-test-subj'?: string; +} + +/** + * A draggable / droppable item. Items can be both draggable and droppable at + * the same time. + * + * @param props + */ +export function DragDrop(props: Props) { + const { dragging, setDragging } = useContext(DragContext); + const [state, setState] = useState({ isActive: false }); + const { className, onDrop, value, children, droppable, draggable } = props; + const isDragging = draggable && value === dragging; + + const classes = classNames('lnsDragDrop', className, { + 'lnsDragDrop-isDropTarget': droppable, + 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive, + 'lnsDragDrop-isDragging': isDragging, + }); + + const dragStart = (e: DroppableEvent) => { + // Setting stopPropgagation causes Chrome failures, so + // we are manually checking if we've already handled this + // in a nested child, and doing nothing if so... + if (e.dataTransfer.getData('text')) { + return; + } + + e.dataTransfer.setData('text', 'dragging'); + + // Chrome causes issues if you try to render from within a + // dragStart event, so we drop a setTimeout to avoid that. + setTimeout(() => setDragging(value)); + }; + + const dragEnd = (e: DroppableEvent) => { + e.stopPropagation(); + setDragging(undefined); + }; + + const dragOver = (e: DroppableEvent) => { + if (!droppable) { + return; + } + + e.preventDefault(); + + // An optimization to prevent a bunch of React churn. + if (!state.isActive) { + setState({ ...state, isActive: true }); + } + }; + + const dragLeave = () => { + setState({ ...state, isActive: false }); + }; + + const drop = (e: DroppableEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setState({ ...state, isActive: false }); + setDragging(undefined); + + if (onDrop) { + onDrop(dragging); + } + }; + + return ( +
+ {children} +
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/index.ts b/x-pack/legacy/plugins/lens/public/drag_drop/index.ts new file mode 100644 index 0000000000000..e597bb8b6e893 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './providers'; +export * from './drag_drop'; diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx new file mode 100644 index 0000000000000..2a8735be426c0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { mount } from 'enzyme'; +import { RootDragDropProvider, DragContext } from './providers'; + +jest.useFakeTimers(); + +describe('RootDragDropProvider', () => { + test('reuses contexts for each render', () => { + const contexts: Array<{}> = []; + const TestComponent = ({ name }: { name: string }) => { + const context = useContext(DragContext); + contexts.push(context); + return ( +
+ {name} {!!context.dragging} +
+ ); + }; + + const RootComponent = ({ name }: { name: string }) => ( + + + + ); + + const component = mount(); + + component.setProps({ name: 'bbbb' }); + + expect(component.find('[data-test-subj="test-component"]').text()).toContain('bbb'); + expect(contexts.length).toEqual(2); + expect(contexts[0]).toStrictEqual(contexts[1]); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx new file mode 100644 index 0000000000000..3e2b7312274c9 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/providers.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo } from 'react'; + +/** + * The shape of the drag / drop context. + */ +export interface DragContextState { + /** + * The item being dragged or undefined. + */ + dragging: unknown; + + /** + * Set the item being dragged. + */ + setDragging: (dragging: unknown) => void; +} + +/** + * The drag / drop context singleton, used like so: + * + * const { dragging, setDragging } = useContext(DragContext); + */ +export const DragContext = React.createContext({ + dragging: undefined, + setDragging: () => {}, +}); + +/** + * The argument to DragDropProvider. + */ +export interface ProviderProps { + /** + * The item being dragged. If unspecified, the provider will + * behave as if it is the root provider. + */ + dragging: unknown; + + /** + * Sets the item being dragged. If unspecified, the provider + * will behave as if it is the root provider. + */ + setDragging: (dragging: unknown) => void; + + /** + * The React children. + */ + children: React.ReactNode; +} + +/** + * A React provider that tracks the dragging state. This should + * be placed at the root of any React application that supports + * drag / drop. + * + * @param props + */ +export function RootDragDropProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState<{ dragging: unknown }>({ + dragging: undefined, + }); + const setDragging = useMemo(() => (dragging: unknown) => setState({ dragging }), [setState]); + + return ( + + {children} + + ); +} + +/** + * A React drag / drop provider that derives its state from a RootDragDropProvider. If + * part of a React application is rendered separately from the root, this provider can + * be used to enable drag / drop functionality within the disconnected part. + * + * @param props + */ +export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) { + const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]); + return {children}; +} diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/readme.md b/x-pack/legacy/plugins/lens/public/drag_drop/readme.md new file mode 100644 index 0000000000000..8d11cb6226927 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/drag_drop/readme.md @@ -0,0 +1,69 @@ +# Drag / Drop + +This is a simple drag / drop mechanism that plays nice with React. + +We aren't using EUI or another library, due to the fact that Lens visualizations and datasources may or may not be written in React. Even visualizations which are written in React will end up having their own ReactDOM.render call, and in that sense will be a standalone React application. We want to enable drag / drop across React and native DOM boundaries. + +## Getting started + +First, place a RootDragDropProvider at the root of your application. + +```js + + ... your app here ... + +``` + +If you have a child React application (e.g. a visualization), you will need to pass the drag / drop context down into it. This can be obtained like so: + +```js +const context = useContext(DragContext); +``` + +In your child application, place a `ChildDragDropProvider` at the root of that, and spread the context into it: + +```js + + ... your child app here ... + +``` + +This enables your child application to share the same drag / drop context as the root application. + +## Dragging + +An item can be both draggable and droppable at the same time, but for simplicity's sake, we'll treat these two cases separately. + +To enable dragging an item, use `DragDrop` with both a `draggable` and a `value` attribute. + +```js +
+ {fields.map(f => ( + + {f.name} + + ))} +
+``` + +## Dropping + +To enable dropping, use `DragDrop` with both a `droppable` attribute and an `onDrop` handler attribute. Droppable should only be set to true if there is an item being dragged, and if a drop of the dragged item is supported. + +```js +const { dragging } = useContext(DragContext); + +return ( + onChange([...items, item])} + > + {items.map(x =>
{x.name}
)} +
+); +``` + +## Limitations + +Currently this is a very simple drag / drop mechanism. We don't support reordering out of the box, though it could probably be built on top of this solution without modification of the core. diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/__mocks__/suggestion_helpers.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/__mocks__/suggestion_helpers.ts new file mode 100644 index 0000000000000..c11ec237add5b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/__mocks__/suggestion_helpers.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const actual = jest.requireActual('../suggestion_helpers'); + +jest.spyOn(actual, 'getSuggestions'); + +export const { getSuggestions, switchToSuggestion } = actual; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx new file mode 100644 index 0000000000000..298b25b5090c4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.test.tsx @@ -0,0 +1,529 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { createMockVisualization, createMockFramePublicAPI, createMockDatasource } from '../mocks'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { ChartSwitch } from './chart_switch'; +import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types'; +import { EuiKeyPadMenuItemButton } from '@elastic/eui'; +import { Action } from './state_management'; + +describe('chart_switch', () => { + function generateVisualization(id: string): jest.Mocked { + return { + ...createMockVisualization(), + id, + visualizationTypes: [ + { + icon: 'empty', + id: `sub${id}`, + label: `Label ${id}`, + }, + ], + initialize: jest.fn((_frame, state?: unknown) => { + return state || `${id} initial state`; + }), + getSuggestions: jest.fn(options => { + return [ + { + score: 1, + title: '', + state: `suggestion ${id}`, + previewIcon: 'empty', + }, + ]; + }), + }; + } + + function mockVisualizations() { + return { + visA: generateVisualization('visA'), + visB: generateVisualization('visB'), + visC: { + ...generateVisualization('visC'), + visualizationTypes: [ + { + icon: 'empty', + id: 'subvisC1', + label: 'C1', + }, + { + icon: 'empty', + id: 'subvisC2', + label: 'C2', + }, + ], + }, + }; + } + + function mockFrame(layers: string[]) { + return { + ...createMockFramePublicAPI(), + datasourceLayers: layers.reduce( + (acc, layerId) => ({ + ...acc, + [layerId]: ({ + getTableSpec: jest.fn(() => { + return [{ columnId: 2 }]; + }), + getOperationForColumnId() { + return {}; + }, + } as unknown) as DatasourcePublicAPI, + }), + {} as Record + ), + } as FramePublicAPI; + } + + function mockDatasourceMap() { + const datasource = createMockDatasource(); + datasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [], + isMultiRow: true, + layerId: 'a', + changeType: 'unchanged', + }, + }, + ]); + return { + testDatasource: datasource, + }; + } + + function mockDatasourceStates() { + return { + testDatasource: { + state: {}, + isLoading: false, + }, + }; + } + + function showFlyout(component: ReactWrapper) { + component + .find('[data-test-subj="lnsChartSwitchPopover"]') + .first() + .simulate('click'); + } + + function switchTo(subType: string, component: ReactWrapper) { + showFlyout(component); + component + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .first() + .simulate('click'); + } + + function getMenuItem(subType: string, component: ReactWrapper) { + showFlyout(component); + return component + .find(EuiKeyPadMenuItemButton) + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .first(); + } + + it('should use suggested state if there is a suggestion from the target visualization', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'suggestion visB', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + datasourceId: 'testDatasource', + datasourceState: {}, + }); + }); + + it('should use initial state if there is no suggestion from the target visualization', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'visB initial state', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + }); + }); + + it('should indicate data loss if not all columns will be used', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const frame = mockFrame(['a']); + + const datasourceMap = mockDatasourceMap(); + datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [ + { + columnId: 'col1', + operation: { + label: '', + dataType: 'string', + isBucketed: true, + }, + }, + { + columnId: 'col2', + operation: { + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + ], + layerId: 'first', + isMultiRow: true, + changeType: 'unchanged', + }, + }, + ]); + datasourceMap.testDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'col1' }, + { columnId: 'col2' }, + { columnId: 'col3' }, + ]); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + }); + + it('should indicate data loss if not all layers will be used', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const frame = mockFrame(['a', 'b']); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + }); + + it('should indicate data loss if no data will be used', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toEqual('alert'); + }); + + it('should not indicate data loss if there is no data', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a']); + (frame.datasourceLayers.a.getTableSpec as jest.Mock).mockReturnValue([]); + + const component = mount( + + ); + + expect(getMenuItem('subvisB', component).prop('betaBadgeIconType')).toBeUndefined(); + }); + + it('should not indicate data loss if visualization is not changed', () => { + const dispatch = jest.fn(); + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(() => 'therebedragons'); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + expect(getMenuItem('subvisC2', component).prop('betaBadgeIconType')).toBeUndefined(); + }); + + it('should remove unused layers', () => { + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(removeLayers).toHaveBeenCalledTimes(1); + expect(removeLayers).toHaveBeenCalledWith(['b', 'c']); + }); + + it('should remove all layers if there is no suggestion', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + visualizations.visB.getSuggestions.mockReturnValueOnce([]); + const frame = mockFrame(['a', 'b', 'c']); + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(frame.removeLayers).toHaveBeenCalledTimes(1); + expect(frame.removeLayers).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('should not remove layers if the visualization is not changing', () => { + const dispatch = jest.fn(); + const removeLayers = jest.fn(); + const frame = { + ...mockFrame(['a', 'b', 'c']), + removeLayers, + }; + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn(() => 'therebedragons'); + + visualizations.visC.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisC2', component); + expect(removeLayers).not.toHaveBeenCalled(); + expect(switchVisualizationType).toHaveBeenCalledWith('subvisC2', 'therebegriffins'); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SWITCH_VISUALIZATION', + initialState: 'therebedragons', + }) + ); + }); + + it('should switch to the updated datasource state', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const frame = mockFrame(['a', 'b']); + + const datasourceMap = mockDatasourceMap(); + datasourceMap.testDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: 'testDatasource suggestion', + table: { + columns: [ + { + columnId: 'col1', + operation: { + label: '', + dataType: 'string', + isBucketed: true, + }, + }, + { + columnId: 'col2', + operation: { + label: '', + dataType: 'number', + isBucketed: false, + }, + }, + ], + layerId: 'a', + isMultiRow: true, + changeType: 'unchanged', + }, + }, + ]); + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'visB', + datasourceId: 'testDatasource', + datasourceState: 'testDatasource suggestion', + initialState: 'suggestion visB', + } as Action); + }); + + it('should ensure the new visualization has the proper subtype', () => { + const dispatch = jest.fn(); + const visualizations = mockVisualizations(); + const switchVisualizationType = jest.fn( + (visualizationType, state) => `${state} ${visualizationType}` + ); + + visualizations.visB.switchVisualizationType = switchVisualizationType; + + const component = mount( + + ); + + switchTo('subvisB', component); + + expect(dispatch).toHaveBeenCalledWith({ + initialState: 'suggestion visB subvisB', + newVisualizationId: 'visB', + type: 'SWITCH_VISUALIZATION', + datasourceId: 'testDatasource', + datasourceState: {}, + }); + }); + + it('should show all visualization types', () => { + const component = mount( + + ); + + showFlyout(component); + + const allDisplayed = ['subvisA', 'subvisB', 'subvisC1', 'subvisC2'].every( + subType => component.find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`).length > 0 + ); + + expect(allDisplayed).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx new file mode 100644 index 0000000000000..34f9f67f8b928 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo } from 'react'; +import { + EuiIcon, + EuiPopover, + EuiPopoverTitle, + EuiKeyPadMenu, + EuiKeyPadMenuItemButton, + EuiButtonEmpty, + EuiTitle, +} from '@elastic/eui'; +import { flatten } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { Visualization, FramePublicAPI, Datasource } from '../../types'; +import { Action } from './state_management'; +import { getSuggestions, switchToSuggestion, Suggestion } from './suggestion_helpers'; + +interface VisualizationSelection { + visualizationId: string; + subVisualizationId: string; + getVisualizationState: () => unknown; + keptLayerIds: string[]; + dataLoss: 'nothing' | 'layers' | 'everything' | 'columns'; + datasourceId?: string; + datasourceState?: unknown; +} + +interface Props { + dispatch: (action: Action) => void; + visualizationMap: Record; + visualizationId: string | null; + visualizationState: unknown; + framePublicAPI: FramePublicAPI; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; +} + +function VisualizationSummary(props: Props) { + const visualization = props.visualizationMap[props.visualizationId || '']; + + if (!visualization) { + return ( + <> + {i18n.translate('xpack.lens.configPanel.chooseVisualization', { + defaultMessage: 'Choose a visualization', + })} + + ); + } + + const description = visualization.getDescription(props.visualizationState); + + return ( + <> + {description.icon && ( + + )} + {description.label} + + ); +} + +export function ChartSwitch(props: Props) { + const [flyoutOpen, setFlyoutOpen] = useState(false); + + const commitSelection = (selection: VisualizationSelection) => { + setFlyoutOpen(false); + + switchToSuggestion(props.framePublicAPI, props.dispatch, { + ...selection, + visualizationState: selection.getVisualizationState(), + }); + }; + + function getSelection( + visualizationId: string, + subVisualizationId: string + ): VisualizationSelection { + const newVisualization = props.visualizationMap[visualizationId]; + const switchVisType = + props.visualizationMap[visualizationId].switchVisualizationType || + ((_type: string, initialState: unknown) => initialState); + if (props.visualizationId === visualizationId) { + return { + visualizationId, + subVisualizationId, + dataLoss: 'nothing', + keptLayerIds: Object.keys(props.framePublicAPI.datasourceLayers), + getVisualizationState: () => switchVisType(subVisualizationId, props.visualizationState), + }; + } + + const layers = Object.entries(props.framePublicAPI.datasourceLayers); + const containsData = layers.some( + ([_layerId, datasource]) => datasource.getTableSpec().length > 0 + ); + + const topSuggestion = getTopSuggestion(props, visualizationId, newVisualization); + + let dataLoss: VisualizationSelection['dataLoss']; + + if (!containsData) { + dataLoss = 'nothing'; + } else if (!topSuggestion) { + dataLoss = 'everything'; + } else if (layers.length > 1) { + dataLoss = 'layers'; + } else if (topSuggestion.columns !== layers[0][1].getTableSpec().length) { + dataLoss = 'columns'; + } else { + dataLoss = 'nothing'; + } + + return { + visualizationId, + subVisualizationId, + dataLoss, + getVisualizationState: topSuggestion + ? () => + switchVisType( + subVisualizationId, + newVisualization.initialize(props.framePublicAPI, topSuggestion.visualizationState) + ) + : () => { + return switchVisType( + subVisualizationId, + newVisualization.initialize(props.framePublicAPI) + ); + }, + keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [], + datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined, + datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined, + }; + } + + const visualizationTypes = useMemo( + () => + flyoutOpen && + flatten( + Object.values(props.visualizationMap).map(v => + v.visualizationTypes.map(t => ({ + visualizationId: v.id, + ...t, + })) + ) + ).map(visualizationType => ({ + ...visualizationType, + selection: getSelection(visualizationType.visualizationId, visualizationType.id), + })), + [ + flyoutOpen, + props.visualizationMap, + props.framePublicAPI, + props.visualizationId, + props.visualizationState, + ] + ); + + const popover = ( + setFlyoutOpen(!flyoutOpen)} + data-test-subj="lnsChartSwitchPopover" + > + ( + {i18n.translate('xpack.lens.configPanel.changeVisualization', { + defaultMessage: 'change', + })} + ) + + } + isOpen={flyoutOpen} + closePopover={() => setFlyoutOpen(false)} + anchorPosition="leftUp" + > + + {i18n.translate('xpack.lens.configPanel.chooseVisualization', { + defaultMessage: 'Choose a visualization', + })} + + + {(visualizationTypes || []).map(v => ( + {v.label}} + role="menuitem" + data-test-subj={`lnsChartSwitchPopover_${v.id}`} + onClick={() => commitSelection(v.selection)} + betaBadgeLabel={ + v.selection.dataLoss !== 'nothing' + ? i18n.translate('xpack.lens.chartSwitch.dataLossLabel', { + defaultMessage: 'Data loss', + }) + : undefined + } + betaBadgeTooltipContent={ + v.selection.dataLoss !== 'nothing' + ? i18n.translate('xpack.lens.chartSwitch.dataLossDescription', { + defaultMessage: 'Switching to this chart will lose some of the configuration', + }) + : undefined + } + betaBadgeIconType={v.selection.dataLoss !== 'nothing' ? 'alert' : undefined} + > + + + ))} + + + ); + + return ( +
+ +

+ {popover} +

+
+
+ ); +} + +function getTopSuggestion( + props: Props, + visualizationId: string, + newVisualization: Visualization +): Suggestion | undefined { + const suggestions = getSuggestions({ + datasourceMap: props.datasourceMap, + datasourceStates: props.datasourceStates, + visualizationMap: { [visualizationId]: newVisualization }, + activeVisualizationId: props.visualizationId, + visualizationState: props.visualizationState, + }).filter(suggestion => { + // don't use extended versions of current data table on switching between visualizations + // to avoid confusing the user. + return suggestion.changeType !== 'extended'; + }); + + // We prefer unchanged or reduced suggestions when switching + // charts since that allows you to switch from A to B and back + // to A with the greatest chance of preserving your original state. + return ( + suggestions.find(s => s.changeType === 'unchanged') || + suggestions.find(s => s.changeType === 'reduced') || + suggestions[0] + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx new file mode 100644 index 0000000000000..67175a19237f5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/config_panel_wrapper.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useContext, memo } from 'react'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; +import { Visualization, FramePublicAPI, Datasource } from '../../types'; +import { DragContext } from '../../drag_drop'; +import { ChartSwitch } from './chart_switch'; + +interface ConfigPanelWrapperProps { + visualizationState: unknown; + visualizationMap: Record; + activeVisualizationId: string | null; + dispatch: (action: Action) => void; + framePublicAPI: FramePublicAPI; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; +} + +export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { + const context = useContext(DragContext); + const setVisualizationState = useMemo( + () => (newState: unknown) => { + props.dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState, + }); + }, + [props.dispatch] + ); + + return ( + <> + + {props.activeVisualizationId && props.visualizationState !== null && ( +
+ +
+ )} + + ); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx new file mode 100644 index 0000000000000..ea4c909d75cbe --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/data_panel_wrapper.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, memo, useContext, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { Query } from 'src/plugins/data/common'; +import { DatasourceDataPanelProps, Datasource } from '../../../public'; +import { NativeRenderer } from '../../native_renderer'; +import { Action } from './state_management'; +import { DragContext } from '../../drag_drop'; +import { StateSetter, FramePublicAPI } from '../../types'; + +interface DataPanelWrapperProps { + datasourceState: unknown; + datasourceMap: Record; + activeDatasource: string | null; + datasourceIsLoading: boolean; + dispatch: (action: Action) => void; + core: DatasourceDataPanelProps['core']; + query: Query; + dateRange: FramePublicAPI['dateRange']; +} + +export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { + const setDatasourceState: StateSetter = useMemo( + () => updater => { + props.dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater, + datasourceId: props.activeDatasource!, + }); + }, + [props.dispatch, props.activeDatasource] + ); + + const datasourceProps: DatasourceDataPanelProps = { + dragDropContext: useContext(DragContext), + state: props.datasourceState, + setState: setDatasourceState, + core: props.core, + query: props.query, + dateRange: props.dateRange, + }; + + const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); + + return ( + <> + {Object.keys(props.datasourceMap).length > 1 && ( + setDatasourceSwitcher(true)} + iconType="gear" + /> + } + isOpen={showDatasourceSwitcher} + closePopover={() => setDatasourceSwitcher(false)} + panelPaddingSize="none" + anchorPosition="rightUp" + > + ( + { + setDatasourceSwitcher(false); + props.dispatch({ + type: 'SWITCH_DATASOURCE', + newDatasourceId: datasourceId, + }); + }} + > + {datasourceId} + + ))} + /> + + )} + {props.activeDatasource && !props.datasourceIsLoading && ( + + )} + + ); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx new file mode 100644 index 0000000000000..23ae8d4ad08e4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.test.tsx @@ -0,0 +1,1487 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { EditorFrame } from './editor_frame'; +import { Visualization, DatasourcePublicAPI, DatasourceSuggestion } from '../../types'; +import { act } from 'react-dom/test-utils'; +import { coreMock } from 'src/core/public/mocks'; +import { + createMockVisualization, + createMockDatasource, + createExpressionRendererMock, + DatasourceMock, +} from '../mocks'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public'; +import { DragDrop } from '../../drag_drop'; +import { EuiPanel, EuiToolTip } from '@elastic/eui'; + +// calling this function will wait for all pending Promises from mock +// datasources to be processed by its callers. +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + +function generateSuggestion(state = {}): DatasourceSuggestion { + return { + state, + table: { + columns: [], + isMultiRow: true, + layerId: 'first', + changeType: 'unchanged', + }, + }; +} + +function getDefaultProps() { + return { + store: { + save: jest.fn(), + load: jest.fn(), + }, + redirectTo: jest.fn(), + onError: jest.fn(), + onChange: jest.fn(), + dateRange: { fromDate: '', toDate: '' }, + query: { query: '', language: 'lucene' }, + core: coreMock.createSetup(), + }; +} + +describe('editor_frame', () => { + let mockVisualization: jest.Mocked; + let mockDatasource: DatasourceMock; + + let mockVisualization2: jest.Mocked; + let mockDatasource2: DatasourceMock; + + let expressionRendererMock: ExpressionRenderer; + + beforeEach(() => { + mockVisualization = { + ...createMockVisualization(), + id: 'testVis', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis', + label: 'TEST1', + }, + ], + }; + mockVisualization2 = { + ...createMockVisualization(), + id: 'testVis2', + visualizationTypes: [ + { + icon: 'empty', + id: 'testVis2', + label: 'TEST2', + }, + ], + }; + + mockDatasource = createMockDatasource(); + mockDatasource2 = createMockDatasource(); + + expressionRendererMock = createExpressionRendererMock(); + }); + + describe('initialization', () => { + it('should initialize initial datasource', () => { + act(() => { + mount( + + ); + }); + + expect(mockDatasource.initialize).toHaveBeenCalled(); + }); + + it('should not initialize datasource and visualization if no initial one is specificed', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).not.toHaveBeenCalled(); + expect(mockDatasource.initialize).not.toHaveBeenCalled(); + }); + + it('should initialize all datasources with state from doc', () => { + const mockDatasource3 = createMockDatasource(); + const datasource1State = { datasource1: '' }; + const datasource2State = { datasource2: '' }; + + act(() => { + mount( + + ); + }); + + expect(mockDatasource.initialize).toHaveBeenCalledWith(datasource1State); + expect(mockDatasource2.initialize).toHaveBeenCalledWith(datasource2State); + expect(mockDatasource3.initialize).not.toHaveBeenCalled(); + }); + + it('should not render something before all datasources are initialized', () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled(); + expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled(); + }); + + it('should not initialize visualization before datasource is initialized', async () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + }); + + it('should pass the public frame api into visualization initialize', async () => { + act(() => { + mount( + + ); + }); + + expect(mockVisualization.initialize).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(mockVisualization.initialize).toHaveBeenCalledWith({ + datasourceLayers: {}, + addNewLayer: expect.any(Function), + removeLayers: expect.any(Function), + query: { query: '', language: 'lucene' }, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + }); + }); + + it('should add new layer on active datasource on frame api call', async () => { + const initialState = { datasource2: '' }; + mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); + act(() => { + mount( + + ); + }); + + await waitForPromises(); + + mockVisualization.initialize.mock.calls[0][0].addNewLayer(); + + expect(mockDatasource2.insertLayer).toHaveBeenCalledWith(initialState, expect.anything()); + }); + + it('should remove layer on active datasource on frame api call', async () => { + const initialState = { datasource2: '' }; + mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState)); + mockDatasource2.getLayers.mockReturnValue(['abc', 'def']); + mockDatasource2.removeLayer.mockReturnValue({ removed: true }); + act(() => { + mount( + + ); + }); + + await waitForPromises(); + + mockVisualization.initialize.mock.calls[0][0].removeLayers(['abc', 'def']); + + expect(mockDatasource2.removeLayer).toHaveBeenCalledWith(initialState, 'abc'); + expect(mockDatasource2.removeLayer).toHaveBeenCalledWith({ removed: true }, 'def'); + }); + + it('should render data panel after initialization is complete', async () => { + const initialState = {}; + let databaseInitialized: ({}) => void; + + act(() => { + mount( + + new Promise(resolve => { + databaseInitialized = resolve; + }), + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + }); + + databaseInitialized!(initialState); + + await waitForPromises(); + expect(mockDatasource.renderDataPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should initialize visualization state and render config panel', async () => { + const initialState = {}; + + mount( + initialState }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + initialize: () => Promise.resolve(), + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should render the resulting expression using the expression renderer', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); + const instance = mount( + 'vis' }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + toExpression: () => 'datasource', + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + instance.update(); + + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "filters": Array [], + "query": Array [ + "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", + ], + "timeRange": Array [ + "{\\"from\\":\\"\\",\\"to\\":\\"\\"}", + ], + }, + "function": "kibana_context", + "type": "function", + }, + Object { + "arguments": Object { + "layerIds": Array [ + "first", + ], + "tables": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_merge_tables", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should render individual expression for each given layer', async () => { + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource2.toExpression.mockImplementation((_state, layerId) => `datasource_${layerId}`); + mockDatasource.initialize.mockImplementation(initialState => Promise.resolve(initialState)); + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource2.initialize.mockImplementation(initialState => Promise.resolve(initialState)); + mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + + const instance = mount( + 'vis' }, + }} + datasourceMap={{ + testDatasource: mockDatasource, + testDatasource2: mockDatasource2, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + doc={{ + visualizationType: 'testVis', + title: '', + expression: '', + state: { + datasourceStates: { + testDatasource: {}, + testDatasource2: {}, + }, + visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }} + /> + ); + + await waitForPromises(); + await waitForPromises(); + + instance.update(); + + expect(instance.find(expressionRendererMock).prop('expression')).toEqual({ + type: 'expression', + chain: expect.arrayContaining([ + expect.objectContaining({ + arguments: expect.objectContaining({ layerIds: ['first', 'second', 'third'] }), + }), + ]), + }); + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "filters": Array [], + "query": Array [ + "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", + ], + "timeRange": Array [ + "{\\"from\\":\\"\\",\\"to\\":\\"\\"}", + ], + }, + "function": "kibana_context", + "type": "function", + }, + Object { + "arguments": Object { + "layerIds": Array [ + "first", + "second", + "third", + ], + "tables": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", + }, + ], + "type": "expression", + }, + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource_second", + "type": "function", + }, + ], + "type": "expression", + }, + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource_third", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_merge_tables", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + }); + + describe('state update', () => { + it('should re-render config panel after state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setVisualizationState = (mockVisualization.renderConfigPanel as jest.Mock).mock + .calls[0][1].setState; + act(() => { + setVisualizationState(updatedState); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: updatedState, + }) + ); + }); + + it('should re-render data panel after state update', async () => { + mount( + + ); + + await waitForPromises(); + + const updatedState = { + title: 'shazm', + }; + const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] + .setState; + act(() => { + setDatasourceState(updatedState); + }); + + expect(mockDatasource.renderDataPanel).toHaveBeenCalledTimes(2); + expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: updatedState, + }) + ); + }); + + it('should re-render config panel with updated datasource api after datasource state update', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); + mount( + + ); + + await waitForPromises(); + + const updatedPublicAPI = {}; + mockDatasource.getPublicAPI.mockReturnValue( + (updatedPublicAPI as unknown) as DatasourcePublicAPI + ); + + const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1] + .setState; + act(() => { + setDatasourceState({}); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2); + expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + frame: expect.objectContaining({ + datasourceLayers: { + first: updatedPublicAPI, + }, + }), + }) + ); + }); + }); + + describe('datasource public api communication', () => { + it('should pass the datasource api for each layer to the visualization', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalled(); + + const datasourceLayers = + mockVisualization.renderConfigPanel.mock.calls[0][1].frame.datasourceLayers; + expect(datasourceLayers.first).toBe(mockDatasource.publicAPIMock); + expect(datasourceLayers.second).toBe(mockDatasource2.publicAPIMock); + expect(datasourceLayers.third).toBe(mockDatasource2.publicAPIMock); + }); + + it('should create a separate datasource public api for each layer', async () => { + mockDatasource.initialize.mockImplementation(initialState => Promise.resolve(initialState)); + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource2.initialize.mockImplementation(initialState => Promise.resolve(initialState)); + mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + + const datasource1State = { datasource1: '' }; + const datasource2State = { datasource2: '' }; + + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith( + datasource1State, + expect.anything(), + 'first' + ); + expect(mockDatasource2.getPublicAPI).toHaveBeenCalledWith( + datasource2State, + expect.anything(), + 'second' + ); + expect(mockDatasource2.getPublicAPI).toHaveBeenCalledWith( + datasource2State, + expect.anything(), + 'third' + ); + }); + + it('should give access to the datasource state in the datasource factory function', async () => { + const datasourceState = {}; + mockDatasource.initialize.mockResolvedValue(datasourceState); + mockDatasource.getLayers.mockReturnValue(['first']); + + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getPublicAPI).toHaveBeenCalledWith( + datasourceState, + expect.any(Function), + 'first' + ); + }); + + it('should re-create the public api after state has been set', async () => { + mockDatasource.getLayers.mockReturnValue(['first']); + mount( + + ); + + await waitForPromises(); + + const updatedState = {}; + const setDatasourceState = mockDatasource.getPublicAPI.mock.calls[0][1]; + act(() => { + setDatasourceState(updatedState); + }); + + expect(mockDatasource.getPublicAPI).toHaveBeenLastCalledWith( + updatedState, + expect.any(Function), + 'first' + ); + }); + }); + + describe('switching', () => { + let instance: ReactWrapper; + + function switchTo(subType: string) { + act(() => { + instance + .find('[data-test-subj="lnsChartSwitchPopover"]') + .last() + .simulate('click'); + }); + + instance.update(); + + act(() => { + instance + .find(`[data-test-subj="lnsChartSwitchPopover_${subType}"]`) + .last() + .simulate('click'); + }); + } + + beforeEach(async () => { + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + columns: [], + isMultiRow: true, + layerId: 'first', + changeType: 'unchanged', + }, + }, + ]); + + instance = mount( + + ); + await waitForPromises(); + + // necessary to flush elements to dom synchronously + instance.update(); + }); + + afterEach(() => { + instance.unmount(); + }); + + it('should have initialized only the initial datasource and visualization', () => { + expect(mockDatasource.initialize).toHaveBeenCalled(); + expect(mockDatasource2.initialize).not.toHaveBeenCalled(); + + expect(mockVisualization.initialize).toHaveBeenCalled(); + expect(mockVisualization2.initialize).not.toHaveBeenCalled(); + }); + + it('should initialize other datasource on switch', async () => { + act(() => { + instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); + }); + act(() => { + (document.querySelector( + '[data-test-subj="datasource-switch-testDatasource2"]' + ) as HTMLButtonElement).click(); + }); + expect(mockDatasource2.initialize).toHaveBeenCalled(); + }); + + it('should call datasource render with new state on switch', async () => { + const initialState = {}; + mockDatasource2.initialize.mockResolvedValue(initialState); + + instance.find('button[data-test-subj="datasource-switch"]').simulate('click'); + + (document.querySelector( + '[data-test-subj="datasource-switch-testDatasource2"]' + ) as HTMLButtonElement).click(); + + await waitForPromises(); + + expect(mockDatasource2.renderDataPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: initialState }) + ); + }); + + it('should initialize other visualization on switch', async () => { + switchTo('testVis2'); + expect(mockVisualization2.initialize).toHaveBeenCalled(); + }); + + it('should use suggestions to switch to new visualization', async () => { + const initialState = { suggested: true }; + mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); + mockVisualization2.getSuggestions.mockReturnValueOnce([ + { + title: 'Suggested vis', + score: 1, + state: initialState, + previewIcon: 'empty', + }, + ]); + + switchTo('testVis2'); + + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState); + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: { initial: true } }) + ); + }); + + it('should fall back when switching visualizations if the visualization has no suggested use', async () => { + mockVisualization2.initialize.mockReturnValueOnce({ initial: true }); + + switchTo('testVis2'); + + expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.initialize).toHaveBeenCalledWith( + expect.objectContaining({ datasourceLayers: { first: mockDatasource.publicAPIMock } }) + ); + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ state: { initial: true } }) + ); + }); + }); + + describe('suggestions', () => { + it('should fetch suggestions of currently active datasource', async () => { + mount( + + ); + + await waitForPromises(); + + expect(mockDatasource.getDatasourceSuggestionsFromCurrentState).toHaveBeenCalled(); + expect(mockDatasource2.getDatasourceSuggestionsFromCurrentState).not.toHaveBeenCalled(); + }); + + it('should fetch suggestions of all visualizations', async () => { + mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { + state: {}, + table: { + changeType: 'unchanged', + columns: [], + isMultiRow: true, + layerId: 'first', + }, + }, + ]); + mount( + + ); + + await waitForPromises(); + + expect(mockVisualization.getSuggestions).toHaveBeenCalled(); + expect(mockVisualization2.getSuggestions).toHaveBeenCalled(); + }); + + it('should display top 5 suggestions in descending order', async () => { + const instance = mount( + [ + { + score: 0.1, + state: {}, + title: 'Suggestion6', + previewIcon: 'empty', + }, + { + score: 0.5, + state: {}, + title: 'Suggestion3', + previewIcon: 'empty', + }, + { + score: 0.7, + state: {}, + title: 'Suggestion2', + previewIcon: 'empty', + }, + { + score: 0.8, + state: {}, + title: 'Suggestion1', + previewIcon: 'empty', + }, + ], + }, + testVis2: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.4, + state: {}, + title: 'Suggestion5', + previewIcon: 'empty', + }, + { + score: 0.45, + state: {}, + title: 'Suggestion4', + previewIcon: 'empty', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + expect( + instance + .find('[data-test-subj="lnsSuggestion"]') + .find(EuiPanel) + .map(el => el.parents(EuiToolTip).prop('content')) + ).toEqual(['Suggestion1', 'Suggestion2', 'Suggestion3', 'Suggestion4', 'Suggestion5']); + }); + + it('should switch to suggested visualization', async () => { + const newDatasourceState = {}; + const suggestionVisState = {}; + const instance = mount( + [ + { + score: 0.8, + state: suggestionVisState, + title: 'Suggestion1', + previewIcon: 'empty', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis2" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance + .find('[data-test-subj="lnsSuggestion"]') + .first() + .simulate('click'); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1); + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + expect(mockDatasource.renderDataPanel).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + state: newDatasourceState, + }) + ); + }); + + it('should switch to best suggested visualization on field drop', async () => { + const suggestionVisState = {}; + const instance = mount( + [ + { + score: 0.2, + state: {}, + title: 'Suggestion1', + previewIcon: 'empty', + }, + { + score: 0.8, + state: suggestionVisState, + title: 'Suggestion2', + previewIcon: 'empty', + }, + ], + }, + testVis2: mockVisualization2, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance + .find('[data-test-subj="lnsWorkspace"]') + .last() + .simulate('drop'); + }); + + expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + }); + + it('should switch to best suggested visualization regardless extension on field drop', async () => { + const suggestionVisState = {}; + const instance = mount( + [ + { + score: 0.2, + state: {}, + title: 'Suggestion1', + previewIcon: 'empty', + }, + { + score: 0.6, + state: {}, + title: 'Suggestion2', + previewIcon: 'empty', + }, + ], + }, + testVis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.8, + state: suggestionVisState, + title: 'Suggestion3', + previewIcon: 'empty', + }, + ], + }, + }} + datasourceMap={{ + testDatasource: { + ...mockDatasource, + getDatasourceSuggestionsForField: () => [generateSuggestion()], + getDatasourceSuggestionsFromCurrentState: () => [generateSuggestion()], + renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { + if (dragging !== 'draggedField') { + setDragging('draggedField'); + } + }, + }, + }} + initialDatasourceId="testDatasource" + initialVisualizationId="testVis" + ExpressionRenderer={expressionRendererMock} + /> + ); + + await waitForPromises(); + + // TODO why is this necessary? + instance.update(); + + act(() => { + instance.find(DragDrop).prop('onDrop')!({ + indexPatternId: '1', + field: {}, + }); + }); + + expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + state: suggestionVisState, + }) + ); + }); + }); + + describe('passing state back to the caller', () => { + let resolver: (value: unknown) => void; + let instance: ReactWrapper; + + it('should call onChange only when the active datasource is finished loading', async () => { + const onChange = jest.fn(); + + mockDatasource.initialize.mockReturnValue( + new Promise(resolve => { + resolver = resolve; + }) + ); + mockDatasource.getLayers.mockReturnValue(['first']); + mockDatasource.getMetaData.mockReturnValue({ + filterableIndexPatterns: [{ id: '1', title: 'resolved' }], + }); + mockVisualization.initialize.mockReturnValue({ initialState: true }); + + act(() => { + instance = mount( + + ); + }); + + expect(onChange).toHaveBeenCalledTimes(0); + + resolver({}); + await waitForPromises(); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenNthCalledWith(1, { + indexPatternTitles: ['resolved'], + doc: { + expression: '', + id: undefined, + state: { + visualization: null, // Not yet loaded + datasourceMetaData: { filterableIndexPatterns: [{ id: '1', title: 'resolved' }] }, + datasourceStates: { testDatasource: undefined }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: 'New visualization', + type: 'lens', + visualizationType: 'testVis', + }, + }); + expect(onChange).toHaveBeenLastCalledWith({ + indexPatternTitles: ['resolved'], + doc: { + expression: '', + id: undefined, + state: { + visualization: { initialState: true }, // Now loaded + datasourceMetaData: { + filterableIndexPatterns: [{ id: '1', title: 'resolved' }], + }, + datasourceStates: { testDatasource: undefined }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: 'New visualization', + type: 'lens', + visualizationType: 'testVis', + }, + }); + }); + + it('should send back a persistable document when the state changes', async () => { + const onChange = jest.fn(); + + const initialState = { datasource: '' }; + + mockDatasource.initialize.mockResolvedValue(initialState); + mockDatasource.getLayers.mockReturnValue(['first']); + mockVisualization.initialize.mockReturnValue({ initialState: true }); + + act(() => { + instance = mount( + + ); + }); + + await waitForPromises(); + + expect(onChange).toHaveBeenCalledTimes(2); + + mockDatasource.toExpression.mockReturnValue('data expression'); + mockVisualization.toExpression.mockReturnValue('vis expression'); + instance.setProps({ query: { query: 'new query', language: 'lucene' } }); + instance.update(); + + await waitForPromises(); + expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenNthCalledWith(3, { + indexPatternTitles: [], + doc: { + expression: expect.stringContaining('vis "expression"'), + id: undefined, + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { testDatasource: undefined }, + visualization: { initialState: true }, + query: { query: 'new query', language: 'lucene' }, + filters: [], + }, + title: 'New visualization', + type: 'lens', + visualizationType: 'testVis', + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx new file mode 100644 index 0000000000000..9d658cd2967d7 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/editor_frame.tsx @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useReducer } from 'react'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import { Query } from '../../../../../../../src/legacy/core_plugins/data/public'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { Datasource, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../../types'; +import { reducer, getInitialState } from './state_management'; +import { DataPanelWrapper } from './data_panel_wrapper'; +import { ConfigPanelWrapper } from './config_panel_wrapper'; +import { FrameLayout } from './frame_layout'; +import { SuggestionPanel } from './suggestion_panel'; +import { WorkspacePanel } from './workspace_panel'; +import { Document } from '../../persistence/saved_object_store'; +import { getSavedObjectFormat } from './save'; +import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; +import { generateId } from '../../id_generator'; + +export interface EditorFrameProps { + doc?: Document; + datasourceMap: Record; + visualizationMap: Record; + initialDatasourceId: string | null; + initialVisualizationId: string | null; + ExpressionRenderer: ExpressionRenderer; + onError: (e: { message: string }) => void; + core: CoreSetup | CoreStart; + dateRange: { + fromDate: string; + toDate: string; + }; + query: Query; + onChange: (arg: { indexPatternTitles: string[]; doc: Document }) => void; +} + +export function EditorFrame(props: EditorFrameProps) { + const [state, dispatch] = useReducer(reducer, props, getInitialState); + const { onError } = props; + + const allLoaded = Object.values(state.datasourceStates).every( + ({ isLoading }) => typeof isLoading === 'boolean' && !isLoading + ); + + // Initialize current datasource and all active datasources + useEffect(() => { + if (!allLoaded) { + Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { + if ( + state.datasourceStates[datasourceId] && + state.datasourceStates[datasourceId].isLoading + ) { + datasource + .initialize(state.datasourceStates[datasourceId].state || undefined) + .then(datasourceState => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceState, + datasourceId, + }); + }) + .catch(onError); + } + }); + } + }, [allLoaded]); + + const datasourceLayers: Record = {}; + Object.keys(props.datasourceMap) + .filter(id => state.datasourceStates[id] && !state.datasourceStates[id].isLoading) + .forEach(id => { + const datasourceState = state.datasourceStates[id].state; + const datasource = props.datasourceMap[id]; + + const layers = datasource.getLayers(datasourceState); + layers.forEach(layer => { + const publicAPI = props.datasourceMap[id].getPublicAPI( + datasourceState, + (newState: unknown) => { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + datasourceId: id, + updater: newState, + }); + }, + layer + ); + + datasourceLayers[layer] = publicAPI; + }); + }); + + const framePublicAPI: FramePublicAPI = { + datasourceLayers, + dateRange: props.dateRange, + query: props.query, + + addNewLayer() { + const newLayerId = generateId(); + + dispatch({ + type: 'UPDATE_LAYER', + datasourceId: state.activeDatasourceId!, + layerId: newLayerId, + updater: props.datasourceMap[state.activeDatasourceId!].insertLayer, + }); + + return newLayerId; + }, + removeLayers: (layerIds: string[]) => { + layerIds.forEach(layerId => { + const layerDatasourceId = Object.entries(props.datasourceMap).find( + ([datasourceId, datasource]) => + state.datasourceStates[datasourceId] && + datasource.getLayers(state.datasourceStates[datasourceId].state).includes(layerId) + )![0]; + dispatch({ + type: 'UPDATE_LAYER', + layerId, + datasourceId: layerDatasourceId, + updater: props.datasourceMap[layerDatasourceId].removeLayer, + }); + }); + }, + }; + + useEffect(() => { + if (props.doc) { + dispatch({ + type: 'VISUALIZATION_LOADED', + doc: props.doc, + }); + } else { + dispatch({ + type: 'RESET', + state: getInitialState(props), + }); + } + }, [props.doc]); + + // Initialize visualization as soon as all datasources are ready + useEffect(() => { + if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) { + const initialVisualizationState = props.visualizationMap[ + state.visualization.activeId + ].initialize(framePublicAPI); + dispatch({ + type: 'UPDATE_VISUALIZATION_STATE', + newState: initialVisualizationState, + }); + } + }, [allLoaded, state.visualization.activeId, state.visualization.state]); + + // The frame needs to call onChange every time its internal state changes + useEffect(() => { + const activeDatasource = + state.activeDatasourceId && !state.datasourceStates[state.activeDatasourceId].isLoading + ? props.datasourceMap[state.activeDatasourceId] + : undefined; + + const visualization = state.visualization.activeId + ? props.visualizationMap[state.visualization.activeId] + : undefined; + + if (!activeDatasource || !visualization) { + return; + } + + const indexPatternTitles: string[] = []; + Object.entries(props.datasourceMap) + .filter(([id, datasource]) => { + const stateWrapper = state.datasourceStates[id]; + return ( + stateWrapper && + !stateWrapper.isLoading && + datasource.getLayers(stateWrapper.state).length > 0 + ); + }) + .forEach(([id, datasource]) => { + indexPatternTitles.push( + ...datasource + .getMetaData(state.datasourceStates[id].state) + .filterableIndexPatterns.map(pattern => pattern.title) + ); + }); + + const doc = getSavedObjectFormat({ + activeDatasources: Object.keys(state.datasourceStates).reduce( + (datasourceMap, datasourceId) => ({ + ...datasourceMap, + [datasourceId]: props.datasourceMap[datasourceId], + }), + {} + ), + visualization, + state, + framePublicAPI, + }); + + props.onChange({ indexPatternTitles, doc }); + }, [state.datasourceStates, state.visualization, props.query, props.dateRange, state.title]); + + return ( + + } + configPanel={ + allLoaded && ( + + ) + } + workspacePanel={ + allLoaded && ( + + + + ) + } + suggestionsPanel={ + allLoaded && ( + + ) + } + /> + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts new file mode 100644 index 0000000000000..da7ddee67453e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/expression_helpers.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TimeRange } from 'src/plugins/data/public'; +import { Query } from 'src/legacy/core_plugins/data/public'; +import { Filter } from '@kbn/es-query'; +import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common'; +import { Visualization, Datasource, FramePublicAPI } from '../../types'; + +export function prependDatasourceExpression( + visualizationExpression: Ast | string | null, + datasourceMap: Record, + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + > +): Ast | null { + const datasourceExpressions: Array<[string, Ast | string]> = []; + + Object.entries(datasourceMap).forEach(([datasourceId, datasource]) => { + const state = datasourceStates[datasourceId].state; + const layers = datasource.getLayers(datasourceStates[datasourceId].state); + + layers.forEach(layerId => { + const result = datasource.toExpression(state, layerId); + if (result) { + datasourceExpressions.push([layerId, result]); + } + }); + }); + + if (datasourceExpressions.length === 0 || visualizationExpression === null) { + return null; + } + const parsedDatasourceExpressions: Array<[string, Ast]> = datasourceExpressions.map( + ([layerId, expr]) => [layerId, typeof expr === 'string' ? fromExpression(expr) : expr] + ); + + const datafetchExpression: ExpressionFunctionAST = { + type: 'function', + function: 'lens_merge_tables', + arguments: { + layerIds: parsedDatasourceExpressions.map(([id]) => id), + tables: parsedDatasourceExpressions.map(([id, expr]) => expr), + }, + }; + + const parsedVisualizationExpression = + typeof visualizationExpression === 'string' + ? fromExpression(visualizationExpression) + : visualizationExpression; + + return { + type: 'expression', + chain: [datafetchExpression, ...parsedVisualizationExpression.chain], + }; +} + +export function prependKibanaContext( + expression: Ast | string, + { + timeRange, + query, + filters, + }: { + timeRange?: TimeRange; + query?: Query; + filters?: Filter[]; + } +): Ast { + const parsedExpression = typeof expression === 'string' ? fromExpression(expression) : expression; + + return { + type: 'expression', + chain: [ + { type: 'function', function: 'kibana', arguments: {} }, + { + type: 'function', + function: 'kibana_context', + arguments: { + timeRange: timeRange ? [JSON.stringify(timeRange)] : [], + query: query ? [JSON.stringify(query)] : [], + filters: filters ? [JSON.stringify(filters)] : [], + }, + }, + ...parsedExpression.chain, + ], + }; +} + +export function buildExpression({ + visualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, + removeDateRange, +}: { + visualization: Visualization | null; + visualizationState: unknown; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + framePublicAPI: FramePublicAPI; + removeDateRange?: boolean; +}): Ast | null { + if (visualization === null) { + return null; + } + const visualizationExpression = visualization.toExpression(visualizationState, framePublicAPI); + + const expressionContext = removeDateRange + ? { query: framePublicAPI.query } + : { + query: framePublicAPI.query, + timeRange: { + from: framePublicAPI.dateRange.fromDate, + to: framePublicAPI.dateRange.toDate, + }, + }; + + const completeExpression = prependDatasourceExpression( + visualizationExpression, + datasourceMap, + datasourceStates + ); + + if (completeExpression) { + return prependKibanaContext(completeExpression, expressionContext); + } else { + return null; + } +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx new file mode 100644 index 0000000000000..8a33178de70cf --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/frame_layout.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageSideBar, EuiPageBody } from '@elastic/eui'; +import { RootDragDropProvider } from '../../drag_drop'; + +export interface FrameLayoutProps { + dataPanel: React.ReactNode; + configPanel?: React.ReactNode; + suggestionsPanel?: React.ReactNode; + workspacePanel?: React.ReactNode; +} + +export function FrameLayout(props: FrameLayoutProps) { + return ( + + +
+ {props.dataPanel} + + {props.workspacePanel} + {props.suggestionsPanel} + + + {props.configPanel} + +
+
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss new file mode 100644 index 0000000000000..33571793a721c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.scss @@ -0,0 +1,137 @@ +$lnsPanelMinWidth: $euiSize * 18; + +.lnsPage { + padding: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + flex-direction: column; +} + +.lnsHeader { + padding: $euiSize; + padding-bottom: 0; +} + +.lnsPageMainContent { + display: flex; + overflow: auto; + flex-grow: 1; +} + +.lnsSidebar { + margin: 0; + flex: 1 0 18%; + min-width: $lnsPanelMinWidth + $euiSize; + display: flex; + flex-direction: column; + position: relative; +} + +.lnsSidebar__header { + padding: $euiSizeS 0; + + > * { + display: flex; + align-items: center; + } +} + +.lnsChartSwitch__summaryIcon { + margin-right: $euiSizeS; + transform: translateY(-2px); +} + +.lnsSidebar--right { + @include euiScrollBar; + min-width: $lnsPanelMinWidth + $euiSize; + overflow-x: hidden; + overflow-y: auto; + padding-top: $euiSize; + padding-right: $euiSize; + max-height: 100%; +} + +.lnsSidebarContainer { + flex: 1 0 100%; + overflow: hidden; +} + +.lnsDatasourceSwitch { + position: absolute; + right: $euiSize + $euiSizeXS; + top: $euiSize + $euiSizeXS; +} + +.lnsPageBody { + @include euiScrollBar; + min-width: $lnsPanelMinWidth + $euiSizeXL; + overflow: hidden; + // Leave out bottom padding so the suggestions scrollbar stays flush to window edge + // This also means needing to add same amount of margin to page content and suggestion items + padding: $euiSize $euiSize 0; + + &:first-child { + padding-left: $euiSize; + } + + .lnsPageContent { + @include euiScrollBar; + overflow: hidden; + padding: 0; + margin-bottom: $euiSize; + display: flex; + flex-direction: column; + + .lnsPageContentHeader { + padding: $euiSizeS; + border-bottom: $euiBorderThin; + margin-bottom: 0; + } + + .lnsPageContentBody { + @include euiScrollBar; + flex-grow: 1; + display: flex; + align-items: stretch; + justify-content: stretch; + overflow: auto; + + > * { + flex: 1 1 100%; + display: flex; + align-items: center; + justify-content: center; + overflow-x: hidden; + } + } + } +} + +.lnsExpressionOutput { + width: 100%; + height: 100%; + display: flex; + overflow-x: hidden; + padding: $euiSize; +} + +.lnsExpressionOutput > * { + flex: 1; +} + +.lnsTitleInput { + width: 100%; + min-width: 100%; + border: 0; + font: inherit; + background: transparent; + box-shadow: none; + font-size: 1.2em; +} + + +@import './suggestion_panel.scss'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts new file mode 100644 index 0000000000000..41558caafc64c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './editor_frame'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts new file mode 100644 index 0000000000000..6bfe8f70d93c4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSavedObjectFormat, Props } from './save'; +import { createMockDatasource, createMockVisualization } from '../mocks'; + +describe('save editor frame state', () => { + const mockVisualization = createMockVisualization(); + mockVisualization.getPersistableState.mockImplementation(x => x); + const mockDatasource = createMockDatasource(); + mockDatasource.getPersistableState.mockImplementation(x => x); + const saveArgs: Props = { + activeDatasources: { + indexpattern: mockDatasource, + }, + visualization: mockVisualization, + state: { + title: 'aaa', + datasourceStates: { + indexpattern: { + state: 'hello', + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', + visualization: { activeId: '2', state: {} }, + }, + framePublicAPI: { + addNewLayer: jest.fn(), + removeLayers: jest.fn(), + datasourceLayers: { + first: mockDatasource.publicAPIMock, + }, + query: { query: '', language: 'lucene' }, + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + }, + }; + + it('transforms from internal state to persisted doc format', async () => { + const datasource = createMockDatasource(); + datasource.getPersistableState.mockImplementation(state => ({ + stuff: `${state}_datasource_persisted`, + })); + + const visualization = createMockVisualization(); + visualization.getPersistableState.mockImplementation(state => ({ + things: `${state}_vis_persisted`, + })); + + const doc = await getSavedObjectFormat({ + ...saveArgs, + activeDatasources: { + indexpattern: datasource, + }, + state: { + title: 'bbb', + datasourceStates: { + indexpattern: { + state: '2', + isLoading: false, + }, + }, + activeDatasourceId: 'indexpattern', + visualization: { activeId: '3', state: '4' }, + }, + visualization, + }); + + expect(doc).toEqual({ + id: undefined, + expression: '', + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { + stuff: '2_datasource_persisted', + }, + }, + visualization: { things: '4_vis_persisted' }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: 'bbb', + type: 'lens', + visualizationType: '3', + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts new file mode 100644 index 0000000000000..6c414d9866033 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/save.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { toExpression } from '@kbn/interpreter/target/common'; +import { EditorFrameState } from './state_management'; +import { Document } from '../../persistence/saved_object_store'; +import { buildExpression } from './expression_helpers'; +import { Datasource, Visualization, FramePublicAPI } from '../../types'; + +export interface Props { + activeDatasources: Record; + state: EditorFrameState; + visualization: Visualization; + framePublicAPI: FramePublicAPI; +} + +export function getSavedObjectFormat({ + activeDatasources, + state, + visualization, + framePublicAPI, +}: Props): Document { + const expression = buildExpression({ + visualization, + visualizationState: state.visualization.state, + datasourceMap: activeDatasources, + datasourceStates: state.datasourceStates, + framePublicAPI, + removeDateRange: true, + }); + + const datasourceStates: Record = {}; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + datasourceStates[id] = datasource.getPersistableState(state.datasourceStates[id].state); + }); + + const filterableIndexPatterns: Array<{ id: string; title: string }> = []; + Object.entries(activeDatasources).forEach(([id, datasource]) => { + filterableIndexPatterns.push( + ...datasource.getMetaData(state.datasourceStates[id].state).filterableIndexPatterns + ); + }); + + return { + id: state.persistedId, + title: state.title, + type: 'lens', + visualizationType: state.visualization.activeId, + expression: expression ? toExpression(expression) : '', + state: { + datasourceStates, + datasourceMetaData: { + filterableIndexPatterns: _.uniq(filterableIndexPatterns, 'id'), + }, + visualization: visualization.getPersistableState(state.visualization.state), + query: framePublicAPI.query, + filters: [], // TODO: Support filters + }, + }; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts new file mode 100644 index 0000000000000..aa6d7ded87ed9 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.test.ts @@ -0,0 +1,408 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getInitialState, reducer } from './state_management'; +import { EditorFrameProps } from '.'; +import { Datasource, Visualization } from '../../types'; +import { createExpressionRendererMock } from '../mocks'; +import { coreMock } from 'src/core/public/mocks'; + +describe('editor_frame state management', () => { + describe('initialization', () => { + let props: EditorFrameProps; + + beforeEach(() => { + props = { + onError: jest.fn(), + datasourceMap: { testDatasource: ({} as unknown) as Datasource }, + visualizationMap: { testVis: ({ initialize: jest.fn() } as unknown) as Visualization }, + initialDatasourceId: 'testDatasource', + initialVisualizationId: 'testVis', + ExpressionRenderer: createExpressionRendererMock(), + onChange: jest.fn(), + core: coreMock.createSetup(), + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'lucene' }, + }; + }); + + it('should store initial datasource and visualization', () => { + const initialState = getInitialState(props); + expect(initialState.activeDatasourceId).toEqual('testDatasource'); + expect(initialState.visualization.activeId).toEqual('testVis'); + }); + + it('should not initialize visualization but set active id', () => { + const initialState = getInitialState(props); + + expect(initialState.visualization.state).toBe(null); + expect(initialState.visualization.activeId).toBe('testVis'); + expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); + }); + + it('should prefill state if doc is passed in', () => { + const initialState = getInitialState({ + ...props, + doc: { + expression: '', + state: { + datasourceStates: { + testDatasource: { internalState1: '' }, + testDatasource2: { internalState2: '' }, + }, + visualization: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: '', + visualizationType: 'testVis', + }, + }); + + expect(initialState.datasourceStates).toMatchInlineSnapshot(` + Object { + "testDatasource": Object { + "isLoading": true, + "state": Object { + "internalState1": "", + }, + }, + "testDatasource2": Object { + "isLoading": true, + "state": Object { + "internalState2": "", + }, + }, + } + `); + expect(initialState.visualization).toMatchInlineSnapshot(` + Object { + "activeId": "testVis", + "state": null, + } + `); + }); + + it('should not set active id if no initial visualization is passed in', () => { + const initialState = getInitialState({ ...props, initialVisualizationId: null }); + + expect(initialState.visualization.state).toEqual(null); + expect(initialState.visualization.activeId).toEqual(null); + expect(props.visualizationMap.testVis.initialize).not.toHaveBeenCalled(); + }); + }); + + describe('state update', () => { + it('should update the corresponding visualization state on update', () => { + const newVisState = {}; + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'aaa', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_VISUALIZATION_STATE', + newState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should update the datasource state with passed in reducer', () => { + const datasourceReducer = jest.fn(() => ({ changed: true })); + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'bbb', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceReducer, + datasourceId: 'testDatasource', + } + ); + + expect(newState.datasourceStates.testDatasource.state).toEqual({ changed: true }); + expect(datasourceReducer).toHaveBeenCalledTimes(1); + }); + + it('should update the layer state with passed in reducer', () => { + const newDatasourceState = {}; + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'bbb', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'UPDATE_DATASOURCE_STATE', + updater: newDatasourceState, + datasourceId: 'testDatasource', + } + ); + + expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); + }); + + it('should should switch active visualization', () => { + const testVisState = {}; + const newVisState = {}; + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'ccc', + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + } + ); + + expect(newState.visualization.state).toBe(newVisState); + }); + + it('should should switch active visualization and update datasource state', () => { + const testVisState = {}; + const newVisState = {}; + const newDatasourceState = {}; + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'ddd', + visualization: { + activeId: 'testVis', + state: testVisState, + }, + }, + { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'testVis2', + initialState: newVisState, + datasourceState: newDatasourceState, + datasourceId: 'testDatasource', + } + ); + + expect(newState.visualization.state).toBe(newVisState); + expect(newState.datasourceStates.testDatasource.state).toBe(newDatasourceState); + }); + + it('should should switch active datasource and initialize new state', () => { + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'eee', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'SWITCH_DATASOURCE', + newDatasourceId: 'testDatasource2', + } + ); + + expect(newState.activeDatasourceId).toEqual('testDatasource2'); + expect(newState.datasourceStates.testDatasource2.isLoading).toEqual(true); + }); + + it('not initialize already initialized datasource on switch', () => { + const datasource2State = {}; + const newState = reducer( + { + datasourceStates: { + testDatasource: { + state: {}, + isLoading: false, + }, + testDatasource2: { + state: datasource2State, + isLoading: false, + }, + }, + activeDatasourceId: 'testDatasource', + title: 'eee', + visualization: { + activeId: 'testVis', + state: {}, + }, + }, + { + type: 'SWITCH_DATASOURCE', + newDatasourceId: 'testDatasource2', + } + ); + + expect(newState.activeDatasourceId).toEqual('testDatasource2'); + expect(newState.datasourceStates.testDatasource2.state).toBe(datasource2State); + }); + + it('should reset the state', () => { + const newState = reducer( + { + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'a', + title: 'jjj', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'RESET', + state: { + datasourceStates: { + z: { + isLoading: false, + state: { hola: 'muchacho' }, + }, + }, + activeDatasourceId: 'z', + persistedId: 'bar', + title: 'lll', + visualization: { + activeId: 'q', + state: { my: 'viz' }, + }, + }, + } + ); + + expect(newState).toMatchObject({ + datasourceStates: { + z: { + isLoading: false, + state: { hola: 'muchacho' }, + }, + }, + activeDatasourceId: 'z', + persistedId: 'bar', + visualization: { + activeId: 'q', + state: { my: 'viz' }, + }, + }); + }); + + it('should load the state from the doc', () => { + const newState = reducer( + { + datasourceStates: { + a: { + state: {}, + isLoading: false, + }, + }, + activeDatasourceId: 'a', + title: 'mmm', + visualization: { + activeId: 'b', + state: {}, + }, + }, + { + type: 'VISUALIZATION_LOADED', + doc: { + id: 'b', + expression: '', + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { a: { foo: 'c' } }, + visualization: { bar: 'd' }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: 'heyo!', + type: 'lens', + visualizationType: 'line', + }, + } + ); + + expect(newState).toEqual({ + activeDatasourceId: 'a', + datasourceStates: { + a: { + isLoading: true, + state: { + foo: 'c', + }, + }, + }, + persistedId: 'b', + title: 'heyo!', + visualization: { + activeId: 'line', + state: { + bar: 'd', + }, + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts new file mode 100644 index 0000000000000..27f315463f175 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/state_management.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { EditorFrameProps } from '../editor_frame'; +import { Document } from '../../persistence/saved_object_store'; + +export interface EditorFrameState { + persistedId?: string; + title: string; + visualization: { + activeId: string | null; + state: unknown; + }; + datasourceStates: Record; + activeDatasourceId: string | null; +} + +export type Action = + | { + type: 'RESET'; + state: EditorFrameState; + } + | { + type: 'UPDATE_TITLE'; + title: string; + } + | { + type: 'UPDATE_DATASOURCE_STATE'; + updater: unknown | ((prevState: unknown) => unknown); + datasourceId: string; + } + | { + type: 'UPDATE_VISUALIZATION_STATE'; + newState: unknown; + } + | { + type: 'UPDATE_LAYER'; + layerId: string; + datasourceId: string; + updater: (state: unknown, layerId: string) => unknown; + } + | { + type: 'VISUALIZATION_LOADED'; + doc: Document; + } + | { + type: 'SWITCH_VISUALIZATION'; + newVisualizationId: string; + initialState: unknown; + } + | { + type: 'SWITCH_VISUALIZATION'; + newVisualizationId: string; + initialState: unknown; + datasourceState: unknown; + datasourceId: string; + } + | { + type: 'SWITCH_DATASOURCE'; + newDatasourceId: string; + }; + +export function getActiveDatasourceIdFromDoc(doc?: Document) { + if (!doc) { + return null; + } + + const [initialDatasourceId] = Object.keys(doc.state.datasourceStates); + return initialDatasourceId || null; +} + +function getInitialDatasourceId(props: EditorFrameProps) { + return props.initialDatasourceId + ? props.initialDatasourceId + : getActiveDatasourceIdFromDoc(props.doc); +} + +export const getInitialState = (props: EditorFrameProps): EditorFrameState => { + const datasourceStates: EditorFrameState['datasourceStates'] = {}; + + if (props.doc) { + Object.entries(props.doc.state.datasourceStates).forEach(([datasourceId, state]) => { + datasourceStates[datasourceId] = { isLoading: true, state }; + }); + } else if (props.initialDatasourceId) { + datasourceStates[props.initialDatasourceId] = { + state: null, + isLoading: true, + }; + } + + return { + title: i18n.translate('xpack.lens.chartTitle', { defaultMessage: 'New visualization' }), + datasourceStates, + activeDatasourceId: getInitialDatasourceId(props), + visualization: { + state: null, + activeId: props.initialVisualizationId, + }, + }; +}; + +export const reducer = (state: EditorFrameState, action: Action): EditorFrameState => { + switch (action.type) { + case 'RESET': + return action.state; + case 'UPDATE_TITLE': + return { ...state, title: action.title }; + case 'UPDATE_LAYER': + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [action.datasourceId]: { + ...state.datasourceStates[action.datasourceId], + state: action.updater( + state.datasourceStates[action.datasourceId].state, + action.layerId + ), + }, + }, + }; + case 'VISUALIZATION_LOADED': + return { + ...state, + persistedId: action.doc.id, + title: action.doc.title, + datasourceStates: Object.entries(action.doc.state.datasourceStates).reduce( + (stateMap, [datasourceId, datasourceState]) => ({ + ...stateMap, + [datasourceId]: { + isLoading: true, + state: datasourceState, + }, + }), + {} + ), + activeDatasourceId: getActiveDatasourceIdFromDoc(action.doc), + visualization: { + ...state.visualization, + activeId: action.doc.visualizationType, + state: action.doc.state.visualization, + }, + }; + case 'SWITCH_DATASOURCE': + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [action.newDatasourceId]: state.datasourceStates[action.newDatasourceId] || { + state: null, + isLoading: true, + }, + }, + activeDatasourceId: action.newDatasourceId, + }; + case 'SWITCH_VISUALIZATION': + return { + ...state, + datasourceStates: + 'datasourceId' in action && action.datasourceId + ? { + ...state.datasourceStates, + [action.datasourceId]: { + ...state.datasourceStates[action.datasourceId], + state: action.datasourceState, + }, + } + : state.datasourceStates, + visualization: { + ...state.visualization, + activeId: action.newVisualizationId, + state: action.initialState, + }, + }; + case 'UPDATE_DATASOURCE_STATE': + return { + ...state, + datasourceStates: { + ...state.datasourceStates, + [action.datasourceId]: { + state: + typeof action.updater === 'function' + ? action.updater(state.datasourceStates[action.datasourceId].state) + : action.updater, + isLoading: false, + }, + }, + }; + case 'UPDATE_VISUALIZATION_STATE': + if (!state.visualization.activeId) { + throw new Error('Invariant: visualization state got updated without active visualization'); + } + return { + ...state, + visualization: { + ...state.visualization, + state: action.newState, + }, + }; + default: + return state; + } +}; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts new file mode 100644 index 0000000000000..7b3e4454a5e39 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.test.ts @@ -0,0 +1,387 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSuggestions } from './suggestion_helpers'; +import { createMockVisualization, createMockDatasource, DatasourceMock } from '../mocks'; +import { TableSuggestion, DatasourceSuggestion } from '../../types'; + +const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({ + state, + table: { + columns: [], + isMultiRow: false, + layerId, + changeType: 'unchanged', + }, +}); + +let datasourceMap: Record; +let datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } +>; + +beforeEach(() => { + datasourceMap = { + mock: createMockDatasource(), + }; + + datasourceStates = { + mock: { + isLoading: false, + state: {}, + }, + }; +}); + +describe('suggestion helpers', () => { + it('should return suggestions array', () => { + const mockVisualization = createMockVisualization(); + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const suggestedState = {}; + const suggestions = getSuggestions({ + visualizationMap: { + vis1: { + ...mockVisualization, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: suggestedState, + previewIcon: 'empty', + }, + ], + }, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].visualizationState).toBe(suggestedState); + }); + + it('should concatenate suggestions from all visualizations', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const suggestions = getSuggestions({ + visualizationMap: { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.5, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.5, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], + }, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(suggestions).toHaveLength(3); + }); + + it('should call getDatasourceSuggestionsForField when a field is passed', () => { + datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([generateSuggestion()]); + const droppedField = {}; + getSuggestions({ + visualizationMap: { + vis1: createMockVisualization(), + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + field: droppedField, + }); + expect(datasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( + datasourceStates.mock.state, + droppedField + ); + }); + + it('should call getDatasourceSuggestionsForField from all datasources with a state', () => { + const multiDatasourceStates = { + mock: { + isLoading: false, + state: {}, + }, + mock2: { + isLoading: false, + state: {}, + }, + }; + const multiDatasourceMap = { + mock: createMockDatasource(), + mock2: createMockDatasource(), + mock3: createMockDatasource(), + }; + const droppedField = {}; + getSuggestions({ + visualizationMap: { + vis1: createMockVisualization(), + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap: multiDatasourceMap, + datasourceStates: multiDatasourceStates, + field: droppedField, + }); + expect(multiDatasourceMap.mock.getDatasourceSuggestionsForField).toHaveBeenCalledWith( + multiDatasourceStates.mock.state, + droppedField + ); + expect(multiDatasourceMap.mock2.getDatasourceSuggestionsForField).toHaveBeenCalledWith( + multiDatasourceStates.mock2.state, + droppedField + ); + expect(multiDatasourceMap.mock3.getDatasourceSuggestionsForField).not.toHaveBeenCalled(); + }); + + it('should rank the visualizations by score', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + const suggestions = getSuggestions({ + visualizationMap: { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.2, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.6, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], + }, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(suggestions[0].score).toBe(0.8); + expect(suggestions[1].score).toBe(0.6); + expect(suggestions[2].score).toBe(0.2); + }); + + it('should call all suggestion getters with all available data tables', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const table1: TableSuggestion = { + columns: [], + isMultiRow: true, + layerId: 'first', + changeType: 'unchanged', + }; + const table2: TableSuggestion = { + columns: [], + isMultiRow: true, + layerId: 'first', + changeType: 'unchanged', + }; + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + { state: {}, table: table1 }, + { state: {}, table: table2 }, + ]); + getSuggestions({ + visualizationMap: { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(mockVisualization1.getSuggestions.mock.calls[0][0].table).toEqual(table1); + expect(mockVisualization1.getSuggestions.mock.calls[1][0].table).toEqual(table2); + expect(mockVisualization2.getSuggestions.mock.calls[0][0].table).toEqual(table1); + expect(mockVisualization2.getSuggestions.mock.calls[1][0].table).toEqual(table2); + }); + + it('should map the suggestion ids back to the correct datasource ids and states', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const tableState1 = {}; + const tableState2 = {}; + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(tableState1), + generateSuggestion(tableState2), + ]); + const vis1Suggestions = jest.fn(); + vis1Suggestions.mockReturnValueOnce([ + { + score: 0.3, + title: 'Test', + state: {}, + previewIcon: 'empty', + }, + ]); + vis1Suggestions.mockReturnValueOnce([ + { + score: 0.2, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ]); + const vis2Suggestions = jest.fn(); + vis2Suggestions.mockReturnValueOnce([]); + vis2Suggestions.mockReturnValueOnce([ + { + score: 0.1, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ]); + const suggestions = getSuggestions({ + visualizationMap: { + vis1: { + ...mockVisualization1, + getSuggestions: vis1Suggestions, + }, + vis2: { + ...mockVisualization2, + getSuggestions: vis2Suggestions, + }, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(suggestions[0].datasourceState).toBe(tableState1); + expect(suggestions[0].datasourceId).toBe('mock'); + expect(suggestions[1].datasourceState).toBe(tableState2); + expect(suggestions[1].datasourceId).toBe('mock'); + expect(suggestions[2].datasourceState).toBe(tableState2); + expect(suggestions[2].datasourceId).toBe('mock'); + }); + + it('should pass the state of the currently active visualization to getSuggestions', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + const currentState = {}; + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(0), + generateSuggestion(1), + ]); + getSuggestions({ + visualizationMap: { + vis1: mockVisualization1, + vis2: mockVisualization2, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(mockVisualization1.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + expect(mockVisualization2.getSuggestions).not.toHaveBeenCalledWith( + expect.objectContaining({ + state: currentState, + }) + ); + }); + + it('should drop other layers only on visualization switch', () => { + const mockVisualization1 = createMockVisualization(); + const mockVisualization2 = createMockVisualization(); + datasourceMap.mock.getDatasourceSuggestionsFromCurrentState.mockReturnValue([ + generateSuggestion(), + ]); + datasourceMap.mock.getLayers.mockReturnValue(['first', 'second']); + const suggestions = getSuggestions({ + visualizationMap: { + vis1: { + ...mockVisualization1, + getSuggestions: () => [ + { + score: 0.8, + title: 'Test2', + state: {}, + previewIcon: 'empty', + }, + ], + }, + vis2: { + ...mockVisualization2, + getSuggestions: () => [ + { + score: 0.6, + title: 'Test3', + state: {}, + previewIcon: 'empty', + }, + ], + }, + }, + activeVisualizationId: 'vis1', + visualizationState: {}, + datasourceMap, + datasourceStates, + }); + expect(suggestions[0].keptLayerIds).toEqual(['first', 'second']); + expect(suggestions[1].keptLayerIds).toEqual(['first']); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts new file mode 100644 index 0000000000000..270c279375088 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_helpers.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Ast } from '@kbn/interpreter/common'; +import { + Visualization, + Datasource, + FramePublicAPI, + TableChangeType, + TableSuggestion, +} from '../../types'; +import { Action } from './state_management'; + +export interface Suggestion { + visualizationId: string; + datasourceState?: unknown; + datasourceId?: string; + keptLayerIds: string[]; + columns: number; + score: number; + title: string; + visualizationState: unknown; + previewExpression?: Ast | string; + previewIcon: string; + hide?: boolean; + changeType: TableChangeType; +} + +/** + * This function takes a list of available data tables and a list of visualization + * extensions and creates a ranked list of suggestions which contain a pair of a data table + * and a visualization. + * + * Each suggestion represents a valid state of the editor and can be applied by creating an + * action with `toSwitchAction` and dispatching it + */ +export function getSuggestions({ + datasourceMap, + datasourceStates, + visualizationMap, + activeVisualizationId, + visualizationState, + field, +}: { + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + visualizationMap: Record; + activeVisualizationId: string | null; + visualizationState: unknown; + field?: unknown; +}): Suggestion[] { + const datasources = Object.entries(datasourceMap).filter( + ([datasourceId]) => datasourceStates[datasourceId] && !datasourceStates[datasourceId].isLoading + ); + + const allLayerIds = _.flatten( + datasources.map(([datasourceId, datasource]) => + datasource.getLayers(datasourceStates[datasourceId].state) + ) + ); + + // Collect all table suggestions from available datasources + const datasourceTableSuggestions = _.flatten( + datasources.map(([datasourceId, datasource]) => { + const datasourceState = datasourceStates[datasourceId].state; + return (field + ? datasource.getDatasourceSuggestionsForField(datasourceState, field) + : datasource.getDatasourceSuggestionsFromCurrentState(datasourceState) + ).map(suggestion => ({ ...suggestion, datasourceId })); + }) + ); + + // Pass all table suggestions to all visualization extensions to get visualization suggestions + // and rank them by score + return _.flatten( + Object.entries(visualizationMap).map(([visualizationId, visualization]) => + _.flatten( + datasourceTableSuggestions.map(datasourceSuggestion => { + const table = datasourceSuggestion.table; + const currentVisualizationState = + visualizationId === activeVisualizationId ? visualizationState : undefined; + const keptLayerIds = + visualizationId !== activeVisualizationId + ? [datasourceSuggestion.table.layerId] + : allLayerIds; + return getVisualizationSuggestions( + visualization, + table, + visualizationId, + datasourceSuggestion, + currentVisualizationState, + keptLayerIds + ); + }) + ) + ) + ).sort((a, b) => b.score - a.score); +} + +/** + * Queries a single visualization extensions for a single datasource suggestion and + * creates an array of complete suggestions containing both the target datasource + * state and target visualization state along with suggestion meta data like score, + * title and preview expression. + */ +function getVisualizationSuggestions( + visualization: Visualization, + table: TableSuggestion, + visualizationId: string, + datasourceSuggestion: { datasourceId: string; state: unknown; table: TableSuggestion }, + currentVisualizationState: unknown, + keptLayerIds: string[] +) { + return visualization + .getSuggestions({ + table, + state: currentVisualizationState, + }) + .map(({ state, ...visualizationSuggestion }) => ({ + ...visualizationSuggestion, + visualizationId, + visualizationState: state, + keptLayerIds, + datasourceState: datasourceSuggestion.state, + datasourceId: datasourceSuggestion.datasourceId, + columns: table.columns.length, + changeType: table.changeType, + })); +} + +export function switchToSuggestion( + frame: FramePublicAPI, + dispatch: (action: Action) => void, + suggestion: Pick< + Suggestion, + 'visualizationId' | 'visualizationState' | 'datasourceState' | 'datasourceId' | 'keptLayerIds' + > +) { + const action: Action = { + type: 'SWITCH_VISUALIZATION', + newVisualizationId: suggestion.visualizationId, + initialState: suggestion.visualizationState, + datasourceState: suggestion.datasourceState, + datasourceId: suggestion.datasourceId, + }; + dispatch(action); + const layerIds = Object.keys(frame.datasourceLayers).filter(id => { + return !suggestion.keptLayerIds.includes(id); + }); + if (layerIds.length > 0) { + frame.removeLayers(layerIds); + } +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss new file mode 100644 index 0000000000000..c966bfcb80668 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.scss @@ -0,0 +1,60 @@ + // SASSTODO: Create this in EUI + @mixin lnsOverflowShadowHorizontal { + $hideHeight: $euiScrollBarCorner * 1.25; + mask-image: linear-gradient(to right, + transparentize(red, .9) 0%, + transparentize(red, 0) $hideHeight, + transparentize(red, 0) calc(100% - #{$hideHeight}), + transparentize(red, .9) 100% + ); +} + +.lnsSuggestionsPanel__title { + margin: $euiSizeS 0 $euiSizeXS; +} + +.lnsSuggestionsPanel__suggestions { + @include euiScrollBar; + @include lnsOverflowShadowHorizontal; + padding-top: $euiSizeXS; + overflow-x: auto; + overflow-y: hidden; + display: flex; + + // Padding / negative margins to make room for overflow shadow + padding-left: $euiSizeXS; + margin-left: -$euiSizeXS; + + // Add margin to the next of the same type + > * + * { + margin-left: $euiSizeS; + } +} + +// These sizes also match canvas' page thumbnails for consistency +$lnsSuggestionHeight: 100px; +$lnsSuggestionWidth: 150px; + +.lnsSuggestionPanel__button { + flex: 0 0 auto; + width: $lnsSuggestionWidth !important; + height: $lnsSuggestionHeight; + // Allows the scrollbar to stay flush to window + margin-bottom: $euiSize; +} + +.lnsSidebar__suggestionIcon { + color: $euiColorDarkShade; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: $euiSizeS; +} + +.lnsSuggestionChartWrapper { + height: $lnsSuggestionHeight - $euiSize; + pointer-events: none; + margin: 0 $euiSizeS; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx new file mode 100644 index 0000000000000..7302ea379eba8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.test.tsx @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { Visualization } from '../../types'; +import { + createMockVisualization, + createMockDatasource, + createExpressionRendererMock, + DatasourceMock, + createMockFramePublicAPI, +} from '../mocks'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel'; +import { getSuggestions, Suggestion } from './suggestion_helpers'; +import { fromExpression } from '@kbn/interpreter/target/common'; +import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; + +jest.mock('./suggestion_helpers'); + +describe('suggestion_panel', () => { + let mockVisualization: Visualization; + let mockDatasource: DatasourceMock; + + let expressionRendererMock: ExpressionRenderer; + let dispatchMock: jest.Mock; + + const suggestion1State = { suggestion1: true }; + const suggestion2State = { suggestion2: true }; + + let defaultProps: SuggestionPanelProps; + + beforeEach(() => { + mockVisualization = createMockVisualization(); + mockDatasource = createMockDatasource(); + expressionRendererMock = createExpressionRendererMock(); + dispatchMock = jest.fn(); + + (getSuggestions as jest.Mock).mockReturnValue([ + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + visualizationState: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + keptLayerIds: ['a'], + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + visualizationState: suggestion2State, + visualizationId: 'vis', + title: 'Suggestion2', + keptLayerIds: ['a'], + }, + ] as Suggestion[]); + + defaultProps = { + activeDatasourceId: 'mock', + datasourceMap: { + mock: mockDatasource, + }, + datasourceStates: { + mock: { + isLoading: false, + state: {}, + }, + }, + activeVisualizationId: 'vis', + visualizationMap: { + vis: mockVisualization, + }, + visualizationState: {}, + dispatch: dispatchMock, + ExpressionRenderer: expressionRendererMock, + frame: createMockFramePublicAPI(), + }; + }); + + it('should list passed in suggestions', () => { + const wrapper = mount(); + + expect( + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .find(EuiPanel) + .map(el => el.parents(EuiToolTip).prop('content')) + ).toEqual(['Suggestion1', 'Suggestion2']); + }); + + it('should dispatch visualization switch action if suggestion is clicked', () => { + const wrapper = mount(); + + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .first() + .simulate('click'); + + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'SWITCH_VISUALIZATION', + initialState: suggestion1State, + }) + ); + }); + + it('should remove unused layers if suggestion is clicked', () => { + defaultProps.frame.datasourceLayers.a = mockDatasource.publicAPIMock; + defaultProps.frame.datasourceLayers.b = mockDatasource.publicAPIMock; + const wrapper = mount(); + + wrapper + .find('[data-test-subj="lnsSuggestion"]') + .first() + .simulate('click'); + + expect(defaultProps.frame.removeLayers).toHaveBeenCalledWith(['b']); + }); + + it('should render preview expression if there is one', () => { + mockDatasource.getLayers.mockReturnValue(['first']); + (getSuggestions as jest.Mock).mockReturnValue([ + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + visualizationState: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + visualizationState: suggestion2State, + visualizationId: 'vis', + title: 'Suggestion2', + previewExpression: 'test | expression', + }, + ] as Suggestion[]); + + mockDatasource.toExpression.mockReturnValue('datasource_expression'); + + mount(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + const passedExpression = fromExpression( + (expressionRendererMock as jest.Mock).mock.calls[0][0].expression + ); + expect(passedExpression).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "query": Array [ + "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", + ], + "timeRange": Array [ + "{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}", + ], + }, + "function": "kibana_context", + "type": "function", + }, + Object { + "arguments": Object { + "layerIds": Array [ + "first", + ], + "tables": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource_expression", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_merge_tables", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "test", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "expression", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should render render icon if there is no preview expression', () => { + mockDatasource.getLayers.mockReturnValue(['first']); + (getSuggestions as jest.Mock).mockReturnValue([ + { + datasourceState: {}, + previewIcon: 'visTable', + score: 0.5, + visualizationState: suggestion1State, + visualizationId: 'vis', + title: 'Suggestion1', + }, + { + datasourceState: {}, + previewIcon: 'empty', + score: 0.5, + visualizationState: suggestion2State, + visualizationId: 'vis', + title: 'Suggestion2', + previewExpression: 'test | expression', + }, + ] as Suggestion[]); + + mockDatasource.toExpression.mockReturnValue('datasource_expression'); + + const wrapper = mount(); + + expect(wrapper.find(EuiIcon)).toHaveLength(1); + expect(wrapper.find(EuiIcon).prop('type')).toEqual('visTable'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx new file mode 100644 index 0000000000000..ad073913930cc --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIcon, EuiTitle, EuiPanel, EuiIconTip, EuiToolTip } from '@elastic/eui'; +import { toExpression, Ast } from '@kbn/interpreter/common'; +import { i18n } from '@kbn/i18n'; +import { Action } from './state_management'; +import { Datasource, Visualization, FramePublicAPI } from '../../types'; +import { getSuggestions, Suggestion, switchToSuggestion } from './suggestion_helpers'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; +import { debouncedComponent } from '../../debounced_component'; + +const MAX_SUGGESTIONS_DISPLAYED = 5; + +// TODO: Remove this when upstream fix is merged https://github.com/elastic/eui/issues/2329 +// eslint-disable-next-line +const EuiPanelFixed = EuiPanel as React.ComponentType; + +export interface SuggestionPanelProps { + activeDatasourceId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + dispatch: (action: Action) => void; + ExpressionRenderer: ExpressionRenderer; + frame: FramePublicAPI; +} + +const SuggestionPreview = ({ + suggestion, + dispatch, + frame, + previewExpression, + ExpressionRenderer: ExpressionRendererComponent, +}: { + suggestion: Suggestion; + dispatch: (action: Action) => void; + frame: FramePublicAPI; + ExpressionRenderer: ExpressionRenderer; + previewExpression?: string; +}) => { + const [expressionError, setExpressionError] = useState(false); + + useEffect(() => { + setExpressionError(false); + }, [previewExpression]); + + const clickHandler = () => { + switchToSuggestion(frame, dispatch, suggestion); + }; + + return ( + + + {expressionError ? ( +
+ +
+ ) : previewExpression ? ( + { + // eslint-disable-next-line no-console + console.error(`Failed to render preview: `, e); + setExpressionError(true); + }} + /> + ) : ( +
+ +
+ )} +
+
+ ); +}; + +export const SuggestionPanel = debouncedComponent(InnerSuggestionPanel, 2000); + +function InnerSuggestionPanel({ + activeDatasourceId, + datasourceMap, + datasourceStates, + activeVisualizationId, + visualizationMap, + visualizationState, + dispatch, + frame, + ExpressionRenderer: ExpressionRendererComponent, +}: SuggestionPanelProps) { + if (!activeDatasourceId) { + return null; + } + + const suggestions = getSuggestions({ + datasourceMap, + datasourceStates, + visualizationMap, + activeVisualizationId, + visualizationState, + }) + .filter(suggestion => !suggestion.hide) + .slice(0, MAX_SUGGESTIONS_DISPLAYED); + + if (suggestions.length === 0) { + return null; + } + + return ( +
+ +

+ +

+
+
+ {suggestions.map((suggestion: Suggestion) => ( + + ))} +
+
+ ); +} + +function preparePreviewExpression( + expression: string | Ast, + datasourceMap: Record>, + datasourceStates: Record, + framePublicAPI: FramePublicAPI, + suggestionDatasourceId?: string, + suggestionDatasourceState?: unknown +) { + const expressionWithDatasource = prependDatasourceExpression( + expression, + datasourceMap, + suggestionDatasourceId + ? { + ...datasourceStates, + [suggestionDatasourceId]: { + isLoading: false, + state: suggestionDatasourceState, + }, + } + : datasourceStates + ); + + const expressionContext = { + query: framePublicAPI.query, + timeRange: { + from: framePublicAPI.dateRange.fromDate, + to: framePublicAPI.dateRange.toDate, + }, + }; + + return expressionWithDatasource + ? toExpression(prependKibanaContext(expressionWithDatasource, expressionContext)) + : undefined; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx new file mode 100644 index 0000000000000..86a0e5c8a833a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.test.tsx @@ -0,0 +1,725 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { ExpressionRendererProps } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { Visualization, FramePublicAPI, TableSuggestion } from '../../types'; +import { + createMockVisualization, + createMockDatasource, + createExpressionRendererMock, + DatasourceMock, + createMockFramePublicAPI, +} from '../mocks'; +import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { DragDrop, ChildDragDropProvider } from '../../drag_drop'; +import { Ast } from '@kbn/interpreter/common'; + +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + +describe('workspace_panel', () => { + let mockVisualization: jest.Mocked; + let mockVisualization2: jest.Mocked; + let mockDatasource: DatasourceMock; + + let expressionRendererMock: jest.Mock; + + let instance: ReactWrapper; + + beforeEach(() => { + mockVisualization = createMockVisualization(); + mockVisualization2 = createMockVisualization(); + + mockDatasource = createMockDatasource(); + + expressionRendererMock = createExpressionRendererMock(); + }); + + afterEach(() => { + instance.unmount(); + }); + + it('should render an explanatory text if no visualization is active', () => { + instance = mount( + {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); + }); + + it('should render an explanatory text if the visualization does not produce an expression', () => { + instance = mount( + null }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); + }); + + it('should render an explanatory text if the datasource does not produce an expression', () => { + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); + }); + + it('should render the resulting expression using the expression renderer', () => { + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, + Object { + "arguments": Object { + "filters": Array [], + "query": Array [ + "{\\"query\\":\\"\\",\\"language\\":\\"lucene\\"}", + ], + "timeRange": Array [ + "{\\"from\\":\\"now-7d\\",\\"to\\":\\"now\\"}", + ], + }, + "function": "kibana_context", + "type": "function", + }, + Object { + "arguments": Object { + "layerIds": Array [ + "first", + ], + "tables": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", + }, + ], + "type": "expression", + }, + ], + }, + "function": "lens_merge_tables", + "type": "function", + }, + Object { + "arguments": Object {}, + "function": "vis", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + + it('should include data fetching for each layer in the expression', () => { + const mockDatasource2 = createMockDatasource(); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + second: mockDatasource2.publicAPIMock, + }; + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + + mockDatasource2.toExpression.mockReturnValue('datasource2'); + mockDatasource2.getLayers.mockReturnValue(['second', 'third']); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect( + (instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.layerIds + ).toEqual(['first', 'second', 'third']); + expect( + (instance.find(expressionRendererMock).prop('expression') as Ast).chain[2].arguments.tables + ).toMatchInlineSnapshot(` + Array [ + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource", + "type": "function", + }, + ], + "type": "expression", + }, + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource2", + "type": "function", + }, + ], + "type": "expression", + }, + Object { + "chain": Array [ + Object { + "arguments": Object {}, + "function": "datasource2", + "type": "function", + }, + ], + "type": "expression", + }, + ] + `); + }); + + it('should run the expression again if the date range changes', async () => { + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + mockDatasource.getLayers.mockReturnValue(['first']); + + mockDatasource.toExpression + .mockReturnValueOnce('datasource') + .mockReturnValueOnce('datasource second'); + + expressionRendererMock = jest.fn(_arg => ); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + // "wait" for the expression to execute + await waitForPromises(); + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + + instance.setProps({ + framePublicAPI: { ...framePublicAPI, dateRange: { fromDate: 'now-90d', toDate: 'now-30d' } }, + }); + + await waitForPromises(); + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(2); + }); + + describe('expression failures', () => { + it('should show an error message if the expression fails to parse', () => { + mockDatasource.toExpression.mockReturnValue('|||'); + mockDatasource.getLayers.mockReturnValue(['first']); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + expect(instance.find('[data-test-subj="expression-failure"]').first()).toBeTruthy(); + expect(instance.find(expressionRendererMock)).toHaveLength(0); + }); + + it('should show an error message if the expression fails to render', async () => { + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + // "wait" for the expression to execute + await waitForPromises(); + + instance.update(); + + expect(instance.find('EuiFlexItem[data-test-subj="expression-failure"]')).toHaveLength(1); + expect(instance.find(expressionRendererMock)).toHaveLength(0); + }); + + it('should not attempt to run the expression again if it does not change', async () => { + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + // "wait" for the expression to execute + await waitForPromises(); + + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + }); + + it('should attempt to run the expression again if changes after an error', async () => { + mockDatasource.toExpression.mockReturnValue('datasource'); + mockDatasource.getLayers.mockReturnValue(['first']); + const framePublicAPI = createMockFramePublicAPI(); + framePublicAPI.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + expressionRendererMock = jest.fn(({ onRenderFailure }) => { + Promise.resolve().then(() => onRenderFailure!({ type: 'error' })); + return ; + }); + + instance = mount( + 'vis' }, + }} + visualizationState={{}} + dispatch={() => {}} + ExpressionRenderer={expressionRendererMock} + /> + ); + + // "wait" for the expression to execute + await waitForPromises(); + + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(1); + + expressionRendererMock.mockImplementation(_ => { + return ; + }); + + instance.setProps({ visualizationState: {} }); + instance.update(); + + expect(expressionRendererMock).toHaveBeenCalledTimes(2); + + expect(instance.find(expressionRendererMock)).toHaveLength(1); + }); + }); + + describe('suggestions from dropping in workspace panel', () => { + let mockDispatch: jest.Mock; + let frame: jest.Mocked; + + const draggedField: unknown = {}; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDispatch = jest.fn(); + }); + + function initComponent(draggingContext: unknown = draggedField) { + instance = mount( + {}}> + + + ); + } + + it('should immediately transition if exactly one suggestion is returned', () => { + const expectedTable: TableSuggestion = { + isMultiRow: true, + layerId: '1', + columns: [], + changeType: 'unchanged', + }; + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: expectedTable, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + previewIcon: 'empty', + }, + ]); + initComponent(); + + instance.find(DragDrop).prop('onDrop')!(draggedField); + + expect(mockDatasource.getDatasourceSuggestionsForField).toHaveBeenCalledTimes(1); + expect(mockVisualization.getSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + table: expectedTable, + }) + ); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: {}, + datasourceState: {}, + datasourceId: 'mock', + }); + }); + + it('should allow to drop if there are suggestions', () => { + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + isMultiRow: true, + layerId: '1', + columns: [], + changeType: 'unchanged', + }, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + previewIcon: 'empty', + }, + ]); + initComponent(); + expect(instance.find(DragDrop).prop('droppable')).toBeTruthy(); + }); + + it('should refuse to drop if there only suggestions from other visualizations if there are data tables', () => { + frame.datasourceLayers.a = mockDatasource.publicAPIMock; + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'a' }]); + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + isMultiRow: true, + layerId: '1', + columns: [], + changeType: 'unchanged', + }, + }, + ]); + mockVisualization2.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + previewIcon: 'empty', + }, + ]); + initComponent(); + expect(instance.find(DragDrop).prop('droppable')).toBeFalsy(); + }); + + it('should allow to drop if there are suggestions from active visualization even if there are data tables', () => { + frame.datasourceLayers.a = mockDatasource.publicAPIMock; + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'a' }]); + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + isMultiRow: true, + layerId: '1', + columns: [], + changeType: 'unchanged', + }, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'my title', + state: {}, + previewIcon: 'empty', + }, + ]); + initComponent(); + expect(instance.find(DragDrop).prop('droppable')).toBeTruthy(); + }); + + it('should refuse to drop if there are no suggestions', () => { + initComponent(); + expect(instance.find(DragDrop).prop('droppable')).toBeFalsy(); + }); + + it('should immediately transition to the first suggestion if there are multiple', () => { + mockDatasource.getDatasourceSuggestionsForField.mockReturnValueOnce([ + { + state: {}, + table: { + isMultiRow: true, + columns: [], + layerId: '1', + changeType: 'unchanged', + }, + }, + { + state: {}, + table: { + isMultiRow: true, + columns: [], + layerId: '1', + changeType: 'unchanged', + }, + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.5, + title: 'second suggestion', + state: {}, + previewIcon: 'empty', + }, + ]); + mockVisualization.getSuggestions.mockReturnValueOnce([ + { + score: 0.8, + title: 'first suggestion', + state: { + isFirst: true, + }, + previewIcon: 'empty', + }, + ]); + + initComponent(); + instance.find(DragDrop).prop('onDrop')!(draggedField); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SWITCH_VISUALIZATION', + newVisualizationId: 'vis', + initialState: { + isFirst: true, + }, + datasourceState: {}, + datasourceId: 'mock', + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx new file mode 100644 index 0000000000000..81777f3593dc0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect, useMemo, useContext } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { toExpression } from '@kbn/interpreter/common'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { Action } from './state_management'; +import { Datasource, Visualization, FramePublicAPI } from '../../types'; +import { DragDrop, DragContext } from '../../drag_drop'; +import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; +import { buildExpression } from './expression_helpers'; +import { debouncedComponent } from '../../debounced_component'; + +export interface WorkspacePanelProps { + activeVisualizationId: string | null; + visualizationMap: Record; + visualizationState: unknown; + activeDatasourceId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + state: unknown; + isLoading: boolean; + } + >; + framePublicAPI: FramePublicAPI; + dispatch: (action: Action) => void; + ExpressionRenderer: ExpressionRenderer; +} + +export const WorkspacePanel = debouncedComponent(InnerWorkspacePanel); + +// Exported for testing purposes only. +export function InnerWorkspacePanel({ + activeDatasourceId, + activeVisualizationId, + visualizationMap, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, + dispatch, + ExpressionRenderer: ExpressionRendererComponent, +}: WorkspacePanelProps) { + const dragDropContext = useContext(DragContext); + + const suggestionForDraggedField = useMemo(() => { + if (!dragDropContext.dragging || !activeDatasourceId) { + return; + } + + const hasData = Object.values(framePublicAPI.datasourceLayers).some( + datasource => datasource.getTableSpec().length > 0 + ); + + const suggestions = getSuggestions({ + datasourceMap: { [activeDatasourceId]: datasourceMap[activeDatasourceId] }, + datasourceStates, + visualizationMap: + hasData && activeVisualizationId + ? { [activeVisualizationId]: visualizationMap[activeVisualizationId] } + : visualizationMap, + activeVisualizationId, + visualizationState, + field: dragDropContext.dragging, + }); + + return suggestions[0]; + }, [dragDropContext.dragging]); + + function onDrop() { + if (suggestionForDraggedField) { + switchToSuggestion(framePublicAPI, dispatch, suggestionForDraggedField); + } + } + + function renderEmptyWorkspace() { + return ( +

+ +

+ ); + } + + function renderVisualization() { + const [expressionError, setExpressionError] = useState(undefined); + + const activeVisualization = activeVisualizationId + ? visualizationMap[activeVisualizationId] + : null; + const expression = useMemo(() => { + try { + return buildExpression({ + visualization: activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, + }); + } catch (e) { + setExpressionError(e.toString()); + } + }, [ + activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI.dateRange, + framePublicAPI.query, + ]); + + useEffect(() => { + // reset expression error if component attempts to run it again + if (expressionError) { + setExpressionError(undefined); + } + }, [expression]); + + if (expression === null) { + return renderEmptyWorkspace(); + } + + if (expressionError) { + return ( + + + {/* TODO word this differently because expressions should not be exposed at this level */} + + + {expression && ( + + {toExpression(expression)} + + )} + + {JSON.stringify(expressionError, null, 2)} + + + ); + } else { + return ( + { + setExpressionError(e); + }} + /> + ); + } + } + + return ( + + {renderVisualization()} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel_wrapper.tsx new file mode 100644 index 0000000000000..e878b870b2760 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel_wrapper.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPageContent, EuiPageContentHeader, EuiPageContentBody } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Action } from './state_management'; + +interface Props { + title: string; + dispatch: React.Dispatch; + children: React.ReactNode | React.ReactNode[]; +} + +export function WorkspacePanelWrapper({ children, title, dispatch }: Props) { + return ( + + + dispatch({ type: 'UPDATE_TITLE', title: e.target.value })} + aria-label={i18n.translate('xpack.lens.chartTitleAriaLabel', { + defaultMessage: 'Title', + })} + /> + + {children} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.test.tsx new file mode 100644 index 0000000000000..96624764bb8ca --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Embeddable } from './embeddable'; +import { TimeRange } from 'src/plugins/data/public'; +import { Query } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public'; +import { Filter } from '@kbn/es-query'; +import { Document } from '../../persistence'; +import { act } from 'react-dom/test-utils'; + +jest.mock('../../../../../../../src/legacy/ui/public/inspector', () => ({ + isAvailable: false, + open: false, +})); + +const savedVis: Document = { + expression: 'my | expression', + state: { + visualization: {}, + datasourceStates: {}, + datasourceMetaData: { + filterableIndexPatterns: [], + }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + title: 'My title', + visualizationType: '', +}; + +describe('embeddable', () => { + let mountpoint: HTMLDivElement; + let expressionRenderer: jest.Mock; + + beforeEach(() => { + mountpoint = document.createElement('div'); + expressionRenderer = jest.fn(_props => null); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should render expression with expression renderer', () => { + const embeddable = new Embeddable( + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123' } + ); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(savedVis.expression); + }); + + it('should display error if expression renderering fails', () => { + const embeddable = new Embeddable( + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123' } + ); + embeddable.render(mountpoint); + + act(() => { + expressionRenderer.mock.calls[0][0]!.onRenderFailure!({ type: 'error' }); + }); + + expect(mountpoint.innerHTML).toContain("Visualization couldn't be displayed"); + }); + + it('should re-render if new input is pushed', () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; + + const embeddable = new Embeddable( + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123' } + ); + embeddable.render(mountpoint); + + embeddable.updateInput({ + timeRange, + query, + filters, + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); + + it('should pass context to embeddable', () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: false } }]; + + const embeddable = new Embeddable( + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123', timeRange, query, filters } + ); + embeddable.render(mountpoint); + + expect(expressionRenderer.mock.calls[0][0].searchContext).toEqual({ + type: 'kibana_context', + timeRange, + query, + filters, + }); + }); + + it('should not re-render if only change is in disabled filter', () => { + const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; + const query: Query = { language: 'kquery', query: '' }; + const filters: Filter[] = [{ meta: { alias: 'test', negate: false, disabled: true } }]; + + const embeddable = new Embeddable( + expressionRenderer, + { + editUrl: '', + editable: true, + savedVis, + }, + { id: '123', timeRange, query, filters } + ); + embeddable.render(mountpoint); + + embeddable.updateInput({ + timeRange, + query, + filters: [{ meta: { alias: 'test', negate: true, disabled: true } }], + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.tsx new file mode 100644 index 0000000000000..e815a1951bdb7 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { TimeRange } from 'src/plugins/data/public'; +import { Query, StaticIndexPattern } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public'; +import { Filter } from '@kbn/es-query'; +import { Subscription } from 'rxjs'; +import { + Embeddable as AbstractEmbeddable, + EmbeddableOutput, + IContainer, + EmbeddableInput, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { Document, DOC_TYPE } from '../../persistence'; +import { ExpressionWrapper } from './expression_wrapper'; + +export interface LensEmbeddableConfiguration { + savedVis: Document; + editUrl: string; + editable: boolean; + indexPatterns?: StaticIndexPattern[]; +} + +export interface LensEmbeddableInput extends EmbeddableInput { + timeRange?: TimeRange; + query?: Query; + filters?: Filter[]; +} + +export interface LensEmbeddableOutput extends EmbeddableOutput { + indexPatterns?: StaticIndexPattern[]; +} + +export class Embeddable extends AbstractEmbeddable { + type = DOC_TYPE; + + private expressionRenderer: ExpressionRenderer; + private savedVis: Document; + private domNode: HTMLElement | Element | undefined; + private subscription: Subscription; + + private currentContext: { + timeRange?: TimeRange; + query?: Query; + filters?: Filter[]; + lastReloadRequestTime?: number; + } = {}; + + constructor( + expressionRenderer: ExpressionRenderer, + { savedVis, editUrl, editable, indexPatterns }: LensEmbeddableConfiguration, + initialInput: LensEmbeddableInput, + parent?: IContainer + ) { + super( + initialInput, + { + defaultTitle: savedVis.title, + savedObjectId: savedVis.id, + editable, + // passing edit url and index patterns to the output of this embeddable for + // the container to pick them up and use them to configure filter bar and + // config dropdown correctly. + editUrl, + indexPatterns, + }, + parent + ); + + this.expressionRenderer = expressionRenderer; + this.savedVis = savedVis; + this.subscription = this.getInput$().subscribe(input => this.onContainerStateChanged(input)); + this.onContainerStateChanged(initialInput); + } + + onContainerStateChanged(containerState: LensEmbeddableInput) { + const cleanedFilters = containerState.filters + ? containerState.filters.filter(filter => !filter.meta.disabled) + : undefined; + if ( + !_.isEqual(containerState.timeRange, this.currentContext.timeRange) || + !_.isEqual(containerState.query, this.currentContext.query) || + !_.isEqual(cleanedFilters, this.currentContext.filters) + ) { + this.currentContext = { + timeRange: containerState.timeRange, + query: containerState.query, + lastReloadRequestTime: this.currentContext.lastReloadRequestTime, + filters: cleanedFilters, + }; + + if (this.domNode) { + this.render(this.domNode); + } + } + } + + /** + * + * @param {HTMLElement} domNode + * @param {ContainerState} containerState + */ + render(domNode: HTMLElement | Element) { + this.domNode = domNode; + render( + , + domNode + ); + } + + destroy() { + super.destroy(); + if (this.domNode) { + unmountComponentAtNode(this.domNode); + } + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + reload() { + const currentTime = Date.now(); + if (this.currentContext.lastReloadRequestTime !== currentTime) { + this.currentContext = { + ...this.currentContext, + lastReloadRequestTime: currentTime, + }; + + if (this.domNode) { + this.render(this.domNode); + } + } + } +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable_factory.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable_factory.ts new file mode 100644 index 0000000000000..c340342a31ff6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/embeddable_factory.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Chrome } from 'ui/chrome'; + +import { capabilities } from 'ui/capabilities'; +import { i18n } from '@kbn/i18n'; +import { IndexPatterns, IndexPattern } from 'src/legacy/core_plugins/data/public'; +import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; +import { + EmbeddableFactory as AbstractEmbeddableFactory, + ErrorEmbeddable, + EmbeddableInput, + IContainer, +} from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { Embeddable } from './embeddable'; +import { SavedObjectIndexStore, DOC_TYPE } from '../../persistence'; +import { getEditPath } from '../../../common'; + +export class EmbeddableFactory extends AbstractEmbeddableFactory { + type = DOC_TYPE; + + private chrome: Chrome; + private indexPatternService: IndexPatterns; + private expressionRenderer: ExpressionRenderer; + + constructor( + chrome: Chrome, + expressionRenderer: ExpressionRenderer, + indexPatternService: IndexPatterns + ) { + super({ + savedObjectMetaData: { + name: i18n.translate('xpack.lens.lensSavedObjectLabel', { + defaultMessage: 'Lens Visualization', + }), + type: DOC_TYPE, + getIconForSavedObject: () => 'faceHappy', + }, + }); + this.chrome = chrome; + this.expressionRenderer = expressionRenderer; + this.indexPatternService = indexPatternService; + } + + public isEditable() { + return capabilities.get().lens.save as boolean; + } + + canCreateNew() { + return false; + } + + getDisplayName() { + return i18n.translate('xpack.lens.embeddableDisplayName', { + defaultMessage: 'lens', + }); + } + + async createFromSavedObject( + savedObjectId: string, + input: Partial & { id: string }, + parent?: IContainer + ) { + const store = new SavedObjectIndexStore(this.chrome.getSavedObjectsClient()); + const savedVis = await store.load(savedObjectId); + + const promises = savedVis.state.datasourceMetaData.filterableIndexPatterns.map( + async ({ id }) => { + try { + return await this.indexPatternService.get(id); + } catch (error) { + // Unable to load index pattern, ignore error as the index patterns are only used to + // configure the filter and query bar - there is still a good chance to get the visualization + // to show. + return null; + } + } + ); + const indexPatterns = (await Promise.all(promises)).filter( + (indexPattern: IndexPattern | null): indexPattern is IndexPattern => Boolean(indexPattern) + ); + + return new Embeddable( + this.expressionRenderer, + { + savedVis, + editUrl: this.chrome.addBasePath(getEditPath(savedObjectId)), + editable: this.isEditable(), + indexPatterns, + }, + input, + parent + ); + } + + async create(input: EmbeddableInput) { + return new ErrorEmbeddable('Lens can only be created from a saved object', input); + } +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/expression_wrapper.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/expression_wrapper.tsx new file mode 100644 index 0000000000000..91d0c2ad34334 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/embeddable/expression_wrapper.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useEffect } from 'react'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; +import { TimeRange } from 'src/plugins/data/public'; +import { Query } from 'src/legacy/core_plugins/data/public'; +import { Filter } from '@kbn/es-query'; +import { ExpressionRenderer } from 'src/legacy/core_plugins/expressions/public'; + +export interface ExpressionWrapperProps { + ExpressionRenderer: ExpressionRenderer; + expression: string; + context: { + timeRange?: TimeRange; + query?: Query; + filters?: Filter[]; + lastReloadRequestTime?: number; + }; +} + +export function ExpressionWrapper({ + ExpressionRenderer: ExpressionRendererComponent, + expression, + context, +}: ExpressionWrapperProps) { + const [expressionError, setExpressionError] = useState(undefined); + useEffect(() => { + // reset expression error if component attempts to run it again + if (expressionError) { + setExpressionError(undefined); + } + }, [expression, context]); + return ( + + {expression === '' || expressionError ? ( + + + + + + + + + + + ) : ( + { + setExpressionError(e); + }} + searchContext={{ ...context, type: 'kibana_context' }} + /> + )} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/index.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.test.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.test.ts new file mode 100644 index 0000000000000..3aa765f2910fd --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mergeTables } from './merge_tables'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; + +describe('lens_merge_tables', () => { + it('should produce a row with the nested table as defined', () => { + const sampleTable1: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'count', name: 'Count' }], + rows: [{ bucket: 'a', count: 5 }, { bucket: 'b', count: 10 }], + }; + + const sampleTable2: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'C' }, { id: 'avg', name: 'Average' }], + rows: [{ bucket: 'a', avg: 2.5 }, { bucket: 'b', avg: 9 }], + }; + + expect( + mergeTables.fn( + null, + { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, + {} + ) + ).toEqual({ + tables: { first: sampleTable1, second: sampleTable2 }, + type: 'lens_multitable', + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.ts b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.ts new file mode 100644 index 0000000000000..9ddba447df293 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/merge_tables.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/types'; +import { KibanaDatatable } from '../../../../../../src/legacy/core_plugins/interpreter/common'; +import { LensMultiTable } from '../types'; + +interface MergeTables { + layerIds: string[]; + tables: KibanaDatatable[]; +} + +export const mergeTables: ExpressionFunction< + 'lens_merge_tables', + null, + MergeTables, + LensMultiTable +> = { + name: 'lens_merge_tables', + type: 'lens_multitable', + help: i18n.translate('xpack.lens.functions.mergeTables.help', { + defaultMessage: 'A helper to merge any number of kibana tables into a single table', + }), + args: { + layerIds: { + types: ['string'], + help: '', + multi: true, + }, + tables: { + types: ['kibana_datatable'], + help: '', + multi: true, + }, + }, + context: { + types: ['null'], + }, + fn(_ctx, { layerIds, tables }: MergeTables) { + const resultTables: Record = {}; + tables.forEach((table, index) => { + resultTables[layerIds[index]] = table; + }); + return { + type: 'lens_multitable', + tables: resultTables, + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx new file mode 100644 index 0000000000000..02e93e4631284 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/mocks.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ExpressionRendererProps } from 'src/legacy/core_plugins/expressions/public'; +import { + ExpressionsSetup, + ExpressionsStart, +} from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types'; +import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin'; + +export function createMockVisualization(): jest.Mocked { + return { + id: 'TEST_VIS', + visualizationTypes: [ + { + icon: 'empty', + id: 'TEST_VIS', + label: 'TEST', + }, + ], + getDescription: jest.fn(_state => ({ label: '' })), + switchVisualizationType: jest.fn((_, x) => x), + getPersistableState: jest.fn(_state => _state), + getSuggestions: jest.fn(_options => []), + initialize: jest.fn((_frame, _state?) => ({})), + renderConfigPanel: jest.fn(), + toExpression: jest.fn((_state, _frame) => null), + }; +} + +export type DatasourceMock = jest.Mocked & { + publicAPIMock: jest.Mocked; +}; + +export function createMockDatasource(): DatasourceMock { + const publicAPIMock: jest.Mocked = { + getTableSpec: jest.fn(() => []), + getOperationForColumnId: jest.fn(), + renderDimensionPanel: jest.fn(), + renderLayerPanel: jest.fn(), + removeColumnInTableSpec: jest.fn(), + moveColumnTo: jest.fn(), + duplicateColumn: jest.fn(), + }; + + return { + getDatasourceSuggestionsForField: jest.fn((_state, item) => []), + getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []), + getPersistableState: jest.fn(), + getPublicAPI: jest.fn((_state, _setState, _layerId) => publicAPIMock), + initialize: jest.fn((_state?) => Promise.resolve()), + renderDataPanel: jest.fn(), + toExpression: jest.fn((_frame, _state) => null), + insertLayer: jest.fn((_state, _newLayerId) => {}), + removeLayer: jest.fn((_state, _layerId) => {}), + getLayers: jest.fn(_state => []), + getMetaData: jest.fn(_state => ({ filterableIndexPatterns: [] })), + + // this is an additional property which doesn't exist on real datasources + // but can be used to validate whether specific API mock functions are called + publicAPIMock, + }; +} + +export type FrameMock = jest.Mocked; + +export function createMockFramePublicAPI(): FrameMock { + return { + datasourceLayers: {}, + addNewLayer: jest.fn(() => ''), + removeLayers: jest.fn(), + dateRange: { fromDate: 'now-7d', toDate: 'now' }, + query: { query: '', language: 'lucene' }, + }; +} + +type Omit = Pick>; + +export type MockedSetupDependencies = Omit & { + expressions: jest.Mocked; +}; + +export type MockedStartDependencies = Omit & { + expressions: jest.Mocked; +}; + +export function createExpressionRendererMock(): jest.Mock< + React.ReactElement, + [ExpressionRendererProps] +> { + return jest.fn(_ => ); +} + +export function createMockSetupDependencies() { + return ({ + data: {}, + expressions: { + registerFunction: jest.fn(), + registerRenderer: jest.fn(), + }, + chrome: { + getSavedObjectsClient: () => {}, + }, + } as unknown) as MockedSetupDependencies; +} + +export function createMockStartDependencies() { + return ({ + data: { + indexPatterns: { + indexPatterns: {}, + }, + }, + expressions: { + ExpressionRenderer: jest.fn(() => null), + }, + embeddables: { + registerEmbeddableFactory: jest.fn(), + }, + } as unknown) as MockedStartDependencies; +} diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.test.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.test.tsx new file mode 100644 index 0000000000000..7b21ec0cac1c2 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EditorFramePlugin } from './plugin'; +import { coreMock } from 'src/core/public/mocks'; +import { + MockedSetupDependencies, + MockedStartDependencies, + createMockSetupDependencies, + createMockStartDependencies, +} from './mocks'; + +jest.mock('ui/new_platform'); +jest.mock('ui/chrome', () => ({ + getSavedObjectsClient: jest.fn(), +})); + +// mock away actual dependencies to prevent all of it being loaded +jest.mock('../../../../../../src/legacy/core_plugins/interpreter/public/registries', () => {}); +jest.mock('../../../../../../src/legacy/core_plugins/data/public/legacy', () => ({ + start: {}, + setup: {}, +})); +jest.mock('../../../../../../src/legacy/core_plugins/expressions/public/legacy', () => ({ + start: {}, + setup: {}, +})); +jest.mock('./embeddable/embeddable_factory', () => ({ + EmbeddableFactory: class Mock {}, +})); + +describe('editor_frame plugin', () => { + let pluginInstance: EditorFramePlugin; + let mountpoint: Element; + let pluginSetupDependencies: MockedSetupDependencies; + let pluginStartDependencies: MockedStartDependencies; + + beforeEach(() => { + pluginInstance = new EditorFramePlugin(); + mountpoint = document.createElement('div'); + pluginSetupDependencies = createMockSetupDependencies(); + pluginStartDependencies = createMockStartDependencies(); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should create an editor frame instance which mounts and unmounts', () => { + expect(() => { + pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies); + const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint, { + onError: jest.fn(), + onChange: jest.fn(), + dateRange: { fromDate: '', toDate: '' }, + query: { query: '', language: 'lucene' }, + }); + instance.unmount(); + }).not.toThrowError(); + }); + + it('should not have child nodes after unmount', () => { + pluginInstance.setup(coreMock.createSetup(), pluginSetupDependencies); + const publicAPI = pluginInstance.start(coreMock.createStart(), pluginStartDependencies); + const instance = publicAPI.createInstance({}); + instance.mount(mountpoint, { + onError: jest.fn(), + onChange: jest.fn(), + dateRange: { fromDate: '', toDate: '' }, + query: { query: '', language: 'lucene' }, + }); + instance.unmount(); + + expect(mountpoint.hasChildNodes()).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx new file mode 100644 index 0000000000000..e27c2e54500cf --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/plugin.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreSetup, CoreStart } from 'src/core/public'; +import chrome, { Chrome } from 'ui/chrome'; +import { npSetup, npStart } from 'ui/new_platform'; +import { Plugin as EmbeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { start as embeddablePlugin } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; +import { + setup as dataSetup, + start as dataStart, +} from '../../../../../../src/legacy/core_plugins/data/public/legacy'; +import { + setup as expressionsSetup, + start as expressionsStart, +} from '../../../../../../src/legacy/core_plugins/expressions/public/legacy'; +import { + Datasource, + Visualization, + EditorFrameSetup, + EditorFrameInstance, + EditorFrameStart, +} from '../types'; +import { EditorFrame } from './editor_frame'; +import { mergeTables } from './merge_tables'; +import { EmbeddableFactory } from './embeddable/embeddable_factory'; +import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management'; + +export interface EditorFrameSetupPlugins { + data: typeof dataSetup; + expressions: typeof expressionsSetup; +} + +export interface EditorFrameStartPlugins { + data: typeof dataStart; + expressions: typeof expressionsStart; + embeddables: ReturnType; + chrome: Chrome; +} + +export class EditorFramePlugin { + constructor() {} + + private readonly datasources: Record = {}; + private readonly visualizations: Record = {}; + + public setup(core: CoreSetup, plugins: EditorFrameSetupPlugins): EditorFrameSetup { + plugins.expressions.registerFunction(() => mergeTables); + + return { + registerDatasource: (name, datasource) => { + this.datasources[name] = datasource as Datasource; + }, + registerVisualization: visualization => { + this.visualizations[visualization.id] = visualization as Visualization; + }, + }; + } + + public start(core: CoreStart, plugins: EditorFrameStartPlugins): EditorFrameStart { + plugins.embeddables.registerEmbeddableFactory( + 'lens', + new EmbeddableFactory( + plugins.chrome, + plugins.expressions.ExpressionRenderer, + plugins.data.indexPatterns.indexPatterns + ) + ); + + const createInstance = (): EditorFrameInstance => { + let domElement: Element; + return { + mount: (element, { doc, onError, dateRange, query, onChange }) => { + domElement = element; + const firstDatasourceId = Object.keys(this.datasources)[0]; + const firstVisualizationId = Object.keys(this.visualizations)[0]; + + render( + + + , + domElement + ); + }, + unmount() { + if (domElement) { + unmountComponentAtNode(domElement); + } + }, + }; + }; + + return { + createInstance, + }; + } + + public stop() { + return {}; + } +} + +const editorFrame = new EditorFramePlugin(); + +export const editorFrameSetup = () => + editorFrame.setup(npSetup.core, { + data: dataSetup, + expressions: expressionsSetup, + }); + +export const editorFrameStart = () => + editorFrame.start(npStart.core, { + data: dataStart, + expressions: expressionsStart, + chrome, + embeddables: embeddablePlugin, + }); + +export const editorFrameStop = () => editorFrame.stop(); diff --git a/x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts b/x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts new file mode 100644 index 0000000000000..29aae6117e442 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/id_generator/id_generator.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generateId } from './id_generator'; + +describe('XYConfigPanel', () => { + it('generates different ids', () => { + expect(generateId()).not.toEqual(generateId()); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts b/x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts new file mode 100644 index 0000000000000..82579769925eb --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/id_generator/id_generator.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid/v4'; + +export function generateId() { + return uuid(); +} diff --git a/x-pack/legacy/plugins/lens/public/id_generator/index.ts b/x-pack/legacy/plugins/lens/public/id_generator/index.ts new file mode 100644 index 0000000000000..541e7e6aa4a70 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/id_generator/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './id_generator'; diff --git a/x-pack/legacy/plugins/lens/public/index.scss b/x-pack/legacy/plugins/lens/public/index.scss new file mode 100644 index 0000000000000..0a5fde0d47444 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/index.scss @@ -0,0 +1,10 @@ +// Import the EUI global scope so we can use EUI constants +@import 'src/legacy/ui/public/styles/_styling_constants'; + +@import "./app_plugin/index"; +@import './xy_visualization_plugin/index'; +@import './datatable_visualization_plugin/index'; +@import './xy_visualization_plugin/xy_expression.scss'; +@import './indexpattern_plugin/indexpattern'; +@import './drag_drop/drag_drop.scss'; +@import './editor_frame_plugin/editor_frame/index'; diff --git a/x-pack/legacy/plugins/lens/public/index.ts b/x-pack/legacy/plugins/lens/public/index.ts new file mode 100644 index 0000000000000..48a37fd5d8656 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './types'; + +import 'ui/autoload/all'; +// Used for kuery autocomplete +import 'uiExports/autocompleteProviders'; +// Used to run esaggs queries +import 'uiExports/fieldFormats'; +import 'uiExports/search'; +import 'uiExports/visRequestHandlers'; +import 'uiExports/visResponseHandlers'; +// Used for kibana_context function +import 'uiExports/savedObjectTypes'; + +import { render, unmountComponentAtNode } from 'react-dom'; +import { IScope } from 'angular'; +import chrome from 'ui/chrome'; +import { appStart, appSetup, appStop } from './app_plugin'; +import { PLUGIN_ID } from '../common'; + +// TODO: Convert this to the "new platform" way of doing UI +function Root($scope: IScope, $element: JQLite) { + const el = $element[0]; + $scope.$on('$destroy', () => { + unmountComponentAtNode(el); + appStop(); + }); + + appSetup(); + return render(appStart(), el); +} + +chrome.setRootController(PLUGIN_ID, Root); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts new file mode 100644 index 0000000000000..87a6dd522ec63 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/loader.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createMockedIndexPattern, createMockedRestrictedIndexPattern } from '../mocks'; + +export function getIndexPatterns() { + return new Promise(resolve => { + resolve([createMockedIndexPattern(), createMockedRestrictedIndexPattern()]); + }); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts new file mode 100644 index 0000000000000..48de1b3d8b4f9 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/__mocks__/state_helpers.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const actual = jest.requireActual('../state_helpers'); + +jest.spyOn(actual, 'changeColumn'); +jest.spyOn(actual, 'updateLayerIndexPattern'); + +export const { + getColumnOrder, + changeColumn, + deleteColumn, + updateColumnParam, + sortByField, + hasField, + updateLayerIndexPattern, + isLayerTransferable, +} = actual; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx new file mode 100644 index 0000000000000..b8ef8b7689627 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.test.tsx @@ -0,0 +1,585 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow, mount } from 'enzyme'; +import React, { ChangeEvent } from 'react'; +import { EuiComboBox } from '@elastic/eui'; +import { IndexPatternPrivateState, IndexPatternColumn } from './indexpattern'; +import { createMockedDragDropContext } from './mocks'; +import { InnerIndexPatternDataPanel, IndexPatternDataPanel, MemoizedDataPanel } from './datapanel'; +import { FieldItem } from './field_item'; +import { act } from 'react-dom/test-utils'; +import { coreMock } from 'src/core/public/mocks'; + +jest.mock('ui/new_platform'); +jest.mock('./loader'); +jest.mock('../../../../../../src/legacy/ui/public/registry/field_formats'); + +const waitForPromises = () => new Promise(resolve => setTimeout(resolve)); + +const initialState: IndexPatternPrivateState = { + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'memory', + }, + }, + }, + second: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'bytes', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + '3': { + id: '3', + title: 'my-compatible-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + }, +}; +describe('IndexPattern Data Panel', () => { + let defaultProps: Parameters[0]; + let core: ReturnType; + + beforeEach(() => { + core = coreMock.createSetup(); + defaultProps = { + dragDropContext: createMockedDragDropContext(), + currentIndexPatternId: '1', + indexPatterns: initialState.indexPatterns, + showIndexPatternSwitcher: false, + setShowIndexPatternSwitcher: jest.fn(), + onChangeIndexPattern: jest.fn(), + core, + dateRange: { + fromDate: 'now-7d', + toDate: 'now', + }, + query: { query: '', language: 'lucene' }, + showEmptyFields: false, + onToggleEmptyFields: jest.fn(), + }; + }); + + it('should update index pattern of layer on switch if it is a single empty one', async () => { + const setStateSpy = jest.fn(); + const wrapper = shallow( + {} }} + /> + ); + + act(() => { + wrapper.find(MemoizedDataPanel).prop('setShowIndexPatternSwitcher')!(true); + }); + wrapper.find(MemoizedDataPanel).prop('onChangeIndexPattern')!('2'); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...initialState, + layers: { first: { indexPatternId: '2', columnOrder: [], columns: {} } }, + currentIndexPatternId: '2', + }); + }); + + it('should not update index pattern of layer on switch if there are more than one', async () => { + const setStateSpy = jest.fn(); + const state = { + ...initialState, + layers: { + first: { indexPatternId: '1', columnOrder: [], columns: {} }, + second: { indexPatternId: '1', columnOrder: [], columns: {} }, + }, + }; + const wrapper = shallow( + {} }} + /> + ); + + act(() => { + wrapper.find(MemoizedDataPanel).prop('setShowIndexPatternSwitcher')!(true); + }); + wrapper.find(MemoizedDataPanel).prop('onChangeIndexPattern')!('2'); + + expect(setStateSpy).toHaveBeenCalledWith({ ...state, currentIndexPatternId: '2' }); + }); + + it('should not update index pattern of layer on switch if there are columns configured', async () => { + const setStateSpy = jest.fn(); + const state = { + ...initialState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { col1: {} as IndexPatternColumn }, + }, + }, + }; + const wrapper = shallow( + {} }} + /> + ); + + act(() => { + wrapper.find(MemoizedDataPanel).prop('setShowIndexPatternSwitcher')!(true); + }); + wrapper.find(MemoizedDataPanel).prop('onChangeIndexPattern')!('2'); + + expect(setStateSpy).toHaveBeenCalledWith({ ...state, currentIndexPatternId: '2' }); + }); + + it('should render a warning if there are no index patterns', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); + }); + + it('should call setState when the index pattern is switched', async () => { + const wrapper = shallow(); + + wrapper.find('[data-test-subj="indexPattern-switch-link"]').simulate('click'); + + expect(defaultProps.setShowIndexPatternSwitcher).toHaveBeenCalledWith(true); + + wrapper.setProps({ showIndexPatternSwitcher: true }); + + const comboBox = wrapper.find(EuiComboBox); + + comboBox.prop('onChange')!([ + { + label: initialState.indexPatterns['2'].title, + value: '2', + }, + ]); + + expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('2'); + }); + + describe('loading existence data', () => { + beforeEach(() => { + core.http.post.mockClear(); + }); + + it('loads existence data and updates the index pattern', async () => { + core.http.post.mockResolvedValue({ + timestamp: { + exists: true, + cardinality: 500, + count: 500, + }, + }); + const updateFields = jest.fn(); + mount(); + + await waitForPromises(); + + expect(core.http.post).toHaveBeenCalledWith(`/api/lens/index_stats/my-fake-index-pattern`, { + body: JSON.stringify({ + fromDate: 'now-7d', + toDate: 'now', + size: 500, + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + }, + { + name: 'bytes', + type: 'number', + }, + { + name: 'memory', + type: 'number', + }, + { + name: 'unsupported', + type: 'geo', + }, + { + name: 'source', + type: 'string', + }, + ], + }), + }); + + expect(updateFields).toHaveBeenCalledWith('1', [ + { + name: 'timestamp', + type: 'date', + exists: true, + cardinality: 500, + count: 500, + aggregatable: true, + searchable: true, + }, + ...defaultProps.indexPatterns['1'].fields + .slice(1) + .map(field => ({ ...field, exists: false })), + ]); + }); + + it('does not attempt to load existence data if the index pattern has it', async () => { + const updateFields = jest.fn(); + const newIndexPatterns = { + ...defaultProps.indexPatterns, + '1': { + ...defaultProps.indexPatterns['1'], + hasExistence: true, + }, + }; + + const props = { ...defaultProps, indexPatterns: newIndexPatterns }; + + mount(); + + await waitForPromises(); + + expect(core.http.post).not.toHaveBeenCalled(); + }); + }); + + describe('while showing empty fields', () => { + it('should list all supported fields in the pattern sorted alphabetically', async () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + 'source', + 'timestamp', + ]); + }); + + it('should filter down by name', () => { + const wrapper = shallow( + + ); + + act(() => { + wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({ + target: { value: 'mem' }, + } as ChangeEvent); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); + + it('should filter down by type', () => { + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="lnsIndexPatternFiltersToggle"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="typeFilter-number"]') + .first() + .simulate('click'); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + ]); + }); + + it('should toggle type if clicked again', () => { + const wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="lnsIndexPatternFiltersToggle"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="typeFilter-number"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="typeFilter-number"]') + .first() + .simulate('click'); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + 'source', + 'timestamp', + ]); + }); + + it('should filter down by type and by name', () => { + const wrapper = mount( + + ); + + act(() => { + wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({ + target: { value: 'mem' }, + } as ChangeEvent); + }); + + wrapper + .find('[data-test-subj="lnsIndexPatternFiltersToggle"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="typeFilter-number"]') + .first() + .simulate('click'); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); + }); + + describe('filtering out empty fields', () => { + let emptyFieldsTestProps: typeof defaultProps; + + beforeEach(() => { + emptyFieldsTestProps = { + ...defaultProps, + indexPatterns: { + ...defaultProps.indexPatterns, + '1': { + ...defaultProps.indexPatterns['1'], + hasExistence: true, + fields: defaultProps.indexPatterns['1'].fields.map(field => ({ + ...field, + exists: field.type === 'number', + })), + }, + }, + onToggleEmptyFields: jest.fn(), + }; + }); + + it('should list all supported fields in the pattern sorted alphabetically', async () => { + const wrapper = shallow(); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'bytes', + 'memory', + ]); + }); + + it('should filter down by name', () => { + const wrapper = shallow( + + ); + + act(() => { + wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').prop('onChange')!({ + target: { value: 'mem' }, + } as ChangeEvent); + }); + + expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([ + 'memory', + ]); + }); + + it('should allow removing the filter for data', () => { + const wrapper = mount(); + + wrapper + .find('[data-test-subj="lnsIndexPatternFiltersToggle"]') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="lnsEmptyFilter"]') + .first() + .prop('onChange')!({} as ChangeEvent); + + expect(emptyFieldsTestProps.onToggleEmptyFields).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx new file mode 100644 index 0000000000000..b397479bf32fd --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -0,0 +1,567 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mapValues, uniq, indexBy } from 'lodash'; +import React, { useState, useEffect, memo, useCallback } from 'react'; +import { + EuiComboBox, + EuiLoadingSpinner, + // @ts-ignore + EuiHighlight, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiContextMenuPanelProps, + EuiPopover, + EuiPopoverTitle, + EuiPopoverFooter, + EuiCallOut, + EuiText, + EuiFormControlLayout, + EuiSwitch, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Query } from 'src/plugins/data/common'; +import { DatasourceDataPanelProps, DataType } from '../types'; +import { IndexPatternPrivateState, IndexPatternField, IndexPattern } from './indexpattern'; +import { ChildDragDropProvider, DragContextState } from '../drag_drop'; +import { FieldItem } from './field_item'; +import { FieldIcon } from './field_icon'; +import { updateLayerIndexPattern } from './state_helpers'; + +// TODO the typings for EuiContextMenuPanel are incorrect - watchedItemProps is missing. This can be removed when the types are adjusted +const FixedEuiContextMenuPanel = (EuiContextMenuPanel as unknown) as React.FunctionComponent< + EuiContextMenuPanelProps & { watchedItemProps: string[] } +>; + +function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { + return fieldA.name.localeCompare(fieldB.name, undefined, { sensitivity: 'base' }); +} + +const supportedFieldTypes = ['string', 'number', 'boolean', 'date']; +const PAGINATION_SIZE = 50; + +const fieldTypeNames: Record = { + string: i18n.translate('xpack.lens.datatypes.string', { defaultMessage: 'string' }), + number: i18n.translate('xpack.lens.datatypes.number', { defaultMessage: 'number' }), + boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }), + date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }), +}; + +function isSingleEmptyLayer(layerMap: IndexPatternPrivateState['layers']) { + const layers = Object.values(layerMap); + return layers.length === 1 && layers[0].columnOrder.length === 0; +} + +export function IndexPatternDataPanel({ + setState, + state, + dragDropContext, + core, + query, + dateRange, +}: DatasourceDataPanelProps) { + const { indexPatterns, currentIndexPatternId } = state; + const [showIndexPatternSwitcher, setShowIndexPatternSwitcher] = useState(false); + + const onChangeIndexPattern = useCallback( + (newIndexPattern: string) => { + setState({ + ...state, + layers: isSingleEmptyLayer(state.layers) + ? mapValues(state.layers, layer => + updateLayerIndexPattern(layer, indexPatterns[newIndexPattern]) + ) + : state.layers, + currentIndexPatternId: newIndexPattern, + }); + }, + [state, setState] + ); + + const updateFieldsWithCounts = useCallback( + (indexPatternId: string, allFields: IndexPattern['fields']) => { + setState(prevState => { + return { + ...prevState, + indexPatterns: { + ...prevState.indexPatterns, + [indexPatternId]: { + ...prevState.indexPatterns[indexPatternId], + hasExistence: true, + fields: allFields, + }, + }, + }; + }); + }, + [currentIndexPatternId, indexPatterns[currentIndexPatternId]] + ); + + const onToggleEmptyFields = useCallback(() => { + setState(prevState => ({ ...prevState, showEmptyFields: !prevState.showEmptyFields })); + }, [state, setState]); + + return ( + + ); +} + +type OverallFields = Record< + string, + { + count: number; + cardinality: number; + } +>; + +interface DataPanelState { + isLoading: boolean; + nameFilter: string; + typeFilter: DataType[]; + isTypeFilterOpen: boolean; +} + +export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ + currentIndexPatternId, + indexPatterns, + query, + dateRange, + dragDropContext, + showIndexPatternSwitcher, + setShowIndexPatternSwitcher, + onChangeIndexPattern, + updateFieldsWithCounts, + showEmptyFields, + onToggleEmptyFields, + core, +}: Partial & { + currentIndexPatternId: string; + indexPatterns: Record; + dateRange: DatasourceDataPanelProps['dateRange']; + query: Query; + core: DatasourceDataPanelProps['core']; + dragDropContext: DragContextState; + showIndexPatternSwitcher: boolean; + setShowIndexPatternSwitcher: (show: boolean) => void; + showEmptyFields: boolean; + onToggleEmptyFields: () => void; + onChangeIndexPattern?: (newId: string) => void; + updateFieldsWithCounts?: (indexPatternId: string, fields: IndexPattern['fields']) => void; +}) { + if (Object.keys(indexPatterns).length === 0) { + return ( + + + +

+ +

+
+
+
+ ); + } + + const [localState, setLocalState] = useState({ + isLoading: false, + nameFilter: '', + typeFilter: [], + isTypeFilterOpen: false, + }); + const [pageSize, setPageSize] = useState(PAGINATION_SIZE); + const [scrollContainer, setScrollContainer] = useState(undefined); + + const currentIndexPattern = indexPatterns[currentIndexPatternId]; + const allFields = currentIndexPattern.fields; + const fieldByName = indexBy(allFields, 'name'); + + const lazyScroll = () => { + if (scrollContainer) { + const nearBottom = + scrollContainer.scrollTop + scrollContainer.clientHeight > + scrollContainer.scrollHeight * 0.9; + if (nearBottom) { + setPageSize(Math.min(pageSize * 1.5, allFields.length)); + } + } + }; + + useEffect(() => { + if (scrollContainer) { + scrollContainer.scrollTop = 0; + setPageSize(PAGINATION_SIZE); + lazyScroll(); + } + }, [localState.nameFilter, localState.typeFilter, currentIndexPatternId, showEmptyFields]); + + const availableFieldTypes = uniq(allFields.map(({ type }) => type)).filter( + type => type in fieldTypeNames + ); + + const displayedFields = allFields.filter(field => { + if (!supportedFieldTypes.includes(field.type)) { + return false; + } + + if ( + localState.nameFilter.length && + !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) + ) { + return false; + } + + if (!showEmptyFields) { + const indexField = + currentIndexPattern && currentIndexPattern.hasExistence && fieldByName[field.name]; + if (localState.typeFilter.length > 0) { + return ( + indexField && indexField.exists && localState.typeFilter.includes(field.type as DataType) + ); + } + return indexField && indexField.exists; + } + + if (localState.typeFilter.length > 0) { + return localState.typeFilter.includes(field.type as DataType); + } + + return true; + }); + + const paginatedFields = displayedFields.sort(sortFields).slice(0, pageSize); + + // Side effect: Fetch field existence data when the index pattern is switched + useEffect(() => { + if (localState.isLoading || currentIndexPattern.hasExistence || !updateFieldsWithCounts) { + return; + } + + setLocalState(s => ({ ...s, isLoading: true })); + + core.http + .post(`/api/lens/index_stats/${currentIndexPattern.title}`, { + body: JSON.stringify({ + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + size: 500, + timeFieldName: currentIndexPattern.timeFieldName, + fields: allFields + .filter(field => field.aggregatable) + .map(field => ({ + name: field.name, + type: field.type, + })), + }), + }) + .then((results: OverallFields) => { + setLocalState(s => ({ + ...s, + isLoading: false, + })); + + if (!updateFieldsWithCounts) { + return; + } + + updateFieldsWithCounts( + currentIndexPatternId, + allFields.map(field => { + const matching = results[field.name]; + if (!matching) { + return { ...field, exists: false }; + } + return { + ...field, + exists: true, + cardinality: matching.cardinality, + count: matching.count, + }; + }) + ); + }) + .catch(() => { + setLocalState(s => ({ ...s, isLoading: false })); + }); + }, [currentIndexPatternId]); + + return ( + + + +
+ {!showIndexPatternSwitcher ? ( + <> + +

+ {currentIndexPattern.title}{' '} +

+
+ setShowIndexPatternSwitcher(true)} + size="xs" + > + ( + + ) + + + ) : ( + ({ + label: title, + value: id, + }))} + inputRef={el => { + if (el) { + el.focus(); + } + }} + selectedOptions={ + currentIndexPatternId + ? [ + { + label: currentIndexPattern.title, + value: currentIndexPattern.id, + }, + ] + : undefined + } + singleSelection={{ asPlainText: true }} + isClearable={false} + onBlur={() => { + setShowIndexPatternSwitcher(false); + }} + onChange={choices => { + onChangeIndexPattern!(choices[0].value as string); + + setLocalState(s => ({ + ...s, + nameFilter: '', + typeFilter: [], + })); + + setShowIndexPatternSwitcher(false); + }} + /> + )} +
+
+ + + + + setLocalState(s => ({ ...localState, isTypeFilterOpen: false })) + } + button={ + { + setLocalState(s => ({ + ...s, + isTypeFilterOpen: !localState.isTypeFilterOpen, + })); + }} + data-test-subj="lnsIndexPatternFiltersToggle" + title={i18n.translate('xpack.lens.indexPatterns.toggleFiltersPopover', { + defaultMessage: 'Toggle filters for index pattern', + })} + aria-label={i18n.translate( + 'xpack.lens.indexPatterns.toggleFiltersPopover', + { + defaultMessage: 'Toggle filters for index pattern', + } + )} + > + + + } + > + + {i18n.translate('xpack.lens.indexPatterns.filterByTypeLabel', { + defaultMessage: 'Filter by type', + })} + + ( + + setLocalState(s => ({ + ...s, + typeFilter: localState.typeFilter.includes(type) + ? localState.typeFilter.filter(t => t !== type) + : [...localState.typeFilter, type], + })) + } + > + {fieldTypeNames[type]} + + ))} + /> + + { + onToggleEmptyFields(); + }} + label={i18n.translate('xpack.lens.indexPatterns.toggleEmptyFieldsSwitch', { + defaultMessage: 'Only show fields with data', + })} + data-test-subj="lnsEmptyFilter" + /> + + + } + clear={{ + title: i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', { + defaultMessage: 'Clear name and type filters', + }), + 'aria-label': i18n.translate('xpack.lens.indexPatterns.clearFiltersLabel', { + defaultMessage: 'Clear name and type filters', + }), + onClick: () => { + setLocalState(s => ({ + ...s, + nameFilter: '', + typeFilter: [], + })); + }, + }} + > + { + setLocalState({ ...localState, nameFilter: e.target.value }); + }} + aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { + defaultMessage: 'Search fields', + })} + /> + + + +
{ + if (el && !el.dataset.dynamicScroll) { + el.dataset.dynamicScroll = 'true'; + setScrollContainer(el); + } + }} + onScroll={lazyScroll} + > +
+ {localState.isLoading && } + + {paginatedFields.map(field => { + const overallField = fieldByName[field.name]; + return ( + + ); + })} + + {!localState.isLoading && paginatedFields.length === 0 && ( + + {showEmptyFields + ? i18n.translate('xpack.lens.indexPatterns.hiddenFieldsLabel', { + defaultMessage: + 'No fields have data with the current filters. You can show fields without data using the filters above.', + }) + : i18n.translate('xpack.lens.indexPatterns.noFieldsLabel', { + defaultMessage: 'No fields can be visualized from {title}', + values: { title: currentIndexPattern.title }, + })} + + )} +
+
+
+
+
+ ); +}; + +export const MemoizedDataPanel = memo(InnerIndexPatternDataPanel); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_index.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_index.scss new file mode 100644 index 0000000000000..919fe52748684 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_index.scss @@ -0,0 +1,2 @@ +@import './popover'; +@import './summary'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_popover.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_popover.scss new file mode 100644 index 0000000000000..42f9366bd1f1e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_popover.scss @@ -0,0 +1,34 @@ +.lnsConfigPanel__summaryPopoverLeft, +.lnsConfigPanel__summaryPopoverRight { + padding: $euiSizeS; +} + +.lnsConfigPanel__summaryPopoverLeft { + padding-top: 0; + background-color: $euiColorLightestShade; +} + +.lnsConfigPanel__summaryPopoverRight { + width: $euiSize * 20; +} + +.lnsConfigPanel__fieldOption--incompatible { + color: $euiColorLightShade; +} + +.lnsConfigPanel__fieldOption--nonExistant { + background-color: $euiColorLightestShade; +} + +.lnsConfigPanel__operation { + padding: $euiSizeXS; + font-size: 0.875rem; +} + +.lnsConfigPanel__operation--selected { + background-color: $euiColorLightShade; +} + +.lnsConfigPanel__operation--incompatible { + opacity: 0.7; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_summary.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_summary.scss new file mode 100644 index 0000000000000..0cf0dc66f9f67 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/_summary.scss @@ -0,0 +1,34 @@ +.lnsConfigPanel__summary { + @include euiFontSizeS; + background: $euiColorEmptyShade; + border-radius: $euiBorderRadius; + display: flex; + align-items: center; + margin-top: $euiSizeXS; + padding: $euiSizeS; +} + +.lnsConfigPanel__summaryPopover { + flex-grow: 1; + line-height: 0; + overflow: hidden; +} + +.lnsConfigPanel__summaryPopoverAnchor { + max-width: 100%; + display: block; +} + +.lnsConfigPanel__summaryIcon { + margin-right: $euiSizeXS; +} + +.lnsConfigPanel__summaryLink { + width: 100%; + display: flex; + align-items: center; +} + +.lnsConfigPanel__summaryField { + color: $euiColorPrimary; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.test.tsx new file mode 100644 index 0000000000000..c9d7ce1ea81f8 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { BucketNestingEditor } from './bucket_nesting_editor'; +import { IndexPatternColumn } from '../indexpattern'; + +describe('BucketNestingEditor', () => { + function mockCol(col: Partial = {}): IndexPatternColumn { + const result = { + dataType: 'string', + isBucketed: true, + label: 'a', + operationType: 'terms', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + sourceField: 'a', + suggestedPriority: 0, + ...col, + }; + + return result as IndexPatternColumn; + } + + it('should display an unchecked switch if there are two buckets and it is the root', () => { + const component = mount( + + ); + const control = component.find('[data-test-subj="indexPattern-nesting-switch"]').first(); + + expect(control.prop('checked')).toBeFalsy(); + }); + + it('should display a checked switch if there are two buckets and it is not the root', () => { + const component = mount( + + ); + const control = component.find('[data-test-subj="indexPattern-nesting-switch"]').first(); + + expect(control.prop('checked')).toBeTruthy(); + }); + + it('should reorder the columns when toggled', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + const control = component.find('[data-test-subj="indexPattern-nesting-switch"]').first(); + + (control.prop('onChange') as () => {})(); + + expect(setColumns).toHaveBeenCalledWith(['a', 'b', 'c']); + }); + + it('should display nothing if there are no buckets', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display nothing if there is one bucket', () => { + const component = mount( + + ); + + expect(component.children().length).toBe(0); + }); + + it('should display a dropdown with the parent column selected if 3+ buckets', () => { + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + + expect(control.prop('value')).toEqual('c'); + }); + + it('should reorder the columns when a column is selected in the dropdown', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: 'b' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['c', 'b', 'a']); + }); + + it('should move to root if the first dropdown item is selected', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['a', 'c', 'b']); + }); + + it('should allow the last bucket to be moved', () => { + const setColumns = jest.fn(); + const component = mount( + + ); + + const control = component.find('[data-test-subj="indexPattern-nesting-select"]').first(); + (control.prop('onChange') as (e: unknown) => {})({ + target: { value: '' }, + }); + + expect(setColumns).toHaveBeenCalledWith(['b', 'c', 'a']); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.tsx new file mode 100644 index 0000000000000..630dc6252b6ee --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/bucket_nesting_editor.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiHorizontalRule, EuiSwitch, EuiSelect, EuiFormLabel } from '@elastic/eui'; +import { IndexPatternLayer } from '../indexpattern'; + +function nestColumn(columnOrder: string[], outer: string, inner: string) { + const result = columnOrder.filter(c => c !== inner); + const outerPosition = result.indexOf(outer); + + result.splice(outerPosition + 1, 0, inner); + + return result; +} + +export function BucketNestingEditor({ + columnId, + layer, + setColumns, +}: { + columnId: string; + layer: IndexPatternLayer; + setColumns: (columns: string[]) => void; +}) { + const column = layer.columns[columnId]; + const columns = Object.entries(layer.columns); + const aggColumns = columns + .filter(([id, c]) => id !== columnId && c.isBucketed) + .map(([value, c]) => ({ value, text: c.label })); + + if (!column || !column.isBucketed || !aggColumns.length) { + return null; + } + + const prevColumn = layer.columnOrder[layer.columnOrder.indexOf(columnId) - 1]; + + if (aggColumns.length === 1) { + const [target] = aggColumns; + + return ( + + <> + + { + if (prevColumn) { + setColumns(nestColumn(layer.columnOrder, columnId, target.value)); + } else { + setColumns(nestColumn(layer.columnOrder, target.value, columnId)); + } + }} + /> + + + ); + } + + return ( + + <> + + + {i18n.translate('xpack.lens.xyChart.nestUnder', { + defaultMessage: 'Nest under', + })} + + setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} + /> + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx new file mode 100644 index 0000000000000..a96a2a9f0d831 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.test.tsx @@ -0,0 +1,1187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper, ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { EuiComboBox, EuiSideNav, EuiPopover } from '@elastic/eui'; +import { IndexPatternPrivateState } from '../indexpattern'; +import { changeColumn } from '../state_helpers'; +import { IndexPatternDimensionPanel, IndexPatternDimensionPanelProps } from './dimension_panel'; +import { DropHandler, DragContextState } from '../../drag_drop'; +import { createMockedDragDropContext } from '../mocks'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { Storage } from 'ui/storage'; + +jest.mock('ui/new_platform'); +jest.mock('../loader'); +jest.mock('../state_helpers'); +jest.mock('../operations'); + +// Used by indexpattern plugin, which is a dependency of a dependency +jest.mock('ui/chrome'); +// Contains old and new platform data plugins, used for interpreter and filter ratio +jest.mock('ui/new_platform'); +jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + ], + }, +}; + +describe('IndexPatternDimensionPanel', () => { + let wrapper: ReactWrapper | ShallowWrapper; + let state: IndexPatternPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionPanelProps; + let dragDropContext: DragContextState; + + function openPopover() { + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .simulate('click'); + } + + beforeEach(() => { + state = { + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + setState = jest.fn(); + + dragDropContext = createMockedDragDropContext(); + + defaultProps = { + dragDropContext, + state, + setState, + columnId: 'col1', + layerId: 'first', + filterOperations: () => true, + storage: {} as Storage, + uiSettings: {} as UiSettingsClientContract, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpServiceBase, + }; + + jest.clearAllMocks(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it('should display a configure button if dimension has no column yet', () => { + wrapper = mount(); + expect( + wrapper + .find('[data-test-subj="indexPattern-configure-dimension"]') + .first() + .prop('iconType') + ).toEqual('plusInCircleFilled'); + }); + + it('should call the filterOperations function', () => { + const filterOperations = jest.fn().mockReturnValue(true); + + wrapper = shallow( + + ); + + expect(filterOperations).toBeCalled(); + }); + + it('should show field select combo box on click', () => { + wrapper = mount(); + + openPopover(); + + expect(wrapper.find(EuiComboBox)).toHaveLength(1); + }); + + it('should not show any choices if the filter returns false', () => { + wrapper = mount( + false} + /> + ); + + openPopover(); + + expect(wrapper.find(EuiComboBox)!.prop('options')!).toHaveLength(0); + }); + + it('should list all field names and document as a whole in prioritized order', () => { + wrapper = mount(); + + openPopover(); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options).toHaveLength(2); + + expect(options![0].label).toEqual('Document'); + + expect(options![1].options!.map(({ label }) => label)).toEqual([ + 'timestamp', + 'bytes', + 'memory', + 'source', + ]); + }); + + it('should indicate fields which are imcompatible for the operation of the current column', () => { + wrapper = mount( + + ); + + openPopover(); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).toEqual('lns-documentOptionIncompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate operations which are incompatible for the field of the current column', () => { + wrapper = mount( + + ); + + openPopover(); + + const options = (wrapper.find(EuiSideNav).prop('items')[0].items as unknown) as Array<{ + name: string; + className: string; + 'data-test-subj': string; + }>; + + expect(options.find(({ name }) => name === 'Minimum')!['data-test-subj']).not.toContain( + 'Incompatible' + ); + + expect(options.find(({ name }) => name === 'Date Histogram')!['data-test-subj']).toContain( + 'Incompatible' + ); + }); + + it('should keep the operation when switching to another field compatible with this operation', () => { + const initialState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + + wrapper = mount(); + + openPopover(); + + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'memory')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'max', + sourceField: 'memory', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should switch operations when selecting a field that requires another operation', () => { + wrapper = mount(); + + openPopover(); + + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should keep the field when switching to another operation compatible for this field', () => { + wrapper = mount( + + ); + + openPopover(); + + act(() => { + wrapper.find('button[data-test-subj="lns-indexPatternDimension-min"]').simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + operationType: 'min', + sourceField: 'bytes', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + it('should not set the state if selecting the currently active operation', () => { + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should update label on label input changes', () => { + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find('input[data-test-subj="indexPattern-label-edit"]') + .simulate('change', { target: { value: 'New Label' } }); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + label: 'New Label', + // Other parts of this don't matter for this test + }), + }, + }, + }, + }); + }); + + describe('transient invalid state', () => { + it('should not set the state if selecting an operation incompatible with the current field', () => { + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should show error message in invalid state', () => { + wrapper = mount(); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).not.toHaveLength(0); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('should leave error state if a compatible operation is selected', () => { + wrapper = mount(); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimension-date_histogram"]') + .simulate('click'); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should leave error state if the popover gets closed', () => { + wrapper = mount(); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + act(() => { + wrapper.find(EuiPopover).prop('closePopover')!(); + }); + + openPopover(); + + expect(wrapper.find('[data-test-subj="indexPattern-invalid-operation"]')).toHaveLength(0); + }); + + it('should indicate fields compatible with selected operation', () => { + wrapper = mount(); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility with selected field operation', () => { + const initialState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-count"]') + .simulate('click'); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + options![1].options!.map(option => + expect(option['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should indicate document and field compatibility with selected document operation', () => { + const initialState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + openPopover(); + + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'source')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should set datasource state if compatible field is selected for operation', () => { + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find('button[data-test-subj="lns-indexPatternDimensionIncompatible-terms"]') + .simulate('click'); + }); + + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options!.find(({ label }) => label === 'source')!; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: expect.objectContaining({ + sourceField: 'source', + operationType: 'terms', + }), + }, + }, + }, + }); + }); + }); + + it('should support selecting the operation before the field', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const comboBox = wrapper.find(EuiComboBox); + const options = comboBox.prop('options'); + + act(() => { + comboBox.prop('onChange')!([options![1].options![0]]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only one field is possible', () => { + const initialState = { + ...state, + indexPatterns: { + 1: { + ...state.indexPatterns['1'], + fields: state.indexPatterns['1'].fields.filter(field => field.name !== 'memory'), + }, + }, + }; + + wrapper = mount( + + ); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns: { + ...initialState.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'avg', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should select operation directly if only document is possible', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-count"]').simulate('click'); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + operationType: 'count', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should indicate compatible fields when selecting the operation first', () => { + wrapper = mount(); + + openPopover(); + + wrapper.find('button[data-test-subj="lns-indexPatternDimension-avg"]').simulate('click'); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).toContain('Incompatible'); + + expect( + options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj'] + ).toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'bytes')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + expect( + options![1].options!.filter(({ label }) => label === 'memory')[0]['data-test-subj'] + ).not.toContain('Incompatible'); + }); + + it('should indicate document compatibility when document operation is selected', () => { + const initialState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'count', + }, + }, + }, + }, + }; + wrapper = mount( + + ); + + openPopover(); + + const options = wrapper.find(EuiComboBox).prop('options'); + + expect(options![0]['data-test-subj']).not.toContain('Incompatible'); + + options![1].options!.map(operation => + expect(operation['data-test-subj']).toContain('Incompatible') + ); + }); + + it('should show all operations that are not filtered out', () => { + wrapper = mount( + !op.isBucketed && op.dataType === 'number'} + /> + ); + + openPopover(); + + expect( + wrapper + .find(EuiSideNav) + .prop('items')[0] + .items.map(({ name }) => name) + ).toEqual(['Average', 'Count', 'Filter Ratio', 'Maximum', 'Minimum', 'Sum']); + }); + + it('should add a column on selection of a field', () => { + wrapper = mount(); + + openPopover(); + + const comboBox = wrapper.find(EuiComboBox)!; + const option = comboBox.prop('options')![1].options![0]; + + act(() => { + comboBox.prop('onChange')!([option]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + sourceField: 'bytes', + // Other parts of this don't matter for this test + }), + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('should use helper function when changing the function', () => { + const initialState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternDimension-min"]') + .first() + .prop('onClick')!({} as React.MouseEvent<{}, MouseEvent>); + }); + + expect(changeColumn).toHaveBeenCalledWith({ + state: initialState, + columnId: 'col1', + layerId: 'first', + newColumn: expect.objectContaining({ + sourceField: 'bytes', + operationType: 'min', + }), + }); + }); + + it('should clear the dimension with the clear button', () => { + wrapper = mount(); + + const clearButton = wrapper.find( + 'EuiButtonIcon[data-test-subj="indexPattern-dimensionPopover-remove"]' + ); + + act(() => { + clearButton.simulate('click'); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + it('should clear the dimension when removing the selection in field combobox', () => { + wrapper = mount(); + + openPopover(); + + act(() => { + wrapper.find(EuiComboBox).prop('onChange')!([]); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + }); + + describe('drag and drop', () => { + function dragDropState(): IndexPatternPrivateState { + return { + indexPatterns: { + foo: { + id: 'foo', + title: 'Foo pattern', + fields: [ + { + aggregatable: true, + name: 'bar', + searchable: true, + type: 'number', + }, + ], + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + myLayer: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + } + + it('is not droppable if no drag is happening', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is not droppable if the dragged item has no field', () => { + wrapper = shallow( + + ); + + expect( + wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is not droppable if field is not supported by filterOperations', () => { + wrapper = shallow( + false} + layerId="myLayer" + /> + ); + + expect( + wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('is droppable if the field is supported by filterOperations', () => { + wrapper = shallow( + op.dataType === 'number'} + layerId="myLayer" + /> + ); + + expect( + wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeTruthy(); + }); + + it('is notdroppable if the field belongs to another index pattern', () => { + wrapper = shallow( + op.dataType === 'number'} + layerId="myLayer" + /> + ); + + expect( + wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('droppable') + ).toBeFalsy(); + }); + + it('appends the dropped column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + wrapper = shallow( + op.dataType === 'number'} + layerId="myLayer" + /> + ); + + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; + + onDrop(dragging); + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col2'], + columns: { + ...testState.layers.myLayer.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }, + }, + }, + }); + }); + + it('updates a column when a field is dropped', () => { + const dragging = { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo', + }; + const testState = dragDropState(); + wrapper = shallow( + op.dataType === 'number'} + layerId="myLayer" + /> + ); + + act(() => { + const onDrop = wrapper + .find('[data-test-subj="indexPattern-dropTarget"]') + .first() + .prop('onDrop') as DropHandler; + + onDrop(dragging); + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bar', + }), + }), + }), + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx new file mode 100644 index 0000000000000..3e8c90b80ad82 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { memo, useMemo } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { Storage } from 'ui/storage'; +import { i18n } from '@kbn/i18n'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { DatasourceDimensionPanelProps, StateSetter } from '../../types'; +import { + IndexPatternColumn, + IndexPatternPrivateState, + IndexPatternField, + OperationType, +} from '../indexpattern'; + +import { getAvailableOperationsByMetadata, buildColumn, changeField } from '../operations'; +import { PopoverEditor } from './popover_editor'; +import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_drop'; +import { changeColumn, deleteColumn } from '../state_helpers'; +import { isDraggedField, hasField } from '../utils'; + +export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { + state: IndexPatternPrivateState; + setState: StateSetter; + dragDropContext: DragContextState; + uiSettings: UiSettingsClientContract; + storage: Storage; + savedObjectsClient: SavedObjectsClientContract; + layerId: string; + http: HttpServiceBase; +}; + +export interface OperationFieldSupportMatrix { + operationByField: Partial>; + fieldByOperation: Partial>; + operationByDocument: OperationType[]; +} + +export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel( + props: IndexPatternDimensionPanelProps +) { + const layerId = props.layerId; + const currentIndexPattern = props.state.indexPatterns[props.state.layers[layerId].indexPatternId]; + + const operationFieldSupportMatrix = useMemo(() => { + const filteredOperationsByMetadata = getAvailableOperationsByMetadata( + currentIndexPattern + ).filter(operation => props.filterOperations(operation.operationMetaData)); + + const supportedOperationsByField: Partial> = {}; + const supportedFieldsByOperation: Partial> = {}; + const supportedOperationsByDocument: OperationType[] = []; + filteredOperationsByMetadata.forEach(({ operations }) => { + operations.forEach(operation => { + if (operation.type === 'field') { + if (supportedOperationsByField[operation.field]) { + supportedOperationsByField[operation.field]!.push(operation.operationType); + } else { + supportedOperationsByField[operation.field] = [operation.operationType]; + } + + if (supportedFieldsByOperation[operation.operationType]) { + supportedFieldsByOperation[operation.operationType]!.push(operation.field); + } else { + supportedFieldsByOperation[operation.operationType] = [operation.field]; + } + } else { + supportedOperationsByDocument.push(operation.operationType); + } + }); + }); + return { + operationByField: _.mapValues(supportedOperationsByField, _.uniq), + fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq), + operationByDocument: _.uniq(supportedOperationsByDocument), + }; + }, [currentIndexPattern, props.filterOperations]); + + const selectedColumn: IndexPatternColumn | null = + props.state.layers[layerId].columns[props.columnId] || null; + + function hasOperationForField(field: IndexPatternField) { + return Boolean(operationFieldSupportMatrix.operationByField[field.name]); + } + + function canHandleDrop() { + const { dragging } = props.dragDropContext; + const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; + + return ( + isDraggedField(dragging) && + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); + } + + return ( + + { + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { + // TODO: What do we do if we couldn't find a column? + return; + } + + const operationsForNewField = + operationFieldSupportMatrix.operationByField[droppedItem.field.name]; + + // We need to check if dragging in a new field, was just a field change on the same + // index pattern and on the same operations (therefore checking if the new field supports + // our previous operation) + const hasFieldChanged = + selectedColumn && + hasField(selectedColumn) && + selectedColumn.sourceField !== droppedItem.field.name && + operationsForNewField && + operationsForNewField.includes(selectedColumn.operationType); + + // If only the field has changed use the onFieldChange method on the operation to get the + // new column, otherwise use the regular buildColumn to get a new column. + const newColumn = hasFieldChanged + ? changeField(selectedColumn, currentIndexPattern, droppedItem.field) + : buildColumn({ + columns: props.state.layers[props.layerId].columns, + indexPattern: currentIndexPattern, + layerId, + suggestedPriority: props.suggestedPriority, + field: droppedItem.field, + }); + + props.setState( + changeColumn({ + state: props.state, + layerId, + columnId: props.columnId, + newColumn, + // If the field has changed, the onFieldChange method needs to take care of everything including moving + // over params. If we create a new column above we want changeColumn to move over params. + keepParams: !hasFieldChanged, + }) + ); + }} + > + + {selectedColumn && ( + { + props.setState( + deleteColumn({ + state: props.state, + layerId, + columnId: props.columnId, + }) + ); + if (props.onRemove) { + props.onRemove(props.columnId); + } + }} + /> + )} + + + ); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx new file mode 100644 index 0000000000000..16d57dc595117 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiComboBoxOptionProps } from '@elastic/eui'; +import classNames from 'classnames'; +import { + // @ts-ignore + EuiHighlight, +} from '@elastic/eui'; +import { OperationType, IndexPattern, IndexPatternField } from '../indexpattern'; +import { FieldIcon } from '../field_icon'; +import { DataType } from '../../types'; +import { OperationFieldSupportMatrix } from './dimension_panel'; + +export type FieldChoice = + | { type: 'field'; field: string; operationType?: OperationType } + | { type: 'document' }; + +export interface FieldSelectProps { + currentIndexPattern: IndexPattern; + showEmptyFields: boolean; + fieldMap: Record; + incompatibleSelectedOperationType: OperationType | null; + selectedColumnOperationType?: OperationType; + selectedColumnSourceField?: string; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + onChoose: (choice: FieldChoice) => void; + onDeleteColumn: () => void; +} + +export function FieldSelect({ + currentIndexPattern, + showEmptyFields, + fieldMap, + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + onChoose, + onDeleteColumn, +}: FieldSelectProps) { + const { operationByDocument, operationByField } = operationFieldSupportMatrix; + + const memoizedFieldOptions = useMemo(() => { + const fields = Object.keys(operationByField).sort(); + + function isCompatibleWithCurrentOperation(fieldName: string) { + if (incompatibleSelectedOperationType) { + return operationByField[fieldName]!.includes(incompatibleSelectedOperationType); + } + return ( + !selectedColumnOperationType || + operationByField[fieldName]!.includes(selectedColumnOperationType) + ); + } + + const isCurrentOperationApplicableWithoutField = + (!selectedColumnOperationType && !incompatibleSelectedOperationType) || + operationByDocument.includes( + incompatibleSelectedOperationType || selectedColumnOperationType! + ); + + const fieldOptions = []; + + if (operationByDocument.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.documentField', { + defaultMessage: 'Document', + }), + value: { type: 'document' }, + className: classNames({ + 'lnsConfigPanel__fieldOption--incompatible': !isCurrentOperationApplicableWithoutField, + }), + 'data-test-subj': `lns-documentOption${ + isCurrentOperationApplicableWithoutField ? '' : 'Incompatible' + }`, + }); + } + + if (fields.length > 0) { + fieldOptions.push({ + label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', { + defaultMessage: 'Individual fields', + }), + options: fields + .map(field => ({ + label: field, + value: { + type: 'field', + field, + dataType: fieldMap[field].type, + operationType: + selectedColumnOperationType && isCompatibleWithCurrentOperation(field) + ? selectedColumnOperationType + : undefined, + }, + exists: fieldMap[field].exists || false, + compatible: isCompatibleWithCurrentOperation(field), + })) + .filter(field => showEmptyFields || field.exists) + .sort((a, b) => { + if (a.compatible && !b.compatible) { + return -1; + } + if (!a.compatible && b.compatible) { + return 1; + } + return 0; + }) + .map(({ label, value, compatible, exists }) => ({ + label, + value, + className: classNames({ + 'lnsConfigPanel__fieldOption--incompatible': !compatible, + 'lnsConfigPanel__fieldOption--nonExistant': !exists, + }), + 'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`, + })), + }); + } + + return fieldOptions; + }, [ + incompatibleSelectedOperationType, + selectedColumnOperationType, + selectedColumnSourceField, + operationFieldSupportMatrix, + currentIndexPattern, + fieldMap, + showEmptyFields, + ]); + + return ( + { + if (choices.length === 0) { + onDeleteColumn(); + return; + } + + onChoose((choices[0].value as unknown) as FieldChoice); + }} + renderOption={(option, searchValue) => { + return ( + + + + + + {option.label} + + + ); + }} + /> + ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts new file mode 100644 index 0000000000000..88e5588ce0e01 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dimension_panel'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx new file mode 100644 index 0000000000000..1ba7bf9dd607a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx @@ -0,0 +1,412 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPopover, + EuiFlexItem, + EuiFlexGroup, + EuiSideNav, + EuiCallOut, + EuiFormRow, + EuiFieldText, + EuiLink, + EuiButtonIcon, + EuiTextColor, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { + IndexPatternColumn, + OperationType, + IndexPattern, + IndexPatternField, +} from '../indexpattern'; +import { IndexPatternDimensionPanelProps, OperationFieldSupportMatrix } from './dimension_panel'; +import { + operationDefinitionMap, + getOperationDisplay, + buildColumn, + changeField, +} from '../operations'; +import { deleteColumn, changeColumn } from '../state_helpers'; +import { FieldSelect } from './field_select'; +import { hasField } from '../utils'; +import { BucketNestingEditor } from './bucket_nesting_editor'; + +const operationPanels = getOperationDisplay(); + +export function asOperationOptions( + operationTypes: OperationType[], + compatibleWithCurrentField: boolean +) { + return [...operationTypes] + .sort((opType1, opType2) => { + return operationPanels[opType1].displayName.localeCompare( + operationPanels[opType2].displayName + ); + }) + .map(operationType => ({ + operationType, + compatibleWithCurrentField, + })); +} + +export interface PopoverEditorProps extends IndexPatternDimensionPanelProps { + selectedColumn?: IndexPatternColumn; + operationFieldSupportMatrix: OperationFieldSupportMatrix; + currentIndexPattern: IndexPattern; +} + +export function PopoverEditor(props: PopoverEditorProps) { + const { + selectedColumn, + operationFieldSupportMatrix, + state, + columnId, + setState, + layerId, + currentIndexPattern, + } = props; + const { operationByDocument, operationByField, fieldByOperation } = operationFieldSupportMatrix; + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [ + incompatibleSelectedOperationType, + setInvalidOperationType, + ] = useState(null); + + const ParamEditor = + selectedColumn && operationDefinitionMap[selectedColumn.operationType].paramEditor; + + const fieldMap: Record = useMemo(() => { + const fields: Record = {}; + currentIndexPattern.fields.forEach(field => { + fields[field.name] = field; + }); + + return fields; + }, [currentIndexPattern]); + + function getOperationTypes() { + const possibleOperationTypes = Object.keys(fieldByOperation).concat( + operationByDocument + ) as OperationType[]; + + const validOperationTypes: OperationType[] = []; + if (!selectedColumn || !hasField(selectedColumn)) { + validOperationTypes.push(...operationByDocument); + } + + if (!selectedColumn) { + validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[])); + } else if (hasField(selectedColumn) && operationByField[selectedColumn.sourceField]) { + validOperationTypes.push(...operationByField[selectedColumn.sourceField]!); + } + + return _.uniq( + [ + ...asOperationOptions(validOperationTypes, true), + ...asOperationOptions(possibleOperationTypes, false), + ], + 'operationType' + ); + } + + function getSideNavItems() { + return [ + { + name: '', + id: '0', + items: getOperationTypes().map(({ operationType, compatibleWithCurrentField }) => ({ + name: operationPanels[operationType].displayName, + id: operationType as string, + className: classNames('lnsConfigPanel__operation', { + 'lnsConfigPanel__operation--selected': Boolean( + incompatibleSelectedOperationType === operationType || + (!incompatibleSelectedOperationType && + selectedColumn && + selectedColumn.operationType === operationType) + ), + 'lnsConfigPanel__operation--incompatible': !compatibleWithCurrentField, + }), + 'data-test-subj': `lns-indexPatternDimension${ + compatibleWithCurrentField ? '' : 'Incompatible' + }-${operationType}`, + onClick() { + if (!selectedColumn) { + const possibleFields = fieldByOperation[operationType] || []; + const isFieldlessPossible = operationByDocument.includes(operationType); + + if ( + possibleFields.length === 1 || + (possibleFields.length === 0 && isFieldlessPossible) + ) { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: possibleFields.length === 1 ? fieldMap[possibleFields[0]] : undefined, + asDocumentOperation: possibleFields.length === 0, + }), + }) + ); + } else { + setInvalidOperationType(operationType); + } + return; + } + if (!compatibleWithCurrentField) { + setInvalidOperationType(operationType); + return; + } + if (incompatibleSelectedOperationType) { + setInvalidOperationType(null); + } + if (selectedColumn.operationType === operationType) { + return; + } + const newColumn: IndexPatternColumn = buildColumn({ + columns: props.state.layers[props.layerId].columns, + suggestedPriority: props.suggestedPriority, + layerId: props.layerId, + op: operationType, + indexPattern: currentIndexPattern, + field: hasField(selectedColumn) ? fieldMap[selectedColumn.sourceField] : undefined, + }); + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn, + }) + ); + }, + })), + }, + ]; + } + + return ( + { + setPopoverOpen(!isPopoverOpen); + }} + data-test-subj="indexPattern-configure-dimension" + aria-label={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + title={i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit configuration', + })} + > + {selectedColumn.label} + + ) : ( + <> + setPopoverOpen(!isPopoverOpen)} + />{' '} + + + + + ) + } + isOpen={isPopoverOpen} + closePopover={() => { + setPopoverOpen(false); + setInvalidOperationType(null); + }} + anchorPosition="leftUp" + withTitle + panelPaddingSize="s" + > + {isPopoverOpen && ( + + + { + setState( + deleteColumn({ + state, + layerId, + columnId, + }) + ); + }} + onChoose={choice => { + let column: IndexPatternColumn; + if ( + !incompatibleSelectedOperationType && + selectedColumn && + ('field' in choice && choice.operationType === selectedColumn.operationType) + ) { + // If we just changed the field are not in an error state and the operation didn't change, + // we use the operations onFieldChange method to calculate the new column. + column = changeField(selectedColumn, currentIndexPattern, fieldMap[choice.field]); + } else { + // Otherwise we'll use the buildColumn method to calculate a new column + column = buildColumn({ + columns: props.state.layers[props.layerId].columns, + field: 'field' in choice ? fieldMap[choice.field] : undefined, + indexPattern: currentIndexPattern, + layerId: props.layerId, + suggestedPriority: props.suggestedPriority, + op: + incompatibleSelectedOperationType || + ('field' in choice ? choice.operationType : undefined), + asDocumentOperation: choice.type === 'document', + }); + } + + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: column, + keepParams: false, + }) + ); + setInvalidOperationType(null); + }} + /> + + + + + + + + {incompatibleSelectedOperationType && selectedColumn && ( + +

+ +

+
+ )} + {incompatibleSelectedOperationType && !selectedColumn && ( + + )} + {!incompatibleSelectedOperationType && ParamEditor && ( + + )} + {!incompatibleSelectedOperationType && selectedColumn && ( + + { + setState( + changeColumn({ + state, + layerId, + columnId, + newColumn: { + ...selectedColumn, + label: e.target.value, + }, + }) + ); + }} + /> + + )} + { + setState({ + ...state, + layers: { + ...state.layers, + [props.layerId]: { + ...state.layers[props.layerId], + columnOrder, + }, + }, + }); + }} + /> +
+
+
+
+ )} +
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx new file mode 100644 index 0000000000000..741fbc5f43ad6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { FieldIcon } from './field_icon'; + +describe('FieldIcon', () => { + it('should render numeric icons', () => { + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + expect(shallow()).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx new file mode 100644 index 0000000000000..04848df198e9e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_icon.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ICON_TYPES, palettes, EuiIcon } from '@elastic/eui'; +import classNames from 'classnames'; +import { DataType } from '../types'; + +function stringToNum(s: string) { + return Array.from(s).reduce((acc, ch) => acc + ch.charCodeAt(0), 1); +} + +function getIconForDataType(dataType: string) { + const icons: Partial>> = { + boolean: 'invert', + date: 'calendar', + }; + return icons[dataType] || ICON_TYPES.find(t => t === dataType) || 'empty'; +} + +export function getColorForDataType(type: string) { + const iconType = getIconForDataType(type); + const { colors } = palettes.euiPaletteColorBlind; + const colorIndex = stringToNum(iconType) % colors.length; + return colors[colorIndex]; +} + +export type UnwrapArray = T extends Array ? P : T; + +export function FieldIcon({ type }: { type: DataType }) { + const iconType = getIconForDataType(type); + + const classes = classNames( + 'lnsFieldListPanel__fieldIcon', + `lnsFieldListPanel__fieldIcon--${type}` + ); + + return ; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx new file mode 100644 index 0000000000000..0afc769688218 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { IndexPattern, IndexPatternField, DraggedField } from './indexpattern'; +import { DragDrop } from '../drag_drop'; +import { FieldIcon } from './field_icon'; +import { DataType } from '..'; + +export interface FieldItemProps { + field: IndexPatternField; + indexPattern: IndexPattern; + highlight?: string; + exists: boolean; +} + +function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; +} + +export function FieldItem({ field, indexPattern, highlight, exists }: FieldItemProps) { + const wrappableName = wrapOnDot(field.name)!; + const wrappableHighlight = wrapOnDot(highlight); + const highlightIndex = wrappableHighlight + ? wrappableName.toLowerCase().indexOf(wrappableHighlight.toLowerCase()) + : -1; + const wrappableHighlightableFieldName = + highlightIndex < 0 ? ( + wrappableName + ) : ( + + {wrappableName.substr(0, highlightIndex)} + {wrappableName.substr(highlightIndex, wrappableHighlight.length)} + {wrappableName.substr(highlightIndex + wrappableHighlight.length)} + + ); + + return ( + +
+ + + + {wrappableHighlightableFieldName} + +
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts new file mode 100644 index 0000000000000..d03370d0b8137 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { calculateFilterRatio } from './filter_ratio'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/common'; + +describe('calculate_filter_ratio', () => { + it('should collapse two rows and columns into a single row and column', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 10 }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], + rows: [{ bucket: 0.5 }], + type: 'kibana_datatable', + }); + }); + + it('should return 0 when the denominator is undefined', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b' }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], + rows: [{ bucket: 0 }], + type: 'kibana_datatable', + }); + }); + + it('should return 0 when the denominator is 0', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 0 }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + ], + rows: [{ bucket: 0 }], + type: 'kibana_datatable', + }); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [ + { id: 'bucket', name: 'A' }, + { id: 'filter-ratio', name: 'B' }, + { id: 'extra', name: 'C' }, + ], + rows: [ + { bucket: 'a', 'filter-ratio': 5, extra: 'first' }, + { bucket: 'b', 'filter-ratio': 10, extra: 'second' }, + ], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {})).toEqual({ + columns: [ + { + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }, + { id: 'extra', name: 'C' }, + ], + rows: [ + { + bucket: 0.5, + extra: 'first', + }, + ], + type: 'kibana_datatable', + }); + }); + + it('should attach a percentage format hint to the ratio column', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'bucket', name: 'A' }, { id: 'filter-ratio', name: 'B' }], + rows: [{ bucket: 'a', 'filter-ratio': 5 }, { bucket: 'b', 'filter-ratio': 10 }], + }; + + expect(calculateFilterRatio.fn(input, { id: 'bucket' }, {}).columns[0]).toEqual({ + id: 'bucket', + name: 'A', + formatHint: { + id: 'percent', + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts new file mode 100644 index 0000000000000..79dbe1dbe6fb3 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/filter_ratio.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; + +interface FilterRatioKey { + id: string; +} + +export const calculateFilterRatio: ExpressionFunction< + 'lens_calculate_filter_ratio', + KibanaDatatable, + FilterRatioKey, + KibanaDatatable +> = { + name: 'lens_calculate_filter_ratio', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.calculateFilterRatio.help', { + defaultMessage: 'A helper to collapse two filter ratio rows into a single row', + }), + args: { + id: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.calculateFilterRatio.id.help', { + defaultMessage: 'The column ID which has the filter ratio', + }), + }, + }, + context: { + types: ['kibana_datatable'], + }, + fn(data: KibanaDatatable, { id }: FilterRatioKey) { + const newRows: KibanaDatatable['rows'] = []; + + if (data.rows.length === 0) { + return data; + } + + if (data.rows.length % 2 === 1) { + throw new Error('Cannot divide an odd number of rows'); + } + + const [[valueKey]] = Object.entries(data.rows[0]).filter(([key]) => + key.includes('filter-ratio') + ); + + for (let i = 0; i < data.rows.length; i += 2) { + const row1 = data.rows[i]; + const row2 = data.rows[i + 1]; + + const calculatedRatio = row2[valueKey] + ? (row1[valueKey] as number) / (row2[valueKey] as number) + : 0; + + const result = { ...row1 }; + delete result[valueKey]; + + result[id] = calculatedRatio; + + newRows.push(result); + } + + const newColumns = data.columns + .filter(col => !col.id.includes('filter-ratio')) + .map(col => + col.id === id + ? { + ...col, + formatHint: { + id: 'percent', + }, + } + : col + ); + + return { + type: 'kibana_datatable', + rows: newRows, + columns: newColumns, + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss new file mode 100644 index 0000000000000..dcc579dd05ec6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.scss @@ -0,0 +1,72 @@ +@import './dimension_panel/index'; + + +.lnsIndexPatternDataPanel { + width: 100%; + height: 100%; + padding: $euiSize 0 0 $euiSize; +} + +.lnsIndexPatternDataPanel__header { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; + + + & > .lnsIndexPatternDataPanel__changeLink { + flex: 0 0 auto; + margin: 0 $euiSize; + } +} + +.lnsIndexPatternDataPanel__filter-wrapper { + flex-grow: 0; +} + +.lnsIndexPatternDataPanel__header-text { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} + +.lnsFieldListPanel__list-wrapper { + @include euiOverflowShadow; + @include euiScrollBar; + margin-top: 2px; // form control shadow + position: relative; + flex-grow: 1; + overflow: auto; +} + +.lnsFieldListPanel__list { + padding-top: $euiSizeS; + scrollbar-width: thin; + position: absolute; + top: 0; + left: 0; + right: 0; +} + +.lnsFieldListPanel__field { + @include euiFontSizeS; + background: $euiColorEmptyShade; + border-radius: $euiBorderRadius; + padding: $euiSizeS; + margin-bottom: $euiSizeXS; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance; + + &:hover { + @include euiBottomShadowMedium; + z-index: 2; + cursor: grab; + } +} + +.lnsFieldListPanel__field--missing { + background: $euiColorLightestShade; +} + +.lnsFieldListPanel__fieldName { + margin-left: $euiSizeXS; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts new file mode 100644 index 0000000000000..dbbdb368b44e6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.test.ts @@ -0,0 +1,459 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chromeMock from 'ui/chrome'; +import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; +import { Storage } from 'ui/storage'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { + getIndexPatternDatasource, + IndexPatternPersistedState, + IndexPatternPrivateState, + IndexPatternColumn, +} from './indexpattern'; +import { DatasourcePublicAPI, Operation, Datasource } from '../types'; +import { coreMock } from 'src/core/public/mocks'; + +jest.mock('./loader'); +jest.mock('../id_generator'); +// chrome, notify, storage are used by ./plugin +jest.mock('ui/chrome'); +// Contains old and new platform data plugins, used for interpreter and filter ratio +jest.mock('ui/new_platform'); +jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, +}; + +describe('IndexPattern Data Source', () => { + let persistedState: IndexPatternPersistedState; + let indexPatternDatasource: Datasource; + + beforeEach(() => { + indexPatternDatasource = getIndexPatternDatasource({ + chrome: chromeMock, + storage: {} as Storage, + interpreter: { functionsRegistry }, + core: coreMock.createSetup(), + data: dataMock, + savedObjectsClient: {} as SavedObjectsClientContract, + }); + + persistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }; + }); + + describe('#initialize', () => { + it('should load a default state', async () => { + const state = await indexPatternDatasource.initialize(); + expect(state).toEqual({ + currentIndexPatternId: '1', + indexPatterns: expectedIndexPatterns, + layers: {}, + showEmptyFields: false, + }); + }); + + it('should initialize from saved state', async () => { + const state = await indexPatternDatasource.initialize(persistedState); + expect(state).toEqual({ + ...persistedState, + indexPatterns: expectedIndexPatterns, + showEmptyFields: false, + }); + }); + }); + + describe('#getPersistedState', () => { + it('should persist from saved state', async () => { + const state = await indexPatternDatasource.initialize(persistedState); + + expect(indexPatternDatasource.getPersistableState(state)).toEqual(persistedState); + }); + }); + + describe('#toExpression', () => { + it('should generate an empty expression when no columns are selected', async () => { + const state = await indexPatternDatasource.initialize(); + expect(indexPatternDatasource.toExpression(state, 'first')).toEqual(null); + }); + + it('should generate an expression for an aggregated query', async () => { + const queryPersistedState: IndexPatternPersistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Count of Documents', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + col2: { + label: 'Date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }, + }, + }, + }; + const state = await indexPatternDatasource.initialize(queryPersistedState); + expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` + "esaggs + index=\\"1\\" + metricsAtAllLevels=false + partialRows=false + includeFormatHints=true + aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":1,\\"extended_bounds\\":{}}}]' | lens_rename_columns idMap='{\\"col-0-col1\\":\\"col1\\",\\"col-1-col2\\":\\"col2\\"}'" + `); + }); + }); + + describe('#insertLayer', () => { + it('should insert an empty layer into the previous state', () => { + const state = { + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + }; + expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ + ...state, + layers: { + ...state.layers, + newLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#removeLayer', () => { + it('should remove a layer', () => { + const state = { + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }; + expect(indexPatternDatasource.removeLayer(state, 'first')).toEqual({ + ...state, + layers: { + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + }); + }); + }); + + describe('#getLayers', () => { + it('should list the current layers', () => { + expect( + indexPatternDatasource.getLayers({ + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual(['first', 'second']); + }); + }); + + describe('#getMetadata', () => { + it('should return the title of the index patterns', () => { + expect( + indexPatternDatasource.getMetaData({ + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + second: { + indexPatternId: '2', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual({ + filterableIndexPatterns: [ + { + id: '1', + title: 'my-fake-index-pattern', + }, + { + id: '2', + title: 'my-fake-restricted-pattern', + }, + ], + }); + }); + }); + + describe('#getPublicAPI', () => { + let publicAPI: DatasourcePublicAPI; + + beforeEach(async () => { + const initialState = await indexPatternDatasource.initialize(persistedState); + publicAPI = indexPatternDatasource.getPublicAPI(initialState, () => {}, 'first'); + }); + + describe('getTableSpec', () => { + it('should include col1', () => { + expect(publicAPI.getTableSpec()).toEqual([ + { + columnId: 'col1', + }, + ]); + }); + }); + + describe('removeColumnInTableSpec', () => { + it('should remove the specified column', async () => { + const initialState = await indexPatternDatasource.initialize(persistedState); + const setState = jest.fn(); + const sampleColumn: IndexPatternColumn = { + dataType: 'number', + isBucketed: false, + label: 'foo', + operationType: 'max', + sourceField: 'baz', + suggestedPriority: 0, + }; + const columns: Record = { + a: { + ...sampleColumn, + suggestedPriority: 0, + }, + b: { + ...sampleColumn, + suggestedPriority: 1, + }, + c: { + ...sampleColumn, + suggestedPriority: 2, + }, + }; + const api = indexPatternDatasource.getPublicAPI( + { + ...initialState, + layers: { + first: { + ...initialState.layers.first, + columns, + columnOrder: ['a', 'b', 'c'], + }, + }, + }, + setState, + 'first' + ); + + api.removeColumnInTableSpec('b'); + + expect(setState.mock.calls[0][0].layers.first.columnOrder).toEqual(['a', 'c']); + expect(setState.mock.calls[0][0].layers.first.columns).toEqual({ + a: columns.a, + c: columns.c, + }); + }); + }); + + describe('getOperationForColumnId', () => { + it('should get an operation for col1', () => { + expect(publicAPI.getOperationForColumnId('col1')).toEqual({ + label: 'My Op', + dataType: 'string', + isBucketed: true, + } as Operation); + }); + + it('should return null for non-existant columns', () => { + expect(publicAPI.getOperationForColumnId('col2')).toBe(null); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx new file mode 100644 index 0000000000000..92c0f6e89aecb --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern.tsx @@ -0,0 +1,316 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { render } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { CoreSetup, SavedObjectsClientContract } from 'src/core/public'; +import { Storage } from 'ui/storage'; +import { + DatasourceDimensionPanelProps, + DatasourceDataPanelProps, + Operation, + DatasourceLayerPanelProps, +} from '../types'; +import { getIndexPatterns } from './loader'; +import { toExpression } from './to_expression'; +import { IndexPatternDimensionPanel } from './dimension_panel'; +import { IndexPatternDatasourcePluginPlugins } from './plugin'; +import { IndexPatternDataPanel } from './datapanel'; +import { + getDatasourceSuggestionsForField, + getDatasourceSuggestionsFromCurrentState, +} from './indexpattern_suggestions'; + +import { isDraggedField } from './utils'; +import { LayerPanel } from './layerpanel'; +import { IndexPatternColumn } from './operations'; +import { Datasource, StateSetter } from '..'; + +export { OperationType, IndexPatternColumn } from './operations'; + +export interface IndexPattern { + id: string; + fields: IndexPatternField[]; + title: string; + timeFieldName?: string | null; + fieldFormatMap?: Record< + string, + { + id: string; + params: unknown; + } + >; + + // TODO: Load index patterns and existence data in one API call + hasExistence?: boolean; +} + +export interface IndexPatternField { + name: string; + type: string; + esTypes?: string[]; + aggregatable: boolean; + searchable: boolean; + aggregationRestrictions?: Partial< + Record< + string, + { + agg: string; + interval?: number; + fixed_interval?: string; + calendar_interval?: string; + delay?: string; + time_zone?: string; + } + > + >; + + // TODO: This is loaded separately, but should be combined into one API + exists?: boolean; + cardinality?: number; + count?: number; +} + +export interface DraggedField { + field: IndexPatternField; + indexPatternId: string; +} + +export interface IndexPatternLayer { + columnOrder: string[]; + columns: Record; + // Each layer is tied to the index pattern that created it + indexPatternId: string; +} +export interface IndexPatternPersistedState { + currentIndexPatternId: string; + layers: Record; +} + +export type IndexPatternPrivateState = IndexPatternPersistedState & { + indexPatterns: Record; + showEmptyFields: boolean; +}; + +export function columnToOperation(column: IndexPatternColumn): Operation { + const { dataType, label, isBucketed, scale } = column; + return { + label, + dataType, + isBucketed, + scale, + }; +} + +type UnwrapPromise = T extends Promise ? P : T; +type InferFromArray = T extends Array ? P : T; + +function addRestrictionsToFields( + indexPattern: InferFromArray>, void>> +): IndexPattern { + const { typeMeta } = indexPattern; + if (!typeMeta) { + return indexPattern; + } + + const aggs = Object.keys(typeMeta.aggs); + + const newFields = [...(indexPattern.fields as IndexPatternField[])]; + newFields.forEach((field, index) => { + const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {}; + aggs.forEach(agg => { + if (typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name]) { + restrictionsObj[agg] = typeMeta.aggs[agg][field.name]; + } + }); + if (Object.keys(restrictionsObj).length) { + newFields[index] = { ...field, aggregationRestrictions: restrictionsObj }; + } + }); + + const { id, title, timeFieldName } = indexPattern; + + return { + id, + title, + timeFieldName: timeFieldName || undefined, + fields: newFields, + }; +} + +function removeProperty(prop: string, object: Record): Record { + const result = { ...object }; + delete result[prop]; + return result; +} + +export function getIndexPatternDatasource({ + core, + chrome, + storage, + savedObjectsClient, +}: IndexPatternDatasourcePluginPlugins & { + core: CoreSetup; + storage: Storage; + savedObjectsClient: SavedObjectsClientContract; +}) { + const uiSettings = chrome.getUiSettingsClient(); + // Not stateful. State is persisted to the frame + const indexPatternDatasource: Datasource = { + async initialize(state?: IndexPatternPersistedState) { + // TODO: The initial request should only load index pattern names because each saved object is large + // Followup requests should load a single index pattern + existence information + const indexPatternObjects = await getIndexPatterns(chrome, core.notifications); + const indexPatterns: Record = {}; + + if (indexPatternObjects) { + indexPatternObjects.forEach(obj => { + indexPatterns[obj.id] = addRestrictionsToFields(obj); + }); + } + + if (state) { + return { + ...state, + indexPatterns, + showEmptyFields: false, + }; + } + return { + currentIndexPatternId: indexPatternObjects ? indexPatternObjects[0].id : '', + indexPatterns, + layers: {}, + showEmptyFields: false, + }; + }, + + getPersistableState({ currentIndexPatternId, layers }: IndexPatternPrivateState) { + return { currentIndexPatternId, layers }; + }, + + insertLayer(state: IndexPatternPrivateState, newLayerId: string) { + return { + ...state, + layers: { + ...state.layers, + [newLayerId]: { + indexPatternId: state.currentIndexPatternId, + columns: {}, + columnOrder: [], + }, + }, + }; + }, + + removeLayer(state: IndexPatternPrivateState, layerId: string) { + const newLayers = { ...state.layers }; + delete newLayers[layerId]; + + return { + ...state, + layers: newLayers, + }; + }, + + getLayers(state: IndexPatternPrivateState) { + return Object.keys(state.layers); + }, + + toExpression, + + getMetaData(state: IndexPatternPrivateState) { + return { + filterableIndexPatterns: _.uniq( + Object.values(state.layers) + .map(layer => layer.indexPatternId) + .map(indexPatternId => ({ + id: indexPatternId, + title: state.indexPatterns[indexPatternId].title, + })) + ), + }; + }, + + renderDataPanel( + domElement: Element, + props: DatasourceDataPanelProps + ) { + render( + + + , + domElement + ); + }, + + getPublicAPI( + state: IndexPatternPrivateState, + setState: StateSetter, + layerId: string + ) { + return { + getTableSpec: () => { + return state.layers[layerId].columnOrder.map(colId => ({ columnId: colId })); + }, + getOperationForColumnId: (columnId: string) => { + const layer = state.layers[layerId]; + + if (layer && layer.columns[columnId]) { + return columnToOperation(layer.columns[columnId]); + } + return null; + }, + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => { + render( + + + , + domElement + ); + }, + + renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => { + render(, domElement); + }, + + removeColumnInTableSpec: (columnId: string) => { + setState({ + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: state.layers[layerId].columnOrder.filter(id => id !== columnId), + columns: removeProperty(columnId, state.layers[layerId].columns), + }, + }, + }); + }, + moveColumnTo: () => {}, + duplicateColumn: () => [], + }; + }, + getDatasourceSuggestionsForField(state, draggedField) { + return isDraggedField(draggedField) + ? getDatasourceSuggestionsForField(state, draggedField.indexPatternId, draggedField.field) + : []; + }, + getDatasourceSuggestionsFromCurrentState, + }; + + return indexPatternDatasource; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx new file mode 100644 index 0000000000000..c7e33640dee87 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.test.tsx @@ -0,0 +1,1375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chromeMock from 'ui/chrome'; +import { data as dataMock } from '../../../../../../src/legacy/core_plugins/data/public/setup'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { SavedObjectsClientContract } from 'src/core/public'; +import { + getIndexPatternDatasource, + IndexPatternPersistedState, + IndexPatternPrivateState, +} from './indexpattern'; +import { Datasource, DatasourceSuggestion } from '../types'; +import { generateId } from '../id_generator'; +import { Storage } from 'ui/storage'; +import { coreMock } from 'src/core/public/mocks'; + +jest.mock('./loader'); +jest.mock('../id_generator'); +// chrome, notify, storage are used by ./plugin +jest.mock('ui/chrome'); +// Contains old and new platform data plugins, used for interpreter and filter ratio +jest.mock('ui/new_platform'); +jest.mock('plugins/data/setup', () => ({ data: { query: { ui: {} } } })); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + // Ignored in the UI + histogram: { + agg: 'histogram', + interval: 1000, + }, + avg: { + agg: 'avg', + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, +}; + +describe('IndexPattern Data Source suggestions', () => { + let persistedState: IndexPatternPersistedState; + let indexPatternDatasource: Datasource; + + beforeEach(() => { + indexPatternDatasource = getIndexPatternDatasource({ + core: coreMock.createSetup(), + chrome: chromeMock, + storage: {} as Storage, + interpreter: { functionsRegistry }, + data: dataMock, + savedObjectsClient: {} as SavedObjectsClientContract, + }); + + persistedState = { + currentIndexPatternId: '1', + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }; + }); + + describe('#getDatasourceSuggestionsForField', () => { + describe('with no layer', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + jest.resetAllMocks(); + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: {}, + }); + (generateId as jest.Mock).mockReturnValueOnce('suggestedLayer'); + (generateId as jest.Mock).mockReturnValueOnce('col1'); + (generateId as jest.Mock).mockReturnValueOnce('col2'); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'source', type: 'string', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'suggestedLayer', + }); + }); + + it('should apply a bucketed aggregation for a date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'timestamp', type: 'date', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'suggestedLayer', + }); + }); + + it('should select a metric for a number field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'bytes', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + suggestedLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'avg', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'suggestedLayer', + }); + }); + + it('should not make any suggestions for a number without a time field', async () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + showEmptyFields: false, + indexPatterns: { + 1: { + id: '1', + title: 'no timefield', + fields: [ + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(state, { + field: { name: 'bytes', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(0); + }); + }); + + describe('with a previous empty layer', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + jest.resetAllMocks(); + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + }, + }); + (generateId as jest.Mock).mockReturnValueOnce('col1'); + (generateId as jest.Mock).mockReturnValueOnce('col2'); + }); + + it('should apply a bucketed aggregation for a string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'source', type: 'string', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'previousLayer', + }); + }); + + it('should apply a bucketed aggregation for a date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'timestamp', type: 'date', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'previousLayer', + }); + }); + + it('should select a metric for a number field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'bytes', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'avg', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'previousLayer', + }); + }); + + it('should not make any suggestions for a number without a time field', async () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + showEmptyFields: false, + indexPatterns: { + 1: { + id: '1', + title: 'no timefield', + fields: [ + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + layers: { + previousLayer: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(state, { + field: { name: 'bytes', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(0); + }); + }); + + describe('suggesting extensions to non-empty tables', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + jest.resetAllMocks(); + (generateId as jest.Mock).mockReturnValueOnce('newId'); + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '2', + columns: {}, + columnOrder: [], + }, + currentLayer: { + indexPatternId: '1', + columns: { + col1: { + dataType: 'string', + isBucketed: true, + sourceField: 'source', + label: 'values of source', + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col2' }, + orderDirection: 'asc', + size: 5, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + label: 'Avg of bytes', + operationType: 'avg', + }, + }, + columnOrder: ['col1', 'col2'], + }, + }, + }); + }); + + it('replaces an existing date histogram column on date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField( + { + ...initialState, + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + col1: { + dataType: 'date', + isBucketed: true, + sourceField: 'timestamp', + label: 'date histogram of timestamp', + operationType: 'date_histogram', + params: { + interval: 'w', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + sourceField: 'bytes', + label: 'Avg of bytes', + operationType: 'avg', + }, + }, + }, + }, + }, + { + field: { name: 'start_date', type: 'date', aggregatable: true, searchable: true }, + indexPatternId: '1', + } + ); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['newId', 'col2'], + columns: { + newId: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'start_date', + }), + col2: initialState.layers.currentLayer.columns.col2, + }, + }), + }, + }) + ); + }); + + it('puts a date histogram column after the last bucket column on date field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'timestamp', type: 'date', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['col1', 'newId', 'col2'], + columns: { + ...initialState.layers.currentLayer.columns, + newId: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'extended', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'newId', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'currentLayer', + }); + }); + + it('does not use the same field for bucketing multiple times', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'source', type: 'string', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(0); + }); + + it('appends a terms column after the last existing bucket column on string field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'dest', type: 'string', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['col1', 'newId', 'col2'], + columns: { + ...initialState.layers.currentLayer.columns, + newId: expect.objectContaining({ + operationType: 'terms', + sourceField: 'dest', + }), + }, + }), + }, + }) + ); + }); + + it('appends a metric column on a number field', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'memory', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2', 'newId'], + columns: { + ...initialState.layers.currentLayer.columns, + newId: expect.objectContaining({ + operationType: 'avg', + sourceField: 'memory', + }), + }, + }), + }, + }) + ); + }); + + it('appends a metric column with a different operation on a number field if field is already in use', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { name: 'bytes', type: 'number', aggregatable: true, searchable: true }, + indexPatternId: '1', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2', 'newId'], + columns: { + ...initialState.layers.currentLayer.columns, + newId: expect.objectContaining({ + operationType: 'sum', + sourceField: 'bytes', + }), + }, + }), + }, + }) + ); + }); + }); + + describe('finding the layer that is using the current index pattern', () => { + let initialState: IndexPatternPrivateState; + + beforeEach(async () => { + jest.resetAllMocks(); + initialState = await indexPatternDatasource.initialize({ + currentIndexPatternId: '1', + layers: { + previousLayer: { + indexPatternId: '1', + columns: {}, + columnOrder: [], + }, + currentLayer: { + indexPatternId: '2', + columns: {}, + columnOrder: [], + }, + }, + }); + (generateId as jest.Mock).mockReturnValueOnce('col1'); + (generateId as jest.Mock).mockReturnValueOnce('col2'); + }); + + it('suggests on the layer that matches by indexPatternId', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField(initialState, { + field: { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + indexPatternId: '2', + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + previousLayer: initialState.layers.previousLayer, + currentLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'col1', + }), + expect.objectContaining({ + columnId: 'col2', + }), + ], + layerId: 'currentLayer', + }); + }); + + it('suggests on the layer with the fewest columns that matches by indexPatternId', () => { + const suggestions = indexPatternDatasource.getDatasourceSuggestionsForField( + { + ...initialState, + layers: { + ...initialState.layers, + previousLayer: { + ...initialState.layers.previousLayer, + indexPatternId: '1', + }, + }, + }, + { + field: { name: 'timestamp', type: 'date', aggregatable: true, searchable: true }, + indexPatternId: '1', + } + ); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0].state).toEqual( + expect.objectContaining({ + layers: { + currentLayer: initialState.layers.currentLayer, + previousLayer: expect.objectContaining({ + columnOrder: ['col1', 'col2'], + columns: { + col1: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + col2: expect.objectContaining({ + operationType: 'count', + }), + }, + }), + }, + }) + ); + }); + }); + }); + + describe('#getDatasourceSuggestionsFromCurrentState', () => { + it('returns no suggestions if there are no columns', () => { + expect( + indexPatternDatasource.getDatasourceSuggestionsFromCurrentState({ + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: [], + columns: {}, + }, + }, + currentIndexPatternId: '1', + }) + ).toEqual([]); + }); + + it('returns a single suggestion containing the current columns for each layer', async () => { + const state = await indexPatternDatasource.initialize({ + ...persistedState, + layers: { + ...persistedState.layers, + second: { + ...persistedState.layers.first, + columns: { + col1: { + label: 'My Op 2', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'op', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + }, + }, + }, + }); + expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)).toEqual([ + expect.objectContaining({ + table: { + isMultiRow: true, + changeType: 'unchanged', + label: undefined, + columns: [ + { + columnId: 'col1', + operation: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + scale: undefined, + }, + }, + ], + layerId: 'first', + }, + }), + expect.objectContaining({ + table: { + isMultiRow: true, + changeType: 'unchanged', + label: undefined, + columns: [ + { + columnId: 'col1', + operation: { + label: 'My Op 2', + dataType: 'string', + isBucketed: true, + scale: undefined, + }, + }, + ], + layerId: 'second', + }, + }), + ]); + }); + + it('returns a metric over time for single metric tables', async () => { + jest.resetAllMocks(); + (generateId as jest.Mock).mockReturnValueOnce('col2'); + const state = await indexPatternDatasource.initialize({ + ...persistedState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'bytes', + scale: 'ratio', + }, + }, + }, + }, + }); + expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)[0]).toEqual( + expect.objectContaining({ + table: { + isMultiRow: true, + changeType: 'extended', + label: 'Over time', + columns: [ + { + columnId: 'col2', + operation: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'col1', + operation: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }, + }, + ], + layerId: 'first', + }, + }) + ); + }); + + it('adds date histogram over default time field for tables without time dimension', async () => { + jest.resetAllMocks(); + (generateId as jest.Mock).mockReturnValueOnce('newCol'); + const state = await indexPatternDatasource.initialize({ + ...persistedState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Terms', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + scale: 'ordinal', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'bytes', + scale: 'ratio', + }, + }, + }, + }, + }); + expect(indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state)[2]).toEqual( + expect.objectContaining({ + table: { + isMultiRow: true, + changeType: 'extended', + label: 'Over time', + columns: [ + { + columnId: 'col1', + operation: { + label: 'My Terms', + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + }, + }, + { + columnId: 'newCol', + operation: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + scale: 'interval', + }, + }, + { + columnId: 'col2', + operation: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }, + }, + ], + layerId: 'first', + }, + }) + ); + }); + + it('does not create an over time suggestion if there is no default time field', async () => { + jest.resetAllMocks(); + (generateId as jest.Mock).mockReturnValueOnce('newCol'); + const state = await indexPatternDatasource.initialize({ + ...persistedState, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'bytes', + scale: 'ratio', + }, + }, + }, + }, + }); + const suggestions = indexPatternDatasource.getDatasourceSuggestionsFromCurrentState({ + ...state, + indexPatterns: { 1: { ...state.indexPatterns['1'], timeFieldName: undefined } }, + }); + suggestions.forEach(suggestion => expect(suggestion.table.columns.length).toBe(1)); + }); + + it('returns simplified versions of table with more than 2 columns', () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + indexPatterns: { + 1: { + id: '1', + title: 'my-fake-index-pattern', + fields: [ + { + name: 'field1', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'field2', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'field3', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'field4', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'field5', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + showEmptyFields: true, + layers: { + first: { + ...persistedState.layers.first, + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + operationType: 'terms', + sourceField: 'field1', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + col2: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + operationType: 'terms', + sourceField: 'field2', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + col3: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + + operationType: 'terms', + sourceField: 'field3', + params: { + size: 5, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }, + col4: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + + operationType: 'avg', + sourceField: 'field4', + }, + col5: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + + operationType: 'min', + sourceField: 'field5', + }, + }, + columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5'], + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state); + // 1 bucket col, 2 metric cols + isTableWithBucketColumns(suggestions[0], ['col1', 'col4', 'col5'], 1); + + // 1 bucket col, 1 metric col + isTableWithBucketColumns(suggestions[1], ['col1', 'col4'], 1); + + // 2 bucket cols, 2 metric cols + isTableWithBucketColumns(suggestions[2], ['col1', 'col2', 'col4', 'col5'], 2); + + // 2 bucket cols, 1 metric col + isTableWithBucketColumns(suggestions[3], ['col1', 'col2', 'col4'], 2); + + // 3 bucket cols, 2 metric cols + isTableWithBucketColumns(suggestions[4], ['col1', 'col2', 'col3', 'col4', 'col5'], 3); + + // 3 bucket cols, 1 metric col + isTableWithBucketColumns(suggestions[5], ['col1', 'col2', 'col3', 'col4'], 3); + + // first metric col + isTableWithMetricColumns(suggestions[6], ['col4']); + + // second metric col + isTableWithMetricColumns(suggestions[7], ['col5']); + + expect(suggestions.length).toBe(8); + }); + + it('returns an only metric version of a given table', () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + indexPatterns: { + 1: { + id: '1', + title: 'my-fake-index-pattern', + fields: [ + { + name: 'field1', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'field2', + type: 'date', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + showEmptyFields: true, + layers: { + first: { + ...persistedState.layers.first, + columns: { + col1: { + label: 'Date histogram', + dataType: 'date', + isBucketed: true, + + operationType: 'date_histogram', + sourceField: 'field2', + params: { + interval: 'd', + }, + }, + col2: { + label: 'Average of field1', + dataType: 'number', + isBucketed: false, + + operationType: 'avg', + sourceField: 'field1', + }, + }, + columnOrder: ['col1', 'col2'], + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state); + expect(suggestions[1].table.columns[0].operation.label).toBe('Average of field1'); + }); + + it('returns an alternative metric for an only-metric table', () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + indexPatterns: { + 1: { + id: '1', + title: 'my-fake-index-pattern', + fields: [ + { + name: 'field1', + type: 'number', + aggregatable: true, + searchable: true, + }, + ], + }, + }, + showEmptyFields: true, + layers: { + first: { + ...persistedState.layers.first, + columns: { + col1: { + label: 'Average of field1', + dataType: 'number', + isBucketed: false, + + operationType: 'avg', + sourceField: 'field1', + }, + }, + columnOrder: ['col1'], + }, + }, + }; + + const suggestions = indexPatternDatasource.getDatasourceSuggestionsFromCurrentState(state); + expect(suggestions[0].table.columns.length).toBe(1); + expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1'); + }); + }); +}); + +function isTableWithBucketColumns( + suggestion: DatasourceSuggestion, + columnIds: string[], + numBuckets: number +) { + expect(suggestion.table.columns.map(column => column.columnId)).toEqual(columnIds); + expect( + suggestion.table.columns.slice(0, numBuckets).every(column => column.operation.isBucketed) + ).toBeTruthy(); +} + +function isTableWithMetricColumns( + suggestion: DatasourceSuggestion, + columnIds: string[] +) { + expect(suggestion.table.isMultiRow).toEqual(false); + expect(suggestion.table.columns.map(column => column.columnId)).toEqual(columnIds); + expect(suggestion.table.columns.every(column => !column.operation.isBucketed)).toBeTruthy(); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts new file mode 100644 index 0000000000000..fb2bfd1783864 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/indexpattern_suggestions.ts @@ -0,0 +1,541 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _, { partition } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { generateId } from '../id_generator'; +import { DatasourceSuggestion, TableChangeType } from '../types'; +import { + columnToOperation, + IndexPatternField, + IndexPatternLayer, + IndexPatternPrivateState, + IndexPattern, +} from './indexpattern'; +import { + buildColumn, + getOperationTypesForField, + operationDefinitionMap, + IndexPatternColumn, +} from './operations'; +import { hasField } from './utils'; + +function buildSuggestion({ + state, + updatedLayer, + layerId, + label, + changeType, +}: { + state: IndexPatternPrivateState; + layerId: string; + changeType: TableChangeType; + updatedLayer?: IndexPatternLayer; + label?: string; +}): DatasourceSuggestion { + const updatedState = updatedLayer + ? { + ...state, + layers: { + ...state.layers, + [layerId]: updatedLayer, + }, + } + : state; + + // It's fairly easy to accidentally introduce a mismatch between + // columnOrder and columns, so this is a safeguard to ensure the + // two match up. + const layers = _.mapValues(updatedState.layers, layer => ({ + ...layer, + columns: _.pick, Record>( + layer.columns, + layer.columnOrder + ), + })); + + const columnOrder = layers[layerId].columnOrder; + const columnMap = layers[layerId].columns; + const isMultiRow = Object.values(columnMap).some(column => column.isBucketed); + + return { + state: { + ...updatedState, + layers, + }, + + table: { + columns: columnOrder.map(columnId => ({ + columnId, + operation: columnToOperation(columnMap[columnId]), + })), + isMultiRow, + layerId, + changeType, + label, + }, + }; +} + +export function getDatasourceSuggestionsForField( + state: IndexPatternPrivateState, + indexPatternId: string, + field: IndexPatternField +): Array> { + const layers = Object.keys(state.layers); + const layerIds = layers.filter(id => state.layers[id].indexPatternId === indexPatternId); + + if (layerIds.length === 0) { + // The field we're suggesting on does not match any existing layer. This will always add + // a new layer if possible, but that might not be desirable if the layers are too complicated + // already + return getEmptyLayerSuggestionsForField(state, generateId(), indexPatternId, field); + } else { + // The field we're suggesting on matches an existing layer. In this case we find the layer with + // the fewest configured columns and try to add the field to this table. If this layer does not + // contain any layers yet, behave as if there is no layer. + const mostEmptyLayerId = _.min(layerIds, layerId => state.layers[layerId].columnOrder.length); + if (state.layers[mostEmptyLayerId].columnOrder.length === 0) { + return getEmptyLayerSuggestionsForField(state, mostEmptyLayerId, indexPatternId, field); + } else { + return getExistingLayerSuggestionsForField(state, mostEmptyLayerId, field); + } + } +} + +function getBucketOperation(field: IndexPatternField) { + return getOperationTypesForField(field).find(op => op === 'date_histogram' || op === 'terms'); +} + +function getExistingLayerSuggestionsForField( + state: IndexPatternPrivateState, + layerId: string, + field: IndexPatternField +) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const operations = getOperationTypesForField(field); + const usableAsBucketOperation = getBucketOperation(field); + const fieldInUse = Object.values(layer.columns).some( + column => hasField(column) && column.sourceField === field.name + ); + let updatedLayer: IndexPatternLayer | undefined; + if (usableAsBucketOperation && !fieldInUse) { + updatedLayer = addFieldAsBucketOperation(layer, layerId, indexPattern, field); + } else if (!usableAsBucketOperation && operations.length > 0) { + updatedLayer = addFieldAsMetricOperation(layer, layerId, indexPattern, field); + } + + return updatedLayer + ? [ + buildSuggestion({ + state, + updatedLayer, + layerId, + changeType: 'extended', + }), + ] + : []; +} + +function addFieldAsMetricOperation( + layer: IndexPatternLayer, + layerId: string, + indexPattern: IndexPattern, + field: IndexPatternField +) { + const operations = getOperationTypesForField(field); + const operationsAlreadyAppliedToThisField = Object.values(layer.columns) + .filter(column => hasField(column) && column.sourceField === field.name) + .map(column => column.operationType); + const operationCandidate = operations.find( + operation => !operationsAlreadyAppliedToThisField.includes(operation) + ); + + if (!operationCandidate) { + return undefined; + } + + const newColumn = buildColumn({ + op: operationCandidate, + columns: layer.columns, + layerId, + indexPattern, + suggestedPriority: undefined, + field, + }); + const newColumnId = generateId(); + const updatedColumns = { + ...layer.columns, + [newColumnId]: newColumn, + }; + const updatedColumnOrder = [...layer.columnOrder, newColumnId]; + + return { + indexPatternId: indexPattern.id, + columns: updatedColumns, + columnOrder: updatedColumnOrder, + }; +} + +function addFieldAsBucketOperation( + layer: IndexPatternLayer, + layerId: string, + indexPattern: IndexPattern, + field: IndexPatternField +) { + const applicableBucketOperation = getBucketOperation(field); + const newColumn = buildColumn({ + op: applicableBucketOperation, + columns: layer.columns, + layerId, + indexPattern, + suggestedPriority: undefined, + field, + }); + const [buckets, metrics] = separateBucketColumns(layer); + const newColumnId = generateId(); + const updatedColumns = { + ...layer.columns, + [newColumnId]: newColumn, + }; + let updatedColumnOrder: string[] = []; + if (applicableBucketOperation === 'terms') { + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; + } else { + const oldDateHistogramColumn = layer.columnOrder.find( + columnId => layer.columns[columnId].operationType === 'date_histogram' + ); + if (oldDateHistogramColumn) { + delete updatedColumns[oldDateHistogramColumn]; + updatedColumnOrder = layer.columnOrder.map(columnId => + columnId !== oldDateHistogramColumn ? columnId : newColumnId + ); + } else { + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; + } + } + return { + indexPatternId: indexPattern.id, + columns: updatedColumns, + columnOrder: updatedColumnOrder, + }; +} + +function getEmptyLayerSuggestionsForField( + state: IndexPatternPrivateState, + layerId: string, + indexPatternId: string, + field: IndexPatternField +) { + const indexPattern = state.indexPatterns[indexPatternId]; + let newLayer: IndexPatternLayer | undefined; + if (getBucketOperation(field)) { + newLayer = createNewLayerWithBucketAggregation(layerId, indexPattern, field); + } else if (indexPattern.timeFieldName && getOperationTypesForField(field).length > 0) { + newLayer = createNewLayerWithMetricAggregation(layerId, indexPattern, field); + } + return newLayer + ? [ + buildSuggestion({ + state, + updatedLayer: newLayer, + layerId, + changeType: 'initial', + }), + ] + : []; +} + +function createNewLayerWithBucketAggregation( + layerId: string, + indexPattern: IndexPattern, + field: IndexPatternField +) { + const countColumn = buildColumn({ + op: 'count', + columns: {}, + indexPattern, + layerId, + suggestedPriority: undefined, + }); + + const col1 = generateId(); + const col2 = generateId(); + + // let column know about count column + const column = buildColumn({ + layerId, + op: getBucketOperation(field), + indexPattern, + columns: { + [col2]: countColumn, + }, + field, + suggestedPriority: undefined, + }); + + return { + indexPatternId: indexPattern.id, + columns: { + [col1]: column, + [col2]: countColumn, + }, + columnOrder: [col1, col2], + }; +} + +function createNewLayerWithMetricAggregation( + layerId: string, + indexPattern: IndexPattern, + field: IndexPatternField +) { + const dateField = indexPattern.fields.find(f => f.name === indexPattern.timeFieldName)!; + + const operations = getOperationTypesForField(field); + const column = buildColumn({ + op: operations[0], + columns: {}, + suggestedPriority: undefined, + field, + indexPattern, + layerId, + }); + + const dateColumn = buildColumn({ + op: 'date_histogram', + columns: {}, + suggestedPriority: undefined, + field: dateField, + indexPattern, + layerId, + }); + + const col1 = generateId(); + const col2 = generateId(); + + return { + indexPatternId: indexPattern.id, + columns: { + [col1]: dateColumn, + [col2]: column, + }, + columnOrder: [col1, col2], + }; +} + +export function getDatasourceSuggestionsFromCurrentState( + state: IndexPatternPrivateState +): Array> { + return _.flatten( + Object.entries(state.layers || {}) + .filter(([_id, layer]) => layer.columnOrder.length) + .map(([layerId, layer], index) => { + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const [buckets, metrics] = separateBucketColumns(layer); + const timeDimension = layer.columnOrder.find( + columnId => + layer.columns[columnId].isBucketed && layer.columns[columnId].dataType === 'date' + ); + + const suggestions: Array> = []; + if (metrics.length === 0) { + // intermediary chart without metric, don't try to suggest reduced versions + suggestions.push( + buildSuggestion({ + state, + layerId, + changeType: 'unchanged', + }) + ); + } else if (buckets.length === 0) { + if (indexPattern.timeFieldName) { + // suggest current metric over time if there is a default time field + suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId)); + } + suggestions.push(...createAlternativeMetricSuggestions(indexPattern, layerId, state)); + // also suggest simple current state + suggestions.push( + buildSuggestion({ + state, + layerId, + changeType: 'unchanged', + }) + ); + } else { + suggestions.push(...createSimplifiedTableSuggestions(state, layerId)); + + if (!timeDimension && indexPattern.timeFieldName) { + // suggest current configuration over time if there is a default time field + // and no time dimension yet + suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId)); + } + + if (buckets.length === 2) { + suggestions.push(createChangedNestingSuggestion(state, layerId)); + } + } + + return suggestions; + }) + ); +} + +function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId: string) { + const layer = state.layers[layerId]; + const [firstBucket, secondBucket, ...rest] = layer.columnOrder; + const updatedLayer = { ...layer, columnOrder: [secondBucket, firstBucket, ...rest] }; + return buildSuggestion({ + state, + layerId, + updatedLayer, + label: i18n.translate('xpack.lens.indexpattern.suggestions.nestingChangeLabel', { + defaultMessage: 'Nest within {operation}', + values: { + operation: layer.columns[secondBucket].label, + }, + }), + changeType: 'extended', + }); +} + +function createAlternativeMetricSuggestions( + indexPattern: IndexPattern, + layerId: string, + state: IndexPatternPrivateState +) { + const layer = state.layers[layerId]; + const suggestions: Array> = []; + layer.columnOrder.forEach(columnId => { + const column = layer.columns[columnId]; + if (!hasField(column)) { + return; + } + const field = indexPattern.fields.find(({ name }) => column.sourceField === name)!; + const alternativeMetricOperations = getOperationTypesForField(field).filter( + operationType => operationType !== column.operationType + ); + if (alternativeMetricOperations.length === 0) { + return; + } + const newId = generateId(); + const newColumn = buildColumn({ + op: alternativeMetricOperations[0], + columns: layer.columns, + indexPattern, + layerId, + field, + suggestedPriority: undefined, + }); + const updatedLayer = { + ...layer, + columns: { [newId]: newColumn }, + columnOrder: [newId], + }; + suggestions.push( + buildSuggestion({ + state, + layerId, + updatedLayer, + changeType: 'initial', + }) + ); + }); + return suggestions; +} + +function createSuggestionWithDefaultDateHistogram( + state: IndexPatternPrivateState, + layerId: string +) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const newId = generateId(); + const [buckets, metrics] = separateBucketColumns(layer); + const timeColumn = buildColumn({ + layerId, + op: 'date_histogram', + indexPattern, + columns: layer.columns, + field: indexPattern.fields.find(({ name }) => name === indexPattern.timeFieldName), + suggestedPriority: undefined, + }); + const updatedLayer = { + ...layer, + columns: { ...layer.columns, [newId]: timeColumn }, + columnOrder: [...buckets, newId, ...metrics], + }; + return buildSuggestion({ + state, + layerId, + updatedLayer, + label: i18n.translate('xpack.lens.indexpattern.suggestions.overTimeLabel', { + defaultMessage: 'Over time', + }), + changeType: 'extended', + }); +} + +function createSimplifiedTableSuggestions(state: IndexPatternPrivateState, layerId: string) { + const layer = state.layers[layerId]; + + const [availableBucketedColumns, availableMetricColumns] = separateBucketColumns(layer); + + return _.flatten( + availableBucketedColumns.map((_col, index) => { + // build suggestions with fewer buckets + const bucketedColumns = availableBucketedColumns.slice(0, index + 1); + const allMetricsSuggestion = { + ...layer, + columnOrder: [...bucketedColumns, ...availableMetricColumns], + }; + + if (availableMetricColumns.length > 1) { + return [ + allMetricsSuggestion, + { ...layer, columnOrder: [...bucketedColumns, availableMetricColumns[0]] }, + ]; + } else { + return allMetricsSuggestion; + } + }) + ) + .concat( + availableMetricColumns.map(columnId => { + // build suggestions with only metrics + return { ...layer, columnOrder: [columnId] }; + }) + ) + .map(updatedLayer => { + return buildSuggestion({ + state, + layerId, + updatedLayer, + changeType: + layer.columnOrder.length === updatedLayer.columnOrder.length ? 'unchanged' : 'reduced', + label: + updatedLayer.columnOrder.length === 1 + ? getMetricSuggestionTitle(updatedLayer, availableMetricColumns.length === 1) + : undefined, + }); + }); +} + +function getMetricSuggestionTitle(layer: IndexPatternLayer, onlyMetric: boolean) { + const { operationType, label } = Object.values(layer.columns)[0]; + return i18n.translate('xpack.lens.indexpattern.suggestions.overallLabel', { + defaultMessage: '{operation} overall', + values: { + operation: onlyMetric ? operationDefinitionMap[operationType].displayName : label, + }, + description: + 'Title of a suggested chart containing only a single numerical metric calculated over all available data', + }); +} + +function separateBucketColumns(layer: IndexPatternLayer) { + return partition(layer.columnOrder, columnId => layer.columns[columnId].isBucketed); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx new file mode 100644 index 0000000000000..755ed02904e31 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.test.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiComboBox } from '@elastic/eui'; +import { IndexPatternPrivateState } from './indexpattern'; +import { act } from 'react-dom/test-utils'; +import { IndexPatternLayerPanelProps, LayerPanel } from './layerpanel'; +import { updateLayerIndexPattern } from './state_helpers'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; + +jest.mock('ui/new_platform'); +jest.mock('./state_helpers'); + +const initialState: IndexPatternPrivateState = { + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'My Op', + dataType: 'string', + isBucketed: true, + operationType: 'terms', + sourceField: 'source', + params: { + size: 5, + orderDirection: 'asc', + orderBy: { + type: 'alphabetical', + }, + }, + }, + col2: { + label: 'My Op', + dataType: 'number', + isBucketed: false, + operationType: 'avg', + sourceField: 'memory', + }, + }, + }, + }, + indexPatterns: { + '1': { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'unsupported', + type: 'geo', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + '2': { + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + histogram: { + agg: 'histogram', + interval: 1000, + }, + max: { + agg: 'max', + }, + min: { + agg: 'min', + }, + sum: { + agg: 'sum', + }, + }, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }, + ], + }, + '3': { + id: '3', + title: 'my-compatible-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, + }, +}; +describe('Layer Data Panel', () => { + let defaultProps: IndexPatternLayerPanelProps; + + beforeEach(() => { + defaultProps = { + layerId: 'first', + state: initialState, + setState: jest.fn(), + }; + }); + + function clickLabel(instance: ReactWrapper) { + act(() => { + instance + .find('[data-test-subj="lns_layerIndexPatternLabel"]') + .first() + .simulate('click'); + }); + + instance.update(); + } + + it('should list all index patterns but the current one', () => { + const instance = mount(); + clickLabel(instance); + + expect( + instance + .find(EuiComboBox) + .prop('options')! + .map(option => option.label) + ).toEqual(['my-fake-restricted-pattern', 'my-compatible-pattern']); + }); + + it('should indicate whether the switch can be made without lossing data', () => { + const instance = mount(); + clickLabel(instance); + + expect( + instance + .find(EuiComboBox) + .prop('options')! + .map(option => (option.value as { isTransferable: boolean }).isTransferable) + ).toEqual([false, true]); + }); + + it('should switch data panel to target index pattern', () => { + const instance = mount(); + clickLabel(instance); + + act(() => { + instance.find(EuiComboBox).prop('onChange')!([ + { + label: 'my-compatible-pattern', + value: defaultProps.state.indexPatterns['3'], + }, + ]); + }); + + expect(defaultProps.setState).toHaveBeenCalledWith( + expect.objectContaining({ + currentIndexPatternId: '3', + }) + ); + }); + + it('should switch using updateLayerIndexPattern', () => { + const instance = mount(); + clickLabel(instance); + + act(() => { + instance.find(EuiComboBox).prop('onChange')!([ + { + label: 'my-compatible-pattern', + value: defaultProps.state.indexPatterns['3'], + }, + ]); + }); + + expect(updateLayerIndexPattern).toHaveBeenCalledWith( + defaultProps.state.layers.first, + defaultProps.state.indexPatterns['3'] + ); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx new file mode 100644 index 0000000000000..c221371dd6aa2 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/layerpanel.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState } from 'react'; +import { + EuiComboBox, + // @ts-ignore + EuiHighlight, + EuiButtonEmpty, + EuiIcon, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import { DatasourceLayerPanelProps, StateSetter } from '../types'; +import { IndexPatternPrivateState, IndexPatternLayer } from './indexpattern'; +import { isLayerTransferable, updateLayerIndexPattern } from './state_helpers'; + +export interface IndexPatternLayerPanelProps extends DatasourceLayerPanelProps { + state: IndexPatternPrivateState; + setState: StateSetter; +} + +function LayerPanelChooser({ + indexPatterns, + layer, + onChangeIndexPattern, + onExitChooser, +}: { + indexPatterns: IndexPatternPrivateState['indexPatterns']; + layer: IndexPatternLayer; + onChangeIndexPattern: (newId: string) => void; + onExitChooser: () => void; +}) { + const currentIndexPatternId = layer.indexPatternId; + const indexPatternList = Object.values(indexPatterns) + .filter(indexPattern => indexPattern.id !== layer.indexPatternId) + .map(indexPattern => ({ + ...indexPattern, + isTransferable: isLayerTransferable(layer, indexPattern), + })); + return ( + ({ + label: indexPattern.title, + value: indexPattern, + }))} + inputRef={el => { + if (el) { + el.focus(); + } + }} + selectedOptions={[ + { + label: indexPatterns[currentIndexPatternId].title, + value: indexPatterns[currentIndexPatternId].id, + }, + ]} + singleSelection={{ asPlainText: true }} + isClearable={false} + onBlur={onExitChooser} + onChange={choices => { + onChangeIndexPattern(choices[0].value!.id); + }} + renderOption={(option, searchValue, contentClassName) => { + const { label, value } = option; + return ( + + {value && value.isTransferable ? ( + + ) : ( + + )} + {label} + + ); + }} + /> + ); +} + +export function LayerPanel({ state, setState, layerId }: IndexPatternLayerPanelProps) { + const [isChooserOpen, setChooserOpen] = useState(false); + + return ( + + + {isChooserOpen ? ( + + { + setChooserOpen(false); + }} + onChangeIndexPattern={newId => { + setState({ + ...state, + currentIndexPatternId: newId, + layers: { + ...state.layers, + [layerId]: updateLayerIndexPattern( + state.layers[layerId], + state.indexPatterns[newId] + ), + }, + }); + + setChooserOpen(false); + }} + /> + + ) : ( + + setChooserOpen(true)} + data-test-subj="lns_layerIndexPatternLabel" + > + {state.indexPatterns[state.layers[layerId].indexPatternId].title} + + + )} + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts new file mode 100644 index 0000000000000..0f6c14415ccf5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/loader.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Chrome } from 'ui/chrome'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectAttributes } from 'src/core/server'; +import { NotificationsSetup } from 'src/core/public'; +import { IndexPatternField } from './indexpattern'; + +interface SavedIndexPatternAttributes extends SavedObjectAttributes { + title: string; + timeFieldName: string | null; + fields: string; + fieldFormatMap: string; + typeMeta: string; +} + +interface SavedRestrictionsObject { + aggs: Record< + string, + Record< + string, + { + agg: string; + fixed_interval?: string; + calendar_interval?: string; + delay?: string; + time_zone?: string; + } + > + >; +} +type SavedRestrictionsInfo = SavedRestrictionsObject | undefined; + +export const getIndexPatterns = (chrome: Chrome, notifications: NotificationsSetup) => { + const savedObjectsClient = chrome.getSavedObjectsClient(); + return savedObjectsClient + .find({ + type: 'index-pattern', + perPage: 1000, // TODO: Paginate index patterns + }) + .then(resp => { + return resp.savedObjects.map(savedObject => { + const { id, attributes, type } = savedObject; + return { + ...attributes, + id, + type, + title: attributes.title, + fields: (JSON.parse(attributes.fields) as IndexPatternField[]).filter( + ({ type: fieldType, esTypes }) => + fieldType !== 'string' || (esTypes && esTypes.includes('keyword')) + ), + typeMeta: attributes.typeMeta + ? (JSON.parse(attributes.typeMeta) as SavedRestrictionsInfo) + : undefined, + fieldFormatMap: attributes.fieldFormatMap + ? JSON.parse(attributes.fieldFormatMap) + : undefined, + }; + }); + }) + .catch(err => { + notifications.toasts.addDanger('Failed to load index patterns'); + }); +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts new file mode 100644 index 0000000000000..79d88136b5fa1 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/mocks.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DragContextState } from '../drag_drop'; +import { IndexPattern } from './indexpattern'; + +export const createMockedIndexPattern = (): IndexPattern => ({ + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'start_date', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + { + name: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], +}); + +export const createMockedRestrictedIndexPattern = () => ({ + id: '2', + title: 'my-fake-restricted-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + typeMeta: { + params: { + rollup_index: 'my-fake-index-pattern', + }, + aggs: { + terms: { + source: { + agg: 'terms', + }, + }, + date_histogram: { + timestamp: { + agg: 'date_histogram', + fixed_interval: '1d', + delay: '7d', + time_zone: 'UTC', + }, + }, + histogram: { + bytes: { + agg: 'histogram', + interval: 1000, + }, + }, + avg: { + bytes: { + agg: 'avg', + }, + }, + max: { + bytes: { + agg: 'max', + }, + }, + min: { + bytes: { + agg: 'min', + }, + }, + sum: { + bytes: { + agg: 'sum', + }, + }, + }, + }, +}); + +export function createMockedDragDropContext(): jest.Mocked { + return { + dragging: undefined, + setDragging: jest.fn(), + }; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts new file mode 100644 index 0000000000000..eeb19bba24006 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/__mocks__/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const actual = jest.requireActual('../../operations'); + +jest.spyOn(actual.operationDefinitionMap.date_histogram, 'paramEditor'); +jest.spyOn(actual.operationDefinitionMap.terms, 'onOtherColumnChanged'); + +export const { + getAvailableOperationsByMetadata, + buildColumn, + getOperations, + getOperationDisplay, + getOperationTypesForField, + getOperationResultType, + operationDefinitionMap, + operationDefinitions, + isColumnTransferable, + changeField, +} = actual; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/column_types.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/column_types.ts new file mode 100644 index 0000000000000..ed0e2fb3c96c5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/column_types.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Operation, DimensionPriority } from '../../../types'; + +/** + * This is the root type of a column. If you are implementing a new + * operation, extend your column type on `BaseIndexPatternColumn` to make + * sure it's matching all the basic requirements. + */ +export interface BaseIndexPatternColumn extends Operation { + // Private + operationType: string; + suggestedPriority?: DimensionPriority; +} + +/** + * Base type for a column that doesn't have additional parameter. + * + * * `TOperationType` should be a string type containing just the type + * of the operation (e.g. `"sum"`). + * * `TBase` is the base column interface the operation type is set for - + * by default this is `FieldBasedIndexPatternColumn`, so + * `ParameterlessIndexPatternColumn<'foo'>` will give you a column type + * for an operation named foo that operates on a field. + * By passing in another `TBase` (e.g. just `BaseIndexPatternColumn`), + * you can also create other column types. + */ +export type ParameterlessIndexPatternColumn< + TOperationType extends string, + TBase extends BaseIndexPatternColumn = FieldBasedIndexPatternColumn +> = TBase & { operationType: TOperationType }; + +export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn { + sourceField: string; + suggestedPriority?: DimensionPriority; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx new file mode 100644 index 0000000000000..68a36787ec189 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/count.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { OperationDefinition } from '.'; +import { ParameterlessIndexPatternColumn, BaseIndexPatternColumn } from './column_types'; + +const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', { + defaultMessage: 'Count of documents', +}); + +export type CountIndexPatternColumn = ParameterlessIndexPatternColumn< + 'count', + BaseIndexPatternColumn +>; + +export const countOperation: OperationDefinition = { + type: 'count', + priority: 2, + displayName: i18n.translate('xpack.lens.indexPattern.count', { + defaultMessage: 'Count', + }), + getPossibleOperationForDocument: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + buildColumn({ suggestedPriority }) { + return { + label: countLabel, + dataType: 'number', + operationType: 'count', + suggestedPriority, + isBucketed: false, + scale: 'ratio', + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'count', + schema: 'metric', + params: {}, + }), + isTransferable: () => { + return true; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx new file mode 100644 index 0000000000000..ac9c9f48c7acb --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.test.tsx @@ -0,0 +1,477 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { DateHistogramIndexPatternColumn } from './date_histogram'; +import { dateHistogramOperation } from '.'; +import { shallow } from 'enzyme'; +import { IndexPatternPrivateState } from '../../indexpattern'; +import { EuiRange, EuiSwitch } from '@elastic/eui'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { Storage } from 'ui/storage'; +import { createMockedIndexPattern } from '../../mocks'; + +jest.mock('ui/new_platform'); + +describe('date_histogram', () => { + let state: IndexPatternPrivateState; + const InlineOptions = dateHistogramOperation.paramEditor!; + + beforeEach(() => { + state = { + currentIndexPatternId: '1', + showEmptyFields: false, + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + ], + }, + 2: { + id: '2', + title: 'Mock Indexpattern 2', + fields: [ + { + name: 'other_timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + ], + }, + }, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'w', + }, + sourceField: 'timestamp', + }, + }, + }, + second: { + indexPatternId: '2', + columnOrder: ['col2'], + columns: { + col2: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'd', + }, + sourceField: 'other_timestamp', + }, + }, + }, + third: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'auto', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + }); + + describe('buildColumn', () => { + it('should create column object with auto interval for primary time field', () => { + const column = dateHistogramOperation.buildColumn({ + columns: {}, + suggestedPriority: 0, + layerId: 'first', + indexPattern: createMockedIndexPattern(), + field: { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + }); + expect(column.params.interval).toEqual('auto'); + }); + + it('should create column object with manual interval for non-primary time fields', () => { + const column = dateHistogramOperation.buildColumn({ + columns: {}, + suggestedPriority: 0, + layerId: 'first', + indexPattern: createMockedIndexPattern(), + field: { + name: 'start_date', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + }, + }); + expect(column.params.interval).toEqual('d'); + }); + + it('should create column object with restrictions', () => { + const column = dateHistogramOperation.buildColumn({ + columns: {}, + suggestedPriority: 0, + layerId: 'first', + indexPattern: createMockedIndexPattern(), + field: { + name: 'timestamp', + type: 'date', + esTypes: ['date'], + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'UTC', + calendar_interval: '1y', + }, + }, + }, + }); + expect(column.params.interval).toEqual('1y'); + expect(column.params.timeZone).toEqual('UTC'); + }); + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = dateHistogramOperation.toEsAggsConfig( + state.layers.first.columns.col1 as DateHistogramIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + interval: 'w', + field: 'timestamp', + }), + }) + ); + }); + }); + + describe('onFieldChange', () => { + it('should change correctly without auto interval', () => { + const oldColumn: DateHistogramIndexPatternColumn = { + operationType: 'date_histogram', + sourceField: 'timestamp', + label: 'Date over timestamp', + isBucketed: true, + dataType: 'date', + params: { + interval: 'd', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newDateField = indexPattern.fields.find(i => i.name === 'start_date')!; + + const column = dateHistogramOperation.onFieldChange(oldColumn, indexPattern, newDateField); + expect(column).toHaveProperty('sourceField', 'start_date'); + expect(column).toHaveProperty('params.interval', 'd'); + expect(column.label).toContain('start_date'); + }); + + it('should change interval from auto when switching to a non primary time field', () => { + const oldColumn: DateHistogramIndexPatternColumn = { + operationType: 'date_histogram', + sourceField: 'timestamp', + label: 'Date over timestamp', + isBucketed: true, + dataType: 'date', + params: { + interval: 'auto', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newDateField = indexPattern.fields.find(i => i.name === 'start_date')!; + + const column = dateHistogramOperation.onFieldChange(oldColumn, indexPattern, newDateField); + expect(column).toHaveProperty('sourceField', 'start_date'); + expect(column).toHaveProperty('params.interval', 'd'); + expect(column.label).toContain('start_date'); + }); + }); + + describe('transfer', () => { + it('should adjust interval and time zone params if that is necessary due to restrictions', () => { + const transferedColumn = dateHistogramOperation.transfer!( + { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'dateField', + params: { + interval: 'd', + }, + }, + { + title: '', + id: '', + fields: [ + { + name: 'dateField', + type: 'date', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'CET', + calendar_interval: 'w', + }, + }, + }, + ], + } + ); + expect(transferedColumn).toEqual( + expect.objectContaining({ + params: { + interval: 'w', + timeZone: 'CET', + }, + }) + ); + }); + + it('should remove time zone param and normalize interval param', () => { + const transferedColumn = dateHistogramOperation.transfer!( + { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'dateField', + params: { + interval: '20s', + }, + }, + { + title: '', + id: '', + fields: [ + { + name: 'dateField', + type: 'date', + aggregatable: true, + searchable: true, + }, + ], + } + ); + expect(transferedColumn).toEqual( + expect.objectContaining({ + params: { + interval: 'M', + timeZone: undefined, + }, + }) + ); + }); + }); + + describe('param editor', () => { + it('should render current value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(1); + }); + + it('should render current value for other index pattern', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(2); + }); + + it('should render disabled switch and no level of detail control for auto interval', () => { + const instance = shallow( + + ); + expect(instance.find(EuiRange).exists()).toBe(false); + expect(instance.find(EuiSwitch).prop('checked')).toBe(false); + }); + + it('should allow switching to manual interval', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + instance.find(EuiSwitch).prop('onChange')!({ + target: { checked: true }, + } as React.ChangeEvent); + expect(setStateSpy).toHaveBeenCalled(); + const newState = setStateSpy.mock.calls[0][0]; + expect(newState).toHaveProperty('layers.third.columns.col1.params.interval', 'd'); + }); + + it('should update state with the interval value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance.find(EuiRange).prop('onChange')!({ + target: { + value: '2', + }, + } as React.ChangeEvent); + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + layers: { + ...state.layers, + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + ...state.layers.first.columns.col1, + params: { + interval: 'd', + }, + }, + }, + }, + }, + }); + }); + + it('should not render options if they are restricted', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx new file mode 100644 index 0000000000000..9558a141ad7a0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/date_histogram.tsx @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiForm, EuiFormRow, EuiRange, EuiSwitch } from '@elastic/eui'; +import { IndexPattern } from '../../indexpattern'; +import { updateColumnParam } from '../../state_helpers'; +import { OperationDefinition } from '.'; +import { FieldBasedIndexPatternColumn } from './column_types'; + +type PropType = C extends React.ComponentType ? P : unknown; + +const autoInterval = 'auto'; +const supportedIntervals = ['M', 'w', 'd', 'h']; +const defaultCustomInterval = supportedIntervals[2]; + +// Add ticks to EuiRange component props +const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< + PropType & { + ticks?: Array<{ + label: string; + value: number; + }>; + } +>; + +function ofName(name: string) { + return i18n.translate('xpack.lens.indexPattern.dateHistogramOf', { + defaultMessage: 'Date Histogram of {name}', + values: { name }, + }); +} + +function supportsAutoInterval(fieldName: string, indexPattern: IndexPattern): boolean { + return indexPattern.timeFieldName ? indexPattern.timeFieldName === fieldName : false; +} + +export interface DateHistogramIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'date_histogram'; + params: { + interval: string; + timeZone?: string; + }; +} + +export const dateHistogramOperation: OperationDefinition = { + type: 'date_histogram', + displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', { + defaultMessage: 'Date Histogram', + }), + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { + if ( + type === 'date' && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions.date_histogram) + ) { + return { + dataType: 'date', + isBucketed: true, + scale: 'interval', + }; + } + }, + buildColumn({ suggestedPriority, field, indexPattern }) { + let interval = indexPattern.timeFieldName === field.name ? autoInterval : defaultCustomInterval; + let timeZone: string | undefined; + if (field.aggregationRestrictions && field.aggregationRestrictions.date_histogram) { + interval = (field.aggregationRestrictions.date_histogram.calendar_interval || + field.aggregationRestrictions.date_histogram.fixed_interval) as string; + timeZone = field.aggregationRestrictions.date_histogram.time_zone; + } + return { + label: ofName(field.name), + dataType: 'date', + operationType: 'date_histogram', + suggestedPriority, + sourceField: field.name, + isBucketed: true, + scale: 'interval', + params: { + interval, + timeZone, + }, + }; + }, + isTransferable: (column, newIndexPattern) => { + const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); + + return Boolean( + newField && + newField.type === 'date' && + newField.aggregatable && + (!newField.aggregationRestrictions || newField.aggregationRestrictions.date_histogram) + ); + }, + transfer: (column, newIndexPattern) => { + const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); + if ( + newField && + newField.aggregationRestrictions && + newField.aggregationRestrictions.date_histogram + ) { + const restrictions = newField.aggregationRestrictions.date_histogram; + return { + ...column, + params: { + ...column.params, + timeZone: restrictions.time_zone, + // TODO this rewrite logic is simplified - if the current interval is a multiple of + // the restricted interval, we could carry it over directly. However as the current + // UI does not allow to select multiples of an interval anyway, this is not included yet. + // If the UI allows to pick more complicated intervals, this should be re-visited. + interval: (newField.aggregationRestrictions.date_histogram.calendar_interval || + newField.aggregationRestrictions.date_histogram.fixed_interval) as string, + }, + }; + } else { + return { + ...column, + params: { + ...column.params, + // TODO remove this once it's possible to specify free intervals instead of picking from a list + interval: supportedIntervals.includes(column.params.interval) + ? column.params.interval + : supportedIntervals[0], + timeZone: undefined, + }, + }; + } + }, + onFieldChange: (oldColumn, indexPattern, field) => { + return { + ...oldColumn, + label: ofName(field.name), + sourceField: field.name, + params: { + ...oldColumn.params, + // If we have an "auto" interval but the field we're switching to doesn't support auto intervals + // we use the default custom interval instead + interval: + oldColumn.params.interval === 'auto' && !supportsAutoInterval(field.name, indexPattern) + ? defaultCustomInterval + : oldColumn.params.interval, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'date_histogram', + schema: 'segment', + params: { + field: column.sourceField, + time_zone: column.params.timeZone, + useNormalizedEsInterval: true, + interval: column.params.interval, + drop_partials: false, + min_doc_count: 1, + extended_bounds: {}, + }, + }), + paramEditor: ({ state, setState, currentColumn: currentColumn, layerId }) => { + const field = + currentColumn && + state.indexPatterns[state.layers[layerId].indexPatternId].fields.find( + currentField => currentField.name === currentColumn.sourceField + ); + const intervalIsRestricted = + field!.aggregationRestrictions && field!.aggregationRestrictions.date_histogram; + const fieldAllowsAutoInterval = + state.indexPatterns[state.layers[layerId].indexPatternId].timeFieldName === field!.name; + + function intervalToNumeric(interval: string) { + return supportedIntervals.indexOf(interval); + } + + function numericToInterval(i: number) { + return supportedIntervals[i]; + } + + function onChangeAutoInterval(ev: React.ChangeEvent) { + const interval = ev.target.checked ? defaultCustomInterval : autoInterval; + setState( + updateColumnParam({ state, layerId, currentColumn, paramName: 'interval', value: interval }) + ); + } + + return ( + + {fieldAllowsAutoInterval && ( + + + + )} + {currentColumn.params.interval !== autoInterval && ( + + {intervalIsRestricted ? ( + + ) : ( + ({ + label: interval, + value: index, + }))} + onChange={( + e: React.ChangeEvent | React.MouseEvent + ) => + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'interval', + value: numericToInterval(Number((e.target as HTMLInputElement).value)), + }) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.dateHistogram.interval', { + defaultMessage: 'Level of detail', + })} + /> + )} + + )} + + ); + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.tsx new file mode 100644 index 0000000000000..8145ae0d5be03 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { FilterRatioIndexPatternColumn } from './filter_ratio'; +import { filterRatioOperation } from '.'; +import { IndexPatternPrivateState } from '../../indexpattern'; +import { Storage } from 'ui/storage'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { QueryBarInput } from '../../../../../../../../src/legacy/core_plugins/data/public/query'; +import { createMockedIndexPattern } from '../../mocks'; + +jest.mock('ui/new_platform'); + +describe('filter_ratio', () => { + let state: IndexPatternPrivateState; + let storageMock: Storage; + const InlineOptions = filterRatioOperation.paramEditor!; + + beforeEach(() => { + state = { + indexPatterns: { + 1: { + id: '1', + title: 'Mock Indexpattern', + fields: [], + }, + }, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Filter Ratio', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'filter_ratio', + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + }, + }, + }, + }, + }; + + storageMock = {} as Storage; + }); + + describe('buildColumn', () => { + it('should create column object with default params', () => { + const column = filterRatioOperation.buildColumn({ + layerId: 'first', + columns: {}, + suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), + }); + expect(column.params.numerator).toEqual({ query: '', language: 'kuery' }); + expect(column.params.denominator).toEqual({ query: '', language: 'kuery' }); + }); + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = filterRatioOperation.toEsAggsConfig( + state.layers.first.columns.col1 as FilterRatioIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + filters: [ + { + input: { query: '', language: 'kuery' }, + label: '', + }, + { + input: { query: '', language: 'kuery' }, + label: '', + }, + ], + }), + }) + ); + }); + }); + + describe('param editor', () => { + it('should render current value', () => { + expect(() => { + shallowWithIntl( + + ); + }).not.toThrow(); + }); + + it('should show only the numerator by default', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find(QueryBarInput)).toHaveLength(1); + expect(wrapper.find(QueryBarInput).prop('indexPatterns')).toEqual(['Mock Indexpattern']); + }); + + it('should update the state when typing into the query bar', () => { + const setState = jest.fn(); + const wrapper = shallowWithIntl( + + ); + + wrapper.find(QueryBarInput).prop('onChange')!({ + query: 'geo.src : "US"', + language: 'kuery', + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + ...state.layers, + first: { + ...state.layers.first, + columns: { + col1: { + ...state.layers.first.columns.col1, + params: { + numerator: { query: 'geo.src : "US"', language: 'kuery' }, + denominator: { query: '', language: 'kuery' }, + }, + }, + }, + }, + }, + }); + }); + + it('should allow editing the denominator', () => { + const setState = jest.fn(); + const wrapper = shallowWithIntl( + + ); + + act(() => { + wrapper + .find('[data-test-subj="lns-indexPatternFilterRatio-showDenominatorButton"]') + .first() + .simulate('click'); + }); + + expect(wrapper.find(QueryBarInput)).toHaveLength(2); + + wrapper + .find(QueryBarInput) + .at(1) + .prop('onChange')!({ + query: 'geo.src : "US"', + language: 'kuery', + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + ...state.layers, + first: { + ...state.layers.first, + columns: { + col1: { + ...state.layers.first.columns.col1, + params: { + numerator: { query: '', language: 'kuery' }, + denominator: { query: 'geo.src : "US"', language: 'kuery' }, + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx new file mode 100644 index 0000000000000..90e959523b328 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/filter_ratio.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiFormRow } from '@elastic/eui'; +import { + Query, + QueryBarInput, +} from '../../../../../../../../src/legacy/core_plugins/data/public/query'; +import { updateColumnParam } from '../../state_helpers'; +import { OperationDefinition } from '.'; +import { BaseIndexPatternColumn } from './column_types'; + +const filterRatioLabel = i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', +}); + +export interface FilterRatioIndexPatternColumn extends BaseIndexPatternColumn { + operationType: 'filter_ratio'; + params: { + numerator: Query; + denominator: Query; + }; +} + +export const filterRatioOperation: OperationDefinition = { + type: 'filter_ratio', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.filterRatio', { + defaultMessage: 'Filter Ratio', + }), + getPossibleOperationForDocument: () => { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + buildColumn({ suggestedPriority }) { + return { + label: filterRatioLabel, + dataType: 'number', + operationType: 'filter_ratio', + suggestedPriority, + isBucketed: false, + scale: 'ratio', + params: { + numerator: { language: 'kuery', query: '' }, + denominator: { language: 'kuery', query: '' }, + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'filters', + schema: 'segment', + params: { + filters: [ + { + input: column.params.numerator, + label: '', + }, + { + input: column.params.denominator, + label: '', + }, + ], + }, + }), + isTransferable: (column, newIndexPattern) => { + // TODO parse the KQL tree and check whether this would work out + return false; + }, + paramEditor: ({ + state, + setState, + currentColumn, + uiSettings, + storage, + layerId, + savedObjectsClient, + http, + }) => { + const [hasDenominator, setDenominator] = useState(false); + + return ( +
+ + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'numerator', + value: newQuery, + }) + ); + }} + /> + + + + {hasDenominator ? ( + { + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'denominator', + value: newQuery, + }) + ); + }} + /> + ) : ( + <> + + + + setDenominator(true)} + > + + + + + )} + +
+ ); + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/index.ts new file mode 100644 index 0000000000000..93d9dd68d1c61 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/index.ts @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Storage } from 'ui/storage'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { termsOperation } from './terms'; +import { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; +import { dateHistogramOperation } from './date_histogram'; +import { countOperation } from './count'; +import { filterRatioOperation } from './filter_ratio'; +import { DimensionPriority, StateSetter, OperationMetadata } from '../../../types'; +import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types'; +import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../indexpattern'; + +// List of all operation definitions registered to this data source. +// If you want to implement a new operation, add it to this array and +// its type will get propagated to everything else +const internalOperationDefinitions = [ + termsOperation, + dateHistogramOperation, + minOperation, + maxOperation, + averageOperation, + sumOperation, + countOperation, + filterRatioOperation, +]; + +export { termsOperation } from './terms'; +export { dateHistogramOperation } from './date_histogram'; +export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics'; +export { countOperation } from './count'; +export { filterRatioOperation } from './filter_ratio'; + +/** + * Properties passed to the operation-specific part of the popover editor + */ +export interface ParamEditorProps { + currentColumn: C; + state: IndexPatternPrivateState; + setState: StateSetter; + columnId: string; + layerId: string; + uiSettings: UiSettingsClientContract; + storage: Storage; + savedObjectsClient: SavedObjectsClientContract; + http: HttpServiceBase; +} + +interface BaseOperationDefinitionProps { + type: C['operationType']; + /** + * The priority of the operation. If multiple operations are possible in + * a given scenario (e.g. the user dragged a field into the workspace), + * the operation with the highest priority is picked. + */ + priority?: number; + /** + * The name of the operation shown to the user (e.g. in the popover editor). + * Should be i18n-ified. + */ + displayName: string; + /** + * This function is called if another column in the same layer changed or got removed. + * Can be used to update references to other columns (e.g. for sorting). + * Based on the current column and the other updated columns, this function has to + * return an updated column. If not implemented, the `id` function is used instead. + */ + onOtherColumnChanged?: ( + currentColumn: C, + columns: Partial> + ) => C; + /** + * React component for operation specific settings shown in the popover editor + */ + paramEditor?: React.ComponentType>; + /** + * Function turning a column into an agg config passed to the `esaggs` function + * together with the agg configs returned from other columns. + */ + toEsAggsConfig: (column: C, columnId: string) => unknown; + /** + * Returns true if the `column` can also be used on `newIndexPattern`. + * If this function returns false, the column is removed when switching index pattern + * for a layer + */ + isTransferable: (column: C, newIndexPattern: IndexPattern) => boolean; + /** + * Transfering a column to another index pattern. This can be used to + * adjust operation specific settings such as reacting to aggregation restrictions + * present on the new index pattern. + */ + transfer?: (column: C, newIndexPattern: IndexPattern) => C; +} + +interface BaseBuildColumnArgs { + suggestedPriority: DimensionPriority | undefined; + layerId: string; + columns: Partial>; + indexPattern: IndexPattern; +} + +interface FieldBasedOperationDefinition + extends BaseOperationDefinitionProps { + /** + * Returns the meta data of the operation if applied to the given field. Undefined + * if the field is not applicable to the operation. + */ + getPossibleOperationForField: (field: IndexPatternField) => OperationMetadata | undefined; + /** + * Builds the column object for the given parameters. Should include default p + */ + buildColumn: ( + arg: BaseBuildColumnArgs & { + field: IndexPatternField; + } + ) => C; + /** + * This method will be called if the user changes the field of an operation. + * You must implement it and return the new column after the field change. + * The most simple implementation will just change the field on the column, and keep + * the rest the same. Some implementations might want to change labels, or their parameters + * when changing the field. + * + * This will only be called for switching the field, not for initially selecting a field. + * + * See {@link OperationDefinition#transfer} for controlling column building when switching an + * index pattern not just a field. + * + * @param oldColumn The column before the user changed the field. + * @param indexPattern The index pattern that field is on. + * @param field The field that the user changed to. + */ + onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C; +} + +interface DocumentBasedOperationDefinition + extends BaseOperationDefinitionProps { + /** + * Returns the meta data of the operation if applied to documents of the given index pattern. + * Undefined if the operation is not applicable to the index pattern. + */ + getPossibleOperationForDocument: (indexPattern: IndexPattern) => OperationMetadata | undefined; + buildColumn: (arg: BaseBuildColumnArgs) => C; +} + +/** + * Shape of an operation definition. If the type parameter of the definition + * indicates a field based column, `getPossibleOperationForField` has to be + * specified, otherwise `getPossibleOperationForDocument` has to be defined. + */ +export type OperationDefinition< + C extends BaseIndexPatternColumn +> = C extends FieldBasedIndexPatternColumn + ? FieldBasedOperationDefinition + : DocumentBasedOperationDefinition; + +// Helper to to infer the column type out of the operation definition. +// This is done to avoid it to have to list out the column types along with +// the operation definition types +type ColumnFromOperationDefinition = D extends OperationDefinition ? C : never; + +/** + * A union type of all available column types. If a column is of an unknown type somewhere + * withing the indexpattern data source it should be typed as `IndexPatternColumn` to make + * typeguards possible that consider all available column types. + */ +export type IndexPatternColumn = ColumnFromOperationDefinition< + (typeof internalOperationDefinitions)[number] +>; + +/** + * A union type of all available operation types. The operation type is a unique id of an operation. + * Each column is assigned to exactly one operation type. + */ +export type OperationType = (typeof internalOperationDefinitions)[number]['type']; + +/** + * This is an operation definition of an unspecified column out of all possible + * column types. It + */ +export type GenericOperationDefinition = + | FieldBasedOperationDefinition + | DocumentBasedOperationDefinition; + +/** + * List of all available operation definitions + */ +export const operationDefinitions = internalOperationDefinitions as GenericOperationDefinition[]; + +/** + * Map of all operation visible to consumers (e.g. the dimension panel). + * This simplifies the type of the map and makes it a simple list of unspecified + * operations definitions, because typescript can't infer the type correctly in most + * situations. + * + * If you need a specifically typed version of an operation (e.g. explicitly working with terms), + * you should import the definition directly from this file + * (e.g. `import { termsOperation } from './operations/definitions'`). This map is + * intended to be used in situations where the operation type is not known during compile time. + */ +export const operationDefinitionMap = internalOperationDefinitions.reduce( + (definitionMap, definition) => ({ ...definitionMap, [definition.type]: definition }), + {} +) as Record; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx new file mode 100644 index 0000000000000..43129f0f1e5d0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/metrics.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { OperationDefinition } from '.'; +import { ParameterlessIndexPatternColumn } from './column_types'; + +function buildMetricOperation>({ + type, + displayName, + ofName, + priority, +}: { + type: T['operationType']; + displayName: string; + ofName: (name: string) => string; + priority?: number; +}) { + return { + type, + priority, + displayName, + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { + if ( + fieldType === 'number' && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions[type]) + ) { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + } + }, + isTransferable: (column, newIndexPattern) => { + const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); + + return Boolean( + newField && + newField.type === 'number' && + newField.aggregatable && + (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) + ); + }, + buildColumn: ({ suggestedPriority, field }) => ({ + label: ofName(field.name), + dataType: 'number', + operationType: type, + suggestedPriority, + sourceField: field.name, + isBucketed: false, + scale: 'ratio', + }), + onFieldChange: (oldColumn, indexPattern, field) => { + return { + ...oldColumn, + label: ofName(field.name), + sourceField: field.name, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: column.operationType, + schema: 'metric', + params: { + field: column.sourceField, + }, + }), + } as OperationDefinition; +} + +export type SumIndexPatternColumn = ParameterlessIndexPatternColumn<'sum'>; +export type AvgIndexPatternColumn = ParameterlessIndexPatternColumn<'avg'>; +export type MinIndexPatternColumn = ParameterlessIndexPatternColumn<'min'>; +export type MaxIndexPatternColumn = ParameterlessIndexPatternColumn<'max'>; + +export const minOperation = buildMetricOperation({ + type: 'min', + displayName: i18n.translate('xpack.lens.indexPattern.min', { + defaultMessage: 'Minimum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.minOf', { + defaultMessage: 'Minimum of {name}', + values: { name }, + }), +}); + +export const maxOperation = buildMetricOperation({ + type: 'max', + displayName: i18n.translate('xpack.lens.indexPattern.max', { + defaultMessage: 'Maximum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.maxOf', { + defaultMessage: 'Maximum of {name}', + values: { name }, + }), +}); + +export const averageOperation = buildMetricOperation({ + type: 'avg', + priority: 2, + displayName: i18n.translate('xpack.lens.indexPattern.avg', { + defaultMessage: 'Average', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.avgOf', { + defaultMessage: 'Average of {name}', + values: { name }, + }), +}); + +export const sumOperation = buildMetricOperation({ + type: 'sum', + priority: 1, + displayName: i18n.translate('xpack.lens.indexPattern.sum', { + defaultMessage: 'Sum', + }), + ofName: name => + i18n.translate('xpack.lens.indexPattern.sumOf', { + defaultMessage: 'Sum of {name}', + values: { name }, + }), +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx new file mode 100644 index 0000000000000..a0c7cdd69ff0d --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.test.tsx @@ -0,0 +1,554 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { IndexPatternPrivateState } from '../../indexpattern'; +import { EuiRange, EuiSelect } from '@elastic/eui'; +import { + UiSettingsClientContract, + SavedObjectsClientContract, + HttpServiceBase, +} from 'src/core/public'; +import { Storage } from 'ui/storage'; +import { createMockedIndexPattern } from '../../mocks'; +import { TermsIndexPatternColumn } from './terms'; +import { termsOperation } from '.'; + +jest.mock('ui/new_platform'); + +describe('terms', () => { + let state: IndexPatternPrivateState; + const InlineOptions = termsOperation.paramEditor!; + + beforeEach(() => { + state = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }, + }, + }; + }); + + describe('toEsAggsConfig', () => { + it('should reflect params correctly', () => { + const esAggsConfig = termsOperation.toEsAggsConfig( + state.layers.first.columns.col1 as TermsIndexPatternColumn, + 'col1' + ); + expect(esAggsConfig).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + orderBy: '_key', + field: 'category', + size: 3, + }), + }) + ); + }); + }); + + describe('onFieldChange', () => { + it('should change correctly to new field', () => { + const oldColumn: TermsIndexPatternColumn = { + operationType: 'terms', + sourceField: 'source', + label: 'Top values of source', + isBucketed: true, + dataType: 'string', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }; + const indexPattern = createMockedIndexPattern(); + const newDateField = indexPattern.fields.find(i => i.name === 'dest')!; + + const column = termsOperation.onFieldChange(oldColumn, indexPattern, newDateField); + expect(column).toHaveProperty('sourceField', 'dest'); + expect(column).toHaveProperty('params.size', 5); + expect(column).toHaveProperty('params.orderBy.type', 'alphabetical'); + expect(column).toHaveProperty('params.orderDirection', 'asc'); + expect(column.label).toContain('dest'); + }); + }); + + describe('getPossibleOperationForField', () => { + it('should return operation with the right type', () => { + expect( + termsOperation.getPossibleOperationForField({ + aggregatable: true, + searchable: true, + name: 'test', + type: 'string', + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }) + ).toEqual({ + dataType: 'string', + isBucketed: true, + scale: 'ordinal', + }); + + expect( + termsOperation.getPossibleOperationForField({ + aggregatable: true, + searchable: true, + name: 'test', + type: 'boolean', + }) + ).toEqual({ + dataType: 'boolean', + isBucketed: true, + scale: 'ordinal', + }); + }); + + it('should not return an operation if restrictions prevent terms', () => { + expect( + termsOperation.getPossibleOperationForField({ + aggregatable: false, + searchable: true, + name: 'test', + type: 'string', + }) + ).toEqual(undefined); + + expect( + termsOperation.getPossibleOperationForField({ + aggregatable: true, + aggregationRestrictions: {}, + searchable: true, + name: 'test', + type: 'string', + }) + ).toEqual(undefined); + }); + }); + + describe('buildColumn', () => { + it('should use type from the passed field', () => { + const termsColumn = termsOperation.buildColumn({ + layerId: 'first', + suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + }, + columns: {}, + }); + expect(termsColumn.dataType).toEqual('boolean'); + }); + + it('should use existing metric column as order column', () => { + const termsColumn = termsOperation.buildColumn({ + layerId: 'first', + suggestedPriority: undefined, + indexPattern: createMockedIndexPattern(), + columns: { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + field: { + aggregatable: true, + searchable: true, + type: 'boolean', + name: 'test', + }, + }); + expect(termsColumn.params).toEqual( + expect.objectContaining({ + orderBy: { type: 'column', columnId: 'col1' }, + }) + ); + }); + }); + + describe('onOtherColumnChanged', () => { + it('should keep the column if order by column still exists and is metric', () => { + const initialColumn: TermsIndexPatternColumn = { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }; + const updatedColumn = termsOperation.onOtherColumnChanged!(initialColumn, { + col1: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }); + expect(updatedColumn).toBe(initialColumn); + }); + + it('should switch to alphabetical ordering if the order column is removed', () => { + const termsColumn = termsOperation.onOtherColumnChanged!( + { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + {} + ); + expect(termsColumn.params).toEqual( + expect.objectContaining({ + orderBy: { type: 'alphabetical' }, + }) + ); + }); + + it('should switch to alphabetical ordering if the order column is not a metric anymore', () => { + const termsColumn = termsOperation.onOtherColumnChanged!( + { + label: 'Top value of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col1' }, + size: 3, + orderDirection: 'asc', + }, + sourceField: 'category', + }, + { + col1: { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: 'w', + }, + sourceField: 'timestamp', + }, + } + ); + expect(termsColumn.params).toEqual( + expect.objectContaining({ + orderBy: { type: 'alphabetical' }, + }) + ); + }); + }); + + describe('popover param editor', () => { + it('should render current order by value and options', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance.find('[data-test-subj="indexPattern-terms-orderBy"]').find(EuiSelect); + + expect(select.prop('value')).toEqual('alphabetical'); + + expect(select.prop('options').map(({ value }) => value)).toEqual([ + 'column$$$col2', + 'alphabetical', + ]); + }); + + it('should not show filter ratio column as sort target', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance.find('[data-test-subj="indexPattern-terms-orderBy"]').find(EuiSelect); + + expect(select.prop('options').map(({ value }) => value)).toEqual(['alphabetical']); + }); + + it('should update state with the order by value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance + .find(EuiSelect) + .find('[data-test-subj="indexPattern-terms-orderBy"]') + .prop('onChange')!({ + target: { + value: 'column$$$col2', + }, + } as React.ChangeEvent); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + ...state.layers.first.columns.col1, + params: { + ...(state.layers.first.columns.col1 as TermsIndexPatternColumn).params, + orderBy: { + type: 'column', + columnId: 'col2', + }, + }, + }, + }, + }, + }, + }); + }); + + it('should render current order direction value and options', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance + .find('[data-test-subj="indexPattern-terms-orderDirection"]') + .find(EuiSelect); + + expect(select.prop('value')).toEqual('asc'); + expect(select.prop('options').map(({ value }) => value)).toEqual(['asc', 'desc']); + }); + + it('should update state with the order direction value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance + .find('[data-test-subj="indexPattern-terms-orderDirection"]') + .find(EuiSelect) + .prop('onChange')!({ + target: { + value: 'desc', + }, + } as React.ChangeEvent); + + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + ...state.layers.first.columns.col1, + params: { + ...(state.layers.first.columns.col1 as TermsIndexPatternColumn).params, + orderDirection: 'desc', + }, + }, + }, + }, + }, + }); + }); + + it('should render current size value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + expect(instance.find(EuiRange).prop('value')).toEqual(3); + }); + + it('should update state with the size value', () => { + const setStateSpy = jest.fn(); + const instance = shallow( + + ); + + instance.find(EuiRange).prop('onChange')!({ + target: { + value: '7', + }, + } as React.ChangeEvent); + expect(setStateSpy).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columns: { + ...state.layers.first.columns, + col1: { + ...state.layers.first.columns.col1, + params: { + ...(state.layers.first.columns.col1 as TermsIndexPatternColumn).params, + size: 7, + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx new file mode 100644 index 0000000000000..52b27f85fb495 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/definitions/terms.tsx @@ -0,0 +1,268 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui'; +import { IndexPatternColumn } from '../../indexpattern'; +import { updateColumnParam } from '../../state_helpers'; +import { DataType } from '../../../types'; +import { OperationDefinition } from '.'; +import { FieldBasedIndexPatternColumn } from './column_types'; + +type PropType = C extends React.ComponentType ? P : unknown; + +// Add ticks to EuiRange component props +const FixedEuiRange = (EuiRange as unknown) as React.ComponentType< + PropType & { + ticks?: Array<{ + label: string; + value: number; + }>; + } +>; + +function ofName(name: string) { + return i18n.translate('xpack.lens.indexPattern.termsOf', { + defaultMessage: 'Top values of {name}', + values: { name }, + }); +} + +function isSortableByColumn(column: IndexPatternColumn) { + return !column.isBucketed && column.operationType !== 'filter_ratio'; +} + +const DEFAULT_SIZE = 3; + +export interface TermsIndexPatternColumn extends FieldBasedIndexPatternColumn { + operationType: 'terms'; + params: { + size: number; + orderBy: { type: 'alphabetical' } | { type: 'column'; columnId: string }; + orderDirection: 'asc' | 'desc'; + }; +} + +export const termsOperation: OperationDefinition = { + type: 'terms', + displayName: i18n.translate('xpack.lens.indexPattern.terms', { + defaultMessage: 'Top Values', + }), + getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => { + if ( + (type === 'string' || type === 'boolean') && + aggregatable && + (!aggregationRestrictions || aggregationRestrictions.terms) + ) { + return { dataType: type, isBucketed: true, scale: 'ordinal' }; + } + }, + isTransferable: (column, newIndexPattern) => { + const newField = newIndexPattern.fields.find(field => field.name === column.sourceField); + + return Boolean( + newField && + newField.type === 'string' && + newField.aggregatable && + (!newField.aggregationRestrictions || newField.aggregationRestrictions.terms) + ); + }, + buildColumn({ suggestedPriority, columns, field }) { + const existingMetricColumn = Object.entries(columns) + .filter(([_columnId, column]) => column && isSortableByColumn(column)) + .map(([id]) => id)[0]; + + return { + label: ofName(field.name), + dataType: field.type as DataType, + operationType: 'terms', + scale: 'ordinal', + suggestedPriority, + sourceField: field.name, + isBucketed: true, + params: { + size: DEFAULT_SIZE, + orderBy: existingMetricColumn + ? { type: 'column', columnId: existingMetricColumn } + : { type: 'alphabetical' }, + orderDirection: existingMetricColumn ? 'desc' : 'asc', + }, + }; + }, + toEsAggsConfig: (column, columnId) => ({ + id: columnId, + enabled: true, + type: 'terms', + schema: 'segment', + params: { + field: column.sourceField, + orderBy: + column.params.orderBy.type === 'alphabetical' ? '_key' : column.params.orderBy.columnId, + order: column.params.orderDirection, + size: column.params.size, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }), + onFieldChange: (oldColumn, indexPattern, field) => { + return { + ...oldColumn, + label: ofName(field.name), + sourceField: field.name, + }; + }, + onOtherColumnChanged: (currentColumn, columns) => { + if (currentColumn.params.orderBy.type === 'column') { + // check whether the column is still there and still a metric + const columnSortedBy = columns[currentColumn.params.orderBy.columnId]; + if (!columnSortedBy || !isSortableByColumn(columnSortedBy)) { + return { + ...currentColumn, + params: { + ...currentColumn.params, + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + }, + }; + } + } + return currentColumn; + }, + paramEditor: ({ state, setState, currentColumn, columnId: currentColumnId, layerId }) => { + const SEPARATOR = '$$$'; + function toValue(orderBy: TermsIndexPatternColumn['params']['orderBy']) { + if (orderBy.type === 'alphabetical') { + return orderBy.type; + } + return `${orderBy.type}${SEPARATOR}${orderBy.columnId}`; + } + + function fromValue(value: string): TermsIndexPatternColumn['params']['orderBy'] { + if (value === 'alphabetical') { + return { type: 'alphabetical' }; + } + const parts = value.split(SEPARATOR); + return { + type: 'column', + columnId: parts[1], + }; + } + + const orderOptions = Object.entries(state.layers[layerId].columns) + .filter(([_columnId, column]) => isSortableByColumn(column)) + .map(([columnId, column]) => { + return { + value: toValue({ type: 'column', columnId }), + text: column.label, + }; + }); + orderOptions.push({ + value: toValue({ type: 'alphabetical' }), + text: i18n.translate('xpack.lens.indexPattern.terms.orderAlphabetical', { + defaultMessage: 'Alphabetical', + }), + }); + return ( + + + | React.MouseEvent + ) => + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'size', + value: Number((e.target as HTMLInputElement).value), + }) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { + defaultMessage: 'Number of values', + })} + /> + + + ) => + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'orderBy', + value: fromValue(e.target.value), + }) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Order by', + })} + /> + + + ) => + setState( + updateColumnParam({ + state, + layerId, + currentColumn, + paramName: 'orderDirection', + value: e.target.value as 'asc' | 'desc', + }) + ) + } + aria-label={i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Order by', + })} + /> + + + ); + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/index.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/index.ts new file mode 100644 index 0000000000000..1e2bc5dcb6b62 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './operations'; +export { OperationType, IndexPatternColumn } from './definitions'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts new file mode 100644 index 0000000000000..0a8e4b57521fe --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.test.ts @@ -0,0 +1,296 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOperationTypesForField, getAvailableOperationsByMetadata, buildColumn } from '.'; +import { IndexPatternPrivateState } from '../indexpattern'; +import { AvgIndexPatternColumn, MinIndexPatternColumn } from './definitions/metrics'; +import { CountIndexPatternColumn } from './definitions/count'; + +jest.mock('ui/new_platform'); +jest.mock('../loader'); + +const expectedIndexPatterns = { + 1: { + id: '1', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + fields: [ + { + name: 'timestamp', + type: 'date', + aggregatable: true, + searchable: true, + }, + { + name: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + }, + { + name: 'source', + type: 'string', + aggregatable: true, + searchable: true, + }, + ], + }, +}; + +describe('getOperationTypesForField', () => { + describe('with aggregatable fields', () => { + it('should return operations on strings', () => { + expect( + getOperationTypesForField({ + type: 'string', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['terms'])); + }); + + it('should return operations on numbers', () => { + expect( + getOperationTypesForField({ + type: 'number', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['avg', 'sum', 'min', 'max'])); + }); + + it('should return operations on dates', () => { + expect( + getOperationTypesForField({ + type: 'date', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual(expect.arrayContaining(['date_histogram'])); + }); + + it('should return no operations on unknown types', () => { + expect( + getOperationTypesForField({ + type: '_source', + name: 'a', + aggregatable: true, + searchable: true, + }) + ).toEqual([]); + }); + }); + + describe('with restrictions', () => { + it('should return operations on strings', () => { + expect( + getOperationTypesForField({ + type: 'string', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + terms: { + agg: 'terms', + }, + }, + }) + ).toEqual(expect.arrayContaining(['terms'])); + }); + + it('should return operations on numbers', () => { + expect( + getOperationTypesForField({ + type: 'number', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + min: { + agg: 'min', + }, + max: { + agg: 'max', + }, + }, + }) + ).toEqual(expect.arrayContaining(['min', 'max'])); + }); + + it('should return operations on dates', () => { + expect( + getOperationTypesForField({ + type: 'date', + name: 'a', + aggregatable: true, + searchable: true, + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + fixed_interval: '60m', + delay: '1d', + time_zone: 'UTC', + }, + }, + }) + ).toEqual(expect.arrayContaining(['date_histogram'])); + }); + }); + + describe('buildColumn', () => { + const state: IndexPatternPrivateState = { + currentIndexPatternId: '1', + showEmptyFields: false, + indexPatterns: expectedIndexPatterns, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }, + }, + }; + + it('should build a column for the given operation type if it is passed in', () => { + const column = buildColumn({ + layerId: 'first', + indexPattern: expectedIndexPatterns[1], + columns: state.layers.first.columns, + suggestedPriority: 0, + op: 'count', + }); + expect(column.operationType).toEqual('count'); + }); + + it('should build a column for the given operation type and field if it is passed in', () => { + const field = expectedIndexPatterns[1].fields[1]; + const column = buildColumn({ + layerId: 'first', + indexPattern: expectedIndexPatterns[1], + columns: state.layers.first.columns, + suggestedPriority: 0, + op: 'avg', + field, + }) as AvgIndexPatternColumn; + expect(column.operationType).toEqual('avg'); + expect(column.sourceField).toEqual(field.name); + }); + + it('should pick a suitable field operation if none is passed in', () => { + const field = expectedIndexPatterns[1].fields[1]; + const column = buildColumn({ + layerId: 'first', + indexPattern: expectedIndexPatterns[1], + columns: state.layers.first.columns, + suggestedPriority: 0, + field, + }) as MinIndexPatternColumn; + expect(column.operationType).toEqual('avg'); + expect(column.sourceField).toEqual(field.name); + }); + + it('should pick a suitable document operation if none is passed in', () => { + const column = buildColumn({ + layerId: 'first', + indexPattern: expectedIndexPatterns[1], + columns: state.layers.first.columns, + suggestedPriority: 0, + asDocumentOperation: true, + }) as CountIndexPatternColumn; + expect(column.operationType).toEqual('count'); + }); + }); + + describe('getAvailableOperationsByMetaData', () => { + it('should list out all field-operation tuples for different operation meta data', () => { + expect(getAvailableOperationsByMetadata(expectedIndexPatterns[1])).toMatchInlineSnapshot(` + Array [ + Object { + "operationMetaData": Object { + "dataType": "string", + "isBucketed": true, + "scale": "ordinal", + }, + "operations": Array [ + Object { + "field": "source", + "operationType": "terms", + "type": "field", + }, + ], + }, + Object { + "operationMetaData": Object { + "dataType": "date", + "isBucketed": true, + "scale": "interval", + }, + "operations": Array [ + Object { + "field": "timestamp", + "operationType": "date_histogram", + "type": "field", + }, + ], + }, + Object { + "operationMetaData": Object { + "dataType": "number", + "isBucketed": false, + "scale": "ratio", + }, + "operations": Array [ + Object { + "field": "bytes", + "operationType": "min", + "type": "field", + }, + Object { + "field": "bytes", + "operationType": "max", + "type": "field", + }, + Object { + "field": "bytes", + "operationType": "avg", + "type": "field", + }, + Object { + "field": "bytes", + "operationType": "sum", + "type": "field", + }, + Object { + "operationType": "count", + "type": "document", + }, + Object { + "operationType": "filter_ratio", + "type": "document", + }, + ], + }, + ] + `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts new file mode 100644 index 0000000000000..0b5a1dd903462 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/operations/operations.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { DimensionPriority, OperationMetadata } from '../../types'; +import { IndexPatternField, IndexPattern } from '../indexpattern'; +import { + operationDefinitionMap, + operationDefinitions, + GenericOperationDefinition, + OperationType, + IndexPatternColumn, +} from './definitions'; + +/** + * Returns all available operation types as a list at runtime. + * This will be an array of each member of the union type `OperationType` + * without any guaranteed order + */ +export function getOperations(): OperationType[] { + return Object.keys(operationDefinitionMap) as OperationType[]; +} + +/** + * Returns true if the given column can be applied to the given index pattern + */ +export function isColumnTransferable(column: IndexPatternColumn, newIndexPattern: IndexPattern) { + return operationDefinitionMap[column.operationType].isTransferable(column, newIndexPattern); +} + +/** + * Returns a list of the display names of all operations with any guaranteed order. + */ +export function getOperationDisplay() { + const display = {} as Record< + OperationType, + { + type: OperationType; + displayName: string; + } + >; + operationDefinitions.forEach(({ type, displayName }) => { + display[type] = { + type, + displayName, + }; + }); + return display; +} + +/** + * Returns all `OperationType`s that can build a column using `buildColumn` based on the + * passed in field. + */ +export function getOperationTypesForField(field: IndexPatternField) { + return operationDefinitions + .filter( + operationDefinition => + 'getPossibleOperationForField' in operationDefinition && + operationDefinition.getPossibleOperationForField(field) + ) + .sort( + (a, b) => (b.priority || Number.NEGATIVE_INFINITY) - (a.priority || Number.NEGATIVE_INFINITY) + ) + .map(({ type }) => type); +} + +type OperationFieldTuple = + | { type: 'field'; operationType: OperationType; field: string } + | { type: 'document'; operationType: OperationType }; + +/** + * Returns all possible operations (matches between operations and fields of the index + * pattern plus matches for operations and documents of the index pattern) indexed by the + * meta data of the operation. + * + * The resulting list is filtered down by the `filterOperations` function passed in by + * the current visualization to determine which operations and field are applicable for + * a given dimension. + * + * Example output: + * ``` + * [ + * { + * operationMetaData: { dataType: 'string', isBucketed: true }, + * operations: ['terms'] + * }, + * { + * operationMetaData: { dataType: 'number', isBucketed: false }, + * operations: ['avg', 'min', 'max'] + * }, + * ] + * ``` + */ +export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) { + const operationByMetadata: Record< + string, + { operationMetaData: OperationMetadata; operations: OperationFieldTuple[] } + > = {}; + + const addToMap = ( + operation: OperationFieldTuple, + operationMetadata: OperationMetadata | undefined | false + ) => { + if (!operationMetadata) return; + const key = JSON.stringify(operationMetadata); + + if (operationByMetadata[key]) { + operationByMetadata[key].operations.push(operation); + } else { + operationByMetadata[key] = { + operationMetaData: operationMetadata, + operations: [operation], + }; + } + }; + + operationDefinitions.forEach(operationDefinition => { + addToMap( + { type: 'document', operationType: operationDefinition.type }, + getPossibleOperationForDocument(operationDefinition, indexPattern) + ); + + indexPattern.fields.forEach(field => { + addToMap( + { + type: 'field', + operationType: operationDefinition.type, + field: field.name, + }, + getPossibleOperationForField(operationDefinition, field) + ); + }); + }); + + return Object.values(operationByMetadata); +} + +function getPossibleOperationForDocument( + operationDefinition: GenericOperationDefinition, + indexPattern: IndexPattern +): OperationMetadata | undefined { + return 'getPossibleOperationForDocument' in operationDefinition + ? operationDefinition.getPossibleOperationForDocument(indexPattern) + : undefined; +} + +function getPossibleOperationForField( + operationDefinition: GenericOperationDefinition, + field: IndexPatternField +): OperationMetadata | undefined { + return 'getPossibleOperationForField' in operationDefinition + ? operationDefinition.getPossibleOperationForField(field) + : undefined; +} + +function getDefinition(findFunction: (definition: GenericOperationDefinition) => boolean) { + const candidates = operationDefinitions.filter(findFunction); + return candidates.reduce((a, b) => + (a.priority || Number.NEGATIVE_INFINITY) > (b.priority || Number.NEGATIVE_INFINITY) ? a : b + ); +} + +/** + * Changes the field of the passed in colum. To do so, this method uses the `onFieldChange` function of + * the operation definition of the column. Returns a new column object with the field changed. + * @param column The column object with the old field configured + * @param indexPattern The index pattern associated to the layer of the column + * @param newField The new field the column should be switched to + */ +export function changeField( + column: IndexPatternColumn, + indexPattern: IndexPattern, + newField: IndexPatternField +) { + const operationDefinition = operationDefinitionMap[column.operationType]; + + if (!('onFieldChange' in operationDefinition)) { + throw new Error( + "Invariant error: Cannot change field if operation isn't a field based operaiton" + ); + } + + return operationDefinition.onFieldChange(column, indexPattern, newField); +} + +/** + * Builds a column object based on the context passed in. It tries + * to find the applicable operation definition and then calls the `buildColumn` + * function of that definition. It passes in the given `field` (if available), + * `suggestedPriority`, `layerId` and the currently existing `columns`. + * * If `op` is specified, the specified operation definition is used directly. + * * If `asDocumentOperation` is true, the first matching document-operation is used. + * * If `field` is specified, the first matching field based operation applicable to the field is used. + */ +export function buildColumn({ + op, + columns, + field, + layerId, + indexPattern, + suggestedPriority, + asDocumentOperation, +}: { + op?: OperationType; + columns: Partial>; + suggestedPriority: DimensionPriority | undefined; + layerId: string; + indexPattern: IndexPattern; + field?: IndexPatternField; + asDocumentOperation?: boolean; +}): IndexPatternColumn { + let operationDefinition: GenericOperationDefinition | undefined; + + if (op) { + operationDefinition = operationDefinitionMap[op]; + } else if (asDocumentOperation) { + operationDefinition = getDefinition(definition => + Boolean(getPossibleOperationForDocument(definition, indexPattern)) + ); + } else if (field) { + operationDefinition = getDefinition(definition => + Boolean(getPossibleOperationForField(definition, field)) + ); + } + + if (!operationDefinition) { + throw new Error('No suitable operation found for given parameters'); + } + + const baseOptions = { + columns, + suggestedPriority, + layerId, + indexPattern, + }; + + // check for the operation for field getter to determine whether + // this is a field based operation type + if ('getPossibleOperationForField' in operationDefinition) { + if (!field) { + throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`); + } + return operationDefinition.buildColumn({ + ...baseOptions, + field, + }); + } else { + return operationDefinition.buildColumn(baseOptions); + } +} + +export { operationDefinitionMap } from './definitions'; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx new file mode 100644 index 0000000000000..581c08f832b67 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/plugin.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Registry } from '@kbn/interpreter/target/common'; +import { CoreSetup } from 'src/core/public'; +// The following dependencies on ui/* and src/legacy/core_plugins must be mocked when testing +import chrome, { Chrome } from 'ui/chrome'; +import { Storage } from 'ui/storage'; +import { npSetup } from 'ui/new_platform'; +import { ExpressionFunction } from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { functionsRegistry } from '../../../../../../src/legacy/core_plugins/interpreter/public/registries'; +import { getIndexPatternDatasource } from './indexpattern'; +import { renameColumns } from './rename_columns'; +import { calculateFilterRatio } from './filter_ratio'; +import { setup as dataSetup } from '../../../../../../src/legacy/core_plugins/data/public/legacy'; + +// TODO these are intermediary types because interpreter is not typed yet +// They can get replaced by references to the real interfaces as soon as they +// are available + +export interface IndexPatternDatasourcePluginPlugins { + chrome: Chrome; + interpreter: InterpreterSetup; + data: typeof dataSetup; +} + +export interface InterpreterSetup { + functionsRegistry: Registry< + ExpressionFunction, + ExpressionFunction + >; +} + +class IndexPatternDatasourcePlugin { + constructor() {} + + setup(core: CoreSetup, { interpreter, data }: IndexPatternDatasourcePluginPlugins) { + interpreter.functionsRegistry.register(() => renameColumns); + interpreter.functionsRegistry.register(() => calculateFilterRatio); + return getIndexPatternDatasource({ + core, + chrome, + interpreter, + data, + storage: new Storage(localStorage), + savedObjectsClient: chrome.getSavedObjectsClient(), + }); + } + + stop() {} +} + +const plugin = new IndexPatternDatasourcePlugin(); + +export const indexPatternDatasourceSetup = () => + plugin.setup(npSetup.core, { + chrome, + interpreter: { + functionsRegistry, + }, + data: dataSetup, + }); +export const indexPatternDatasourceStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts new file mode 100644 index 0000000000000..641b1ceb431fb --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renameColumns } from './rename_columns'; +import { KibanaDatatable } from 'src/legacy/core_plugins/interpreter/common'; + +describe('rename_columns', () => { + it('should rename columns of a given datatable', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }], + rows: [{ a: 1, b: 2 }, { a: 3, b: 4 }, { a: 5, b: 6 }, { a: 7, b: 8 }], + }; + + const idMap = { + a: 'b', + b: 'c', + }; + + expect(renameColumns.fn(input, { idMap: JSON.stringify(idMap) }, {})).toMatchInlineSnapshot(` +Object { + "columns": Array [ + Object { + "id": "b", + "name": "A", + }, + Object { + "id": "c", + "name": "B", + }, + ], + "rows": Array [ + Object { + "b": 1, + "c": 2, + }, + Object { + "b": 3, + "c": 4, + }, + Object { + "b": 5, + "c": 6, + }, + Object { + "b": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", +} +`); + }); + + it('should keep columns which are not mapped', () => { + const input: KibanaDatatable = { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }], + rows: [{ a: 1, b: 2 }, { a: 3, b: 4 }, { a: 5, b: 6 }, { a: 7, b: 8 }], + }; + + const idMap = { + b: 'c', + }; + + expect(renameColumns.fn(input, { idMap: JSON.stringify(idMap) }, {})).toMatchInlineSnapshot(` +Object { + "columns": Array [ + Object { + "id": "a", + "name": "A", + }, + Object { + "id": "c", + "name": "B", + }, + ], + "rows": Array [ + Object { + "a": 1, + "c": 2, + }, + Object { + "a": 3, + "c": 4, + }, + Object { + "a": 5, + "c": 6, + }, + Object { + "a": 7, + "c": 8, + }, + ], + "type": "kibana_datatable", +} +`); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts new file mode 100644 index 0000000000000..4a54bcad56163 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/rename_columns.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionFunction, KibanaDatatable } from 'src/legacy/core_plugins/interpreter/public'; + +interface RemapArgs { + idMap: string; +} + +export const renameColumns: ExpressionFunction< + 'lens_rename_columns', + KibanaDatatable, + RemapArgs, + KibanaDatatable +> = { + name: 'lens_rename_columns', + type: 'kibana_datatable', + help: i18n.translate('xpack.lens.functions.renameColumns.help', { + defaultMessage: 'A helper to rename the columns of a datatable', + }), + args: { + idMap: { + types: ['string'], + help: i18n.translate('xpack.lens.functions.renameColumns.idMap.help', { + defaultMessage: + 'A JSON encoded object in which keys are the old column ids and values are the corresponding new ones. All other columns ids are kept.', + }), + }, + }, + context: { + types: ['kibana_datatable'], + }, + fn(data: KibanaDatatable, { idMap: encodedIdMap }: RemapArgs) { + const idMap = JSON.parse(encodedIdMap) as Record; + return { + type: 'kibana_datatable', + rows: data.rows.map(row => { + const mappedRow: Record = {}; + Object.entries(idMap).forEach(([fromId, toId]) => { + mappedRow[toId] = row[fromId]; + }); + + Object.entries(row).forEach(([id, value]) => { + if (id in idMap) { + mappedRow[idMap[id]] = value; + } else { + mappedRow[id] = value; + } + }); + + return mappedRow; + }), + columns: data.columns.map(column => ({ + ...column, + id: idMap[column.id] ? idMap[column.id] : column.id, + })), + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts new file mode 100644 index 0000000000000..9023173ab95df --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.test.ts @@ -0,0 +1,676 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + updateColumnParam, + changeColumn, + getColumnOrder, + deleteColumn, + updateLayerIndexPattern, +} from './state_helpers'; +import { IndexPatternPrivateState, IndexPattern, IndexPatternLayer } from './indexpattern'; +import { operationDefinitionMap } from './operations'; +import { TermsIndexPatternColumn } from './operations/definitions/terms'; +import { DateHistogramIndexPatternColumn } from './operations/definitions/date_histogram'; +import { AvgIndexPatternColumn } from './operations/definitions/metrics'; + +jest.mock('ui/new_platform'); +jest.mock('./operations'); + +describe('state_helpers', () => { + describe('deleteColumn', () => { + it('should remove column', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }, + }, + }; + + expect( + deleteColumn({ state, columnId: 'col2', layerId: 'first' }).layers.first.columns + ).toEqual({ + col1: termsColumn, + }); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }, + }, + }; + + deleteColumn({ + state, + columnId: 'col2', + layerId: 'first', + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + }); + }); + }); + + describe('updateColumnParam', () => { + it('should set the param for the given column', () => { + const currentColumn: DateHistogramIndexPatternColumn = { + label: 'Value of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: currentColumn, + }, + }, + }, + }; + + expect( + updateColumnParam({ + state, + layerId: 'first', + currentColumn, + paramName: 'interval', + value: 'M', + }).layers.first.columns.col1 + ).toEqual({ + ...currentColumn, + params: { interval: 'M' }, + }); + }); + }); + + describe('changeColumn', () => { + it('should update order on changing the column', () => { + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col2: { + label: 'Max of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'max', + sourceField: 'bytes', + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + columnId: 'col2', + layerId: 'first', + newColumn: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }) + ).toEqual({ + ...state, + layers: { + first: expect.objectContaining({ + columnOrder: ['col2', 'col1'], + }), + }, + }); + }); + + it('should carry over params from old column if the operation type stays the same', () => { + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }, + }, + }, + }; + expect( + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn: { + label: 'Date histogram of order_date', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'order_date', + params: { + interval: 'w', + }, + }, + }).layers.first.columns.col1 + ).toEqual( + expect.objectContaining({ + params: { interval: 'h' }, + }) + ); + }); + + it('should execute adjustments for other columns', () => { + const termsColumn: TermsIndexPatternColumn = { + label: 'Top values of source', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'source', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }; + + const newColumn: AvgIndexPatternColumn = { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }; + + const state: IndexPatternPrivateState = { + indexPatterns: {}, + currentIndexPatternId: '1', + showEmptyFields: false, + layers: { + first: { + indexPatternId: '1', + columnOrder: ['col1', 'col2'], + columns: { + col1: termsColumn, + col2: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + }, + }, + }, + }, + }; + + changeColumn({ + state, + layerId: 'first', + columnId: 'col2', + newColumn, + }); + + expect(operationDefinitionMap.terms.onOtherColumnChanged).toHaveBeenCalledWith(termsColumn, { + col1: termsColumn, + col2: newColumn, + }); + }); + }); + + describe('getColumnOrder', () => { + it('should work for empty columns', () => { + expect(getColumnOrder({})).toEqual([]); + }); + + it('should work for one column', () => { + expect( + getColumnOrder({ + col1: { + label: 'Value of timestamp', + dataType: 'string', + isBucketed: false, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: 'h', + }, + }, + }) + ).toEqual(['col1']); + }); + + it('should put any number of aggregations before metrics', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + }, + col3: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col1', 'col3', 'col2']); + }); + + it('should reorder aggregations based on suggested priority', () => { + expect( + getColumnOrder({ + col1: { + label: 'Top Values of category', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + sourceField: 'category', + params: { + size: 5, + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'asc', + }, + suggestedPriority: 2, + }, + col2: { + label: 'Average of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'avg', + sourceField: 'bytes', + suggestedPriority: 0, + }, + col3: { + label: 'Date Histogram of timestamp', + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + sourceField: 'timestamp', + suggestedPriority: 1, + params: { + interval: '1d', + }, + }, + }) + ).toEqual(['col3', 'col1', 'col2']); + }); + }); + + describe('updateLayerIndexPattern', () => { + const indexPattern: IndexPattern = { + id: 'test', + title: '', + fields: [ + { + name: 'fieldA', + aggregatable: true, + searchable: true, + type: 'string', + }, + { + name: 'fieldB', + aggregatable: true, + searchable: true, + type: 'number', + aggregationRestrictions: { + avg: { + agg: 'avg', + }, + }, + }, + { + name: 'fieldC', + aggregatable: false, + searchable: true, + type: 'date', + }, + { + name: 'fieldD', + aggregatable: true, + searchable: true, + type: 'date', + aggregationRestrictions: { + date_histogram: { + agg: 'date_histogram', + time_zone: 'CET', + calendar_interval: 'w', + }, + }, + }, + { + name: 'fieldE', + aggregatable: true, + searchable: true, + type: 'date', + }, + ], + }; + + it('should switch index pattern id in layer', () => { + const layer = { columnOrder: [], columns: {}, indexPatternId: 'original' }; + expect(updateLayerIndexPattern(layer, indexPattern)).toEqual({ + ...layer, + indexPatternId: 'test', + }); + }); + + it('should remove operations referencing unavailable fields', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'xxx', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with insufficient capabilities', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldC', + params: { + interval: 'd', + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldB', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col2']); + expect(updatedLayer.columns).toEqual({ + col2: layer.columns.col2, + }); + }); + + it('should rewrite column params if that is necessary due to restrictions', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: '', + operationType: 'date_histogram', + sourceField: 'fieldD', + params: { + interval: 'd', + }, + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: { + ...layer.columns.col1, + params: { + interval: 'w', + timeZone: 'CET', + }, + }, + }); + }); + + it('should remove operations referencing fields with wrong field types', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'avg', + sourceField: 'fieldD', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + + it('should remove operations referencing fields with incompatible restrictions', () => { + const layer: IndexPatternLayer = { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'string', + isBucketed: true, + label: '', + operationType: 'terms', + sourceField: 'fieldA', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 3, + }, + }, + col2: { + dataType: 'number', + isBucketed: false, + label: '', + operationType: 'min', + sourceField: 'fieldC', + }, + }, + indexPatternId: 'original', + }; + const updatedLayer = updateLayerIndexPattern(layer, indexPattern); + expect(updatedLayer.columnOrder).toEqual(['col1']); + expect(updatedLayer.columns).toEqual({ + col1: layer.columns.col1, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts new file mode 100644 index 0000000000000..f2b55bbcb0dd5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/state_helpers.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { IndexPatternPrivateState, IndexPatternLayer, IndexPattern } from './indexpattern'; +import { isColumnTransferable } from './operations'; +import { operationDefinitionMap, IndexPatternColumn } from './operations'; + +export function updateColumnParam< + C extends IndexPatternColumn & { params: object }, + K extends keyof C['params'] +>({ + state, + layerId, + currentColumn, + paramName, + value, +}: { + state: IndexPatternPrivateState; + layerId: string; + currentColumn: C; + paramName: K; + value: C['params'][K]; +}): IndexPatternPrivateState { + const columnId = Object.entries(state.layers[layerId].columns).find( + ([_columnId, column]) => column === currentColumn + )![0]; + + if (!('params' in state.layers[layerId].columns[columnId])) { + throw new Error('Invariant: no params in this column'); + } + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columns: { + ...state.layers[layerId].columns, + [columnId]: { + ...currentColumn, + params: { + ...currentColumn.params, + [paramName]: value, + }, + }, + }, + }, + }, + }; +} + +function adjustColumnReferencesForChangedColumn( + columns: Record, + columnId: string +) { + const newColumns = { ...columns }; + Object.keys(newColumns).forEach(currentColumnId => { + if (currentColumnId !== columnId) { + const currentColumn = newColumns[currentColumnId]; + const operationDefinition = operationDefinitionMap[currentColumn.operationType]; + newColumns[currentColumnId] = operationDefinition.onOtherColumnChanged + ? operationDefinition.onOtherColumnChanged(currentColumn, newColumns) + : currentColumn; + } + }); + return newColumns; +} + +export function changeColumn({ + state, + layerId, + columnId, + newColumn, + keepParams = true, +}: { + state: IndexPatternPrivateState; + layerId: string; + columnId: string; + newColumn: C; + keepParams?: boolean; +}): IndexPatternPrivateState { + const oldColumn = state.layers[layerId].columns[columnId]; + + const updatedColumn = + keepParams && + oldColumn && + oldColumn.operationType === newColumn.operationType && + 'params' in oldColumn + ? { ...newColumn, params: oldColumn.params } + : newColumn; + + const newColumns = adjustColumnReferencesForChangedColumn( + { + ...state.layers[layerId].columns, + [columnId]: updatedColumn, + }, + columnId + ); + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function deleteColumn({ + state, + layerId, + columnId, +}: { + state: IndexPatternPrivateState; + layerId: string; + columnId: string; +}): IndexPatternPrivateState { + const newColumns = adjustColumnReferencesForChangedColumn( + state.layers[layerId].columns, + columnId + ); + delete newColumns[columnId]; + + return { + ...state, + layers: { + ...state.layers, + [layerId]: { + ...state.layers[layerId], + columnOrder: getColumnOrder(newColumns), + columns: newColumns, + }, + }, + }; +} + +export function getColumnOrder(columns: Record): string[] { + const entries = Object.entries(columns); + + const [aggregations, metrics] = _.partition(entries, ([id, col]) => col.isBucketed); + + return aggregations + .sort(([id, col], [id2, col2]) => { + return ( + // Sort undefined orders last + (col.suggestedPriority !== undefined ? col.suggestedPriority : Number.MAX_SAFE_INTEGER) - + (col2.suggestedPriority !== undefined ? col2.suggestedPriority : Number.MAX_SAFE_INTEGER) + ); + }) + .map(([id]) => id) + .concat(metrics.map(([id]) => id)); +} + +export function isLayerTransferable(layer: IndexPatternLayer, newIndexPattern: IndexPattern) { + return Object.values(layer.columns).every(column => + isColumnTransferable(column, newIndexPattern) + ); +} + +export function updateLayerIndexPattern( + layer: IndexPatternLayer, + newIndexPattern: IndexPattern +): IndexPatternLayer { + const keptColumns: IndexPatternLayer['columns'] = _.pick(layer.columns, column => + isColumnTransferable(column, newIndexPattern) + ); + const newColumns: IndexPatternLayer['columns'] = _.mapValues(keptColumns, column => { + const operationDefinition = operationDefinitionMap[column.operationType]; + return operationDefinition.transfer + ? operationDefinition.transfer(column, newIndexPattern) + : column; + }); + const newColumnOrder = layer.columnOrder.filter(columnId => newColumns[columnId]); + + return { + ...layer, + indexPatternId: newIndexPattern.id, + columns: newColumns, + columnOrder: newColumnOrder, + }; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts new file mode 100644 index 0000000000000..9bd68aac90403 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/to_expression.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; + +import { IndexPatternPrivateState, IndexPatternColumn, IndexPattern } from './indexpattern'; +import { buildColumn, operationDefinitionMap } from './operations'; + +function getExpressionForLayer( + indexPattern: IndexPattern, + layerId: string, + columns: Record, + columnOrder: string[] +) { + if (columnOrder.length === 0) { + return null; + } + + function getEsAggsConfig(column: C, columnId: string) { + return operationDefinitionMap[column.operationType].toEsAggsConfig(column, columnId); + } + + const columnEntries = columnOrder.map(colId => [colId, columns[colId]] as const); + + if (columnEntries.length) { + const aggs = columnEntries.map(([colId, col]) => { + return getEsAggsConfig(col, colId); + }); + + const idMap = columnEntries.reduce( + (currentIdMap, [colId], index) => { + return { + ...currentIdMap, + [`col-${index}-${colId}`]: colId, + }; + }, + {} as Record + ); + + const filterRatios = columnEntries.filter( + ([colId, col]) => col.operationType === 'filter_ratio' + ); + + if (filterRatios.length) { + const countColumn = buildColumn({ + op: 'count', + columns, + suggestedPriority: 2, + layerId, + indexPattern, + }); + aggs.push(getEsAggsConfig(countColumn, 'filter-ratio')); + + return `esaggs + index="${indexPattern.id}" + metricsAtAllLevels=false + partialRows=false + includeFormatHints=true + aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify( + idMap + )}' | ${filterRatios.map(([id]) => `lens_calculate_filter_ratio id=${id}`).join(' | ')}`; + } + + return `esaggs + index="${indexPattern.id}" + metricsAtAllLevels=false + partialRows=false + includeFormatHints=true + aggConfigs='${JSON.stringify(aggs)}' | lens_rename_columns idMap='${JSON.stringify(idMap)}'`; + } + + return null; +} + +export function toExpression(state: IndexPatternPrivateState, layerId: string) { + if (state.layers[layerId]) { + return getExpressionForLayer( + state.indexPatterns[state.layers[layerId].indexPatternId], + layerId, + state.layers[layerId].columns, + state.layers[layerId].columnOrder + ); + } + + return null; +} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts new file mode 100644 index 0000000000000..aab991a27856a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/utils.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { DraggedField } from './indexpattern'; +import { + BaseIndexPatternColumn, + FieldBasedIndexPatternColumn, +} from './operations/definitions/column_types'; + +export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn { + return 'sourceField' in column; +} + +export function sortByField(columns: C[]) { + return [...columns].sort((column1, column2) => { + if (hasField(column1) && hasField(column2)) { + return column1.sourceField.localeCompare(column2.sourceField); + } + return column1.operationType.localeCompare(column2.operationType); + }); +} + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + 'field' in fieldCandidate && + 'indexPatternId' in fieldCandidate + ); +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx new file mode 100644 index 0000000000000..722be9048e775 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { computeScale, AutoScale } from './auto_scale'; +import { render } from 'enzyme'; + +const mockElement = (clientWidth = 100, clientHeight = 200) => ({ + clientHeight, + clientWidth, +}); + +describe('AutoScale', () => { + describe('computeScale', () => { + it('is 1 if any element is null', () => { + expect(computeScale(null, null)).toBe(1); + expect(computeScale(mockElement(), null)).toBe(1); + expect(computeScale(null, mockElement())).toBe(1); + }); + + it('is never over 1', () => { + expect(computeScale(mockElement(2000, 2000), mockElement(1000, 1000))).toBe(1); + }); + + it('is never under 0.3 in default case', () => { + expect(computeScale(mockElement(2000, 1000), mockElement(1000, 10000))).toBe(0.3); + }); + + it('is never under specified min scale if specified', () => { + expect(computeScale(mockElement(2000, 1000), mockElement(1000, 10000), 0.1)).toBe(0.1); + }); + + it('is the lesser of the x or y scale', () => { + expect(computeScale(mockElement(2000, 2000), mockElement(3000, 5000))).toBe(0.4); + expect(computeScale(mockElement(2000, 3000), mockElement(4000, 3200))).toBe(0.5); + }); + }); + + describe('AutoScale', () => { + it('renders', () => { + expect( + render( + +

Hoi!

+
+ ) + ).toMatchInlineSnapshot(` +
+
+

+ Hoi! +

+
+
+ `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.tsx new file mode 100644 index 0000000000000..37dc71b28b87f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/auto_scale.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import _ from 'lodash'; +import { EuiResizeObserver } from '@elastic/eui'; + +interface Props extends React.HTMLAttributes { + children: React.ReactNode | React.ReactNode[]; + minScale?: number; +} + +interface State { + scale: number; +} + +export class AutoScale extends React.Component { + private child: Element | null = null; + private parent: Element | null = null; + private scale: () => void; + + constructor(props: Props) { + super(props); + + this.scale = _.throttle(() => { + const scale = computeScale(this.parent, this.child, this.props.minScale); + + // Prevent an infinite render loop + if (this.state.scale !== scale) { + this.setState({ scale }); + } + }); + + // An initial scale of 0 means we always redraw + // at least once, which is sub-optimal, but it + // prevents an annoying flicker. + this.state = { scale: 0 }; + } + + setParent = (el: Element | null) => { + if (el && this.parent !== el) { + this.parent = el; + setTimeout(() => this.scale()); + } + }; + + setChild = (el: Element | null) => { + if (el && this.child !== el) { + this.child = el; + setTimeout(() => this.scale()); + } + }; + + render() { + const { children, minScale, ...rest } = this.props; + const { scale } = this.state; + const style = this.props.style || {}; + + return ( + + {resizeRef => ( +
{ + this.setParent(el); + resizeRef(el); + }} + style={{ + ...style, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + maxWidth: '100%', + maxHeight: '100%', + overflow: 'hidden', + }} + > +
+ {children} +
+
+ )} +
+ ); + } +} + +interface ClientDimensionable { + clientWidth: number; + clientHeight: number; +} + +const MAX_SCALE = 1; +const MIN_SCALE = 0.3; + +/** + * computeScale computes the ratio by which the child needs to shrink in order + * to fit into the parent. This function is only exported for testing purposes. + */ +export function computeScale( + parent: ClientDimensionable | null, + child: ClientDimensionable | null, + minScale: number = MIN_SCALE +) { + if (!parent || !child) { + return 1; + } + + const scaleX = parent.clientWidth / child.clientWidth; + const scaleY = parent.clientHeight / child.clientHeight; + + return Math.max(Math.min(MAX_SCALE, Math.min(scaleX, scaleY)), minScale); +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx new file mode 100644 index 0000000000000..ff2e55ac83dcc --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { MetricConfigPanel } from './metric_config_panel'; +import { DatasourceDimensionPanelProps, Operation, DatasourcePublicAPI } from '../types'; +import { State } from './types'; +import { NativeRendererProps } from '../native_renderer'; +import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_plugin/mocks'; + +describe('MetricConfigPanel', () => { + const dragDropContext = { dragging: undefined, setDragging: jest.fn() }; + + function mockDatasource(): DatasourcePublicAPI { + return createMockDatasource().publicAPIMock; + } + + function testState(): State { + return { + accessor: 'foo', + layerId: 'bar', + }; + } + + function testSubj(component: ReactWrapper, subj: string) { + return component + .find(`[data-test-subj="${subj}"]`) + .first() + .props(); + } + + test('the value dimension panel only accepts singular numeric operations', () => { + const state = testState(); + const component = mount( + + ); + + const panel = testSubj(component, 'lns_metric_valueDimensionPanel'); + const nativeProps = (panel as NativeRendererProps).nativeProps; + const { columnId, filterOperations } = nativeProps; + const exampleOperation: Operation = { + dataType: 'number', + isBucketed: false, + label: 'bar', + }; + const ops: Operation[] = [ + { ...exampleOperation, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(columnId).toEqual('shazm'); + expect(ops.filter(filterOperations)).toEqual([{ ...exampleOperation, dataType: 'number' }]); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.tsx new file mode 100644 index 0000000000000..d558f14fdd7c6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_config_panel.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { State } from './types'; +import { VisualizationProps, OperationMetadata } from '../types'; +import { NativeRenderer } from '../native_renderer'; + +const isMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; + +export function MetricConfigPanel(props: VisualizationProps) { + const { state, frame } = props; + const [datasource] = Object.values(frame.datasourceLayers); + const [layerId] = Object.keys(frame.datasourceLayers); + + return ( + + + + + + + + + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx new file mode 100644 index 0000000000000..f942206e4b70b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { metricChart, MetricChart } from './metric_expression'; +import { LensMultiTable } from '../types'; +import React from 'react'; +import { shallow } from 'enzyme'; +import { MetricConfig } from './types'; +import { FieldFormat } from 'ui/registry/field_formats'; + +function sampleArgs() { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'kibana_datatable', + columns: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }, { id: 'c', name: 'c' }], + rows: [{ a: 10110, b: 2, c: 3 }], + }, + }, + }; + + const args: MetricConfig = { + accessor: 'a', + layerId: 'l1', + title: 'My fanci metric chart', + mode: 'full', + }; + + return { data, args }; +} + +describe('metric_expression', () => { + describe('metricChart', () => { + test('it renders with the specified data and args', () => { + const { data, args } = sampleArgs(); + + expect(metricChart.fn(data, args, {})).toEqual({ + type: 'render', + as: 'lens_metric_chart_renderer', + value: { data, args }, + }); + }); + }); + + describe('MetricChart component', () => { + test('it renders the title and value', () => { + const { data, args } = sampleArgs(); + + expect(shallow( x as FieldFormat} />)) + .toMatchInlineSnapshot(` +
+ +
+ 10110 +
+
+ My fanci metric chart +
+
+
+ `); + }); + + test('it does not render title in reduced mode', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as FieldFormat} + /> + ) + ).toMatchInlineSnapshot(` +
+ +
+ 10110 +
+
+
+ `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx new file mode 100644 index 0000000000000..f940e9cf5ece7 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_expression.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public/expressions'; +import { MetricConfig } from './types'; +import { LensMultiTable } from '../types'; +import { AutoScale } from './auto_scale'; + +export interface MetricChartProps { + data: LensMultiTable; + args: MetricConfig; +} + +export interface MetricRender { + type: 'render'; + as: 'lens_metric_chart_renderer'; + value: MetricChartProps; +} + +export const metricChart: ExpressionFunction< + 'lens_metric_chart', + LensMultiTable, + MetricConfig, + MetricRender +> = ({ + name: 'lens_metric_chart', + type: 'render', + help: 'A metric chart', + args: { + title: { + types: ['string'], + help: 'The chart title.', + }, + accessor: { + types: ['string'], + help: 'The column whose value is being displayed', + }, + mode: { + types: ['string'], + options: ['reduced', 'full'], + default: 'full', + help: + 'The display mode of the chart - reduced will only show the metric itself without min size', + }, + }, + context: { + types: ['lens_multitable'], + }, + fn(data: LensMultiTable, args: MetricChartProps) { + return { + type: 'render', + as: 'lens_metric_chart_renderer', + value: { + data, + args, + }, + }; + }, + // TODO the typings currently don't support custom type args. As soon as they do, this can be removed +} as unknown) as ExpressionFunction< + 'lens_metric_chart', + LensMultiTable, + MetricConfig, + MetricRender +>; + +export const getMetricChartRenderer = ( + formatFactory: FormatFactory +): IInterpreterRenderFunction => ({ + name: 'lens_metric_chart_renderer', + displayName: 'Metric Chart', + help: 'Metric Chart Renderer', + validate: () => {}, + reuseDomNode: true, + render: async (domNode: Element, config: MetricChartProps, _handlers: unknown) => { + ReactDOM.render(, domNode); + }, +}); + +export function MetricChart({ + data, + args, + formatFactory, +}: MetricChartProps & { formatFactory: FormatFactory }) { + const { title, accessor, mode } = args; + let value = '-'; + const firstTable = Object.values(data.tables)[0]; + + if (firstTable) { + const column = firstTable.columns[0]; + const row = firstTable.rows[0]; + if (row[accessor]) { + value = + column && column.formatHint + ? formatFactory(column.formatHint).convert(row[accessor]) + : Number(Number(row[accessor]).toFixed(3)).toString(); + } + } + + return ( +
+ +
+ {value} +
+ {mode === 'full' && ( +
+ {title} +
+ )} +
+
+ ); +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.test.ts new file mode 100644 index 0000000000000..8baa78987b756 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.test.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSuggestions } from './metric_suggestions'; +import { TableSuggestionColumn, TableSuggestion } from '..'; + +describe('metric_suggestions', () => { + function numCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Avg ${columnId}`, + isBucketed: false, + }, + }; + } + + function strCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'string', + label: `Top 5 ${columnId}`, + isBucketed: true, + }, + }; + } + + function dateCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'date', + isBucketed: true, + label: `${columnId} histogram`, + }, + }; + } + + test('ignores invalid combinations', () => { + const unknownCol = () => { + const str = strCol('foo'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { ...str, operation: { ...str.operation, dataType: 'wonkies' } } as any; + }; + + expect( + ([ + { + columns: [dateCol('a')], + isMultiRow: true, + layerId: 'l1', + changeType: 'unchanged', + }, + { + columns: [strCol('foo'), strCol('bar')], + isMultiRow: true, + layerId: 'l1', + changeType: 'unchanged', + }, + { + layerId: 'l1', + isMultiRow: true, + columns: [numCol('bar')], + changeType: 'unchanged', + }, + { + columns: [unknownCol(), numCol('bar')], + isMultiRow: true, + layerId: 'l1', + changeType: 'unchanged', + }, + { + columns: [numCol('bar'), numCol('baz')], + isMultiRow: false, + layerId: 'l1', + changeType: 'unchanged', + }, + ] as TableSuggestion[]).map(table => expect(getSuggestions({ table })).toEqual([])) + ); + }); + + test('suggests a basic metric chart', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + columns: [numCol('bytes')], + isMultiRow: false, + layerId: 'l1', + changeType: 'unchanged', + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestion).toMatchInlineSnapshot(` + Object { + "previewExpression": Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + "bytes", + ], + "mode": Array [ + "reduced", + ], + "title": Array [ + "", + ], + }, + "function": "lens_metric_chart", + "type": "function", + }, + ], + "type": "expression", + }, + "previewIcon": "visMetric", + "score": 0.5, + "state": Object { + "accessor": "bytes", + "layerId": "l1", + }, + "title": "Avg bytes", + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts new file mode 100644 index 0000000000000..9cf5133527183 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_suggestions.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../types'; +import { State } from './types'; + +/** + * Generate suggestions for the metric chart. + * + * @param opts + */ +export function getSuggestions({ + table, + state, +}: SuggestionRequest): Array> { + // We only render metric charts for single-row queries. We require a single, numeric column. + if ( + table.isMultiRow || + table.columns.length > 1 || + table.columns[0].operation.dataType !== 'number' + ) { + return []; + } + + // don't suggest current table if visualization is active + if (state && table.changeType === 'unchanged') { + return []; + } + + return [getSuggestion(table)]; +} + +function getSuggestion(table: TableSuggestion): VisualizationSuggestion { + const col = table.columns[0]; + const title = table.label || col.operation.label; + + return { + title, + score: 0.5, + previewIcon: 'visMetric', + previewExpression: { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_metric_chart', + arguments: { + title: [''], + accessor: [col.columnId], + mode: ['reduced'], + }, + }, + ], + }, + state: { + layerId: table.layerId, + accessor: col.columnId, + }, + }; +} diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts new file mode 100644 index 0000000000000..b6de912089c4b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { metricVisualization } from './metric_visualization'; +import { State } from './types'; +import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_plugin/mocks'; +import { generateId } from '../id_generator'; +import { DatasourcePublicAPI, FramePublicAPI } from '../types'; + +jest.mock('../id_generator'); + +function exampleState(): State { + return { + accessor: 'a', + layerId: 'l1', + }; +} + +function mockFrame(): FramePublicAPI { + return { + ...createMockFramePublicAPI(), + addNewLayer: () => 'l42', + datasourceLayers: { + l1: createMockDatasource().publicAPIMock, + l42: createMockDatasource().publicAPIMock, + }, + }; +} + +describe('metric_visualization', () => { + describe('#initialize', () => { + it('loads default state', () => { + (generateId as jest.Mock).mockReturnValueOnce('test-id1'); + const initialState = metricVisualization.initialize(mockFrame()); + + expect(initialState.accessor).toBeDefined(); + expect(initialState).toMatchInlineSnapshot(` + Object { + "accessor": "test-id1", + "layerId": "l42", + } + `); + }); + + it('loads from persisted state', () => { + expect(metricVisualization.initialize(mockFrame(), exampleState())).toEqual(exampleState()); + }); + }); + + describe('#getPersistableState', () => { + it('persists the state as given', () => { + expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState()); + }); + }); + + describe('#toExpression', () => { + it('should map to a valid AST', () => { + const datasource: DatasourcePublicAPI = { + ...createMockDatasource().publicAPIMock, + getOperationForColumnId(_: string) { + return { + id: 'a', + dataType: 'number', + isBucketed: false, + label: 'shazm', + }; + }, + }; + + const frame = { + ...mockFrame(), + datasourceLayers: { l1: datasource }, + }; + + expect(metricVisualization.toExpression(exampleState(), frame)).toMatchInlineSnapshot(` + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessor": Array [ + "a", + ], + "title": Array [ + "shazm", + ], + }, + "function": "lens_metric_chart", + "type": "function", + }, + ], + "type": "expression", + } + `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx new file mode 100644 index 0000000000000..f178b2bd4fe5e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/metric_visualization.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { getSuggestions } from './metric_suggestions'; +import { MetricConfigPanel } from './metric_config_panel'; +import { Visualization } from '../types'; +import { State, PersistableState } from './types'; +import { generateId } from '../id_generator'; + +export const metricVisualization: Visualization = { + id: 'lnsMetric', + + visualizationTypes: [ + { + id: 'lnsMetric', + icon: 'visMetric', + label: i18n.translate('xpack.lens.metric.label', { + defaultMessage: 'Metric', + }), + }, + ], + + getDescription() { + return { + icon: 'visMetric', + label: i18n.translate('xpack.lens.metric.label', { + defaultMessage: 'Metric', + }), + }; + }, + + getSuggestions, + + initialize(frame, state) { + return ( + state || { + layerId: frame.addNewLayer(), + accessor: generateId(), + } + ); + }, + + getPersistableState: state => state, + + renderConfigPanel: (domElement, props) => + render( + + + , + domElement + ), + + toExpression(state, frame) { + const [datasource] = Object.values(frame.datasourceLayers); + const operation = datasource && datasource.getOperationForColumnId(state.accessor); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_metric_chart', + arguments: { + title: [(operation && operation.label) || ''], + accessor: [state.accessor], + }, + }, + ], + }; + }, +}; diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..832efb6200ee5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/plugin.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { FormatFactory, getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { metricVisualization } from './metric_visualization'; +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { setup as expressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public/legacy'; +import { metricChart, getMetricChartRenderer } from './metric_expression'; + +export interface MetricVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + // TODO this is a simulated NP plugin. + // Once field formatters are actually migrated, the actual shim can be used + fieldFormat: { + formatFactory: FormatFactory; + }; +} + +class MetricVisualizationPlugin { + constructor() {} + + setup( + _core: CoreSetup | null, + { expressions, fieldFormat }: MetricVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => metricChart); + + expressions.registerRenderer(() => getMetricChartRenderer(fieldFormat.formatFactory)); + + return metricVisualization; + } + + stop() {} +} + +const plugin = new MetricVisualizationPlugin(); + +export const metricVisualizationSetup = () => + plugin.setup(null, { + expressions: expressionsSetup, + fieldFormat: { + formatFactory: getFormat, + }, + }); + +export const metricVisualizationStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts new file mode 100644 index 0000000000000..6348d80b15e2f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/metric_visualization_plugin/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface State { + layerId: string; + accessor: string; +} + +export interface MetricConfig extends State { + title: string; + mode: 'reduced' | 'full'; +} + +export type PersistableState = State; diff --git a/x-pack/legacy/plugins/lens/public/multi_column_editor/index.ts b/x-pack/legacy/plugins/lens/public/multi_column_editor/index.ts new file mode 100644 index 0000000000000..92bad0dc90766 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/multi_column_editor/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './multi_column_editor'; diff --git a/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.test.tsx b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.test.tsx new file mode 100644 index 0000000000000..012c27d3ce3ff --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { createMockDatasource } from '../editor_frame_plugin/mocks'; +import { MultiColumnEditor } from './multi_column_editor'; +import { mount } from 'enzyme'; + +jest.useFakeTimers(); + +describe('MultiColumnEditor', () => { + it('should add a trailing accessor if the accessor list is empty', () => { + const onAdd = jest.fn(); + mount( + true} + layerId="foo" + onAdd={onAdd} + onRemove={jest.fn()} + testSubj="bar" + /> + ); + + expect(onAdd).toHaveBeenCalledTimes(0); + + jest.runAllTimers(); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('should add a trailing accessor if the last accessor is configured', () => { + const onAdd = jest.fn(); + mount( + true} + layerId="foo" + onAdd={onAdd} + onRemove={jest.fn()} + testSubj="bar" + /> + ); + + expect(onAdd).toHaveBeenCalledTimes(0); + + jest.runAllTimers(); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx new file mode 100644 index 0000000000000..422f1dcf60f3c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/multi_column_editor/multi_column_editor.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { NativeRenderer } from '../native_renderer'; +import { DatasourcePublicAPI, OperationMetadata } from '../types'; +import { DragContextState } from '../drag_drop'; + +interface Props { + accessors: string[]; + datasource: DatasourcePublicAPI; + dragDropContext: DragContextState; + onRemove: (accessor: string) => void; + onAdd: () => void; + filterOperations: (op: OperationMetadata) => boolean; + suggestedPriority?: 0 | 1 | 2 | undefined; + testSubj: string; + layerId: string; +} + +export function MultiColumnEditor({ + accessors, + datasource, + dragDropContext, + onRemove, + onAdd, + filterOperations, + suggestedPriority, + testSubj, + layerId, +}: Props) { + const lastOperation = datasource.getOperationForColumnId(accessors[accessors.length - 1]); + + useEffect(() => { + if (accessors.length === 0 || lastOperation !== null) { + setTimeout(onAdd); + } + }, [lastOperation]); + + return ( + <> + {accessors.map(accessor => ( +
+ +
+ ))} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/index.ts b/x-pack/legacy/plugins/lens/public/native_renderer/index.ts new file mode 100644 index 0000000000000..0ef9bd8807bc5 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/native_renderer/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './native_renderer'; diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.test.tsx new file mode 100644 index 0000000000000..07642e7936d25 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { NativeRenderer } from './native_renderer'; +import { act } from 'react-dom/test-utils'; + +function renderAndTriggerHooks(element: JSX.Element, mountpoint: Element) { + // act takes care of triggering state hooks + act(() => { + render(element, mountpoint); + }); +} + +describe('native_renderer', () => { + let mountpoint: Element; + + beforeEach(() => { + mountpoint = document.createElement('div'); + }); + + afterEach(() => { + mountpoint.remove(); + }); + + it('should render element in container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).toHaveBeenCalledWith(containerElement, testProps); + }); + + it('should render again if props change', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(3); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'def' }); + }); + + it('should render again if props is just a string', () => { + const renderSpy = jest.fn(); + const testProps = 'abc'; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks(, mountpoint); + renderAndTriggerHooks(, mountpoint); + expect(renderSpy).toHaveBeenCalledTimes(3); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, 'def'); + }); + + it('should render again if props are extended', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'abc', b: 'def' }); + }); + + it('should render again if props are limited', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc', b: 'def' }; + + renderAndTriggerHooks( + , + mountpoint + ); + renderAndTriggerHooks( + , + mountpoint + ); + expect(renderSpy).toHaveBeenCalledTimes(2); + const containerElement = mountpoint.firstElementChild; + expect(renderSpy).lastCalledWith(containerElement, { a: 'abc' }); + }); + + it('should render a div as container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement: Element = mountpoint.firstElementChild!; + expect(containerElement.nodeName).toBe('DIV'); + }); + + it('should pass regular html attributes to the wrapping element', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement: HTMLElement = mountpoint.firstElementChild! as HTMLElement; + expect(containerElement.className).toBe('testClass'); + expect(containerElement.dataset.testSubj).toBe('container'); + }); + + it('should render a specified element as container', () => { + const renderSpy = jest.fn(); + const testProps = { a: 'abc' }; + + renderAndTriggerHooks( + , + mountpoint + ); + const containerElement: Element = mountpoint.firstElementChild!; + expect(containerElement.nodeName).toBe('SPAN'); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx new file mode 100644 index 0000000000000..08464dd65f67e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/native_renderer/native_renderer.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { HTMLAttributes } from 'react'; + +export interface NativeRendererProps extends HTMLAttributes { + render: (domElement: Element, props: T) => void; + nativeProps: T; + tag?: string; +} + +/** + * A component which takes care of providing a mountpoint for a generic render + * function which takes an html element and an optional props object. + * By default the mountpoint element will be a div, this can be changed with the + * `tag` prop. + * + * @param props + */ +export function NativeRenderer({ render, nativeProps, tag, ...rest }: NativeRendererProps) { + return React.createElement(tag || 'div', { + ...rest, + ref: el => el && render(el, nativeProps), + }); +} diff --git a/x-pack/legacy/plugins/lens/public/persistence/index.ts b/x-pack/legacy/plugins/lens/public/persistence/index.ts new file mode 100644 index 0000000000000..1f823ff75c8c6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/persistence/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './saved_object_store'; diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts new file mode 100644 index 0000000000000..515d008d82586 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectIndexStore } from './saved_object_store'; + +describe('LensStore', () => { + function testStore(testId?: string) { + const client = { + create: jest.fn(() => Promise.resolve({ id: testId || 'testid' })), + update: jest.fn((_type: string, id: string) => Promise.resolve({ id })), + get: jest.fn(), + }; + + return { + client, + store: new SavedObjectIndexStore(client), + }; + } + + describe('save', () => { + test('creates and returns a visualization document', async () => { + const { client, store } = testStore('FOO'); + const doc = await store.save({ + title: 'Hello', + visualizationType: 'bar', + expression: '', + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, + visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + expect(doc).toEqual({ + id: 'FOO', + title: 'Hello', + visualizationType: 'bar', + expression: '', + state: { + datasourceMetaData: { + filterableIndexPatterns: [], + }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, + visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + expect(client.create).toHaveBeenCalledTimes(1); + expect(client.create).toHaveBeenCalledWith('lens', { + title: 'Hello', + visualizationType: 'bar', + expression: '', + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { + indexpattern: { type: 'index_pattern', indexPattern: '.kibana_test' }, + }, + visualization: { x: 'foo', y: 'baz' }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + }); + + test('updates and returns a visualization document', async () => { + const { client, store } = testStore(); + const doc = await store.save({ + id: 'Gandalf', + title: 'Even the very wise cannot see all ends.', + visualizationType: 'line', + expression: '', + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, + visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + expect(doc).toEqual({ + id: 'Gandalf', + title: 'Even the very wise cannot see all ends.', + visualizationType: 'line', + expression: '', + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, + visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledWith('lens', 'Gandalf', { + title: 'Even the very wise cannot see all ends.', + visualizationType: 'line', + expression: '', + state: { + datasourceMetaData: { filterableIndexPatterns: [] }, + datasourceStates: { indexpattern: { type: 'index_pattern', indexPattern: 'lotr' } }, + visualization: { gear: ['staff', 'pointy hat'] }, + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + }); + }); + + describe('load', () => { + test('throws if an error is returned', async () => { + const { client, store } = testStore(); + client.get = jest.fn(async () => ({ + id: 'Paul', + type: 'lens', + attributes: { + title: 'Hope clouds observation.', + visualizationType: 'dune', + state: '{ "datasource": { "giantWorms": true } }', + }, + error: new Error('shoot dang!'), + })); + + await expect(store.load('Paul')).rejects.toThrow('shoot dang!'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts new file mode 100644 index 0000000000000..5fa7e3f0aca4a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/persistence/saved_object_store.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectAttributes } from 'src/core/server'; +import { Filter } from '@kbn/es-query'; +import { Query } from 'src/plugins/data/common'; + +export interface Document { + id?: string; + type?: string; + visualizationType: string | null; + title: string; + expression: string; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: Record; + visualization: unknown; + query: Query; + filters: Filter[]; + }; +} + +export const DOC_TYPE = 'lens'; + +interface SavedObjectClient { + create: (type: string, object: SavedObjectAttributes) => Promise<{ id: string }>; + update: (type: string, id: string, object: SavedObjectAttributes) => Promise<{ id: string }>; + get: ( + type: string, + id: string + ) => Promise<{ + id: string; + type: string; + attributes: SavedObjectAttributes; + error?: { statusCode: number; message: string }; + }>; +} + +export interface DocumentSaver { + save: (vis: Document) => Promise<{ id: string }>; +} + +export interface DocumentLoader { + load: (id: string) => Promise; +} + +export type SavedObjectStore = DocumentLoader & DocumentSaver; + +export class SavedObjectIndexStore implements SavedObjectStore { + private client: SavedObjectClient; + + constructor(client: SavedObjectClient) { + this.client = client; + } + + async save(vis: Document) { + const { id, type, ...rest } = vis; + // TODO: SavedObjectAttributes should support this kind of object, + // remove this workaround when SavedObjectAttributes is updated. + const attributes = (rest as unknown) as SavedObjectAttributes; + const result = await (id + ? this.client.update(DOC_TYPE, id, attributes) + : this.client.create(DOC_TYPE, attributes)); + + return { ...vis, id: result.id }; + } + + async load(id: string): Promise { + const { type, attributes, error } = await this.client.get(DOC_TYPE, id); + + if (error) { + throw error; + } + + return { + ...attributes, + id, + type, + } as Document; + } +} diff --git a/x-pack/legacy/plugins/lens/public/register_embeddable.ts b/x-pack/legacy/plugins/lens/public/register_embeddable.ts new file mode 100644 index 0000000000000..f86cb91a0029e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/register_embeddable.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { indexPatternDatasourceSetup } from './indexpattern_plugin'; +import { xyVisualizationSetup } from './xy_visualization_plugin'; +import { editorFrameSetup, editorFrameStart } from './editor_frame_plugin'; +import { datatableVisualizationSetup } from './datatable_visualization_plugin'; +import { metricVisualizationSetup } from './metric_visualization_plugin'; + +// bootstrap shimmed plugins to register everything necessary (expression functions and embeddables). +// the new platform will take care of this once in place. +indexPatternDatasourceSetup(); +datatableVisualizationSetup(); +xyVisualizationSetup(); +metricVisualizationSetup(); +editorFrameSetup(); +editorFrameStart(); diff --git a/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts new file mode 100644 index 0000000000000..004f7ab1ce64f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/register_vis_type_alias.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { visualizations } from '../../../../../src/legacy/core_plugins/visualizations/public'; +import { BASE_APP_URL, getEditPath } from '../common'; + +visualizations.types.visTypeAliasRegistry.add({ + aliasUrl: BASE_APP_URL, + name: 'lens', + title: i18n.translate('xpack.lens.visTypeAlias.title', { + defaultMessage: 'Lens Visualizations', + }), + description: i18n.translate('xpack.lens.visTypeAlias.description', { + defaultMessage: `Lens is a simpler way to create basic visualizations`, + }), + icon: 'faceHappy', + appExtensions: { + visualizations: { + docTypes: ['lens'], + searchFields: ['title^3'], + toListItem(savedObject) { + const { id, type, attributes } = savedObject; + const { title } = attributes as { title: string }; + return { + id, + title, + editUrl: getEditPath(id), + icon: 'faceHappy', + isExperimental: true, + savedObjectType: type, + typeTitle: i18n.translate('xpack.lens.visTypeAlias.type', { defaultMessage: 'Lens' }), + }; + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/lens/public/types.ts b/x-pack/legacy/plugins/lens/public/types.ts new file mode 100644 index 0000000000000..2b2c46bf5cd2a --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/types.ts @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Ast } from '@kbn/interpreter/common'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { CoreSetup } from 'src/core/public'; +import { Query } from 'src/plugins/data/common'; +import { KibanaDatatable } from '../../../../../src/legacy/core_plugins/interpreter/common'; +import { DragContextState } from './drag_drop'; +import { Document } from './persistence'; + +// eslint-disable-next-line +export interface EditorFrameOptions {} + +export type ErrorCallback = (e: { message: string }) => void; + +export interface EditorFrameProps { + onError: ErrorCallback; + doc?: Document; + dateRange: { + fromDate: string; + toDate: string; + }; + query: Query; + + // Frame loader (app or embeddable) is expected to call this when it loads and updates + onChange: (newState: { indexPatternTitles: string[]; doc: Document }) => void; +} +export interface EditorFrameInstance { + mount: (element: Element, props: EditorFrameProps) => void; + unmount: () => void; +} + +export interface EditorFrameSetup { + // generic type on the API functions to pull the "unknown vs. specific type" error into the implementation + registerDatasource: (name: string, datasource: Datasource) => void; + registerVisualization: (visualization: Visualization) => void; +} + +export interface EditorFrameStart { + createInstance: (options: EditorFrameOptions) => EditorFrameInstance; +} + +// Hints the default nesting to the data source. 0 is the highest priority +export type DimensionPriority = 0 | 1 | 2; + +export interface TableSuggestionColumn { + columnId: string; + operation: Operation; +} + +/** + * A possible table a datasource can create. This object is passed to the visualization + * which tries to build a meaningful visualization given the shape of the table. If this + * is possible, the visualization returns a `VisualizationSuggestion` object + */ +export interface TableSuggestion { + /** + * Flag indicating whether the table will include more than one column. + * This is not the case for example for a single metric aggregation + * */ + isMultiRow: boolean; + /** + * The columns of the table. Each column has to be mapped to a dimension in a chart. If a visualization + * can't use all columns of a suggestion, it should not return a `VisualizationSuggestion` based on it + * because there would be unreferenced columns + */ + columns: TableSuggestionColumn[]; + /** + * The layer this table will replace. This is only relevant if the visualization this suggestion is passed + * is currently active and has multiple layers configured. If this suggestion is applied, the table of this + * layer will be replaced by the columns specified in this suggestion + */ + layerId: string; + /** + * A label describing the table. This can be used to provide a title for the `VisualizationSuggestion`, + * but the visualization can also decide to overwrite it. + */ + label?: string; + /** + * The change type indicates what was changed in this table compared to the currently active table of this layer. + */ + changeType: TableChangeType; +} + +/** + * Indicates what was changed in this table compared to the currently active table of this layer. + * * `initial` means the layer associated with this table does not exist in the current configuration + * * `unchanged` means the table is the same in the currently active configuration + * * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them) + * * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns) + */ +export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended'; + +export interface DatasourceSuggestion { + state: T; + table: TableSuggestion; +} + +export interface DatasourceMetaData { + filterableIndexPatterns: Array<{ id: string; title: string }>; +} + +export type StateSetter = (newState: T | ((prevState: T) => T)) => void; + +/** + * Interface for the datasource registry + */ +export interface Datasource { + // For initializing, either from an empty state or from persisted state + // Because this will be called at runtime, state might have a type of `any` and + // datasources should validate their arguments + initialize: (state?: P) => Promise; + + // Given the current state, which parts should be saved? + getPersistableState: (state: T) => P; + + insertLayer: (state: T, newLayerId: string) => T; + removeLayer: (state: T, layerId: string) => T; + getLayers: (state: T) => string[]; + + renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; + + toExpression: (state: T, layerId: string) => Ast | string | null; + + getMetaData: (state: T) => DatasourceMetaData; + + getDatasourceSuggestionsForField: (state: T, field: unknown) => Array>; + getDatasourceSuggestionsFromCurrentState: (state: T) => Array>; + + getPublicAPI: (state: T, setState: StateSetter, layerId: string) => DatasourcePublicAPI; +} + +/** + * This is an API provided to visualizations by the frame, which calls the publicAPI on the datasource + */ +export interface DatasourcePublicAPI { + getTableSpec: () => TableSpec; + getOperationForColumnId: (columnId: string) => Operation | null; + + // Render can be called many times + renderDimensionPanel: (domElement: Element, props: DatasourceDimensionPanelProps) => void; + renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; + + removeColumnInTableSpec: (columnId: string) => void; + moveColumnTo: (columnId: string, targetIndex: number) => void; + duplicateColumn: (columnId: string) => TableSpec; +} + +export interface TableSpecColumn { + // Column IDs are the keys for internal state in data sources and visualizations + columnId: string; +} + +// TableSpec is managed by visualizations +export type TableSpec = TableSpecColumn[]; + +export interface DatasourceDataPanelProps { + state: T; + dragDropContext: DragContextState; + setState: StateSetter; + core: Pick; + query: Query; + dateRange: FramePublicAPI['dateRange']; +} + +// The only way a visualization has to restrict the query building +export interface DatasourceDimensionPanelProps { + layerId: string; + columnId: string; + + dragDropContext: DragContextState; + + // Visualizations can restrict operations based on their own rules + filterOperations: (operation: OperationMetadata) => boolean; + + // Visualizations can hint at the role this dimension would play, which + // affects the default ordering of the query + suggestedPriority?: DimensionPriority; + onRemove?: (accessor: string) => void; +} + +export interface DatasourceLayerPanelProps { + layerId: string; +} + +export type DataType = 'string' | 'number' | 'date' | 'boolean'; + +// An operation represents a column in a table, not any information +// about how the column was created such as whether it is a sum or average. +// Visualizations are able to filter based on the output, not based on the +// underlying data +export interface Operation extends OperationMetadata { + // User-facing label for the operation + label: string; +} + +export interface OperationMetadata { + // The output of this operation will have this data type + dataType: DataType; + // A bucketed operation is grouped by duplicate values, otherwise each row is + // treated as unique + isBucketed: boolean; + scale?: 'ordinal' | 'interval' | 'ratio'; + // Extra meta-information like cardinality, color + // TODO currently it's not possible to differentiate between a field from a raw + // document and an aggregated metric which might be handy in some cases. Once we + // introduce a raw document datasource, this should be considered here. +} + +export interface LensMultiTable { + type: 'lens_multitable'; + tables: Record; +} + +export interface VisualizationProps { + dragDropContext: DragContextState; + frame: FramePublicAPI; + state: T; + setState: (newState: T) => void; +} + +/** + * Object passed to `getSuggestions` of a visualization. + * It contains a possible table the current datasource could + * provide and the state of the visualization if it is currently active. + * + * If the current datasource suggests multiple tables, `getSuggestions` + * is called multiple times with separate `SuggestionRequest` objects. + */ +export interface SuggestionRequest { + /** + * A table configuration the datasource could provide. + */ + table: TableSuggestion; + /** + * State is only passed if the visualization is active. + */ + state?: T; +} + +/** + * A possible configuration of a given visualization. It is based on a `TableSuggestion`. + * Suggestion might be shown in the UI to be chosen by the user directly, but they are + * also applied directly under some circumstances (dragging in the first field from the data + * panel or switching to another visualization in the chart switcher). + */ +export interface VisualizationSuggestion { + /** + * The score of a suggestion should indicate how valuable the suggestion is. It is used + * to rank multiple suggestions of multiple visualizations. The number should be between 0 and 1 + */ + score: number; + /** + * Flag indicating whether this suggestion should not be advertised to the user. It is still + * considered in scenarios where the available suggestion with the highest suggestion is applied + * directly. + */ + hide?: boolean; + /** + * Descriptive title of the suggestion. Should be as short as possible. This title is shown if + * the suggestion is advertised to the user and will also show either the `previewExpression` or + * the `previewIcon` + */ + title: string; + /** + * The new state of the visualization if this suggestion is applied. + */ + state: T; + /** + * The expression of the preview of the chart rendered if the suggestion is advertised to the user. + * If there is no expression provided, the preview icon is used. + */ + previewExpression?: Ast | string; + /** + * An EUI icon type shown instead of the preview expression. + */ + previewIcon: string; +} + +export interface FramePublicAPI { + datasourceLayers: Record; + dateRange: { + fromDate: string; + toDate: string; + }; + query: Query; + + // Adds a new layer. This has a side effect of updating the datasource state + addNewLayer: () => string; + removeLayers: (layerIds: string[]) => void; +} + +export interface VisualizationType { + id: string; + icon?: EuiIconType | string; + label: string; +} + +export interface Visualization { + id: string; + + visualizationTypes: VisualizationType[]; + + getDescription: ( + state: T + ) => { + icon?: EuiIconType | string; + label: string; + }; + + switchVisualizationType?: (visualizationTypeId: string, state: T) => T; + + // For initializing from saved object + initialize: (frame: FramePublicAPI, state?: P) => T; + + getPersistableState: (state: T) => P; + + renderConfigPanel: (domElement: Element, props: VisualizationProps) => void; + + toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; + + // The frame will call this function on all visualizations when the table changes, or when + // rendering additional ways of using the data + getSuggestions: (context: SuggestionRequest) => Array>; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap new file mode 100644 index 0000000000000..b6cd37a9f7568 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_expression.test.tsx.snap @@ -0,0 +1,454 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`xy_expression XYChart component it renders area 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders bar 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders line 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders stacked area 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders stacked bar 1`] = ` + + + + + + +`; + +exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = ` + + + + + + +`; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap new file mode 100644 index 0000000000000..12902f548e45b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/__snapshots__/xy_visualization.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`xy_visualization #toExpression should map to a valid AST 1`] = ` +Object { + "chain": Array [ + Object { + "arguments": Object { + "isHorizontal": Array [ + false, + ], + "layers": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "accessors": Array [ + "b", + "c", + ], + "columnToLabel": Array [ + "{\\"b\\":\\"col_b\\",\\"c\\":\\"col_c\\",\\"d\\":\\"col_d\\"}", + ], + "hide": Array [ + false, + ], + "isHistogram": Array [ + false, + ], + "layerId": Array [ + "first", + ], + "seriesType": Array [ + "area", + ], + "splitAccessor": Array [ + "d", + ], + "xAccessor": Array [ + "a", + ], + "xScaleType": Array [ + "linear", + ], + "yScaleType": Array [ + "linear", + ], + }, + "function": "lens_xy_layer", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "legend": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "isVisible": Array [ + true, + ], + "position": Array [ + "bottom", + ], + }, + "function": "lens_xy_legendConfig", + "type": "function", + }, + ], + "type": "expression", + }, + ], + "xTitle": Array [ + "col_a", + ], + "yTitle": Array [ + "col_b", + ], + }, + "function": "lens_xy_chart", + "type": "function", + }, + ], + "type": "expression", +} +`; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_config_panel.scss b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_config_panel.scss new file mode 100644 index 0000000000000..284c25f0f3792 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_config_panel.scss @@ -0,0 +1,20 @@ +.lnsConfigPanel__panel { + margin-bottom: $euiSizeS; +} + +.lnsConfigPanel__axis { + background: $euiColorLightestShade; + padding: $euiSizeS; + border-radius: $euiBorderRadius; + + // Add margin to the top of the next same panel + & + & { + margin-top: $euiSizeS; + } +} + +.lnsConfigPanel__addLayerBtn { + color: transparentize($euiColorMediumShade, .3); + box-shadow: none !important; + border: 1px dashed currentColor; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss new file mode 100644 index 0000000000000..ec94ebdf235b0 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/_xy_expression.scss @@ -0,0 +1,3 @@ +.lnsChart { + height: 100%; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.scss b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.scss new file mode 100644 index 0000000000000..9508c8cb9a05c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.scss @@ -0,0 +1,2 @@ +@import "./_xy_config_panel"; +@import "./_xy_expression"; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.ts new file mode 100644 index 0000000000000..f75dce9b7507f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './plugin'; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx new file mode 100644 index 0000000000000..f5f7664b4d352 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/plugin.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, UiSettingsClientContract } from 'src/core/public'; +import chrome, { Chrome } from 'ui/chrome'; +import moment from 'moment-timezone'; +import { getFormat, FormatFactory } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { ExpressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { setup as expressionsSetup } from '../../../../../../src/legacy/core_plugins/expressions/public/legacy'; +import { xyVisualization } from './xy_visualization'; +import { xyChart, getXyChartRenderer } from './xy_expression'; +import { legendConfig, xConfig, layerConfig } from './types'; + +export interface XyVisualizationPluginSetupPlugins { + expressions: ExpressionsSetup; + chrome: Chrome; + // TODO this is a simulated NP plugin. + // Once field formatters are actually migrated, the actual shim can be used + fieldFormat: { + formatFactory: FormatFactory; + }; +} + +function getTimeZone(uiSettings: UiSettingsClientContract) { + const configuredTimeZone = uiSettings.get('dateFormat:tz'); + if (configuredTimeZone === 'Browser') { + return moment.tz.guess(); + } + + return configuredTimeZone; +} + +class XyVisualizationPlugin { + constructor() {} + + setup( + _core: CoreSetup | null, + { + expressions, + fieldFormat: { formatFactory }, + chrome: { getUiSettingsClient }, + }: XyVisualizationPluginSetupPlugins + ) { + expressions.registerFunction(() => legendConfig); + expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => layerConfig); + expressions.registerFunction(() => xyChart); + + expressions.registerRenderer(() => + getXyChartRenderer({ + formatFactory, + timeZone: getTimeZone(getUiSettingsClient()), + }) + ); + + return xyVisualization; + } + + stop() {} +} + +const plugin = new XyVisualizationPlugin(); + +export const xyVisualizationSetup = () => + plugin.setup(null, { + expressions: expressionsSetup, + fieldFormat: { + formatFactory: getFormat, + }, + chrome, + }); +export const xyVisualizationStop = () => plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts new file mode 100644 index 0000000000000..bbb27bae778b2 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/to_expression.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Ast } from '@kbn/interpreter/common'; +import { ScaleType } from '@elastic/charts'; +import { State, LayerConfig } from './types'; +import { FramePublicAPI, OperationMetadata } from '../types'; + +function xyTitles(layer: LayerConfig, frame: FramePublicAPI) { + const defaults = { + xTitle: 'x', + yTitle: 'y', + }; + + if (!layer || !layer.accessors.length) { + return defaults; + } + const datasource = frame.datasourceLayers[layer.layerId]; + if (!datasource) { + return defaults; + } + const x = datasource.getOperationForColumnId(layer.xAccessor); + const y = datasource.getOperationForColumnId(layer.accessors[0]); + + return { + xTitle: x ? x.label : defaults.xTitle, + yTitle: y ? y.label : defaults.yTitle, + }; +} + +export const toExpression = (state: State, frame: FramePublicAPI): Ast | null => { + if (!state || !state.layers.length) { + return null; + } + + const stateWithValidAccessors = { + ...state, + layers: state.layers.map(layer => { + const datasource = frame.datasourceLayers[layer.layerId]; + + const newLayer = { ...layer }; + + if (!datasource.getOperationForColumnId(layer.splitAccessor)) { + delete newLayer.splitAccessor; + } + + return { + ...newLayer, + accessors: layer.accessors.filter(accessor => + Boolean(datasource.getOperationForColumnId(accessor)) + ), + }; + }), + }; + + const metadata: Record> = {}; + state.layers.forEach(layer => { + metadata[layer.layerId] = {}; + const datasource = frame.datasourceLayers[layer.layerId]; + datasource.getTableSpec().forEach(column => { + const operation = frame.datasourceLayers[layer.layerId].getOperationForColumnId( + column.columnId + ); + metadata[layer.layerId][column.columnId] = operation; + }); + }); + + return buildExpression( + stateWithValidAccessors, + metadata, + frame, + xyTitles(state.layers[0], frame) + ); +}; + +export function getScaleType(metadata: OperationMetadata | null, defaultScale: ScaleType) { + if (!metadata) { + return defaultScale; + } + + // use scale information if available + if (metadata.scale === 'ordinal') { + return ScaleType.Ordinal; + } + if (metadata.scale === 'interval' || metadata.scale === 'ratio') { + return metadata.dataType === 'date' ? ScaleType.Time : ScaleType.Linear; + } + + // fall back to data type if necessary + switch (metadata.dataType) { + case 'boolean': + case 'string': + return ScaleType.Ordinal; + case 'date': + return ScaleType.Time; + default: + return ScaleType.Linear; + } +} + +export const buildExpression = ( + state: State, + metadata: Record>, + frame?: FramePublicAPI, + { xTitle, yTitle }: { xTitle: string; yTitle: string } = { xTitle: '', yTitle: '' } +): Ast => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_chart', + arguments: { + xTitle: [xTitle], + yTitle: [yTitle], + isHorizontal: [state.isHorizontal], + legend: [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_legendConfig', + arguments: { + isVisible: [state.legend.isVisible], + position: [state.legend.position], + }, + }, + ], + }, + ], + layers: state.layers.map(layer => { + const columnToLabel: Record = {}; + + if (frame) { + const datasource = frame.datasourceLayers[layer.layerId]; + layer.accessors.concat([layer.splitAccessor]).forEach(accessor => { + const operation = datasource.getOperationForColumnId(accessor); + if (operation && operation.label) { + columnToLabel[accessor] = operation.label; + } + }); + } + + const xAxisOperation = + frame && frame.datasourceLayers[layer.layerId].getOperationForColumnId(layer.xAccessor); + + const isHistogramDimension = Boolean( + xAxisOperation && + xAxisOperation.isBucketed && + xAxisOperation.scale && + xAxisOperation.scale !== 'ordinal' + ); + + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_layer', + arguments: { + layerId: [layer.layerId], + + hide: [Boolean(layer.hide)], + + xAccessor: [layer.xAccessor], + yScaleType: [ + getScaleType(metadata[layer.layerId][layer.accessors[0]], ScaleType.Ordinal), + ], + xScaleType: [ + getScaleType(metadata[layer.layerId][layer.xAccessor], ScaleType.Linear), + ], + isHistogram: [isHistogramDimension], + splitAccessor: [layer.splitAccessor], + seriesType: [layer.seriesType], + accessors: layer.accessors, + columnToLabel: [JSON.stringify(columnToLabel)], + }, + }, + ], + }; + }), + }, + }, + ], +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts new file mode 100644 index 0000000000000..742cc36be4ea6 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/types.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Position } from '@elastic/charts'; +import { i18n } from '@kbn/i18n'; +import { + ExpressionFunction, + ArgumentType, +} from '../../../../../../src/legacy/core_plugins/interpreter/public'; +import { VisualizationType } from '..'; + +export interface LegendConfig { + isVisible: boolean; + position: Position; +} + +type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; + +export const legendConfig: ExpressionFunction< + 'lens_xy_legendConfig', + null, + LegendConfig, + LegendConfigResult +> = { + name: 'lens_xy_legendConfig', + aliases: [], + type: 'lens_xy_legendConfig', + help: `Configure the xy chart's legend`, + context: { + types: ['null'], + }, + args: { + isVisible: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.isVisible.help', { + defaultMessage: 'Specifies whether or not the legend is visible.', + }), + }, + position: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: i18n.translate('xpack.lens.xyChart.position.help', { + defaultMessage: 'Specifies the legend position.', + }), + }, + }, + fn: function fn(_context: unknown, args: LegendConfig) { + return { + type: 'lens_xy_legendConfig', + ...args, + }; + }, +}; + +interface AxisConfig { + title: string; + hide?: boolean; +} + +const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = { + title: { + types: ['string'], + help: i18n.translate('xpack.lens.xyChart.title.help', { + defaultMessage: 'The axis title', + }), + }, + hide: { + types: ['boolean'], + default: false, + help: 'Show / hide axis', + }, +}; + +export interface YState extends AxisConfig { + accessors: string[]; +} + +export interface XConfig extends AxisConfig { + accessor: string; +} + +type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; + +export const xConfig: ExpressionFunction<'lens_xy_xConfig', null, XConfig, XConfigResult> = { + name: 'lens_xy_xConfig', + aliases: [], + type: 'lens_xy_xConfig', + help: `Configure the xy chart's x axis`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + accessor: { + types: ['string'], + help: 'The column to display on the x axis.', + }, + }, + fn: function fn(_context: unknown, args: XConfig) { + return { + type: 'lens_xy_xConfig', + ...args, + }; + }, +}; + +type LayerConfigResult = LayerArgs & { type: 'lens_xy_layer' }; + +export const layerConfig: ExpressionFunction< + 'lens_xy_layer', + null, + LayerArgs, + LayerConfigResult +> = { + name: 'lens_xy_layer', + aliases: [], + type: 'lens_xy_layer', + help: `Configure a layer in the xy chart`, + context: { + types: ['null'], + }, + args: { + ...axisConfig, + layerId: { + types: ['string'], + help: '', + }, + xAccessor: { + types: ['string'], + help: '', + }, + seriesType: { + types: ['string'], + options: ['bar', 'line', 'area', 'bar_stacked', 'area_stacked'], + help: 'The type of chart to display.', + }, + xScaleType: { + options: ['ordinal', 'linear', 'time'], + help: 'The scale type of the x axis', + default: 'ordinal', + }, + isHistogram: { + types: ['boolean'], + default: false, + help: 'Whether to layout the chart as a histogram', + }, + yScaleType: { + options: ['log', 'sqrt', 'linear', 'time'], + help: 'The scale type of the y axes', + default: 'linear', + }, + splitAccessor: { + types: ['string'], + help: 'The column to split by', + multi: false, + }, + accessors: { + types: ['string'], + help: 'The columns to display on the y axis.', + multi: true, + }, + columnToLabel: { + types: ['string'], + help: 'JSON key-value pairs of column ID to label', + }, + }, + fn: function fn(_context: unknown, args: LayerArgs) { + return { + type: 'lens_xy_layer', + ...args, + }; + }, +}; + +export type SeriesType = 'bar' | 'line' | 'area' | 'bar_stacked' | 'area_stacked'; + +export interface LayerConfig { + hide?: boolean; + layerId: string; + xAccessor: string; + accessors: string[]; + seriesType: SeriesType; + splitAccessor: string; +} + +export type LayerArgs = LayerConfig & { + columnToLabel?: string; // Actually a JSON key-value pair + yScaleType: 'time' | 'linear' | 'log' | 'sqrt'; + xScaleType: 'time' | 'linear' | 'ordinal'; + isHistogram: boolean; +}; + +// Arguments to XY chart expression, with computed properties +export interface XYArgs { + xTitle: string; + yTitle: string; + legend: LegendConfig; + layers: LayerArgs[]; + isHorizontal: boolean; +} + +// Persisted parts of the state +export interface XYState { + preferredSeriesType: SeriesType; + legend: LegendConfig; + layers: LayerConfig[]; + isHorizontal: boolean; +} + +export type State = XYState; +export type PersistableState = XYState; + +export const visualizationTypes: VisualizationType[] = [ + { + id: 'bar', + icon: 'visBarVertical', + label: i18n.translate('xpack.lens.xyVisualization.barLabel', { + defaultMessage: 'Bar', + }), + }, + { + id: 'bar_stacked', + icon: 'visBarVertical', + label: i18n.translate('xpack.lens.xyVisualization.stackedBarLabel', { + defaultMessage: 'Stacked Bar', + }), + }, + { + id: 'line', + icon: 'visLine', + label: i18n.translate('xpack.lens.xyVisualization.lineLabel', { + defaultMessage: 'Line', + }), + }, + { + id: 'area', + icon: 'visArea', + label: i18n.translate('xpack.lens.xyVisualization.areaLabel', { + defaultMessage: 'Area', + }), + }, + { + id: 'area_stacked', + icon: 'visArea', + label: i18n.translate('xpack.lens.xyVisualization.stackedAreaLabel', { + defaultMessage: 'Stacked Area', + }), + }, +]; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx new file mode 100644 index 0000000000000..ad08b8949f3b9 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.test.tsx @@ -0,0 +1,422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent } from 'react'; +import { ReactWrapper } from 'enzyme'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { EuiButtonGroupProps } from '@elastic/eui'; +import { XYConfigPanel } from './xy_config_panel'; +import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types'; +import { State, XYState } from './types'; +import { Position } from '@elastic/charts'; +import { NativeRendererProps } from '../native_renderer'; +import { generateId } from '../id_generator'; +import { createMockFramePublicAPI, createMockDatasource } from '../editor_frame_plugin/mocks'; +import { act } from 'react-test-renderer'; + +jest.mock('../id_generator'); + +describe('XYConfigPanel', () => { + const dragDropContext = { dragging: undefined, setDragging: jest.fn() }; + + let frame: FramePublicAPI; + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + preferredSeriesType: 'bar', + isHorizontal: false, + layers: [ + { + seriesType: 'bar', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + } + + function testSubj(component: ReactWrapper, subj: string) { + return component + .find(`[data-test-subj="${subj}"]`) + .first() + .props(); + } + + function openComponentPopover(component: ReactWrapper, layerId: string) { + component + .find(`[data-test-subj="lnsXY_layer_${layerId}"]`) + .first() + .find(`[data-test-subj="lnsXY_layer_advanced"]`) + .first() + .simulate('click'); + } + + beforeEach(() => { + frame = createMockFramePublicAPI(); + frame.datasourceLayers = { + first: createMockDatasource().publicAPIMock, + }; + }); + + test.skip('toggles axis position when going from horizontal bar to any other type', () => {}); + test.skip('allows toggling of legend visibility', () => {}); + test.skip('allows changing legend position', () => {}); + test.skip('allows toggling the y axis gridlines', () => {}); + test.skip('allows toggling the x axis gridlines', () => {}); + + test('puts the horizontal toggle in a popover', () => { + const state = testState(); + const setState = jest.fn(); + const component = mount( + + ); + + component + .find(`[data-test-subj="lnsXY_chart_settings"]`) + .first() + .simulate('click'); + + act(() => { + component + .find('[data-test-subj="lnsXY_chart_horizontal"]') + .first() + .prop('onChange')!({} as FormEvent); + }); + + expect(setState).toHaveBeenCalledWith({ + ...state, + isHorizontal: true, + }); + }); + + test('enables stacked chart types even when there is no split series', () => { + const state = testState(); + const component = mount( + + ); + + openComponentPopover(component, 'first'); + + const options = component + .find('[data-test-subj="lnsXY_seriesType"]') + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ id }) => id)).toEqual([ + 'bar', + 'bar_stacked', + 'line', + 'area', + 'area_stacked', + ]); + + expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]); + }); + + test('the x dimension panel accepts only bucketed operations', () => { + // TODO: this should eventually also accept raw operation + const state = testState(); + const component = mount( + + ); + + const panel = testSubj(component, 'lnsXY_xDimensionPanel'); + const nativeProps = (panel as NativeRendererProps).nativeProps; + const { columnId, filterOperations } = nativeProps; + const exampleOperation: Operation = { + dataType: 'number', + isBucketed: false, + label: 'bar', + }; + const bucketedOps: Operation[] = [ + { ...exampleOperation, isBucketed: true, dataType: 'number' }, + { ...exampleOperation, isBucketed: true, dataType: 'string' }, + { ...exampleOperation, isBucketed: true, dataType: 'boolean' }, + { ...exampleOperation, isBucketed: true, dataType: 'date' }, + ]; + const ops: Operation[] = [ + ...bucketedOps, + { ...exampleOperation, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(columnId).toEqual('shazm'); + expect(ops.filter(filterOperations)).toEqual(bucketedOps); + }); + + test('the y dimension panel accepts numeric operations', () => { + const state = testState(); + const component = mount( + + ); + + const filterOperations = component + .find('[data-test-subj="lensXY_yDimensionPanel"]') + .first() + .prop('filterOperations') as (op: Operation) => boolean; + + const exampleOperation: Operation = { + dataType: 'number', + isBucketed: false, + label: 'bar', + }; + const ops: Operation[] = [ + { ...exampleOperation, dataType: 'number' }, + { ...exampleOperation, dataType: 'string' }, + { ...exampleOperation, dataType: 'boolean' }, + { ...exampleOperation, dataType: 'date' }, + ]; + expect(ops.filter(filterOperations).map(x => x.dataType)).toEqual(['number']); + }); + + test('allows removal of y dimensions', () => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + openComponentPopover(component, 'first'); + + const onRemove = component + .find('[data-test-subj="lensXY_yDimensionPanel"]') + .first() + .prop('onRemove') as (accessor: string) => {}; + + onRemove('b'); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [ + { + ...state.layers[0], + accessors: ['a', 'c'], + }, + ], + }); + }); + + test('allows adding a y axis dimension', () => { + (generateId as jest.Mock).mockReturnValueOnce('zed'); + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + const onAdd = component + .find('[data-test-subj="lensXY_yDimensionPanel"]') + .first() + .prop('onAdd') as () => {}; + + onAdd(); + + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [ + { + ...state.layers[0], + accessors: ['a', 'b', 'c', 'zed'], + }, + ], + }); + }); + + describe('layers', () => { + it('adds layers', () => { + frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); + (generateId as jest.Mock).mockReturnValue('accessor'); + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + component + .find('[data-test-subj="lnsXY_layer_add"]') + .first() + .simulate('click'); + + expect(frame.addNewLayer).toHaveBeenCalled(); + expect(setState).toHaveBeenCalledTimes(1); + expect(generateId).toHaveBeenCalledTimes(4); + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [ + ...state.layers, + expect.objectContaining({ + layerId: 'newLayerId', + xAccessor: 'accessor', + accessors: ['accessor'], + splitAccessor: 'accessor', + }), + ], + }); + }); + + it('should use series type of existing layers if they all have the same', () => { + frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); + frame.datasourceLayers.second = createMockDatasource().publicAPIMock; + (generateId as jest.Mock).mockReturnValue('accessor'); + const setState = jest.fn(); + const state: XYState = { + ...testState(), + preferredSeriesType: 'bar', + layers: [ + { + seriesType: 'line', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + { + seriesType: 'line', + layerId: 'second', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + const component = mount( + + ); + + component + .find('[data-test-subj="lnsXY_layer_add"]') + .first() + .simulate('click'); + + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [ + ...state.layers, + expect.objectContaining({ + seriesType: 'line', + }), + ], + }); + }); + + it('should use preffered series type if there are already various different layers', () => { + frame.addNewLayer = jest.fn().mockReturnValue('newLayerId'); + frame.datasourceLayers.second = createMockDatasource().publicAPIMock; + (generateId as jest.Mock).mockReturnValue('accessor'); + const setState = jest.fn(); + const state: XYState = { + ...testState(), + preferredSeriesType: 'bar', + layers: [ + { + seriesType: 'area', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + { + seriesType: 'line', + layerId: 'second', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + const component = mount( + + ); + + component + .find('[data-test-subj="lnsXY_layer_add"]') + .first() + .simulate('click'); + + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [ + ...state.layers, + expect.objectContaining({ + seriesType: 'bar', + }), + ], + }); + }); + + it('removes layers', () => { + const setState = jest.fn(); + const state = testState(); + const component = mount( + + ); + + openComponentPopover(component, 'first'); + + component + .find('[data-test-subj="lnsXY_layer_remove"]') + .first() + .simulate('click'); + + expect(frame.removeLayers).toHaveBeenCalled(); + expect(setState).toHaveBeenCalledTimes(1); + expect(setState.mock.calls[0][0]).toMatchObject({ + layers: [], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx new file mode 100644 index 0000000000000..4d0c7b7044163 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -0,0 +1,305 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, + EuiButtonIcon, + EuiPopover, + EuiSwitch, + EuiSpacer, + EuiButtonEmpty, + EuiPopoverFooter, +} from '@elastic/eui'; +import { State, SeriesType, LayerConfig, visualizationTypes } from './types'; +import { VisualizationProps, OperationMetadata } from '../types'; +import { NativeRenderer } from '../native_renderer'; +import { MultiColumnEditor } from '../multi_column_editor'; +import { generateId } from '../id_generator'; + +const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; +const isBucketed = (op: OperationMetadata) => op.isBucketed; + +type UnwrapArray = T extends Array ? P : T; + +function updateLayer(state: State, layer: UnwrapArray, index: number): State { + const newLayers = [...state.layers]; + newLayers[index] = layer; + + return { + ...state, + layers: newLayers, + }; +} + +function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig { + return { + layerId, + seriesType, + xAccessor: generateId(), + accessors: [generateId()], + splitAccessor: generateId(), + }; +} + +function LayerSettings({ + layer, + setSeriesType, + removeLayer, +}: { + layer: LayerConfig; + setSeriesType: (seriesType: SeriesType) => void; + removeLayer: () => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const { icon } = visualizationTypes.find(c => c.id === layer.seriesType)!; + + return ( + setIsOpen(!isOpen)} + data-test-subj="lnsXY_layer_advanced" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="leftUp" + > + + ({ + ...t, + iconType: t.icon || 'empty', + }))} + idSelected={layer.seriesType} + onChange={seriesType => setSeriesType(seriesType as SeriesType)} + isIconOnly + /> + + + + {i18n.translate('xpack.lens.xyChart.removeLayer', { + defaultMessage: 'Remove layer', + })} + + + + ); +} + +export function XYConfigPanel(props: VisualizationProps) { + const { state, setState, frame } = props; + const [isChartOptionsOpen, setIsChartOptionsOpen] = useState(false); + + return ( + + setIsChartOptionsOpen(false)} + button={ + setIsChartOptionsOpen(!isChartOptionsOpen)} + aria-label={i18n.translate('xpack.lens.xyChart.chartSettings', { + defaultMessage: 'Chart Settings', + })} + title={i18n.translate('xpack.lens.xyChart.chartSettings', { + defaultMessage: 'Chart Settings', + })} + /> + } + > + { + setState({ + ...state, + isHorizontal: !state.isHorizontal, + }); + }} + data-test-subj="lnsXY_chart_horizontal" + /> + + + {state.layers.map((layer, index) => ( + + + + + setState(updateLayer(state, { ...layer, seriesType }, index)) + } + removeLayer={() => { + frame.removeLayers([layer.layerId]); + setState({ ...state, layers: state.layers.filter(l => l !== layer) }); + }} + /> + + + + + + + + + + + + + + + setState( + updateLayer( + state, + { + ...layer, + accessors: [...layer.accessors, generateId()], + }, + index + ) + ) + } + onRemove={accessor => + setState( + updateLayer( + state, + { + ...layer, + accessors: layer.accessors.filter(col => col !== accessor), + }, + index + ) + ) + } + filterOperations={isNumericMetric} + data-test-subj="lensXY_yDimensionPanel" + testSubj="lensXY_yDimensionPanel" + layerId={layer.layerId} + /> + + + + + + ))} + + { + const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); + setState({ + ...state, + layers: [ + ...state.layers, + newLayerState( + usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType, + frame.addNewLayer() + ), + ], + }); + }} + iconType="plusInCircleFilled" + /> + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx new file mode 100644 index 0000000000000..0ac286c7bb83c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.test.tsx @@ -0,0 +1,403 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Axis } from '@elastic/charts'; +import { AreaSeries, BarSeries, Position, LineSeries, Settings, ScaleType } from '@elastic/charts'; +import { xyChart, XYChart } from './xy_expression'; +import { LensMultiTable } from '../types'; +import React from 'react'; +import { shallow } from 'enzyme'; +import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; + +function sampleArgs() { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { + id: 'a', + name: 'a', + formatHint: { id: 'number', params: { pattern: '0,0.000' } }, + }, + { id: 'b', name: 'b', formatHint: { id: 'number', params: { pattern: '000,0' } } }, + { id: 'c', name: 'c', formatHint: { id: 'string' } }, + ], + rows: [{ a: 1, b: 2, c: 'I' }, { a: 1, b: 5, c: 'J' }], + }, + }, + }; + + const args: XYArgs = { + xTitle: '', + yTitle: '', + isHorizontal: false, + legend: { + isVisible: false, + position: Position.Top, + }, + layers: [ + { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }, + ], + }; + + return { data, args }; +} + +describe('xy_expression', () => { + describe('configs', () => { + test('legendConfig produces the correct arguments', () => { + const args: LegendConfig = { + isVisible: true, + position: Position.Left, + }; + + expect(legendConfig.fn(null, args, {})).toEqual({ + type: 'lens_xy_legendConfig', + ...args, + }); + }); + + test('layerConfig produces the correct arguments', () => { + const args: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + yScaleType: 'linear', + isHistogram: false, + }; + + expect(layerConfig.fn(null, args, {})).toEqual({ + type: 'lens_xy_layer', + ...args, + }); + }); + }); + + describe('xyChart', () => { + test('it renders with the specified data and args', () => { + const { data, args } = sampleArgs(); + + expect(xyChart.fn(data, args, {})).toEqual({ + type: 'render', + as: 'lens_xy_chart_renderer', + value: { data, args }, + }); + }); + }); + + describe('XYChart component', () => { + let getFormatSpy: jest.Mock; + let convertSpy: jest.Mock; + + beforeEach(() => { + convertSpy = jest.fn(x => x); + getFormatSpy = jest.fn(); + getFormatSpy.mockReturnValue({ convert: convertSpy }); + }); + + test('it renders line', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(LineSeries)).toHaveLength(1); + }); + + test('it renders bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + }); + + test('it renders area', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(AreaSeries)).toHaveLength(1); + }); + + test('it renders horizontal bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(Settings).prop('rotation')).toEqual(90); + }); + + test('it renders stacked bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + }); + + test('it renders stacked area', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(AreaSeries)).toHaveLength(1); + expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); + }); + + test('it renders stacked horizontal bar', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(Settings).prop('rotation')).toEqual(90); + }); + + test('it passes time zone to the series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); + }); + + test('it applies histogram mode to the series for single series', () => { + const { data, args } = sampleArgs(); + const firstLayer: LayerArgs = { ...args.layers[0], seriesType: 'bar', isHistogram: true }; + delete firstLayer.splitAccessor; + const component = shallow( + + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + }); + + test('it applies histogram mode to the series for stacked series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + }); + + test('it does not apply histogram mode for splitted series', () => { + const { data, args } = sampleArgs(); + const component = shallow( + + ); + expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); + }); + + test('it rewrites the rows based on provided labels', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(LineSeries).prop('data')).toEqual([ + { 'Label A': 1, 'Label B': 2, c: 'I' }, + { 'Label A': 1, 'Label B': 5, c: 'J' }, + ]); + }); + + test('it uses labels as Y accessors', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); + }); + + test('it set the scale of the x axis according to the args prop', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + }); + + test('it set the scale of the y axis according to the args prop', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); + }); + + test('it gets the formatter for the x axis', () => { + const { data, args } = sampleArgs(); + + shallow( + + ); + + expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); + }); + + test('it gets a default formatter for y if there are multiple y accessors', () => { + const { data, args } = sampleArgs(); + + shallow( + + ); + + expect(getFormatSpy).toHaveBeenCalledTimes(2); + expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); + }); + + test('it gets the formatter for the y axis if there is only one accessor', () => { + const { data, args } = sampleArgs(); + + shallow( + + ); + expect(getFormatSpy).toHaveBeenCalledWith({ + id: 'number', + params: { pattern: '0,0.000' }, + }); + }); + + test('it should pass the formatter function to the axis', () => { + const { data, args } = sampleArgs(); + + const instance = shallow( + + ); + + const tickFormatter = instance + .find(Axis) + .first() + .prop('tickFormat'); + tickFormatter('I'); + + expect(convertSpy).toHaveBeenCalledWith('I'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx new file mode 100644 index 0000000000000..77853c38d075b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_expression.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { + Chart, + Settings, + Axis, + LineSeries, + getAxisId, + getSpecId, + AreaSeries, + BarSeries, + Position, +} from '@elastic/charts'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionFunction } from 'src/legacy/core_plugins/interpreter/types'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, IconType } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; +import { IInterpreterRenderFunction } from '../../../../../../src/legacy/core_plugins/expressions/public'; +import { LensMultiTable } from '../types'; +import { XYArgs, SeriesType, visualizationTypes } from './types'; + +export interface XYChartProps { + data: LensMultiTable; + args: XYArgs; +} + +export interface XYRender { + type: 'render'; + as: 'lens_xy_chart_renderer'; + value: XYChartProps; +} + +export const xyChart: ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs, XYRender> = ({ + name: 'lens_xy_chart', + type: 'render', + help: i18n.translate('xpack.lens.xyChart.help', { + defaultMessage: 'An X/Y chart', + }), + args: { + xTitle: { + types: ['string'], + help: 'X axis title', + }, + yTitle: { + types: ['string'], + help: 'Y axis title', + }, + legend: { + types: ['lens_xy_legendConfig'], + help: i18n.translate('xpack.lens.xyChart.legend.help', { + defaultMessage: 'Configure the chart legend.', + }), + }, + layers: { + types: ['lens_xy_layer'], + help: 'Layers of visual series', + multi: true, + }, + isHorizontal: { + types: ['boolean'], + help: 'Render horizontally', + }, + }, + context: { + types: ['lens_multitable'], + }, + fn(data: LensMultiTable, args: XYArgs) { + return { + type: 'render', + as: 'lens_xy_chart_renderer', + value: { + data, + args, + }, + }; + }, + // TODO the typings currently don't support custom type args. As soon as they do, this can be removed +} as unknown) as ExpressionFunction<'lens_xy_chart', LensMultiTable, XYArgs, XYRender>; + +export interface XYChartProps { + data: LensMultiTable; + args: XYArgs; +} + +export const getXyChartRenderer = (dependencies: { + formatFactory: FormatFactory; + timeZone: string; +}): IInterpreterRenderFunction => ({ + name: 'lens_xy_chart_renderer', + displayName: 'XY Chart', + help: i18n.translate('xpack.lens.xyChart.renderer.help', { + defaultMessage: 'X/Y Chart Renderer', + }), + validate: () => {}, + reuseDomNode: true, + render: async (domNode: Element, config: XYChartProps, _handlers: unknown) => { + ReactDOM.render( + + + , + domNode + ); + }, +}); + +function getIconForSeriesType(seriesType: SeriesType): IconType { + return visualizationTypes.find(c => c.id === seriesType)!.icon || 'empty'; +} + +export function XYChart({ + data, + args, + formatFactory, + timeZone, +}: XYChartProps & { + formatFactory: FormatFactory; + timeZone: string; +}) { + const { legend, layers, isHorizontal } = args; + + if (Object.values(data.tables).every(table => table.rows.length === 0)) { + const icon: IconType = layers.length > 0 ? getIconForSeriesType(layers[0].seriesType) : 'bar'; + return ( + + + + + + + + + + + ); + } + + // use formatting hint of first x axis column to format ticks + const xAxisColumn = Object.values(data.tables)[0].columns.find( + ({ id }) => id === layers[0].xAccessor + ); + const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint); + + // use default number formatter for y axis and use formatting hint if there is just a single y column + let yAxisFormatter = formatFactory({ id: 'number' }); + if (layers.length === 1 && layers[0].accessors.length === 1) { + const firstYAxisColumn = Object.values(data.tables)[0].columns.find( + ({ id }) => id === layers[0].accessors[0] + ); + if (firstYAxisColumn && firstYAxisColumn.formatHint) { + yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); + } + } + + return ( + + + + xAxisFormatter.convert(d)} + /> + + yAxisFormatter.convert(d)} + /> + + {layers.map( + ( + { + splitAccessor, + seriesType, + accessors, + xAccessor, + layerId, + columnToLabel, + yScaleType, + xScaleType, + isHistogram, + }, + index + ) => { + if (!data.tables[layerId] || data.tables[layerId].rows.length === 0) { + return; + } + + const columnToLabelMap = columnToLabel ? JSON.parse(columnToLabel) : {}; + + const rows = data.tables[layerId].rows.map(row => { + const newRow: typeof row = {}; + + // Remap data to { 'Count of documents': 5 } + Object.keys(row).forEach(key => { + if (columnToLabelMap[key]) { + newRow[columnToLabelMap[key]] = row[key]; + } else { + newRow[key] = row[key]; + } + }); + return newRow; + }); + + const splitAccessorLabel = columnToLabelMap[splitAccessor]; + const yAccessors = accessors.map(accessor => columnToLabelMap[accessor] || accessor); + const idForLegend = splitAccessorLabel || yAccessors; + + const seriesProps = { + key: index, + splitSeriesAccessors: [splitAccessorLabel || splitAccessor], + stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], + id: getSpecId(idForLegend), + xAccessor, + yAccessors, + data: rows, + xScaleType, + yScaleType, + enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), + timeZone, + }; + + return seriesType === 'line' ? ( + + ) : seriesType === 'bar' || seriesType === 'bar_stacked' ? ( + + ) : ( + + ); + } + )} + + ); +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts new file mode 100644 index 0000000000000..ececea6a1d99f --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.test.ts @@ -0,0 +1,380 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSuggestions } from './xy_suggestions'; +import { + TableSuggestionColumn, + VisualizationSuggestion, + DataType, + TableSuggestion, +} from '../types'; +import { State, XYState } from './types'; +import { generateId } from '../id_generator'; +import { Ast } from '@kbn/interpreter/target/common'; + +jest.mock('../id_generator'); + +describe('xy_suggestions', () => { + function numCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'number', + label: `Avg ${columnId}`, + isBucketed: false, + scale: 'ratio', + }, + }; + } + + function strCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'string', + label: `Top 5 ${columnId}`, + isBucketed: true, + scale: 'ordinal', + }, + }; + } + + function dateCol(columnId: string): TableSuggestionColumn { + return { + columnId, + operation: { + dataType: 'date', + isBucketed: true, + label: `${columnId} histogram`, + scale: 'interval', + }, + }; + } + + // Helper that plucks out the important part of a suggestion for + // most test assertions + function suggestionSubset(suggestion: VisualizationSuggestion) { + return suggestion.state.layers.map(({ seriesType, splitAccessor, xAccessor, accessors }) => ({ + seriesType, + splitAccessor, + x: xAccessor, + y: accessors, + })); + } + + test('ignores invalid combinations', () => { + const unknownCol = () => { + const str = strCol('foo'); + return { ...str, operation: { ...str.operation, dataType: 'wonkies' as DataType } }; + }; + + expect( + ([ + { + isMultiRow: true, + columns: [dateCol('a')], + layerId: 'first', + changeType: 'unchanged', + }, + { + isMultiRow: true, + columns: [strCol('foo'), strCol('bar')], + layerId: 'first', + changeType: 'unchanged', + }, + { + isMultiRow: false, + columns: [strCol('foo'), numCol('bar')], + layerId: 'first', + changeType: 'unchanged', + }, + { + isMultiRow: true, + columns: [unknownCol(), numCol('bar')], + layerId: 'first', + changeType: 'unchanged', + }, + ] as TableSuggestion[]).map(table => expect(getSuggestions({ table })).toEqual([])) + ); + }); + + test('suggests a basic x y chart with date on x', () => { + (generateId as jest.Mock).mockReturnValueOnce('aaa'); + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "area", + "splitAccessor": "aaa", + "x": "date", + "y": Array [ + "bytes", + ], + }, + ] + `); + }); + + test('does not suggest multiple splits', () => { + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [ + numCol('price'), + numCol('quantity'), + dateCol('date'), + strCol('product'), + strCol('city'), + ], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + expect(suggestions).toHaveLength(0); + }); + + test('suggests a split x y chart with date on x', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "area", + "splitAccessor": "product", + "x": "date", + "y": Array [ + "price", + "quantity", + ], + }, + ] + `); + }); + + test('uses datasource provided title if available', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + label: 'Datasource title', + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.title).toEqual('Datasource title'); + }); + + test('hides reduced suggestions if there is a current state', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'reduced', + }, + state: { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price', 'quantity'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'product', + xAccessor: 'date', + }, + ], + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.hide).toBeTruthy(); + }); + + test('does not hide reduced suggestions if xy visualization is not active', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'reduced', + }, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.hide).toBeFalsy(); + }); + + test('suggests an area chart for unchanged table and existing bar chart on non-ordinal x axis', () => { + const currentState: XYState = { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price', 'quantity'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'product', + xAccessor: 'date', + }, + ], + }; + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.state).toEqual({ + ...currentState, + preferredSeriesType: 'area', + layers: [{ ...currentState.layers[0], seriesType: 'area' }], + }); + expect(suggestion.previewIcon).toEqual('visArea'); + expect(suggestion.title).toEqual('Area chart'); + }); + + test('suggests a flipped chart for unchanged table and existing bar chart on ordinal x axis', () => { + (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); + const currentState: XYState = { + isHorizontal: false, + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price', 'quantity'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'dummyCol', + xAccessor: 'product', + }, + ], + }; + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), numCol('quantity'), strCol('product')], + layerId: 'first', + changeType: 'unchanged', + }, + state: currentState, + }); + + expect(rest).toHaveLength(0); + expect(suggestion.state).toEqual({ + ...currentState, + isHorizontal: true, + }); + expect(suggestion.title).toEqual('Flip'); + }); + + test('handles two numeric values', () => { + (generateId as jest.Mock).mockReturnValueOnce('ddd'); + const [suggestion] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('quantity'), numCol('price')], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "ddd", + "x": "quantity", + "y": Array [ + "price", + ], + }, + ] + `); + }); + + test('handles unbucketed suggestions', () => { + (generateId as jest.Mock).mockReturnValueOnce('eee'); + const [suggestion] = getSuggestions({ + table: { + isMultiRow: true, + columns: [ + numCol('num votes'), + { + columnId: 'mybool', + operation: { + dataType: 'boolean', + isBucketed: false, + label: 'Yes / No', + }, + }, + ], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + expect(suggestionSubset(suggestion)).toMatchInlineSnapshot(` + Array [ + Object { + "seriesType": "bar", + "splitAccessor": "eee", + "x": "mybool", + "y": Array [ + "num votes", + ], + }, + ] + `); + }); + + test('adds a preview expression with disabled axes and legend', () => { + const [suggestion] = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('bytes'), dateCol('date')], + layerId: 'first', + changeType: 'unchanged', + }, + }); + + const expression = suggestion.previewExpression! as Ast; + + expect( + (expression.chain[0].arguments.legend[0] as Ast).chain[0].arguments.isVisible[0] + ).toBeFalsy(); + expect( + (expression.chain[0].arguments.layers[0] as Ast).chain[0].arguments.hide[0] + ).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts new file mode 100644 index 0000000000000..3ffd15067e73c --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_suggestions.ts @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { partition } from 'lodash'; +import { Position } from '@elastic/charts'; +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import { + SuggestionRequest, + VisualizationSuggestion, + TableSuggestionColumn, + TableSuggestion, + OperationMetadata, + TableChangeType, +} from '../types'; +import { State, SeriesType, XYState } from './types'; +import { generateId } from '../id_generator'; +import { buildExpression } from './to_expression'; + +const columnSortOrder = { + date: 0, + string: 1, + boolean: 2, + number: 3, +}; + +function getIconForSeries(type: SeriesType): EuiIconType { + switch (type) { + case 'area': + case 'area_stacked': + return 'visArea'; + case 'bar': + case 'bar_stacked': + return 'visBarVertical'; + case 'line': + return 'visLine'; + default: + throw new Error('unknown series type'); + } +} + +/** + * Generate suggestions for the xy chart. + * + * @param opts + */ +export function getSuggestions({ + table, + state, +}: SuggestionRequest): Array> { + if ( + // We only render line charts for multi-row queries. We require at least + // two columns: one for x and at least one for y, and y columns must be numeric. + // We reject any datasource suggestions which have a column of an unknown type. + !table.isMultiRow || + table.columns.length <= 1 || + table.columns.every(col => col.operation.dataType !== 'number') || + table.columns.some(col => !columnSortOrder.hasOwnProperty(col.operation.dataType)) + ) { + return []; + } + + const suggestion = getSuggestionForColumns(table, state); + + if (suggestion) { + return [suggestion]; + } + + return []; +} + +function getSuggestionForColumns( + table: TableSuggestion, + currentState?: State +): VisualizationSuggestion | undefined { + const [buckets, values] = partition( + prioritizeColumns(table.columns), + col => col.operation.isBucketed + ); + + if (buckets.length === 1 || buckets.length === 2) { + const [x, splitBy] = buckets; + return getSuggestion( + table.layerId, + table.changeType, + x, + values, + splitBy, + currentState, + table.label + ); + } else if (buckets.length === 0) { + const [x, ...yValues] = values; + return getSuggestion( + table.layerId, + table.changeType, + x, + yValues, + undefined, + currentState, + table.label + ); + } +} + +// This shuffles columns around so that the left-most column defualts to: +// date, string, boolean, then number, in that priority. We then use this +// order to pluck out the x column, and the split / stack column. +function prioritizeColumns(columns: TableSuggestionColumn[]) { + return [...columns].sort( + (a, b) => columnSortOrder[a.operation.dataType] - columnSortOrder[b.operation.dataType] + ); +} + +function getSuggestion( + layerId: string, + changeType: TableChangeType, + xValue: TableSuggestionColumn, + yValues: TableSuggestionColumn[], + splitBy?: TableSuggestionColumn, + currentState?: State, + tableLabel?: string +): VisualizationSuggestion { + const title = getSuggestionTitle(yValues, xValue, tableLabel); + const seriesType: SeriesType = getSeriesType(currentState, layerId, xValue, changeType); + const isHorizontal = currentState ? currentState.isHorizontal : false; + + const options = { + isHorizontal, + currentState, + seriesType, + layerId, + title, + yValues, + splitBy, + changeType, + xValue, + }; + + const isSameState = currentState && changeType === 'unchanged'; + + if (!isSameState) { + return buildSuggestion(options); + } + + // if current state is using the same data, suggest same chart with different presentational configuration + + if (xValue.operation.scale === 'ordinal') { + // flip between horizontal/vertical for ordinal scales + return buildSuggestion({ + ...options, + title: i18n.translate('xpack.lens.xySuggestions.flipTitle', { defaultMessage: 'Flip' }), + isHorizontal: !options.isHorizontal, + }); + } + + // change chart type for interval or ratio scales on x axis + const newSeriesType = flipSeriesType(seriesType); + return buildSuggestion({ + ...options, + seriesType: newSeriesType, + title: newSeriesType.startsWith('area') + ? i18n.translate('xpack.lens.xySuggestions.areaChartTitle', { + defaultMessage: 'Area chart', + }) + : i18n.translate('xpack.lens.xySuggestions.barChartTitle', { + defaultMessage: 'Bar chart', + }), + }); +} + +function flipSeriesType(oldSeriesType: SeriesType) { + switch (oldSeriesType) { + case 'area': + return 'bar'; + case 'area_stacked': + return 'bar_stacked'; + case 'bar': + return 'area'; + case 'bar_stacked': + return 'area_stacked'; + default: + return 'bar'; + } +} + +function getSeriesType( + currentState: XYState | undefined, + layerId: string, + xValue: TableSuggestionColumn, + changeType: TableChangeType +): SeriesType { + const defaultType = xValue.operation.dataType === 'date' ? 'area' : 'bar'; + if (changeType === 'initial') { + return defaultType; + } else { + const oldLayer = getExistingLayer(currentState, layerId); + return ( + (oldLayer && oldLayer.seriesType) || + (currentState && currentState.preferredSeriesType) || + defaultType + ); + } +} + +function getSuggestionTitle( + yValues: TableSuggestionColumn[], + xValue: TableSuggestionColumn, + tableLabel: string | undefined +) { + const yTitle = yValues + .map(col => col.operation.label) + .join( + i18n.translate('xpack.lens.xySuggestions.yAxixConjunctionSign', { + defaultMessage: ' & ', + description: + 'A character that can be used for conjunction of multiple enumarated items. Make sure to include spaces around it if needed.', + }) + ); + const xTitle = xValue.operation.label; + const title = + tableLabel || + (xValue.operation.dataType === 'date' + ? i18n.translate('xpack.lens.xySuggestions.dateSuggestion', { + defaultMessage: '{yTitle} over {xTitle}', + description: + 'Chart description for charts over time, like "Transfered bytes over log.timestamp"', + values: { xTitle, yTitle }, + }) + : i18n.translate('xpack.lens.xySuggestions.nonDateSuggestion', { + defaultMessage: '{yTitle} of {xTitle}', + description: + 'Chart description for a value of some groups, like "Top URLs of top 5 countries"', + values: { xTitle, yTitle }, + })); + return title; +} + +function buildSuggestion({ + isHorizontal, + currentState, + seriesType, + layerId, + title, + yValues, + splitBy, + changeType, + xValue, +}: { + currentState: XYState | undefined; + isHorizontal: boolean; + seriesType: SeriesType; + title: string; + yValues: TableSuggestionColumn[]; + xValue: TableSuggestionColumn; + splitBy: TableSuggestionColumn | undefined; + layerId: string; + changeType: string; +}) { + const newLayer = { + ...(getExistingLayer(currentState, layerId) || {}), + layerId, + seriesType, + xAccessor: xValue.columnId, + splitAccessor: splitBy ? splitBy.columnId : generateId(), + accessors: yValues.map(col => col.columnId), + }; + + const state: State = { + isHorizontal, + legend: currentState ? currentState.legend : { isVisible: true, position: Position.Right }, + preferredSeriesType: seriesType, + layers: [ + ...(currentState ? currentState.layers.filter(layer => layer.layerId !== layerId) : []), + newLayer, + ], + }; + + return { + title, + // chart with multiple y values and split series will have a score of 1, single y value and no split series reduce score + score: ((yValues.length > 1 ? 2 : 1) + (splitBy ? 1 : 0)) / 3, + // don't advertise chart of same type but with less data + hide: currentState && changeType === 'reduced', + state, + previewIcon: getIconForSeries(seriesType), + previewExpression: buildPreviewExpression(state, layerId, xValue, yValues, splitBy), + }; +} + +function buildPreviewExpression( + state: XYState, + layerId: string, + xValue: TableSuggestionColumn, + yValues: TableSuggestionColumn[], + splitBy: TableSuggestionColumn | undefined +) { + return buildExpression( + { + ...state, + // only show changed layer in preview and hide axes + layers: state.layers + .filter(layer => layer.layerId === layerId) + .map(layer => ({ ...layer, hide: true })), + // hide legend for preview + legend: { + ...state.legend, + isVisible: false, + }, + }, + { [layerId]: collectColumnMetaData(xValue, yValues, splitBy) } + ); +} + +function getExistingLayer(currentState: XYState | undefined, layerId: string) { + return currentState && currentState.layers.find(layer => layer.layerId === layerId); +} + +function collectColumnMetaData( + xValue: TableSuggestionColumn, + yValues: TableSuggestionColumn[], + splitBy: TableSuggestionColumn | undefined +) { + const metadata: Record = {}; + [xValue, ...yValues, splitBy].forEach(col => { + if (col) { + metadata[col.columnId] = col.operation; + } + }); + return metadata; +} diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts new file mode 100644 index 0000000000000..8d9092f63f59b --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { xyVisualization } from './xy_visualization'; +import { Position } from '@elastic/charts'; +import { Operation } from '../types'; +import { State } from './types'; +import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_plugin/mocks'; +import { generateId } from '../id_generator'; +import { Ast } from '@kbn/interpreter/target/common'; + +jest.mock('../id_generator'); + +function exampleState(): State { + return { + isHorizontal: false, + legend: { position: Position.Bottom, isVisible: true }, + preferredSeriesType: 'bar', + layers: [ + { + layerId: 'first', + seriesType: 'area', + splitAccessor: 'd', + xAccessor: 'a', + accessors: ['b', 'c'], + }, + ], + }; +} + +describe('xy_visualization', () => { + describe('#initialize', () => { + it('loads default state', () => { + (generateId as jest.Mock) + .mockReturnValueOnce('test-id1') + .mockReturnValueOnce('test-id2') + .mockReturnValue('test-id3'); + const mockFrame = createMockFramePublicAPI(); + const initialState = xyVisualization.initialize(mockFrame); + + expect(initialState.layers).toHaveLength(1); + expect(initialState.layers[0].xAccessor).toBeDefined(); + expect(initialState.layers[0].accessors[0]).toBeDefined(); + expect(initialState.layers[0].xAccessor).not.toEqual(initialState.layers[0].accessors[0]); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "isHorizontal": false, + "layers": Array [ + Object { + "accessors": Array [ + "test-id1", + ], + "layerId": "", + "position": "top", + "seriesType": "bar", + "showGridlines": false, + "splitAccessor": "test-id2", + "xAccessor": "test-id3", + }, + ], + "legend": Object { + "isVisible": true, + "position": "right", + }, + "preferredSeriesType": "bar", + "title": "Empty XY Chart", + } + `); + }); + + it('loads from persisted state', () => { + expect(xyVisualization.initialize(createMockFramePublicAPI(), exampleState())).toEqual( + exampleState() + ); + }); + }); + + describe('#getPersistableState', () => { + it('persists the state as given', () => { + expect(xyVisualization.getPersistableState(exampleState())).toEqual(exampleState()); + }); + }); + + describe('#toExpression', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource(); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + mockDatasource.publicAPIMock.getOperationForColumnId.mockImplementation(col => { + return { label: `col_${col}`, dataType: 'number' } as Operation; + }); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + }); + + it('should map to a valid AST', () => { + expect(xyVisualization.toExpression(exampleState(), frame)).toMatchSnapshot(); + }); + + it('should default to labeling all columns with their column label', () => { + const expression = xyVisualization.toExpression(exampleState(), frame)! as Ast; + + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('b'); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('c'); + expect(mockDatasource.publicAPIMock.getOperationForColumnId).toHaveBeenCalledWith('d'); + expect(expression.chain[0].arguments.xTitle).toEqual(['col_a']); + expect(expression.chain[0].arguments.yTitle).toEqual(['col_b']); + expect( + (expression.chain[0].arguments.layers[0] as Ast).chain[0].arguments.columnToLabel + ).toEqual([ + JSON.stringify({ + b: 'col_b', + c: 'col_c', + d: 'col_d', + }), + ]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx new file mode 100644 index 0000000000000..15a34abf12651 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_visualization.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import _ from 'lodash'; +import { render } from 'react-dom'; +import { Position } from '@elastic/charts'; +import { I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { getSuggestions } from './xy_suggestions'; +import { XYConfigPanel } from './xy_config_panel'; +import { Visualization } from '../types'; +import { State, PersistableState, SeriesType, visualizationTypes } from './types'; +import { toExpression } from './to_expression'; +import { generateId } from '../id_generator'; + +const defaultIcon = 'visBarVertical'; +const defaultSeriesType = 'bar'; + +function getDescription(state?: State) { + if (!state) { + return { + icon: defaultIcon, + label: i18n.translate('xpack.lens.xyVisualization.xyLabel', { + defaultMessage: 'XY Chart', + }), + }; + } + + if (!state.layers.length) { + return visualizationTypes.find(v => v.id === state.preferredSeriesType)!; + } + + const visualizationType = visualizationTypes.find(t => t.id === state.layers[0].seriesType)!; + const seriesTypes = _.unique(state.layers.map(l => l.seriesType)); + + return { + icon: visualizationType.icon, + label: + seriesTypes.length === 1 + ? visualizationType.label + : i18n.translate('xpack.lens.xyVisualization.mixedLabel', { + defaultMessage: 'Mixed XY Chart', + }), + }; +} + +export const xyVisualization: Visualization = { + id: 'lnsXY', + + visualizationTypes, + + getDescription(state) { + const { icon, label } = getDescription(state); + return { + icon: icon || defaultIcon, + label, + }; + }, + + switchVisualizationType(seriesType: string, state: State) { + return { + ...state, + preferredSeriesType: seriesType as SeriesType, + layers: state.layers.map(layer => ({ ...layer, seriesType: seriesType as SeriesType })), + }; + }, + + getSuggestions, + + initialize(frame, state) { + return ( + state || { + title: 'Empty XY Chart', + isHorizontal: false, + legend: { isVisible: true, position: Position.Right }, + preferredSeriesType: defaultSeriesType, + layers: [ + { + layerId: frame.addNewLayer(), + accessors: [generateId()], + position: Position.Top, + seriesType: defaultSeriesType, + showGridlines: false, + splitAccessor: generateId(), + xAccessor: generateId(), + }, + ], + } + ); + }, + + getPersistableState: state => state, + + renderConfigPanel: (domElement, props) => + render( + + + , + domElement + ), + + toExpression, +}; diff --git a/x-pack/legacy/plugins/lens/readme.md b/x-pack/legacy/plugins/lens/readme.md new file mode 100644 index 0000000000000..0ea0778dd17ef --- /dev/null +++ b/x-pack/legacy/plugins/lens/readme.md @@ -0,0 +1,14 @@ +# Lens + +## Testing + +Run all tests from the `x-pack` root directory + +- Unit tests: `node scripts/jest --watch lens` +- Functional tests: + - Run `node scripts/functional_tests_server` + - Run `node ../scripts/functional_test_runner.js --config ./test/functional/config.js` + - You may want to comment out all imports except for Lens in the config file. +- API Functional tests: + - Run `node scripts/functional_tests_server` + - Run `node ../scripts/functional_test_runner.js --config ./test/api_integration/config.js --grep=Lens` diff --git a/x-pack/legacy/plugins/lens/server/index.ts b/x-pack/legacy/plugins/lens/server/index.ts new file mode 100644 index 0000000000000..ae14d1c5a0052 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LensServer } from './plugin'; + +export * from './plugin'; + +export const lensServerPlugin = () => new LensServer(); diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx new file mode 100644 index 0000000000000..9c33889a514a4 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup } from 'src/core/server'; +import { setupRoutes } from './routes'; + +export class LensServer implements Plugin<{}, {}, {}, {}> { + constructor() {} + + setup(core: CoreSetup) { + setupRoutes(core); + + return {}; + } + + start() { + return {}; + } + + stop() {} +} diff --git a/x-pack/legacy/plugins/lens/server/routes/index.ts b/x-pack/legacy/plugins/lens/server/routes/index.ts new file mode 100644 index 0000000000000..9a957765cc87d --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/server'; +import { initStatsRoute } from './index_stats'; + +export function setupRoutes(setup: CoreSetup) { + initStatsRoute(setup); +} diff --git a/x-pack/legacy/plugins/lens/server/routes/index_stats.test.ts b/x-pack/legacy/plugins/lens/server/routes/index_stats.test.ts new file mode 100644 index 0000000000000..4116db05a5f60 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/routes/index_stats.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import realHits from '../../../../../../src/fixtures/real_hits'; +// @ts-ignore +import stubbedLogstashFields from '../../../../../../src/fixtures/logstash_fields'; +import { recursiveFlatten } from './index_stats'; + +describe('Index Stats route', () => { + it('should ignore empty fields, but not falsy ones', () => { + const results = recursiveFlatten( + [{ _source: {} }, { _source: { bytes: false } }], + stubbedLogstashFields(), + [ + { + name: 'extension.keyword', + type: 'string', + }, + { + name: 'bytes', + type: 'number', + }, + { + name: 'geo.src', + type: 'string', + }, + ] + ); + + expect(results).toEqual({ + bytes: { + cardinality: 1, + count: 1, + }, + }); + }); + + it('should find existing fields based on mapping', () => { + const results = recursiveFlatten(realHits, stubbedLogstashFields(), [ + { + name: 'extension.keyword', + type: 'string', + }, + { + name: 'bytes', + type: 'number', + }, + ]); + + expect(results).toEqual({ + bytes: { + count: 20, + cardinality: 16, + }, + 'extension.keyword': { + count: 20, + cardinality: 4, + }, + }); + }); + + // TODO: Alias information is not persisted in the index pattern, so we don't have access + it('fails to map alias fields', () => { + const results = recursiveFlatten(realHits, stubbedLogstashFields(), [ + { + name: '@timestamp', + type: 'date', + }, + ]); + + expect(results).toEqual({}); + }); + + // TODO: Scripts are not currently run in the _search query + it('should fail to map scripted fields', () => { + const scriptedField = { + name: 'hour_of_day', + type: 'number', + count: 0, + scripted: true, + script: "doc['timestamp'].value.hourOfDay", + lang: 'painless', + searchable: true, + aggregatable: true, + readFromDocValues: false, + }; + + const results = recursiveFlatten( + realHits, + [...stubbedLogstashFields(), scriptedField], + [ + { + name: 'hour_of_day', + type: 'number', + }, + ] + ); + + expect(results).toEqual({}); + }); +}); diff --git a/x-pack/legacy/plugins/lens/server/routes/index_stats.ts b/x-pack/legacy/plugins/lens/server/routes/index_stats.ts new file mode 100644 index 0000000000000..aeb213e356786 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/routes/index_stats.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { get } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { SearchResponse } from 'elasticsearch'; +import { CoreSetup } from 'src/core/server'; +import { + IndexPatternsService, + FieldDescriptor, +} from '../../../../../../src/legacy/server/index_patterns/service'; + +type Document = Record; + +type Fields = Array<{ name: string; type: string; esTypes?: string[] }>; + +export async function initStatsRoute(setup: CoreSetup) { + const router = setup.http.createRouter(); + router.post( + { + path: '/index_stats/{indexPatternTitle}', + validate: { + params: schema.object({ + indexPatternTitle: schema.string(), + }), + body: schema.object({ + fromDate: schema.string(), + toDate: schema.string(), + timeZone: schema.maybe(schema.string()), + timeFieldName: schema.string(), + size: schema.number(), + fields: schema.arrayOf( + schema.object({ + name: schema.string(), + type: schema.string(), + }) + ), + }), + }, + }, + async (context, req, res) => { + const requestClient = context.core.elasticsearch.dataClient; + + const indexPatternsService = new IndexPatternsService(requestClient.callAsCurrentUser); + + const { fromDate, toDate, timeZone, timeFieldName, fields, size } = req.body; + + try { + const indexPattern = await indexPatternsService.getFieldsForWildcard({ + pattern: req.params.indexPatternTitle, + // TODO: Pull this from kibana advanced settings + metaFields: ['_source', '_id', '_type', '_index', '_score'], + }); + + const results = (await requestClient.callAsCurrentUser('search', { + index: req.params.indexPatternTitle, + body: { + query: { + bool: { + filter: [ + { + range: { + [timeFieldName]: { + gte: fromDate, + lte: toDate, + time_zone: timeZone, + }, + }, + }, + ], + }, + }, + size, + }, + })) as SearchResponse; + + if (results.hits.hits.length) { + return res.ok({ + body: recursiveFlatten(results.hits.hits, indexPattern, fields), + }); + } + return res.ok({ body: {} }); + } catch (e) { + if (e.isBoom) { + return res.internalError(e); + } else { + return res.internalError({ + body: Boom.internal(e.message || e.name), + }); + } + } + } + ); +} + +export function recursiveFlatten( + docs: Array<{ + _source: Document; + }>, + indexPattern: FieldDescriptor[], + fields: Fields +): Record< + string, + { + count: number; + cardinality: number; + } +> { + const overallKeys: Record< + string, + { + count: number; + samples: Set; + } + > = {}; + + const expectedKeys = new Set(fields.map(f => f.name)); + + indexPattern.forEach(field => { + if (!expectedKeys.has(field.name)) { + return; + } + + docs.forEach(doc => { + if (!doc) { + return; + } + + const match = get(doc._source, field.parent || field.name); + if (typeof match === 'undefined') { + return; + } + + const record = overallKeys[field.name]; + if (record) { + record.count += 1; + record.samples.add(match); + } else { + overallKeys[field.name] = { + count: 1, + // Using a set here makes the most sense and avoids the later uniq computation + samples: new Set([match]), + }; + } + }); + }); + + const returnTypes: Record< + string, + { + count: number; + cardinality: number; + } + > = {}; + Object.entries(overallKeys).forEach(([key, value]) => { + returnTypes[key] = { + count: value.count, + cardinality: value.samples.size, + }; + }); + return returnTypes; +} diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 9125e1924a702..9350abf2fe7e7 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -51,7 +51,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS privileges: { all: { savedObject: { - all: ['visualization', 'url', 'query'], + all: ['visualization', 'url', 'query', 'lens'], read: ['index-pattern', 'search'], }, ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], @@ -59,7 +59,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS read: { savedObject: { all: [], - read: ['index-pattern', 'search', 'visualization', 'query'], + read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], }, ui: ['show'], }, diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index fb17c76cb6bc8..469c32541c23d 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -112,6 +112,7 @@ export default function({ getService }: FtrProviderContext) { 'canvas', 'code', 'infrastructure', + 'lens', 'logs', 'maps', 'uptime', diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index ffb3e1c64774f..b90110da3506d 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -27,5 +27,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./siem')); loadTestFile(require.resolve('./code')); loadTestFile(require.resolve('./short_urls')); + loadTestFile(require.resolve('./lens')); }); } diff --git a/x-pack/test/api_integration/apis/lens/index.ts b/x-pack/test/api_integration/apis/lens/index.ts new file mode 100644 index 0000000000000..9827eadb1278b --- /dev/null +++ b/x-pack/test/api_integration/apis/lens/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function lensApiIntegrationTests({ loadTestFile }: FtrProviderContext) { + describe('Lens', () => { + loadTestFile(require.resolve('./index_stats')); + }); +} diff --git a/x-pack/test/api_integration/apis/lens/index_stats.ts b/x-pack/test/api_integration/apis/lens/index_stats.ts new file mode 100644 index 0000000000000..8dc181fa9b601 --- /dev/null +++ b/x-pack/test/api_integration/apis/lens/index_stats.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +const TEST_START_TIME = '2015-09-19T06:31:44.000'; +const TEST_END_TIME = '2015-09-23T18:31:44.000'; +const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', +}; + +const fieldsNotInPattern = [ + { name: 'geo', type: 'object' }, + { name: 'id', type: 'string' }, + { name: 'machine', type: 'object' }, +]; + +const fieldsNotInDocuments = [ + { name: 'meta', type: 'object' }, + { name: 'meta.char', type: 'string' }, + { name: 'meta.related', type: 'string' }, + { name: 'meta.user', type: 'object' }, + { name: 'meta.user.firstname', type: 'string' }, + { name: 'meta.user.lastname', type: 'string' }, +]; + +const fieldsWithData = [ + { name: '@message', type: 'string' }, + { name: '@message.raw', type: 'string' }, + { name: '@tags', type: 'string' }, + { name: '@tags.raw', type: 'string' }, + { name: '@timestamp', type: 'date' }, + { name: 'agent', type: 'string' }, + { name: 'agent.raw', type: 'string' }, + { name: 'bytes', type: 'number' }, + { name: 'clientip', type: 'ip' }, + { name: 'extension', type: 'string' }, + { name: 'extension.raw', type: 'string' }, + { name: 'geo.coordinates', type: 'geo_point' }, + { name: 'geo.dest', type: 'string' }, + { name: 'geo.src', type: 'string' }, + { name: 'geo.srcdest', type: 'string' }, + { name: 'headings', type: 'string' }, + { name: 'headings.raw', type: 'string' }, + { name: 'host', type: 'string' }, + { name: 'host.raw', type: 'string' }, + { name: 'index', type: 'string' }, + { name: 'index.raw', type: 'string' }, + { name: 'ip', type: 'ip' }, + { name: 'links', type: 'string' }, + { name: 'links.raw', type: 'string' }, + { name: 'machine.os', type: 'string' }, + { name: 'machine.os.raw', type: 'string' }, + { name: 'machine.ram', type: 'string' }, + { name: 'memory', type: 'string' }, + { name: 'phpmemory', type: 'string' }, + { name: 'referer', type: 'string' }, + { name: 'request', type: 'string' }, + { name: 'request.raw', type: 'string' }, + { name: 'response', type: 'string' }, + { name: 'response.raw', type: 'string' }, + { name: 'spaces', type: 'string' }, + { name: 'spaces.raw', type: 'string' }, + { name: 'type', type: 'string' }, + { name: 'url', type: 'string' }, + { name: 'url.raw', type: 'string' }, + { name: 'utc_time', type: 'string' }, + { name: 'xss', type: 'string' }, + { name: 'xss.raw', type: 'string' }, +]; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('index stats apis', () => { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('visualize/default'); + }); + after(async () => { + await esArchiver.unload('logstash_functional'); + await esArchiver.unload('visualize/default'); + }); + + describe('existence', () => { + it('should find which fields exist in the sample documents', async () => { + const { body } = await supertest + .post('/api/lens/index_stats/logstash-2015.09.22') + .set(COMMON_HEADERS) + .send({ + fromDate: TEST_START_TIME, + toDate: TEST_END_TIME, + timeFieldName: '@timestamp', + size: 500, + fields: fieldsWithData.concat(fieldsNotInDocuments, fieldsNotInPattern), + }) + .expect(200); + + expect(Object.keys(body)).to.eql(fieldsWithData.map(field => field.name)); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 843c1b22f0ad8..bd5af56abbb3c 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -1150,6 +1150,47 @@ export default function({ getService }: FtrProviderContext) { `saved_object:${version}:config/find`, ], }, + lens: { + all: [ + 'login:', + `version:${version}`, + `api:${version}:lens`, + `app:${version}:lens`, + `app:${version}:kibana`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `saved_object:${version}:telemetry/bulk_get`, + `saved_object:${version}:telemetry/get`, + `saved_object:${version}:telemetry/find`, + `saved_object:${version}:telemetry/create`, + `saved_object:${version}:telemetry/bulk_create`, + `saved_object:${version}:telemetry/update`, + `saved_object:${version}:telemetry/delete`, + `saved_object:${version}:config/bulk_get`, + `saved_object:${version}:config/get`, + `saved_object:${version}:config/find`, + `ui:${version}:savedObjectsManagement/telemetry/delete`, + `ui:${version}:savedObjectsManagement/telemetry/edit`, + `ui:${version}:savedObjectsManagement/telemetry/read`, + `ui:${version}:savedObjectsManagement/config/read`, + `ui:${version}:lens/show`, + 'allHack:', + ], + read: [ + 'login:', + `version:${version}`, + `api:${version}:lens`, + `app:${version}:lens`, + `app:${version}:kibana`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `saved_object:${version}:config/bulk_get`, + `saved_object:${version}:config/get`, + `saved_object:${version}:config/find`, + `ui:${version}:savedObjectsManagement/config/read`, + `ui:${version}:lens/show`, + ], + }, }, global: { all: [ @@ -1474,6 +1515,11 @@ export default function({ getService }: FtrProviderContext) { `ui:${version}:catalogue/uptime`, `ui:${version}:navLinks/uptime`, `ui:${version}:uptime/save`, + `api:${version}:lens`, + `app:${version}:lens`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `ui:${version}:lens/show`, 'allHack:', ], read: [ @@ -1634,6 +1680,11 @@ export default function({ getService }: FtrProviderContext) { `app:${version}:uptime`, `ui:${version}:catalogue/uptime`, `ui:${version}:navLinks/uptime`, + `api:${version}:lens`, + `app:${version}:lens`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `ui:${version}:lens/show`, ], }, space: { @@ -1956,6 +2007,11 @@ export default function({ getService }: FtrProviderContext) { `ui:${version}:catalogue/uptime`, `ui:${version}:navLinks/uptime`, `ui:${version}:uptime/save`, + `api:${version}:lens`, + `app:${version}:lens`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `ui:${version}:lens/show`, 'allHack:', ], read: [ @@ -2116,6 +2172,11 @@ export default function({ getService }: FtrProviderContext) { `app:${version}:uptime`, `ui:${version}:catalogue/uptime`, `ui:${version}:navLinks/uptime`, + `api:${version}:lens`, + `app:${version}:lens`, + `ui:${version}:catalogue/lens`, + `ui:${version}:navLinks/lens`, + `ui:${version}:lens/show`, ], }, reserved: { @@ -2182,6 +2243,7 @@ export default function({ getService }: FtrProviderContext) { apm: ['all', 'read'], siem: ['all', 'read'], code: ['all', 'read'], + lens: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts new file mode 100644 index 0000000000000..64fc31564d41f --- /dev/null +++ b/x-pack/test/functional/apps/lens/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context.d'; + +// eslint-disable-next-line @typescript-eslint/no-namespace, import/no-default-export +export default function({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + + describe('lens app', () => { + before(async () => { + log.debug('Starting lens before method'); + browser.setWindowSize(1280, 800); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + }); + + after(async () => { + await esArchiver.unload('logstash_functional'); + await esArchiver.unload('visualize/default'); + }); + + describe('', function() { + this.tags(['ciGroup4', 'skipFirefox']); + + loadTestFile(require.resolve('./smokescreen')); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts new file mode 100644 index 0000000000000..1abd137659d91 --- /dev/null +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'header', + 'common', + 'visualize', + 'dashboard', + 'header', + 'timePicker', + 'lens', + ]); + const find = getService('find'); + const dashboardAddPanel = getService('dashboardAddPanel'); + + async function assertExpectedMetric() { + await PageObjects.lens.assertExactText( + '[data-test-subj="lns_metric_title"]', + 'Maximum of bytes' + ); + await PageObjects.lens.assertExactText('[data-test-subj="lns_metric_value"]', '19,986'); + } + + async function assertExpectedTable() { + await PageObjects.lens.assertExactText( + '[data-test-subj="lnsDataTable"] thead .euiTableCellContent__text', + 'Maximum of bytes' + ); + await PageObjects.lens.assertExactText( + '[data-test-subj="lnsDataTable"] tbody .euiTableCellContent__text', + '19,986' + ); + } + + describe('lens smokescreen tests', () => { + it('should allow editing saved visualizations', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); + await PageObjects.lens.goToTimeRange(); + await assertExpectedMetric(); + }); + + it('should be embeddable in dashboards', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await find.clickByButtonText('Artistpreviouslyknownaslens'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await assertExpectedMetric(); + }); + + it('should allow seamless transition to and from table view', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); + await PageObjects.lens.goToTimeRange(); + await assertExpectedMetric(); + await PageObjects.lens.switchToVisualization('lnsChartSwitchPopover_lnsDatatable'); + await assertExpectedTable(); + await PageObjects.lens.switchToVisualization('lnsChartSwitchPopover_lnsMetric'); + await assertExpectedMetric(); + }); + + it('should allow creation of lens visualizations', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.toggleExistenceFilter(); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_xDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'date_histogram', + }); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_yDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.setTitle('Afancilenstest'); + + await PageObjects.lens.save(); + + // Ensure the visualization shows up in the visualize list, and takes + // us back to the visualization as we configured it. + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.lens.clickVisualizeListItemTitle('Afancilenstest'); + await PageObjects.lens.goToTimeRange(); + + expect(await PageObjects.lens.getTitle()).to.eql('Afancilenstest'); + + // .echLegendItem__title is the only viable way of getting the xy chart's + // legend item(s), so we're using a class selector here. + await PageObjects.lens.assertExpectedText( + '.echLegendItem__title', + legendText => !!legendText && legendText.includes('Average of bytes') + ); + }); + }); +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index d106c407f9c2a..d920b368922cd 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -34,6 +34,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/discover'), resolve(__dirname, './apps/security'), resolve(__dirname, './apps/spaces'), + resolve(__dirname, './apps/lens'), resolve(__dirname, './apps/logstash'), resolve(__dirname, './apps/grok_debugger'), resolve(__dirname, './apps/infra'), @@ -99,6 +100,9 @@ export default async function ({ readConfigFile }) { // Kibana's config in order to use this helper apps: { ...kibanaFunctionalConfig.get('apps'), + lens: { + pathname: '/app/lens', + }, login: { pathname: '/login', }, diff --git a/x-pack/test/functional/es_archives/lens/basic/data.json.gz b/x-pack/test/functional/es_archives/lens/basic/data.json.gz new file mode 100644 index 0000000000000..992bf7c85e9fd Binary files /dev/null and b/x-pack/test/functional/es_archives/lens/basic/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/lens/basic/mappings.json b/x-pack/test/functional/es_archives/lens/basic/mappings.json new file mode 100644 index 0000000000000..10a94d305dd5d --- /dev/null +++ b/x-pack/test/functional/es_archives/lens/basic/mappings.json @@ -0,0 +1,1155 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "ecc01e367a369542bc2b15dae1fb1773", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "b2d549df61fd5bf8098427ec68a4712d", + "apm-telemetry": "07ee1939fa4302c62ddc052ec03fed90", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "config": "87aca8fdb053154f11383fce3dbf3edf", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "d69713426be87ba23283776aab149b9a", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "siem-ui-timeline": "1f6f0860ad7bc0dba3e42467ca40470d", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "25de8c2deec044392922989cfcf24c54", + "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "description": { + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "alertTypeParams": { + "enabled": false, + "type": "object" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "interval": { + "type": "keyword" + }, + "scheduledTaskId": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "gis-map": { + "properties": { + "bounds": { + "strategy": "recursive", + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "mapsTotalCount": { + "type": "long" + }, + "timeCaptured": { + "type": "date" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "visualization": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "dynamic": "true", + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 690a77ff00aa1..4163e77911934 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -44,6 +44,7 @@ import { SnapshotRestorePageProvider } from './snapshot_restore_page'; import { CrossClusterReplicationPageProvider } from './cross_cluster_replication_page'; import { RemoteClustersPageProvider } from './remote_clusters_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; +import { LensPageProvider } from './lens_page'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -74,4 +75,5 @@ export const pageObjects = { crossClusterReplication: CrossClusterReplicationPageProvider, remoteClusters: RemoteClustersPageProvider, copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider, + lens: LensPageProvider, }; diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts new file mode 100644 index 0000000000000..1825876782d15 --- /dev/null +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; +import { logWrapper } from './log_wrapper'; + +export function LensPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const find = getService('find'); + const PageObjects = getPageObjects([ + 'header', + 'common', + 'visualize', + 'dashboard', + 'header', + 'timePicker', + ]); + + return logWrapper('lensPage', log, { + /** + * Clicks the index pattern filters toggle. + */ + async toggleIndexPatternFiltersPopover() { + await testSubjects.click('lnsIndexPatternFiltersToggle'); + }, + + /** + * Toggles the field existence checkbox. + */ + async toggleExistenceFilter() { + await this.toggleIndexPatternFiltersPopover(); + await testSubjects.click('lnsEmptyFilter'); + await this.toggleIndexPatternFiltersPopover(); + }, + + async findAllFields() { + return await testSubjects.findAll('lnsFieldListPanelField'); + }, + + /** + * Move the date filter to the specified time range, defaults to + * a range that has data in our dataset. + */ + goToTimeRange(fromTime = '2015-09-19 06:31:44.000', toTime = '2015-09-23 18:31:44.000') { + return PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }, + + /** + * Wait for the specified element to have text that passes the specified test. + * + * @param selector - the element selector + * @param test - the test function to run on the element's text + */ + async assertExpectedText(selector: string, test: (value?: string) => boolean) { + let actualText: string | undefined; + + await retry.waitForWithTimeout('assertExpectedText', 1000, async () => { + actualText = await find.byCssSelector(selector).then(el => el.getVisibleText()); + return test(actualText); + }); + + if (!test(actualText)) { + throw new Error(`"${actualText}" did not match expectation.`); + } + }, + + /** + * Asserts that the specified element has the expected inner text. + * + * @param selector - the element selector + * @param expectedText - the expected text + */ + assertExactText(selector: string, expectedText: string) { + return this.assertExpectedText(selector, value => value === expectedText); + }, + + /** + * Uses the Lens visualization switcher to switch visualizations. + * + * @param dataTestSubj - the data-test-subj of the visualization to switch to + */ + async switchToVisualization(dataTestSubj: string) { + await testSubjects.click('lnsChartSwitchPopover'); + await testSubjects.click(dataTestSubj); + }, + + /** + * Clicks a visualize list item's title (in the visualize app). + * + * @param title - the title of the list item to be clicked + */ + clickVisualizeListItemTitle(title: string) { + return testSubjects.click(`visListingTitleLink-${title}`); + }, + + /** + * Changes the specified dimension to the specified operation and (optinally) field. + * + * @param opts.from - the text of the dimension being changed + * @param opts.to - the desired operation for the dimension + * @param opts.field - the desired field for the dimension + */ + async configureDimension(opts: { dimension: string; operation?: string; field?: string }) { + await find.clickByCssSelector(opts.dimension); + + if (opts.operation) { + await find.clickByCssSelector( + `[data-test-subj="lns-indexPatternDimensionIncompatible-${opts.operation}"], + [data-test-subj="lns-indexPatternDimension-${opts.operation}"]` + ); + } + + if (opts.field) { + await testSubjects.click('indexPattern-dimension-field'); + await testSubjects.click(`lns-fieldOption-${opts.field}`); + } + }, + + /** + * Save the current Lens visualization. + */ + save() { + return testSubjects.click('lnsApp_saveButton'); + }, + + setTitle(title: string) { + return testSubjects.setValue('lns_ChartTitle', title); + }, + + getTitle() { + return testSubjects.getAttribute('lns_ChartTitle', 'value'); + }, + }); +} diff --git a/x-pack/test/functional/page_objects/log_wrapper.ts b/x-pack/test/functional/page_objects/log_wrapper.ts new file mode 100644 index 0000000000000..56ab7be81caba --- /dev/null +++ b/x-pack/test/functional/page_objects/log_wrapper.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; + +/** + * Wraps the specified object instance with debug log statements of all method calls. + * + * @param prefix - The string to prefix to all log messages + * @param log - The logger to use + * @param instance - The object being wrapped + */ +export function logWrapper>( + prefix: string, + log: ToolingLog, + instance: T +): T { + return Object.keys(instance).reduce((acc, prop) => { + const baseFn = acc[prop]; + (acc as Record)[prop] = (...args: unknown[]) => { + logMethodCall(log, prefix, prop, args); + return baseFn.apply(instance, args); + }; + return acc; + }, instance); +} + +function logMethodCall(log: ToolingLog, prefix: string, prop: string, args: unknown[]) { + const argsStr = args.map(arg => (typeof arg === 'string' ? `'${arg}'` : arg)).join(', '); + log.debug(`${prefix}.${prop}(${argsStr})`); +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 5f1eef440b6f4..d7d1a99e63e02 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -60,7 +60,7 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest